Skip to content

refactor(memory): split into history + recall, add retrieval pipeline + bench#33

Merged
lIang70 merged 22 commits into
mainfrom
refactor/memory-ltm-subpackage
Apr 23, 2026
Merged

refactor(memory): split into history + recall, add retrieval pipeline + bench#33
lIang70 merged 22 commits into
mainfrom
refactor/memory-ltm-subpackage

Conversation

@lIang70

@lIang70 lIang70 commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator

TL;DR

sdk/memory is split into two orthogonal packages with distinct lifetimes
and concerns:

  • sdk/history — short-term conversation transcripts (Buffer / Compacted)
  • sdk/recall — long-term extracted facts with retrieval pipeline,
    async Save jobs, journal, and soft-merge

The cross-domain glue (MemoryAware, ContextAssembler, implicit LT
injection in Load) is deleted in favor of explicit composition at
the call site (~10-line pattern, see
examples/chatbot-with-recall/).

A unified retrieval layer (sdk/retrieval + sdk/retrieval/pipeline) is
introduced and used by recall. Knowledge can adopt it incrementally
in a follow-up — no API change required for that to happen.

A bench/ module is added for offline evals (bench/history-compression,
bench/locomo) and is wired into make ci (vet + unit tests only;
heavyweight eval CLIs are main packages and stay opt-in).

Why

Three structural problems on the old sdk/memory:

  1. God-object. Memory did three things at once — conversation window,
    long-term fact store, and automatic prompt assembly. Callers couldn't
    inspect what the LT layer pulled, couldn't dry-run, couldn't swap
    composition order.
  2. Concurrency holes. Save(fullHistory) had a read-modify-write race
    that silently dropped messages under concurrent writers; compactor's
    global semaphore silently dropped DAG ingests under load. Both fixed
    here with per-conversation locks + atomic writes
    (workspace.AtomicWrite).
  3. Configuration cliff. The old struct config used "zero-value-means-on"
    in some fields and "zero-value-means-off" in others, and adding new
    knobs broke source compat. Replaced with functional options across
    recall.New, history.NewBuffer, history.NewCompacted.

Highlights

History (sdk/history)

  • New file layout aligned with the memory-refactor RFC.

  • Memory contract tightened to Append(newOnly); per-conversation locks
    protect against concurrent writers.

  • File-backed stores use workspace.AtomicWrite (write-tmp + rename) for
    crash recovery.

  • New history.Closer sub-interface so callers of `NewCompacted` can
    drain background ingest/archive goroutines on shutdown.
    Type-assert pattern (NewBuffer is intentionally not Closeable):

    ```go
    defer func() { if c, ok := hist.(history.Closer); ok { c.Close() } }()
    ```

Recall (sdk/recall)

  • Promoted from sdk/memory/ltm; no symbol-level renames in the lift
    itself, but later commits collapse Scope / RecallScope and drop
    Scope.SessionID (see breaking changes below).
  • Functional options: WithLLM, WithExtractor, WithJobQueue,
    WithJobTimeout, WithSoftMerge, WithSweeper, …
  • Worker lifecycle: per-job context.WithTimeout derived from a
    cancellable workerCtx; Close() cancels in-flight extractor calls
    so it doesn't hang forever on a stuck LLM.
  • Bookkeeping (Complete/Fail/Reschedule) runs on a separate
    Background-rooted ctx so a worker cancel never orphans a successful
    job in running.
  • New Auditable and JobController sub-interfaces (Memory stays
    minimal; capabilities discovered via type-assert).

Retrieval (sdk/retrieval)

  • Stage-based pipeline (Embed → MultiRetrieve → RRFFusion → Boost → Decay → Rerank → Limit) with explicit State.
  • Journal layer with replayable Upsert / Delete events; supports
    recall.Rollback and recall.History.
  • Soft-merge semantics: supersession is reversible (we keep the old
    entry + a SupersededBy edge rather than deleting); a multiplicative
    SupersededDecay ranks the predecessor below the survivor without
    losing it.

Async Save infrastructure

  • recall.JobQueue interface + in-process MemoryJobQueue.
  • sdkx/recall/jobqueue/sqlite for crash-recoverable async Save.
  • SaveAsync returns immediately with a JobID; JobController
    exposes JobStatus / AwaitJob.

Observability (production-ready)

OTel coverage now matches what sdk/history/archive + compactor already
had. New telemetry for the recall + retrieval surface:

  • Spans — `memory.recall.add`, `memory.recall.recall`,
    `retrieval.stage.` (one per pipeline stage, automatically
    parented).
  • Metrics — under `memory.recall.*`:
    `add_total{outcome,reason}`, `add_duration`,
    `recall_total{outcome,reason}`, `recall_duration`, `recall_hits`,
    `job_total{outcome=succeeded|failed|timeout|dead, stage, attempts}`,
    `job_duration`, `job_lease_errors_total`.
  • Warn logs (trace-correlated) — worker lease failure, async job
    failure (timeout vs error split via `errors.Is(ctx.DeadlineExceeded)`),
    dead-letter, bookkeeping failure.
  • Cardinality-safe: Scope.UserID and query text are never attached
    as attributes (PII + cardinality). Only structural shape (`top_k`,
    `hits`, `query_len`, `has_user_id`).

