@gamaze/hicortex 0.20.4 → 0.20.6

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.
@@ -147,6 +147,20 @@ export declare function recallQueryVector(registry: CentroidStore, sessionId: st
147
147
  * old → new). One query, not per-candidate.
148
148
  */
149
149
  export declare function findSupersededIds(db: Database.Database, candidateIds: string[]): Set<string>;
150
+ /**
151
+ * The full ranking-demotion set among `candidateIds` (#384): the UNION of
152
+ * (a) sources of a `superseded_by` link (legacy + stageSupersession — link
153
+ * driven, works on pre-v14 rows with NULL status) and (b) rows whose
154
+ * `memories.status` is 'superseded' or 'retracted' (reconsolidation marks +
155
+ * explicit ingest marks). `corrected` is deliberately NOT demoting — a
156
+ * rewritten memory carries the CORRECTION, and demoting it would bury the
157
+ * fix (the exact failure reconsolidation exists to repair). `absorbed` needs
158
+ * no entry here: absorbed rows have no vector/FTS row and are filtered at
159
+ * candidacy. One batched query, both call sites (retrieve + searchRecent).
160
+ * Byte-identical behavior for memories with no correction relationship
161
+ * (NULL status, no link) — they never match either arm.
162
+ */
163
+ export declare function findDemotedIds(db: Database.Database, candidateIds: string[]): Set<string>;
150
164
  /**
151
165
  * Convert an L2 distance (as returned by sqlite-vec's vec0 `distance`) to
152
166
  * cosine similarity. Valid because our embeddings are L2-normalized
package/dist/retrieval.js CHANGED
@@ -63,6 +63,7 @@ exports.getSessionIntent = getSessionIntent;
63
63
  exports.blendQueryVector = blendQueryVector;
64
64
  exports.recallQueryVector = recallQueryVector;
65
65
  exports.findSupersededIds = findSupersededIds;
66
+ exports.findDemotedIds = findDemotedIds;
66
67
  exports.l2ToCosine = l2ToCosine;
67
68
  exports.effectiveStrength = effectiveStrength;
68
69
  exports.computeScore = computeScore;
@@ -276,6 +277,34 @@ function findSupersededIds(db, candidateIds) {
276
277
  .all(...candidateIds);
277
278
  return new Set(rows.map((r) => r.source_id));
278
279
  }
280
+ /**
281
+ * The full ranking-demotion set among `candidateIds` (#384): the UNION of
282
+ * (a) sources of a `superseded_by` link (legacy + stageSupersession — link
283
+ * driven, works on pre-v14 rows with NULL status) and (b) rows whose
284
+ * `memories.status` is 'superseded' or 'retracted' (reconsolidation marks +
285
+ * explicit ingest marks). `corrected` is deliberately NOT demoting — a
286
+ * rewritten memory carries the CORRECTION, and demoting it would bury the
287
+ * fix (the exact failure reconsolidation exists to repair). `absorbed` needs
288
+ * no entry here: absorbed rows have no vector/FTS row and are filtered at
289
+ * candidacy. One batched query, both call sites (retrieve + searchRecent).
290
+ * Byte-identical behavior for memories with no correction relationship
291
+ * (NULL status, no link) — they never match either arm.
292
+ */
293
+ function findDemotedIds(db, candidateIds) {
294
+ if (candidateIds.length === 0)
295
+ return new Set();
296
+ const placeholders = candidateIds.map(() => "?").join(",");
297
+ const rows = db
298
+ .prepare(`SELECT DISTINCT id FROM (
299
+ SELECT source_id AS id FROM memory_links
300
+ WHERE relationship = 'superseded_by' AND source_id IN (${placeholders})
301
+ UNION
302
+ SELECT id FROM memories
303
+ WHERE status IN ('superseded', 'retracted') AND id IN (${placeholders})
304
+ )`)
305
+ .all(...candidateIds, ...candidateIds);
306
+ return new Set(rows.map((r) => r.id));
307
+ }
279
308
  /**
280
309
  * Placeholder L2 distance for candidates that have no measured vector
281
310
  * distance (FTS-only hits and graph-discovered neighbors). Chosen so that
@@ -608,6 +637,11 @@ async function retrieve(db, embedFn, query, options) {
608
637
  const mem = storage.getMemory(db, gid);
609
638
  if (!mem)
610
639
  continue;
640
+ // #384: absorbed memories never enter via the graph either — they keep
641
+ // their link rows (evidence + rollback reference), so graph traversal can
642
+ // reach them, but they are invisible to recall by contract.
643
+ if (mem.status === "absorbed")
644
+ continue;
611
645
  // #203: project check removed — project is a soft affinity in computeScore,
612
646
  // not a filter. 0.16.x: privacy check removed — the column is vestigial,
613
647
  // never filtered. sourceAgent stays a hard filter.
@@ -622,8 +656,10 @@ async function retrieve(db, embedFn, query, options) {
622
656
  const scored = [];
623
657
  const maxRrf = Math.max(...([...rrfScores.values()].length > 0 ? [...rrfScores.values()] : [1]));
624
658
  // One query for the whole candidate set (#191 Phase B): superseded memories
625
- // are demoted in computeScore rather than strength-penalized.
626
- const supersededIds = findSupersededIds(db, [...candidateMap.keys()]);
659
+ // are demoted in computeScore rather than strength-penalized. #384: the set
660
+ // is the full demotion set — link-driven supersessions UNION status-marked
661
+ // superseded/retracted rows (see findDemotedIds).
662
+ const supersededIds = findDemotedIds(db, [...candidateMap.keys()]);
627
663
  // #203: ONE batched load of every candidate's graded domain tags — fed to
628
664
  // computeScore for domain affinity. Only needed when the scope carries
629
665
  // missionDomains; absent otherwise (skips the query entirely on /search and
@@ -714,7 +750,9 @@ function searchRecent(db, options) {
714
750
  ? [...connectionCounts.values()]
715
751
  : [0]));
716
752
  const scored = [];
717
- const supersededRecent = findSupersededIds(db, candidates.map((c) => c.id));
753
+ // #384: same full demotion set as retrieve() — link-driven supersessions
754
+ // UNION status-marked superseded/retracted rows (findDemotedIds).
755
+ const supersededRecent = findDemotedIds(db, candidates.map((c) => c.id));
718
756
  for (const mem of candidates) {
719
757
  const connCount = connectionCounts.get(mem.id) ?? 0;
720
758
  const score = computeScore(mem, DEFAULT_GRAPH_DISTANCE, connCount, maxConnections, now, {
package/dist/state.d.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * Note: ~/.hicortex/config.json is intentionally NOT merged here. Config is
17
17
  * user-edited and tracked separately from machine state.
18
18
  */
