Skip to content

feat: add a consolidate command to reconcile duplicate and superseded memories across write lanes#946

Draft
gorkem2020 wants to merge 32 commits into
CortexReach:masterfrom
gorkem2020:feat/memory-consolidate-command
Draft

feat: add a consolidate command to reconcile duplicate and superseded memories across write lanes#946
gorkem2020 wants to merge 32 commits into
CortexReach:masterfrom
gorkem2020:feat/memory-consolidate-command

Conversation

@gorkem2020

@gorkem2020 gorkem2020 commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Summary

The store has no cross-lane consolidation. The same fact can accumulate rows via manual tool writes, reflection writer-1 mapped rows, and the full extraction pipeline, and two existing rows saying the same thing are never reconciled. A preference reversal ("user quit doing X") also never supersedes the rows it contradicts, since plain vector similarity puts a reversal too far from what it contradicts.

This adds a consolidate CLI command, registered under the memory-pro command group, that scans a scope's existing rows and reconciles duplicates, near-duplicates, and reversals across all three write lanes:

memory-pro consolidate --scope <scope> [--apply] [--yes] [--category <cat>] [--since <ISO>] [--include-reflection-slices] [--agent <agentId>]

What changed

Clustering. Seed-based single-hop grouping (every row must be directly linked to the cluster's seed, never merely linked through an intermediate member, so a chain of only-moderately-similar pairs can't bridge unrelated topics) combining embedding cosine similarity, a shared fact_key, and two gated topic-overlap fallbacks: one for reversal-shaped text ("no longer", "stopped", "quit", "doesn't", ...) linking a reversal to what it contradicts, and one for cross-lane near-duplicates that aren't reversal-shaped (a majority token-overlap ratio, not just one shared word, so unrelated short statements don't bridge).

Decision. One batched LLM call per run, not one per cluster, reusing the existing skip / merge / supersede / contradict vocabulary from src/memory-categories.ts. Verdicts are deterministic across repeat runs: temperature 0 on the decide call, candidates and cluster members sorted by row id before both clustering and prompt assembly, and a tightened rubric with an explicit merge-vs-supersede tiebreak. A malformed or out-of-range verdict for one cluster is dropped without discarding the others; a fully malformed response degrades to every cluster skipped.

Non-destructive execution. merge reuses the existing merge-writer prompt (buildMergePrompt). Both merge and supersede now soft-invalidate the absorbed rows (invalidated_at / superseded_by, plus a consolidation_audit trail pointing back at the survivor) rather than deleting. delete was removed from ConsolidateWriteDeps entirely, so no LLM verdict path can hard-delete a row even by future accident; hard delete stays reachable only through the operator-only CLI delete commands, a separate code path.

Append-only shield. events/cases rows are refined from blanket merge-immunity to invalidation-protection: a merge is allowed when every acted-upon row shares the identical append-only category (for example two near-duplicate events rows describing the same occurrence), but a merge mixing an append-only row with a non-append-only row, or with a different append-only category, and any supersede/contradict touching an append-only row, stays blocked unconditionally.

Store-layer invisibility for invalidated rows. excludeInactive now defaults to true across vectorSearch, bm25Search, lexicalFallbackSearch, list, and the compactor's fetchForCompaction path, so invalidated/superseded rows are invisible everywhere by default instead of only where a caller remembered to filter. stats() gains a liveCount alongside the existing totalCount, surfaced in both --json and the human-readable CLI output. The existing export and vault-export paths explicitly opt out (excludeInactive: false) to keep their full-dump, forensic-backup semantics; list and vault-export also gain an --include-invalidated flag for callers who want the historical rows back.

Cost gate. Clustering is free and runs first. Before any LLM call fires, dry-run or --apply alike, a mandatory cost-preview reports real numbers (N clusters -> 1 batched decider call, plus up to M merge-content generations). --yes bypasses the prompt for automation; a declined or missing confirm is a safe abort, never assumed consent.

