feat: add a consolidate command to reconcile duplicate and superseded memories across write lanes#946
Draft
gorkem2020 wants to merge 32 commits into
Draft
Conversation
gorkem2020
force-pushed
the
feat/memory-consolidate-command
branch
3 times, most recently
from
July 17, 2026 21:37
842e35a to
b47d619
Compare
…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
force-pushed
the
feat/memory-consolidate-command
branch
from
July 18, 2026 15:53
b47d619 to
16be783
Compare
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
consolidateCLI command, registered under thememory-procommand group, that scans a scope's existing rows and reconciles duplicates, near-duplicates, and reversals across all three write lanes: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 / contradictvocabulary fromsrc/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.
mergereuses the existing merge-writer prompt (buildMergePrompt). Bothmergeandsupersedenow soft-invalidate the absorbed rows (invalidated_at/superseded_by, plus aconsolidation_audittrail pointing back at the survivor) rather than deleting.deletewas removed fromConsolidateWriteDepsentirely, 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/casesrows 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-duplicateeventsrows 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.
excludeInactivenow defaults totrueacrossvectorSearch,bm25Search,lexicalFallbackSearch,list, and the compactor'sfetchForCompactionpath, so invalidated/superseded rows are invisible everywhere by default instead of only where a caller remembered to filter.stats()gains aliveCountalongside the existingtotalCount, surfaced in both--jsonand the human-readable CLI output. The existingexportand vault-export paths explicitly opt out (excludeInactive: false) to keep their full-dump, forensic-backup semantics;listand vault-export also gain an--include-invalidatedflag for callers who want the historical rows back.Cost gate. Clustering is free and runs first. Before any LLM call fires, dry-run or
--applyalike, a mandatory cost-preview reports real numbers (N clusters -> 1 batched decider call, plus up to M merge-content generations).--yesbypasses 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 existingmdMirrorjournal writer'smeta.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.tsandcli.tsnever referenceadmission-control.ts, andadmissionControl.enabledhas no bearing on whether consolidate's own LLM client gets constructed. Pinned with a regression test using a poison-pillAdmissionControllerwhose 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-slicesopts in. Already-invalidated rows are excluded from future scans, which is what makes--applyidempotent.Tests
Red-first throughout.
test/memory-consolidate.test.mjscovers 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.mjsandtest/store-excludeinactive-default.test.mjscover the store-layer default flip and its fallout across existing call sites (with matching updates totest/store-empty-scope-filter.test.mjs,test/migrate-legacy-schema.test.mjs, andtest/temporal-facts.test.mjs).test/memory-consolidate-cost-gate.test.mjs,test/memory-consolidate-two-phase-apply.test.mjs, andtest/memory-consolidate-admission-independence.test.mjscover the cost gate, two-phase apply plus staleness, and admission independence. All new files are registered in bothpackage.json's test chain andscripts/ci-test-manifest.mjs.npm run build(tsc) and the full localnpm testchain are green, with one pre-existing, environment-specific test skipped (a host-side port 11434 conflict unrelated to this change). A structural regression test confirmsconsolidateis attached under thememory-progroup, 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-batchcall per chunk of up toCONSOLIDATE_MERGE_BATCH_MAX_SIZE(10) verdicts, instead of oneconsolidate-mergecall 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.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 duplicatedextraction-prompts.tsacross sibling branches, so the shared module follows the same convention) and converged every prompt builder in the file onto it:## Cluster N/### N. category/ plainLabel: valuefield lines /#### Existing memoryand#### New informationnested 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.buildDedupPromptandbuildMergePromptnow 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 anextraction-prompts.tsmerge 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:
--agent-only: scope auto-derives asagent:<id>,--scopeis gone, and journal-mirror writes always route to that agent's workspace.dbPath/consolidate-settled.jsonand dropped before the cost gate on later runs; any member change re-opens its cluster. A fully converged store prints "0 candidates".