Skip to content

feat: Phase 2 Wave 1 — protocol slimming, answer-first, progressive retrieval, pinned core memory - #5

Merged
fasdwcx merged 29 commits into
mainfrom
feat/phase2-wave1
Jul 28, 2026
Merged

feat: Phase 2 Wave 1 — protocol slimming, answer-first, progressive retrieval, pinned core memory#5
fasdwcx merged 29 commits into
mainfrom
feat/phase2-wave1

Conversation

@fasdwcx

@fasdwcx fasdwcx commented Jul 28, 2026

Copy link
Copy Markdown

Closes #4

Summary

Phase 2 Wave 1 of the memory eval & optimization program: five optimization rounds executed as an autonomous change→measure→gate loop, all kept, zero reverts. 27 commits, every task reviewed + fixed + re-reviewed, final whole-branch review on the strongest model.

What shipped

  • R5 guardrails first: distractor-ratio metric, evalrun compare suite, -tasks filter with partial-scorecard guard, ENGRAM_MCP_AUDIT_LOG (all 22 mem_* tools)
  • R1 protocol slimming: slim protocol-mode revived (floor 0.4.0, --set), compact 1265B slim heredoc, MCP instructions 3147B→736B (detail moved to on-demand SKILL)
  • R2 answer-first: silent/batched saves + bounded retrieval in ALL 12 agent-facing protocol copies (incl. mem_save tool description, post-compaction hooks; slim now survives compaction); guards: per-heredoc + front-matter extraction tests, banned-phrase sweeps
  • R3 progressive retrieval: mem_search returns a budgeted reference-first index (517→206 tok/query, budget gates text AND structured envelope); layered mem_context ≤400 tok both backends (1600B cap, mutation-proof tests)
  • R4 pinned core memory: 1KB capped pinned block, stable pin-time ordering, pin criterion in protocol; 2 real phoenix gotcha facts pinned
  • Server proposals: docs/proposals/2026-07-memorylake-server-asks.md (temporal validity, min_score, batch endpoints — evidence-cited)

Corrected acceptance (read eval/results/wave1-report.md)

The final review caught an infrastructure artifact: 12 no-memory L3 runs (both baseline and final) had died on OAuth expiry and scored 0, inflating the program's uplift anchor. After checkpoint re-runs: true baseline uplift −0.367, Wave 1 −0.183. Defensible wins: injected tokens −22% harness / −50% real-session at flat recall@5 0.796; memory-arm e2e mean +0.967; arch drag cured (−0.90→+0.75); memory-arm verbosity +48%→+9%. The original ×2 target is void (anchored on the artifact); Wave 2 mandates (task-set redesign for memory-only knowledge, re-anchored goals, harness auth guard) are in the report.

Behavior notes for reviewers

  • Slim protocol is opt-in (engram protocol-mode <slug> --set slim); full remains default until a v0.4.0 release decision
  • mem_search index mode + layered context + answer-first text change ALL users' agent behavior (intended; scorecards attached as evidence)
  • User prompts no longer render in mem_context (write-only for stdio agents — closes a secret-replay path)

🤖 Generated with Claude Code

fasdwcx added 29 commits July 27, 2026 09:34
…-set

The slim protocol path was unreachable: protocolVersionFloor was 1.4.0 while
this fork versions in the 0.x line, so `engram protocol-mode <slug>` always
printed "full" and the session-start hook never rendered the compact protocol
text.

- Lower protocolVersionFloor to 0.4.0 (fork versioning).
- Treat the "dev" build placeholder as meeting the floor (source builds are by
  definition current), and strip semver pre-release/build metadata before
  parsing so Go >= 1.24 VCS pseudo-versions (0.4.1-0.2026...+dirty) parse
  instead of silently degrading to "full".
- Add `engram protocol-mode <slug> --set slim|full`, which persists via the
  existing setup.WriteProtocolMode and then prints the effective mode. An
  invalid value is fatal (unlike `setup --protocol=`, where it only warns).
- Retune the pre-existing floor tests to the new 0.4.0 floor and dev-meets-
  floor semantics.
…1 measures agent-visible payload

mem_search no longer renders per-hit bodies. Hits render as a budgeted index
(one line per hit: type, title, first-sentence summary, sync_id, approximate
expansion cost, score) via the new FormatSearchIndex; agents scan the index and
expand deliberately through mem_get_observation. New optional `budget` argument
caps the index size (default 600 tokens); dropped hits are announced with an
explicit "more omitted" marker instead of being silently lost.

The response envelope is unchanged: count header, project/detection metadata,
and the structured `results` array (which still lists every hit) all stay, as do
the per-hit relation annotation lines (REQ-012), now interleaved after each
shown index line.

eval/runner L1 measures avg_tokens_per_query from FormatSearchIndex instead of a
JSON dump of the result rows, so the metric tracks what the agent actually pays
for.
…ends)

FormatContext now renders one layered block on both backends:

  ## Memory Context
  ### Pinned              (as before: content cut to 300 runes)
  ### Recent Sessions     (max 5, one line: <started_at> <first sentence>)
  ### Recent Observations (max 3, title + first sentence, 160-rune cap)
  Full bodies: mem_search / mem_get_observation.

The whole block is hard capped at 1600 bytes (~400 tokens): overflow drops
the oldest observation lines first, then the oldest session lines. Pinned
content is never dropped (capping the pinned block itself is a separate
change).