Two-phase apply. Merge-content generation moved from apply time to plan-build time, so a dry run now builds the complete plan (every verdict plus precomputed merge content) before a single "apply these now?" prompt; confirming executes it as pure store writes with zero further LLM calls. A staleness guard snapshots each member row's metadata at plan-build time and skips, never partially applies, any cluster whose rows changed or disappeared by execution time.

Journal mirroring. --agent <agentId> threads through to the existing mdMirror journal writer's meta.agentId, so applied verdicts land in the invoking agent's own workspace instead of the fallback mirror directory. Omitting it preserves the existing fallback behavior.

admissionControl independence. Verified rather than changed: consolidate.ts and cli.ts never reference admission-control.ts, and admissionControl.enabled has no bearing on whether consolidate's own LLM client gets constructed. Pinned with a regression test using a poison-pill AdmissionController whose every method throws if invoked.

Reflection writer-2 slice rows (category reflection) stay excluded from the scan by default, since they're a separate instructions-to-self lane; --include-reflection-slices opts in. Already-invalidated rows are excluded from future scans, which is what makes --apply idempotent.

Tests

Red-first throughout. test/memory-consolidate.test.mjs covers clustering (cosine, fact_key, both topic-overlap fallbacks), chunking, verdict parsing, both prompt builders, and the full orchestrator end to end. test/invalidated-rows-visibility.test.mjs and test/store-excludeinactive-default.test.mjs cover the store-layer default flip and its fallout across existing call sites (with matching updates to test/store-empty-scope-filter.test.mjs, test/migrate-legacy-schema.test.mjs, and test/temporal-facts.test.mjs). test/memory-consolidate-cost-gate.test.mjs, test/memory-consolidate-two-phase-apply.test.mjs, and test/memory-consolidate-admission-independence.test.mjs cover the cost gate, two-phase apply plus staleness, and admission independence. All new files are registered in both package.json's test chain and scripts/ci-test-manifest.mjs. npm run build (tsc) and the full local npm test chain are green, with one pre-existing, environment-specific test skipped (a host-side port 11434 conflict unrelated to this change). A structural regression test confirms consolidate is attached under the memory-pro group, not the root program.

Notes for reviewers

Fixture rows are entirely synthesized, never copied from a live system. Fixtures modeling real dry-run cluster shapes are paraphrased with all identifying details replaced by a generic "User".

Depends on #945: fetchForCompaction, the row-fetch path this command's clustering scan uses, still drops LanceDB's typed-array vector columns to an empty array on a real store (this branch's own tests use in-memory fixtures with plain-array vectors, so they pass either way). This branch does not rebase onto that fix, to keep the two independently reviewable, but this PR should not be merged before, or without, it.

Batched merge-content writer (2026-07-17)

Plan build now generates merge content for all merge verdicts with one consolidate-merge-batch call per chunk of up to CONSOLIDATE_MERGE_BATCH_MAX_SIZE (10) verdicts, instead of one consolidate-merge call per absorbed member. Each numbered job folds a verdict's survivor plus every absorbed member in one output; the merge requirements text is carried over verbatim, only the call topology changes.

  • Per-item fail-closed matches the sequential fold's failure semantics exactly: a missing or malformed response entry keeps only that job's survivor content unchanged (what the old fold produced when its per-member completions returned null), and a failed chunk call degrades every job in that chunk the same way, never crashing and never fanning back out into per-member calls.
  • Zero merge verdicts make zero writer calls; a single verdict still uses the batch shape.
  • The cost preview now reports the real batched call count: "up to N batched merge-content call(s) covering up to M merge job(s)", where N is ceil(M/10), replacing the per-absorbed-member generation count.

Update: single-source prompt architecture (2026-07-17)