19
- import type { LicenseInfo, ModuleIndex } from "./types.js";
19
+ import type { LicenseInfo, ModuleIndex, ResolutionBandStat } from "./types.js";
20
20
  /** Persisted tier information — reflects the last successful validation. */
21
21
  export interface PersistedTier {
22
22
  /** Tier name from the validation API response. */
@@ -60,6 +60,17 @@ export interface HicortexState {
60
60
  * gradually over many nights.
61
61
  */
62
62
  supersessionCursor?: number;
63
+ /**
64
+ * Resume cursor for the nightly's reconsolidation stage (#384) — highest
65
+ * memories.rowid whose candidates have been evaluated (or infra-skipped)
66
+ * this run. Absent/0 = never run. Same advance-past-considered-candidates
67
+ * discipline as supersessionCursor, with one addition: when a rewrite group
68
+ * could not be applied (budget exhausted / rewrite-call infra error), the
69
+ * cursor holds BELOW the earliest candidate contributing to an un-applied
70
+ * group so those pairs are re-detected next run — a confirmed correction is
71
+ * never silently dropped by the cursor passing it.
72
+ */
73
+ reconsolidationCursor?: number;
63
74
  /**
64
75
  * Resume cursor for `hicortex classify-types` (#216) — highest memories.rowid
65
76
  * whose batch has been fully committed. Absent/0 = never run (or reset).
@@ -89,6 +100,17 @@ export interface HicortexState {
89
100
  * which means the first run is never throttled (correct: no baseline yet).
90
101
  */
91
102
  llmTokensLastRun?: number;
103
+ /**
104
+ * Cumulative per-band verdict statistics for the unified resolution pass
105
+ * (#392), keyed by cosine band label ("0.75-0.8", …, ">=0.92" — labels
106
+ * derive from the live floor/ceiling at write time). Accumulated across
107
+ * runs, never reset: labeled calibration evidence for moving the
108
+ * floor/ceiling boundaries later. The deterministic zone persists its own
109
+ * band; the reconsolidation stage persists the judged bands. Never written
110
+ * on dry runs. The per-run snapshot lives in the stage report
111
+ * (`stages.reconsolidation.band_stats`).
112
+ */
113
+ resolutionBandStats?: Record<string, ResolutionBandStat>;
92
114
  }
93
115
  /**
94
116
  * Load the state file. Returns an empty state if the file is missing
package/dist/storage.d.ts CHANGED
@@ -46,6 +46,27 @@ export declare function touchMemoriesShown(db: Database.Database, memoryIds: str
46
46
  * Delete a memory, its vector, its tags, and all its links.
47
47
  */
48
48
  export declare function deleteMemory(db: Database.Database, memoryId: string): void;
49
+ /**
50
+ * A memory's rowid in `memories` (the FTS table's rowid), or null when the
51
+ * row does not exist. Shared by the absorb primitive below and the
52
+ * reconsolidation stage's FTS bookkeeping (#384/#392).
53
+ */
54
+ export declare function memoryRowid(db: Database.Database, memoryId: string): number | null;
55
+ /**
56
+ * Drop a memory's retrieval candidacy: status `absorbed`, vector row deleted,
57
+ * FTS row deleted (direct DELETE — the AFTER UPDATE trigger's `UPDATE … WHERE
58
+ * rowid` is a silent no-op on the missing row, so later column edits cannot
59
+ * resurrect it). The plain row + links are KEPT (evidence, session lineage,
60
+ * rollback reference). Must run inside a transaction.
61
+ *
62
+ * The shared absorb primitive (#392): the reconsolidation stage's rewrite
63
+ * path (via the `absorbTrigger` re-export) AND dedup merge losers both fold a
64
+ * row into invisible-evidence state through this ONE function, so the
65
+ * "absorbed" vocabulary can never drift between them. Tags/domain are the
66
+ * CALLER's concern (the rewrite path clears the target's; a dedup merge
67
+ * clears the loser's before absorbing).
68
+ */
69
+ export declare function absorbMemory(db: Database.Database, memoryId: string): void;
49
70
  /** Options for setMemoryTags (graded-schema spec 2026-07-07). */
50
71
  export interface SetMemoryTagsOptions {
51
72
  /**
@@ -223,6 +244,8 @@ export declare function insertMemoriesBatch(db: Database.Database, memories: Arr
223
244
  export declare function countMemories(db: Database.Database): number;
224
245
  /**
225
246
  * Get memories created in the last N days, newest first.
247
+ * Absorbed memories are excluded (#384): they are invisible to recall — the
248
+ * plain row is evidence only, never a recent-recall candidate.
226
249
  */
227
250
  export declare function getRecentMemories(db: Database.Database, days?: number, limit?: number): Memory[];
228
251
  /**
@@ -245,5 +268,7 @@ export declare function getPruneCandidates(db: Database.Database, cutoffIso: str
245
268
  export declare function getAllLinkCounts(db: Database.Database): Map<string, number>;
246
269
  /**
247
270
  * Get all memories with default base_strength (never scored).
271
+ * Absorbed memories are excluded (#384): they are invisible to recall, so
272
+ * importance-scoring one would spend an LLM call on dead evidence.
248
273
  */
249
274
  export declare function getUnscoredMemories(db: Database.Database): Memory[];
package/dist/storage.js CHANGED
@@ -13,6 +13,8 @@ exports.updateMemory = updateMemory;
13
13
  exports.strengthenMemory = strengthenMemory;
14
14
  exports.touchMemoriesShown = touchMemoriesShown;
15
15
  exports.deleteMemory = deleteMemory;
16
+ exports.memoryRowid = memoryRowid;
17
+ exports.absorbMemory = absorbMemory;
16
18
  exports.setMemoryTags = setMemoryTags;
17
19
  exports.getMemoryTags = getMemoryTags;
18
20
  exports.getMemoryTagsWeighted = getMemoryTagsWeighted;
@@ -132,6 +134,10 @@ const ALLOWED_UPDATE_FIELDS = new Set([
132
134
  "privacy",
133
135
  "memory_type",
134
136
  "updated_at",
137
+ // Reconsolidation state (#384, migration v14): written by the
138
+ // reconsolidation stage, explicit ingest marks, and history rollback.
139
+ // Code-defined vocabulary — see Memory.status.
140
+ "status",
135
141
  ]);
136
142
  /**
137
143
  * Update specific fields on a memory.
@@ -185,6 +191,36 @@ function deleteMemory(db, memoryId) {
185
191
  db.prepare("DELETE FROM memory_vectors WHERE id = ?").run(memoryId);
186
192
  db.prepare("DELETE FROM memories WHERE id = ?").run(memoryId);
187
193
  }
194
+ /**
195
+ * A memory's rowid in `memories` (the FTS table's rowid), or null when the
196
+ * row does not exist. Shared by the absorb primitive below and the
197
+ * reconsolidation stage's FTS bookkeeping (#384/#392).
198
+ */
199
+ function memoryRowid(db, memoryId) {
200
+ const row = db.prepare("SELECT rowid AS rid FROM memories WHERE id = ?").get(memoryId);
201
+ return row?.rid ?? null;
202
+ }
203
+ /**
204
+ * Drop a memory's retrieval candidacy: status `absorbed`, vector row deleted,
205
+ * FTS row deleted (direct DELETE — the AFTER UPDATE trigger's `UPDATE … WHERE
206
+ * rowid` is a silent no-op on the missing row, so later column edits cannot
207
+ * resurrect it). The plain row + links are KEPT (evidence, session lineage,
208
+ * rollback reference). Must run inside a transaction.
209
+ *
210
+ * The shared absorb primitive (#392): the reconsolidation stage's rewrite
211
+ * path (via the `absorbTrigger` re-export) AND dedup merge losers both fold a
212
+ * row into invisible-evidence state through this ONE function, so the
213
+ * "absorbed" vocabulary can never drift between them. Tags/domain are the
214
+ * CALLER's concern (the rewrite path clears the target's; a dedup merge
215
+ * clears the loser's before absorbing).
216
+ */
217
+ function absorbMemory(db, memoryId) {
218
+ const rid = memoryRowid(db, memoryId);
219
+ updateMemory(db, memoryId, { status: "absorbed" });
220
+ db.prepare("DELETE FROM memory_vectors WHERE id = ?").run(memoryId);
221
+ if (rid !== null)
222
+ db.prepare("DELETE FROM memories_fts WHERE rowid = ?").run(rid);
223
+ }
188
224
  /**
189
225
  * Set a memory's classification tags (graded schema model).
190
226
  *
@@ -451,12 +487,14 @@ function searchFts(db, query, limit = 10, sourceAgent) {
451
487
  * Create a link between two memories.
452
488
  */
453
489
  function addLink(db, sourceId, targetId, relationship, strength = 0.5) {
454
- // Guard: superseded_by is the sole ranking-demotion signal, so never let a
455
- // different relationship clobber an existing superseded_by link for the same
456
- // pair — INSERT OR REPLACE would otherwise silently remove the demotion.
457
- if (relationship !== "superseded_by") {
490
+ // Guard: superseded_by and corrected_by are the ranking-demotion /
491
+ // correction-resolution signals, so never let a different relationship
492
+ // clobber an existing one for the same pair — INSERT OR REPLACE would
493
+ // otherwise silently remove the resolution (corrected_by is protected
494
+ // exactly like superseded_by, #384 AC9).
495
+ if (relationship !== "superseded_by" && relationship !== "corrected_by") {
458
496
  const protectedLink = db
459
- .prepare("SELECT 1 FROM memory_links WHERE source_id = ? AND target_id = ? AND relationship = 'superseded_by' LIMIT 1")
497
+ .prepare("SELECT 1 FROM memory_links WHERE source_id = ? AND target_id = ? AND relationship IN ('superseded_by', 'corrected_by') LIMIT 1")
460
498
  .get(sourceId, targetId);
461
499
  if (protectedLink)
462
500
  return;
@@ -530,11 +568,13 @@ function countMemories(db) {
530
568
  }
531
569
  /**
532
570
  * Get memories created in the last N days, newest first.
571
+ * Absorbed memories are excluded (#384): they are invisible to recall — the
572
+ * plain row is evidence only, never a recent-recall candidate.
533
573
  */
534
574
  function getRecentMemories(db, days = 7, limit = 50) {
535
575
  const rows = db
536
576
  .prepare(`SELECT * FROM memories
537
- WHERE created_at >= datetime('now', ?)
577
+ WHERE created_at >= datetime('now', ?) AND COALESCE(status, '') != 'absorbed'
538
578
  ORDER BY created_at DESC LIMIT ?`)
539
579
  .all(`-${days} days`, limit);
540
580
  return rows.map(rowToMemory);
@@ -602,11 +642,13 @@ function getAllLinkCounts(db) {
602
642
  }
603
643
  /**
604
644
  * Get all memories with default base_strength (never scored).
645
+ * Absorbed memories are excluded (#384): they are invisible to recall, so
646
+ * importance-scoring one would spend an LLM call on dead evidence.
605
647
  */
606
648
  function getUnscoredMemories(db) {
607
649
  const rows = db
608
650
  .prepare(`SELECT * FROM memories
609
- WHERE base_strength = 0.5
651
+ WHERE base_strength = 0.5 AND COALESCE(status, '') != 'absorbed'
610
652
  ORDER BY ingested_at ASC`)
611
653
  .all();
612
654
  return rows.map(rowToMemory);
@@ -151,8 +151,10 @@ async function classifyMemoryType(content, llm) {
151
151
  for (let attempt = 0; attempt < 2; attempt++) {
152
152
  let raw;
153
153
  try {
154
- // ~20 tokens covers "type score" + headroom for models that add labels.
155
- const r = await llm.completeClassify(prompt, 20);
154
+ // No per-call cap (#391): the classify-tier ceiling (classifyMaxTokens,
155
+ // default 1024) resolves inside completeClassify — a hardcoded 20
156
+ // starved reasoning models whose thinking ate the whole output budget.
157
+ const r = await llm.completeClassify(prompt);
156
158
  raw = r.text;
157
159
  }
158
160
  catch (err) {
package/dist/types.d.ts CHANGED
@@ -32,6 +32,16 @@ export interface Memory {
32
32
  privacy: ("PUBLIC" | "WORK" | "PERSONAL" | "SENSITIVE") | null;
33
33
  memory_type: "experience" | "learnings" | "knowledge" | "decisions";
34
34
  updated_at: string | null;
35
+ /**
36
+ * Reconsolidation state (#384, migration v14). Code-defined vocabulary,
37
+ * never config: NULL/absent = active (the default, and every pre-v14 row);
38
+ * 'superseded'/'retracted' = marked stale or wrong (demoted in ranking);
39
+ * 'corrected' = rewritten in place (does NOT demote — demoting it would
40
+ * bury the correction); 'absorbed' = invisible to recall (trigger memory
41
+ * folded into a corrected target — no vector/FTS row, plain row + link
42
+ * kept as evidence and rollback reference).
43
+ */
44
+ status?: string | null;
35
45
  }
36
46
  /** A link between two memories. */
37
47
  export interface MemoryLink {
@@ -67,6 +77,68 @@ export interface MemorySearchResult {
67
77
  * both, or graph traversal). Used by the /recall-index relevance gate. */
68
78
  source?: "vector" | "fts" | "both" | "graph";
69
79
  }
80
+ /**
81
+ * Per-cosine-band verdict statistics for the unified resolution pass (#392).
82
+ * Bands are labeled from the live floor/ceiling ("0.75-0.8", …, ">=0.92").
83
+ * The stage report carries the per-run snapshot; state.json
84
+ * `resolutionBandStats` carries the cumulative series — calibration evidence
85
+ * for moving the floor/ceiling boundaries later, with data.
86
+ */
87
+ export interface ResolutionBandStat {
88
+ /** Candidate pairs judged (or deterministically merged) in this band. */
89
+ pairs: number;
90
+ /** Verdict/action counts. `merge` counts gated merges (applied or applicable). */
91
+ merge: number;
92
+ corrects: number;
93
+ supersedes: number;
94
+ none: number;
95
+ /** Merge verdicts below the confidence gate — both memories kept. */
96
+ merge_below_gate: number;
97
+ /** Sum of verdict confidences (divide by `pairs` for the mean). Deterministic merges count 1.0 each. */
98
+ conf_sum: number;
99
+ /** Deterministic band only: clusters refused by the metadata rails. */
100
+ metadata_skipped?: number;
101
+ }
102
+ /**
103
+ * Report of the deterministic merge zone (#392) — the >= dedupAutoMergeThreshold
104
+ * band, merged by the dedup core's union-find clustering with ZERO LLM calls.
105
+ * Computed in dedup.ts (runDeterministicMergeZone); surfaced verbatim as
106
+ * `stages.reconsolidation.merges`.
107
+ */
108
+ export interface DeterministicMergeZoneReport {
109
+ /** The cosine ceiling in force (config dedupAutoMergeThreshold; default 0.92). */
110
+ threshold: number;
111
+ /** The pacing cap in force (dedupNightlyMaxMerges; 0 = machinery disabled). */
112
+ max_merges: number;
113
+ /** Every cluster found at the threshold (mergeable + mismatch-skipped). */
114
+ clusters_found: number;
115
+ /** Clusters that passed the metadata rails (would merge). */
116
+ mergeable_clusters: number;
117
+ /** Clusters actually merged this run (apply only; 0 on dry-run). */
118
+ merged_clusters: number;
119
+ /** Loser rows absorbed (hidden from recall, kept as evidence) this run. */
120
+ losers_merged: number;
121
+ /** Loser links re-pointed onto canonicals this run. */
122
+ links_repointed: number;
123
+ /** Clusters skipped — members disagree on project / source_agent. */
124
+ skipped_metadata_mismatch: number;
125
+ /** Mergeable clusters NOT attempted because the pacing cap was exhausted. */
126
+ capped: number;
127
+ /** Clusters whose merge transaction failed (rolled back; retried next run). */
128
+ failed: number;
129
+ /** Apply only: the capture lock was busy — zero merges, fail-soft. */
130
+ lock_busy?: boolean;
131
+ /** Apply only: the pre-merge backup failed — zero merges, fail-soft. */
132
+ backup_failed?: boolean;
133
+ /** Apply only: path of the pre-merge DB backup. */
134
+ backup_path?: string;
135
+ /** Dry-run only: bounded preview of the first 10 mergeable clusters. */
136
+ preview?: Array<{
137
+ size: number;
138
+ canonical_id: string;
139
+ loser_ids: string[];
140
+ }>;
141
+ }
70
142
  /** Report returned by the consolidation pipeline. */
71
143
  export interface ConsolidationReport {
72
144
  started_at: string;
@@ -141,6 +213,81 @@ export interface ConsolidationReport {
141
213
  /** supersessionCursor after this run (unchanged in dry-run). */
142
214
  cursor: number;
143
215
  };
216
+ /**
217
+ * Reconsolidation (#384) — runs after supersession, before decay/prune.
218
+ * Since #392 this is THE unified resolution stage: its verdict also carries
219
+ * a `merge` disposition, and the deterministic merge zone (pairs at/above
220
+ * `dedupAutoMergeThreshold`) runs inside it, LLM-free, before the scan.
221
+ */
222
+ reconsolidation?: {
223
+ /** Candidates examined this run (rowid > cursor; no shape filter). */
224
+ scanned: number;
225
+ /** Pairs actually sent to the verdict LLM (detection + explicit-mark verification). */
226
+ pairs_evaluated: number;
227
+ /**
228
+ * #394: pairs the similarity floor discovered this run (KNN neighbors
229
+ * at/above correctionMinSimilarity), counted before any skip or
230
+ * judgment — the only sizing number a dry-run can show, where
231
+ * pairs_evaluated is always 0.
232
+ */
233
+ pairs_discovered: number;
234
+ /** #394: discovered pairs with no resolution link yet — the actionable
235
+ * candidates (deterministic-zone work + would-be verdict calls). */
236
+ pairs_discovered_unlinked: number;
237
+ /** Targets rewritten in place this run (one history row each). */
238
+ rewritten: number;
239
+ /** Triggers absorbed (invisible to recall: vector + FTS dropped). */
240
+ absorbed: number;
241
+ /** Triggers kept live by their disposition (standalone substance). */
242
+ kept_linked: number;
243
+ /** Memories marked status 'superseded' (mark-only path). */
244
+ marked_superseded: number;
245
+ /** Memories marked status 'retracted' (mark-only: below gate / non-fact / failed contract). */
246
+ marked_retracted: number;
247
+ /** Verdicts that were `corrects` but below correctionRewriteMinConfidence. */
248
+ below_gate: number;
249
+ /** Rewrite groups degraded to mark-only on a failed rewrite contract. */
250
+ contract_failed: number;
251
+ /** Verdict/rewrite calls skipped on a parse/infra error (retried naturally). */
252
+ skipped_infra: number;
253
+ /** Pairs skipped because a resolution link already existed (either direction). */
254
+ skipped_idempotent: number;
255
+ /** Explicit ingest marks verified and upgraded into a rewrite group. */
256
+ explicit_verified: number;
257
+ /** Explicit marks whose verification diverged (mark retained untouched). */
258
+ explicit_divergent: number;
259
+ /** reconsolidationCursor after this run (unchanged in dry-run). */
260
+ cursor: number;
261
+ /**
262
+ * #392: the deterministic merge zone's own report (pairs >= the
263
+ * ceiling, union-find merged, zero LLM). Present on every run —
264
+ * including quiet-night skips (a stock install with a pre-upgrade
265
+ * backlog still drains it, LLM-free).
266
+ */
267
+ merges: DeterministicMergeZoneReport;
268
+ /** #392: judged-zone pair merges applied this run (merge verdicts at/above the confidence gate). */
269
+ merge_pairs_applied: number;
270
+ /** #392: merge verdicts below correctionRewriteMinConfidence — both memories kept. */
271
+ merge_below_gate: number;
272
+ /**
273
+ * #392: pairs the scan saw at/above the ceiling — owned by the
274
+ * deterministic zone (or waiting for its cap), never LLM-judged.
275
+ */
276
+ skipped_above_ceiling: number;
277
+ /**
278
+ * #392: judged merge pairs refused by the metadata rails (project /
279
+ * source_agent disagreement). Both memories kept; the cursor advances —
280
+ * the verdict was rendered, this is not an infra failure.
281
+ */
282
+ skipped_metadata_mismatch: number;
283
+ /**
284
+ * #392: per-run verdict statistics by cosine band ("0.75-0.8" …
285
+ * ">=0.92"; labels derive from the live floor/ceiling). Calibration
286
+ * evidence for moving the boundaries later; the cumulative series lives
287
+ * in state.json `resolutionBandStats`.
288
+ */
289
+ band_stats: Record<string, ResolutionBandStat>;
290
+ };
144
291
  decay_prune?: {
145
292
  candidates: number;
146
293
  pruned: number;
@@ -345,6 +492,18 @@ export interface HicortexConfig {
345
492
  * when it finishes early. Read in llm.ts; see #220.
346
493
  */
347
494
  maxTokens?: number;
495
+ /**
496
+ * Max output tokens for the classify tier ONLY — the short JSON-verdict
497
+ * calls: correction/supersession verdicts, rewrite contracts, and type +
498
+ * domain tag classification. Default 1024. A ceiling, not a target
499
+ * (generation stops at the model's natural end) — raise it when a
500
+ * reasoning-style model spends the budget on internal reasoning and returns
501
+ * empty verdicts (the pre-#391 hardcoded per-call caps starved exactly that
502
+ * shape; a local non-reasoning model is unaffected by the raise).
503
+ * `maxTokens` continues to govern the heavy phases (distill/reflect).
504
+ * Read in llm.ts; see #391.
505
+ */
506
+ classifyMaxTokens?: number;
348
507
  /**
349
508
  * Toggle the model's internal reasoning ("thinking") stream on the openai-compat
350
509
  * path — applies to ALL phases (distill / reflect / classify / scoring) since one
@@ -547,6 +706,45 @@ export interface HicortexConfig {
547
706
  orgName?: string;
548
707
  /** Plan/tier label rendered as a small badge (e.g. "Cloud · Early bird"). */
549
708
  planLabel?: string;
709
+ /**
710
+ * Minimum cosine similarity for a reconsolidation candidate pair (#384):
711
+ * each new-since-cursor memory is paired with up to 5 older KNN neighbors
712
+ * at/above this bar before the verdict call. Default 0.75 — a touch wider
713
+ * than the supersession stage's 0.80 because a retraction often rides inside
714
+ * an otherwise unrelated memory; the verdict + confidence gate carry the
715
+ * precision. Number in (0, 1]; invalid/absent keeps the default.
716
+ */
717
+ correctionMinSimilarity?: number;
718
+ /**
719
+ * Minimum verdict confidence for the REWRITE fork of reconsolidation (#384):
720
+ * a `corrects` verdict at/above this bar on a fact-shaped target is rewritten
721
+ * in place; below it the pair degrades to mark-only (a weak mark is
722
+ * recoverable, a weak rewrite is corruption). Default 0.80. Number in
723
+ * (0, 1]; invalid/absent keeps the default. Since #392 this same gate also
724
+ * decides whether a `merge` verdict is applied (analogous reasoning: a weak
725
+ * merge keeps both memories, a confirmed merge hides one).
726
+ */
727
+ correctionRewriteMinConfidence?: number;
728
+ /**
729
+ * Deterministic merge ceiling for the unified resolution pass (#392): memory
730
+ * pairs at/above this cosine are merged by the dedup core's union-find
731
+ * clustering with ZERO LLM calls; pairs in [correctionMinSimilarity, this
732
+ * value) get the one unified verdict call (merge/corrects/supersedes/none).
733
+ * Default 0.92 (the #100/#191 calibration). The legacy `dedupMergeThreshold`
734
+ * key is honored as a fallback when this key is absent. Number in (0, 1];
735
+ * invalid/absent keeps the default. Also read by the manual `hicortex dedup`
736
+ * CLI (same precedence: --threshold > this key > legacy key > 0.92).
737
+ */
738
+ dedupAutoMergeThreshold?: number;
739
+ /**
740
+ * Pacing cap on merge OPERATIONS per nightly run (#392): deterministic-zone
741
+ * clusters plus judged pair merges count against ONE cap, so a
742
+ * misbehaving-distiller burst is bounded and a large pre-existing backlog
743
+ * drains over a few nights rather than in one run. Default 250. `0` disables
744
+ * the merge machinery entirely (the deterministic zone is skipped; a
745
+ * confirmed merge verdict keeps both memories). Non-negative integer.
746
+ */
747
+ dedupNightlyMaxMerges?: number;
550
748
  }
551
749
  /** A config-owned life-sphere domain (see HicortexConfig.domains). */
552
750
  export interface DomainDef {
@@ -484,8 +484,12 @@ class HicortexProvider(MemoryProvider):
484
484
  {
485
485
  "name": "hicortex_search",
486
486
  "description": (
487
- "Search long-term memory using semantic similarity. Returns the most "
488
- "relevant memories from past sessions."
487
+ "Search shared long-term memory (all agents, all sessions). "
488
+ "CALL THIS BEFORE assuming, guessing, or asking the user about "
489
+ "anything that may have come up before: prior decisions, "
490
+ "preferences, project facts, people, hardware, past incidents. "
491
+ "If you are about to write 'I don't have information about…', "
492
+ "search first."
489
493
  ),
490
494
  "parameters": {
491
495
  "type": "object",
@@ -504,13 +508,13 @@ class HicortexProvider(MemoryProvider):
504
508
  "name": "hicortex_get",
505
509
  "description": (
506
510
  "Fetch ONE memory's full content by id — use this to lazy-load "
507
- "entries from the recall index or from search results whose "
508
- "snippet was not enough. Fetching a memory marks it as used "
509
- "(strengthens it), so fetch entries that could change your "
510
- "action — not every shown one. When the memory shapes your "
511
- "answer, cite it as given in the response — mark a fetched "
512
- "memory FETCHED and a one-line entry cited unread SNIPPET; "
513
- "don't pass SNIPPET off as established."
511
+ "entries from the '## Memory recall (auto)' index or from search "
512
+ "results whose snippet was not enough. Fetching a memory marks "
513
+ "it as used (strengthens it), so fetch entries that could "
514
+ "change your action — not every shown one. When the memory "
515
+ "shapes your answer, cite it to the user (id + date + origin "
516
+ "agent) — mark a fetched memory `FETCHED` and a one-line entry "
517
+ "cited unread `SNIPPET`; don't pass SNIPPET off as established."
514
518
  ),
515
519
  "parameters": {
516
520
  "type": "object",
@@ -526,9 +530,9 @@ class HicortexProvider(MemoryProvider):
526
530
  {
527
531
  "name": "hicortex_recent",
528
532
  "description": (
529
- "Get recent memories, optionally filtered by project. Queryless recall "
530
- "of the latest memories by project, ranked by importance. Useful to "
531
- "catch up on what happened recently."
533
+ "Get recent memories, optionally filtered by project. CALL THIS "
534
+ "AT THE START of substantive work on a project to catch up on "
535
+ "its latest state — cheaper than asking the user what happened."
532
536
  ),
533
537
  "parameters": {
534
538
  "type": "object",
@@ -542,7 +546,9 @@ class HicortexProvider(MemoryProvider):
542
546
  "name": "hicortex_ingest",
543
547
  "description": (
544
548
  "Store a new memory in long-term storage. "
545
- "Use for Knowledge, Decisions, or Learnings."
549
+ "Use for Knowledge, Decisions, or Learnings. Capture is "
550
+ "automatic (nightly) — use this ONLY for explicitly requested "
551
+ "learnings, never routine content."
546
552
  ),
547
553
  "parameters": {
548
554
  "type": "object",
@@ -561,8 +567,10 @@ class HicortexProvider(MemoryProvider):
561
567
  {
562
568
  "name": "hicortex_lessons",
563
569
  "description": (
564
- "Get actionable Learnings distilled from past sessions. "
565
- "Auto-generated insights about mistakes to avoid."
570
+ "Get actionable Learnings — auto-generated insights about "
571
+ "mistakes to avoid. CALL THIS before retrying an approach that "
572
+ "failed before, or when picking up work where past problems may "
573
+ "have been recorded."
566
574
  ),
567
575
  "parameters": {
568
576
  "type": "object",
@@ -575,7 +583,9 @@ class HicortexProvider(MemoryProvider):
575
583
  "name": "hicortex_index",
576
584
  "description": (
577
585
  "Get the knowledge domain index — shows what topics and projects "
578
- "are stored in memory, grouped by domain."
586
+ "are stored in memory, grouped by domain. Call before a broad "
587
+ "search to see which knowledge domains exist, or when unsure "
588
+ "what the memory covers."
579
589
  ),
580
590
  "parameters": {
581
591
  "type": "object",
@@ -586,7 +596,9 @@ class HicortexProvider(MemoryProvider):
586
596
  "name": "hicortex_graph",
587
597
  "description": (
588
598
  "Query the memory knowledge graph — find connected memories, "
589
- "hub nodes, or paths between memories."
599
+ "hub nodes, or paths between memories. Use it to explore "
600
+ "memories connected to one you just fetched, or to find hub "
601
+ "memories in a domain."
590
602
  ),
591
603
  "parameters": {
592
604
  "type": "object",