Bench (bench/)

  • New module outside `go.work` (uses `replace` directives + `GOWORK=off`).
  • `bench/history-compression` — evidence-loss detector for compactor.
  • `bench/locomo` — LOCOMO benchmark integration with eval / convert / fetch / ingest CLIs.
  • Wired into `make ci` (vet + unit tests, ~1.6s; eval CLIs are `main`
    packages and stay opt-in).

Build / CI

  • `Makefile`: fail-fast loops (`set -e` macro). Previously a failing
    `go vet` in any submodule was silently swallowed by the `for ... done`
    exit code, making "make ci green" occasionally untrue. Bench was
    silently failing vet ever since it became its own module.
  • `SHELL := /bin/bash` pinned for stable `set -e` semantics across hosts.

Breaking changes — please read

  1. Schema: `recall.Scope` no longer has `SessionID`.
    `deterministicEntryID` no longer mixes the session line, so re-ingest
    may produce duplicates of facts extracted before this change.
    Treat the LTM index as a fresh corpus. Re-ingest from the source of
    truth or accept the duplication.

  2. API: The `MemoryAware` / `ContextAssembler` glue is deleted.
    Compose `history.Memory` + `recall.Memory` at the call site.
    Reference: `examples/chatbot-with-recall/main.go` (~120 LoC, full
    working example).

  3. Construction: `memory.NewWithLLM(cfg, ...)` (struct config) →
    `history.NewBuffer` / `history.NewCompacted` and `recall.New`
    (functional options). Old fields like `Type`, `LongTerm.Enabled`,
    etc. have no equivalent — composition is now explicit.

  4. Lifecycle: `recall.Memory.Close()` must be called on shutdown
    to drain async workers / sweeper. `history.Closer` is opt-in via
    type-assert (NewBuffer is stateless, NewCompacted is not).

  5. Imports:

    Old New
    `sdk/memory` (chat side) `sdk/history`
    `sdk/memory.LongTermStore` etc. `sdk/recall`
    `sdk/memory/ltm` `sdk/recall`

Migration cheat-sheet

```go
// Before
mem, _ := memory.NewWithLLM(memory.Config{
Type: "lossless",
LongTerm: memory.LongTermConfig{Enabled: true, TopK: 5},
}, store, llmClient, ltStore, memory.WithWorkspace(ws))
msgs, _ := mem.Load(ctx, convID) // implicit LT injection

// After
hist := history.NewBuffer(history.NewInMemoryStore(),
history.WithBufferMax(20))

mem, _ := recall.New(memidx.New(),
recall.WithLLM(llmClient),
recall.WithJobQueue(recall.NewMemoryJobQueue()),
recall.WithJobTimeout(2*time.Minute),
)
defer mem.Close()
defer func() { if c, ok := hist.(history.Closer); ok { c.Close() } }()

hits, _ := mem.Recall(ctx, scope, recall.Request{Query: userMsg, TopK: 5})
ltContext := formatHits(hits) // caller decides format
msgs, _ := hist.Load(ctx, convID, history.Budget{})
prompt := ltContext + msgs // caller composes
```

Verification

  • `make ci` — green, ~40s end-to-end (sdk + sdkx + examples + bench).
  • `go test -race` — clean across `sdk/recall/...` and `sdk/retrieval/...`,
    including the new `TestCloseCancelsStuckJob` regression test that
    pins the per-job timeout fix.
  • `TestNewCompacted_SmokeBoots` was flaky after merging main (background
    goroutine racing with `t.TempDir` cleanup); now uses
    `history.Closer` to drain — green at `-count=5`.

Compatibility with main's recent work

Out of scope (follow-up issues welcome)

  • Migrating `sdk/knowledge` onto the unified retrieval pipeline (it can
    do this incrementally without breaking callers).
  • WAL-style journal-first ordering for `retrieval/journal/wrap.go`
    (current v0 ordering: inner Upsert/Delete succeeds before `Journal.Record`,
    acceptable for dev, recommend WAL for production).
  • Wider Knowledge ↔ Recall integration (entity sharing, cross-source
    RRF tuning).

Made with Cursor

lIang70 added 22 commits April 22, 2026 15:38
…etrieval, add locomo bench

- sdk/memory: extract ltm into subpackage with extractor/merger/save split,
  add SaveWithContext, soft-merge, MD5 dedup, scope-aware operations
- sdk/retrieval: unify retrieval pipeline (factory, stages, boost/decay,
  llm reranker, score threshold, entity extraction)
- sdkx/retrieval: sqlite + postgres index backends with shared test suite
- bench: promote to top-level standalone module via go.mod replace
  directives; add locomo benchmark harness (ingest/eval/judge) with
  qwen + azure provider support and per-question fault tolerance

Made-with: Cursor
Workspace.Rename is the canonical "publish a finalized payload" primitive:
- LocalWorkspace.Rename uses os.Rename (atomic on POSIX)
- MemWorkspace.Rename moves entries in-map under the existing lock
- ScopedWorkspace.Rename enforces write permission on both src and dst
- objstore.Rename falls back to copy + delete (object stores lack atomic
  rename); doc clarifies the trade-off

AtomicWrite wraps Write-tmp + Rename so concurrent readers never observe
a half-written file. Replaces the old non-atomic free-function Rename
helper, which had no callers.

