@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.
package/dist/dedup.js CHANGED
@@ -1,42 +1,61 @@
1
1
  "use strict";
2
2
  /**
3
- * `hicortex dedup` — cluster + merge near-duplicate memories (issue #100).
3
+ * `hicortex dedup` + the nightly deterministic merge zone (issues #100, #392).
4
4
  *
5
5
  * Corpus-quality companion to `hicortex relink`/`classify-domains`: instead of
6
- * discovering NEW structure, this command collapses memories that are
7
- * near-identical (top-10 KNN cosine >= dedupMergeThreshold, default 0.92,
8
- * union-find clustered — same math as the #191 D1 duplicate-rate audit; see
9
- * cluster.ts). Default is a DRY RUN: report only, zero writes. `--apply`
10
- * executes the merge.
6
+ * discovering NEW structure, this collapses memories that are near-identical
7
+ * (top-10 KNN cosine >= threshold, default 0.92, union-find clustered — same
8
+ * math as the #191 D1 duplicate-rate audit; see cluster.ts). Two surfaces,
9
+ * ONE core:
11
10
  *
12
- * Per cluster:
11
+ * - `runDedup` — the manual CLI. Default is a DRY RUN: report only, zero
12
+ * writes. `--apply` executes the merge. Threshold resolution:
13
+ * `--threshold` > config `dedupAutoMergeThreshold` > legacy config
14
+ * `dedupMergeThreshold` > 0.92.
15
+ * - `runDeterministicMergeZone` (#392) — the nightly's LLM-free merge zone:
16
+ * pairs at/above the ceiling merge deterministically, ZERO LLM calls, under
17
+ * its own pacing cap (`dedupNightlyMaxMerges`). Called from the
18
+ * reconsolidation stage (and from the quiet-night skip path in
19
+ * consolidate.ts) so one stage report covers all resolution work.
20
+ *
21
+ * Per cluster (shared `planDedup`/`mergeCluster` core — no forks):
13
22
  * - Canonical = highest access_count (tie: oldest created_at, then
14
23
  * lexicographically smallest id — fully deterministic for audit).
15
24
  * - Losers' links are re-pointed onto the canonical (a link that would
16
25
  * become a self-link, or one whose (canonical, target) ordered pair
17
26
  * ALREADY holds an edge, is skipped rather than overwritten — see
18
- * planLinkRepoints for why `relationship` cannot be part of that guard).
27
+ * planLinkRepoints for why `relationship` cannot be part of that guard);
28
+ * the losers' own link rows are then deleted (previously cascade-deleted
29
+ * with the row).
19
30
  * - canonical.access_count/shown_count = summed across the cluster;
20
31
  * last_accessed = max; base_strength = max.
21
32
  * - Tags are UNIONED onto the canonical (weights NULL — the next nightly's
22
33
  * reconsolidation pass recomputes weights and the derived primary from
23
- * the merged tag set).
24
- * - A `dedup_log` row is written per loser BEFORE it is deleted — audit
25
- * trail AND the safety net /distill consults (mcp-server.ts) so a
26
- * deleted loser's `source_session` marker still blocks a re-ingest.
27
- * - Losers are deleted via storage.deleteMemory (cascades links/tags/
28
- * vectors/FTS).
34
+ * the merged tag set); the losers' tag rows are cleared and their domain
35
+ * set NULL — a loser must not count in moduleIndex/tag recomputes.
36
+ * - A `dedup_log` row is written per loser — audit trail AND the safety net
37
+ * /distill consults (mcp-server.ts) so an absorbed loser's
38
+ * `source_session` marker still blocks a re-ingest.
39
+ * - Losers are ABSORBED (storage.absorbMemory), not deleted (#392): status
40
+ * 'absorbed', vector + FTS rows dropped, plain row retained — invisible
41
+ * to recall, fetchable by id as evidence. Same vocabulary as the
42
+ * reconsolidation rewrite path. Merges are NOT history-rollback-able —
43
+ * dedup_log (loser_id → canonical_id) + the retained loser row is the
44
+ * record.
29
45
  *
30
- * A cluster whose members disagree on project, privacy, or source_agent is
31
- * SKIPPED entirely and listed for manual review — no --force in this release.
46
+ * A cluster whose members disagree on project or source_agent is SKIPPED
47
+ * entirely and listed for manual review — no --force in this release.
32
48
  *
33
- * Safety rails on --apply:
49
+ * Safety rails when applying (CLI and zone alike):
34
50
  * - A full DB backup (SQLite backup API) is taken FIRST, to
35
- * ~/.hicortex/backups/pre-dedup-<ISO>.db. Abort (no merges attempted) if
36
- * the backup fails.
51
+ * <state>/backups/pre-dedup-<ISO>.db, pruned to `backupRetention` newest
52
+ * (pattern-scoped: full `hicortex-*.tar.gz` artifacts keep their own
53
+ * count). The CLI aborts (no merges attempted) if the backup fails; the
54
+ * nightly zone is fail-soft (backup_failed flag, zero merges).
37
55
  * - The existing single-flight capture lock (capture.ts) is held for the
38
56
  * duration of the merge so a concurrent nightly/capture run can't race
39
- * the dedup_log bookkeeping the merge relies on.
57
+ * the dedup_log bookkeeping the merge relies on. The CLI fails fast on a
58
+ * busy lock; the zone reports lock_busy and merges nothing.
40
59
  *
41
60
  * Server-mode only (needs the local DB), like relink/classify-domains.
42
61
  */