Behaviour changes beyond rendering:
- The "### Recent User Prompts" section is gone — prompts cost more bytes
  than they buy inside a 400-token budget, and they have no MemoryLake
  analogue, so keeping them would break cross-backend layout parity. They
  remain available via RecentPrompts / SearchPrompts (GET /prompts/recent,
  GET /prompts/search). FormatContext no longer reads user_prompts at all.
- Store heading changed from "## Memory from Previous Sessions" and the
  MemoryLake heading from "## Memory Context (MemoryLake)" to the shared
  "## Memory Context" so both backends emit byte-identical structure.

firstSentence is copied into internal/store and internal/memorylake with a
comment naming internal/mcp.firstSentence as the canonical twin: internal/mcp
imports internal/store, so store cannot import it back, and memorylake must
not import mcp (import cycle). Same duplication pattern already used for
truncate and approxTokens.

Layout parity is enforced by the two mirrored unit tests
(store.TestFormatContextLayeredBudget and
memorylake.TestBackend_FormatContext_LayeredBudget): paritytest's corpus
schema (CorpusEntry) has no context dimension — it describes observations to
save plus gold queries — so a corpus row cannot express a context case, and
`go test -tags parity ./internal/paritytest/` skips here (no live
ENGRAM_MEMORYLAKE_API_KEY configured in this sandbox).

Updated assertions that encoded the old layout: cmd/engram (heading),
internal/server e2e (heading), store's prompts-section assertion (now
asserts absence), store's "recent prompts error" subtest (now asserts
FormatContext survives a dropped user_prompts table), and memorylake's
300-rune truncation test (retargeted at a pinned fact + a 160-rune summary
assertion for the unpinned one).
…nd prompts posture

Wave1 Task 9 review fixes.

Tests: the store byte-cap test rendered 1473 bytes (under the 1600 cap), so the
cap loop never ran and its drop-order assertions were vacuous. Both backends now
use fixtures that overflow before dropping (store 1693, memorylake 1654), guard
that fact explicitly via a reconstructed pre-cap render, and assert survivors by
name: sessions all survive, newest observations survive, the oldest rendered one
is dropped, pinned and footer never dropped. Section order (Pinned < Recent
Sessions < Recent Observations) asserted on both sides. Validated against three
mutations (sessions-before-observations, newest-first drop, cap loop deleted) --
each fails the store test.

Code: sessionSummaryLine names the project in cross-project renders only
(blank project filter, i.e. scope=personal), memorylake's
formatContextContentTruncateLen renamed to contextPinnedTruncLen for grep
parity with internal/store, and handleContext notes that contextByteCap bounds
the rendered block only -- the stats/nudge tail is uncapped and outside the L2
measurement.

Docs: DOCS.md no longer implies stdio agents can read prompts back; states the
Wave-1 posture (write-only over MCP, readable via engram serve HTTP and the
cloud dashboard) and documents the cross-project session line format.
The pinned section is the one part of the layered mem_context block the
1600-byte outer cap never drops, so an over-pinning project could starve
sessions/observations out of the block from the inside. Give it its own
ceiling.

Both backends now render `### Pinned (core memory)` with:

- a 1024-byte cap over the whole section (heading + entries + marker +
  separator), enforced before the outer contextByteCap runs, so the outer
  invariant ("never drops pinned") is unchanged;
- entries in pin-time ASCENDING order, keeping the section a stable prefix
  that grows at the end as new pins arrive;
- overflow that drops the OLDEST pins from *rendering* only (nothing is
  unpinned or deleted) behind a trailing
  `(pin cap reached: N pinned facts not shown — mem_unpin to prune)`.

Pin time is a documented proxy, not a stored timestamp: neither backend
records when a pin happened (pinning deliberately does not touch
updated_at on SQLite), so ordering uses updated_at falling back to
created_at, with id breaking ties. Documented on PinnedObservations,
factPinTime and in DOCS.md.

Protocol text gains the pin criterion — "only facts whose loss causes
irreversible damage or repeated pitfalls" — in all four slim heredocs
(kept byte-identical, slim is now 1399/3400 bytes) and both memory
SKILL.md files, so an agent learns the cost of pinning in the same
session it can pin.
…text lines

capPinnedLines fell through its drop loop when the pinned section reduced to a
single entry that alone exceeded pinnedSectionByteCap, returning that line plus
"(pin cap reached: 0 pinned facts not shown - mem_unpin to prune)". Nothing was
withheld, so the marker was false agent-facing text. A lone entry now returns
early: core memory is never emptied and no marker is emitted, so the marker's
count is always >= 1.

Also cap rendered titles at 120 runes (contextTitleTruncLen) in the pinned and
recent-observation line renderers of both backends. Content was already bounded,
but a pinned line is the one line neither cap may drop, so an unbounded title
was the remaining way to carry the whole block past contextByteCap with nothing
left to trim.

Tests: per-backend coverage for the lone-over-cap entry (no marker anywhere) and
for a 300-byte title no longer pushing a pinned-intact block past the 1600-byte
outer cap, each with a guard proving the path is exercised. Adds a pin-cap
sanity guard to the ML byte-cap fixture, notes the blank-line caveat on the
pinnedSectionBody test helpers, and documents the two edge cases in DOCS.md and
both memory SKILLs.
@fasdwcx
fasdwcx merged commit 7351354 into main Jul 28, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Phase 2 Wave 1: protocol slimming, answer-first behavior, progressive retrieval, pinned core memory

1 participant