@gamaze/hicortex 0.20.4 → 0.20.5

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.
@@ -66,6 +66,8 @@ const state_js_1 = require("./state.js");
66
66
  const domain_classify_js_1 = require("./domain-classify.js");
67
67
  const schema_prototypes_js_1 = require("./schema-prototypes.js");
68
68
  const nofit_js_1 = require("./nofit.js");
69
+ const reconsolidation_js_1 = require("./reconsolidation.js");
70
+ const dedup_js_1 = require("./dedup.js");
69
71
  // Default config constants (matching Python config.py)
70
72
  /**
71
73
  * Default ceiling on total LLM calls across all classify-tier consolidation
@@ -1065,7 +1067,7 @@ function parseSupersessionReply(reply) {
1065
1067
  */
1066
1068
  async function classifySupersession(llm, oldContent, newContent) {
1067
1069
  try {
1068
- const r = await llm.completeClassify(buildSupersessionPrompt(oldContent, newContent), 32);
1070
+ const r = await llm.completeClassify(buildSupersessionPrompt(oldContent, newContent));
1069
1071
  return { verdict: parseSupersessionReply(r.text), usage: r.usage };
1070
1072
  }
1071
1073
  catch {
@@ -1360,6 +1362,66 @@ function stageMemoryCapEviction(db, dryRun, cap) {
1360
1362
  `memories (corpus was ${count}, cap ${cap}).`);
1361
1363
  return { cap, evicted: victims.length };
1362
1364
  }
1365
+ /**
1366
+ * Minimal resolution-stage report for a SKIPPED (quiet-night) consolidation
1367
+ * run (#392): every stage field zero except `merges` — the deterministic
1368
+ * zone's own report — and its derived deterministic band snapshot. Keeps the
1369
+ * one-report surface intact (the zone is the only resolution work a quiet
1370
+ * night does) while telemetry's "skipped = zero LLM work" stays true. Knob
1371
+ * validation mirrors the stage's own (invalid → defaults).
1372
+ */
1373
+ async function skippedRunResolutionReport(db, dryRun, stateDir, options = {}) {
1374
+ const validNumber = (v, fallback, ok) => {
1375
+ const n = Number(v);
1376
+ return Number.isFinite(n) && ok(n) ? n : fallback;
1377
+ };
1378
+ const autoMergeThreshold = validNumber(options.autoMergeThreshold, dedup_js_1.DEFAULT_DEDUP_MERGE_THRESHOLD, (n) => n > 0 && n <= 1);
1379
+ const maxMerges = validNumber(options.maxMerges, dedup_js_1.DEFAULT_DEDUP_NIGHTLY_MAX_MERGES, (n) => n >= 0);
1380
+ const merges = await (0, dedup_js_1.runDeterministicMergeZone)(db, {
1381
+ stateDir,
1382
+ threshold: autoMergeThreshold,
1383
+ maxMerges,
1384
+ dryRun,
1385
+ acquireLock: options.acquireLock,
1386
+ });
1387
+ const bandStats = {};
1388
+ if (merges.max_merges > 0) {
1389
+ bandStats[`>=${autoMergeThreshold}`] = {
1390
+ pairs: merges.losers_merged,
1391
+ merge: merges.losers_merged,
1392
+ corrects: 0,
1393
+ supersedes: 0,
1394
+ none: 0,
1395
+ merge_below_gate: 0,
1396
+ conf_sum: merges.losers_merged,
1397
+ ...(merges.skipped_metadata_mismatch > 0
1398
+ ? { metadata_skipped: merges.skipped_metadata_mismatch }
1399
+ : {}),
1400
+ };
1401
+ }
1402
+ return {
1403
+ scanned: 0,
1404
+ pairs_evaluated: 0,
1405
+ rewritten: 0,
1406
+ absorbed: 0,
1407
+ kept_linked: 0,
1408
+ marked_superseded: 0,
1409
+ marked_retracted: 0,
1410
+ below_gate: 0,
1411
+ contract_failed: 0,
1412
+ skipped_infra: 0,
1413
+ skipped_idempotent: 0,
1414
+ explicit_verified: 0,
1415
+ explicit_divergent: 0,
1416
+ cursor: (0, state_js_1.loadState)(stateDir).reconsolidationCursor ?? 0,
1417
+ merges,
1418
+ merge_pairs_applied: 0,
1419
+ merge_below_gate: 0,
1420
+ skipped_above_ceiling: 0,
1421
+ skipped_metadata_mismatch: 0,
1422
+ band_stats: bandStats,
1423
+ };
1424
+ }
1363
1425
  async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions, supersessionOptions,
1364
1426
  /** Total LLM-call ceiling across classify-tier stages (#241). The caller
1365
1427
  * reads `consolidateMaxLlmCalls` from config and passes it; unset → the
@@ -1368,7 +1430,13 @@ budgetMaxCalls,
1368
1430
  /** Soft cap on the corpus (#245). Nightly.ts reads `memorySoftCap` from
1369
1431
  * config and passes it; unset → `DEFAULT_MEMORY_SOFT_CAP` (10000). `0`
1370
1432
  * disables eviction (indefinite growth). */
1371
- memorySoftCap) {
1433
+ memorySoftCap,
1434
+ /** Reconsolidation-stage knobs (#384), threaded from config by nightly.ts
1435
+ * (correctionMinSimilarity / correctionRewriteMinConfidence) exactly like
1436
+ * supersessionOptions above; unset fields → the stage's defaults.
1437
+ * Appended AFTER the pre-#384 params so every existing positional caller
1438
+ * (tests, hosted nightly) keeps its argument meaning. */
1439
+ reconsolidationOptions) {
1372
1440
  const start = new Date();
1373
1441
  const report = {
1374
1442
  started_at: start.toISOString(),
@@ -1407,6 +1475,13 @@ memorySoftCap) {
1407
1475
  // but the cap stage is pure DB: cheap, idempotent when under cap).
1408
1476
  report.stages.memory_cap = stageMemoryCapEviction(db, dryRun, memorySoftCap ?? exports.DEFAULT_MEMORY_SOFT_CAP);
1409
1477
  if (skip) {
1478
+ // #392: the deterministic merge zone is LLM-free, so a quiet night (zero
1479
+ // new memories → this skip) still drains a pre-existing duplicate
1480
+ // backlog — the memory_cap precedent. Results ride the ONE resolution
1481
+ // stage report (telemetry's "skipped = zero LLM work" stays true), and
1482
+ // the zone never runs twice: the main path runs it INSIDE the stage, this
1483
+ // skip path returns before that.
1484
+ report.stages.reconsolidation = await skippedRunResolutionReport(db, dryRun, stateDir, reconsolidationOptions);
1410
1485
  report.status = "skipped";
1411
1486
  report.completed_at = new Date().toISOString();
1412
1487
  return report;
@@ -1455,6 +1530,11 @@ memorySoftCap) {
1455
1530
  report.stages.hub_boost = stageHubBoost(db, dryRun);
1456
1531
  // Stage 3.7: Supersession Detection (#191 Phase B)
1457
1532
  report.stages.supersession = await stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, supersessionOptions);
1533
+ // Stage 3.8: Reconsolidation (#384) — resolve corrections: rewrite
1534
+ // fact-shaped targets in place (absorbing transition-only triggers),
1535
+ // mark everything else. Rides the same shared budget under its own stage
1536
+ // label + cursor (supersession-stage pattern).
1537
+ report.stages.reconsolidation = await (0, reconsolidation_js_1.stageReconsolidation)(db, llm, budget, embedFn, dryRun, stateDir, reconsolidationOptions);
1458
1538
  // Stage 4: Decay & Prune
1459
1539
  report.stages.decay_prune = stageDecayPrune(db, dryRun);
1460
1540
  // (Memory cap eviction moved before the precheck skip — see above.)
package/dist/db.js CHANGED
@@ -511,6 +511,42 @@ const MIGRATIONS = [
511
511
  db.exec("UPDATE memories SET memory_type = 'learnings' WHERE memory_type = 'lesson'");
512
512
  },
513
513
  },
514
+ {
515
+ version: 14,
516
+ name: "reconsolidation_status_history",
517
+ up: (db) => {
518
+ // #384 reconsolidation. `memories.status` is the code-defined state
519
+ // vocabulary (NULL/active default; 'superseded'/'retracted' demote in
520
+ // ranking; 'corrected' = rewritten, never demotes; 'absorbed' =
521
+ // invisible to recall — no vector, no FTS row). NULL default with NO
522
+ // backfill: legacy supersessions stay link-driven (findSupersededIds
523
+ // already demotes them); a status is only ever written by the
524
+ // reconsolidation stage, an explicit ingest mark, or a rollback.
525
+ // `memory_history` records CONTENT REWRITES ONLY (before/after content,
526
+ // prior status, trigger dispositions) — one structure serving audit AND
527
+ // `hicortex history --rollback`. Marks need no history row: they are
528
+ // auditable via links + status. Idempotent: hasColumn + IF NOT EXISTS.
529
+ if (!hasColumn(db, "memories", "status")) {
530
+ db.exec("ALTER TABLE memories ADD COLUMN status TEXT");
531
+ }
532
+ db.exec(`
533
+ CREATE TABLE IF NOT EXISTS memory_history (
534
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
535
+ memory_id TEXT NOT NULL,
536
+ old_content TEXT NOT NULL,
537
+ new_content TEXT NOT NULL,
538
+ prev_status TEXT,
539
+ new_status TEXT,
540
+ triggers_json TEXT,
541
+ evidence_id TEXT,
542
+ confidence REAL,
543
+ cause TEXT NOT NULL,
544
+ created_at TEXT NOT NULL
545
+ )
546
+ `);
547
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_history_memory ON memory_history(memory_id)");
548
+ },
549
+ },
514
550
  ];
