@gamaze/hicortex 0.14.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -157,6 +157,8 @@ npx @gamaze/hicortex nightly # Run distill + consolidate (full
157
157
  npx @gamaze/hicortex nightly --capture-only # Capture only, skip consolidation (safe for sub-daily runs)
158
158
  npx @gamaze/hicortex nightly --dry-run # Preview without changes
159
159
  npx @gamaze/hicortex classify-domains # Backfill domain tags over the corpus (see Memory Domains & Tags)
160
+ npx @gamaze/hicortex dedup # Preview near-duplicate memory clusters (dry run, no changes)
161
+ npx @gamaze/hicortex dedup --apply # Merge near-duplicate clusters (backs up the DB first)
160
162
  npx @gamaze/hicortex context show [name] # Print the standing context layer (see Context Layer)
161
163
  npx @gamaze/hicortex context edit <name> # Edit a context section in $EDITOR
162
164
  npx @gamaze/hicortex context show --agent <id> # Show a specific agent's resolved context (0.13)
@@ -215,6 +217,10 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
215
217
  | `recallMinSimilarity` | Relevance floor for index entries (default: 0.55; text-search matches always pass) |
216
218
  | `recallReshowTurns` | Turns before an already-shown memory may reappear in the same session (default: 30) |
217
219
  | `recallMinPromptChars` | Prompts shorter than this skip the recall index (default: 20) |
220
+ | `dedupMergeThreshold` | Minimum cosine similarity for `hicortex dedup` to cluster memories as near-duplicates (default: 0.92) |
221
+ | `supersessionMinSimilarity` | Minimum cosine similarity for a nightly supersession candidate pair (default: 0.80) |
222
+ | `supersessionMaxCalls` | Max classify-tier LLM calls the nightly's supersession stage spends per run (default: 30) |
223
+ | `supersessionPenalty` | Multiplier applied to a superseded memory's `base_strength` (default: 0.5) |
218
224
  | `telemetry` | Anonymous usage telemetry, `false` to opt out |
219
225
 