Added this branch's own copy of src/prompt-blocks.ts (the extraction/dedup/merge/consolidate prompt builders live in a duplicated extraction-prompts.ts across sibling branches, so the shared module follows the same convention) and converged every prompt builder in the file onto it:

  • Identity openers: the consolidate decider and consolidate merge writer already opened with their identity; that text is now sourced from the shared module instead of being duplicated inline. The capture-path extraction agent, dedup decider, and merge writer prompts (present in this file but not on the consolidate call path) gain the same identity openers as the sibling admission-batch-utility branch, for consistency at the next assembly.
  • Shared category taxonomy: composed into the consolidate decider and consolidate merge-writer prompts (both singular and batched variants).
  • Markdown payload standard: cluster/member and merge-job payloads move from indentation-only numbered lists to ## Cluster N / ### N. category / plain Label: value field lines / #### Existing memory and #### New information nested subsections. Provenance fields (source, timestamp, valid_from) keep their existing snake_case labels verbatim as plain lines under the new heading. Every JSON output contract is now fenced as a ```json code block.
  • buildDedupPrompt and buildMergePrompt now take the full candidate object instead of separate positional strings, matching the sibling branch's shape.

This is purely a prompt-formatting change; clusterConsolidateCandidates, verdict parsing, and the apply/dry-run execution paths are untouched. Expect an extraction-prompts.ts merge conflict against the admission-batch-utility branch at the next assembly (both branches now carry the identical shared-module content); keep one copy.

Update (2026-07-18)

Rebased onto current master, plus an operator-driven polish round, all live-tested on our fleet:

  • The CLI is now --agent-only: scope auto-derives as agent:<id>, --scope is gone, and journal-mirror writes always route to that agent's workspace.
  • Convergence to zero: clusters decided skip/contradict, and verdicts withheld by the append-only shield, are recorded as member-set fingerprints in dbPath/consolidate-settled.json and dropped before the cost gate on later runs; any member change re-opens its cluster. A fully converged store prints "0 candidates".
  • Shield-blocked verdicts are labeled in both the cluster listing and the apply-prompt plan instead of silently losing their action.
  • Honest failure classing: a decide call that returns no response is one aggregate log line and its own counter, no longer per-cluster "missing or malformed verdict" spam.

@gorkem2020
gorkem2020 force-pushed the feat/memory-consolidate-command branch 3 times, most recently from 842e35a to b47d619 Compare July 17, 2026 21:37
gorkem2020 and others added 27 commits July 18, 2026 18:53
…ion decisions

Split {system, user} prompt reusing the existing skip/merge/supersede/
contradict dedup vocabulary, adapted from candidate-vs-store to row-vs-row
so it fits alongside buildDedupPrompt and buildMergePrompt.
runConsolidate scans a scope's rows, clusters them by embedding cosine
similarity OR shared fact_key (so a low-cosine reversal row still joins
the cluster it contradicts), sends each cluster (chunked to 8 rows) to a
single LLM decision, and executes merge (reusing the existing
buildMergePrompt merge-writer) or supersede (reusing the existing
invalidated_at/superseded_by soft-invalidation fields via
buildSmartMetadata/appendRelation, the same mechanism smart-extractor's
private invalidateSupersededMemory already uses).

Guards: dry-run by default in the orchestrator's apply flag; malformed or
missing LLM verdicts skip just that cluster with a logged warning rather
than failing the run; append-only categories (events/cases) are hard-
blocked from merge/supersede regardless of what the LLM returns; reflection
writer-2 slice rows are excluded from the scan by default
(--include-reflection-slices opts in); already-invalidated rows are
excluded from future scans, which is what makes apply mode idempotent.
Every applied action gets a consolidation_audit metadata trail.
New 'consolidate --scope <scope> [--dry-run|--apply] [--category] [--since]
[--include-reflection-slices]' command, dry-run by default (--apply required
to write). Threads the plugin's real mdMirror writer through CLIContext so
applied merge/supersede actions get a daily-journal line too, composing
with the mirror-reflection-slices work rather than duplicating it.
…ogram

Structural red-first test found the command was registered on the root
commander program (program.command(...)), same as the pre-existing
reindex-fts/repair-summaries pattern I copied from — none of these are
reachable through the memory-pro dispatcher core actually routes.
Reattached to the memory group so it's invoked as
'memory-pro consolidate ...', matching every other command in this file.
… cross-lane wordings