@@ -74,7 +93,11 @@ var __importStar = (this && this.__importStar) || (function () {
74
93
  };
75
94
  })();
76
95
  Object.defineProperty(exports, "__esModule", { value: true });
77
- exports.DEFAULT_DEDUP_MERGE_THRESHOLD = void 0;
96
+ exports.DEFAULT_DEDUP_NIGHTLY_MAX_MERGES = exports.DEFAULT_DEDUP_MERGE_THRESHOLD = void 0;
97
+ exports.planDedup = planDedup;
98
+ exports.mergeMemoryIds = mergeMemoryIds;
99
+ exports.takePreDedupBackup = takePreDedupBackup;
100
+ exports.runDeterministicMergeZone = runDeterministicMergeZone;
78
101
  exports.runDedup = runDedup;
79
102
  exports.escapeLikeSessionId = escapeLikeSessionId;
80
103
  exports.countExistingSegment = countExistingSegment;
@@ -86,14 +109,28 @@ const db_js_1 = require("./db.js");
86
109
  const storage = __importStar(require("./storage.js"));
87
110
  const cluster_js_1 = require("./cluster.js");
88
111
  const capture_js_1 = require("./capture.js");
112
+ const state_js_1 = require("./state.js");
113
+ const config_read_js_1 = require("./config-read.js");
114
+ const backup_js_1 = require("./backup.js");
89
115
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
90
116
  /**
91
117
  * Default merge threshold. Measured on the #191 mechanical audit corpus:
92
118
  * 89 clusters / 110 excess rows at 0.92 (data/audit-20260729/eval-report.md).
119
+ * #392: also the default `dedupAutoMergeThreshold` — the deterministic/LLM
120
+ * boundary of the unified resolution pass.
93
121
  */
94
122
  exports.DEFAULT_DEDUP_MERGE_THRESHOLD = 0.92;
123
+ /**
124
+ * Default pacing cap on merge OPERATIONS per nightly run (#392): the zone's
125
+ * clusters and the stage's judged pair merges count against ONE cap. Bounds a
126
+ * misbehaving-distiller burst; a large backlog drains over a few nights.
127
+ * `0` disables the merge machinery entirely.
128
+ */
129
+ exports.DEFAULT_DEDUP_NIGHTLY_MAX_MERGES = 250;
95
130
  /** KNN neighbors considered per memory — same as the #191 audit (cluster.ts default). */
96
131
  const DEDUP_KNN_K = 10;
132
+ /** Pre-merge backup filename pattern (takePreDedupBackup) — scoped retention. */
133
+ const PRE_DEDUP_BACKUP_PATTERN = /^pre-dedup-.*\.db$/;
97
134
  function readConfig(stateDir) {
98
135
  try {
99
136
  return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(stateDir, "config.json"), "utf-8"));
@@ -102,6 +139,13 @@ function readConfig(stateDir) {
102
139
  return null;
103
140
  }
104
141
  }
142
+ /**
143
+ * Threshold resolution for the manual CLI (#392): explicit `--threshold` >
144
+ * config `dedupAutoMergeThreshold` > legacy config `dedupMergeThreshold` >
145
+ * DEFAULT. An invalid explicit value throws (existing error style); invalid
146
+ * config values fall through to the next step, matching the pre-#392
147
+ * silent-fallback boundary behavior.
148
+ */
105
149
  function resolveThreshold(explicit, config) {
106
150
  if (explicit !== undefined) {
107
151
  if (!Number.isFinite(explicit) || explicit <= 0 || explicit > 1) {
@@ -109,16 +153,19 @@ function resolveThreshold(explicit, config) {
109
153
  }
110
154
  return explicit;
111
155
  }
112
- const fromConfig = Number(config?.dedupMergeThreshold);
113
- return Number.isFinite(fromConfig) && fromConfig > 0 && fromConfig <= 1
114
- ? fromConfig
115
- : exports.DEFAULT_DEDUP_MERGE_THRESHOLD;
156
+ for (const key of ["dedupAutoMergeThreshold", "dedupMergeThreshold"]) {
157
+ const fromConfig = Number(config?.[key]);
158
+ if (Number.isFinite(fromConfig) && fromConfig > 0 && fromConfig <= 1) {
159
+ return fromConfig;
160
+ }
161
+ }
162
+ return exports.DEFAULT_DEDUP_MERGE_THRESHOLD;
116
163
  }
117
164
  function loadMembers(db, ids) {
118
165
  const placeholders = ids.map(() => "?").join(", ");
119
166
  return db
120
167
  .prepare(`SELECT id, content, access_count, shown_count, last_accessed, base_strength,
121
- created_at, project, privacy, source_agent, source_session
168
+ created_at, project, privacy, source_agent, source_session, status
122
169
  FROM memories WHERE id IN (${placeholders})`)
123
170
  .all(...ids);
124
171
  }
@@ -199,16 +246,54 @@ function planLinkRepoints(db, canonical, losers) {
199
246
  }
200
247
  return { toAdd, skippedSelfLink, skippedExisting };
201
248
  }