Made-with: Cursor
…lias

This is the first half of splitting sdk/memory into a clearly-named pair:

* sdk/history (this commit): conversation transcripts (buffer, lossless DAG,
  archive, summary store, file store, sanitize, tools, counter).
* sdk/memory/ltm (unchanged here, moves to sdk/recall in a follow-up commit):
  long-term fact extraction & retrieval.

What this commit changes is intentionally narrow:

1. git mv every non-ltm file from sdk/memory/ to sdk/history/ and rewrite
   the package clause (package memory -> package history). No type, function,
   or behavior changes.
2. Delete sdk/memory/ltm_alias.go. The pre-Phase-3 trampoline that re-exported
   ltm.* under sdk/memory.* is gone; callers that used those aliases now
   import sdk/memory/ltm directly. Inside sdk/history we qualify the same
   ltm.* symbols (LongTermConfig, LongTermStore, MemoryAwareMemory, etc.).
3. Update the four call sites that imported sdk/memory:
   - sdkx/llm/memory_integration_test.go (history.NewBufferMemory + ltm.*)
   - sdkx/memory/jobqueue/sqlite/queue_test.go (ltm.* only)
   - bench/locomo/{eval,runners,runners/flowcraft,cmd/ingest} (ltm.*; the
     flowcraft runner also keeps history.* for its short-term store).
4. sdk/memory/ now contains only the ltm/ subdirectory. It will disappear
   entirely once sdk/memory/ltm moves to sdk/recall (next commit).

Why now: the old sdk/memory tree mixed two packages (history vs LTM) plus
a backward-compat shim under one name, which made the public surface
ambiguous and made every later refactor need a "memory or memory/ltm?"
disambiguation. Splitting the rename out keeps the diff reviewable and lets
CI lock in a green baseline before any interface changes land.

make vet + make test pass on sdk, sdkx, examples/voice-pipeline. bench
passes with GOWORK=off go test ./... (bench is intentionally outside
go.work and uses replace directives).

Made-with: Cursor
Second half of the sdk/memory split. After this commit sdk/memory/ no
longer exists; long-term agent memory lives at sdk/recall.

Mechanics:

1. git mv every file from sdk/memory/ltm/ to sdk/recall/ (26 files,
   all 100% similarity per git's rename detection).
2. Rewrite the package clause: package ltm -> package recall, including
   the external test package (package ltm_test -> package recall_test in
   memory_test.go).
3. Rewrite every importer: import path
   "github.com/GizClaw/flowcraft/sdk/memory/ltm" -> ".../sdk/recall"
   and qualifier ltm.X -> recall.X. Affected files:
   - sdk/history/{memory,factory,memory_test,factory_test}.go
   - sdkx/llm/memory_integration_test.go
   - sdkx/memory/jobqueue/sqlite/{queue,queue_test}.go
   - bench/locomo/{eval,runners/runner,runners/flowcraft/runner,
                   cmd/ingest/main}.go
4. Refresh sdk/recall/doc.go to describe the package by its new name
   (the previous "Compared with the legacy sdk/memory.LongTermStore"
   contrast no longer makes sense — that legacy is gone).
5. Remove the now-empty sdk/memory/ and sdk/memory/ltm/ directories.

What is intentionally NOT in this commit:

- No interface, type, or behavior changes. recall.Memory, recall.Entry,
  recall.NewRetrievalStore, recall.MemoryAwareMemory, etc. all keep the
  exact same signatures they had as ltm.*. The Save->Append rework, the
  Config -> functional-options migration, and the removal of the
  Assembler / MemoryAware glue are scheduled for separate commits.
- Telemetry meter and span names ("memory.dag.ingest", "memory.archive",
  ...) are kept as-is to avoid breaking dashboards. They will be
  revisited together with the interface refactor.
- sdkx/memory/jobqueue/sqlite stays in sdkx/memory/ for now; its move
  to sdkx/recall/jobqueue/sqlite is a later commit so this one stays a
  pure rename.

make vet + make test pass on sdk, sdkx, examples/voice-pipeline, and
bench (GOWORK=off go test ./...).

Made-with: Cursor
…ability bugs

Replace the lossy Memory.Save(fullHistory) contract with Memory.Append(newOnly)
and close several persistence-correctness holes in the lossless backend.

Interface change
----------------
The previous contract required callers to pass the *entire* conversation
history on every write; the implementation diffed it against GetMessages
to discover what was new. That contract had two structural problems:

  1. Read-modify-write race. Two goroutines that called Save on the same
     conversation could both observe the pre-state, both diff against it,
     and both overwrite — silently dropping one side's writes. There was
     no per-conversation lock.
  2. Truncated histories were silently accepted (and silently shrank the
     persisted log) because Save trusted the caller's slice as the new
     ground truth.

The new shape is

    type Memory interface {
        Load(ctx, convID) ([]Message, error)
        Append(ctx, convID, newMessages []Message) error
        Clear(ctx, convID) error
    }

There is no compat shim for Save: only one external caller existed
(recall.MemoryAwareMemory), the four in-tree tests, and one bench runner.
Keeping a deprecated Save would have meant keeping the racy code path
alive too — defeating the point of this commit.

P0 durability fixes inside sdk/history
--------------------------------------
* lossless.go: per-conversation lock around Append. The semaphore-bounded
  background ingest is gone; it could silently drop DAG ingests under load
  (telemetry warned, but the summarized history quietly shrank). Each
  Append now schedules its own goroutine and is naturally serialized for
  the same conversationID by the lock; cross-conversation parallelism is
  unbounded as before but no longer fights for a fixed-size pool.
* lossless.go: ingest and archive get independent timeouts (defaultIngestTimeout,
  defaultArchiveTimeout) instead of sharing a single 60s budget. A slow
  LLM summarization can no longer starve archive (or vice-versa).
* archive.go: SaveManifest, writeIntent now go through workspace.AtomicWrite
  (write-tmp + rename), so a crash mid-write cannot leave RecoverArchive
  staring at a half-serialized manifest or intent file. deleteIntent uses
  ws.Delete instead of overwriting with an empty payload (which left a
  zombie file that made Exists() lie).
* summary_store.go: Rewrite uses workspace.AtomicWrite. A crash during
  the rewrite previously risked a half-truncated summaries.jsonl that
  the line scanner would silently treat as missing summaries.
* summary_store.go: FileSummaryStore caches are bounded (LRU, default
  cap 1024 conversations). The previous unbounded map leaked memory
  proportional to the conversation cardinality the process had ever
  touched. Eviction drops the cache slice and the per-conversation lock
  together (a stale lock with no cache is just a leak; reconstructing
  it on next access is free). New WithSummaryStoreCapacity option for
  callers that need a different bound. As a side effect, appendCache
  no longer materializes a "cache of one" when the entry has not been
  loaded — that prior behavior could fool subsequent List/Search into
  thinking the conversation only had the just-appended node.
* buffer.go: Append takes a per-conversation lock too. When the Store
  satisfies MessageAppender, we delegate; otherwise we do the get +
  concat + save fallback under the lock so it stays race-free.

Callers and tests
-----------------
* sdk/recall/aware.go: ShortTermMemory interface now declares Append (it
  is the local mirror of history.Memory). MemoryAwareMemory.Append simply
  forwards.
* sdk/recall/aware_test.go: in-package fake bufferMemory implements Append
  with the same get+concat+save fallback.
* sdk/history/{buffer,file_store,lossless,compact}_test.go: call sites
  switch from Save(fullHistory) to Append(newOnly). The two tests that
  intentionally re-passed the loaded prefix have been updated to pass
  only the freshly produced messages — they were testing the racy
  contract, not real user behavior.
* sdk/history/lossless_test.go: TestLosslessMemory_SemaphoreFull is
  replaced by TestLosslessMemory_NoIngestDrop, which pins the new
  invariant: 20 rapid Appends across 20 conversations all complete with
  no silent drops. The previous test only confirmed the (now removed)
  drop-on-full path.
* sdk/history/summary_store_test.go: cache white-box assertions go
  through a new cacheSnapshot test helper that reads the LRU entry by
  conversation id (instead of poking the removed `cache` map directly).

Verification
------------
make vet and make test pass on sdk, sdkx, examples/voice-pipeline.
go test -race ./history/ ./recall/ is clean. bench passes with
GOWORK=off go test ./...

Made-with: Cursor
…, drop aware/assembler glue

The recall API surface had three independent shapes for the same idea
("which bucket does this read/write live in"): persistence Scope on
Entry, query Scope on List/Search, and a separate RecallScope for
multi-partition union. They were also leaking through a stateful
MemoryAware/ContextAssembler glue that mutated runtimeID/scope on the
instance — causing user IDs to leak across requests in any reused
wrapper. This commit collapses the surface to one type and removes the
glue layer:

Constructor

  - recall.New(idx, ...Option) replaces recall.New(Config). Options are
    self-documenting and additive: WithLLM/WithEmbedder/WithJournal/
    WithSaveContext/WithoutMD5Dedup/WithoutSoftMerge/etc. The previous
    "all-zero booleans auto-flip to ON" heuristic in Config was removed;
    defaults are now declared in one place inside New.

Scope (one type, not three)

  - MemoryScope -> Scope; MemoryEntry -> Entry; MemorySource -> Source;
    MemoryCategory -> Category; MemoryPartition -> Partition;
    LongTermStore -> Store. The package qualifier already says "recall",
    the Memory* prefix was redundant.
  - RecallScope is gone; its Partitions field moved onto Scope, with a
    new Scope.EffectivePartitions() that auto-derives [PartitionUser] or
    [PartitionGlobal] from UserID when the caller does not set it
    explicitly. List/Search read partitions exclusively from
    opts.Scope.Partitions, so callers no longer pick between two ways
    of saying the same thing.
  - Scope.SessionID is removed. It conflated conversation-thread state
    (which belongs in sdk/history) with long-term-fact partitioning,
    leaked thread IDs into the namespace and the deterministic ID hash,
    and forced every recall caller to thread a session ID even when
    they only wanted user-level facts. Callers that previously
    partitioned by session should either keep that data in
    sdk/history (transient) or model it as Entry.Keywords (durable).
    Note this is a breaking schema change: deterministicEntryID now
    omits the session line, so re-ingest may produce duplicates of
    facts that were extracted before the upgrade.

Glue layer removed

  - Deleted aware.go (MemoryAwareMemory), assembler.go, assembler_cache,
    assembler_metrics, and their tests (~1750 LoC). They wrapped
    history.Memory + recall.Store and prepended a "[Long-term memory]"
    block to the system prompt; they also held mutable runtimeID/scope
    on the instance, which leaked per-user state across reuse and made
    SetScope a footgun. The replacement is a ~10-line snippet at each
    call site: recall.Memory.Recall() -> format hits -> prepend to the
    system prompt before calling LLM. examples/chatbot-with-recall (in
    a follow-up commit) shows the canonical shape.
  - sdk/history.Config.LongTerm is removed and history.NewWithLLM no
    longer takes an ltStore parameter; the history package no longer
    imports recall. This removes a circular layering pressure between
    "transcript storage" and "fact recall".

Call-site updates

  - bench/locomo runners and the SQLite jobqueue tests switch to
    recall.New(idx, ...Option).
  - sdkx/llm/memory_integration_test.go drops the MemoryAware path and
    builds the with-memory variant directly: Recall() -> format ->
    prepend to system prompt -> Generate. This is the same pattern
    examples/chatbot-with-recall will demonstrate.

Verified with make ci (all sdk/sdkx tests, plus go test -race on
sdk/recall and sdk/history) and a manual `go test ./...` from bench/.

Made-with: Cursor
… example

Finishes the sdk/memory → sdk/history + sdk/recall split by removing
the last sdkx/memory/* path and shipping a runnable composition demo.

* git mv sdkx/memory/jobqueue/sqlite → sdkx/recall/jobqueue/sqlite
  Keeps file history; the wrapper directory under sdkx/memory/ is now
  fully removed. Updates the lone comment reference in
  sdk/recall/jobs.go.
* Renames the persistent table from memory_jobs to recall_jobs to keep
  the schema names aligned with the package name. This is a breaking
  on-disk change — doc.go now spells out the one-liner ALTER TABLE
  needed to migrate an existing queue.
* Adds examples/chatbot-with-recall, a ~150-line self-contained demo
  showing the recommended history+recall composition that replaces the
  deleted MemoryAwareMemory wrapper. Uses in-memory stub LLMs so it
  runs network-free; README explains how to swap in a real provider
  and a SQLite job queue. Intentionally not added to go.work — run
  with GOWORK=off go run . from the example directory.

Verified: make ci (sdk/sdkx green) + GOWORK=off go test ./... in bench
+ GOWORK=off go run . in examples/chatbot-with-recall.

Made-with: Cursor
… RFC

Closes the gap between the post-split implementation and the design
note in docs/memory-refactor.md so callers see one consistent shape.

sdk/recall:
- Slim Memory to the read/write core (Save/SaveAsync/Add/Recall/
  Forget/Close); pull History+Rollback into Auditable and JobStatus+
  AwaitJob into JobController. The canonical lt impl satisfies all
  three so tests just type-assert.
- Rename RecallRequest→Request, RecallHit→Hit, AddRaw→Add.
- Drop Store / RetrievalStore / ListOptions / SearchOptions /
  Embedder / VectorSearcher / NormalizeScopeForCategory /
  DefaultGlobalCategories. Memory.Recall is the only public read path;
  callers needing raw enumeration drop to retrieval.Index directly
  (see the rewritten sdkx/llm/memory_integration_test.go for the
  pattern).
- Recall search tests now exercise Memory.Recall instead of the
  removed RetrievalStore.

sdk/history:
- Memory.Load(ctx, convID) → Memory.Load(ctx, convID, Budget). Zero
  Budget keeps the old "use defaults" semantics; non-zero MaxTokens /
  MaxMessages clamp.
- Replace exported BufferMemory / LosslessMemory + their constructors
  with NewBuffer(store, BufferOption…) and NewCompacted(store, llm,
  ws, CompactOption…) returning unexported buffer / compacted types.
- Delete history.Config and history.LosslessConfig; per-knob
  CompactOption replaces them. NewWithLLM is gone.
- Rename history_expand / history_compact tools (was memory_*) and
  bump the LLM-node schema hint to match.

examples/chatbot-with-recall:
- Split into main.go (≤80 lines, the wiring) + stubs.go (offline LLM
  doubles + demoTurns + buildPrompt/formatRecallBlock) so the example
  is readable at a glance.
- Pick up history.NewBuffer / history.Budget / recall.Request / Hit /
  Add naming.

bench/locomo, sdkx/llm integration tests, sdkx/recall/jobqueue/sqlite:
- Updated for the renames and the JobController / Auditable
  type-assertion pattern.

Verified: make ci, GOWORK=off go test ./... in bench, GOWORK=off
go run . in examples/chatbot-with-recall, go vet -tags=integration
sdkx/...

Made-with: Cursor
Strict alignment with §4.1 of docs/memory-refactor.md (Scheme A):

- Rename interface Memory→History (matches package name; eliminates
  collision with recall.Memory)
- File renames:
  - memory.go → history.go (interface + Budget only)
  - lossless.go → compactor.go (also absorbs summary_dag.go,
    summary_index.go, factory.go's NewCompacted/Options — all
    summarization concerns now live in one file)
  - file_store.go + InMemoryStore → store.go (one file for the
    persistence interface and shipped impls)
  - counter.go → token.go (matches RFC label "TokenCounter")
  - summary_prompt.go → compactor_prompt.go
- Internal compacted struct → compactor (matches file name)
- Add doc.go (package overview the RFC explicitly calls out)
- Drop factory.go entirely; NewBuffer lives next to buffer in
  buffer.go, NewCompacted next to compactor in compactor.go
- Test files renamed to mirror sources

Public API change: history.Memory → history.History. Single caller
(examples/chatbot-with-recall) updated. No production deployments
exist yet; no compat shim added.

verified: make ci + GOWORK=off bench + example demo all green.
Made-with: Cursor
Made-with: Cursor
Two new bench surfaces filling gaps surfaced during the memory refactor:

1. bench/history-compression/
   Long-context QA quality vs prompt-token cost across three strategies
   (none / buffer / compacted). Reuses bench/locomo's dataset + metrics
   so the two reports are directly comparable. CLI mirrors locomo's
   provider:model flag plumbing. Compacted strategy auto-skips when
   --summary-llm is empty so CI smoke stays LLM-free.

   Runs against synthetic in <1s with a stub LLM; pointed at LoCoMo10
   it produces (qa_judge, prompt_tokens_p95, load_latency_p95) per
   strategy — i.e. the "is the compactor's compression free?" question
   becomes a single diff.

2. sdkx/recall/jobqueue/sqlite/bench_test.go
   Three Go micro-benchmarks measuring the SaveAsync hot path:
   - Enqueue (memory + file modes)
   - Lease+Complete drain loop
   - SaveAsync end-to-end at workers=1/4/16 with a fake LLM, which
     isolates queue + persistence overhead from extractor latency.

   Sample numbers on M2 Max:
     Enqueue/memory   ~74K ops/s
     Enqueue/file     ~26K ops/s   (fsync-bound)
     Lease+Complete   ~13K ops/s
     SaveAsync e2e    ~900 ops/s   (single-writer SQLite is the floor)

   These numbers establish the baseline for future jobqueue work
   (batched commits, alternative backends).

CI: make ci green; bench/history-compression unit test green under
GOWORK=off; sdkx/recall/jobqueue/sqlite already in make ci.

Made-with: Cursor
Adds the truncation signal that was previously stubbed at 0. The detector
fingerprints each evidence-bearing turn at ingest (first ~32 normalized
runes — long enough to be conversation-unique, short enough to survive
compactor rephrasing of trailing words) and after every Load checks that
at least one fingerprint still appears in the loaded prompt.

Three new report fields:

- evidence_measured: questions that carried any evidence_id
- truncated:         of those, how many lost ALL evidence turns
- truncated_rate:    truncated / evidence_measured

Operationally this lets reports separate two failure modes that previously
collapsed into qa_judge ↓:
  1. compactor compressed the evidence away      → truncated > 0
  2. model failed to use evidence still in prompt → truncated = 0

Tests:
- TestRun_TruncatedDetected: BufferMax=2 must report Truncated=1 for a
  4-turn conv whose evidence is in turn 1
- TestRun_NoEvidenceLeavesTruncatedZero: datasets without evidence_ids
  must report 0/0/0 (no false positives, no div-by-zero)

CLI prints `truncated=k/N (rate%)` for evidence-bearing runs, `n/a`
otherwise.

Made-with: Cursor
… log

A 1542-question full LoCoMo run was strictly sequential, so even with
qwen-turbo (~3s/call) one strategy took ~25min and gpt-5.4 was
unrunnable. Per-conversation Load is concurrent-safe (compactor and
buffer both lock per conv, ingest happens before the loop), so the
answer/judge fan-out can shard across goroutines without changing the
result.

Adds Options.Concurrency (default 1, opt-in) and Options.ProgressEvery,
plus matching --concurrency / --progress-every CLI flags. With
--concurrency 8 the full LoCoMo10 run drops from "many hours" to ~16min
(none 7min, buffer 4min, compacted 5min) on qwen-turbo.

Made-with: Cursor
… b.N

Default -benchtime=1s lets the harness inflate b.N to ~6500 jobs, but
workers=1 drains them serially and a fixed 30s per-await timeout
expired on ~15% of jobs, failing the bench. Scale the timeout
proportionally to b.N/workers (1ms per queued predecessor, floored at
30s, no upper cap) so single-worker runs complete on slow CI hardware
without spurious failures.

Made-with: Cursor
Mirrors locomo/results/: per-run report JSON is reproducible from the
dataset + flags and bulky enough to clutter diffs.

Made-with: Cursor
Three correctness-leaning fixes surfaced during the post-refactor review:

1. recall.Add: validate Content before deriving any state.
   Previously the empty-content error was returned AFTER the deterministic
   ID was hashed, the timestamps stamped, and the scope copied — so a
   throwaway ltm_<sha256> ID could land in debug logs / span attrs for
   an entry that was about to be rejected. Move the check to the top of
   the method.

2. history.Closer: expose compactor.Close to History consumers.
   history.NewCompacted returns a History whose async ingest/archive
   goroutines outlive each Append, but the History interface had no Close
   method, leaving callers no way to drain on shutdown — the goroutines
   leaked until process exit. NewBuffer is stateless and intentionally
   does NOT satisfy Closer, so the canonical pattern is a type-assert:

       hist := history.NewCompacted(store, llm, ws)
       defer func() {
           if c, ok := hist.(history.Closer); ok { c.Close() }
       }()

   compile-time assert added on *compactor; chatbot example updated to
   demonstrate the type-assert pattern even though it uses NewBuffer.

3. recall: bound Memory.Close() with a per-job context.
   The async worker previously ran Extract / upsertFacts under
   context.Background(), so a stuck LLM call would pin Memory.Close()
   on m.wgWorkers.Wait() forever — wg.Wait() had no upper bound at all.

   New three-tier context model:
   - workerCtx (cancelled by Close): root for Lease + jobCtx, lets Close
     unblock blocking Lease implementations promptly.
   - jobCtx = WithTimeout(workerCtx, jobTimeout): wraps Extract +
     upsertFacts so a single misbehaving job is bounded by the configured
     timeout (default 5min, override via new WithJobTimeout option).
   - bookCtx = WithTimeout(Background, 5s): wraps Complete / Fail /
     Reschedule. Derived from Background, NOT workerCtx, so Close()'s
     cancel of workerCtx cannot orphan a successful job in 'running'
     state.

   Close() is now idempotent (guarded select on stopCh + cancel is a
   no-op the second time) and is bounded by jobTimeout in the worst
   case (extractor that ignores ctx.Done()).

Tests
-----
- TestAddEmptyContentFailsFast pins the validation order.
- TestCloseCancelsStuckJob uses a blockingLLM that only returns on
  ctx.Done() and asserts Close() returns within 5s (vs the 2-minute
  jobTimeout configured on the test instance) — proving Close cancels
  in-flight jobs rather than waiting them out.

make ci green; go test -race ./recall/... clean.

Made-with: Cursor
Two fixes to the per-module loop:

1. Errors no longer get swallowed.
   The previous form

       @for m in $(MODULES); do echo "==> $$m"; ( cd $$m && go vet ./... ); done

   ran each submodule in a subshell, but the shell-level for-loop's exit
   status is the status of `done`, not of the failing subshell. A `go vet`
   failure in any module printed the error but make recorded exit 0 — so
   "make ci green" was, on this branch, occasionally a lie. Bench in
   particular has been silently failing vet ever since it was promoted to
   a separate module (it requires GOWORK=off, which the loop did not
   set).

   The new GO_FOREACH macro prefixes `set -e` and re-tests after the
   subshell, so the first module that fails terminates the whole target
   with the right exit code. Verified by injecting a deliberate vet error
   in sdk/recall — `make vet` now exits 1 with the offending file in the
   output.

2. bench is back in CI for vet + test.
   bench/ lives outside go.work (heavyweight eval datasets/CLIs that
   would dirty the workspace dependency graph) and needs GOWORK=off. The
   new MODULES_OFFWORK list captures that, so the loop transparently
   prefixes GOWORK=off for those modules.

   Cost check: bench's actual *test* surface (history-compression +
   locomo evals running against in-memory stubs) is ~1.6s on M2 Max;
   total `make ci` is ~40s end-to-end. The expensive paths
   (bench/locomo/cmd/eval, history-compression/cmd/eval) are main
   packages — `go test ./...` does not invoke them, so dataset / LLM
   calls stay opt-in.

3. Help text rewritten to spell out the bench-in-CI policy (vet + test
   yes, eval CLIs no).

Made-with: Cursor
…ationale

Three godoc blocks pointed at files that do not (and likely will not)
exist in this repo:

  - sdk/recall/doc.go            -> docs/memory-refactor.md
  - sdk/recall/merger.go         -> RFC-0002
  - sdk/retrieval/pipeline/stages_boost.go -> RFC-0002

`go doc` rendered them as broken jump targets, and grep'ing the repo for
the referenced filenames returned only the references themselves —
classic "doc that promises a doc". Rather than backfill the missing
files, inline the actual content the comments needed:

* sdk/recall: replace the migration-doc pointer with self-contained
  v0.2.0 migration notes — covers the ltm.X -> recall.X rename, the
  Scope.SessionID removal (and its impact on deterministicEntryID, i.e.
  treat the LTM index as a fresh corpus), and the explicit instruction
  to compose history.Memory + recall.Memory at the call site instead of
  reaching for the deleted MemoryAware glue.

* recall.merger / retrieval boost stage: replace the RFC pointer with
  the soft-merge contract itself — supersession is reversible (we keep
  the old entry + a SupersededBy edge rather than deleting), and
  SupersededDecay applies a multiplicative penalty so the predecessor
  drops below the survivor in normal ranking but resurfaces on direct
  ID lookup.

No behavioural change; only comments touched. Goal is that future
readers can answer "why does this code exist" entirely from the file in
front of them.

Made-with: Cursor
Post-merge alignment: main bumped sdkx → sdk v0.1.12 in #32. bench
lives outside go.work and uses a `replace ../sdk` for builds, but
`go vet` / `go mod tidy` still verify the version pin matches what
the workspace's other modules declare. After merging origin/main,
`make vet` complained:

    go: updates to go.mod needed; to update it: go mod tidy

Bump bench's pin to v0.1.12 to match. No transitive deps changed
(go.sum is untouched) because the actual build still resolves through
the local replace.

Made-with: Cursor
The smoke test boots a compactor against t.TempDir(), Appends one
message, and returns. Background ingest/archive goroutines were still
flushing to disk when t.Cleanup tried to RemoveAll the tempdir,
producing flaky failures of the form:

    TempDir RemoveAll cleanup: unlinkat .../TestNewCompacted_SmokeBoots...:
    directory not empty

This is exactly the leak the new history.Closer interface exists to
fix. Use it:

    t.Cleanup(func() { if c, ok := mem.(Closer); ok { c.Close() } })

Reproduced once on this machine after merging main; -count=5 green
after the fix.

Made-with: Cursor
Aligns the new long-term-memory surface (sdk/recall) and the shared
retrieval pipeline with the OTel coverage already in sdk/history
(memory.archive, memory.dag.*). Without these signals the hottest
paths in the refactored memory layer — Add, Recall, the async Save
worker, and every retrieval stage — were a black box in production:
no spans for latency attribution, no counters for error rates, no
warn-level log when an extractor wedged past jobTimeout.

What gets emitted
-----------------

sdk/recall/telemetry.go (new) centralises the meter so suffixes stay
canonical (memory.recall.*, paralleling memory.archive / memory.dag).
Histograms are in seconds; counters carry only enum-style labels
(outcome / stage / reason). Scope.UserID is deliberately never an
attribute — it is PII and would explode metric cardinality.

  Spans:
    memory.recall.add        (write path; child upserts inherit)
    memory.recall.recall     (read path; pipeline stages become children)
    retrieval.stage.<name>   (one per pipeline stage, parented by recall)

  Counters:
    memory.recall.add_total{outcome=success|fail, reason=...}
    memory.recall.recall_total{outcome=success|fail, reason=...}
    memory.recall.job_total{outcome=succeeded|failed|timeout|dead, stage, attempts}
    memory.recall.job_lease_errors_total

  Histograms:
    memory.recall.add_duration       (seconds)
    memory.recall.recall_duration    (seconds)
    memory.recall.recall_hits        (count, per call)
    memory.recall.job_duration       (seconds, extract + upsert)

  Logs (telemetry.Warn, trace-correlated):
    "recall: worker lease failed"
    "recall: async job failed"          (with outcome=timeout|failed)
    "recall: async job dead-lettered"
    "recall: job complete bookkeeping failed"

Notes
-----

- handleJob splits "extractor returned err" from "ctx.DeadlineExceeded"
  via errors.Is, so dashboards can isolate "extractor wedged past
  jobTimeout" from "extractor returned business error". This pairs with
  the per-job timeout added earlier in this branch.
- failOrRetry distinguishes dead-letter (Fail) from transient retry so
  alert thresholds can target the dead path, not the retry noise.
- pipeline.Run keeps the pre-existing StageTrace bookkeeping; the new
  spans are additive and let downstream OTel collectors do the same
  attribution without parsing State.Trace.
- Query text and Scope.UserID are intentionally absent from span
  attributes (PII + cardinality). Only structural shape is captured
  (query_len, top_k, has_user_id, hits).

Verified
--------

  go vet ./recall/... ./retrieval/...     # clean
  go test ./recall/... ./retrieval/... -race -count=1   # all green
  make ci   # full pipeline green, ~40s

Made-with: Cursor
@lIang70

lIang70 commented Apr 23, 2026

Copy link
Copy Markdown
Collaborator Author

Suggested review order

If you only have 30 minutes:

  1. examples/chatbot-with-recall/main.go — read this first. The whole point of this refactor is captured in ~120 LoC of caller code: explicit history.Memory + recall.Memory composition, no glue layer.
  2. sdk/recall/memory.go + save.go + recall.go — the new core API surface (New, Save, Add, Recall, Close).
  3. sdk/history/compactor.go — per-conversation locking, atomic writes, and the new Closer contract.
  4. sdk/recall/jobs.go — async worker lifecycle. The three-tier context model (workerCtxjobCtx with timeout → bookCtx for bookkeeping) is the bit worth scrutinizing.
  5. sdk/retrieval/pipeline/pipeline.go — stage-based retrieval; per-stage child spans for trace tree.
  6. Makefile + bench/ — CI fail-fast fix and the new bench module that's actually wired in.

Things to ignore on first pass

  • Tests under sdk/history/ — most are direct lifts from old sdk/memory, only renames + a couple of durability assertions added.
  • sdkx/recall/jobqueue/sqlite/ — straightforward JobQueue impl backed by SQLite, identical contract to MemoryJobQueue.
  • bench/locomo/ — research evals, no production code path goes through it.

Top three things I want eyes on

  1. recall.Scope losing SessionID (sdk/recall/scope.go) — biggest behavioural break; do you agree session-thread state belongs in history, not recall?
  2. history.Closer as a sub-interface vs adding Close() to History (sdk/history/history.go) — I went with sub-interface so NewBuffer (stateless) doesn't need a no-op Close. Acceptable, or would you rather one big interface?
  3. OTel attribute set on memory.recall.recall (sdk/recall/recall.go) — I deliberately omit query text and Scope.UserID (PII + cardinality). Anything you'd add?

@lIang70
lIang70 merged commit c9afd19 into main Apr 23, 2026
9 checks passed
@lIang70
lIang70 deleted the refactor/memory-ltm-subpackage branch April 23, 2026 14:38
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