Confirmed against the real deriveFactKey (not a hypothetical) that free-text
reflection-mapped rows and naturally-phrased reversals get a unique derived
fact_key that never matches a smart-extraction row's clean, colon-based key.
Combined with a cosine gap an embedder might introduce for a semantic
reversal, the existing cosine-or-fact_key clustering misses exactly the case
this feature exists to catch.
…-word overlap

deriveFactKey only aligns rows across lanes when they share the exact
'[Merge key]: text' abstract convention. Reflection writer-1 mapped rows
carry no stored fact_key and are free-text LLM summaries with no colon
convention, and a naturally-phrased reversal is typically the same, so
their derived fact_key is effectively the whole unique sentence and never
matches anything.

Added a third, narrowly-gated linking condition alongside cosine and
fact_key: if either row's abstract matches a reversal-signal pattern (no
longer, stopped, quit, doesn't, ...) and the two abstracts share a
significant topic word (a lowercase content token, filtered through a
small stopword list of generic preference-phrasing words), they link.
Gating on the reversal signal keeps this from widening clustering for
ordinary rows that would otherwise rely on cosine or fact_key alone; a
control row about an unrelated topic that happens to also be
reversal-shaped ('user no longer works at Acme Corp') still stays out,
since it shares no topic word with the cluster.

Known tradeoff, not addressed here: the stopword list is a minimal v1 and
could still let two unrelated reversal-shaped rows link if they happen to
share one generic-but-not-stopworded word. Acceptable for this pass since
false links only ever get a downstream LLM decision (skip/contradict is
always available), never an automatic write.
…cts, and clustering precision

Live dry-run ground truth (five clusters, every verdict skip) exposed three
defects:
- the decider treated supersede as destructive, unaware it is soft
  invalidation
- an unreferenced append-only row anywhere in a cluster vetoed acting on
  the rest of it
- union-find transitivity glued unrelated topics into 8-row grab-bag
  clusters, partly through a long multi-topic reversal narrative bridging
  a tight duplicate cluster to unrelated rows via incidental keyword
  overlap

Fixtures are paraphrased from the live output (sanitized: the operator's
real name replaced with 'User', matching every other fixture row).
…dicts, non-transitive clustering

Prompt (src/extraction-prompts.ts): explicitly states supersede preserves
absorbed rows as an auditable historical record (never deletes them),
mirroring buildDedupPrompt's own SUPERSEDE language, and explicitly
permits leaving rows out of survivor_index/absorbed_indices -- unlisted
rows are simply untouched, which lets the decider act on the actionable
subset of a cluster instead of skipping the whole thing over one
unrelated or append-only member.

Runtime guard (src/consolidate.ts): the append-only veto now only
inspects survivor_index and absorbed_indices (the rows actually being
acted on), not every member of the cluster, so a cluster mixing
actionable duplicates with an append-only row can still merge/supersede
the actionable rows while the append-only row stays untouched.

Clustering (src/consolidate.ts): replaced union-find (transitive closure)
with seed-based single-hop grouping -- every row must be DIRECTLY linked
to the cluster's seed (cosine, fact_key, or the topic-overlap fallback),
never merely linked through an intermediate member. This is the same
seed-based shape memory-compactor.ts's buildClusters already uses, and it
is what stops a moderately-similar bridge pair from transitively chaining
two otherwise-unrelated duplicate pairs into one cluster.

The topic-overlap fallback also gained three tightenings: it now requires
both abstracts to be in the same memory category, requires both to be
under a length cap (a long multi-topic narrative recap can incidentally
share a keyword with several unrelated short statements at once, which
is exactly what was gluing unrelated clusters together), and matches
tokens on containment as well as exact equality (so 'cola' and
'coca-cola' are recognized as the same topic across paraphrased lanes).
…adapter

The CLI's completeJson adapter was a 2-param lambda that silently dropped
the system prompt built by buildConsolidatePrompt, so every consolidate
decision ran under the generic extraction system message and produced
extraction-shaped JSON instead of a verdict.

- add an optional systemPrompt param to LlmClient.completeJson (both
  concrete clients), defaulting to the prior generic message when omitted
- pass buildConsolidatePrompt's system/user separately instead of
  concatenating them into one string
- give the merge-writer call its own system prompt instead of reusing the
  decider's
- add a source-provenance legend and per-member timestamp/valid_from to the
  cluster listing so supersede recency is explicit rather than inferred
- collapse identical L0/L1/L2 tiers (common on legacy/mapped/manual rows
  with no real overview/content) into a single Fact: line
Replace the one-completeJson-call-per-cluster loop in runConsolidate
with a single batched call: buildConsolidateBatchPrompt lists every
cluster (with its own 1-based member numbering) in one prompt, and the
decider returns one verdict per cluster tagged by cluster_index.

parseConsolidateBatchVerdicts fails closed per-cluster: an unparseable
or out-of-range entry for one cluster_index is dropped without
discarding the other clusters' verdicts, matching the existing
per-cluster fail-closed behavior. A malformed whole-response (no
verdicts array) still degrades to "every cluster skipped", same as
before.

buildConsolidatePrompt (single-cluster) is left in place, unused by
runConsolidate now but still covered by its own tests.
The consolidate command's onAudit callback already called mdMirror per
applied verdict, but never passed an agentId in meta, so every write
landed in the fallback mirror directory instead of the invoking
agent's own workspace/memory dir. Add --agent <agentId> to the CLI
command and thread it into the mirror call's meta.agentId. Omitting
--agent preserves the existing fallback-directory behavior.
Three levers, all scoped to the consolidate-decide call only:

- temperature 0: LlmClient.completeJson gains an optional temperature
  override (api-key client honors it; the OAuth/responses client has
  no temperature knob and accepts-but-ignores it). Also fixes the
  CLI's completeJson adapter, which silently dropped the argument the
  same way it once dropped the system prompt.
- deterministic prompt assembly: candidates are sorted by row id right
  after filtering, before clustering -- clusterConsolidateCandidates'
  seed-based scan always seeds from the lowest surviving index, so a
  pre-sorted input makes both cluster composition and member order a
  pure function of the candidate set, independent of fetchRows'
  ordering. Units are re-sorted by id again at prompt-assembly time as
  a defense-in-depth backstop.
- tightened rubric: explicit ordered decision criteria per verdict,
  plus a prefer-supersede tiebreak for ambiguous merge-vs-supersede
  cases (only in the batch prompt; the unused single-cluster prompt is
  untouched).
…rsal-shaped

Three near-duplicate rows stating the same fact in different write
lanes (e.g. strict "[Key]: text" convention vs free-text
reflection-mapped prose vs a casual auto-capture paraphrase) never
clustered with each other: cosine and fact_key both miss on
cross-lane tokenization drift, and the existing topic-overlap
fallback only fires when at least one side of a pair is
reversal-shaped, so a contradicting row could link to at most one of
the plain duplicates (whichever the seed-based scan reached first),
stranding the rest.

Add a second, separate topic-overlap fallback gated on a majority
token-overlap ratio (>=60% of the smaller row's significant topic
tokens) rather than the single-shared-token check used for the
reversal case. A ratio requirement (instead of "any shared word")
is what keeps two short but topically different statements from
bridging, since true paraphrases of one narrow fact share most of
their significant words while unrelated statements rarely do.

Candidate detection is intentionally not append-only-guarded here
(consistent with existing clustering behavior); resolution still
refuses to act on events/cases rows via the existing APPEND_ONLY_CATEGORIES
check in the apply loop.
applyMergeVerdict hard-deleted absorbed rows via deps.delete. Absorbed
rows are now soft-invalidated with the same primitive
applySupersedeVerdict uses (invalidated_at + superseded_by + a
relations entry), plus their own consolidation_audit pointing back at
the survivor. --apply stays idempotent: invalidated rows are already
excluded from candidacy (isMemoryActiveAt), so a second run is a
no-op.

