@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/README.md +4 -1
- package/dist/backup.d.ts +12 -8
- package/dist/backup.js +13 -9
- package/dist/claude-desktop.d.ts +138 -0
- package/dist/claude-desktop.js +251 -0
- package/dist/cli.d.ts +6 -2
- package/dist/cli.js +62 -3
- package/dist/consolidate.d.ts +8 -1
- package/dist/consolidate.js +82 -2
- package/dist/db.js +36 -0
- package/dist/dedup.d.ts +157 -25
- package/dist/dedup.js +376 -83
- package/dist/domain-classify.js +4 -2
- package/dist/index.js +7 -7
- package/dist/init.d.ts +4 -1
- package/dist/init.js +103 -1
- package/dist/llm.d.ts +19 -13
- package/dist/llm.js +25 -14
- package/dist/mcp-server.d.ts +6 -0
- package/dist/mcp-server.js +84 -13
- package/dist/mcp-stdio.js +6 -1
- package/dist/memory-instructions.d.ts +18 -0
- package/dist/memory-instructions.js +39 -2
- package/dist/nightly.js +14 -1
- package/dist/reconsolidation.d.ts +323 -0
- package/dist/reconsolidation.js +1226 -0
- package/dist/retrieval.d.ts +14 -0
- package/dist/retrieval.js +41 -3
- package/dist/state.d.ts +23 -1
- package/dist/storage.d.ts +25 -0
- package/dist/storage.js +49 -7
- package/dist/type-classify.js +4 -2
- package/dist/types.d.ts +188 -0
- package/hermes-plugin/hicortex/provider.py +29 -17
- package/opencode-plugin/hicortex/index.ts +7 -7
- package/package.json +1 -1
- package/pi-extension/hicortex/index.ts +7 -7
- package/server.json +2 -2
package/dist/dedup.js
CHANGED
|
@@ -1,42 +1,61 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* `hicortex dedup`
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
31
|
-
*
|
|
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
|
|
49
|
+
* Safety rails when applying (CLI and zone alike):
|
|
34
50
|
* - A full DB backup (SQLite backup API) is taken FIRST, to
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
220
|
-
//
|
|
221
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
250
|
-
// a loser's source_session) then
|
|
251
|
-
//
|
|
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.
|
|
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
|
-
|
|
281
|
-
|
|
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
|
|
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:
|
|
301
|
-
canonicalId: canonical.id,
|
|
302
|
-
loserIds: losers.map((l) => l.id),
|
|
303
|
-
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:
|
|
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: ${
|
|
328
|
-
`(${plannedMerges} row(s) would be
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
666
|
+
for (const p of plan.mergePlans) {
|
|
376
667
|
try {
|
|
377
|
-
const tx = db.transaction(() => mergeCluster(db,
|
|
668
|
+
const tx = db.transaction(() => mergeCluster(db, p.canonical, p.losers, options._injectFailureAfterWrites));
|
|
378
669
|
const appliedPlan = tx();
|
|
379
670
|
merged++;
|
|
380
|
-
|
|
671
|
+
losersAbsorbed += p.losers.length;
|
|
381
672
|
linksSkippedExistingApplied += appliedPlan.skippedExisting;
|
|
382
|
-
console.log(`[hicortex] merged cluster: canonical ${
|
|
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 ${
|
|
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.
|
|
683
|
+
report.losersAbsorbed = losersAbsorbed;
|
|
393
684
|
report.failedClusters = failedClusters;
|
|
394
685
|
report.linksSkippedExisting = linksSkippedExistingApplied;
|
|
395
|
-
console.log(`[hicortex] dedup complete: ${merged} cluster(s) merged, ${
|
|
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
|
|
412
|
-
// mergeCluster above)
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
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);
|
package/dist/domain-classify.js
CHANGED
|
@@ -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
|
-
//
|
|
277
|
-
|
|
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
|