@gamaze/hicortex 0.14.4 → 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 +13 -11
- 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/README.md
CHANGED
|
@@ -157,6 +157,8 @@ npx @gamaze/hicortex nightly # Run distill + consolidate (full
|
|
|
157
157
|
npx @gamaze/hicortex nightly --capture-only # Capture only, skip consolidation (safe for sub-daily runs)
|
|
158
158
|
npx @gamaze/hicortex nightly --dry-run # Preview without changes
|
|
159
159
|
npx @gamaze/hicortex classify-domains # Backfill domain tags over the corpus (see Memory Domains & Tags)
|
|
160
|
+
npx @gamaze/hicortex dedup # Preview near-duplicate memory clusters (dry run, no changes)
|
|
161
|
+
npx @gamaze/hicortex dedup --apply # Merge near-duplicate clusters (backs up the DB first)
|
|
160
162
|
npx @gamaze/hicortex context show [name] # Print the standing context layer (see Context Layer)
|
|
161
163
|
npx @gamaze/hicortex context edit <name> # Edit a context section in $EDITOR
|
|
162
164
|
npx @gamaze/hicortex context show --agent <id> # Show a specific agent's resolved context (0.13)
|
|
@@ -215,6 +217,10 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
|
|
|
215
217
|
| `recallMinSimilarity` | Relevance floor for index entries (default: 0.55; text-search matches always pass) |
|
|
216
218
|
| `recallReshowTurns` | Turns before an already-shown memory may reappear in the same session (default: 30) |
|
|
217
219
|
| `recallMinPromptChars` | Prompts shorter than this skip the recall index (default: 20) |
|
|
220
|
+
| `dedupMergeThreshold` | Minimum cosine similarity for `hicortex dedup` to cluster memories as near-duplicates (default: 0.92) |
|
|
221
|
+
| `supersessionMinSimilarity` | Minimum cosine similarity for a nightly supersession candidate pair (default: 0.80) |
|
|
222
|
+
| `supersessionMaxCalls` | Max classify-tier LLM calls the nightly's supersession stage spends per run (default: 30) |
|
|
223
|
+
| `supersessionPenalty` | Multiplier applied to a superseded memory's `base_strength` (default: 0.5) |
|
|
218
224
|
| `telemetry` | Anonymous usage telemetry, `false` to opt out |
|
|
219
225
|
|
|
220
226
|
Full docs: [hicortex.gamaze.com/docs/configuration.html](https://hicortex.gamaze.com/docs/configuration.html)
|
package/dist/cli.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* nightly --capture-only Capture only, skip consolidation
|
|
10
10
|
* nightly --status Show nightly pipeline health check
|
|
11
11
|
* relink Resumable link-discovery pass over the entire corpus (issue #143)
|
|
12
|
+
* dedup Cluster + merge near-duplicate memories (issue #100)
|
|
13
|
+
* dedup --apply Execute the merge (default: dry run)
|
|
12
14
|
* status Show config, DB stats, adapter status
|
|
13
15
|
* uninstall Clean removal of CC integration
|
|
14
16
|
*/
|
package/dist/cli.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
* nightly --capture-only Capture only, skip consolidation
|
|
11
11
|
* nightly --status Show nightly pipeline health check
|
|
12
12
|
* relink Resumable link-discovery pass over the entire corpus (issue #143)
|
|
13
|
+
* dedup Cluster + merge near-duplicate memories (issue #100)
|
|
14
|
+
* dedup --apply Execute the merge (default: dry run)
|
|
13
15
|
* status Show config, DB stats, adapter status
|
|
14
16
|
* uninstall Clean removal of CC integration
|
|
15
17
|
*/
|
|
@@ -134,6 +136,40 @@ switch (command) {
|
|
|
134
136
|
});
|
|
135
137
|
break;
|
|
136
138
|
}
|
|
139
|
+
case "dedup": {
|
|
140
|
+
const args = process.argv.slice(3);
|
|
141
|
+
let threshold;
|
|
142
|
+
try {
|
|
143
|
+
const raw = (0, cli_args_js_1.readValueFlag)(args, "--threshold");
|
|
144
|
+
if (raw !== undefined) {
|
|
145
|
+
threshold = parseFloat(raw);
|
|
146
|
+
if (isNaN(threshold)) {
|
|
147
|
+
console.error("[hicortex] dedup: --threshold requires a numeric value, e.g. --threshold 0.9");
|
|
148
|
+
process.exit(1);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
console.error("[hicortex] dedup: --threshold requires a value, e.g. --threshold 0.9");
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
let dbPath;
|
|
157
|
+
try {
|
|
158
|
+
dbPath = (0, cli_args_js_1.readValueFlag)(args, "--db");
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
console.error("[hicortex] dedup: --db requires a path value");
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
const dedupOptions = { apply: args.includes("--apply"), threshold, dbPath };
|
|
165
|
+
import("./dedup.js").then(({ runDedup }) => {
|
|
166
|
+
runDedup(dedupOptions).catch((err) => {
|
|
167
|
+
console.error(err instanceof Error ? err.message : `[hicortex] dedup failed: ${err}`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
137
173
|
case "context": {
|
|
138
174
|
// Standing context layer edit surface (spec §6): show|edit against the
|
|
139
175
|
// configured server. Secondary to the /context/ui Web UI; for headless boxes.
|
|
@@ -205,6 +241,7 @@ Commands:
|
|
|
205
241
|
Pass --agent-name "" to clear it back to global
|
|
206
242
|
nightly Run nightly denoise + capture + consolidate
|
|
207
243
|
relink Resumable link-discovery pass over the ENTIRE corpus (server mode)
|
|
244
|
+
dedup Cluster + merge near-duplicate memories (server mode; dry run by default)
|
|
208
245
|
classify-domains Backfill content-based domain tags over the corpus (server mode, needs config.domains)
|
|
209
246
|
lessons-context Fetch lessons and print Markdown to stdout (CC SessionStart hook)
|
|
210
247
|
recall-hook Pushed recall index for the current prompt (CC UserPromptSubmit/SessionStart hook)
|
|
@@ -222,6 +259,9 @@ Options:
|
|
|
222
259
|
relink --dry-run Discovery + counts only, zero writes, cursor untouched
|
|
223
260
|
relink --batch <n> Memories per batch (default: 200)
|
|
224
261
|
relink --reset Restart from the beginning (ignore saved cursor)
|
|
262
|
+
dedup --apply Execute the merge (default: dry run, report only)
|
|
263
|
+
dedup --threshold <t> Override config dedupMergeThreshold for one run
|
|
264
|
+
dedup --db <path> DB path override (defaults to the configured DB)
|
|
225
265
|
classify-domains --all Reclassify every memory (default: only NULL/stale-domain rows)
|
|
226
266
|
classify-domains --batch <n> Memories per batch (default: 200)
|
|
227
267
|
classify-domains --reset Restart from the beginning (ignore saved cursor)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared vector-cluster utilities: union-find clustering + KNN edge building.
|
|
3
|
+
*
|
|
4
|
+
* Originally lived entirely in src/eval/dups.ts (the #191 D1 duplicate-rate
|
|
5
|
+
* audit). Extracted so `hicortex dedup` (#100) reuses the EXACT same
|
|
6
|
+
* clustering math instead of re-implementing it — the read-only audit and the
|
|
7
|
+
* live merge command must agree on what a "cluster" is.
|
|
8
|
+
*/
|
|
9
|
+
import type Database from "better-sqlite3";
|
|
10
|
+
/** An undirected similarity edge between two memory ids. */
|
|
11
|
+
export interface Edge {
|
|
12
|
+
a: string;
|
|
13
|
+
b: string;
|
|
14
|
+
cosine: number;
|
|
15
|
+
}
|
|
16
|
+
/** Minimal union-find over string ids, with path compression. */
|
|
17
|
+
export declare class UnionFind {
|
|
18
|
+
private parent;
|
|
19
|
+
find(x: string): string;
|
|
20
|
+
union(a: string, b: string): void;
|
|
21
|
+
/** All known nodes grouped by cluster root (includes singletons). */
|
|
22
|
+
clusters(): Map<string, string[]>;
|
|
23
|
+
}
|
|
24
|
+
/** Build clusters (size >= 2) from edges at/above a cosine threshold. */
|
|
25
|
+
export declare function clusterEdges(edges: Edge[], threshold: number): string[][];
|
|
26
|
+
/** Excess = sum(cluster size − 1) — rows that would disappear if every cluster merged to one. */
|
|
27
|
+
export declare function clusterExcess(clusters: string[][]): number;
|
|
28
|
+
/** The three metadata fields a merge candidate cluster must agree on. */
|
|
29
|
+
export interface ClusterMetaRow {
|
|
30
|
+
project: string | null;
|
|
31
|
+
privacy: string;
|
|
32
|
+
source_agent: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ClusterMetadataMismatch {
|
|
35
|
+
projectMismatch: boolean;
|
|
36
|
+
privacyMismatch: boolean;
|
|
37
|
+
sourceAgentMismatch: boolean;
|
|
38
|
+
}
|
|
39
|
+
/** Do cluster members disagree on project/privacy/source_agent? (merge-safety input for #100). */
|
|
40
|
+
export declare function clusterMetadataMismatch(members: ClusterMetaRow[]): ClusterMetadataMismatch;
|
|
41
|
+
/**
|
|
42
|
+
* Build the max-cosine edge set via top-K KNN on `memory_vectors`, keeping
|
|
43
|
+
* only pairs at/above `minCosine`. Shared by the #191 audit (which then
|
|
44
|
+
* clusters at several report thresholds) and `hicortex dedup` (a single merge
|
|
45
|
+
* threshold) — same query, same math, so the two never disagree on what
|
|
46
|
+
* counts as a near-duplicate pair.
|
|
47
|
+
*/
|
|
48
|
+
export declare function buildKnnEdges(db: Database.Database, opts: {
|
|
49
|
+
k?: number;
|
|
50
|
+
minCosine: number;
|
|
51
|
+
}): Edge[];
|
package/dist/cluster.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared vector-cluster utilities: union-find clustering + KNN edge building.
|
|
4
|
+
*
|
|
5
|
+
* Originally lived entirely in src/eval/dups.ts (the #191 D1 duplicate-rate
|
|
6
|
+
* audit). Extracted so `hicortex dedup` (#100) reuses the EXACT same
|
|
7
|
+
* clustering math instead of re-implementing it — the read-only audit and the
|
|
8
|
+
* live merge command must agree on what a "cluster" is.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.UnionFind = void 0;
|
|
12
|
+
exports.clusterEdges = clusterEdges;
|
|
13
|
+
exports.clusterExcess = clusterExcess;
|
|
14
|
+
exports.clusterMetadataMismatch = clusterMetadataMismatch;
|
|
15
|
+
exports.buildKnnEdges = buildKnnEdges;
|
|
16
|
+
const retrieval_js_1 = require("./retrieval.js");
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Union-Find (pure — unit tested)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
/** Minimal union-find over string ids, with path compression. */
|
|
21
|
+
class UnionFind {
|
|
22
|
+
parent = new Map();
|
|
23
|
+
find(x) {
|
|
24
|
+
if (!this.parent.has(x)) {
|
|
25
|
+
this.parent.set(x, x);
|
|
26
|
+
return x;
|
|
27
|
+
}
|
|
28
|
+
let root = x;
|
|
29
|
+
while (this.parent.get(root) !== root) {
|
|
30
|
+
root = this.parent.get(root);
|
|
31
|
+
}
|
|
32
|
+
let cur = x;
|
|
33
|
+
while (this.parent.get(cur) !== root) {
|
|
34
|
+
const next = this.parent.get(cur);
|
|
35
|
+
this.parent.set(cur, root);
|
|
36
|
+
cur = next;
|
|
37
|
+
}
|
|
38
|
+
return root;
|
|
39
|
+
}
|
|
40
|
+
union(a, b) {
|
|
41
|
+
const ra = this.find(a);
|
|
42
|
+
const rb = this.find(b);
|
|
43
|
+
if (ra !== rb)
|
|
44
|
+
this.parent.set(ra, rb);
|
|
45
|
+
}
|
|
46
|
+
/** All known nodes grouped by cluster root (includes singletons). */
|
|
47
|
+
clusters() {
|
|
48
|
+
const groups = new Map();
|
|
49
|
+
for (const node of this.parent.keys()) {
|
|
50
|
+
const root = this.find(node);
|
|
51
|
+
const arr = groups.get(root) ?? [];
|
|
52
|
+
arr.push(node);
|
|
53
|
+
groups.set(root, arr);
|
|
54
|
+
}
|
|
55
|
+
return groups;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.UnionFind = UnionFind;
|
|
59
|
+
/** Build clusters (size >= 2) from edges at/above a cosine threshold. */
|
|
60
|
+
function clusterEdges(edges, threshold) {
|
|
61
|
+
const uf = new UnionFind();
|
|
62
|
+
for (const e of edges) {
|
|
63
|
+
if (e.cosine >= threshold)
|
|
64
|
+
uf.union(e.a, e.b);
|
|
65
|
+
}
|
|
66
|
+
return [...uf.clusters().values()].filter((g) => g.length >= 2);
|
|
67
|
+
}
|
|
68
|
+
/** Excess = sum(cluster size − 1) — rows that would disappear if every cluster merged to one. */
|
|
69
|
+
function clusterExcess(clusters) {
|
|
70
|
+
return clusters.reduce((sum, c) => sum + (c.length - 1), 0);
|
|
71
|
+
}
|
|
72
|
+
/** Do cluster members disagree on project/privacy/source_agent? (merge-safety input for #100). */
|
|
73
|
+
function clusterMetadataMismatch(members) {
|
|
74
|
+
const projects = new Set(members.map((m) => m.project ?? "\u0000null"));
|
|
75
|
+
const privacies = new Set(members.map((m) => m.privacy));
|
|
76
|
+
const agents = new Set(members.map((m) => m.source_agent));
|
|
77
|
+
return {
|
|
78
|
+
projectMismatch: projects.size > 1,
|
|
79
|
+
privacyMismatch: privacies.size > 1,
|
|
80
|
+
sourceAgentMismatch: agents.size > 1,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// KNN edge building
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
/** Neighbors requested per memory (excluding the memory itself), unless overridden. */
|
|
87
|
+
const DEFAULT_KNN_K = 10;
|
|
88
|
+
/**
|
|
89
|
+
* Build the max-cosine edge set via top-K KNN on `memory_vectors`, keeping
|
|
90
|
+
* only pairs at/above `minCosine`. Shared by the #191 audit (which then
|
|
91
|
+
* clusters at several report thresholds) and `hicortex dedup` (a single merge
|
|
92
|
+
* threshold) — same query, same math, so the two never disagree on what
|
|
93
|
+
* counts as a near-duplicate pair.
|
|
94
|
+
*/
|
|
95
|
+
function buildKnnEdges(db, opts) {
|
|
96
|
+
const k = opts.k ?? DEFAULT_KNN_K;
|
|
97
|
+
const vectorRows = db.prepare("SELECT id, embedding FROM memory_vectors").all();
|
|
98
|
+
const knnStmt = db.prepare("SELECT id, distance FROM memory_vectors WHERE embedding MATCH ? AND k = ? ORDER BY distance");
|
|
99
|
+
const edgeMap = new Map(); // "a|b" (a < b) -> max cosine seen from either direction
|
|
100
|
+
for (const row of vectorRows) {
|
|
101
|
+
const neighbors = knnStmt.all(row.embedding, k + 1);
|
|
102
|
+
for (const n of neighbors) {
|
|
103
|
+
if (n.id === row.id)
|
|
104
|
+
continue;
|
|
105
|
+
const cosine = (0, retrieval_js_1.l2ToCosine)(n.distance);
|
|
106
|
+
if (cosine < opts.minCosine)
|
|
107
|
+
continue;
|
|
108
|
+
const key = row.id < n.id ? `${row.id}|${n.id}` : `${n.id}|${row.id}`;
|
|
109
|
+
const existing = edgeMap.get(key);
|
|
110
|
+
if (existing === undefined || cosine > existing)
|
|
111
|
+
edgeMap.set(key, cosine);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return [...edgeMap.entries()].map(([key, cosine]) => {
|
|
115
|
+
const [a, b] = key.split("|");
|
|
116
|
+
return { a, b, cosine };
|
|
117
|
+
});
|
|
118
|
+
}
|
package/dist/consolidate.d.ts
CHANGED
|
@@ -131,6 +131,68 @@ export declare function classifyLinkCandidates(candidates: LinkCandidate[], _llm
|
|
|
131
131
|
* boundary from the l2ToCosine calibration is preserved.
|
|
132
132
|
*/
|
|
133
133
|
export declare function classifyRelationship(source: Memory, target: Memory, similarity: number): string;
|
|
134
|
+
/** Default minimum COSINE similarity for a supersession candidate pair. */
|
|
135
|
+
export declare const DEFAULT_SUPERSESSION_MIN_SIMILARITY = 0.8;
|
|
136
|
+
/** Default max classify-tier LLM calls (pairs evaluated) spent per nightly run. */
|
|
137
|
+
export declare const DEFAULT_SUPERSESSION_MAX_CALLS = 30;
|
|
138
|
+
/** Default multiplier applied to a superseded memory's base_strength. */
|
|
139
|
+
export declare const DEFAULT_SUPERSESSION_PENALTY = 0.5;
|
|
140
|
+
export interface SupersessionOptions {
|
|
141
|
+
minSimilarity?: number;
|
|
142
|
+
maxCalls?: number;
|
|
143
|
+
penalty?: number;
|
|
144
|
+
}
|
|
145
|
+
export interface SupersessionStageResult {
|
|
146
|
+
scanned: number;
|
|
147
|
+
evaluated: number;
|
|
148
|
+
superseded: number;
|
|
149
|
+
skipped_infra: number;
|
|
150
|
+
skipped_idempotent: number;
|
|
151
|
+
cursor: number;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Build the constrained supersession-check prompt. Content is truncated the
|
|
155
|
+
* same width as domain-classify.ts's classifier (1500 chars) — this is a
|
|
156
|
+
* classify-tier call with the same cost profile.
|
|
157
|
+
*/
|
|
158
|
+
export declare function buildSupersessionPrompt(oldContent: string, newContent: string): string;
|
|
159
|
+
/**
|
|
160
|
+
* Parse the model's supersession verdict. Returns the boolean on a valid
|
|
161
|
+
* reply, or null on anything unparseable (caller skips the pair — no retry,
|
|
162
|
+
* unlike domain-classify's tag classifier; a missed pair is retried naturally
|
|
163
|
+
* when this stage revisits the corpus).
|
|
164
|
+
*/
|
|
165
|
+
export declare function parseSupersessionReply(reply: string): boolean | null;
|
|
166
|
+
/**
|
|
167
|
+
* Nightly supersession-detection stage. Scans memories/rowid > cursor whose
|
|
168
|
+
* shape suggests a decision/correction, checks each against its older
|
|
169
|
+
* same-shape neighbors, and links confirmed supersessions. Dry-run performs
|
|
170
|
+
* discovery + the free idempotency check only — no LLM calls, no writes, no
|
|
171
|
+
* cursor persistence (mirrors stageImportance/stageContentDomains's dry-run
|
|
172
|
+
* convention of never spending budget on a preview).
|
|
173
|
+
*
|
|
174
|
+
* Cursor discipline is DELIBERATELY simple (owner amendment): the cursor
|
|
175
|
+
* advances past a candidate once its neighbor set has been considered,
|
|
176
|
+
* REGARDLESS of whether every pair got an LLM call (call budget) or a clean
|
|
177
|
+
* verdict (infra skip) — missing one pair is acceptable and self-heals next
|
|
178
|
+
* time this memory's neighborhood is re-examined via a NEWER memory's own
|
|
179
|
+
* candidacy. It only stops SHORT of a candidate when the budget is already
|
|
180
|
+
* exhausted before that candidate starts, so the cursor never skips a
|
|
181
|
+
* candidate that was never looked at.
|
|
182
|
+
*/
|
|
183
|
+
export declare function stageSupersession(db: Database.Database, llm: LlmClient, budget: BudgetTracker, embedFn: EmbedFn, dryRun: boolean, stateDir: string | undefined, options?: SupersessionOptions): Promise<SupersessionStageResult>;
|
|
184
|
+
/**
|
|
185
|
+
* Exported for the #191 eval baseline (src/eval/decay-eval.ts) so the audit
|
|
186
|
+
* runs the REAL production prune predicate against a DB snapshot instead of
|
|
187
|
+
* reimplementing it. `dryRun=true` performs reads only (candidates are
|
|
188
|
+
* counted, nothing is deleted) — safe against a readonly snapshot connection.
|
|
189
|
+
* Not otherwise part of the public API surface.
|
|
190
|
+
*/
|
|
191
|
+
export declare function stageDecayPrune(db: Database.Database, dryRun: boolean): {
|
|
192
|
+
candidates: number;
|
|
193
|
+
pruned: number;
|
|
194
|
+
failed: number;
|
|
195
|
+
};
|
|
134
196
|
/**
|
|
135
197
|
* Run the full consolidation pipeline. Returns a structured report.
|
|
136
198
|
*/
|
|
@@ -156,7 +218,7 @@ export interface DomainStageOptions {
|
|
|
156
218
|
*/
|
|
157
219
|
weakPrimaryFloor?: number;
|
|
158
220
|
}
|
|
159
|
-
export declare function runConsolidation(db: Database.Database, llm: LlmClient, embedFn: EmbedFn, dryRun?: boolean, skipReflection?: boolean, stateDir?: string, domainOptions?: DomainStageOptions): Promise<ConsolidationReport>;
|
|
221
|
+
export declare function runConsolidation(db: Database.Database, llm: LlmClient, embedFn: EmbedFn, dryRun?: boolean, skipReflection?: boolean, stateDir?: string, domainOptions?: DomainStageOptions, supersessionOptions?: SupersessionOptions): Promise<ConsolidationReport>;
|
|
160
222
|
/**
|
|
161
223
|
* Calculate milliseconds until the next occurrence of a given hour (local time).
|
|
162
224
|
*/
|
package/dist/consolidate.js
CHANGED
|
@@ -38,13 +38,17 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
38
38
|
};
|
|
39
39
|
})();
|
|
40
40
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
41
|
-
exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = void 0;
|
|
41
|
+
exports.DEFAULT_SUPERSESSION_PENALTY = exports.DEFAULT_SUPERSESSION_MAX_CALLS = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = void 0;
|
|
42
42
|
exports.isContradictionCandidate = isContradictionCandidate;
|
|
43
43
|
exports.parseJsonLenient = parseJsonLenient;
|
|
44
44
|
exports.rebuildContentModuleIndex = rebuildContentModuleIndex;
|
|
45
45
|
exports.discoverLinkCandidates = discoverLinkCandidates;
|
|
46
46
|
exports.classifyLinkCandidates = classifyLinkCandidates;
|
|
47
47
|
exports.classifyRelationship = classifyRelationship;
|
|
48
|
+
exports.buildSupersessionPrompt = buildSupersessionPrompt;
|
|
49
|
+
exports.parseSupersessionReply = parseSupersessionReply;
|
|
50
|
+
exports.stageSupersession = stageSupersession;
|
|
51
|
+
exports.stageDecayPrune = stageDecayPrune;
|
|
48
52
|
exports.runConsolidation = runConsolidation;
|
|
49
53
|
exports.msUntilHour = msUntilHour;
|
|
50
54
|
exports.scheduleConsolidation = scheduleConsolidation;
|
|
@@ -822,8 +826,228 @@ function stageHubBoost(db, dryRun) {
|
|
|
822
826
|
return { hubs_found: hubs.length, boosted };
|
|
823
827
|
}
|
|
824
828
|
// ---------------------------------------------------------------------------
|
|
829
|
+
// Stage 3.7: Supersession Detection (#191 Phase B)
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
//
|
|
832
|
+
// A later decision/correction can reverse, replace, or invalidate an earlier
|
|
833
|
+
// one — e.g. "chose Ollama for distillation" superseded a month later by
|
|
834
|
+
// "switched distillation to the MBP's 35B model over Tailscale". Left
|
|
835
|
+
// unlinked, retrieval and lesson selection can surface the stale one. This
|
|
836
|
+
// stage links OLD → NEW with relationship `superseded_by` and accelerates the
|
|
837
|
+
// old memory's decay, WITHOUT deleting it (unlike `hicortex dedup`'s merge —
|
|
838
|
+
// this is a judgment call about content, not a duplicate).
|
|
839
|
+
//
|
|
840
|
+
// Scope: memories with `rowid > supersessionCursor` (state.json; starts 0 —
|
|
841
|
+
// the corpus is back-processed gradually, config `supersessionMaxCalls` LLM
|
|
842
|
+
// calls per night) whose shape suggests a decision/correction. For each,
|
|
843
|
+
// KNN top-5 OLDER same-shape neighbors at/above `supersessionMinSimilarity`;
|
|
844
|
+
// one constrained classify-tier LLM call per pair decides `superseded: true|
|
|
845
|
+
// false`. A parse/infra error skips just that PAIR (retried naturally next
|
|
846
|
+
// night since the cursor still advances past the memory — see the cursor
|
|
847
|
+
// note below); it never mis-links.
|
|
848
|
+
/** Default minimum COSINE similarity for a supersession candidate pair. */
|
|
849
|
+
exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = 0.8;
|
|
850
|
+
/** Default max classify-tier LLM calls (pairs evaluated) spent per nightly run. */
|
|
851
|
+
exports.DEFAULT_SUPERSESSION_MAX_CALLS = 30;
|
|
852
|
+
/** Default multiplier applied to a superseded memory's base_strength. */
|
|
853
|
+
exports.DEFAULT_SUPERSESSION_PENALTY = 0.5;
|
|
854
|
+
/** Floor under which a superseded memory's base_strength never drops. */
|
|
855
|
+
const SUPERSESSION_STRENGTH_FLOOR = 0.1;
|
|
856
|
+
/** Neighbor pool size before shape/older/similarity filtering narrows to top 5. */
|
|
857
|
+
const SUPERSESSION_NEIGHBOR_POOL = 15;
|
|
858
|
+
/** Older-neighbor pairs kept per candidate after filtering. */
|
|
859
|
+
const SUPERSESSION_NEIGHBOR_TOP_K = 5;
|
|
860
|
+
/** Candidate rows read per SQL page (call budget stops the loop well before this in practice). */
|
|
861
|
+
const SUPERSESSION_BATCH_SIZE = 500;
|
|
862
|
+
/** A memory whose content/type marks it as a decision or correction. */
|
|
863
|
+
function isDecisionShape(mem) {
|
|
864
|
+
return (mem.memory_type === "decision" ||
|
|
865
|
+
mem.content.includes("[Decisions Made]") ||
|
|
866
|
+
mem.content.includes("[Corrections & Rejections]"));
|
|
867
|
+
}
|
|
868
|
+
/** True when a `superseded_by` link already exists between the pair, either direction. */
|
|
869
|
+
function alreadySupersedeLinked(db, oldId, newId) {
|
|
870
|
+
const row = db
|
|
871
|
+
.prepare(`SELECT 1 FROM memory_links WHERE relationship = 'superseded_by'
|
|
872
|
+
AND ((source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?))`)
|
|
873
|
+
.get(oldId, newId, newId, oldId);
|
|
874
|
+
return !!row;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* Build the constrained supersession-check prompt. Content is truncated the
|
|
878
|
+
* same width as domain-classify.ts's classifier (1500 chars) — this is a
|
|
879
|
+
* classify-tier call with the same cost profile.
|
|
880
|
+
*/
|
|
881
|
+
function buildSupersessionPrompt(oldContent, newContent) {
|
|
882
|
+
const trunc = (s) => (s.length > 1500 ? `${s.slice(0, 1500)}…` : s);
|
|
883
|
+
return (`You are checking whether a NEWER memory supersedes an OLDER one in an AI agent's long-term memory.\n\n` +
|
|
884
|
+
`OLDER MEMORY:\n${trunc(oldContent)}\n\n` +
|
|
885
|
+
`NEWER MEMORY:\n${trunc(newContent)}\n\n` +
|
|
886
|
+
`Does the NEWER memory reverse, replace, or invalidate the OLDER one — e.g. a later decision overturns an ` +
|
|
887
|
+
`earlier one, or a correction retracts a prior claim? Two memories that are merely related, or that both ` +
|
|
888
|
+
`still hold true, are NOT a supersession.\n` +
|
|
889
|
+
`Reply with ONLY a JSON object, no prose: {"superseded": true} or {"superseded": false}.`);
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Parse the model's supersession verdict. Returns the boolean on a valid
|
|
893
|
+
* reply, or null on anything unparseable (caller skips the pair — no retry,
|
|
894
|
+
* unlike domain-classify's tag classifier; a missed pair is retried naturally
|
|
895
|
+
* when this stage revisits the corpus).
|
|
896
|
+
*/
|
|
897
|
+
function parseSupersessionReply(reply) {
|
|
898
|
+
if (!reply)
|
|
899
|
+
return null;
|
|
900
|
+
const start = reply.indexOf("{");
|
|
901
|
+
const end = reply.lastIndexOf("}");
|
|
902
|
+
if (start === -1 || end === -1 || end <= start)
|
|
903
|
+
return null;
|
|
904
|
+
try {
|
|
905
|
+
const obj = JSON.parse(reply.slice(start, end + 1));
|
|
906
|
+
return typeof obj.superseded === "boolean" ? obj.superseded : null;
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* ONE classify-tier LLM call judging whether `newContent` supersedes
|
|
914
|
+
* `oldContent`. Returns null on any infra error or unparseable reply — the
|
|
915
|
+
* caller treats null as "skip this pair" (never mis-links on ambiguity).
|
|
916
|
+
*/
|
|
917
|
+
async function classifySupersession(llm, oldContent, newContent) {
|
|
918
|
+
try {
|
|
919
|
+
const raw = await llm.completeClassify(buildSupersessionPrompt(oldContent, newContent), 32);
|
|
920
|
+
return parseSupersessionReply(raw);
|
|
921
|
+
}
|
|
922
|
+
catch {
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Find up to SUPERSESSION_NEIGHBOR_TOP_K OLDER, same-shape neighbors for a
|
|
928
|
+
* candidate, at/above minSimilarity, highest cosine first. Reuses the
|
|
929
|
+
* candidate's stored embedding when available (relink-style fallback to
|
|
930
|
+
* embedFn otherwise).
|
|
931
|
+
*/
|
|
932
|
+
async function findOlderNeighbors(db, candidate, embedFn, minSimilarity) {
|
|
933
|
+
const embedding = storage.getStoredEmbedding(db, candidate.id) ?? (await embedFn(candidate.content));
|
|
934
|
+
return storage
|
|
935
|
+
.vectorSearch(db, embedding, SUPERSESSION_NEIGHBOR_POOL, [candidate.id])
|
|
936
|
+
.filter((n) => n.created_at < candidate.created_at &&
|
|
937
|
+
isDecisionShape(n) &&
|
|
938
|
+
(0, retrieval_js_1.l2ToCosine)(n.distance) >= minSimilarity)
|
|
939
|
+
.sort((a, b) => (0, retrieval_js_1.l2ToCosine)(b.distance) - (0, retrieval_js_1.l2ToCosine)(a.distance))
|
|
940
|
+
.slice(0, SUPERSESSION_NEIGHBOR_TOP_K);
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Nightly supersession-detection stage. Scans memories/rowid > cursor whose
|
|
944
|
+
* shape suggests a decision/correction, checks each against its older
|
|
945
|
+
* same-shape neighbors, and links confirmed supersessions. Dry-run performs
|
|
946
|
+
* discovery + the free idempotency check only — no LLM calls, no writes, no
|
|
947
|
+
* cursor persistence (mirrors stageImportance/stageContentDomains's dry-run
|
|
948
|
+
* convention of never spending budget on a preview).
|
|
949
|
+
*
|
|
950
|
+
* Cursor discipline is DELIBERATELY simple (owner amendment): the cursor
|
|
951
|
+
* advances past a candidate once its neighbor set has been considered,
|
|
952
|
+
* REGARDLESS of whether every pair got an LLM call (call budget) or a clean
|
|
953
|
+
* verdict (infra skip) — missing one pair is acceptable and self-heals next
|
|
954
|
+
* time this memory's neighborhood is re-examined via a NEWER memory's own
|
|
955
|
+
* candidacy. It only stops SHORT of a candidate when the budget is already
|
|
956
|
+
* exhausted before that candidate starts, so the cursor never skips a
|
|
957
|
+
* candidate that was never looked at.
|
|
958
|
+
*/
|
|
959
|
+
async function stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, options = {}) {
|
|
960
|
+
// Config values pass through `unknown`-typed JSON — validate rather than
|
|
961
|
+
// trust (same discipline as retrieval.ts's configureRecall).
|
|
962
|
+
const validNumber = (v, fallback, ok) => {
|
|
963
|
+
const n = Number(v);
|
|
964
|
+
return Number.isFinite(n) && ok(n) ? n : fallback;
|
|
965
|
+
};
|
|
966
|
+
const minSimilarity = validNumber(options.minSimilarity, exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY, (n) => n > 0 && n <= 1);
|
|
967
|
+
const maxCalls = validNumber(options.maxCalls, exports.DEFAULT_SUPERSESSION_MAX_CALLS, (n) => n >= 0);
|
|
968
|
+
const penalty = validNumber(options.penalty, exports.DEFAULT_SUPERSESSION_PENALTY, (n) => n > 0 && n <= 1);
|
|
969
|
+
const startCursor = (0, state_js_1.loadState)(stateDir).supersessionCursor ?? 0;
|
|
970
|
+
const rows = db
|
|
971
|
+
.prepare(`SELECT rowid AS __rowid, * FROM memories
|
|
972
|
+
WHERE rowid > ?
|
|
973
|
+
AND (memory_type = 'decision' OR content LIKE '%[Decisions Made]%' OR content LIKE '%[Corrections & Rejections]%')
|
|
974
|
+
ORDER BY rowid ASC LIMIT ?`)
|
|
975
|
+
.all(startCursor, SUPERSESSION_BATCH_SIZE);
|
|
976
|
+
let scanned = 0;
|
|
977
|
+
let evaluated = 0;
|
|
978
|
+
let superseded = 0;
|
|
979
|
+
let skippedInfra = 0;
|
|
980
|
+
let skippedIdempotent = 0;
|
|
981
|
+
let callsUsed = 0;
|
|
982
|
+
let cursor = startCursor;
|
|
983
|
+
for (const candidate of rows) {
|
|
984
|
+
if (!dryRun && (callsUsed >= maxCalls || budget.exhausted))
|
|
985
|
+
break;
|
|
986
|
+
scanned++;
|
|
987
|
+
let neighbors;
|
|
988
|
+
try {
|
|
989
|
+
neighbors = await findOlderNeighbors(db, candidate, embedFn, minSimilarity);
|
|
990
|
+
}
|
|
991
|
+
catch (err) {
|
|
992
|
+
console.warn(`[hicortex] supersession: discovery failed for ${candidate.id.slice(0, 8)} — ${err instanceof Error ? err.message : String(err)}`);
|
|
993
|
+
cursor = candidate.__rowid;
|
|
994
|
+
continue;
|
|
995
|
+
}
|
|
996
|
+
for (const neighbor of neighbors) {
|
|
997
|
+
if (alreadySupersedeLinked(db, neighbor.id, candidate.id)) {
|
|
998
|
+
skippedIdempotent++;
|
|
999
|
+
continue;
|
|
1000
|
+
}
|
|
1001
|
+
if (dryRun)
|
|
1002
|
+
continue; // preview only — no LLM call, no write
|
|
1003
|
+
if (callsUsed >= maxCalls || !budget.use("supersession"))
|
|
1004
|
+
break;
|
|
1005
|
+
callsUsed++;
|
|
1006
|
+
const verdict = await classifySupersession(llm, neighbor.content, candidate.content);
|
|
1007
|
+
evaluated++;
|
|
1008
|
+
if (verdict === null) {
|
|
1009
|
+
skippedInfra++;
|
|
1010
|
+
continue;
|
|
1011
|
+
}
|
|
1012
|
+
if (verdict) {
|
|
1013
|
+
const cosine = (0, retrieval_js_1.l2ToCosine)(neighbor.distance);
|
|
1014
|
+
storage.addLink(db, neighbor.id, candidate.id, "superseded_by", cosine);
|
|
1015
|
+
const newStrength = Math.max(SUPERSESSION_STRENGTH_FLOOR, (neighbor.base_strength ?? 0.5) * penalty);
|
|
1016
|
+
storage.updateMemory(db, neighbor.id, { base_strength: newStrength });
|
|
1017
|
+
superseded++;
|
|
1018
|
+
console.log(`[hicortex] Supersession: ${neighbor.id.slice(0, 8)} superseded_by ${candidate.id.slice(0, 8)} (cosine ${cosine.toFixed(3)})`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
cursor = candidate.__rowid;
|
|
1022
|
+
}
|
|
1023
|
+
if (!dryRun) {
|
|
1024
|
+
(0, state_js_1.updateState)((s) => {
|
|
1025
|
+
s.supersessionCursor = cursor;
|
|
1026
|
+
}, stateDir);
|
|
1027
|
+
}
|
|
1028
|
+
if (rows.length > 0) {
|
|
1029
|
+
console.log(`[hicortex] Supersession detection: ${scanned} scanned, ${evaluated} evaluated, ${superseded} superseded, ` +
|
|
1030
|
+
`${skippedIdempotent} already-linked, ${skippedInfra} infra-skipped (cursor ${cursor})`);
|
|
1031
|
+
}
|
|
1032
|
+
return {
|
|
1033
|
+
scanned,
|
|
1034
|
+
evaluated,
|
|
1035
|
+
superseded,
|
|
1036
|
+
skipped_infra: skippedInfra,
|
|
1037
|
+
skipped_idempotent: skippedIdempotent,
|
|
1038
|
+
cursor,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
// ---------------------------------------------------------------------------
|
|
825
1042
|
// Stage 4: Decay & Prune
|
|
826
1043
|
// ---------------------------------------------------------------------------
|
|
1044
|
+
/**
|
|
1045
|
+
* Exported for the #191 eval baseline (src/eval/decay-eval.ts) so the audit
|
|
1046
|
+
* runs the REAL production prune predicate against a DB snapshot instead of
|
|
1047
|
+
* reimplementing it. `dryRun=true` performs reads only (candidates are
|
|
1048
|
+
* counted, nothing is deleted) — safe against a readonly snapshot connection.
|
|
1049
|
+
* Not otherwise part of the public API surface.
|
|
1050
|
+
*/
|
|
827
1051
|
function stageDecayPrune(db, dryRun) {
|
|
828
1052
|
const now = new Date();
|
|
829
1053
|
const cutoff = new Date(now.getTime() - CONSOLIDATE_PRUNE_MIN_AGE_DAYS * 24 * 60 * 60 * 1000);
|
|
@@ -853,7 +1077,7 @@ function stageDecayPrune(db, dryRun) {
|
|
|
853
1077
|
}
|
|
854
1078
|
return { candidates, pruned, failed };
|
|
855
1079
|
}
|
|
856
|
-
async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions) {
|
|
1080
|
+
async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions, supersessionOptions) {
|
|
857
1081
|
const start = new Date();
|
|
858
1082
|
const report = {
|
|
859
1083
|
started_at: start.toISOString(),
|
|
@@ -924,6 +1148,8 @@ async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection
|
|
|
924
1148
|
report.stages.links = await stageLinks(db, precheck.newMemories, embedFn, dryRun, llm, budget);
|
|
925
1149
|
// Stage 3.5: Hub Detection — boost highly-connected memories
|
|
926
1150
|
report.stages.hub_boost = stageHubBoost(db, dryRun);
|
|
1151
|
+
// Stage 3.7: Supersession Detection (#191 Phase B)
|
|
1152
|
+
report.stages.supersession = await stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, supersessionOptions);
|
|
927
1153
|
// Stage 4: Decay & Prune
|
|
928
1154
|
report.stages.decay_prune = stageDecayPrune(db, dryRun);
|
|
929
1155
|
}
|
package/dist/db.js
CHANGED
|
@@ -335,6 +335,32 @@ const MIGRATIONS = [
|
|
|
335
335
|
}
|
|
336
336
|
},
|
|
337
337
|
},
|
|
338
|
+
{
|
|
339
|
+
version: 9,
|
|
340
|
+
name: "add_dedup_log",
|
|
341
|
+
up: (db) => {
|
|
342
|
+
// `hicortex dedup` (#100) audit trail. Every merged-away loser gets a
|
|
343
|
+
// row here BEFORE it is deleted, keyed by loser_id so a loser can only
|
|
344
|
+
// be logged once (defensive — the CLI never re-merges a deleted id).
|
|
345
|
+
// `source_session` is the loser's OWN source_session value (may be
|
|
346
|
+
// NULL): it is CRITICAL that /distill's dedup prechecks in
|
|
347
|
+
// mcp-server.ts also consult this table, because a deleted loser may
|
|
348
|
+
// have carried the ONLY marker for a session — without this table a
|
|
349
|
+
// `--recapture-window` run could re-ingest content the merge already
|
|
350
|
+
// consolidated. Sidecar table (not a memories column) since it survives
|
|
351
|
+
// the row it describes being deleted.
|
|
352
|
+
db.exec(`
|
|
353
|
+
CREATE TABLE IF NOT EXISTS dedup_log (
|
|
354
|
+
loser_id TEXT PRIMARY KEY,
|
|
355
|
+
canonical_id TEXT NOT NULL,
|
|
356
|
+
source_session TEXT,
|
|
357
|
+
content_head TEXT,
|
|
358
|
+
merged_at TIMESTAMP NOT NULL
|
|
359
|
+
)
|
|
360
|
+
`);
|
|
361
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_dedup_log_source_session ON dedup_log(source_session)");
|
|
362
|
+
},
|
|
363
|
+
},
|
|
338
364
|
];
|
|
339
365
|
/**
|
|
340
366
|
* Run all pending migrations against the database.
|