@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
|
@@ -0,0 +1,1226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Reconsolidation (#384, #392) — the store resolves its own corrections, and
|
|
4
|
+
* THE unified resolution stage.
|
|
5
|
+
*
|
|
6
|
+
* Nightly consolidation stage (runs as Stage 3.8, after supersession, before
|
|
7
|
+
* decay/prune) that detects memories which correct, retract, supersede, or
|
|
8
|
+
* DUPLICATE older ones; REWRITES corrected facts in place (absorbing
|
|
9
|
+
* transition-only trigger memories), MERGES confirmed duplicates via the
|
|
10
|
+
* dedup core (absorbing the loser), and marks everything else. Companions to
|
|
11
|
+
* the stage:
|
|
12
|
+
* - explicit write-time marking (`corrects`/`supersedes` at /ingest +
|
|
13
|
+
* `hicortex_ingest`) — deterministic link + status, zero LLM;
|
|
14
|
+
* - `memory_history` audit + the `hicortex history` CLI (listing + rollback).
|
|
15
|
+
*
|
|
16
|
+
* #392 — one zone system, ONE verdict per pair: below `correctionMinSimilarity`
|
|
17
|
+
* (floor, 0.75) pairs are not candidates; in [floor, `dedupAutoMergeThreshold`)
|
|
18
|
+
* (ceiling, 0.92) each unlinked pair gets ONE verdict call whose action is
|
|
19
|
+
* `merge` | `corrects` | `supersedes` | `none`; at/above the ceiling the
|
|
20
|
+
* deterministic merge zone (dedup.ts runDeterministicMergeZone — LLM-free,
|
|
21
|
+
* budget-free) owns the pair. The merge disposition reuses the dedup core's
|
|
22
|
+
* execution (canonical pick, link re-point, dedup_log, metadata rails); a
|
|
23
|
+
* merge verdict below `correctionRewriteMinConfidence` keeps both memories.
|
|
24
|
+
*
|
|
25
|
+
* Status vocabulary (code-defined, extensible — deliberately NOT config):
|
|
26
|
+
* NULL/'active' default | 'superseded' + 'retracted' demote in ranking |
|
|
27
|
+
* 'corrected' = rewritten, never demotes (demoting it would bury the
|
|
28
|
+
* correction — the exact failure this stage fixes) | 'absorbed' = invisible
|
|
29
|
+
* to recall (no vector row, no FTS row; plain row + link kept as evidence,
|
|
30
|
+
* session lineage, rollback reference). Merge losers share 'absorbed'
|
|
31
|
+
* (storage.absorbMemory is the one primitive) — the only difference is the
|
|
32
|
+
* audit trail: rewrites roll back via memory_history; merges recover via
|
|
33
|
+
* dedup_log + the retained loser row (NOT history-rollback-able).
|
|
34
|
+
*
|
|
35
|
+
* This module deliberately does NOT import consolidate.ts (which imports this
|
|
36
|
+
* module to wire the stage) — the budget is consumed through the structural
|
|
37
|
+
* StageBudget interface below, which BudgetTracker satisfies. dedup.ts is
|
|
38
|
+
* imported (never the reverse) for the merge core.
|
|
39
|
+
*/
|
|
40
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
41
|
+
if (k2 === undefined) k2 = k;
|
|
42
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
43
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
44
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
45
|
+
}
|
|
46
|
+
Object.defineProperty(o, k2, desc);
|
|
47
|
+
}) : (function(o, m, k, k2) {
|
|
48
|
+
if (k2 === undefined) k2 = k;
|
|
49
|
+
o[k2] = m[k];
|
|
50
|
+
}));
|
|
51
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
52
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
53
|
+
}) : function(o, v) {
|
|
54
|
+
o["default"] = v;
|
|
55
|
+
});
|
|
56
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
57
|
+
var ownKeys = function(o) {
|
|
58
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
59
|
+
var ar = [];
|
|
60
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
61
|
+
return ar;
|
|
62
|
+
};
|
|
63
|
+
return ownKeys(o);
|
|
64
|
+
};
|
|
65
|
+
return function (mod) {
|
|
66
|
+
if (mod && mod.__esModule) return mod;
|
|
67
|
+
var result = {};
|
|
68
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
69
|
+
__setModuleDefault(result, mod);
|
|
70
|
+
return result;
|
|
71
|
+
};
|
|
72
|
+
})();
|
|
73
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
74
|
+
exports.absorbTrigger = exports.DEMOTED_STATUSES = exports.FOOTER_HEAD_MAX_CHARS = exports.DEFAULT_CORRECTION_REWRITE_MIN_CONFIDENCE = exports.DEFAULT_CORRECTION_MIN_SIMILARITY = exports.RECONSOLIDATION_STAGE_LABEL = void 0;
|
|
75
|
+
exports.isFactShapedTarget = isFactShapedTarget;
|
|
76
|
+
exports.buildCorrectionVerdictPrompt = buildCorrectionVerdictPrompt;
|
|
77
|
+
exports.parseCorrectionVerdict = parseCorrectionVerdict;
|
|
78
|
+
exports.buildRewritePrompt = buildRewritePrompt;
|
|
79
|
+
exports.parseRewriteReply = parseRewriteReply;
|
|
80
|
+
exports.buildCorrectionFooter = buildCorrectionFooter;
|
|
81
|
+
exports.checkExplicitMarkTarget = checkExplicitMarkTarget;
|
|
82
|
+
exports.applyExplicitMark = applyExplicitMark;
|
|
83
|
+
exports.replaceMemoryVector = replaceMemoryVector;
|
|
84
|
+
exports.unabsorbTrigger = unabsorbTrigger;
|
|
85
|
+
exports.getMemoryHistory = getMemoryHistory;
|
|
86
|
+
exports.getHistoryRow = getHistoryRow;
|
|
87
|
+
exports.buildResolutionBands = buildResolutionBands;
|
|
88
|
+
exports.bandForCosine = bandForCosine;
|
|
89
|
+
exports.stageReconsolidation = stageReconsolidation;
|
|
90
|
+
exports.rollbackHistoryRow = rollbackHistoryRow;
|
|
91
|
+
exports.runHistoryCommand = runHistoryCommand;
|
|
92
|
+
const retrieval_js_1 = require("./retrieval.js");
|
|
93
|
+
const storage = __importStar(require("./storage.js"));
|
|
94
|
+
const state_js_1 = require("./state.js");
|
|
95
|
+
const db_js_1 = require("./db.js");
|
|
96
|
+
const capture_js_1 = require("./capture.js");
|
|
97
|
+
const paths_js_1 = require("./paths.js");
|
|
98
|
+
const dedup_js_1 = require("./dedup.js");
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Constants + status vocabulary
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
/** Stage label used for every budget.use()/recordUsage() call (#384). */
|
|
103
|
+
exports.RECONSOLIDATION_STAGE_LABEL = "reconsolidation";
|
|
104
|
+
/**
|
|
105
|
+
* Default minimum COSINE similarity for a correction candidate pair. Lower
|
|
106
|
+
* than the supersession stage's 0.80 on purpose: a retraction often rides
|
|
107
|
+
* inside an otherwise unrelated memory (the field failure that opened this
|
|
108
|
+
* issue), so the neighborhood gate must be a touch wider while the LLM
|
|
109
|
+
* verdict + confidence gate carry the precision load.
|
|
110
|
+
*/
|
|
111
|
+
exports.DEFAULT_CORRECTION_MIN_SIMILARITY = 0.75;
|
|
112
|
+
/**
|
|
113
|
+
* Default minimum verdict confidence for the REWRITE fork. Below this a
|
|
114
|
+
* `corrects` verdict degrades to mark-only — a weak mark is recoverable, a
|
|
115
|
+
* weak rewrite is corruption.
|
|
116
|
+
*/
|
|
117
|
+
exports.DEFAULT_CORRECTION_REWRITE_MIN_CONFIDENCE = 0.8;
|
|
118
|
+
/** Neighbor pool size before older/similarity filtering narrows to top 5 (supersession mirror). */
|
|
119
|
+
const CORRECTION_NEIGHBOR_POOL = 15;
|
|
120
|
+
/** Older-neighbor pairs kept per candidate after filtering (supersession mirror). */
|
|
121
|
+
const CORRECTION_NEIGHBOR_TOP_K = 5;
|
|
122
|
+
/** Content truncation for prompts (classify-tier cost profile; supersession precedent). */
|
|
123
|
+
const PROMPT_TRUNCATE_CHARS = 1500;
|
|
124
|
+
/** Head of the old content quoted in the provenance footer. */
|
|
125
|
+
exports.FOOTER_HEAD_MAX_CHARS = 160;
|
|
126
|
+
/** Base slack allowed on a rewrite beyond the old content length (AC4). */
|
|
127
|
+
const REWRITE_BASE_SLACK_CHARS = 2000;
|
|
128
|
+
/** Additional slack per trigger beyond the first (AC4). */
|
|
129
|
+
const REWRITE_PER_TRIGGER_SLACK_CHARS = 500;
|
|
130
|
+
/**
|
|
131
|
+
* Statuses that demote a memory's ranking score (retrieval.ts findDemotedIds).
|
|
132
|
+
* 'corrected' is deliberately absent — see module doc.
|
|
133
|
+
*/
|
|
134
|
+
exports.DEMOTED_STATUSES = ["superseded", "retracted"];
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Shape + link helpers
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
/**
|
|
139
|
+
* True when a memory is REWRITE-ELIGIBLE — a fact-shaped target. The fork is
|
|
140
|
+
* keyed on the existing taxonomy the code already trusts (facts are
|
|
141
|
+
* rewritten; decisions/plans/experiences are history, marked only).
|
|
142
|
+
*/
|
|
143
|
+
function isFactShapedTarget(mem) {
|
|
144
|
+
return mem.memory_type === "knowledge" || mem.content.includes("[Facts Learned]");
|
|
145
|
+
}
|
|
146
|
+
/** True when a superseded_by OR corrected_by link already exists between the pair, either direction. */
|
|
147
|
+
function alreadyResolutionLinked(db, oldId, newId) {
|
|
148
|
+
const row = db
|
|
149
|
+
.prepare(`SELECT 1 FROM memory_links WHERE relationship IN ('superseded_by', 'corrected_by')
|
|
150
|
+
AND ((source_id = ? AND target_id = ?) OR (source_id = ? AND target_id = ?))`)
|
|
151
|
+
.get(oldId, newId, newId, oldId);
|
|
152
|
+
return !!row;
|
|
153
|
+
}
|
|
154
|
+
/** True when a link with exactly this relationship exists on the ordered pair. */
|
|
155
|
+
function hasLink(db, sourceId, targetId, relationship) {
|
|
156
|
+
return !!db
|
|
157
|
+
.prepare("SELECT 1 FROM memory_links WHERE source_id = ? AND target_id = ? AND relationship = ?")
|
|
158
|
+
.get(sourceId, targetId, relationship);
|
|
159
|
+
}
|
|
160
|
+
function nowIso() {
|
|
161
|
+
return new Date().toISOString();
|
|
162
|
+
}
|
|
163
|
+
/** Build the constrained pair-verdict prompt (1500-char truncation, supersession precedent). */
|
|
164
|
+
function buildCorrectionVerdictPrompt(oldContent, newContent) {
|
|
165
|
+
const trunc = (s) => (s.length > PROMPT_TRUNCATE_CHARS ? `${s.slice(0, PROMPT_TRUNCATE_CHARS)}…` : s);
|
|
166
|
+
return (`You are checking how a NEWER memory relates to an OLDER one in an AI agent's long-term memory.\n\n` +
|
|
167
|
+
`OLDER MEMORY:\n${trunc(oldContent)}\n\n` +
|
|
168
|
+
`NEWER MEMORY:\n${trunc(newContent)}\n\n` +
|
|
169
|
+
`How does the NEWER memory relate to the OLDER one?\n` +
|
|
170
|
+
`- "merge": the two memories carry the SAME underlying fact, verdict, or decision, differing only in ` +
|
|
171
|
+
`wording, detail, or qualifiers — neither invalidates the other; they are two statements of one claim.\n` +
|
|
172
|
+
`- "corrects": the newer memory fixes a factual error or retraction in the older one — the older claim is ` +
|
|
173
|
+
`wrong, no longer true, or was retracted, and the newer memory carries the corrected fact.\n` +
|
|
174
|
+
`- "supersedes": the newer memory replaces a decision, plan, or state that was valid at the time but is ` +
|
|
175
|
+
`now outdated — a replacement, not a factual correction.\n` +
|
|
176
|
+
`- "none": unrelated, merely similar, or both can still be true (an addition or elaboration).\n\n` +
|
|
177
|
+
`Reply with ONLY a JSON object, no prose: ` +
|
|
178
|
+
`{"action": "merge" | "corrects" | "supersedes" | "none", "confidence": <number between 0 and 1>}`);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Parse the pair verdict. Null on anything unparseable, unknown action, or an
|
|
182
|
+
* out-of-range/missing confidence — the caller counts skipped_infra and moves
|
|
183
|
+
* on (same discipline as parseSupersessionReply: never mis-judge on ambiguity).
|
|
184
|
+
*/
|
|
185
|
+
function parseCorrectionVerdict(reply) {
|
|
186
|
+
if (!reply)
|
|
187
|
+
return null;
|
|
188
|
+
const start = reply.indexOf("{");
|
|
189
|
+
const end = reply.lastIndexOf("}");
|
|
190
|
+
if (start === -1 || end === -1 || end <= start)
|
|
191
|
+
return null;
|
|
192
|
+
let obj;
|
|
193
|
+
try {
|
|
194
|
+
obj = JSON.parse(reply.slice(start, end + 1));
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
const action = obj.action;
|
|
200
|
+
if (action !== "merge" && action !== "corrects" && action !== "supersedes" && action !== "none")
|
|
201
|
+
return null;
|
|
202
|
+
const confidence = Number(obj.confidence);
|
|
203
|
+
if (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)
|
|
204
|
+
return null;
|
|
205
|
+
return { action, confidence };
|
|
206
|
+
}
|
|
207
|
+
/** Build the constrained rewrite prompt: old content + N trigger contents, nothing else. */
|
|
208
|
+
function buildRewritePrompt(oldContent, triggers) {
|
|
209
|
+
const trunc = (s) => (s.length > PROMPT_TRUNCATE_CHARS ? `${s.slice(0, PROMPT_TRUNCATE_CHARS)}…` : s);
|
|
210
|
+
const triggerBlocks = triggers
|
|
211
|
+
.map((t) => `[${t.id}] ${trunc(t.content)}`)
|
|
212
|
+
.join("\n\n");
|
|
213
|
+
return (`You are rewriting a memory in an AI agent's long-term memory so it carries the corrected story.\n\n` +
|
|
214
|
+
`OLDER MEMORY (currently stored; contains the outdated or incorrect claim):\n${trunc(oldContent)}\n\n` +
|
|
215
|
+
`CORRECTING MEMORIES (newer; together they supply the correction):\n${triggerBlocks}\n\n` +
|
|
216
|
+
`Compose the corrected memory using ONLY the older memory and the correcting memories — no outside ` +
|
|
217
|
+
`knowledge, no speculation. Keep the older memory's subject and scope; replace the wrong claim with the ` +
|
|
218
|
+
`corrected fact. Write plain prose for long-term recall (no meta commentary, no JSON inside the text).\n` +
|
|
219
|
+
`Then judge each correcting memory: "absorb" if it mostly restates what the corrected memory now says ` +
|
|
220
|
+
`(transition-only — safe to hide from recall); "keep" if it carries standalone substance beyond the ` +
|
|
221
|
+
`correction.\n\n` +
|
|
222
|
+
`Reply with ONLY a JSON object, no prose:\n` +
|
|
223
|
+
`{"rewritten": "<the corrected memory text>", "triggers": [{"id": "<trigger id verbatim>", ` +
|
|
224
|
+
`"disposition": "absorb" | "keep"}, ...]} — one triggers entry per correcting memory above, ids verbatim.`);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Parse + validate the rewrite contract (AC4). Null on ANY failure:
|
|
228
|
+
* - unparseable JSON / wrong shape;
|
|
229
|
+
* - rewritten empty, identical to the old content, or longer than
|
|
230
|
+
* old + 2000 + 500 per additional trigger;
|
|
231
|
+
* - the triggers array not covering every input trigger id exactly once
|
|
232
|
+
* (missing, unknown, or duplicated) or carrying an invalid disposition.
|
|
233
|
+
* A null return degrades the WHOLE group to mark-only (never a partial apply).
|
|
234
|
+
*/
|
|
235
|
+
function parseRewriteReply(reply, expectedTriggerIds, oldContent) {
|
|
236
|
+
if (!reply)
|
|
237
|
+
return null;
|
|
238
|
+
const start = reply.indexOf("{");
|
|
239
|
+
const end = reply.lastIndexOf("}");
|
|
240
|
+
if (start === -1 || end === -1 || end <= start)
|
|
241
|
+
return null;
|
|
242
|
+
let obj;
|
|
243
|
+
try {
|
|
244
|
+
obj = JSON.parse(reply.slice(start, end + 1));
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
const rewritten = obj.rewritten;
|
|
250
|
+
if (typeof rewritten !== "string" || rewritten.trim().length === 0)
|
|
251
|
+
return null;
|
|
252
|
+
if (rewritten.trim() === oldContent.trim())
|
|
253
|
+
return null;
|
|
254
|
+
const maxLen = oldContent.length + REWRITE_BASE_SLACK_CHARS + REWRITE_PER_TRIGGER_SLACK_CHARS * Math.max(0, expectedTriggerIds.length - 1);
|
|
255
|
+
if (rewritten.length > maxLen)
|
|
256
|
+
return null;
|
|
257
|
+
const triggersRaw = obj.triggers;
|
|
258
|
+
if (!Array.isArray(triggersRaw) || triggersRaw.length !== expectedTriggerIds.length)
|
|
259
|
+
return null;
|
|
260
|
+
const seen = new Set();
|
|
261
|
+
const triggers = [];
|
|
262
|
+
for (const t of triggersRaw) {
|
|
263
|
+
if (!t || typeof t !== "object")
|
|
264
|
+
return null;
|
|
265
|
+
const rec = t;
|
|
266
|
+
if (typeof rec.id !== "string" || !expectedTriggerIds.includes(rec.id))
|
|
267
|
+
return null;
|
|
268
|
+
if (seen.has(rec.id))
|
|
269
|
+
return null;
|
|
270
|
+
if (rec.disposition !== "absorb" && rec.disposition !== "keep")
|
|
271
|
+
return null;
|
|
272
|
+
seen.add(rec.id);
|
|
273
|
+
triggers.push({ id: rec.id, disposition: rec.disposition });
|
|
274
|
+
}
|
|
275
|
+
return { rewritten: rewritten.trim(), triggers };
|
|
276
|
+
}
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Provenance footer (template-controlled — uniform audit)
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
/**
|
|
281
|
+
* The provenance footer appended to every rewritten memory:
|
|
282
|
+
* `previously believed "<≤160-char head of old content>" until <date>`.
|
|
283
|
+
* date = the ISO DATE (YYYY-MM-DD) derived from the latest trigger's
|
|
284
|
+
* created_at — the same trigger recorded as the history row's evidence_id.
|
|
285
|
+
*/
|
|
286
|
+
function buildCorrectionFooter(oldContent, dateISO) {
|
|
287
|
+
const head = oldContent.slice(0, exports.FOOTER_HEAD_MAX_CHARS);
|
|
288
|
+
return `previously believed "${head}" until ${dateISO}`;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Validate an explicit mark target BEFORE anything is written (AC1: on an
|
|
292
|
+
* unknown/ambiguous id the WHOLE request fails and nothing is stored).
|
|
293
|
+
* httpStatus is the REST code; the MCP tool reuses the message verbatim.
|
|
294
|
+
*/
|
|
295
|
+
function checkExplicitMarkTarget(db, input) {
|
|
296
|
+
const targetId = storage.resolveMemoryId(db, input.target);
|
|
297
|
+
if (!targetId) {
|
|
298
|
+
return {
|
|
299
|
+
ok: false,
|
|
300
|
+
httpStatus: 404,
|
|
301
|
+
error: `${input.kind} target not found or ambiguous: ${input.target}`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
const target = storage.getMemory(db, targetId);
|
|
305
|
+
if (!target) {
|
|
306
|
+
return { ok: false, httpStatus: 404, error: `${input.kind} target not found: ${input.target}` };
|
|
307
|
+
}
|
|
308
|
+
if (target.status === "absorbed") {
|
|
309
|
+
return {
|
|
310
|
+
ok: false,
|
|
311
|
+
httpStatus: 409,
|
|
312
|
+
error: `${input.kind} target ${targetId.slice(0, 8)} is absorbed (invisible to recall) — roll back the absorbing rewrite first (hicortex history --rollback)`,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
return { ok: true, targetId };
|
|
316
|
+
}
|
|
317
|
+
/**
|
|
318
|
+
* Apply a validated explicit mark: link old → new + status on the old memory.
|
|
319
|
+
* Deterministic — no LLM. Link strength 1.0: an operator-declared mark, not a
|
|
320
|
+
* measured cosine. `corrects` → `corrected_by` + status `retracted`;
|
|
321
|
+
* `supersedes` → `superseded_by` + status `superseded` (AC1).
|
|
322
|
+
*/
|
|
323
|
+
function applyExplicitMark(db, newMemoryId, input) {
|
|
324
|
+
const check = checkExplicitMarkTarget(db, input);
|
|
325
|
+
if (!check.ok) {
|
|
326
|
+
throw new Error(`applyExplicitMark: unvalidated mark refused — ${check.error}`);
|
|
327
|
+
}
|
|
328
|
+
const relationship = input.kind === "corrects" ? "corrected_by" : "superseded_by";
|
|
329
|
+
storage.addLink(db, check.targetId, newMemoryId, relationship, 1.0);
|
|
330
|
+
storage.updateMemory(db, check.targetId, {
|
|
331
|
+
status: input.kind === "corrects" ? "retracted" : "superseded",
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
// Absorb / un-absorb + vector-replace primitives (shared by stage + rollback)
|
|
336
|
+
// ---------------------------------------------------------------------------
|
|
337
|
+
/**
|
|
338
|
+
* Drop a trigger's retrieval candidacy: status `absorbed`, vector row deleted,
|
|
339
|
+
* FTS row deleted (direct DELETE — the AFTER UPDATE trigger's `UPDATE … WHERE
|
|
340
|
+
* rowid` is a silent no-op on the missing row, so later column edits cannot
|
|
341
|
+
* resurrect it). Row + links are KEPT (evidence, session lineage, rollback).
|
|
342
|
+
* Must run inside a transaction. Tags/domain deliberately untouched (only the
|
|
343
|
+
* rewritten TARGET gets its tags cleared).
|
|
344
|
+
*
|
|
345
|
+
* #392: the implementation moved to storage.ts (`absorbMemory`) so the dedup
|
|
346
|
+
* merge core shares the ONE primitive without an import cycle; re-exported
|
|
347
|
+
* here under its historical name for the rewrite/rollback paths (nothing
|
|
348
|
+
* external imports it today, but it is the module's documented surface).
|
|
349
|
+
*/
|
|
350
|
+
exports.absorbTrigger = storage.absorbMemory;
|
|
351
|
+
/**
|
|
352
|
+
* Replace a memory's vector (delete + insert) — the /update re-embed pattern.
|
|
353
|
+
* Must run inside a transaction (the caller pre-computes the embedding
|
|
354
|
+
* asynchronously, outside the sync transaction).
|
|
355
|
+
*/
|
|
356
|
+
function replaceMemoryVector(db, memoryId, embedding) {
|
|
357
|
+
db.prepare("DELETE FROM memory_vectors WHERE id = ?").run(memoryId);
|
|
358
|
+
db.prepare("INSERT INTO memory_vectors (id, embedding) VALUES (?, ?)").run(memoryId, storage.embedToBlob(embedding));
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Reverse an absorb: status back to active (NULL), vector re-embedded from the
|
|
362
|
+
* (untouched) content, FTS row re-inserted explicitly (migration v10's rebuild
|
|
363
|
+
* pattern — the AFTER UPDATE trigger cannot recreate a deleted FTS row).
|
|
364
|
+
* Must run inside a transaction; no-op on a memory that is not currently
|
|
365
|
+
* absorbed (never resurrects an already-live row, never duplicates an FTS row).
|
|
366
|
+
*/
|
|
367
|
+
function unabsorbTrigger(db, triggerId, embedding) {
|
|
368
|
+
const mem = storage.getMemory(db, triggerId);
|
|
369
|
+
if (!mem || mem.status !== "absorbed")
|
|
370
|
+
return;
|
|
371
|
+
const rid = storage.memoryRowid(db, triggerId);
|
|
372
|
+
storage.updateMemory(db, triggerId, { status: null });
|
|
373
|
+
replaceMemoryVector(db, triggerId, embedding);
|
|
374
|
+
if (rid !== null) {
|
|
375
|
+
db.prepare(`INSERT INTO memories_fts (rowid, content, project, domain)
|
|
376
|
+
SELECT rowid, content, COALESCE(project, ''), COALESCE(domain, '') FROM memories
|
|
377
|
+
WHERE id = ? AND NOT EXISTS (SELECT 1 FROM memories_fts WHERE rowid = ?)`).run(triggerId, rid);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
/** History rows for one memory, oldest first. */
|
|
381
|
+
function getMemoryHistory(db, memoryId) {
|
|
382
|
+
return db
|
|
383
|
+
.prepare("SELECT * FROM memory_history WHERE memory_id = ? ORDER BY id ASC")
|
|
384
|
+
.all(memoryId);
|
|
385
|
+
}
|
|
386
|
+
function getHistoryRow(db, historyRowId) {
|
|
387
|
+
return db
|
|
388
|
+
.prepare("SELECT * FROM memory_history WHERE id = ?")
|
|
389
|
+
.get(historyRowId) ?? null;
|
|
390
|
+
}
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Cosine-band statistics (#392) — self-calibration evidence
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
/** Fixed intermediate band edges — only the floor and ceiling are config. */
|
|
395
|
+
const BAND_INTERMEDIATE_EDGES = [0.8, 0.85, 0.9];
|
|
396
|
+
/**
|
|
397
|
+
* Build the verdict-statistic bands from the LIVE floor/ceiling (#392): edges
|
|
398
|
+
* = sorted unique [floor, 0.80, 0.85, 0.90, ceiling]; bands are [e0,e1) …
|
|
399
|
+
* [e(n-1),en) plus the deterministic ">=en" band. Intermediate edges outside
|
|
400
|
+
* (floor, ceiling) are dropped — a band below the floor can never receive a
|
|
401
|
+
* verdict (candidates are >= floor), so a raised floor collapses the lower
|
|
402
|
+
* bands away instead of seeding dead labels. Labels use the numbers as
|
|
403
|
+
* configured ("0.75-0.8" … "0.9-0.92", ">=0.92").
|
|
404
|
+
*/
|
|
405
|
+
function buildResolutionBands(floor, ceiling) {
|
|
406
|
+
const edges = [
|
|
407
|
+
floor,
|
|
408
|
+
...BAND_INTERMEDIATE_EDGES.filter((e) => e > floor && e < ceiling),
|
|
409
|
+
ceiling,
|
|
410
|
+
]
|
|
411
|
+
.filter((e) => Number.isFinite(e))
|
|
412
|
+
.filter((e, i, arr) => arr.indexOf(e) === i)
|
|
413
|
+
.sort((a, b) => a - b);
|
|
414
|
+
const bands = [];
|
|
415
|
+
for (let i = 0; i < edges.length - 1; i++) {
|
|
416
|
+
bands.push({ label: `${edges[i]}-${edges[i + 1]}`, lo: edges[i], hi: edges[i + 1] });
|
|
417
|
+
}
|
|
418
|
+
if (edges.length > 0) {
|
|
419
|
+
bands.push({ label: `>=${edges[edges.length - 1]}`, lo: edges[edges.length - 1], hi: Infinity });
|
|
420
|
+
}
|
|
421
|
+
return bands;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* The band a pair's cosine falls into: the [lo, hi) band that contains it,
|
|
425
|
+
* falling through to the final >= band for cosines at/above the last edge.
|
|
426
|
+
* Null only for a cosine below the floor (never a candidate).
|
|
427
|
+
*/
|
|
428
|
+
function bandForCosine(bands, cosine) {
|
|
429
|
+
for (const b of bands) {
|
|
430
|
+
if (cosine >= b.lo && cosine < b.hi)
|
|
431
|
+
return b;
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
/** An empty band-stat record (fresh accumulation starts from zeroes). */
|
|
436
|
+
function emptyBandStat() {
|
|
437
|
+
return { pairs: 0, merge: 0, corrects: 0, supersedes: 0, none: 0, merge_below_gate: 0, conf_sum: 0 };
|
|
438
|
+
}
|
|
439
|
+
/** Add a run's per-band counts into a cumulative record (in place). */
|
|
440
|
+
function accumulateBandStat(cumulative, run) {
|
|
441
|
+
cumulative.pairs += run.pairs;
|
|
442
|
+
cumulative.merge += run.merge;
|
|
443
|
+
cumulative.corrects += run.corrects;
|
|
444
|
+
cumulative.supersedes += run.supersedes;
|
|
445
|
+
cumulative.none += run.none;
|
|
446
|
+
cumulative.merge_below_gate += run.merge_below_gate;
|
|
447
|
+
cumulative.conf_sum += run.conf_sum;
|
|
448
|
+
if (run.metadata_skipped !== undefined) {
|
|
449
|
+
cumulative.metadata_skipped = (cumulative.metadata_skipped ?? 0) + run.metadata_skipped;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Find up to CORRECTION_NEIGHBOR_TOP_K OLDER neighbors for a candidate at/above
|
|
454
|
+
* minSimilarity, highest cosine first. NO shape filter on either side — a
|
|
455
|
+
* retraction riding inside an unrelated memory is exactly the pair this stage
|
|
456
|
+
* exists to catch. Absorbed neighbors are structurally absent (no vector row).
|
|
457
|
+
*/
|
|
458
|
+
async function findOlderCorrectionNeighbors(db, candidate, embedFn, minSimilarity) {
|
|
459
|
+
const embedding = storage.getStoredEmbedding(db, candidate.id) ?? (await embedFn(candidate.content));
|
|
460
|
+
return storage
|
|
461
|
+
.vectorSearch(db, embedding, CORRECTION_NEIGHBOR_POOL, [candidate.id])
|
|
462
|
+
.filter((n) => n.created_at < candidate.created_at && (0, retrieval_js_1.l2ToCosine)(n.distance) >= minSimilarity)
|
|
463
|
+
.sort((a, b) => (0, retrieval_js_1.l2ToCosine)(b.distance) - (0, retrieval_js_1.l2ToCosine)(a.distance))
|
|
464
|
+
.slice(0, CORRECTION_NEIGHBOR_TOP_K);
|
|
465
|
+
}
|
|
466
|
+
async function classifyPair(llm, oldContent, newContent) {
|
|
467
|
+
try {
|
|
468
|
+
const r = await llm.completeClassify(buildCorrectionVerdictPrompt(oldContent, newContent));
|
|
469
|
+
return { verdict: parseCorrectionVerdict(r.text), usage: r.usage };
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
return { verdict: null, usage: undefined };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Nightly reconsolidation stage (#384, #392 — THE unified resolution stage).
|
|
477
|
+
*
|
|
478
|
+
* Phase 0 (#392): the deterministic merge zone (pairs >= the ceiling) runs
|
|
479
|
+
* first — LLM-free, budget-free, own lock/backup/cap.
|
|
480
|
+
*
|
|
481
|
+
* Scan: every memory with rowid > reconsolidationCursor (no shape filter;
|
|
482
|
+
* absorbed candidates are skipped — invisible memories are not re-judged).
|
|
483
|
+
* Each candidate's pairs: incoming explicit marks (verified once, AC7) then
|
|
484
|
+
* up-to-5 older KNN neighbors in [floor, ceiling) (verdict call per unlinked
|
|
485
|
+
* pair, AC2 — pairs at/above the ceiling are counted, never judged). Confirmed
|
|
486
|
+
* `corrects` pairs above the confidence gate on fact-shaped targets group by
|
|
487
|
+
* target into ONE rewrite call each (AC3); confirmed `merge` pairs queue for
|
|
488
|
+
* the merge phase; everything else is mark-only.
|
|
489
|
+
*
|
|
490
|
+
* Merge phase (#392): queued pairs merge through the dedup core under one
|
|
491
|
+
* lock/backup window, capped with the zone by dedupNightlyMaxMerges. A pair
|
|
492
|
+
* that cannot apply keeps both memories and holds the cursor.
|
|
493
|
+
*
|
|
494
|
+
* Cursor discipline mirrors stageSupersession: the cursor advances past a
|
|
495
|
+
* candidate once its neighbor set has been considered, regardless of infra
|
|
496
|
+
* skips — EXCEPT when rewrite groups or confirmed merges could not be applied
|
|
497
|
+
* (budget exhausted / rewrite-call infra error / merge cap or lock): the
|
|
498
|
+
* cursor then holds BELOW the earliest candidate contributing to the
|
|
499
|
+
* un-applied work, so those pairs are re-detected next run (dup-over-loss —
|
|
500
|
+
* an un-marked, un-rewritten, un-merged confirmed resolution must never be
|
|
501
|
+
* silently dropped by the cursor passing it).
|
|
502
|
+
*
|
|
503
|
+
* Dry-run: the zone's discovery + the free idempotency check only — zero LLM
|
|
504
|
+
* calls, zero writes, no cursor or band-stats persistence.
|
|
505
|
+
*/
|
|
506
|
+
async function stageReconsolidation(db, llm, budget, embedFn, dryRun, stateDir, options = {}) {
|
|
507
|
+
// Config values pass through `unknown`-typed JSON — validate, never trust.
|
|
508
|
+
const validNumber = (v, fallback, ok) => {
|
|
509
|
+
const n = Number(v);
|
|
510
|
+
return Number.isFinite(n) && ok(n) ? n : fallback;
|
|
511
|
+
};
|
|
512
|
+
const minSimilarity = validNumber(options.minSimilarity, exports.DEFAULT_CORRECTION_MIN_SIMILARITY, (n) => n > 0 && n <= 1);
|
|
513
|
+
const rewriteMinConfidence = validNumber(options.rewriteMinConfidence, exports.DEFAULT_CORRECTION_REWRITE_MIN_CONFIDENCE, (n) => n > 0 && n <= 1);
|
|
514
|
+
const autoMergeThreshold = validNumber(options.autoMergeThreshold, dedup_js_1.DEFAULT_DEDUP_MERGE_THRESHOLD, (n) => n > 0 && n <= 1);
|
|
515
|
+
const maxMerges = validNumber(options.maxMerges, dedup_js_1.DEFAULT_DEDUP_NIGHTLY_MAX_MERGES, (n) => n >= 0);
|
|
516
|
+
// ---- #392 phase 0: the deterministic merge zone (pairs >= the ceiling),
|
|
517
|
+
// LLM-free and budget-free — an LLM-less night still drains duplicates. Its
|
|
518
|
+
// own short lock window, pre-merge backup, and pacing cap; fail-soft, never
|
|
519
|
+
// a throw. Runs FIRST so the scan below never sees the pairs it owns.
|
|
520
|
+
const merges = await (0, dedup_js_1.runDeterministicMergeZone)(db, {
|
|
521
|
+
stateDir: stateDir ?? (0, paths_js_1.hicortexHome)(),
|
|
522
|
+
threshold: autoMergeThreshold,
|
|
523
|
+
maxMerges,
|
|
524
|
+
dryRun,
|
|
525
|
+
acquireLock: options.acquireLock,
|
|
526
|
+
});
|
|
527
|
+
// Per-run verdict statistics by cosine band (#392) — report snapshot here,
|
|
528
|
+
// cumulative series in state.json at stage end (never on dry-run).
|
|
529
|
+
const bands = buildResolutionBands(minSimilarity, autoMergeThreshold);
|
|
530
|
+
const runBands = new Map();
|
|
531
|
+
const recordBand = (cosine, action, confidence) => {
|
|
532
|
+
const band = bandForCosine(bands, cosine);
|
|
533
|
+
if (!band)
|
|
534
|
+
return; // below the floor — never a candidate (defensive)
|
|
535
|
+
const stat = runBands.get(band.label) ?? emptyBandStat();
|
|
536
|
+
stat.pairs++;
|
|
537
|
+
stat[action]++;
|
|
538
|
+
stat.conf_sum += confidence;
|
|
539
|
+
runBands.set(band.label, stat);
|
|
540
|
+
};
|
|
541
|
+
const startCursor = (0, state_js_1.loadState)(stateDir).reconsolidationCursor ?? 0;
|
|
542
|
+
// NO shape filter (AC2) — unlike stageSupersession. Absorbed rows are
|
|
543
|
+
// excluded: they are invisible to recall and must not re-enter judgment.
|
|
544
|
+
const rows = db
|
|
545
|
+
.prepare(`SELECT rowid AS __rowid, * FROM memories
|
|
546
|
+
WHERE rowid > ? AND COALESCE(status, '') != 'absorbed'
|
|
547
|
+
ORDER BY rowid ASC`)
|
|
548
|
+
.all(startCursor);
|
|
549
|
+
let scanned = 0;
|
|
550
|
+
let pairsEvaluated = 0;
|
|
551
|
+
let rewritten = 0;
|
|
552
|
+
let absorbed = 0;
|
|
553
|
+
let keptLinked = 0;
|
|
554
|
+
let markedSuperseded = 0;
|
|
555
|
+
let markedRetracted = 0;
|
|
556
|
+
let belowGate = 0;
|
|
557
|
+
let contractFailed = 0;
|
|
558
|
+
let skippedInfra = 0;
|
|
559
|
+
let skippedIdempotent = 0;
|
|
560
|
+
let explicitVerified = 0;
|
|
561
|
+
let explicitDivergent = 0;
|
|
562
|
+
let mergeBelowGate = 0;
|
|
563
|
+
let skippedAboveCeiling = 0;
|
|
564
|
+
let skippedMetadataMismatch = 0;
|
|
565
|
+
let mergePairsApplied = 0;
|
|
566
|
+
let cursor = startCursor;
|
|
567
|
+
const queuedMerges = [];
|
|
568
|
+
// #392 cursor-hold anchor, shared by the merge phase and the rewrite phase:
|
|
569
|
+
// un-applied work holds the cursor BELOW the earliest contributing
|
|
570
|
+
// candidate so the pairs are re-detected next run (dup-over-loss).
|
|
571
|
+
let pendingMinRowid = null;
|
|
572
|
+
// Links created by THIS stage in THIS run — lets the explicit-mark pass
|
|
573
|
+
// distinguish operator marks (pre-existing) from stage output.
|
|
574
|
+
const linksCreatedThisRun = new Set();
|
|
575
|
+
const markLink = (oldId, newId, relationship, strength) => {
|
|
576
|
+
storage.addLink(db, oldId, newId, relationship, strength);
|
|
577
|
+
linksCreatedThisRun.add(`${oldId}|${newId}`);
|
|
578
|
+
};
|
|
579
|
+
const groups = new Map();
|
|
580
|
+
const addTrigger = (target, trigger, confidence, cosine, explicit) => {
|
|
581
|
+
let group = groups.get(target.id);
|
|
582
|
+
if (!group) {
|
|
583
|
+
group = { targetId: target.id, target, triggers: [] };
|
|
584
|
+
groups.set(target.id, group);
|
|
585
|
+
}
|
|
586
|
+
if (!group.triggers.some((t) => t.id === trigger.id)) {
|
|
587
|
+
group.triggers.push({
|
|
588
|
+
id: trigger.id,
|
|
589
|
+
memory: trigger,
|
|
590
|
+
confidence,
|
|
591
|
+
cosine,
|
|
592
|
+
candidateRowid: trigger.__rowid,
|
|
593
|
+
explicit,
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
for (const candidate of rows) {
|
|
598
|
+
if (!dryRun && budget.exhausted)
|
|
599
|
+
break;
|
|
600
|
+
scanned++;
|
|
601
|
+
// ---- AC7: verify incoming explicit marks (corrected_by/superseded_by
|
|
602
|
+
// links targeting this candidate) before they can join a rewrite group.
|
|
603
|
+
if (!dryRun) {
|
|
604
|
+
const incoming = db
|
|
605
|
+
.prepare(`SELECT source_id, relationship FROM memory_links
|
|
606
|
+
WHERE target_id = ? AND source_id != ?
|
|
607
|
+
AND relationship IN ('corrected_by', 'superseded_by')`)
|
|
608
|
+
.all(candidate.id, candidate.id);
|
|
609
|
+
let markBudgetStop = false;
|
|
610
|
+
for (const mark of incoming) {
|
|
611
|
+
if (linksCreatedThisRun.has(`${mark.source_id}|${candidate.id}`))
|
|
612
|
+
continue; // stage output, not a mark
|
|
613
|
+
if (!budget.use(exports.RECONSOLIDATION_STAGE_LABEL)) {
|
|
614
|
+
markBudgetStop = true;
|
|
615
|
+
break;
|
|
616
|
+
}
|
|
617
|
+
const target = storage.getMemory(db, mark.source_id);
|
|
618
|
+
if (!target || target.status === "absorbed") {
|
|
619
|
+
explicitDivergent++; // mark's target is gone/invisible — retain link, nothing to upgrade
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const { verdict, usage } = await classifyPair(llm, target.content, candidate.content);
|
|
623
|
+
budget.recordUsage(exports.RECONSOLIDATION_STAGE_LABEL, usage);
|
|
624
|
+
pairsEvaluated++;
|
|
625
|
+
if (!verdict) {
|
|
626
|
+
skippedInfra++; // mark retained; the neighborhood is revisited via newer candidacies
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (verdict.action === "corrects" && verdict.confidence >= rewriteMinConfidence && isFactShapedTarget(target)) {
|
|
630
|
+
addTrigger(target, candidate, verdict.confidence, null, true);
|
|
631
|
+
explicitVerified++;
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
// Divergent: the nightly verdict did not confirm a rewrite. The mark
|
|
635
|
+
// is RETAINED untouched — explicit input is deliberate (owner
|
|
636
|
+
// decision 1), and marks are cheap to reverse via CLI.
|
|
637
|
+
explicitDivergent++;
|
|
638
|
+
console.log(`[hicortex] Reconsolidation: explicit mark on ${candidate.id.slice(0, 8)} diverged ` +
|
|
639
|
+
`(verdict ${verdict.action}, confidence ${verdict.confidence.toFixed(2)}) — mark retained`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (markBudgetStop)
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
// ---- AC2: detection pairs against older KNN neighbors.
|
|
646
|
+
let neighbors;
|
|
647
|
+
try {
|
|
648
|
+
neighbors = await findOlderCorrectionNeighbors(db, candidate, embedFn, minSimilarity);
|
|
649
|
+
}
|
|
650
|
+
catch (err) {
|
|
651
|
+
console.warn(`[hicortex] reconsolidation: discovery failed for ${candidate.id.slice(0, 8)} — ${err instanceof Error ? err.message : String(err)}`);
|
|
652
|
+
cursor = candidate.__rowid;
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
for (const neighbor of neighbors) {
|
|
656
|
+
if (alreadyResolutionLinked(db, neighbor.id, candidate.id)) {
|
|
657
|
+
skippedIdempotent++;
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
// #392: pairs at/above the ceiling belong to the deterministic zone —
|
|
661
|
+
// counted here, never LLM-judged (the zone merges them or holds them
|
|
662
|
+
// for its cap; re-detection is structural, not cursor-based).
|
|
663
|
+
const pairCosine = (0, retrieval_js_1.l2ToCosine)(neighbor.distance);
|
|
664
|
+
if (pairCosine >= autoMergeThreshold) {
|
|
665
|
+
skippedAboveCeiling++;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (dryRun)
|
|
669
|
+
continue; // preview only — no LLM call, no write
|
|
670
|
+
if (!budget.use(exports.RECONSOLIDATION_STAGE_LABEL))
|
|
671
|
+
break;
|
|
672
|
+
const { verdict, usage } = await classifyPair(llm, neighbor.content, candidate.content);
|
|
673
|
+
budget.recordUsage(exports.RECONSOLIDATION_STAGE_LABEL, usage);
|
|
674
|
+
pairsEvaluated++;
|
|
675
|
+
if (!verdict) {
|
|
676
|
+
skippedInfra++;
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
recordBand(pairCosine, verdict.action, verdict.confidence);
|
|
680
|
+
// #392: a merge verdict is queued for the merge phase (below) — no
|
|
681
|
+
// link, no write here. Below the confidence gate BOTH memories stay
|
|
682
|
+
// live: a weak mark is recoverable, and there is nothing to mark for a
|
|
683
|
+
// duplicate — keeping both is the recoverable outcome.
|
|
684
|
+
if (verdict.action === "merge") {
|
|
685
|
+
if (verdict.confidence < rewriteMinConfidence) {
|
|
686
|
+
mergeBelowGate++;
|
|
687
|
+
const band = bandForCosine(bands, pairCosine);
|
|
688
|
+
if (band) {
|
|
689
|
+
const stat = runBands.get(band.label) ?? emptyBandStat();
|
|
690
|
+
stat.merge_below_gate++;
|
|
691
|
+
runBands.set(band.label, stat);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
queuedMerges.push({ oldId: neighbor.id, newId: candidate.id, candidateRowid: candidate.__rowid });
|
|
696
|
+
}
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
if (verdict.action === "supersedes") {
|
|
700
|
+
markLink(neighbor.id, candidate.id, "superseded_by", (0, retrieval_js_1.l2ToCosine)(neighbor.distance));
|
|
701
|
+
storage.updateMemory(db, neighbor.id, { status: "superseded" });
|
|
702
|
+
markedSuperseded++;
|
|
703
|
+
console.log(`[hicortex] Reconsolidation: ${neighbor.id.slice(0, 8)} superseded_by ${candidate.id.slice(0, 8)} (mark-only)`);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
if (verdict.action === "corrects") {
|
|
707
|
+
const cosine = pairCosine;
|
|
708
|
+
if (verdict.confidence < rewriteMinConfidence) {
|
|
709
|
+
// Below the gate: mark-only, never rewrite. The
|
|
710
|
+
// trigger stays live — it is the only carrier of the correction.
|
|
711
|
+
belowGate++;
|
|
712
|
+
markLink(neighbor.id, candidate.id, "corrected_by", cosine);
|
|
713
|
+
storage.updateMemory(db, neighbor.id, { status: "retracted" });
|
|
714
|
+
markedRetracted++;
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
if (!isFactShapedTarget(neighbor)) {
|
|
718
|
+
// Decisions/plans/experiences are history, not error — mark only.
|
|
719
|
+
markLink(neighbor.id, candidate.id, "corrected_by", cosine);
|
|
720
|
+
storage.updateMemory(db, neighbor.id, { status: "retracted" });
|
|
721
|
+
markedRetracted++;
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
addTrigger(neighbor, candidate, verdict.confidence, cosine, false);
|
|
725
|
+
}
|
|
726
|
+
// verdict "none" → nothing to do
|
|
727
|
+
}
|
|
728
|
+
cursor = candidate.__rowid;
|
|
729
|
+
}
|
|
730
|
+
// ---- #392 judged-merge phase: apply the queued pair merges through the
|
|
731
|
+
// dedup core (mergeMemoryIds — same canonical pick, link re-points,
|
|
732
|
+
// dedup_log, absorb). One short lock/backup window for the whole batch, one
|
|
733
|
+
// transaction per pair. Zone merge operations count against the SAME
|
|
734
|
+
// dedupNightlyMaxMerges cap. A pair that cannot apply (cap exhausted, busy
|
|
735
|
+
// lock, failed backup) keeps BOTH memories live and holds the cursor below
|
|
736
|
+
// its candidate — a confirmed merge is never silently dropped by the cursor
|
|
737
|
+
// passing it (dup-over-loss). A metadata-rail refusal is different: the
|
|
738
|
+
// verdict WAS rendered, both memories stay live, the cursor advances.
|
|
739
|
+
const zoneOpsUsed = merges.merged_clusters + merges.failed;
|
|
740
|
+
let mergeOpsRemaining = maxMerges > 0 ? Math.max(0, maxMerges - zoneOpsUsed) : 0;
|
|
741
|
+
let mergePairsDeferred = 0;
|
|
742
|
+
if (!dryRun && queuedMerges.length > 0) {
|
|
743
|
+
const holdQueued = (from) => {
|
|
744
|
+
for (let i = from; i < queuedMerges.length; i++) {
|
|
745
|
+
pendingMinRowid =
|
|
746
|
+
pendingMinRowid === null
|
|
747
|
+
? queuedMerges[i].candidateRowid
|
|
748
|
+
: Math.min(pendingMinRowid, queuedMerges[i].candidateRowid);
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
if (maxMerges === 0) {
|
|
752
|
+
// Machinery disabled by config: keep both (counted in band_stats as
|
|
753
|
+
// merge verdicts) and ADVANCE — holding the cursor would re-judge the
|
|
754
|
+
// same pairs into the same disabled state forever.
|
|
755
|
+
console.log(`[hicortex] Reconsolidation: ${queuedMerges.length} confirmed merge(s) kept — ` +
|
|
756
|
+
`dedupNightlyMaxMerges is 0 (merge machinery disabled)`);
|
|
757
|
+
}
|
|
758
|
+
else if (mergeOpsRemaining <= 0) {
|
|
759
|
+
mergePairsDeferred = queuedMerges.length;
|
|
760
|
+
holdQueued(0); // zone consumed the whole cap — retry next run
|
|
761
|
+
console.log(`[hicortex] Reconsolidation: ${mergePairsDeferred} confirmed merge(s) deferred — ` +
|
|
762
|
+
`dedupNightlyMaxMerges exhausted by the deterministic zone`);
|
|
763
|
+
}
|
|
764
|
+
else {
|
|
765
|
+
const acquire = options.acquireLock ?? capture_js_1.acquireCaptureLock;
|
|
766
|
+
const release = await acquire(stateDir ?? (0, paths_js_1.hicortexHome)(), 0);
|
|
767
|
+
if (!release) {
|
|
768
|
+
mergePairsDeferred = queuedMerges.length;
|
|
769
|
+
holdQueued(0); // a busy capture run defers the batch — fail-soft
|
|
770
|
+
console.warn(`[hicortex] Reconsolidation: capture lock busy — ${mergePairsDeferred} confirmed merge(s) deferred to next run`);
|
|
771
|
+
}
|
|
772
|
+
else {
|
|
773
|
+
try {
|
|
774
|
+
let backupOk = true;
|
|
775
|
+
try {
|
|
776
|
+
await (0, dedup_js_1.takePreDedupBackup)(db, stateDir ?? (0, paths_js_1.hicortexHome)());
|
|
777
|
+
}
|
|
778
|
+
catch (err) {
|
|
779
|
+
backupOk = false;
|
|
780
|
+
console.error(`[hicortex] Reconsolidation: pre-merge backup failed ` +
|
|
781
|
+
`(${err instanceof Error ? err.message : String(err)}) — ${queuedMerges.length} merge(s) deferred`);
|
|
782
|
+
}
|
|
783
|
+
if (backupOk) {
|
|
784
|
+
for (let i = 0; i < queuedMerges.length; i++) {
|
|
785
|
+
const pair = queuedMerges[i];
|
|
786
|
+
if (mergeOpsRemaining <= 0) {
|
|
787
|
+
mergePairsDeferred = queuedMerges.length - i;
|
|
788
|
+
holdQueued(i); // cap exhausted mid-batch — the rest retry next run
|
|
789
|
+
console.log(`[hicortex] Reconsolidation: ${mergePairsDeferred} confirmed merge(s) deferred — dedupNightlyMaxMerges exhausted`);
|
|
790
|
+
break;
|
|
791
|
+
}
|
|
792
|
+
const result = (0, dedup_js_1.mergeMemoryIds)(db, [pair.oldId, pair.newId]);
|
|
793
|
+
if (result.ok) {
|
|
794
|
+
mergePairsApplied++;
|
|
795
|
+
mergeOpsRemaining--;
|
|
796
|
+
console.log(`[hicortex] Reconsolidation: merged ${pair.oldId.slice(0, 8)} + ${pair.newId.slice(0, 8)} ` +
|
|
797
|
+
`into canonical ${result.canonicalId.slice(0, 8)} (${result.linksRepointed} link(s) re-pointed)`);
|
|
798
|
+
}
|
|
799
|
+
else if (result.reason === "metadata_mismatch") {
|
|
800
|
+
skippedMetadataMismatch++;
|
|
801
|
+
console.log(`[hicortex] Reconsolidation: merge of ${pair.oldId.slice(0, 8)} + ${pair.newId.slice(0, 8)} ` +
|
|
802
|
+
`skipped (metadata mismatch) — both kept`);
|
|
803
|
+
}
|
|
804
|
+
// "no_members": a member vanished/was absorbed since the
|
|
805
|
+
// verdict — nothing to merge, nothing to hold; the cursor
|
|
806
|
+
// advances past it.
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
mergePairsDeferred = queuedMerges.length;
|
|
811
|
+
holdQueued(0);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
finally {
|
|
815
|
+
release();
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
// ---- Rewrite phase (AC3/AC4/AC5). Three sub-phases so the multi-target
|
|
821
|
+
// keep rule can be honored: (R1) collect contracts, (R2) resolve every
|
|
822
|
+
// trigger's FINAL disposition across all groups, (R3) apply one transaction
|
|
823
|
+
// per group. A group whose rewrite call was never made (budget/infra) is
|
|
824
|
+
// left untouched and holds the cursor — never partially applied.
|
|
825
|
+
const contracts = new Map(); // null = contract failed
|
|
826
|
+
// pendingMinRowid (min candidate rowid among un-applied work) is declared
|
|
827
|
+
// above — shared with the merge phase's holdQueued.
|
|
828
|
+
const deferFrom = (fromTargetId) => {
|
|
829
|
+
let seen = false;
|
|
830
|
+
for (const group of groups.values()) {
|
|
831
|
+
if (!seen && group.targetId !== fromTargetId)
|
|
832
|
+
continue;
|
|
833
|
+
seen = true;
|
|
834
|
+
for (const t of group.triggers) {
|
|
835
|
+
pendingMinRowid = pendingMinRowid === null ? t.candidateRowid : Math.min(pendingMinRowid, t.candidateRowid);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
if (!dryRun && groups.size > 0) {
|
|
840
|
+
for (const group of groups.values()) {
|
|
841
|
+
if (!budget.use(exports.RECONSOLIDATION_STAGE_LABEL)) {
|
|
842
|
+
deferFrom(group.targetId);
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
const triggersArg = group.triggers.map((t) => ({ id: t.id, content: t.memory.content }));
|
|
846
|
+
let contract = null;
|
|
847
|
+
let infraError = false;
|
|
848
|
+
try {
|
|
849
|
+
const r = await llm.completeClassify(buildRewritePrompt(group.target.content, triggersArg));
|
|
850
|
+
contract = parseRewriteReply(r.text, group.triggers.map((t) => t.id), group.target.content);
|
|
851
|
+
budget.recordUsage(exports.RECONSOLIDATION_STAGE_LABEL, r.usage);
|
|
852
|
+
}
|
|
853
|
+
catch {
|
|
854
|
+
infraError = true;
|
|
855
|
+
}
|
|
856
|
+
if (infraError) {
|
|
857
|
+
skippedInfra++;
|
|
858
|
+
deferFrom(group.targetId); // group NOT marked, NOT rewritten — retried next run
|
|
859
|
+
break;
|
|
860
|
+
}
|
|
861
|
+
contracts.set(group.targetId, contract);
|
|
862
|
+
if (!contract)
|
|
863
|
+
contractFailed++;
|
|
864
|
+
}
|
|
865
|
+
// R2: final per-trigger disposition — a trigger in multiple groups is
|
|
866
|
+
// absorbed only if EVERY disposition says absorb (any keep keeps it).
|
|
867
|
+
const finalOutcome = new Map();
|
|
868
|
+
for (const contract of contracts.values()) {
|
|
869
|
+
if (!contract)
|
|
870
|
+
continue;
|
|
871
|
+
for (const t of contract.triggers) {
|
|
872
|
+
if (t.disposition === "keep" || finalOutcome.get(t.id) === "keep")
|
|
873
|
+
finalOutcome.set(t.id, "keep");
|
|
874
|
+
else
|
|
875
|
+
finalOutcome.set(t.id, "absorb");
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
// R3: apply (one transaction per group). An apply that fails mid-flight
|
|
879
|
+
// (embed error, DB error) writes NOTHING (the transaction never ran) —
|
|
880
|
+
// the group is deferred like a pending one so it retries next run.
|
|
881
|
+
const appliedOutcome = new Map();
|
|
882
|
+
const deferGroup = (group) => {
|
|
883
|
+
for (const t of group.triggers) {
|
|
884
|
+
pendingMinRowid = pendingMinRowid === null ? t.candidateRowid : Math.min(pendingMinRowid, t.candidateRowid);
|
|
885
|
+
}
|
|
886
|
+
};
|
|
887
|
+
for (const group of groups.values()) {
|
|
888
|
+
const contract = contracts.get(group.targetId);
|
|
889
|
+
if (contract === undefined)
|
|
890
|
+
continue; // pending group — untouched this run
|
|
891
|
+
if (contract === null) {
|
|
892
|
+
// Failed rewrite contract → whole group mark-only, never a partial
|
|
893
|
+
// apply. Content untouched, NO trigger absorbed.
|
|
894
|
+
try {
|
|
895
|
+
applyMarkOnlyGroup(db, group);
|
|
896
|
+
}
|
|
897
|
+
catch (err) {
|
|
898
|
+
console.warn(`[hicortex] reconsolidation: mark-only fallback failed for ${group.targetId.slice(0, 8)} — ${err instanceof Error ? err.message : String(err)}`);
|
|
899
|
+
skippedInfra++;
|
|
900
|
+
deferGroup(group);
|
|
901
|
+
continue;
|
|
902
|
+
}
|
|
903
|
+
markedRetracted++;
|
|
904
|
+
console.log(`[hicortex] Reconsolidation: rewrite contract failed for ${group.targetId.slice(0, 8)} — group degraded to mark-only`);
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
let applied = false;
|
|
908
|
+
try {
|
|
909
|
+
applied = await applyRewriteGroup(db, group, contract, finalOutcome, embedFn);
|
|
910
|
+
}
|
|
911
|
+
catch (err) {
|
|
912
|
+
console.warn(`[hicortex] reconsolidation: rewrite apply failed for ${group.targetId.slice(0, 8)} — ${err instanceof Error ? err.message : String(err)}`);
|
|
913
|
+
}
|
|
914
|
+
if (!applied) {
|
|
915
|
+
skippedInfra++; // defensive absorbed-target guard, or an apply error — retry next run
|
|
916
|
+
deferGroup(group);
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
rewritten++;
|
|
920
|
+
// Counted from APPLIED groups only (a deferred group's dispositions
|
|
921
|
+
// never took effect); a trigger in several applied groups counts once.
|
|
922
|
+
for (const t of contract.triggers) {
|
|
923
|
+
const outcome = finalOutcome.get(t.id) ?? "keep";
|
|
924
|
+
if (outcome === "keep" || appliedOutcome.get(t.id) === "keep")
|
|
925
|
+
appliedOutcome.set(t.id, "keep");
|
|
926
|
+
else
|
|
927
|
+
appliedOutcome.set(t.id, "absorb");
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
for (const outcome of appliedOutcome.values()) {
|
|
931
|
+
if (outcome === "absorb")
|
|
932
|
+
absorbed++;
|
|
933
|
+
else
|
|
934
|
+
keptLinked++;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
// Cursor hold: un-applied work (rewrite groups, confirmed merges) holds the
|
|
938
|
+
// cursor BELOW its earliest contributing candidate so the pairs are
|
|
939
|
+
// re-detected next run.
|
|
940
|
+
if (pendingMinRowid !== null) {
|
|
941
|
+
cursor = Math.min(cursor, pendingMinRowid - 1);
|
|
942
|
+
}
|
|
943
|
+
// Report snapshot: the deterministic band (from the zone's own numbers —
|
|
944
|
+
// losers are merge verdicts at confidence 1.0; the zone persists the
|
|
945
|
+
// cumulative copy itself) plus this run's judged bands.
|
|
946
|
+
const bandStats = {};
|
|
947
|
+
if (merges.max_merges > 0) {
|
|
948
|
+
const det = emptyBandStat();
|
|
949
|
+
det.pairs = merges.losers_merged;
|
|
950
|
+
det.merge = merges.losers_merged;
|
|
951
|
+
det.conf_sum = merges.losers_merged;
|
|
952
|
+
if (merges.skipped_metadata_mismatch > 0) {
|
|
953
|
+
det.metadata_skipped = merges.skipped_metadata_mismatch;
|
|
954
|
+
}
|
|
955
|
+
bandStats[`>=${autoMergeThreshold}`] = det;
|
|
956
|
+
}
|
|
957
|
+
for (const [label, stat] of runBands)
|
|
958
|
+
bandStats[label] = stat;
|
|
959
|
+
if (!dryRun) {
|
|
960
|
+
(0, state_js_1.updateState)((s) => {
|
|
961
|
+
s.reconsolidationCursor = cursor;
|
|
962
|
+
// Cumulative judged-band accumulation (#392) — the zone already
|
|
963
|
+
// persisted the deterministic band under its own label.
|
|
964
|
+
if (runBands.size > 0) {
|
|
965
|
+
const cumulative = s.resolutionBandStats ?? {};
|
|
966
|
+
for (const [label, run] of runBands) {
|
|
967
|
+
const b = cumulative[label] ?? emptyBandStat();
|
|
968
|
+
accumulateBandStat(b, run);
|
|
969
|
+
cumulative[label] = b;
|
|
970
|
+
}
|
|
971
|
+
s.resolutionBandStats = cumulative;
|
|
972
|
+
}
|
|
973
|
+
}, stateDir);
|
|
974
|
+
}
|
|
975
|
+
if (rows.length > 0 || groups.size > 0 || mergePairsApplied > 0 || mergeBelowGate > 0) {
|
|
976
|
+
console.log(`[hicortex] Reconsolidation: ${scanned} scanned, ${pairsEvaluated} pairs evaluated, ` +
|
|
977
|
+
`${rewritten} rewritten (${absorbed} triggers absorbed, ${keptLinked} kept), ` +
|
|
978
|
+
`${mergePairsApplied} pair(s) merged, ${markedSuperseded} superseded, ` +
|
|
979
|
+
`${markedRetracted} retracted (${belowGate} below gate, ${mergeBelowGate} merge below gate, ` +
|
|
980
|
+
`${contractFailed} contract failed), ${skippedInfra} infra-skipped, ${skippedIdempotent} ` +
|
|
981
|
+
`already-linked, ${skippedAboveCeiling} above ceiling, ${explicitVerified} explicit verified, ` +
|
|
982
|
+
`${explicitDivergent} explicit divergent (cursor ${cursor})`);
|
|
983
|
+
}
|
|
984
|
+
return {
|
|
985
|
+
scanned,
|
|
986
|
+
pairs_evaluated: pairsEvaluated,
|
|
987
|
+
rewritten,
|
|
988
|
+
absorbed,
|
|
989
|
+
kept_linked: keptLinked,
|
|
990
|
+
marked_superseded: markedSuperseded,
|
|
991
|
+
marked_retracted: markedRetracted,
|
|
992
|
+
below_gate: belowGate,
|
|
993
|
+
contract_failed: contractFailed,
|
|
994
|
+
skipped_infra: skippedInfra,
|
|
995
|
+
skipped_idempotent: skippedIdempotent,
|
|
996
|
+
explicit_verified: explicitVerified,
|
|
997
|
+
explicit_divergent: explicitDivergent,
|
|
998
|
+
cursor,
|
|
999
|
+
merges,
|
|
1000
|
+
merge_pairs_applied: mergePairsApplied,
|
|
1001
|
+
merge_below_gate: mergeBelowGate,
|
|
1002
|
+
skipped_above_ceiling: skippedAboveCeiling,
|
|
1003
|
+
skipped_metadata_mismatch: skippedMetadataMismatch,
|
|
1004
|
+
band_stats: bandStats,
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
/** Mark-only fallback for a group: links + retracted status, content + triggers untouched. */
|
|
1008
|
+
function applyMarkOnlyGroup(db, group) {
|
|
1009
|
+
const tx = db.transaction(() => {
|
|
1010
|
+
for (const t of group.triggers) {
|
|
1011
|
+
if (!hasLink(db, group.targetId, t.id, "corrected_by")) {
|
|
1012
|
+
storage.addLink(db, group.targetId, t.id, "corrected_by", t.cosine ?? 1.0);
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
storage.updateMemory(db, group.targetId, { status: "retracted" });
|
|
1016
|
+
});
|
|
1017
|
+
tx();
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Apply one rewrite group in ONE transaction (AC3): memory_history row, target
|
|
1021
|
+
* content + status `corrected`, vector replaced, tags cleared + domain NULL
|
|
1022
|
+
* (re-classified next nightly), corrected_by link per trigger, per-trigger
|
|
1023
|
+
* disposition (absorb → status + vector + FTS dropped; keep → untouched).
|
|
1024
|
+
*/
|
|
1025
|
+
async function applyRewriteGroup(db, group, contract, finalOutcome, embedFn) {
|
|
1026
|
+
// Defensive (#384): never rewrite an absorbed row (it has no vector/FTS
|
|
1027
|
+
// row — a rewrite would resurrect dead evidence). Unreachable via the
|
|
1028
|
+
// normal paths (absorbed targets are neither neighbors — no vector — nor
|
|
1029
|
+
// explicit-mark targets — rejected at ingest); counted skipped_infra by
|
|
1030
|
+
// the caller when hit.
|
|
1031
|
+
const currentTarget = storage.getMemory(db, group.targetId);
|
|
1032
|
+
if (!currentTarget || currentTarget.status === "absorbed")
|
|
1033
|
+
return false;
|
|
1034
|
+
const target = group.target; // content snapshot the verdicts judged
|
|
1035
|
+
// evidence_id = the latest-created trigger of the group — the same trigger
|
|
1036
|
+
// the footer date derives from (documented semantics, #384).
|
|
1037
|
+
const latest = [...group.triggers].sort((a, b) => a.memory.created_at === b.memory.created_at
|
|
1038
|
+
? a.id.localeCompare(b.id)
|
|
1039
|
+
: a.memory.created_at.localeCompare(b.memory.created_at))[group.triggers.length - 1];
|
|
1040
|
+
const dateISO = latest.memory.created_at.slice(0, 10);
|
|
1041
|
+
const footer = buildCorrectionFooter(target.content, dateISO);
|
|
1042
|
+
const newContent = `${contract.rewritten}\n\n${footer}`;
|
|
1043
|
+
// Embeddings are async — computed BEFORE the sync transaction.
|
|
1044
|
+
const newEmbedding = await embedFn(newContent);
|
|
1045
|
+
const triggersJson = JSON.stringify(group.triggers.map((t) => ({
|
|
1046
|
+
id: t.id,
|
|
1047
|
+
disposition: finalOutcome.get(t.id) ?? "keep",
|
|
1048
|
+
confidence: t.confidence,
|
|
1049
|
+
})));
|
|
1050
|
+
// History confidence: the group's MINIMUM trigger confidence — the weakest
|
|
1051
|
+
// verdict that gated this rewrite (conservative audit).
|
|
1052
|
+
const minConfidence = Math.min(...group.triggers.map((t) => t.confidence));
|
|
1053
|
+
const tx = db.transaction(() => {
|
|
1054
|
+
db.prepare(`INSERT INTO memory_history
|
|
1055
|
+
(memory_id, old_content, new_content, prev_status, new_status, triggers_json,
|
|
1056
|
+
evidence_id, confidence, cause, created_at)
|
|
1057
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(target.id, target.content, newContent,
|
|
1058
|
+
// prev_status from the LIVE row, not the detection snapshot — a
|
|
1059
|
+
// below-gate mark earlier in this same run may have retracted it.
|
|
1060
|
+
currentTarget.status ?? null, "corrected", triggersJson, latest.id, minConfidence, "reconsolidation", nowIso());
|
|
1061
|
+
// Content + status + domain NULL in one UPDATE (fires the FTS update
|
|
1062
|
+
// trigger — the target still HAS an FTS row).
|
|
1063
|
+
storage.updateMemory(db, target.id, { content: newContent, status: "corrected", domain: null });
|
|
1064
|
+
db.prepare("DELETE FROM memory_tags WHERE memory_id = ?").run(target.id);
|
|
1065
|
+
replaceMemoryVector(db, target.id, newEmbedding);
|
|
1066
|
+
for (const t of group.triggers) {
|
|
1067
|
+
if (!hasLink(db, target.id, t.id, "corrected_by")) {
|
|
1068
|
+
storage.addLink(db, target.id, t.id, "corrected_by", t.cosine ?? 1.0);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
for (const t of group.triggers) {
|
|
1072
|
+
if (finalOutcome.get(t.id) === "absorb")
|
|
1073
|
+
(0, exports.absorbTrigger)(db, t.id);
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
tx();
|
|
1077
|
+
console.log(`[hicortex] Reconsolidation: rewrote ${target.id.slice(0, 8)} (corrected; ` +
|
|
1078
|
+
`${group.triggers.length} trigger(s), evidence ${latest.id.slice(0, 8)})`);
|
|
1079
|
+
return true;
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Roll back ONE rewrite history row: restore the recorded prior content and
|
|
1083
|
+
* prior status via the same mechanics as the rewrite (re-embed, clear tags +
|
|
1084
|
+
* domain NULL), reverse every absorb recorded in triggers_json (status
|
|
1085
|
+
* restored, vector re-embedded, FTS row re-inserted), and write the rollback's
|
|
1086
|
+
* own history row (cause `rollback`).
|
|
1087
|
+
*
|
|
1088
|
+
* Newest-first discipline: the row must be the NEWEST history entry for its
|
|
1089
|
+
* memory (rolling back an older entry under a newer one would clobber — undo
|
|
1090
|
+
* the newest first). The rollback row itself can be rolled back (undo the
|
|
1091
|
+
* undo), which is what makes the chain navigable.
|
|
1092
|
+
*/
|
|
1093
|
+
async function rollbackHistoryRow(db, historyRowId, embedFn) {
|
|
1094
|
+
const row = getHistoryRow(db, historyRowId);
|
|
1095
|
+
if (!row) {
|
|
1096
|
+
throw new Error(`history row not found: ${historyRowId}`);
|
|
1097
|
+
}
|
|
1098
|
+
const newer = db
|
|
1099
|
+
.prepare("SELECT COUNT(*) AS n FROM memory_history WHERE memory_id = ? AND id > ?")
|
|
1100
|
+
.get(row.memory_id, historyRowId).n;
|
|
1101
|
+
if (newer > 0) {
|
|
1102
|
+
throw new Error(`history row ${historyRowId} is not the newest entry for memory ${row.memory_id.slice(0, 8)} — ` +
|
|
1103
|
+
`roll back the newest entry first (history rows are undone newest-first)`);
|
|
1104
|
+
}
|
|
1105
|
+
const mem = storage.getMemory(db, row.memory_id);
|
|
1106
|
+
if (!mem) {
|
|
1107
|
+
throw new Error(`memory ${row.memory_id} no longer exists — nothing to roll back`);
|
|
1108
|
+
}
|
|
1109
|
+
let triggers = [];
|
|
1110
|
+
if (row.triggers_json) {
|
|
1111
|
+
try {
|
|
1112
|
+
triggers = JSON.parse(row.triggers_json);
|
|
1113
|
+
}
|
|
1114
|
+
catch {
|
|
1115
|
+
triggers = []; // unreadable trigger record — un-absorb impossible, content restore still proceeds
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
// Async embedding work BEFORE the sync transaction: the restored target
|
|
1119
|
+
// content + every currently-absorbed trigger's (untouched) content.
|
|
1120
|
+
const restoredEmbedding = await embedFn(row.old_content);
|
|
1121
|
+
const unabsorbEmbeddings = new Map();
|
|
1122
|
+
for (const t of triggers) {
|
|
1123
|
+
// Disposition values are "absorb"/"keep" (the rewrite contract's
|
|
1124
|
+
// vocabulary) — distinct from the STATUS "absorbed" written on the row.
|
|
1125
|
+
if (t.disposition !== "absorb")
|
|
1126
|
+
continue;
|
|
1127
|
+
const triggerMem = storage.getMemory(db, t.id);
|
|
1128
|
+
if (triggerMem && triggerMem.status === "absorbed") {
|
|
1129
|
+
unabsorbEmbeddings.set(t.id, await embedFn(triggerMem.content));
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
const tx = db.transaction(() => {
|
|
1133
|
+
const result = db
|
|
1134
|
+
.prepare(`INSERT INTO memory_history
|
|
1135
|
+
(memory_id, old_content, new_content, prev_status, new_status, triggers_json,
|
|
1136
|
+
evidence_id, confidence, cause, created_at)
|
|
1137
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1138
|
+
.run(row.memory_id, mem.content, row.old_content, mem.status ?? null, row.prev_status, row.triggers_json, row.evidence_id, row.confidence, "rollback", nowIso());
|
|
1139
|
+
storage.updateMemory(db, row.memory_id, {
|
|
1140
|
+
content: row.old_content,
|
|
1141
|
+
status: row.prev_status ?? null,
|
|
1142
|
+
domain: null,
|
|
1143
|
+
});
|
|
1144
|
+
db.prepare("DELETE FROM memory_tags WHERE memory_id = ?").run(row.memory_id);
|
|
1145
|
+
replaceMemoryVector(db, row.memory_id, restoredEmbedding);
|
|
1146
|
+
for (const [triggerId, embedding] of unabsorbEmbeddings) {
|
|
1147
|
+
unabsorbTrigger(db, triggerId, embedding);
|
|
1148
|
+
}
|
|
1149
|
+
return result.lastInsertRowid;
|
|
1150
|
+
});
|
|
1151
|
+
const newHistoryRowId = tx();
|
|
1152
|
+
console.log(`[hicortex] history: rolled back row ${historyRowId} — memory ${row.memory_id.slice(0, 8)} restored ` +
|
|
1153
|
+
`to its prior content/status, ${unabsorbEmbeddings.size} trigger(s) un-absorbed ` +
|
|
1154
|
+
`(new history row ${newHistoryRowId})`);
|
|
1155
|
+
return {
|
|
1156
|
+
historyRowId,
|
|
1157
|
+
memoryId: row.memory_id,
|
|
1158
|
+
restoredStatus: row.prev_status ?? null,
|
|
1159
|
+
unabsorbed: [...unabsorbEmbeddings.keys()],
|
|
1160
|
+
newHistoryRowId,
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
function head(text, max = 100) {
|
|
1164
|
+
const single = text.replace(/\s+/g, " ").trim();
|
|
1165
|
+
return single.length > max ? `${single.slice(0, max)}…` : single;
|
|
1166
|
+
}
|
|
1167
|
+
/** List one memory's rewrite events (dates, before/after heads, dispositions, confidence). */
|
|
1168
|
+
function printHistory(rows, memoryId) {
|
|
1169
|
+
if (rows.length === 0) {
|
|
1170
|
+
console.log(`[hicortex] No history recorded for memory ${memoryId.slice(0, 8)}.`);
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
console.log(`[hicortex] History for memory ${memoryId.slice(0, 8)} (${rows.length} event(s), oldest first):`);
|
|
1174
|
+
for (const r of rows) {
|
|
1175
|
+
console.log(` #${r.id} ${r.created_at} cause=${r.cause} ${r.prev_status ?? "active"} → ${r.new_status ?? "active"}`);
|
|
1176
|
+
console.log(` before: ${head(r.old_content)}`);
|
|
1177
|
+
console.log(` after: ${head(r.new_content)}`);
|
|
1178
|
+
if (r.confidence !== null && r.confidence !== undefined) {
|
|
1179
|
+
console.log(` confidence: ${r.confidence}`);
|
|
1180
|
+
}
|
|
1181
|
+
if (r.triggers_json) {
|
|
1182
|
+
try {
|
|
1183
|
+
const triggers = JSON.parse(r.triggers_json);
|
|
1184
|
+
for (const t of triggers) {
|
|
1185
|
+
console.log(` trigger ${t.id.slice(0, 8)}: ${t.disposition}`);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
catch {
|
|
1189
|
+
console.log(` triggers: (unreadable record)`);
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (r.evidence_id)
|
|
1193
|
+
console.log(` evidence: ${r.evidence_id.slice(0, 8)}`);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Runner for `hicortex history <id>` / `hicortex history --rollback <n>`.
|
|
1198
|
+
* Returns a process exit code (0 success, 1 failure); throws only on
|
|
1199
|
+
* unexpected infra errors (cli.ts prints those).
|
|
1200
|
+
*/
|
|
1201
|
+
async function runHistoryCommand(options) {
|
|
1202
|
+
if (options.rollbackId === undefined && !options.memoryId) {
|
|
1203
|
+
console.error("[hicortex] history: pass a memory id to list its history, or --rollback <history-row-id>.");
|
|
1204
|
+
return 1;
|
|
1205
|
+
}
|
|
1206
|
+
const db = (0, db_js_1.initDb)((0, db_js_1.resolveDbPath)(options.dbPath));
|
|
1207
|
+
try {
|
|
1208
|
+
if (options.rollbackId !== undefined) {
|
|
1209
|
+
// Lazy-load the ONNX embedder ONLY when a rollback actually needs to
|
|
1210
|
+
// re-embed (listing never loads it).
|
|
1211
|
+
const embedFn = options.embedFn ?? (await import("./embedder.js")).embed;
|
|
1212
|
+
await rollbackHistoryRow(db, options.rollbackId, embedFn);
|
|
1213
|
+
return 0;
|
|
1214
|
+
}
|
|
1215
|
+
const resolved = storage.resolveMemoryId(db, options.memoryId);
|
|
1216
|
+
if (!resolved) {
|
|
1217
|
+
console.error(`[hicortex] history: memory not found or ambiguous: ${options.memoryId}`);
|
|
1218
|
+
return 1;
|
|
1219
|
+
}
|
|
1220
|
+
printHistory(getMemoryHistory(db, resolved), resolved);
|
|
1221
|
+
return 0;
|
|
1222
|
+
}
|
|
1223
|
+
finally {
|
|
1224
|
+
db.close();
|
|
1225
|
+
}
|
|
1226
|
+
}
|