220
226
  Full docs: [hicortex.gamaze.com/docs/configuration.html](https://hicortex.gamaze.com/docs/configuration.html)
package/dist/cli.d.ts CHANGED
@@ -9,6 +9,8 @@
9
9
  * nightly --capture-only Capture only, skip consolidation
10
10
  * nightly --status Show nightly pipeline health check
11
11
  * relink Resumable link-discovery pass over the entire corpus (issue #143)
12
+ * dedup Cluster + merge near-duplicate memories (issue #100)
13
+ * dedup --apply Execute the merge (default: dry run)
12
14
  * status Show config, DB stats, adapter status
13
15
  * uninstall Clean removal of CC integration
14
16
  */
package/dist/cli.js CHANGED
@@ -10,6 +10,8 @@
10
10
  * nightly --capture-only Capture only, skip consolidation
11
11
  * nightly --status Show nightly pipeline health check
12
12
  * relink Resumable link-discovery pass over the entire corpus (issue #143)
13
+ * dedup Cluster + merge near-duplicate memories (issue #100)
14
+ * dedup --apply Execute the merge (default: dry run)
13
15
  * status Show config, DB stats, adapter status
14
16
  * uninstall Clean removal of CC integration
15
17
  */
@@ -134,6 +136,40 @@ switch (command) {
134
136
  });
135
137
  break;
136
138
  }
139
+ case "dedup": {
140
+ const args = process.argv.slice(3);
141
+ let threshold;
142
+ try {
143
+ const raw = (0, cli_args_js_1.readValueFlag)(args, "--threshold");
144
+ if (raw !== undefined) {
145
+ threshold = parseFloat(raw);
146
+ if (isNaN(threshold)) {
147
+ console.error("[hicortex] dedup: --threshold requires a numeric value, e.g. --threshold 0.9");
148
+ process.exit(1);
149
+ }
150
+ }
151
+ }
152
+ catch {
153
+ console.error("[hicortex] dedup: --threshold requires a value, e.g. --threshold 0.9");
154
+ process.exit(1);
155
+ }
156
+ let dbPath;
157
+ try {
158
+ dbPath = (0, cli_args_js_1.readValueFlag)(args, "--db");
159
+ }
160
+ catch {
161
+ console.error("[hicortex] dedup: --db requires a path value");
162
+ process.exit(1);
163
+ }
164
+ const dedupOptions = { apply: args.includes("--apply"), threshold, dbPath };
165
+ import("./dedup.js").then(({ runDedup }) => {
166
+ runDedup(dedupOptions).catch((err) => {
167
+ console.error(err instanceof Error ? err.message : `[hicortex] dedup failed: ${err}`);
168
+ process.exit(1);
169
+ });
170
+ });
171
+ break;
172
+ }
137
173
  case "context": {
138
174
  // Standing context layer edit surface (spec §6): show|edit against the
139
175
  // configured server. Secondary to the /context/ui Web UI; for headless boxes.
@@ -205,6 +241,7 @@ Commands:
205
241
  Pass --agent-name "" to clear it back to global
206
242
  nightly Run nightly denoise + capture + consolidate
207
243
  relink Resumable link-discovery pass over the ENTIRE corpus (server mode)
244
+ dedup Cluster + merge near-duplicate memories (server mode; dry run by default)
208
245
  classify-domains Backfill content-based domain tags over the corpus (server mode, needs config.domains)
209
246
  lessons-context Fetch lessons and print Markdown to stdout (CC SessionStart hook)
210
247
  recall-hook Pushed recall index for the current prompt (CC UserPromptSubmit/SessionStart hook)
@@ -222,6 +259,9 @@ Options:
222
259
  relink --dry-run Discovery + counts only, zero writes, cursor untouched
223
260
  relink --batch <n> Memories per batch (default: 200)
224
261
  relink --reset Restart from the beginning (ignore saved cursor)
262
+ dedup --apply Execute the merge (default: dry run, report only)
263
+ dedup --threshold <t> Override config dedupMergeThreshold for one run
264
+ dedup --db <path> DB path override (defaults to the configured DB)
225
265
  classify-domains --all Reclassify every memory (default: only NULL/stale-domain rows)
226
266
  classify-domains --batch <n> Memories per batch (default: 200)
227
267
  classify-domains --reset Restart from the beginning (ignore saved cursor)
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Shared vector-cluster utilities: union-find clustering + KNN edge building.
3
+ *
4
+ * Originally lived entirely in src/eval/dups.ts (the #191 D1 duplicate-rate
5
+ * audit). Extracted so `hicortex dedup` (#100) reuses the EXACT same
6
+ * clustering math instead of re-implementing it — the read-only audit and the
7
+ * live merge command must agree on what a "cluster" is.
8
+ */
9
+ import type Database from "better-sqlite3";
10
+ /** An undirected similarity edge between two memory ids. */
11
+ export interface Edge {
12
+ a: string;
13
+ b: string;
14
+ cosine: number;
15
+ }
16
+ /** Minimal union-find over string ids, with path compression. */
17
+ export declare class UnionFind {
18
+ private parent;
19
+ find(x: string): string;
20
+ union(a: string, b: string): void;
21
+ /** All known nodes grouped by cluster root (includes singletons). */
22
+ clusters(): Map<string, string[]>;
23
+ }
24
+ /** Build clusters (size >= 2) from edges at/above a cosine threshold. */
25
+ export declare function clusterEdges(edges: Edge[], threshold: number): string[][];
26
+ /** Excess = sum(cluster size − 1) — rows that would disappear if every cluster merged to one. */
27
+ export declare function clusterExcess(clusters: string[][]): number;
28
+ /** The three metadata fields a merge candidate cluster must agree on. */
29
+ export interface ClusterMetaRow {
30
+ project: string | null;
31
+ privacy: string;
32
+ source_agent: string;
33
+ }
34
+ export interface ClusterMetadataMismatch {
35
+ projectMismatch: boolean;
36
+ privacyMismatch: boolean;
37
+ sourceAgentMismatch: boolean;
38
+ }
39
+ /** Do cluster members disagree on project/privacy/source_agent? (merge-safety input for #100). */
40
+ export declare function clusterMetadataMismatch(members: ClusterMetaRow[]): ClusterMetadataMismatch;
41
+ /**
42
+ * Build the max-cosine edge set via top-K KNN on `memory_vectors`, keeping
43
+ * only pairs at/above `minCosine`. Shared by the #191 audit (which then
44
+ * clusters at several report thresholds) and `hicortex dedup` (a single merge
45
+ * threshold) — same query, same math, so the two never disagree on what
46
+ * counts as a near-duplicate pair.
47
+ */
48
+ export declare function buildKnnEdges(db: Database.Database, opts: {
49
+ k?: number;
50
+ minCosine: number;
51
+ }): Edge[];
@@ -0,0 +1,118 @@
1
+ "use strict";
2
+ /**
3
+ * Shared vector-cluster utilities: union-find clustering + KNN edge building.
4
+ *
5
+ * Originally lived entirely in src/eval/dups.ts (the #191 D1 duplicate-rate
6
+ * audit). Extracted so `hicortex dedup` (#100) reuses the EXACT same
7
+ * clustering math instead of re-implementing it — the read-only audit and the
8
+ * live merge command must agree on what a "cluster" is.
9
+ */
10
+ Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.UnionFind = void 0;
12
+ exports.clusterEdges = clusterEdges;
13
+ exports.clusterExcess = clusterExcess;
14
+ exports.clusterMetadataMismatch = clusterMetadataMismatch;
15
+ exports.buildKnnEdges = buildKnnEdges;
16
+ const retrieval_js_1 = require("./retrieval.js");
17
+ // ---------------------------------------------------------------------------
18
+ // Union-Find (pure — unit tested)
19
+ // ---------------------------------------------------------------------------
20
+ /** Minimal union-find over string ids, with path compression. */
21
+ class UnionFind {
22
+ parent = new Map();
23
+ find(x) {
24
+ if (!this.parent.has(x)) {
25
+ this.parent.set(x, x);
26
+ return x;
27
+ }
28
+ let root = x;
29
+ while (this.parent.get(root) !== root) {
30
+ root = this.parent.get(root);
31
+ }
32
+ let cur = x;
33
+ while (this.parent.get(cur) !== root) {
34
+ const next = this.parent.get(cur);
35
+ this.parent.set(cur, root);
36
+ cur = next;
37
+ }
38
+ return root;
39
+ }
40
+ union(a, b) {
41
+ const ra = this.find(a);
42
+ const rb = this.find(b);
43
+ if (ra !== rb)
44
+ this.parent.set(ra, rb);
45
+ }
46
+ /** All known nodes grouped by cluster root (includes singletons). */
47
+ clusters() {
48
+ const groups = new Map();
49
+ for (const node of this.parent.keys()) {
50
+ const root = this.find(node);
51
+ const arr = groups.get(root) ?? [];
52
+ arr.push(node);
53
+ groups.set(root, arr);
54
+ }
55
+ return groups;
56
+ }
57
+ }
58
+ exports.UnionFind = UnionFind;
59
+ /** Build clusters (size >= 2) from edges at/above a cosine threshold. */
60
+ function clusterEdges(edges, threshold) {
61
+ const uf = new UnionFind();
62
+ for (const e of edges) {
63
+ if (e.cosine >= threshold)
64
+ uf.union(e.a, e.b);
65
+ }
66
+ return [...uf.clusters().values()].filter((g) => g.length >= 2);
67
+ }
68
+ /** Excess = sum(cluster size − 1) — rows that would disappear if every cluster merged to one. */
69
+ function clusterExcess(clusters) {
70
+ return clusters.reduce((sum, c) => sum + (c.length - 1), 0);
71
+ }
72
+ /** Do cluster members disagree on project/privacy/source_agent? (merge-safety input for #100). */
73
+ function clusterMetadataMismatch(members) {
74
+ const projects = new Set(members.map((m) => m.project ?? "\u0000null"));
75
+ const privacies = new Set(members.map((m) => m.privacy));
76
+ const agents = new Set(members.map((m) => m.source_agent));
77
+ return {
78
+ projectMismatch: projects.size > 1,
79
+ privacyMismatch: privacies.size > 1,
80
+ sourceAgentMismatch: agents.size > 1,
81
+ };
82
+ }
83
+ // ---------------------------------------------------------------------------
84
+ // KNN edge building
85
+ // ---------------------------------------------------------------------------
86
+ /** Neighbors requested per memory (excluding the memory itself), unless overridden. */
87
+ const DEFAULT_KNN_K = 10;
88
+ /**
89
+ * Build the max-cosine edge set via top-K KNN on `memory_vectors`, keeping
90
+ * only pairs at/above `minCosine`. Shared by the #191 audit (which then
91
+ * clusters at several report thresholds) and `hicortex dedup` (a single merge
92
+ * threshold) — same query, same math, so the two never disagree on what
93
+ * counts as a near-duplicate pair.
94
+ */
95
+ function buildKnnEdges(db, opts) {
96
+ const k = opts.k ?? DEFAULT_KNN_K;
97
+ const vectorRows = db.prepare("SELECT id, embedding FROM memory_vectors").all();
98
+ const knnStmt = db.prepare("SELECT id, distance FROM memory_vectors WHERE embedding MATCH ? AND k = ? ORDER BY distance");
99
+ const edgeMap = new Map(); // "a|b" (a < b) -> max cosine seen from either direction
100
+ for (const row of vectorRows) {
101
+ const neighbors = knnStmt.all(row.embedding, k + 1);
102
+ for (const n of neighbors) {
103
+ if (n.id === row.id)
104
+ continue;
105
+ const cosine = (0, retrieval_js_1.l2ToCosine)(n.distance);
106
+ if (cosine < opts.minCosine)
107
+ continue;
108
+ const key = row.id < n.id ? `${row.id}|${n.id}` : `${n.id}|${row.id}`;
109
+ const existing = edgeMap.get(key);
110
+ if (existing === undefined || cosine > existing)
111
+ edgeMap.set(key, cosine);
112
+ }
113
+ }
114
+ return [...edgeMap.entries()].map(([key, cosine]) => {
115
+ const [a, b] = key.split("|");
116
+ return { a, b, cosine };
117
+ });
118
+ }
@@ -131,6 +131,68 @@ export declare function classifyLinkCandidates(candidates: LinkCandidate[], _llm
131
131
  * boundary from the l2ToCosine calibration is preserved.
132
132
  */
133
133
  export declare function classifyRelationship(source: Memory, target: Memory, similarity: number): string;
134
+ /** Default minimum COSINE similarity for a supersession candidate pair. */
135
+ export declare const DEFAULT_SUPERSESSION_MIN_SIMILARITY = 0.8;
136
+ /** Default max classify-tier LLM calls (pairs evaluated) spent per nightly run. */
137
+ export declare const DEFAULT_SUPERSESSION_MAX_CALLS = 30;
138
+ /** Default multiplier applied to a superseded memory's base_strength. */
139
+ export declare const DEFAULT_SUPERSESSION_PENALTY = 0.5;
140
+ export interface SupersessionOptions {
141
+ minSimilarity?: number;
142
+ maxCalls?: number;
143
+ penalty?: number;
144
+ }
145
+ export interface SupersessionStageResult {
146
+ scanned: number;
147
+ evaluated: number;
148
+ superseded: number;
149
+ skipped_infra: number;
150
+ skipped_idempotent: number;
151
+ cursor: number;
152
+ }
153
+ /**
154
+ * Build the constrained supersession-check prompt. Content is truncated the
155
+ * same width as domain-classify.ts's classifier (1500 chars) — this is a
156
+ * classify-tier call with the same cost profile.
157
+ */
158
+ export declare function buildSupersessionPrompt(oldContent: string, newContent: string): string;
159
+ /**
160
+ * Parse the model's supersession verdict. Returns the boolean on a valid
161
+ * reply, or null on anything unparseable (caller skips the pair — no retry,
162
+ * unlike domain-classify's tag classifier; a missed pair is retried naturally
163
+ * when this stage revisits the corpus).
164
+ */
165
+ export declare function parseSupersessionReply(reply: string): boolean | null;
166
+ /**
167
+ * Nightly supersession-detection stage. Scans memories/rowid > cursor whose
168
+ * shape suggests a decision/correction, checks each against its older
169
+ * same-shape neighbors, and links confirmed supersessions. Dry-run performs
170
+ * discovery + the free idempotency check only — no LLM calls, no writes, no
171
+ * cursor persistence (mirrors stageImportance/stageContentDomains's dry-run
172
+ * convention of never spending budget on a preview).
173
+ *
174
+ * Cursor discipline is DELIBERATELY simple (owner amendment): the cursor
175
+ * advances past a candidate once its neighbor set has been considered,
176
+ * REGARDLESS of whether every pair got an LLM call (call budget) or a clean
177
+ * verdict (infra skip) — missing one pair is acceptable and self-heals next
178
+ * time this memory's neighborhood is re-examined via a NEWER memory's own
179
+ * candidacy. It only stops SHORT of a candidate when the budget is already
180
+ * exhausted before that candidate starts, so the cursor never skips a
181
+ * candidate that was never looked at.
182
+ */
183
+ export declare function stageSupersession(db: Database.Database, llm: LlmClient, budget: BudgetTracker, embedFn: EmbedFn, dryRun: boolean, stateDir: string | undefined, options?: SupersessionOptions): Promise<SupersessionStageResult>;
184
+ /**
185
+ * Exported for the #191 eval baseline (src/eval/decay-eval.ts) so the audit
186
+ * runs the REAL production prune predicate against a DB snapshot instead of
187
+ * reimplementing it. `dryRun=true` performs reads only (candidates are
188
+ * counted, nothing is deleted) — safe against a readonly snapshot connection.
189
+ * Not otherwise part of the public API surface.
190
+ */
191
+ export declare function stageDecayPrune(db: Database.Database, dryRun: boolean): {
192
+ candidates: number;
193
+ pruned: number;
194
+ failed: number;
195
+ };
134
196
  /**
135
197
  * Run the full consolidation pipeline. Returns a structured report.
136
198
  */
@@ -156,7 +218,7 @@ export interface DomainStageOptions {
156
218
  */
157
219
  weakPrimaryFloor?: number;
158
220
  }
159
- export declare function runConsolidation(db: Database.Database, llm: LlmClient, embedFn: EmbedFn, dryRun?: boolean, skipReflection?: boolean, stateDir?: string, domainOptions?: DomainStageOptions): Promise<ConsolidationReport>;
221
+ export declare function runConsolidation(db: Database.Database, llm: LlmClient, embedFn: EmbedFn, dryRun?: boolean, skipReflection?: boolean, stateDir?: string, domainOptions?: DomainStageOptions, supersessionOptions?: SupersessionOptions): Promise<ConsolidationReport>;
160
222
  /**
161
223
  * Calculate milliseconds until the next occurrence of a given hour (local time).
162
224
  */
@@ -38,13 +38,17 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  };
39
39
  })();
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
- exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = void 0;
41
+ exports.DEFAULT_SUPERSESSION_PENALTY = exports.DEFAULT_SUPERSESSION_MAX_CALLS = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = void 0;
42
42
  exports.isContradictionCandidate = isContradictionCandidate;
43
43
  exports.parseJsonLenient = parseJsonLenient;
44
44
  exports.rebuildContentModuleIndex = rebuildContentModuleIndex;
45
45
  exports.discoverLinkCandidates = discoverLinkCandidates;
46
46
  exports.classifyLinkCandidates = classifyLinkCandidates;
47
47
  exports.classifyRelationship = classifyRelationship;
48
+ exports.buildSupersessionPrompt = buildSupersessionPrompt;
49
+ exports.parseSupersessionReply = parseSupersessionReply;
50
+ exports.stageSupersession = stageSupersession;
51
+ exports.stageDecayPrune = stageDecayPrune;
48
52
  exports.runConsolidation = runConsolidation;
49
53
  exports.msUntilHour = msUntilHour;
50
54
  exports.scheduleConsolidation = scheduleConsolidation;
@@ -822,8 +826,228 @@ function stageHubBoost(db, dryRun) {
822
826
  return { hubs_found: hubs.length, boosted };
823
827
  }
824
828
  // ---------------------------------------------------------------------------
829
+ // Stage 3.7: Supersession Detection (#191 Phase B)
830
+ // ---------------------------------------------------------------------------
831
+ //
832
+ // A later decision/correction can reverse, replace, or invalidate an earlier
833
+ // one — e.g. "chose Ollama for distillation" superseded a month later by
834
+ // "switched distillation to the MBP's 35B model over Tailscale". Left
835
+ // unlinked, retrieval and lesson selection can surface the stale one. This
836
+ // stage links OLD → NEW with relationship `superseded_by` and accelerates the
837
+ // old memory's decay, WITHOUT deleting it (unlike `hicortex dedup`'s merge —
838
+ // this is a judgment call about content, not a duplicate).
839
+ //
840
+ // Scope: memories with `rowid > supersessionCursor` (state.json; starts 0 —
841
+ // the corpus is back-processed gradually, config `supersessionMaxCalls` LLM
842
+ // calls per night) whose shape suggests a decision/correction. For each,
843
+ // KNN top-5 OLDER same-shape neighbors at/above `supersessionMinSimilarity`;
844
+ // one constrained classify-tier LLM call per pair decides `superseded: true|
845
+ // false`. A parse/infra error skips just that PAIR (retried naturally next
846
+ // night since the cursor still advances past the memory — see the cursor
847
+ // note below); it never mis-links.
848
+ /** Default minimum COSINE similarity for a supersession candidate pair. */
849
+ exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = 0.8;
850
+ /** Default max classify-tier LLM calls (pairs evaluated) spent per nightly run. */
851
+ exports.DEFAULT_SUPERSESSION_MAX_CALLS = 30;
852
+ /** Default multiplier applied to a superseded memory's base_strength. */
853
+ exports.DEFAULT_SUPERSESSION_PENALTY = 0.5;
854
+ /** Floor under which a superseded memory's base_strength never drops. */
855
+ const SUPERSESSION_STRENGTH_FLOOR = 0.1;
856
+ /** Neighbor pool size before shape/older/similarity filtering narrows to top 5. */
857
+ const SUPERSESSION_NEIGHBOR_POOL = 15;
858
+ /** Older-neighbor pairs kept per candidate after filtering. */
859
+ const SUPERSESSION_NEIGHBOR_TOP_K = 5;
860
+ /** Candidate rows read per SQL page (call budget stops the loop well before this in practice). */
861
+ const SUPERSESSION_BATCH_SIZE = 500;
862
+ /** A memory whose content/type marks it as a decision or correction. */
863
+ function isDecisionShape(mem) {
864
+ return (mem.memory_type === "decision" ||
865
+ mem.content.includes("[Decisions Made]") ||
866
+ mem.content.includes("[Corrections & Rejections]"));
867
+ }
868
+ /** True when a `superseded_by` link already exists between the pair, either direction. */
869
+ function alreadySupersedeLinked(db, oldId, newId) {
870
+ const row = db
871
+ .prepare(`SELECT 1 FROM memory_links WHERE relationship = 'superseded_by'
872
+ AND ((source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?))`)
873
+ .get(oldId, newId, newId, oldId);
874
+ return !!row;
875
+ }
876
+ /**
877
+ * Build the constrained supersession-check prompt. Content is truncated the
878
+ * same width as domain-classify.ts's classifier (1500 chars) — this is a
879
+ * classify-tier call with the same cost profile.
880
+ */
881
+ function buildSupersessionPrompt(oldContent, newContent) {
882
+ const trunc = (s) => (s.length > 1500 ? `${s.slice(0, 1500)}…` : s);
883
+ return (`You are checking whether a NEWER memory supersedes an OLDER one in an AI agent's long-term memory.\n\n` +
884
+ `OLDER MEMORY:\n${trunc(oldContent)}\n\n` +
885
+ `NEWER MEMORY:\n${trunc(newContent)}\n\n` +
886
+ `Does the NEWER memory reverse, replace, or invalidate the OLDER one — e.g. a later decision overturns an ` +
887
+ `earlier one, or a correction retracts a prior claim? Two memories that are merely related, or that both ` +
888
+ `still hold true, are NOT a supersession.\n` +
889
+ `Reply with ONLY a JSON object, no prose: {"superseded": true} or {"superseded": false}.`);
890
+ }
891
+ /**
892
+ * Parse the model's supersession verdict. Returns the boolean on a valid
893
+ * reply, or null on anything unparseable (caller skips the pair — no retry,
894
+ * unlike domain-classify's tag classifier; a missed pair is retried naturally
895
+ * when this stage revisits the corpus).
896
+ */
897
+ function parseSupersessionReply(reply) {
898
+ if (!reply)
899
+ return null;
900
+ const start = reply.indexOf("{");
901
+ const end = reply.lastIndexOf("}");
902
+ if (start === -1 || end === -1 || end <= start)
903
+ return null;
904
+ try {
905
+ const obj = JSON.parse(reply.slice(start, end + 1));
906
+ return typeof obj.superseded === "boolean" ? obj.superseded : null;
907
+ }
908
+ catch {
909
+ return null;
910
+ }
911
+ }
912
+ /**
913
+ * ONE classify-tier LLM call judging whether `newContent` supersedes
914
+ * `oldContent`. Returns null on any infra error or unparseable reply — the
915
+ * caller treats null as "skip this pair" (never mis-links on ambiguity).
916
+ */
917
+ async function classifySupersession(llm, oldContent, newContent) {
918
+ try {
919
+ const raw = await llm.completeClassify(buildSupersessionPrompt(oldContent, newContent), 32);
920
+ return parseSupersessionReply(raw);
921
+ }
922
+ catch {
923
+ return null;
924
+ }
925
+ }
926
+ /**
927
+ * Find up to SUPERSESSION_NEIGHBOR_TOP_K OLDER, same-shape neighbors for a
928
+ * candidate, at/above minSimilarity, highest cosine first. Reuses the
929
+ * candidate's stored embedding when available (relink-style fallback to
930
+ * embedFn otherwise).
931
+ */
932
+ async function findOlderNeighbors(db, candidate, embedFn, minSimilarity) {
933
+ const embedding = storage.getStoredEmbedding(db, candidate.id) ?? (await embedFn(candidate.content));
934
+ return storage
935
+ .vectorSearch(db, embedding, SUPERSESSION_NEIGHBOR_POOL, [candidate.id])
936
+ .filter((n) => n.created_at < candidate.created_at &&
937
+ isDecisionShape(n) &&
938
+ (0, retrieval_js_1.l2ToCosine)(n.distance) >= minSimilarity)
939
+ .sort((a, b) => (0, retrieval_js_1.l2ToCosine)(b.distance) - (0, retrieval_js_1.l2ToCosine)(a.distance))
940
+ .slice(0, SUPERSESSION_NEIGHBOR_TOP_K);
941
+ }
942
+ /**
943
+ * Nightly supersession-detection stage. Scans memories/rowid > cursor whose
944
+ * shape suggests a decision/correction, checks each against its older
945
+ * same-shape neighbors, and links confirmed supersessions. Dry-run performs
946
+ * discovery + the free idempotency check only — no LLM calls, no writes, no
947
+ * cursor persistence (mirrors stageImportance/stageContentDomains's dry-run
948
+ * convention of never spending budget on a preview).
949
+ *
950
+ * Cursor discipline is DELIBERATELY simple (owner amendment): the cursor
951
+ * advances past a candidate once its neighbor set has been considered,
952
+ * REGARDLESS of whether every pair got an LLM call (call budget) or a clean
953
+ * verdict (infra skip) — missing one pair is acceptable and self-heals next
954
+ * time this memory's neighborhood is re-examined via a NEWER memory's own
955
+ * candidacy. It only stops SHORT of a candidate when the budget is already
956
+ * exhausted before that candidate starts, so the cursor never skips a
957
+ * candidate that was never looked at.
958
+ */
959
+ async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, options = {}) {
960
+ // Config values pass through `unknown`-typed JSON — validate rather than
961
+ // trust (same discipline as retrieval.ts's configureRecall).
962
+ const validNumber = (v, fallback, ok) => {
963
+ const n = Number(v);
964
+ return Number.isFinite(n) && ok(n) ? n : fallback;
965
+ };
966
+ const minSimilarity = validNumber(options.minSimilarity, exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY, (n) => n > 0 && n <= 1);
967
+ const maxCalls = validNumber(options.maxCalls, exports.DEFAULT_SUPERSESSION_MAX_CALLS, (n) => n >= 0);
968
+ const penalty = validNumber(options.penalty, exports.DEFAULT_SUPERSESSION_PENALTY, (n) => n > 0 && n <= 1);
969
+ const startCursor = (0, state_js_1.loadState)(stateDir).supersessionCursor ?? 0;
970
+ const rows = db
971
+ .prepare(`SELECT rowid AS __rowid, * FROM memories
972
+ WHERE rowid > ?
973
+ AND (memory_type = 'decision' OR content LIKE '%[Decisions Made]%' OR content LIKE '%[Corrections & Rejections]%')
974
+ ORDER BY rowid ASC LIMIT ?`)
975
+ .all(startCursor, SUPERSESSION_BATCH_SIZE);
976
+ let scanned = 0;
977
+ let evaluated = 0;
978
+ let superseded = 0;
979
+ let skippedInfra = 0;
980
+ let skippedIdempotent = 0;
981
+ let callsUsed = 0;
982
+ let cursor = startCursor;
983
+ for (const candidate of rows) {
984
+ if (!dryRun && (callsUsed >= maxCalls || budget.exhausted))
985
+ break;
986
+ scanned++;
987
+ let neighbors;
988
+ try {
989
+ neighbors = await findOlderNeighbors(db, candidate, embedFn, minSimilarity);
990
+ }
991
+ catch (err) {
992
+ console.warn(`[hicortex] supersession: discovery failed for ${candidate.id.slice(0, 8)} — ${err instanceof Error ? err.message : String(err)}`);
993
+ cursor = candidate.__rowid;
994
+ continue;
995
+ }
996
+ for (const neighbor of neighbors) {
997
+ if (alreadySupersedeLinked(db, neighbor.id, candidate.id)) {
998
+ skippedIdempotent++;
999
+ continue;
1000
+ }
1001
+ if (dryRun)
1002
+ continue; // preview only — no LLM call, no write
1003
+ if (callsUsed >= maxCalls || !budget.use("supersession"))
1004
+ break;
1005
+ callsUsed++;
1006
+ const verdict = await classifySupersession(llm, neighbor.content, candidate.content);
1007
+ evaluated++;
1008
+ if (verdict === null) {
1009
+ skippedInfra++;
1010
+ continue;
1011
+ }
1012
+ if (verdict) {
1013
+ const cosine = (0, retrieval_js_1.l2ToCosine)(neighbor.distance);
1014
+ storage.addLink(db, neighbor.id, candidate.id, "superseded_by", cosine);
1015
+ const newStrength = Math.max(SUPERSESSION_STRENGTH_FLOOR, (neighbor.base_strength ?? 0.5) * penalty);
1016
+ storage.updateMemory(db, neighbor.id, { base_strength: newStrength });
1017
+ superseded++;
1018
+ console.log(`[hicortex] Supersession: ${neighbor.id.slice(0, 8)} superseded_by ${candidate.id.slice(0, 8)} (cosine ${cosine.toFixed(3)})`);
1019
+ }
1020
+ }
1021
+ cursor = candidate.__rowid;
1022
+ }
1023
+ if (!dryRun) {
1024
+ (0, state_js_1.updateState)((s) => {
1025
+ s.supersessionCursor = cursor;
1026
+ }, stateDir);
1027
+ }
1028
+ if (rows.length > 0) {
1029
+ console.log(`[hicortex] Supersession detection: ${scanned} scanned, ${evaluated} evaluated, ${superseded} superseded, ` +
1030
+ `${skippedIdempotent} already-linked, ${skippedInfra} infra-skipped (cursor ${cursor})`);
1031
+ }
1032
+ return {
1033
+ scanned,
1034
+ evaluated,
1035
+ superseded,
1036
+ skipped_infra: skippedInfra,
1037
+ skipped_idempotent: skippedIdempotent,
1038
+ cursor,
1039
+ };
1040
+ }
1041
+ // ---------------------------------------------------------------------------
825
1042
  // Stage 4: Decay & Prune
826
1043
  // ---------------------------------------------------------------------------
1044
+ /**
1045
+ * Exported for the #191 eval baseline (src/eval/decay-eval.ts) so the audit
1046
+ * runs the REAL production prune predicate against a DB snapshot instead of
1047
+ * reimplementing it. `dryRun=true` performs reads only (candidates are
1048
+ * counted, nothing is deleted) — safe against a readonly snapshot connection.
1049
+ * Not otherwise part of the public API surface.
1050
+ */
827
1051
  function stageDecayPrune(db, dryRun) {
828
1052
  const now = new Date();
829
1053
  const cutoff = new Date(now.getTime() - CONSOLIDATE_PRUNE_MIN_AGE_DAYS * 24 * 60 * 60 * 1000);
@@ -853,7 +1077,7 @@ function stageDecayPrune(db, dryRun) {
853
1077
  }
854
1078
  return { candidates, pruned, failed };
855
1079
  }
856
- async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions) {
1080
+ async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions, supersessionOptions) {
857
1081
  const start = new Date();
858
1082
  const report = {
859
1083
  started_at: start.toISOString(),
@@ -924,6 +1148,8 @@ async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection
924
1148
  report.stages.links = await stageLinks(db, precheck.newMemories, embedFn, dryRun, llm, budget);
925
1149
  // Stage 3.5: Hub Detection — boost highly-connected memories
926
1150
  report.stages.hub_boost = stageHubBoost(db, dryRun);
1151
+ // Stage 3.7: Supersession Detection (#191 Phase B)
1152
+ report.stages.supersession = await stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, supersessionOptions);
927
1153
  // Stage 4: Decay & Prune
928
1154
  report.stages.decay_prune = stageDecayPrune(db, dryRun);
929
1155
  }
package/dist/db.js CHANGED
@@ -335,6 +335,32 @@ const MIGRATIONS = [
335
335
  }
336
336
  },