Remove `delete` from ConsolidateWriteDeps entirely, and from the CLI's
wiring, so no LLM verdict path can hard-delete a row even by future
accident -- hard delete stays reachable only via the operator-only
CLI delete/delete-bulk commands, a separate code path. A grep across
smart-extractor.ts/memory-upgrader.ts/dreaming-engine.ts confirms none
of the other LLM-decision pipelines called delete either; consolidate
was the only offender.
…choke point

Item 6: flip excludeInactive from opt-in-false to opt-in-TRUE at the
store-layer choke point (vectorSearch/bm25Search/lexicalFallbackSearch),
and add the same option (default true) to list()/fetchForCompaction(),
which had no invalidation awareness at all. getById stays unfiltered
by design (supersede/merge and single-row tool handlers need it).

This is a default flip, not new filtering logic, so most leaking
callers (admission-control's novelty gate, smart-extractor's profile
search, index.ts's auto-capture/mapped-reflection dup pre-checks, the
reflection-slice loader, memory-compactor's background fetch, cli.ts's
import-markdown dedup) are fixed automatically: they call the affected
methods with no options object and inherit the new default with zero
code changes. Verified each by reading the call site (none pass an
explicit excludeInactive:false override) and by an end-to-end test
proving the admission-control novelty gate no longer compares a
candidate against an invalidated row.

Explicit exceptions (keep full-dump semantics): CLI export and the
plugin's automated backup dump now pass {excludeInactive:false}
explicitly, since flipping the default would otherwise silently make
backups incomplete.

Explicit opt-ins added for forensic reads: CLI list/obsidian gain
--include-invalidated; the memory_list/memory_compact tools gain an
includeInvalidated param mirroring memory_fact_query's existing
includeHistory shape. stats() now reports both totalCount (blended)
and liveCount (excludeInactive-filtered).

Punted (noted, not implemented): a --include-invalidated opt-in for
CLI search and an includeInvalidated param for memory_debug. Both
route through retriever.ts, which hardcodes excludeInactive:true at
5 internal vectorSearch/bm25Search call sites inside private helper
methods (vectorOnlyRetrieval/hybridRetrieval/bm25OnlyRetrieval) --
threading a caller override through would touch the primary
recall/prompt-injection path, materially higher risk than the rest of
this change for a forensic-only nice-to-have. Both already default
correctly (excludeInactive:true), covered by tests; only the opt-in
flag is missing.

Two pre-existing tests updated to reflect the new default (not
regressions in the new logic): migrate-legacy-schema.test.mjs used a
timestamp value that normalizes to a future date after the seconds-
to-ms heuristic, which the new excludeInactive default correctly
treats as not-yet-active; temporal-facts.test.mjs explicitly checks
that supersede preserves history, which now needs excludeInactive:false
to see the invalidated row it's asserting on.
Item 7: clustering (free) now runs ahead of a mandatory cost-preview gate
before any LLM call fires, covering both dry-run and --apply. The preview
reports real numbers (N clusters -> 1 batched decider call, + up to M
merge-content generations, computed from clustering alone). --yes bypasses
it for automation; a declined or missing confirm is a safe abort, never
assumed consent.

Item 8: merge-content generation moves from apply time to plan-build time,
so a dry run now builds the COMPLETE plan (verdicts + exact precomputed
merge content) and presents it before a single "Apply these now?" prompt.
Confirming executes the plan as pure store writes with zero further LLM
calls. Direct --apply keeps executing immediately (gate -> plan -> write,
no second prompt). A staleness guard snapshots each member row's metadata
at plan-build time and skips (never partially applies) any cluster whose
rows changed or disappeared by execution time.

Implemented together since both live in the same runConsolidate control
flow; committing as one unit rather than splitting an interdependent diff
into two non-compiling halves.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…pendence