515
551
  /**
516
552
  * Run all pending migrations against the database.
package/dist/dedup.d.ts CHANGED
@@ -1,52 +1,101 @@
1
1
  /**
2
- * `hicortex dedup` — cluster + merge near-duplicate memories (issue #100).
2
+ * `hicortex dedup` + the nightly deterministic merge zone (issues #100, #392).
3
3
  *
4
4
  * Corpus-quality companion to `hicortex relink`/`classify-domains`: instead of
5
- * discovering NEW structure, this command collapses memories that are
6
- * near-identical (top-10 KNN cosine >= dedupMergeThreshold, default 0.92,
7
- * union-find clustered — same math as the #191 D1 duplicate-rate audit; see
8
- * cluster.ts). Default is a DRY RUN: report only, zero writes. `--apply`
9
- * executes the merge.
5
+ * discovering NEW structure, this collapses memories that are near-identical
6
+ * (top-10 KNN cosine >= threshold, default 0.92, union-find clustered — same
7
+ * math as the #191 D1 duplicate-rate audit; see cluster.ts). Two surfaces,
8
+ * ONE core:
10
9
  *
11
- * Per cluster:
10
+ * - `runDedup` — the manual CLI. Default is a DRY RUN: report only, zero
11
+ * writes. `--apply` executes the merge. Threshold resolution:
12
+ * `--threshold` > config `dedupAutoMergeThreshold` > legacy config
13
+ * `dedupMergeThreshold` > 0.92.
14
+ * - `runDeterministicMergeZone` (#392) — the nightly's LLM-free merge zone:
15
+ * pairs at/above the ceiling merge deterministically, ZERO LLM calls, under
16
+ * its own pacing cap (`dedupNightlyMaxMerges`). Called from the
17
+ * reconsolidation stage (and from the quiet-night skip path in
18
+ * consolidate.ts) so one stage report covers all resolution work.
19
+ *
20
+ * Per cluster (shared `planDedup`/`mergeCluster` core — no forks):
12
21
  * - Canonical = highest access_count (tie: oldest created_at, then
13
22
  * lexicographically smallest id — fully deterministic for audit).
14
23
  * - Losers' links are re-pointed onto the canonical (a link that would
15
24
  * become a self-link, or one whose (canonical, target) ordered pair
16
25
  * ALREADY holds an edge, is skipped rather than overwritten — see
17
- * planLinkRepoints for why `relationship` cannot be part of that guard).
26
+ * planLinkRepoints for why `relationship` cannot be part of that guard);
27
+ * the losers' own link rows are then deleted (previously cascade-deleted
28
+ * with the row).
18
29
  * - canonical.access_count/shown_count = summed across the cluster;
19
30
  * last_accessed = max; base_strength = max.
20
31
  * - Tags are UNIONED onto the canonical (weights NULL — the next nightly's
21
32
  * reconsolidation pass recomputes weights and the derived primary from
22
- * the merged tag set).
23
- * - A `dedup_log` row is written per loser BEFORE it is deleted — audit
24
- * trail AND the safety net /distill consults (mcp-server.ts) so a
25
- * deleted loser's `source_session` marker still blocks a re-ingest.
26
- * - Losers are deleted via storage.deleteMemory (cascades links/tags/
27
- * vectors/FTS).
33
+ * the merged tag set); the losers' tag rows are cleared and their domain
34
+ * set NULL — a loser must not count in moduleIndex/tag recomputes.
35
+ * - A `dedup_log` row is written per loser — audit trail AND the safety net
36
+ * /distill consults (mcp-server.ts) so an absorbed loser's
37
+ * `source_session` marker still blocks a re-ingest.
38
+ * - Losers are ABSORBED (storage.absorbMemory), not deleted (#392): status
39
+ * 'absorbed', vector + FTS rows dropped, plain row retained — invisible
40
+ * to recall, fetchable by id as evidence. Same vocabulary as the
41
+ * reconsolidation rewrite path. Merges are NOT history-rollback-able —
42
+ * dedup_log (loser_id → canonical_id) + the retained loser row is the
43
+ * record.
28
44
  *
29
- * A cluster whose members disagree on project, privacy, or source_agent is
30
- * SKIPPED entirely and listed for manual review — no --force in this release.
45
+ * A cluster whose members disagree on project or source_agent is SKIPPED
46
+ * entirely and listed for manual review — no --force in this release.
31
47
  *
32
- * Safety rails on --apply:
48
+ * Safety rails when applying (CLI and zone alike):
33
49
  * - A full DB backup (SQLite backup API) is taken FIRST, to
34
- * ~/.hicortex/backups/pre-dedup-<ISO>.db. Abort (no merges attempted) if
35
- * the backup fails.
50
+ * <state>/backups/pre-dedup-<ISO>.db, pruned to `backupRetention` newest
51
+ * (pattern-scoped: full `hicortex-*.tar.gz` artifacts keep their own
52
+ * count). The CLI aborts (no merges attempted) if the backup fails; the
53
+ * nightly zone is fail-soft (backup_failed flag, zero merges).
36
54
  * - The existing single-flight capture lock (capture.ts) is held for the
37
55
  * duration of the merge so a concurrent nightly/capture run can't race
38
- * the dedup_log bookkeeping the merge relies on.
56
+ * the dedup_log bookkeeping the merge relies on. The CLI fails fast on a
57
+ * busy lock; the zone reports lock_busy and merges nothing.
39
58
  *
40
59
  * Server-mode only (needs the local DB), like relink/classify-domains.
41
60
  */