249
+ /**
250
+ * Discovery + merge planning at a cosine threshold (read-only — no writes).
251
+ * The ONE clustering core shared by the manual CLI (`runDedup`) and the
252
+ * nightly deterministic merge zone (`runDeterministicMergeZone`): KNN edges
253
+ * (k=10) → union-find clusters → member load → metadata-rail classification →
254
+ * canonical pick. Never forked.
255
+ */
256
+ function planDedup(db, threshold) {
257
+ const edges = (0, cluster_js_1.buildKnnEdges)(db, { k: DEDUP_KNN_K, minCosine: threshold });
258
+ const clusters = (0, cluster_js_1.clusterEdges)(edges, threshold);
259
+ const mergePlans = [];
260
+ const mismatchSkipped = [];
261
+ for (const memberIds of clusters) {
262
+ const members = loadMembers(db, memberIds);
263
+ if (members.length < 2)
264
+ continue; // defensive — a member vanished between KNN and load
265
+ const mismatch = (0, cluster_js_1.clusterMetadataMismatch)(members);
266
+ if (mismatch.projectMismatch || mismatch.sourceAgentMismatch) {
267
+ mismatchSkipped.push({ size: members.length, memberIds: members.map((m) => m.id), mismatch });
268
+ continue;
269
+ }
270
+ const { canonical, losers } = pickCanonical(members);
271
+ mergePlans.push({
272
+ canonical,
273
+ losers,
274
+ membersOldestFirst: [...members].sort((a, b) => a.created_at.localeCompare(b.created_at)),
275
+ });
276
+ }
277
+ return { clusterCount: clusters.length, mergePlans, mismatchSkipped };
278
+ }
202
279
  /**
203
280
  * Apply one cluster's merge. Pure DB writes against the passed connection —
204
281
  * the caller wraps this in db.transaction() so a mid-merge error rolls back
205
282
  * the whole cluster (dup-over-loss: a failed cluster is retried on a later
206
- * `dedup --apply`, never left half-merged).
283
+ * run, never left half-merged).
284
+ *
285
+ * #392 absorb semantics: losers LEAVE RECALL but stay fetchable by id —
286
+ * links re-pointed onto the canonical then deleted from the losers, tags
287
+ * unioned onto the canonical then cleared from the losers (domain NULL),
288
+ * counters summed, a dedup_log row written, and the loser absorbed via
289
+ * storage.absorbMemory (status 'absorbed', vector + FTS rows dropped, plain
290
+ * row retained as evidence). This mirrors the reconsolidation rewrite path's
291
+ * absorb mechanics exactly — one vocabulary, one primitive.
207
292
  *
208
293
  * Returns the link-repoint plan that was actually applied (computed live,
209
294
  * here, against current DB state — NOT a caller-supplied discovery-time
210
- * snapshot, so it stays correct even if an earlier cluster in the same
211
- * --apply run already rewrote a link that touches this cluster).
295
+ * snapshot, so it stays correct even if an earlier cluster in the same run
296
+ * already rewrote a link that touches this cluster).
212
297
  */
213
298
  function mergeCluster(db, canonical, losers, injectFailure) {
214
299
  // 1. Re-point losers' links onto the canonical.
@@ -216,9 +301,16 @@ function mergeCluster(db, canonical, losers, injectFailure) {
216
301
  for (const link of plan.toAdd) {
217
302
  storage.addLink(db, link.source, link.target, link.relationship, link.strength);
218
303
  }
219
- // 2. Union tags onto the canonical. Weights NULL — the next nightly's
220
- // reconsolidation pass (recomputeAllTagWeights/refreshPrimaries) recomputes
221
- // them and the derived primary from the merged tag set.
304
+ // 2. Delete the losers' own link rows — the pre-#392 delete cascaded them
305
+ // with the row; with the row retained, the stale edges must go explicitly
306
+ // (they were either re-pointed in step 1 or deliberately skipped).
307
+ const deleteLoserLinks = db.prepare("DELETE FROM memory_links WHERE source_id = ? OR target_id = ?");
308
+ for (const loser of losers)
309
+ deleteLoserLinks.run(loser.id, loser.id);
310
+ // 3. Union tags onto the canonical (reads the losers' tags BEFORE they are
311
+ // cleared below). Weights NULL — the next nightly's reconsolidation pass
312
+ // (recomputeAllTagWeights/refreshPrimaries) recomputes them and the derived
313
+ // primary from the merged tag set.
222
314
  const allTags = new Set(storage.getMemoryTags(db, canonical.id));
223
315
  for (const loser of losers) {
224
316
  for (const tag of storage.getMemoryTags(db, loser.id))
@@ -230,7 +322,14 @@ function mergeCluster(db, canonical, losers, injectFailure) {
230
322
  weights: Object.fromEntries(tagList.map((t) => [t, null])),
231
323
  });
232
324
  }
233
- // 3. Merge counters onto the canonical.
325
+ // 4. Clear the losers' tag rows + domain NULL — closest to the old delete
326
+ // semantics: an absorbed loser must not count in moduleIndex/tag recomputes.
327
+ const clearLoserTags = db.prepare("DELETE FROM memory_tags WHERE memory_id = ?");
328
+ for (const loser of losers) {
329
+ clearLoserTags.run(loser.id);
330
+ storage.updateMemory(db, loser.id, { domain: null });
331
+ }
332
+ // 5. Merge counters onto the canonical.
234
333
  const accessCount = canonical.access_count + losers.reduce((s, l) => s + l.access_count, 0);
235
334
  const shownCount = (canonical.shown_count ?? 0) + losers.reduce((s, l) => s + (l.shown_count ?? 0), 0);
236
335
  const lastAccessed = [canonical, ...losers]
@@ -246,22 +345,229 @@ function mergeCluster(db, canonical, losers, injectFailure) {
246
345
  base_strength: baseStrength,
247
346
  });
248
347
  injectFailure?.(canonical.id);
249
- // 4. Audit trail (BEFORE delete — dedup_log is the only surviving record of
250
- // a loser's source_session) then delete each loser (cascades links/tags/
251
- // vectors/FTS via storage.deleteMemory).
348
+ // 6. Audit trail (dedup_log is the merge record — and the only surviving
349
+ // marker of a loser's source_session) then absorb each loser (never delete:
350
+ // the row stays as evidence, session lineage, and the dedup_log companion).
252
351
  const mergedAt = new Date().toISOString();
253
352
  const logStmt = db.prepare(`INSERT OR REPLACE INTO dedup_log (loser_id, canonical_id, source_session, content_head, merged_at)
254
353
  VALUES (?, ?, ?, ?, ?)`);
255
354
  for (const loser of losers) {
256
355
  logStmt.run(loser.id, canonical.id, loser.source_session, loser.content.slice(0, 200), mergedAt);
257
- storage.deleteMemory(db, loser.id);
356
+ storage.absorbMemory(db, loser.id);
258
357
  }
259
358
  return plan;
260
359
  }