Runs the full consolidate flow (cluster -> --yes gate -> batched decider ->
merge-content plan -> apply) through the real CLI action with
admissionControl.enabled:false in config and a poison-pill AdmissionController
whose every method throws if ever invoked. Passes with zero source changes:
static (grep) and dynamic (SmartExtractor construction path) inspection both
confirm consolidate.ts and cli.ts never reference admission-control.ts at
all, and admissionControl.enabled has no bearing on whether the LLM client
consolidate depends on gets constructed. No hidden coupling found; nothing
to decouple.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Append-only means invalidation-protection, not merge-immunity: the
guard blocked every merge/supersede touching an events/cases row
unconditionally, even two byte-near duplicate rows in the exact same
append-only category (e.g. two "events" rows describing the same
occurrence). Refine the code guard: merge is now allowed when every
acted-upon row shares the identical append-only category; a merge
that would mix an append-only row with a non-append-only row or with
a different append-only category stays blocked, and supersede/
contradict stay blocked unconditionally regardless of category
match, since those invalidate the absorbed row's currency.

Update both decider prompts (buildConsolidatePrompt and
buildConsolidateBatchPrompt) to describe the same rubric, and add a
human-readable live/total split to `memory-pro stats`, matching what
--json already exposed via store.stats()'s liveCount field.
…ontent call

Plan build now generates merge content for ALL merge verdicts with a single
consolidate-merge-batch call per chunk of up to
CONSOLIDATE_MERGE_BATCH_MAX_SIZE (10) verdicts, instead of one
consolidate-merge call per absorbed member. Each numbered job folds a
verdict's survivor plus every absorbed member in one output;
buildConsolidateBatchMergePrompt keeps CONSOLIDATE_MERGE_SYSTEM_PROMPT's
merge requirements verbatim and renders jobs as numbered blocks (fields
indented, content-carried list markers stripped).

Per-item fail-closed matches the sequential fold's failure semantics
exactly: a missing or malformed response entry degrades only that job to
the survivor's own unmodified content (what the old fold produced when its
per-member completions returned null), and a failed chunk call degrades
every job in that chunk the same way - never a crash, never a fan-out into
per-member calls. Zero merge verdicts make zero writer calls; a single
verdict still uses the batch shape.

The cost preview now reports the real batched call count: 'up to
ceil(M/10) batched merge-content call(s) covering up to M merge job(s)'
replaces the per-absorbed-member generation count.
Slot-conformance pins for the two batched consolidate prompts
(consolidate-decide, consolidate-merge-batch): identity, decision/merge
rules, the source legend, and the JSON output contract live in the
SYSTEM slot; the USER slot carries only the numbered cluster member and
merge job data. Both builders already conform and both call sites
already deliver a real split (completeJson(user, label, system)); these
assertions keep static text from drifting into the user slot. The
merge-batch pin was mutation-verified: leaking a static sentinel into
the user slot fails the new assertion.
…eld-blocked visibility, honest failure classing

Operator-specced polish round:
- consolidate now takes a required --agent and derives scope agent:<id>;
  --scope is gone, and journal-mirror writes always route to that agent's
  workspace.
- clusters decided skip/contradict or withheld by the append-only shield
  are recorded in a settled ledger (dbPath/consolidate-settled.json) as
  member-set+content fingerprints; later runs drop them before the cost
  gate, so repeated runs over an unchanged store converge to
  '0 candidates'. Any member change re-opens its cluster.
- shield-blocked verdicts are marked in both the cluster listing and the
  apply-prompt plan instead of silently losing their action.
- a decide call that returns no response is classed call-failed with one
  aggregate log line and its own result counter, no longer surfaced as
  per-cluster 'missing or malformed verdict' spam.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 1fa421f)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gorkem2020
gorkem2020 force-pushed the feat/memory-consolidate-command branch from b47d619 to 16be783 Compare July 18, 2026 15:53
stats() now reports liveCount alongside totalCount (soft-invalidation
keeps superseded rows in the store), so the strict-equality expectations
gain the new field.
…erdict plan, real plurals, non-error cancel)

(cherry picked from commit c14a4944796c342730bd3b00cea153120a9a9188)
…ry decider doctrine

(cherry picked from commit d66416ae87800a220de77cee41694432c9d8f4ff)
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