337
337
  },
338
+ {
339
+ version: 9,
340
+ name: "add_dedup_log",
341
+ up: (db) => {
342
+ // `hicortex dedup` (#100) audit trail. Every merged-away loser gets a
343
+ // row here BEFORE it is deleted, keyed by loser_id so a loser can only
344
+ // be logged once (defensive — the CLI never re-merges a deleted id).
345
+ // `source_session` is the loser's OWN source_session value (may be
346
+ // NULL): it is CRITICAL that /distill's dedup prechecks in
347
+ // mcp-server.ts also consult this table, because a deleted loser may
348
+ // have carried the ONLY marker for a session — without this table a
349
+ // `--recapture-window` run could re-ingest content the merge already
350
+ // consolidated. Sidecar table (not a memories column) since it survives
351
+ // the row it describes being deleted.
352
+ db.exec(`
353
+ CREATE TABLE IF NOT EXISTS dedup_log (
354
+ loser_id TEXT PRIMARY KEY,
355
+ canonical_id TEXT NOT NULL,
356
+ source_session TEXT,
357
+ content_head TEXT,
358
+ merged_at TIMESTAMP NOT NULL
359
+ )
360
+ `);
361
+ db.exec("CREATE INDEX IF NOT EXISTS idx_dedup_log_source_session ON dedup_log(source_session)");
362
+ },
363
+ },
338
364
  ];
339
365
  /**
340
366
  * Run all pending migrations against the database.