360
+ /**
361
+ * Merge an explicit set of memories (the judged-pair phase of #392: the
362
+ * reconsolidation stage queues verdict-confirmed pairs and applies them
363
+ * through THIS function so the merge math stays single-definition). Loads the
364
+ * LIVE rows at apply time — members that vanished or were absorbed between
365
+ * verdict and apply are dropped defensively; a metadata disagreement refuses
366
+ * the merge (both memories stay live). One transaction for the whole set.
367
+ */
368
+ function mergeMemoryIds(db, ids) {
369
+ const unique = [...new Set(ids)];
370
+ const members = loadMembers(db, unique).filter((m) => m.status !== "absorbed");
371
+ if (members.length < 2)
372
+ return { ok: false, reason: "no_members" };
373
+ const mismatch = (0, cluster_js_1.clusterMetadataMismatch)(members);
374
+ if (mismatch.projectMismatch || mismatch.sourceAgentMismatch) {
375
+ return { ok: false, reason: "metadata_mismatch" };
376
+ }
377
+ const { canonical, losers } = pickCanonical(members);
378
+ const tx = db.transaction(() => mergeCluster(db, canonical, losers));
379
+ const plan = tx();
380
+ return {
381
+ ok: true,
382
+ canonicalId: canonical.id,
383
+ loserIds: losers.map((l) => l.id),
384
+ linksRepointed: plan.toAdd.length,
385
+ };
386
+ }
387
+ // ---------------------------------------------------------------------------
388
+ // Pre-merge backup (shared by the CLI and the nightly zone)
389
+ // ---------------------------------------------------------------------------
390
+ /**
391
+ * Take a pre-merge DB backup to <stateDir>/backups/pre-dedup-<ISO>.db and
392
+ * prune older pre-dedup backups to `backupRetention` (config, default 7;
393
+ * pattern-scoped so full `hicortex-*.tar.gz` artifacts keep their own,
394
+ * independent retention count). THROWS on failure — the callers own the
395
+ * policy: the CLI aborts, the nightly zone is fail-soft. Returns the path.
396
+ */
397
+ async function takePreDedupBackup(db, stateDir, config) {
398
+ const backupDir = (0, node_path_1.join)(stateDir, "backups");
399
+ (0, node_fs_1.mkdirSync)(backupDir, { recursive: true });
400
+ const backupPath = (0, node_path_1.join)(backupDir, `pre-dedup-${new Date().toISOString().replace(/[:.]/g, "-")}.db`);
401
+ await db.backup(backupPath);
402
+ const retention = (0, config_read_js_1.readNonNegativeConfig)(config ?? {}, "backupRetention", backup_js_1.DEFAULT_BACKUP_RETENTION);
403
+ (0, backup_js_1.pruneBackupArtifacts)(backupDir, retention, PRE_DEDUP_BACKUP_PATTERN);
404
+ return backupPath;
405
+ }
406
+ /**
407
+ * The >= dedupAutoMergeThreshold band of the unified resolution pass (#392):
408
+ * planDedup discovery + per-cluster mergeCluster — LLM-free, budget-free, so
409
+ * an LLM-less night still drains duplicates. Its own short capture-lock
410
+ * window and pre-merge backup; fail-soft on a busy lock (lock_busy) and on a
411
+ * backup failure (backup_failed) — zero merges either way, never a throw.
412
+ *
413
+ * Also persists the deterministic band's cumulative statistics to state.json
414
+ * `resolutionBandStats` (label `>=threshold`; losers count as merge verdicts
415
+ * at confidence 1.0, mismatch clusters as metadata_skipped) — skipped
416
+ * entirely on dry-run. Called from the reconsolidation stage (main path) and
417
+ * from runConsolidation's quiet-night skip path — exactly one of the two per
418
+ * run.
419
+ */
420
+ async function runDeterministicMergeZone(db, opts = {}) {
421
+ const validNumber = (v, fallback, ok) => {
422
+ const n = Number(v);
423
+ return Number.isFinite(n) && ok(n) ? n : fallback;
424
+ };
425
+ const threshold = validNumber(opts.threshold, exports.DEFAULT_DEDUP_MERGE_THRESHOLD, (n) => n > 0 && n <= 1);
426
+ const maxMerges = validNumber(opts.maxMerges, exports.DEFAULT_DEDUP_NIGHTLY_MAX_MERGES, (n) => n >= 0);
427
+ const stateDir = opts.stateDir ?? HICORTEX_HOME;
428
+ const dryRun = opts.dryRun ?? false;
429
+ // 0 = the merge machinery is disabled — skip discovery entirely.
430
+ if (maxMerges === 0) {
431
+ return {
432
+ threshold, max_merges: 0, clusters_found: 0, mergeable_clusters: 0,
433
+ merged_clusters: 0, losers_merged: 0, links_repointed: 0,
434
+ skipped_metadata_mismatch: 0, capped: 0, failed: 0,
435
+ };
436
+ }
437
+ try {
438
+ const plan = planDedup(db, threshold);
439
+ const report = {
440
+ threshold,
441
+ max_merges: maxMerges,
442
+ clusters_found: plan.clusterCount,
443
+ mergeable_clusters: plan.mergePlans.length,
444
+ merged_clusters: 0,
445
+ losers_merged: 0,
446
+ links_repointed: 0,
447
+ skipped_metadata_mismatch: plan.mismatchSkipped.length,
448
+ capped: 0,
449
+ failed: 0,
450
+ };
451
+ // Dry-run: discovery counts + a bounded preview (first 10 clusters) only —
452
+ // zero writes, no lock, no backup, no state.json persistence.
453
+ if (dryRun) {
454
+ report.preview = plan.mergePlans.slice(0, 10).map((p) => ({
455
+ size: p.membersOldestFirst.length,
456
+ canonical_id: p.canonical.id,
457
+ loser_ids: p.losers.map((l) => l.id),
458
+ }));
459
+ return report;
460
+ }
461
+ // Cumulative deterministic-band stats (state.json) — one write at zone
462
+ // end, on every apply-path exit, never when there is nothing to record.
463
+ const persistBand = () => {
464
+ if (report.losers_merged === 0 && report.skipped_metadata_mismatch === 0)
465
+ return;
466
+ (0, state_js_1.updateState)((s) => {
467
+ const label = `>=${threshold}`;
468
+ const bands = s.resolutionBandStats ?? {};
469
+ const b = bands[label] ?? {
470
+ pairs: 0, merge: 0, corrects: 0, supersedes: 0, none: 0,
471
+ merge_below_gate: 0, conf_sum: 0,
472
+ };
473
+ bands[label] = {
474
+ ...b,
475
+ pairs: b.pairs + report.losers_merged,
476
+ merge: b.merge + report.losers_merged,
477
+ // Deterministic merges carry no verdict — model confidence 1.0 each
478
+ // (the calibration line: measured ~100% same-memory at the ceiling).
479
+ conf_sum: b.conf_sum + report.losers_merged,
480
+ metadata_skipped: (b.metadata_skipped ?? 0) + report.skipped_metadata_mismatch,
481
+ };
482
+ s.resolutionBandStats = bands;
483
+ }, stateDir);
484
+ };
485
+ // Idle corpus: nothing mergeable at the ceiling — no lock window, no
486
+ // backup (a clean corpus pays discovery only; the metadata rails' skips
487
+ // still record in the band stats). This is the common nightly case.
488
+ if (plan.mergePlans.length === 0) {
489
+ persistBand();
490
+ return report;
491
+ }
492
+ // Short single-flight lock window (waitMs 0): a busy capture/nightly run
493
+ // defers the whole zone to the next run — fail-soft, never a wait.
494
+ const acquire = opts.acquireLock ?? capture_js_1.acquireCaptureLock;
495
+ const release = await acquire(stateDir, 0);
496
+ if (!release) {
497
+ report.lock_busy = true;
498
+ persistBand();
499
+ console.warn(`[hicortex] deterministic-merge zone: capture lock busy — zero merges this run (retried next run).`);
500
+ return report;
501
+ }
502
+ try {
503
+ // Backup FIRST — abort all merges (fail-soft) if it fails.
504
+ let backupPath;
505
+ try {
506
+ const config = opts.config !== undefined ? opts.config : readConfig(stateDir);
507
+ backupPath = await takePreDedupBackup(db, stateDir, config);
508
+ }
509
+ catch (err) {
510
+ report.backup_failed = true;
511
+ console.error(`[hicortex] deterministic-merge zone: pre-merge backup failed ` +
512
+ `(${err instanceof Error ? err.message : String(err)}) — zero merges attempted.`);
513
+ persistBand();
514
+ return report;
515
+ }
516
+ report.backup_path = backupPath;
517
+ // Discovery order, capped at maxMerges merge OPERATIONS. Capped
518
+ // clusters wait for the cap (they are the zone's backlog — the verdict
519
+ // scan never touches them), so a large pre-existing corpus drains over
520
+ // a few nights.
521
+ const toAttempt = plan.mergePlans.slice(0, maxMerges);
522
+ report.capped = plan.mergePlans.length - toAttempt.length;
523
+ for (const p of toAttempt) {
524
+ try {
525
+ const tx = db.transaction(() => mergeCluster(db, p.canonical, p.losers));
526
+ const appliedPlan = tx();
527
+ report.merged_clusters++;
528
+ report.losers_merged += p.losers.length;
529
+ report.links_repointed += appliedPlan.toAdd.length;
530
+ }
531
+ catch (err) {
532
+ report.failed++;
533
+ console.error(`[hicortex] deterministic-merge zone: cluster merge FAILED (canonical ` +
534
+ `${p.canonical.id.slice(0, 8)}): ${err instanceof Error ? err.message : String(err)} ` +
535
+ `— rolled back, left for a re-run`);
536
+ }
537
+ }
538
+ console.log(`[hicortex] deterministic-merge zone (>= ${threshold}): ${report.merged_clusters}/${plan.mergePlans.length} ` +
539
+ `cluster(s) merged, ${report.losers_merged} loser(s) absorbed, ` +
540
+ `${report.skipped_metadata_mismatch} skipped (metadata mismatch)` +
541
+ (report.capped > 0 ? `, ${report.capped} capped (dedupNightlyMaxMerges)` : "") +
542
+ (report.failed > 0 ? `, ${report.failed} FAILED` : ""));
543
+ persistBand();
544
+ return report;
545
+ }
546
+ finally {
547
+ release();
548
+ }
549
+ }
550
+ catch (err) {
551
+ // Total fail-soft: the zone must never take the nightly down. A
552
+ // discovery-level failure is logged and reported as an empty run.
553
+ console.error(`[hicortex] deterministic-merge zone failed: ${err instanceof Error ? err.message : String(err)} ` +
554
+ `(no merges attempted; retried next run).`);
555
+ return {
556
+ threshold, max_merges: maxMerges, clusters_found: 0, mergeable_clusters: 0,
557
+ merged_clusters: 0, losers_merged: 0, links_repointed: 0,
558
+ skipped_metadata_mismatch: 0, capped: 0, failed: 0,
559
+ };
560
+ }
561
+ }
562
+ // ---------------------------------------------------------------------------
563
+ // The manual CLI (`hicortex dedup`)
564
+ // ---------------------------------------------------------------------------
261
565
  /**
262
566
  * Run `hicortex dedup`. Dry run by default (options.apply falsy) — discovery
263
567
  * + merge planning only, zero writes. `options.apply` executes: backup, then
264
- * one transaction per cluster.
568
+ * one transaction per cluster. Fails fast (throws) on a busy capture lock or
569
+ * a failed backup — a deliberate manual command should be retried by the
570
+ * operator, not silently deferred (the nightly zone is the fail-soft twin).
265
571
  */
266
572
  async function runDedup(options = {}) {
267
573
  const stateDir = options.stateDir ?? HICORTEX_HOME;
@@ -277,32 +583,19 @@ async function runDedup(options = {}) {
277
583
  const db = (0, db_js_1.initDb)(dbPath);
278
584
  try {
279
585
  console.log(`[hicortex] dedup starting (${apply ? "APPLY" : "dry-run"}): threshold ${threshold}, db ${dbPath}`);
280
- const edges = (0, cluster_js_1.buildKnnEdges)(db, { k: DEDUP_KNN_K, minCosine: threshold });
281
- const clusters = (0, cluster_js_1.clusterEdges)(edges, threshold);
586
+ // Shared discovery core (planDedup) — the manual CLI and the nightly
587
+ // zone must never disagree on what a cluster is.
588
+ const plan = planDedup(db, threshold);
282
589
  const mergeable = [];
283
- const mismatchSkipped = [];
284
- const plans = [];
285
- for (const memberIds of clusters) {
286
- const members = loadMembers(db, memberIds);
287
- if (members.length < 2)
288
- continue; // defensive — a member vanished between KNN and load
289
- const mismatch = (0, cluster_js_1.clusterMetadataMismatch)(members);
290
- if (mismatch.projectMismatch || mismatch.sourceAgentMismatch) {
291
- mismatchSkipped.push({ size: members.length, memberIds: members.map((m) => m.id), mismatch });
292
- continue;
293
- }
294
- const { canonical, losers } = pickCanonical(members);
295
- plans.push({ canonical, losers });
590
+ for (const p of plan.mergePlans) {
296
591
  // Read-only preview against the CURRENT DB state — see planLinkRepoints
297
592
  // for why apply recomputes this live rather than reusing this snapshot.
298
- const linkPlan = planLinkRepoints(db, canonical, losers);
593
+ const linkPlan = planLinkRepoints(db, p.canonical, p.losers);
299
594
  mergeable.push({
300
- size: members.length,
301
- canonicalId: canonical.id,
302
- loserIds: losers.map((l) => l.id),
303
- members: [...members]
304
- .sort((a, b) => a.created_at.localeCompare(b.created_at))
305
- .map((m) => ({
595
+ size: p.membersOldestFirst.length,
596
+ canonicalId: p.canonical.id,
597
+ loserIds: p.losers.map((l) => l.id),
598
+ members: p.membersOldestFirst.map((m) => ({
306
599
  id: m.id,
307
600
  created_at: m.created_at,
308
601
  access_count: m.access_count,
@@ -318,14 +611,14 @@ async function runDedup(options = {}) {
318
611
  const report = {
319
612
  dryRun: !apply,
320
613
  threshold,
321
- clusterCount: clusters.length,
614
+ clusterCount: plan.clusterCount,
322
615
  mergeable,
323
- mismatchSkipped,
616
+ mismatchSkipped: plan.mismatchSkipped,
324
617
  plannedMerges,
325
618
  linksSkippedExisting: linksSkippedExistingPreview,
326
619
  };
327
- console.log(`[hicortex] dedup: ${clusters.length} cluster(s) found, ${mergeable.length} mergeable ` +
328
- `(${plannedMerges} row(s) would be removed), ${mismatchSkipped.length} skipped (metadata mismatch), ` +
620
+ console.log(`[hicortex] dedup: ${plan.clusterCount} cluster(s) found, ${mergeable.length} mergeable ` +
621
+ `(${plannedMerges} row(s) would be absorbed), ${plan.mismatchSkipped.length} skipped (metadata mismatch), ` +
329
622
  `${linksSkippedExistingPreview} link(s) would be skipped (existing edge on the canonical)`);
330
623
  if (!apply) {
331
624
  for (const c of mergeable) {
@@ -334,7 +627,7 @@ async function runDedup(options = {}) {
334
627
  `links: ${c.linksRepointed} to re-point, ${c.linksSkippedExisting} skipped (existing edge), ` +
335
628
  `${c.linksSkippedSelfLink} skipped (self-link)`);
336
629
  }
337
- for (const c of mismatchSkipped) {
630
+ for (const c of plan.mismatchSkipped) {
338
631
  const reasons = Object.entries(c.mismatch)
339
632
  .filter(([, v]) => v)
340
633
  .map(([k]) => k)
@@ -354,11 +647,9 @@ async function runDedup(options = {}) {
354
647
  }
355
648
  try {
356
649
  // Backup FIRST — abort entirely (no merges attempted) if it fails.
357
- const backupDir = (0, node_path_1.join)(stateDir, "backups");
358
- (0, node_fs_1.mkdirSync)(backupDir, { recursive: true });
359
- const backupPath = (0, node_path_1.join)(backupDir, `pre-dedup-${new Date().toISOString().replace(/[:.]/g, "-")}.db`);
650
+ let backupPath;
360
651
  try {
361
- await db.backup(backupPath);
652
+ backupPath = await takePreDedupBackup(db, stateDir, config);
362
653
  }
363
654
  catch (err) {
364
655
  throw new Error(`[hicortex] dedup --apply aborted: backup failed (${err instanceof Error ? err.message : String(err)}). No merges attempted.`);
@@ -366,33 +657,34 @@ async function runDedup(options = {}) {
366
657
  console.log(`[hicortex] Backup written: ${backupPath}`);
367
658
  report.backupPath = backupPath;
368
659
  let merged = 0;
369
- let losersDeleted = 0;
660
+ let losersAbsorbed = 0;
370
661
  let failedClusters = 0;
371
662
  // Recomputed from the ACTUAL, live per-cluster merges below (may differ
372
663
  // from the discovery-time preview if an earlier cluster in this same
373
664
  // run rewrote a link that a later cluster's plan also touches).
374
665
  let linksSkippedExistingApplied = 0;
375
- for (const plan of plans) {
666
+ for (const p of plan.mergePlans) {
376
667
  try {
377
- const tx = db.transaction(() => mergeCluster(db, plan.canonical, plan.losers, options._injectFailureAfterWrites));
668
+ const tx = db.transaction(() => mergeCluster(db, p.canonical, p.losers, options._injectFailureAfterWrites));
378
669
  const appliedPlan = tx();
379
670
  merged++;
380
- losersDeleted += plan.losers.length;
671
+ losersAbsorbed += p.losers.length;
381
672
  linksSkippedExistingApplied += appliedPlan.skippedExisting;
382
- console.log(`[hicortex] merged cluster: canonical ${plan.canonical.id.slice(0, 8)} absorbed ${plan.losers.length} loser(s), ` +
673
+ console.log(`[hicortex] merged cluster: canonical ${p.canonical.id.slice(0, 8)} absorbed ${p.losers.length} loser(s), ` +
383
674
  `${appliedPlan.toAdd.length} link(s) re-pointed, ${appliedPlan.skippedExisting} skipped (existing edge)`);
384
675
  }
385
676
  catch (err) {
386
677
  failedClusters++;
387
- console.error(`[hicortex] cluster merge FAILED (canonical ${plan.canonical.id.slice(0, 8)}): ` +
678
+ console.error(`[hicortex] cluster merge FAILED (canonical ${p.canonical.id.slice(0, 8)}): ` +
388
679
  `${err instanceof Error ? err.message : String(err)} — rolled back, left for a re-run`);
389
680
  }
390
681
  }
391
682
  report.merged = merged;
392
- report.losersDeleted = losersDeleted;
683
+ report.losersAbsorbed = losersAbsorbed;
393
684
  report.failedClusters = failedClusters;
394
685
  report.linksSkippedExisting = linksSkippedExistingApplied;
395
- console.log(`[hicortex] dedup complete: ${merged} cluster(s) merged, ${losersDeleted} loser(s) deleted` +
686
+ console.log(`[hicortex] dedup complete: ${merged} cluster(s) merged, ${losersAbsorbed} loser(s) absorbed ` +
687
+ `(hidden from recall, kept as evidence)` +
396
688
  (failedClusters > 0 ? `, ${failedClusters} cluster(s) FAILED (see errors above)` : ""));
397
689
  return report;
398
690
  }
@@ -408,12 +700,13 @@ async function runDedup(options = {}) {
408
700
  // /distill dedup_log consultation (shared with mcp-server.ts)
409
701
  // ---------------------------------------------------------------------------
410
702
  //
411
- // A merged-away loser's `source_session` marker moves to `dedup_log` (see
412
- // mergeCluster above) before the memories row is deleted. /distill's dedup
413
- // prechecks must therefore consult BOTH tables — otherwise a
414
- // `--recapture-window` run (or any retried capture) could re-ingest content a
415
- // dedup merge already consolidated, because the only memories row carrying
416
- // that session's marker is gone.
703
+ // A merged-away loser's `source_session` marker is recorded in `dedup_log`
704
+ // (see mergeCluster above). Since #392 the loser row itself is retained
705
+ // (absorbed, not deleted), so the marker survives on the row too — but
706
+ // pre-#392 merges DELETED their losers, and /distill's dedup prechecks must
707
+ // consult BOTH tables so a `--recapture-window` run (or any retried capture)
708
+ // can never re-ingest content a dedup merge already consolidated regardless
709
+ // of which era merged it.
417
710
  /** Escape SQL LIKE wildcards — session ids (e.g. Hermes) can contain "_"/"%". */
418
711
  function escapeLikeSessionId(s) {
419
712
  return s.replace(/[\\%_]/g, (m) => "\\" + m);
@@ -273,8 +273,10 @@ async function classifyMemoryTags(content, project, domains, llm, onUsage) {
273
273
  for (let attempt = 0; attempt < 2; attempt++) {
274
274
  let raw;
275
275
  try {
276
- // ~64 tokens covers a short JSON object with a handful of tags.
277
- const r = await llm.completeClassify(prompt, 64);
276
+ // No per-call cap (#391): the classify-tier ceiling (classifyMaxTokens,
277
+ // default 1024) resolves inside completeClassify — a hardcoded 64
278
+ // starved reasoning models whose thinking ate the whole output budget.
279
+ const r = await llm.completeClassify(prompt);
278
280
  raw = r.text;
279
281
  threw = false;
280
282
  // Surface the usage ONLY when this attempt's reply parses (below). Hold