Skip to content

v3.9 — Compaction, drawer, and a more reliable router for file operations - #9

Merged
Kurtisone merged 20 commits into
mainfrom
v3.9-context
Aug 1, 2026
Merged

v3.9 — Compaction, drawer, and a more reliable router for file operations#9
Kurtisone merged 20 commits into
mainfrom
v3.9-context

Conversation

@Kurtisone

Copy link
Copy Markdown
Owner

What's new

Context management

  • Automatic history compaction: past a threshold, the oldest non-pinned messages are replaced with a summary instead of just being dropped (v3.8's FIFO sliding window kept as a hard-cap safety net)
  • Two interchangeable strategies (COMPACTION_STRATEGY): rag_pointer (pointer into vector memory, no LLM call, default) or llm_summary (condensed inline summary)
  • Manual compaction on demand: POST /compact, a button in the UI, or !compact in the REPL

The drawer

  • Message pinning (GET/POST /drawer): exempt from both compaction and the hard cap
  • Pinning a bubble pins the whole exchange (question + answer) by default; unpinning stays possible independently for either half

Web UI

  • Persistent conversation thread, reloaded from GET /history (survives a page refresh)
  • Lightweight hand-rolled markdown rendering (bold, italic, lists, headers, links) — no CDN dependency, so it keeps working offline
  • Colored ```diff blocks, rendered like real git diff output
  • Consistent SVG icons (no more emoji)

Router reliability

  • The files tool had a worked example for reading but none for writing — a small local model never actually followed the write action, even when explicitly asked
  • Editing an existing file never went through files either — new read (done:false) → write pattern, mirroring the recall→answer pattern already in place for the memory tool
  • files:write now returns a real diff (computed in Python with difflib, never asked of the model) instead of re-posting the whole file on every edit

Bugs found and fixed through real usage

All caught by actually using the feature, not by re-reading the code:

  • A pre-existing 300-char truncation (_MAX_MEMORY_CONTENT) was silently corrupting any content shown in the UI (e.g. a Containerfile cut off mid-word)
  • /health showed a stale model name — now queries llama-server directly via /props
  • The compaction summary's id could end up out of chronological order in the history
  • The page scrolled rapidly on a full thread reload (one jump per message instead of one total)
  • Diffs on a file with no trailing newline glued the removed and added lines together

Tests

254 tests, ruff lint/format clean.

Deliberately left out

  • Multi-turn reference resolution ("analyze the content" without naming the file) can make Forge hallucinate — a distinct problem, not a missing example, to be tackled separately
  • UI tab cleanup (fully conversational interface) — still deferred, a deliberate choice

COMPACTION_ENABLED, COMPACTION_THRESHOLD, COMPACTION_KEEP_RECENT,
COMPACTION_STRATEGY (rag_pointer default, llm_summary optional).
MEMORY_MAX_HISTORY stays as-is, now documented as the hard-cap safety
net behind compaction rather than the only retention mechanism.
New module implementing the two-strategy compaction design agreed on:
maybe_compact(history, force=False) decides WHEN to compact
(COMPACTION_THRESHOLD), a strategy function decides HOW. Two
strategies, same signature, swappable via config:
  - rag_pointer (default): push the compacted block into vector
    memory verbatim, replace it inline with a short pointer. No LLM
    call.
  - llm_summary: one LLM call per compaction, condenses the block
    into prose kept inline.
Pinned messages are always excluded from what gets compacted.
9 tests covering threshold behaviour, pinned exemption, forced
compaction, and failure handling for both strategies.
…nto memory

Every history entry now carries a stable 'id' (a per-file monotonic
counter) and a 'pinned' flag. pin_message/unpin_message/get_pinned
implement the drawer ('tiroir'): pinned messages are exempt from both
compaction and the v3.8 hard-cap fallback. compact_now() forces a
compaction pass on demand (manual trigger, per the agreed design).

_apply_retention() now tries compaction.maybe_compact() first and
falls back to the v3.8 drop-oldest hard cap (pinned exempt) only if
compaction is disabled or its strategy raises -- history must never
grow unbounded either way.

Updated two existing tests whose assertions depended on the exact
pre-v3.9 dict shape (no id/pinned fields); behaviour unchanged.
GET /history   -- full rolling history with ids, what the single-thread
                  web UI renders on load and after each turn.
GET /drawer    -- currently pinned messages.
POST /drawer/pin, POST /drawer/unpin -- by message_id.
POST /compact  -- force a compaction pass now, regardless of
                  COMPACTION_THRESHOLD.

Same auth/rate-limit dependencies as every other endpoint. 5 tests
added covering history/drawer round-trip, unknown-id 404, token
gating, and the manual compact endpoint.
Mirrors the manual /compact API endpoint for REPL usage: forces a
compaction pass now, pinned messages excluded. Documented alongside
the existing !clear/!trace/!remember/!recall commands.
…ompaction

Chat panel now loads and re-renders from GET /history instead of
staying purely client-side state, so the thread survives a page
reload and every bubble carries its real message id.

Each non-system message gets a 📌 pin/unpin button (POST
/drawer/pin|unpin). New 'Tiroir' tab lists pinned messages via
GET /drawer with a 'Retirer' action, plus a manual '🗜 Compacter
maintenant' button (POST /compact).
GET /history's response model requires 'id' and 'pinned' on every
entry. Any memory.json written before v3.9 has plain {role, content}
history entries -- loading one unmigrated made every /history call
fail response validation (500), silently swallowed by the web UI's
loadHistory(), so a chat reply never appeared even though the
orchestrator answered correctly. load_memory() now backfills both
fields on read and persists the migration once.
… message

The always-visible '📌 Épingler'/'📌 Épinglé' button under every bubble
was too much visual noise on a full thread. Each message is now
wrapped in a .msg-row (bubble + a small round pin icon), the icon
staying at opacity 0 until the row is hovered. Pinned messages keep a
faint permanent marker (amber, ~80% opacity) so pinned state is still
readable at a glance without a label. Alignment (left/right) moved
from the bubble itself to the row, since the icon needs to sit
outside the bubble; the thinking indicator got the same row wrapper
to keep its alignment.
The 📌 emoji renders in full color regardless of the .pin-icon opacity
rules and clashes with the dark theme. Swapped for a minimal thumbtack
outline (stroke=currentColor), so it correctly inherits muted/hover/
pinned coloring like any other icon in the UI.
…ault

Placement: dropped the .msg-row wrapper and put the pin icon back
inside the bubble itself, absolutely positioned as a small floating
badge on the bubble's outer corner (opposite the tail, so it never
sits over text) with a soft shadow and scale-in on hover. Pinned
state gets a subtle amber glow instead of relying on color alone.

Behaviour: pinning a message now pins its exchange partner too
(add_exchange always assigns the assistant id right after the user
id, so the partner is deducible without extra API calls) -- a
question or answer read back alone tends to lose its point. Unpinning
stays per-message, so either half can be removed afterward without
touching the other.
Root cause of the truncated Containerfile: _remember() has hard-cut
both sides of every exchange to _MAX_MEMORY_CONTENT (300 chars) since
before v3.9. That was harmless when memory.json only fed the
router's own next-turn prompt (a shorter prompt, not a wrong one),
but the v3.9 web UI renders GET /history directly -- so a full tool
result (e.g. reading Containerfile via the files tool, 1035 bytes)
got silently cut to 300 chars before it ever reached the screen.

Removed the truncation and the now-unused constant. Prompt-size
management is v3.9's job now: MEMORY_MAX_HISTORY caps message count,
compaction.py replaces old messages with a real summary once
COMPACTION_THRESHOLD is crossed -- both far better suited to this
than a blind per-message character cut that mangled tool output.

Confirmed via SHOW_DEBUG logs: router.raw_output/router.decision
showed the files tool read the full 1035-byte Containerfile
correctly; the cut only happened once it round-tripped through
memory.json.
…of trusting LLM_MODEL

LLM_MODEL is never sent in the /completion payload for the llama_cpp
provider (confirmed while debugging the truncation bug) -- unlike
ollama/openrouter, where it's authoritative. It was a purely
cosmetic label the person had to remember to update by hand every
time they swapped GGUF files, and it silently drifted.

Added get_loaded_model(url) in providers/llama_cpp.py: queries
llama-server's own /props endpoint (checking a few known field
names across server versions), best-effort with a short 2s timeout
-- returns None on any failure so /health degrades to the configured
LLM_MODEL rather than breaking. /health now uses this for the
llama_cpp provider only; ollama/openrouter are untouched since
LLM_MODEL is already correct for them.

9 tests added across test_providers.py (the probe itself) and
test_api.py (the /health fallback behavior); 3 existing /health
tests pinned to a non-llama_cpp provider so they don't depend on
real network refusal timing.
…he LLM_MODEL fix

- API Endpoints table: added /history, /drawer, /drawer/pin,
  /drawer/unpin, /compact (v3.9); corrected /health's description.
- Configuration table: fixed MEMORY_MAX_HISTORY's default (was
  documented as 20, actually 100 since v3.8); documented LLAMA_CPP_ID_SLOT/
  CACHE_PROMPT (v3.8) and all four COMPACTION_* vars (v3.9), which were
  entirely missing; corrected LLM_MODEL's description to explain it's
  informational-only for llama_cpp.
- Conversation Memory section: removed the now-false '300 chars
  truncated' claim (the bug just fixed), added a Context compaction
  & drawer subsection explaining the v3.9 mechanism and its rationale
  versus the plain MEMORY_MAX_HISTORY hard cap.
The summary message had no 'id' when created in compaction.py. It
only got one lazily on the NEXT load, via memory._migrate_history()
-- which was meant for old pre-v3.9 entries, not fresh compaction
summaries -- assigning whatever next_id had advanced to by then. That
put the summary's id well ahead of the still-uncompacted messages
right after it chronologically, which the user spotted directly in
their memory.json (a summary of 59 earlier messages sitting at id 83,
*after* messages 63-82 in id order despite summarizing everything
before them).

Fixed by assigning the summary the id of the oldest message it
replaces (messages[0]['id']) at creation time, for both strategies --
that id is free for reuse since the original message no longer
exists standalone. Regression test added.
…, remaining emoji

- Scroll: appendMsg took an optional scroll param (default true);
  loadHistory() now scrolls once after the whole batch instead of once
  per appended message, fixing the rapid-fire scroll-flash on every
  full re-render (page load, after each turn, after pin/unpin).
- Model name: dropped the hardcoded .slice(0, 30) that cut off real
  GGUF filenames (now longer since /health reports the live loaded
  model). #status-text gets CSS text-overflow:ellipsis with a max-width
  instead, plus a title tooltip showing the full name on hover.
- Replaced the four remaining emoji (logo gear, token key, compact,
  refresh) with the same style of inline monochrome SVG already used
  for the pin icon, for visual consistency across the whole UI.
formatContent() rewritten from a two-regex code-fence-only formatter
into a small dependency-free markdown subset: headers (h3-h5), bold,
italic, links, ordered/unordered lists, inline code, and fenced code
blocks -- deliberately hand-rolled instead of pulling marked.js/etc.
from a CDN, since Forge is meant to keep working with no internet on
the NiPoGi. Fenced "diff" blocks get special handling: +/- lines
colored like real git-diff output, so an "improve this file" answer
can show what changed instead of the whole file every time.

Fixed a real pre-existing gap along the way: only code-fence content
was ever HTML-escaped before, so raw '<'/'&' in a model's own answer
outside a fence would have been injected as live markup. Everything
is escaped first now, before markdown rules are applied on top.

Also split .msg pre / .msg code CSS, which previously applied the
same block-level padding to inline code spans as to full pre blocks.

Verified with `node --check` (real syntax validation, not brace
counting) and a set of representative inputs run through Node
directly (multiline text, bold/italic/links, lists, headers, a code
fence, a diff fence, and an XSS-attempt string confirming it comes
out escaped).
Root cause behind "aide-moi a creer un fichier hello world en go"
never actually creating anything: TOOL_DESCRIPTIONS already documented
the write action, but _TOOL_EXAMPLES["files"] only ever showed a read
example. Every other tool in this prompt (memory especially, per its
own comments) already leans on the principle that a small local model
follows a worked example far more reliably than a stated rule -- files
was the one tool where that principle wasn't actually applied to its
second action. The model always saw "read", never "write", and
defaulted to answering with a code block as plain chat text instead
of persisting anything -- confirmed in real usage: a later request to
edit "the file you created" failed with file-not-found, because
nothing had ever been written.

Added a second worked example demonstrating write, mirroring the
existing read one. 2 tests added confirming the example is present
and its JSON is well-formed on both the outer (files) and inner
(write instruction) level.
…ing file

The other half of "improve this file just repeats the whole thing":
even once the router correctly calls files:write (previous commit),
the tool itself only ever confirmed "[ok] written N bytes" -- no diff,
whether shown to the user or not. Asking the model to hand-format its
own diff syntax is failure-prone for a small local model; computing
one deterministically in Python (difflib.unified_diff) is not.

_action_write() now reads the old content before overwriting (skipped
if the existing file exceeds _MAX_READ_BYTES, same guard already used
by read, so the write itself always still succeeds) and returns a
```diff-fenced unified diff instead of the bare confirmation -- which
the web UI's diff renderer (already shipped) picks up and colors like
real git-diff output. A brand-new path has nothing to diff against and
keeps the plain confirmation; identical content reports "inchangé"
rather than an empty diff block.

5 tests added: diff on overwrite, unchanged-content reporting, new-file
has no diff, and the oversized-existing-file guard (write still
succeeds, diff skipped).
Confirmed live: "remplace X par Y dans hello.go" (and several repeats
of it) never touched the real file -- the router had a write example
(previous commit) but no example at all for EDITING an existing
named file, so it kept answering with the ORIGINAL unmodified content
from memory/guesswork instead of the real one on disk.

Mirrors the existing memory tool's recall -> done:false -> rephrase
pattern, applied to files:
  - New third files example: an edit-intent message ("remplace X par
    Y dans hello.go") maps to a read with "done": false, not a write
    or a chat answer.
  - New step_context steering hint (elif alongside the existing
    memory one): right after a [files] read result, push explicitly
    toward "action":"write" with the real content + the requested
    change, and away from re-reading or just answering in chat.

Caught while wiring the hint: step_context truncates every entry to
_MAX_HISTORY_ENTRY (120 chars) -- sized for compact history summaries,
not for a file the model is about to be asked to reproduce in full
with one change applied. A files:read result now gets a separate,
much higher cap (_MAX_STEP_CONTEXT_FILE_ENTRY, 4000 chars) instead,
bounded well below the files tool's own 64KB read cap to stay inside
an 8k-token local model's prompt budget.

6 new router-prompt tests (example present, hint fires only after a
read and not after a write, the 120-char cap doesn't clip a files:read
entry) plus one full orchestrator-level integration test proving the
2-step flow actually updates the file on disk and returns a real diff,
not just a plausible-looking chat answer.
…newline

Confirmed live: -print('Hello World')+print('Bienvenue') rendered as
one line instead of two. unified_diff() preserves each source line's
own ending, so a file with no trailing newline (single-line scripts,
the real case this was caught on) produces a last diff line with no
newline either -- joining it straight onto the next line with zero
separator. Display-only diff, not meant to be fed to patch, so
normalizing every line to end in \n before joining is always safe.
Regression test added with the exact real-world case (a one-line file,
no trailing newline).
@Kurtisone
Kurtisone merged commit 039b0ac into main Aug 1, 2026
2 checks passed
@Kurtisone
Kurtisone deleted the v3.9-context branch August 1, 2026 12:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant