@gamaze/hicortex 0.14.3 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +40 -0
- package/dist/cluster.d.ts +51 -0
- package/dist/cluster.js +118 -0
- package/dist/consolidate.d.ts +63 -1
- package/dist/consolidate.js +228 -2
- package/dist/db.js +26 -0
- package/dist/dedup.d.ts +157 -0
- package/dist/dedup.js +445 -0
- package/dist/eval/decay-eval.d.ts +110 -0
- package/dist/eval/decay-eval.js +252 -0
- package/dist/eval/dups.d.ts +100 -0
- package/dist/eval/dups.js +174 -0
- package/dist/eval/eval-db.d.ts +25 -0
- package/dist/eval/eval-db.js +67 -0
- package/dist/eval/graph-eval.d.ts +76 -0
- package/dist/eval/graph-eval.js +200 -0
- package/dist/eval/reflection-census.d.ts +19 -0
- package/dist/eval/reflection-census.js +25 -0
- package/dist/eval/run-eval.d.ts +17 -0
- package/dist/eval/run-eval.js +275 -0
- package/dist/mcp-server.js +30 -11
- package/dist/memory-instructions.d.ts +38 -0
- package/dist/memory-instructions.js +63 -0
- package/dist/nightly.js +4 -0
- package/dist/relink.d.ts +3 -2
- package/dist/relink.js +6 -11
- package/dist/seed-lesson.d.ts +1 -1
- package/dist/seed-lesson.js +1 -1
- package/dist/state.d.ts +10 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +23 -0
- package/dist/types.d.ts +15 -0
- package/package.json +2 -1
package/dist/dedup.d.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hicortex dedup` — cluster + merge near-duplicate memories (issue #100).
|
|
3
|
+
*
|
|
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.
|
|
10
|
+
*
|
|
11
|
+
* Per cluster:
|
|
12
|
+
* - Canonical = highest access_count (tie: oldest created_at, then
|
|
13
|
+
* lexicographically smallest id — fully deterministic for audit).
|
|
14
|
+
* - Losers' links are re-pointed onto the canonical (a link that would
|
|
15
|
+
* become a self-link, or one whose (canonical, target) ordered pair
|
|
16
|
+
* ALREADY holds an edge, is skipped rather than overwritten — see
|
|
17
|
+
* planLinkRepoints for why `relationship` cannot be part of that guard).
|
|
18
|
+
* - canonical.access_count/shown_count = summed across the cluster;
|
|
19
|
+
* last_accessed = max; base_strength = max.
|
|
20
|
+
* - Tags are UNIONED onto the canonical (weights NULL — the next nightly's
|
|
21
|
+
* 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).
|
|
28
|
+
*
|
|
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.
|
|
31
|
+
*
|
|
32
|
+
* Safety rails on --apply:
|
|
33
|
+
* - 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.
|
|
36
|
+
* - The existing single-flight capture lock (capture.ts) is held for the
|
|
37
|
+
* duration of the merge so a concurrent nightly/capture run can't race
|
|
38
|
+
* the dedup_log bookkeeping the merge relies on.
|
|
39
|
+
*
|
|
40
|
+
* Server-mode only (needs the local DB), like relink/classify-domains.
|
|
41
|
+
*/
|
|
42
|
+
import type Database from "better-sqlite3";
|
|
43
|
+
import { type ClusterMetadataMismatch } from "./cluster.js";
|
|
44
|
+
import { acquireCaptureLock } from "./capture.js";
|
|
45
|
+
/**
|
|
46
|
+
* Default merge threshold. Measured on the #191 mechanical audit corpus:
|
|
47
|
+
* 89 clusters / 110 excess rows at 0.92 (data/audit-20260729/eval-report.md).
|
|
48
|
+
*/
|
|
49
|
+
export declare const DEFAULT_DEDUP_MERGE_THRESHOLD = 0.92;
|
|
50
|
+
export interface DedupClusterPlan {
|
|
51
|
+
size: number;
|
|
52
|
+
canonicalId: string;
|
|
53
|
+
loserIds: string[];
|
|
54
|
+
/** Preview lines for the dry-run report / manual review, oldest first. */
|
|
55
|
+
members: Array<{
|
|
56
|
+
id: string;
|
|
57
|
+
created_at: string;
|
|
58
|
+
access_count: number;
|
|
59
|
+
preview: string;
|
|
60
|
+
}>;
|
|
61
|
+
/** Losers' links that will be (or were) re-pointed onto the canonical. */
|
|
62
|
+
linksRepointed: number;
|
|
63
|
+
/** Would-be self-links dropped (both endpoints normalize to the canonical). */
|
|
64
|
+
linksSkippedSelfLink: number;
|
|
65
|
+
/**
|
|
66
|
+
* Losers' links dropped because the canonical (or an earlier loser in this
|
|
67
|
+
* same cluster) already holds an edge for that ordered (source, target)
|
|
68
|
+
* pair. NEVER silently overwritten — see planLinkRepoints for why the
|
|
69
|
+
* schema forces this to be counted rather than replaced.
|
|
70
|
+
*/
|
|
71
|
+
linksSkippedExisting: number;
|
|
72
|
+
}
|
|
73
|
+
export interface DedupMismatchCluster {
|
|
74
|
+
size: number;
|
|
75
|
+
memberIds: string[];
|
|
76
|
+
mismatch: ClusterMetadataMismatch;
|
|
77
|
+
}
|
|
78
|
+
export interface DedupReport {
|
|
79
|
+
dryRun: boolean;
|
|
80
|
+
threshold: number;
|
|
81
|
+
/** Every cluster found at the threshold (mergeable + mismatch-skipped). */
|
|
82
|
+
clusterCount: number;
|
|
83
|
+
mergeable: DedupClusterPlan[];
|
|
84
|
+
mismatchSkipped: DedupMismatchCluster[];
|
|
85
|
+
/** Rows that would disappear if every mergeable cluster merged (loser count). */
|
|
86
|
+
plannedMerges: number;
|
|
87
|
+
/**
|
|
88
|
+
* Sum of `linksSkippedExisting` across every mergeable cluster (dry-run:
|
|
89
|
+
* computed from the discovery-time read; --apply: recomputed live per
|
|
90
|
+
* cluster as it merges, so it reflects any same-run ripple across
|
|
91
|
+
* clusters — see planLinkRepoints). Surfaced at the top level so a
|
|
92
|
+
* clobber-risk is never buried in per-cluster output only.
|
|
93
|
+
*/
|
|
94
|
+
linksSkippedExisting: number;
|
|
95
|
+
/** --apply only: clusters actually merged. */
|
|
96
|
+
merged?: number;
|
|
97
|
+
/** --apply only: loser rows deleted. */
|
|
98
|
+
losersDeleted?: number;
|
|
99
|
+
/** --apply only: clusters that errored mid-merge (rolled back; left for a re-run). */
|
|
100
|
+
failedClusters?: number;
|
|
101
|
+
/** --apply only: path to the pre-merge backup. */
|
|
102
|
+
backupPath?: string;
|
|
103
|
+
}
|
|
104
|
+
export interface DedupOptions {
|
|
105
|
+
/** Execute the merge. Default false = dry run (report only, zero writes). */
|
|
106
|
+
apply?: boolean;
|
|
107
|
+
/** Override config.dedupMergeThreshold for one run. */
|
|
108
|
+
threshold?: number;
|
|
109
|
+
/** DB path override (tests / manual snapshot verification). Defaults to resolveDbPath(). */
|
|
110
|
+
dbPath?: string;
|
|
111
|
+
/** State dir override (tests). Defaults to ~/.hicortex. Backups also land under here/backups/. */
|
|
112
|
+
stateDir?: string;
|
|
113
|
+
/** Config override (tests). Defaults to reading stateDir/config.json. */
|
|
114
|
+
config?: Record<string, unknown> | null;
|
|
115
|
+
/** Capture-lock acquirer override (tests). Defaults to the real capture.ts lock. */
|
|
116
|
+
acquireLock?: typeof acquireCaptureLock;
|
|
117
|
+
/**
|
|
118
|
+
* Test-only failure injection: called once per cluster merge, after the
|
|
119
|
+
* link/tag/counter writes but before the audit-log + delete step. Throwing
|
|
120
|
+
* here proves a mid-merge error rolls the WHOLE cluster's writes back
|
|
121
|
+
* (better-sqlite3 transaction semantics) rather than leaving a half-merged
|
|
122
|
+
* cluster. Never set in production.
|
|
123
|
+
*/
|
|
124
|
+
_injectFailureAfterWrites?: (canonicalId: string) => void;
|
|
125
|
+
}
|
|
126
|
+
/** One link the merge will (or would) add onto the canonical. */
|
|
127
|
+
interface PlannedLink {
|
|
128
|
+
source: string;
|
|
129
|
+
target: string;
|
|
130
|
+
relationship: string;
|
|
131
|
+
strength: number;
|
|
132
|
+
}
|
|
133
|
+
export interface LinkRepointPlan {
|
|
134
|
+
toAdd: PlannedLink[];
|
|
135
|
+
skippedSelfLink: number;
|
|
136
|
+
skippedExisting: number;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Run `hicortex dedup`. Dry run by default (options.apply falsy) — discovery
|
|
140
|
+
* + merge planning only, zero writes. `options.apply` executes: backup, then
|
|
141
|
+
* one transaction per cluster.
|
|
142
|
+
*/
|
|
143
|
+
export declare function runDedup(options?: DedupOptions): Promise<DedupReport>;
|
|
144
|
+
/** Escape SQL LIKE wildcards — session ids (e.g. Hermes) can contain "_"/"%". */
|
|
145
|
+
export declare function escapeLikeSessionId(s: string): string;
|
|
146
|
+
/**
|
|
147
|
+
* Count of memories + dedup_log rows matching an exact segment
|
|
148
|
+
* (`<sid>#<segment_id>#<i>`). Mirrors the /distill segment-exact precheck.
|
|
149
|
+
*/
|
|
150
|
+
export declare function countExistingSegment(db: Database.Database, sessionId: string, segmentId: string): number;
|
|
151
|
+
/**
|
|
152
|
+
* Count of memories + dedup_log rows matching a whole legacy session (exact
|
|
153
|
+
* id, or any `<sid>#...` chunk). Mirrors the /distill legacy session-level
|
|
154
|
+
* precheck.
|
|
155
|
+
*/
|
|
156
|
+
export declare function countExistingSession(db: Database.Database, sessionId: string): number;
|
|
157
|
+
export {};
|
package/dist/dedup.js
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `hicortex dedup` — cluster + merge near-duplicate memories (issue #100).
|
|
4
|
+
*
|
|
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.
|
|
11
|
+
*
|
|
12
|
+
* Per cluster:
|
|
13
|
+
* - Canonical = highest access_count (tie: oldest created_at, then
|
|
14
|
+
* lexicographically smallest id — fully deterministic for audit).
|
|
15
|
+
* - Losers' links are re-pointed onto the canonical (a link that would
|
|
16
|
+
* become a self-link, or one whose (canonical, target) ordered pair
|
|
17
|
+
* ALREADY holds an edge, is skipped rather than overwritten — see
|
|
18
|
+
* planLinkRepoints for why `relationship` cannot be part of that guard).
|
|
19
|
+
* - canonical.access_count/shown_count = summed across the cluster;
|
|
20
|
+
* last_accessed = max; base_strength = max.
|
|
21
|
+
* - Tags are UNIONED onto the canonical (weights NULL — the next nightly's
|
|
22
|
+
* 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).
|
|
29
|
+
*
|
|
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.
|
|
32
|
+
*
|
|
33
|
+
* Safety rails on --apply:
|
|
34
|
+
* - 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.
|
|
37
|
+
* - The existing single-flight capture lock (capture.ts) is held for the
|
|
38
|
+
* duration of the merge so a concurrent nightly/capture run can't race
|
|
39
|
+
* the dedup_log bookkeeping the merge relies on.
|
|
40
|
+
*
|
|
41
|
+
* Server-mode only (needs the local DB), like relink/classify-domains.
|
|
42
|
+
*/
|
|
43
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
44
|
+
if (k2 === undefined) k2 = k;
|
|
45
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
46
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
47
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
48
|
+
}
|
|
49
|
+
Object.defineProperty(o, k2, desc);
|
|
50
|
+
}) : (function(o, m, k, k2) {
|
|
51
|
+
if (k2 === undefined) k2 = k;
|
|
52
|
+
o[k2] = m[k];
|
|
53
|
+
}));
|
|
54
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
55
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
56
|
+
}) : function(o, v) {
|
|
57
|
+
o["default"] = v;
|
|
58
|
+
});
|
|
59
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
60
|
+
var ownKeys = function(o) {
|
|
61
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
62
|
+
var ar = [];
|
|
63
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
64
|
+
return ar;
|
|
65
|
+
};
|
|
66
|
+
return ownKeys(o);
|
|
67
|
+
};
|
|
68
|
+
return function (mod) {
|
|
69
|
+
if (mod && mod.__esModule) return mod;
|
|
70
|
+
var result = {};
|
|
71
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
72
|
+
__setModuleDefault(result, mod);
|
|
73
|
+
return result;
|
|
74
|
+
};
|
|
75
|
+
})();
|
|
76
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
77
|
+
exports.DEFAULT_DEDUP_MERGE_THRESHOLD = void 0;
|
|
78
|
+
exports.runDedup = runDedup;
|
|
79
|
+
exports.escapeLikeSessionId = escapeLikeSessionId;
|
|
80
|
+
exports.countExistingSegment = countExistingSegment;
|
|
81
|
+
exports.countExistingSession = countExistingSession;
|
|
82
|
+
const paths_js_1 = require("./paths.js");
|
|
83
|
+
const node_fs_1 = require("node:fs");
|
|
84
|
+
const node_path_1 = require("node:path");
|
|
85
|
+
const db_js_1 = require("./db.js");
|
|
86
|
+
const storage = __importStar(require("./storage.js"));
|
|
87
|
+
const cluster_js_1 = require("./cluster.js");
|
|
88
|
+
const capture_js_1 = require("./capture.js");
|
|
89
|
+
const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
|
|
90
|
+
/**
|
|
91
|
+
* Default merge threshold. Measured on the #191 mechanical audit corpus:
|
|
92
|
+
* 89 clusters / 110 excess rows at 0.92 (data/audit-20260729/eval-report.md).
|
|
93
|
+
*/
|
|
94
|
+
exports.DEFAULT_DEDUP_MERGE_THRESHOLD = 0.92;
|
|
95
|
+
/** KNN neighbors considered per memory — same as the #191 audit (cluster.ts default). */
|
|
96
|
+
const DEDUP_KNN_K = 10;
|
|
97
|
+
function readConfig(stateDir) {
|
|
98
|
+
try {
|
|
99
|
+
return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(stateDir, "config.json"), "utf-8"));
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function resolveThreshold(explicit, config) {
|
|
106
|
+
if (explicit !== undefined) {
|
|
107
|
+
if (!Number.isFinite(explicit) || explicit <= 0 || explicit > 1) {
|
|
108
|
+
throw new Error(`[hicortex] dedup: invalid --threshold value: ${explicit} (must be in (0, 1])`);
|
|
109
|
+
}
|
|
110
|
+
return explicit;
|
|
111
|
+
}
|
|
112
|
+
const fromConfig = Number(config?.dedupMergeThreshold);
|
|
113
|
+
return Number.isFinite(fromConfig) && fromConfig > 0 && fromConfig <= 1
|
|
114
|
+
? fromConfig
|
|
115
|
+
: exports.DEFAULT_DEDUP_MERGE_THRESHOLD;
|
|
116
|
+
}
|
|
117
|
+
function loadMembers(db, ids) {
|
|
118
|
+
const placeholders = ids.map(() => "?").join(", ");
|
|
119
|
+
return db
|
|
120
|
+
.prepare(`SELECT id, content, access_count, shown_count, last_accessed, base_strength,
|
|
121
|
+
created_at, project, privacy, source_agent, source_session
|
|
122
|
+
FROM memories WHERE id IN (${placeholders})`)
|
|
123
|
+
.all(...ids);
|
|
124
|
+
}
|
|
125
|
+
/** Canonical = highest access_count; ties broken by oldest created_at, then lexicographically smallest id. */
|
|
126
|
+
function pickCanonical(members) {
|
|
127
|
+
const sorted = [...members].sort((a, b) => {
|
|
128
|
+
if (b.access_count !== a.access_count)
|
|
129
|
+
return b.access_count - a.access_count;
|
|
130
|
+
if (a.created_at !== b.created_at)
|
|
131
|
+
return a.created_at.localeCompare(b.created_at);
|
|
132
|
+
return a.id.localeCompare(b.id);
|
|
133
|
+
});
|
|
134
|
+
const [canonical, ...losers] = sorted;
|
|
135
|
+
return { canonical, losers };
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Compute (read-only — no writes) what re-pointing the cluster's losers'
|
|
139
|
+
* links onto the canonical would do. Shared by the dry-run/apply report (a
|
|
140
|
+
* preview against the CURRENT DB state) and mergeCluster (the live,
|
|
141
|
+
* authoritative computation at execution time, inside the transaction).
|
|
142
|
+
*
|
|
143
|
+
* The guard checks the ordered (source, target) pair ONLY — never
|
|
144
|
+
* `relationship`. `memory_links`' primary key is `(source_id, target_id)`
|
|
145
|
+
* with NO relationship column in the key, and storage.addLink is
|
|
146
|
+
* `INSERT OR REPLACE`: a loser's link to some target X under a DIFFERENT
|
|
147
|
+
* relationship than the canonical's EXISTING X-edge would otherwise slip past
|
|
148
|
+
* a triple-keyed guard and REPLACE silently erase the canonical's edge
|
|
149
|
+
* (relationship + strength). Since the schema physically holds at most one
|
|
150
|
+
* edge per ordered pair, ANY existing edge for that pair — regardless of its
|
|
151
|
+
* relationship — must skip, never overwrite.
|
|
152
|
+
*
|
|
153
|
+
* `plannedPairs` also dedups WITHIN this same plan: two different losers
|
|
154
|
+
* linking to the same external target both remap to (canonical, target), and
|
|
155
|
+
* only the first is kept — the DB isn't touched between planning and
|
|
156
|
+
* applying a single cluster, so a pair "already added" and a pair "already in
|
|
157
|
+
* the DB" are the same kind of collision from the canonical's point of view.
|
|
158
|
+
*
|
|
159
|
+
* Links are fetched with ONE query across all losers (source_id OR target_id
|
|
160
|
+
* IN the loser set) rather than per-loser `storage.getLinks` calls — an edge
|
|
161
|
+
* BETWEEN two losers in the same cluster would otherwise be visited twice
|
|
162
|
+
* (once from each side), double-counting it as two self-link skips instead
|
|
163
|
+
* of one. Each row in `memory_links` is a single (source_id, target_id) pair
|
|
164
|
+
* (the primary key), so this query returns each affected edge exactly once.
|
|
165
|
+
*/
|
|
166
|
+
function planLinkRepoints(db, canonical, losers) {
|
|
167
|
+
const loserIdSet = new Set(losers.map((l) => l.id));
|
|
168
|
+
const remap = (id) => (loserIdSet.has(id) ? canonical.id : id);
|
|
169
|
+
const placeholders = losers.map(() => "?").join(", ");
|
|
170
|
+
const loserIds = losers.map((l) => l.id);
|
|
171
|
+
const affectedLinks = db
|
|
172
|
+
.prepare(`SELECT source_id, target_id, relationship, strength FROM memory_links
|
|
173
|
+
WHERE source_id IN (${placeholders}) OR target_id IN (${placeholders})`)
|
|
174
|
+
.all(...loserIds, ...loserIds);
|
|
175
|
+
const existsStmt = db.prepare("SELECT 1 FROM memory_links WHERE source_id = ? AND target_id = ?");
|
|
176
|
+
const plannedPairs = new Set();
|
|
177
|
+
const toAdd = [];
|
|
178
|
+
let skippedSelfLink = 0;
|
|
179
|
+
let skippedExisting = 0;
|
|
180
|
+
for (const link of affectedLinks) {
|
|
181
|
+
const newSource = remap(link.source_id);
|
|
182
|
+
const newTarget = remap(link.target_id);
|
|
183
|
+
// Would-be self-link — e.g. a link between two losers in this same
|
|
184
|
+
// cluster, or a loser already linked to the canonical.
|
|
185
|
+
if (newSource === newTarget) {
|
|
186
|
+
skippedSelfLink++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const pairKey = `${newSource}|${newTarget}`;
|
|
190
|
+
// Already present on the canonical (in the DB, or already queued by an
|
|
191
|
+
// earlier link in this same plan) — the ordered pair can hold only one
|
|
192
|
+
// edge, so it is skipped and counted, NEVER overwritten.
|
|
193
|
+
if (plannedPairs.has(pairKey) || existsStmt.get(newSource, newTarget)) {
|
|
194
|
+
skippedExisting++;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
plannedPairs.add(pairKey);
|
|
198
|
+
toAdd.push({ source: newSource, target: newTarget, relationship: link.relationship, strength: link.strength });
|
|
199
|
+
}
|
|
200
|
+
return { toAdd, skippedSelfLink, skippedExisting };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Apply one cluster's merge. Pure DB writes against the passed connection —
|
|
204
|
+
* the caller wraps this in db.transaction() so a mid-merge error rolls back
|
|
205
|
+
* the whole cluster (dup-over-loss: a failed cluster is retried on a later
|
|
206
|
+
* `dedup --apply`, never left half-merged).
|
|
207
|
+
*
|
|
208
|
+
* Returns the link-repoint plan that was actually applied (computed live,
|
|
209
|
+
* 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).
|
|
212
|
+
*/
|
|
213
|
+
function mergeCluster(db, canonical, losers, injectFailure) {
|
|
214
|
+
// 1. Re-point losers' links onto the canonical.
|
|
215
|
+
const plan = planLinkRepoints(db, canonical, losers);
|
|
216
|
+
for (const link of plan.toAdd) {
|
|
217
|
+
storage.addLink(db, link.source, link.target, link.relationship, link.strength);
|
|
218
|
+
}
|
|
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.
|
|
222
|
+
const allTags = new Set(storage.getMemoryTags(db, canonical.id));
|
|
223
|
+
for (const loser of losers) {
|
|
224
|
+
for (const tag of storage.getMemoryTags(db, loser.id))
|
|
225
|
+
allTags.add(tag);
|
|
226
|
+
}
|
|
227
|
+
if (allTags.size > 0) {
|
|
228
|
+
const tagList = [...allTags];
|
|
229
|
+
storage.setMemoryTags(db, canonical.id, tagList, {
|
|
230
|
+
weights: Object.fromEntries(tagList.map((t) => [t, null])),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// 3. Merge counters onto the canonical.
|
|
234
|
+
const accessCount = canonical.access_count + losers.reduce((s, l) => s + l.access_count, 0);
|
|
235
|
+
const shownCount = (canonical.shown_count ?? 0) + losers.reduce((s, l) => s + (l.shown_count ?? 0), 0);
|
|
236
|
+
const lastAccessed = [canonical, ...losers]
|
|
237
|
+
.map((m) => m.last_accessed)
|
|
238
|
+
.filter((v) => Boolean(v))
|
|
239
|
+
.sort()
|
|
240
|
+
.pop();
|
|
241
|
+
const baseStrength = Math.max(canonical.base_strength, ...losers.map((l) => l.base_strength));
|
|
242
|
+
storage.updateMemory(db, canonical.id, {
|
|
243
|
+
access_count: accessCount,
|
|
244
|
+
shown_count: shownCount,
|
|
245
|
+
...(lastAccessed ? { last_accessed: lastAccessed } : {}),
|
|
246
|
+
base_strength: baseStrength,
|
|
247
|
+
});
|
|
248
|
+
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).
|
|
252
|
+
const mergedAt = new Date().toISOString();
|
|
253
|
+
const logStmt = db.prepare(`INSERT OR REPLACE INTO dedup_log (loser_id, canonical_id, source_session, content_head, merged_at)
|
|
254
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
255
|
+
for (const loser of losers) {
|
|
256
|
+
logStmt.run(loser.id, canonical.id, loser.source_session, loser.content.slice(0, 200), mergedAt);
|
|
257
|
+
storage.deleteMemory(db, loser.id);
|
|
258
|
+
}
|
|
259
|
+
return plan;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Run `hicortex dedup`. Dry run by default (options.apply falsy) — discovery
|
|
263
|
+
* + merge planning only, zero writes. `options.apply` executes: backup, then
|
|
264
|
+
* one transaction per cluster.
|
|
265
|
+
*/
|
|
266
|
+
async function runDedup(options = {}) {
|
|
267
|
+
const stateDir = options.stateDir ?? HICORTEX_HOME;
|
|
268
|
+
const config = options.config !== undefined ? options.config : readConfig(stateDir);
|
|
269
|
+
// Server-mode only — client installs have no local DB.
|
|
270
|
+
if (config?.mode === "client") {
|
|
271
|
+
throw new Error("[hicortex] dedup is server-mode only (it needs the local DB). " +
|
|
272
|
+
`This machine is a client of ${config.serverUrl ?? "a remote server"} — run dedup on the server.`);
|
|
273
|
+
}
|
|
274
|
+
const threshold = resolveThreshold(options.threshold, config);
|
|
275
|
+
const apply = options.apply ?? false;
|
|
276
|
+
const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
|
|
277
|
+
const db = (0, db_js_1.initDb)(dbPath);
|
|
278
|
+
try {
|
|
279
|
+
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);
|
|
282
|
+
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.privacyMismatch || 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 });
|
|
296
|
+
// Read-only preview against the CURRENT DB state — see planLinkRepoints
|
|
297
|
+
// for why apply recomputes this live rather than reusing this snapshot.
|
|
298
|
+
const linkPlan = planLinkRepoints(db, canonical, losers);
|
|
299
|
+
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) => ({
|
|
306
|
+
id: m.id,
|
|
307
|
+
created_at: m.created_at,
|
|
308
|
+
access_count: m.access_count,
|
|
309
|
+
preview: m.content.slice(0, 80),
|
|
310
|
+
})),
|
|
311
|
+
linksRepointed: linkPlan.toAdd.length,
|
|
312
|
+
linksSkippedSelfLink: linkPlan.skippedSelfLink,
|
|
313
|
+
linksSkippedExisting: linkPlan.skippedExisting,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
const plannedMerges = mergeable.reduce((s, c) => s + c.loserIds.length, 0);
|
|
317
|
+
const linksSkippedExistingPreview = mergeable.reduce((s, c) => s + c.linksSkippedExisting, 0);
|
|
318
|
+
const report = {
|
|
319
|
+
dryRun: !apply,
|
|
320
|
+
threshold,
|
|
321
|
+
clusterCount: clusters.length,
|
|
322
|
+
mergeable,
|
|
323
|
+
mismatchSkipped,
|
|
324
|
+
plannedMerges,
|
|
325
|
+
linksSkippedExisting: linksSkippedExistingPreview,
|
|
326
|
+
};
|
|
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), ` +
|
|
329
|
+
`${linksSkippedExistingPreview} link(s) would be skipped (existing edge on the canonical)`);
|
|
330
|
+
if (!apply) {
|
|
331
|
+
for (const c of mergeable) {
|
|
332
|
+
console.log(`[hicortex] cluster size ${c.size}: canonical ${c.canonicalId.slice(0, 8)}, ` +
|
|
333
|
+
`losers ${c.loserIds.map((id) => id.slice(0, 8)).join(", ")}, ` +
|
|
334
|
+
`links: ${c.linksRepointed} to re-point, ${c.linksSkippedExisting} skipped (existing edge), ` +
|
|
335
|
+
`${c.linksSkippedSelfLink} skipped (self-link)`);
|
|
336
|
+
}
|
|
337
|
+
for (const c of mismatchSkipped) {
|
|
338
|
+
const reasons = Object.entries(c.mismatch)
|
|
339
|
+
.filter(([, v]) => v)
|
|
340
|
+
.map(([k]) => k)
|
|
341
|
+
.join(", ");
|
|
342
|
+
console.log(`[hicortex] SKIPPED (${reasons}): ${c.memberIds.map((id) => id.slice(0, 8)).join(", ")}`);
|
|
343
|
+
}
|
|
344
|
+
return report;
|
|
345
|
+
}
|
|
346
|
+
// --apply: acquire the single-flight capture lock so a concurrent
|
|
347
|
+
// nightly/capture run can't race the merge's dedup_log writes. Fails fast
|
|
348
|
+
// (waitMs 0) — dedup is a deliberate manual command; a busy nightly should
|
|
349
|
+
// be retried later, not silently waited on.
|
|
350
|
+
const acquireLock = options.acquireLock ?? capture_js_1.acquireCaptureLock;
|
|
351
|
+
const releaseLock = await acquireLock(stateDir, 0);
|
|
352
|
+
if (!releaseLock) {
|
|
353
|
+
throw new Error("[hicortex] dedup --apply aborted: another capture/nightly run holds the lock. Retry when it finishes.");
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
// 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`);
|
|
360
|
+
try {
|
|
361
|
+
await db.backup(backupPath);
|
|
362
|
+
}
|
|
363
|
+
catch (err) {
|
|
364
|
+
throw new Error(`[hicortex] dedup --apply aborted: backup failed (${err instanceof Error ? err.message : String(err)}). No merges attempted.`);
|
|
365
|
+
}
|
|
366
|
+
console.log(`[hicortex] Backup written: ${backupPath}`);
|
|
367
|
+
report.backupPath = backupPath;
|
|
368
|
+
let merged = 0;
|
|
369
|
+
let losersDeleted = 0;
|
|
370
|
+
let failedClusters = 0;
|
|
371
|
+
// Recomputed from the ACTUAL, live per-cluster merges below (may differ
|
|
372
|
+
// from the discovery-time preview if an earlier cluster in this same
|
|
373
|
+
// run rewrote a link that a later cluster's plan also touches).
|
|
374
|
+
let linksSkippedExistingApplied = 0;
|
|
375
|
+
for (const plan of plans) {
|
|
376
|
+
try {
|
|
377
|
+
const tx = db.transaction(() => mergeCluster(db, plan.canonical, plan.losers, options._injectFailureAfterWrites));
|
|
378
|
+
const appliedPlan = tx();
|
|
379
|
+
merged++;
|
|
380
|
+
losersDeleted += plan.losers.length;
|
|
381
|
+
linksSkippedExistingApplied += appliedPlan.skippedExisting;
|
|
382
|
+
console.log(`[hicortex] merged cluster: canonical ${plan.canonical.id.slice(0, 8)} absorbed ${plan.losers.length} loser(s), ` +
|
|
383
|
+
`${appliedPlan.toAdd.length} link(s) re-pointed, ${appliedPlan.skippedExisting} skipped (existing edge)`);
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
failedClusters++;
|
|
387
|
+
console.error(`[hicortex] cluster merge FAILED (canonical ${plan.canonical.id.slice(0, 8)}): ` +
|
|
388
|
+
`${err instanceof Error ? err.message : String(err)} — rolled back, left for a re-run`);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
report.merged = merged;
|
|
392
|
+
report.losersDeleted = losersDeleted;
|
|
393
|
+
report.failedClusters = failedClusters;
|
|
394
|
+
report.linksSkippedExisting = linksSkippedExistingApplied;
|
|
395
|
+
console.log(`[hicortex] dedup complete: ${merged} cluster(s) merged, ${losersDeleted} loser(s) deleted` +
|
|
396
|
+
(failedClusters > 0 ? `, ${failedClusters} cluster(s) FAILED (see errors above)` : ""));
|
|
397
|
+
return report;
|
|
398
|
+
}
|
|
399
|
+
finally {
|
|
400
|
+
releaseLock();
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
finally {
|
|
404
|
+
db.close();
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// /distill dedup_log consultation (shared with mcp-server.ts)
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
//
|
|
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.
|
|
417
|
+
/** Escape SQL LIKE wildcards — session ids (e.g. Hermes) can contain "_"/"%". */
|
|
418
|
+
function escapeLikeSessionId(s) {
|
|
419
|
+
return s.replace(/[\\%_]/g, (m) => "\\" + m);
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Count of memories + dedup_log rows matching an exact segment
|
|
423
|
+
* (`<sid>#<segment_id>#<i>`). Mirrors the /distill segment-exact precheck.
|
|
424
|
+
*/
|
|
425
|
+
function countExistingSegment(db, sessionId, segmentId) {
|
|
426
|
+
const likePrefix = `${escapeLikeSessionId(sessionId)}#${escapeLikeSessionId(segmentId)}#%`;
|
|
427
|
+
const memCount = db.prepare("SELECT COUNT(*) as c FROM memories WHERE source_session LIKE ? ESCAPE '\\'").get(likePrefix).c;
|
|
428
|
+
const logCount = db.prepare("SELECT COUNT(*) as c FROM dedup_log WHERE source_session LIKE ? ESCAPE '\\'").get(likePrefix).c;
|
|
429
|
+
return memCount + logCount;
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Count of memories + dedup_log rows matching a whole legacy session (exact
|
|
433
|
+
* id, or any `<sid>#...` chunk). Mirrors the /distill legacy session-level
|
|
434
|
+
* precheck.
|
|
435
|
+
*/
|
|
436
|
+
function countExistingSession(db, sessionId) {
|
|
437
|
+
const likePrefix = `${escapeLikeSessionId(sessionId)}#%`;
|
|
438
|
+
const memCount = db
|
|
439
|
+
.prepare("SELECT COUNT(*) as c FROM memories WHERE source_session = ? OR source_session LIKE ? ESCAPE '\\'")
|
|
440
|
+
.get(sessionId, likePrefix).c;
|
|
441
|
+
const logCount = db
|
|
442
|
+
.prepare("SELECT COUNT(*) as c FROM dedup_log WHERE source_session = ? OR source_session LIKE ? ESCAPE '\\'")
|
|
443
|
+
.get(sessionId, likePrefix).c;
|
|
444
|
+
return memCount + logCount;
|
|
445
|
+
}
|