42
61
  import type Database from "better-sqlite3";
43
62
  import { type ClusterMetadataMismatch } from "./cluster.js";
44
63
  import { acquireCaptureLock } from "./capture.js";
64
+ import type { DeterministicMergeZoneReport } from "./types.js";
45
65
  /**
46
66
  * Default merge threshold. Measured on the #191 mechanical audit corpus:
47
67
  * 89 clusters / 110 excess rows at 0.92 (data/audit-20260729/eval-report.md).
68
+ * #392: also the default `dedupAutoMergeThreshold` — the deterministic/LLM
69
+ * boundary of the unified resolution pass.
48
70
  */
49
71
  export declare const DEFAULT_DEDUP_MERGE_THRESHOLD = 0.92;
72
+ /**
73
+ * Default pacing cap on merge OPERATIONS per nightly run (#392): the zone's
74
+ * clusters and the stage's judged pair merges count against ONE cap. Bounds a
75
+ * misbehaving-distiller burst; a large backlog drains over a few nights.
76
+ * `0` disables the merge machinery entirely.
77
+ */
78
+ export declare const DEFAULT_DEDUP_NIGHTLY_MAX_MERGES = 250;
79
+ /**
80
+ * Row shape read from `memories` for merge decisions — a superset of the
81
+ * fields the Memory type declares (shown_count isn't on that interface yet).
82
+ * `status` rides along so judged-pair merges can defensively drop rows that
83
+ * were absorbed between verdict and apply.
84
+ */
85
+ interface DedupMemberRow {
86
+ id: string;
87
+ content: string;
88
+ access_count: number;
89
+ shown_count: number | null;
90
+ last_accessed: string | null;
91
+ base_strength: number;
92
+ created_at: string;
93
+ project: string | null;
94
+ privacy: string | null;
95
+ source_agent: string;
96
+ source_session: string | null;
97
+ status: string | null;
98
+ }
50
99
  export interface DedupClusterPlan {
51
100
  size: number;
52
101
  canonicalId: string;
@@ -94,8 +143,8 @@ export interface DedupReport {
94
143
  linksSkippedExisting: number;
95
144
  /** --apply only: clusters actually merged. */
96
145
  merged?: number;
97
- /** --apply only: loser rows deleted. */
98
- losersDeleted?: number;
146
+ /** --apply only: loser rows absorbed (hidden from recall, kept as evidence). */
147
+ losersAbsorbed?: number;
99
148
  /** --apply only: clusters that errored mid-merge (rolled back; left for a re-run). */
100
149
  failedClusters?: number;
101
150
  /** --apply only: path to the pre-merge backup. */
@@ -104,7 +153,7 @@ export interface DedupReport {
104
153
  export interface DedupOptions {
105
154
  /** Execute the merge. Default false = dry run (report only, zero writes). */
106
155
  apply?: boolean;
107
- /** Override config.dedupMergeThreshold for one run. */
156
+ /** Override the configured threshold for one run. */
108
157
  threshold?: number;
109
158
  /** DB path override (tests / manual snapshot verification). Defaults to resolveDbPath(). */
110
159
  dbPath?: string;
@@ -116,7 +165,7 @@ export interface DedupOptions {
116
165
  acquireLock?: typeof acquireCaptureLock;
117
166
  /**
118
167
  * Test-only failure injection: called once per cluster merge, after the
119
- * link/tag/counter writes but before the audit-log + delete step. Throwing
168
+ * link/tag/counter writes but before the audit-log + absorb step. Throwing
120
169
  * here proves a mid-merge error rolls the WHOLE cluster's writes back
121
170
  * (better-sqlite3 transaction semantics) rather than leaving a half-merged
122
171
  * cluster. Never set in production.
@@ -135,10 +184,93 @@ export interface LinkRepointPlan {
135
184
  skippedSelfLink: number;
136
185
  skippedExisting: number;
137
186
  }
187
+ /** One cluster's execution plan from `planDedup` — canonical, losers, members. */
188
+ export interface DedupMergePlan {
189
+ canonical: DedupMemberRow;
190
+ losers: DedupMemberRow[];
191
+ /** All member rows, oldest first (CLI preview lines derive from this). */
192
+ membersOldestFirst: DedupMemberRow[];
193
+ }
194
+ export interface PlanDedupResult {
195
+ /** Every cluster found at the threshold (mergeable + mismatch-skipped). */
196
+ clusterCount: number;
197
+ /** Clusters that passed the metadata rails, in discovery order. */
198
+ mergePlans: DedupMergePlan[];
199
+ mismatchSkipped: DedupMismatchCluster[];
200
+ }
201
+ /**
202
+ * Discovery + merge planning at a cosine threshold (read-only — no writes).
203
+ * The ONE clustering core shared by the manual CLI (`runDedup`) and the
204
+ * nightly deterministic merge zone (`runDeterministicMergeZone`): KNN edges
205
+ * (k=10) → union-find clusters → member load → metadata-rail classification →
206
+ * canonical pick. Never forked.
207
+ */
208
+ export declare function planDedup(db: Database.Database, threshold: number): PlanDedupResult;
209
+ export type MergeMemoryIdsResult = {
210
+ ok: true;
211
+ canonicalId: string;
212
+ loserIds: string[];
213
+ linksRepointed: number;
214
+ } | {
215
+ ok: false;
216
+ reason: "metadata_mismatch" | "no_members";
217
+ };
218
+ /**
219
+ * Merge an explicit set of memories (the judged-pair phase of #392: the
220
+ * reconsolidation stage queues verdict-confirmed pairs and applies them
221
+ * through THIS function so the merge math stays single-definition). Loads the
222
+ * LIVE rows at apply time — members that vanished or were absorbed between
223
+ * verdict and apply are dropped defensively; a metadata disagreement refuses
224
+ * the merge (both memories stay live). One transaction for the whole set.
225
+ */
226
+ export declare function mergeMemoryIds(db: Database.Database, ids: string[]): MergeMemoryIdsResult;
227
+ /**
228
+ * Take a pre-merge DB backup to <stateDir>/backups/pre-dedup-<ISO>.db and
229
+ * prune older pre-dedup backups to `backupRetention` (config, default 7;
230
+ * pattern-scoped so full `hicortex-*.tar.gz` artifacts keep their own,
231
+ * independent retention count). THROWS on failure — the callers own the
232
+ * policy: the CLI aborts, the nightly zone is fail-soft. Returns the path.
233
+ */
234
+ export declare function takePreDedupBackup(db: Database.Database, stateDir: string, config?: Record<string, unknown> | null): Promise<string>;
235
+ export interface DeterministicMergeZoneOptions {
236
+ /** State dir (lock + backup + band-stats persistence). Defaults to ~/.hicortex. */
237
+ stateDir?: string;
238
+ /** Cosine ceiling; validated (0,1] → DEFAULT_DEDUP_MERGE_THRESHOLD. */
239
+ threshold?: number;
240
+ /**
241
+ * Pacing cap on merge operations this run; validated >= 0 →
242
+ * DEFAULT_DEDUP_NIGHTLY_MAX_MERGES. `0` disables the machinery entirely
243
+ * (discovery skipped, zeroed report).
244
+ */
245
+ maxMerges?: number;
246
+ /** Discovery + bounded preview only — zero writes, no lock, no backup. */
247
+ dryRun?: boolean;
248
+ /** Config override (backupRetention) — defaults to reading stateDir/config.json. */
249
+ config?: Record<string, unknown> | null;
250
+ /** Capture-lock acquirer override (tests). Defaults to the real capture.ts lock. */
251
+ acquireLock?: typeof acquireCaptureLock;
252
+ }
253
+ /**
254
+ * The >= dedupAutoMergeThreshold band of the unified resolution pass (#392):
255
+ * planDedup discovery + per-cluster mergeCluster — LLM-free, budget-free, so
256
+ * an LLM-less night still drains duplicates. Its own short capture-lock
257
+ * window and pre-merge backup; fail-soft on a busy lock (lock_busy) and on a
258
+ * backup failure (backup_failed) — zero merges either way, never a throw.
259
+ *
260
+ * Also persists the deterministic band's cumulative statistics to state.json
261
+ * `resolutionBandStats` (label `>=threshold`; losers count as merge verdicts
262
+ * at confidence 1.0, mismatch clusters as metadata_skipped) — skipped
263
+ * entirely on dry-run. Called from the reconsolidation stage (main path) and
264
+ * from runConsolidation's quiet-night skip path — exactly one of the two per
265
+ * run.
266
+ */
267
+ export declare function runDeterministicMergeZone(db: Database.Database, opts?: DeterministicMergeZoneOptions): Promise<DeterministicMergeZoneReport>;
138
268
  /**
139
269
  * Run `hicortex dedup`. Dry run by default (options.apply falsy) — discovery
140
270
  * + merge planning only, zero writes. `options.apply` executes: backup, then
141
- * one transaction per cluster.
271
+ * one transaction per cluster. Fails fast (throws) on a busy capture lock or
272
+ * a failed backup — a deliberate manual command should be retried by the
273
+ * operator, not silently deferred (the nightly zone is the fail-soft twin).
142
274
  */
143
275
  export declare function runDedup(options?: DedupOptions): Promise<DedupReport>;
144
276
  /** Escape SQL LIKE wildcards — session ids (e.g. Hermes) can contain "_"/"%". */