@gamaze/hicortex 0.20.3 → 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.
Files changed (44) hide show
  1. package/README.md +20 -1
  2. package/assets/dashboard.html +318 -8
  3. package/assets/identity.html +349 -8
  4. package/assets/viz.html +352 -8
  5. package/dist/backup.d.ts +12 -8
  6. package/dist/backup.js +13 -9
  7. package/dist/claude-desktop.d.ts +138 -0
  8. package/dist/claude-desktop.js +251 -0
  9. package/dist/cli.d.ts +7 -2
  10. package/dist/cli.js +84 -3
  11. package/dist/consolidate.d.ts +8 -1
  12. package/dist/consolidate.js +82 -2
  13. package/dist/dashboard.d.ts +17 -0
  14. package/dist/dashboard.js +32 -0
  15. package/dist/db.js +36 -0
  16. package/dist/dedup.d.ts +157 -25
  17. package/dist/dedup.js +376 -83
  18. package/dist/domain-classify.js +4 -2
  19. package/dist/index.js +7 -7
  20. package/dist/init.d.ts +4 -1
  21. package/dist/init.js +103 -1
  22. package/dist/llm.d.ts +19 -13
  23. package/dist/llm.js +25 -14
  24. package/dist/mcp-server.d.ts +6 -0
  25. package/dist/mcp-server.js +94 -13
  26. package/dist/mcp-stdio.d.ts +138 -0
  27. package/dist/mcp-stdio.js +313 -0
  28. package/dist/memory-instructions.d.ts +18 -0
  29. package/dist/memory-instructions.js +39 -2
  30. package/dist/nightly.js +14 -1
  31. package/dist/reconsolidation.d.ts +323 -0
  32. package/dist/reconsolidation.js +1226 -0
  33. package/dist/retrieval.d.ts +14 -0
  34. package/dist/retrieval.js +41 -3
  35. package/dist/state.d.ts +23 -1
  36. package/dist/storage.d.ts +25 -0
  37. package/dist/storage.js +49 -7
  38. package/dist/type-classify.js +4 -2
  39. package/dist/types.d.ts +188 -0
  40. package/hermes-plugin/hicortex/provider.py +29 -17
  41. package/opencode-plugin/hicortex/index.ts +7 -7
  42. package/package.json +4 -2
  43. package/pi-extension/hicortex/index.ts +7 -7
  44. package/server.json +44 -0
@@ -0,0 +1,251 @@
1
+ "use strict";
2
+ /**
3
+ * Claude Desktop auto-configuration (#381) — the init-side writer for the
4
+ * `claude_desktop_config.json` MCP entry.
5
+ *
6
+ * Claude Desktop's ONLY stdio MCP route is this hand-edited file (no CLI, no
7
+ * plugin discovery), which is why Desktop never had an init step while CC
8
+ * (`claude mcp add`), Pi, and opencode (dropped extension files) did. The
9
+ * 0.20.4 `hicortex mcp` stdio bridge makes such an entry meaningful, so init
10
+ * now offers to write it — the same auto-detect pattern as the other
11
+ * harnesses, gated on the Claude Desktop config DIRECTORY existing (the
12
+ * config FILE may not exist yet; creating it fresh is in scope).
13
+ *
14
+ * Two properties dominate the design:
15
+ *
16
+ * 1. IT IS ANOTHER APP'S FILE. `claude_desktop_config.json` is owned by
17
+ * Claude Desktop and carries the user's other servers and app settings.
18
+ * The write is therefore merge-safe (preserve EVERY top-level key and
19
+ * every other server; touch only `mcpServers.hicortex`), takes a
20
+ * timestamped `.bak` copy of the existing file first, lands via
21
+ * tmp-write + JSON-validate + rename in the same directory (atomic),
22
+ * and REFUSES — touching nothing, not even a backup — when the existing
23
+ * file is not valid JSON. Unlike our own config.json there is no repair
24
+ * flow here (quarantening another app's config would break IT); the
25
+ * user fixes the file by hand with the snippet init prints.
26
+ *
27
+ * 2. THE COMMAND MUST BE AN ABSOLUTE NPX PATH. Desktop is a GUI app and
28
+ * does not inherit the shell PATH — a bare "npx" command is the #1
29
+ * Desktop MCP failure mode. Resolution walks PATH plus the common
30
+ * install locations and rejects npm's ephemeral `/_npx/` cache (#176: a
31
+ * path that dies on the next cache GC — the entry would break silently
32
+ * weeks later). Unresolvable → manual instructions, nothing written.
33
+ * Windows .cmd shims cannot be spawned by Electron without a shell, so
34
+ * they are wrapped in `cmd /c`.
35
+ *
36
+ * Every helper is pure/parametrised (platform/env/home/exists injected) so
37
+ * the suite covers darwin/win32/linux without touching a real machine. This
38
+ * module deliberately imports NOTHING from init.ts (init imports this — no
39
+ * cycle); the package spec is passed in from init's getPackageSpec().
40
+ */
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ exports.desktopConfigDir = desktopConfigDir;
43
+ exports.resolveDesktopNpxPath = resolveDesktopNpxPath;
44
+ exports.buildDesktopServerEntry = buildDesktopServerEntry;
45
+ exports.isLocalServerUrl = isLocalServerUrl;
46
+ exports.mergeDesktopServerConfig = mergeDesktopServerConfig;
47
+ exports.writeDesktopServerConfig = writeDesktopServerConfig;
48
+ const node_fs_1 = require("node:fs");
49
+ const node_path_1 = require("node:path");
50
+ const node_os_1 = require("node:os");
51
+ const node_crypto_1 = require("node:crypto");
52
+ // ---------------------------------------------------------------------------
53
+ // Config-dir detection
54
+ // ---------------------------------------------------------------------------
55
+ /**
56
+ * The Claude Desktop config directory for this platform, or null where no
57
+ * Desktop build exists (Linux → silent skip). Parametrised so tests cover
58
+ * the platform matrix without a real machine. Detection in init is
59
+ * `desktopConfigDir() !== null && existsSync(dir)` — the config FILE itself
60
+ * may legitimately not exist yet.
61
+ */
62
+ function desktopConfigDir(platform = (0, node_os_1.platform)(), env = process.env, home = (0, node_os_1.homedir)()) {
63
+ if (platform === "darwin") {
64
+ return (0, node_path_1.join)(home, "Library", "Application Support", "Claude");
65
+ }
66
+ if (platform === "win32") {
67
+ // %APPDATA% is the documented location; the AppData\Roaming fallback
68
+ // covers shells/services where the env var is not exported.
69
+ return (0, node_path_1.join)(env.APPDATA ?? (0, node_path_1.join)(home, "AppData", "Roaming"), "Claude");
70
+ }
71
+ return null;
72
+ }
73
+ /**
74
+ * Candidate npx paths in resolution order: every PATH dir first, then the
75
+ * common install locations. On win32 each dir contributes `npx.cmd` before
76
+ * `npx.exe` (the cmd shim is what npm actually installs there).
77
+ */
78
+ function candidateNpxPaths(platform, pathEnv, home, env) {
79
+ const names = platform === "win32" ? ["npx.cmd", "npx.exe"] : ["npx"];
80
+ const delimiter = platform === "win32" ? ";" : ":";
81
+ const pathDirs = pathEnv.split(delimiter).filter(Boolean);
82
+ const commonDirs = platform === "win32"
83
+ ? [
84
+ // The npm global bin dir (%APPDATA%\npm) and the Node installer's
85
+ // dir — where a GUI-launched Desktop is most likely to find npx
86
+ // even when PATH (a shell concept) carries nothing useful.
87
+ (0, node_path_1.join)(env.APPDATA ?? (0, node_path_1.join)(home, "AppData", "Roaming"), "npm"),
88
+ "C:\\Program Files\\nodejs",
89
+ ]
90
+ : [
91
+ "/opt/homebrew/bin",
92
+ "/usr/local/bin",
93
+ (0, node_path_1.join)(home, ".npm-global", "bin"),
94
+ (0, node_path_1.join)(home, ".volta", "bin"),
95
+ ];
96
+ return [...pathDirs, ...commonDirs].flatMap((dir) => names.map((name) => (0, node_path_1.join)(dir, name)));
97
+ }
98
+ /**
99
+ * Resolve an ABSOLUTE npx path for the Desktop entry, or null when nothing
100
+ * durable exists (init then prints manual instructions and writes nothing).
101
+ * Rejects npm's ephemeral npx cache in BOTH separator spellings — unix
102
+ * `/_npx/` and Windows `\_npx\` — a bare includes("/_npx/") would let the
103
+ * win32 form through (#176: the path dies on the next cache GC).
104
+ */
105
+ function resolveDesktopNpxPath(options = {}) {
106
+ const platform = options.platform ?? (0, node_os_1.platform)();
107
+ const pathEnv = options.pathEnv ?? process.env.PATH ?? "";
108
+ const home = options.home ?? (0, node_os_1.homedir)();
109
+ const env = options.env ?? process.env;
110
+ const exists = options.exists ?? node_fs_1.existsSync;
111
+ for (const candidate of candidateNpxPaths(platform, pathEnv, home, env)) {
112
+ if (candidate.includes("/_npx/") || candidate.includes("\\_npx\\"))
113
+ continue;
114
+ if (exists(candidate))
115
+ return candidate;
116
+ }
117
+ return null;
118
+ }
119
+ /**
120
+ * Build the stdio entry for the `hicortex mcp` bridge. NO "type" field —
121
+ * stdio is implied for Desktop entries (the CC `.claude.json` writer needs
122
+ * `"type":"sse"`; this is the other shape). A `.cmd`/`.bat` shim is wrapped
123
+ * in `cmd /c` because Electron spawns without a shell and cannot exec a cmd
124
+ * script directly. An empty/absent env omits the key entirely — a local
125
+ * entry must carry no env (the bridge autostarts the daemon; loopback
126
+ * bypasses auth).
127
+ */
128
+ function buildDesktopServerEntry(npxPath, packageSpec, env) {
129
+ const entry = /\.(cmd|bat)$/i.test(npxPath)
130
+ ? { command: "cmd", args: ["/c", npxPath, "-y", packageSpec, "mcp"] }
131
+ : { command: npxPath, args: ["-y", packageSpec, "mcp"] };
132
+ if (env && Object.keys(env).length > 0)
133
+ entry.env = { ...env };
134
+ return entry;
135
+ }
136
+ /**
137
+ * Loopback check for a server URL — decides whether the Desktop entry needs
138
+ * an env block at all (local: none, the bridge resolves + autostarts the
139
+ * daemon; remote: URL + token). Mirrors mcp-stdio's isLoopbackHost (kept
140
+ * local rather than imported to avoid dragging the SDK into init's graph).
141
+ * An unparseable URL is NOT local — fail toward carrying the env, which
142
+ * still works everywhere the URL is real.
143
+ */
144
+ function isLocalServerUrl(url) {
145
+ try {
146
+ const hostname = new URL(url).hostname.toLowerCase();
147
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ /**
154
+ * Parse the existing config text and merge our entry in, preserving every
155
+ * top-level key and every other server. `rawText === null` means "no file"
156
+ * (fresh install — a config containing only our entry is created). Malformed
157
+ * JSON, a non-object document, or a non-object `mcpServers` value returns
158
+ * `{ ok: false }` — the caller refuses to write, so the ORIGINAL file and
159
+ * its bytes are what the user keeps.
160
+ */
161
+ function mergeDesktopServerConfig(rawText, entry) {
162
+ if (rawText === null) {
163
+ return { ok: true, config: { mcpServers: { hicortex: entry } } };
164
+ }
165
+ let parsed;
166
+ try {
167
+ parsed = JSON.parse(rawText);
168
+ }
169
+ catch (e) {
170
+ return {
171
+ ok: false,
172
+ reason: `the file is not valid JSON (${e instanceof Error ? e.message : String(e)}) — ` +
173
+ `fix it by hand and re-run init`,
174
+ };
175
+ }
176
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
177
+ const kind = parsed === null ? "null" : Array.isArray(parsed) ? "an array" : typeof parsed;
178
+ return { ok: false, reason: `the file parses to ${kind}, not a JSON object — fix it by hand and re-run init` };
179
+ }
180
+ const config = { ...parsed };
181
+ const existing = config.mcpServers;
182
+ if (existing !== undefined && (typeof existing !== "object" || existing === null || Array.isArray(existing))) {
183
+ const kind = Array.isArray(existing) ? "an array" : typeof existing;
184
+ return { ok: false, reason: `"mcpServers" is ${kind}, not an object — fix it by hand and re-run init` };
185
+ }
186
+ config.mcpServers = { ...existing, hicortex: entry };
187
+ return { ok: true, config };
188
+ }
189
+ /**
190
+ * Orchestrate the merge-safe, atomic write of our entry into
191
+ * `claude_desktop_config.json`:
192
+ *
193
+ * 1. Read the existing file (ENOENT → fresh-install path).
194
+ * 2. Merge; a malformed/shape-refused file → `{ status: "refused" }` with
195
+ * NOTHING touched — no backup, no tmp, no bytes changed.
196
+ * 3. Copy the existing file to `<path>.bak-<ISO-colons-stripped>` BEFORE
197
+ * writing (the quarantineMalformedConfig naming convention).
198
+ * 4. Write the serialized payload to a tmp file in the SAME directory
199
+ * (same filesystem → the rename is atomic), JSON-parse the exact bytes
200
+ * that landed on disk, then renameSync over the target.
201
+ *
202
+ * Any unexpected I/O error returns `{ status: "failed" }` after removing the
203
+ * tmp file — a half-written tmp must never masquerade as a config. Never
204
+ * throws; the init wiring turns every non-"written" result into a printed
205
+ * warning and init continues.
206
+ */
207
+ function writeDesktopServerConfig(configPath, entry) {
208
+ // 1. Read — only a genuinely-absent file (ENOENT) is a fresh install.
209
+ let raw = null;
210
+ try {
211
+ raw = (0, node_fs_1.readFileSync)(configPath, "utf-8");
212
+ }
213
+ catch (e) {
214
+ if (e.code !== "ENOENT") {
215
+ return { status: "failed", reason: `could not read ${configPath}: ${e instanceof Error ? e.message : String(e)}` };
216
+ }
217
+ }
218
+ // 2. Merge — refusal happens BEFORE any disk mutation (step 3+).
219
+ const merged = mergeDesktopServerConfig(raw, entry);
220
+ if (!merged.ok) {
221
+ return { status: "refused", reason: `Refusing to write ${configPath}: ${merged.reason}` };
222
+ }
223
+ const payload = JSON.stringify(merged.config, null, 2);
224
+ let tmpPath;
225
+ try {
226
+ const dir = (0, node_path_1.dirname)(configPath);
227
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
228
+ // 3. Backup the existing file BEFORE any write of ours.
229
+ let backupPath;
230
+ if (raw !== null) {
231
+ backupPath = `${configPath}.bak-${new Date().toISOString().replace(/:/g, "-")}`;
232
+ (0, node_fs_1.copyFileSync)(configPath, backupPath);
233
+ }
234
+ // 4. tmp write → validate the exact on-disk bytes → atomic rename.
235
+ tmpPath = (0, node_path_1.join)(dir, `${(0, node_path_1.basename)(configPath)}.tmp-${(0, node_crypto_1.randomUUID)().slice(0, 8)}`);
236
+ (0, node_fs_1.writeFileSync)(tmpPath, payload);
237
+ JSON.parse((0, node_fs_1.readFileSync)(tmpPath, "utf-8"));
238
+ (0, node_fs_1.renameSync)(tmpPath, configPath);
239
+ tmpPath = undefined; // committed — nothing left to clean up
240
+ return backupPath === undefined ? { status: "written" } : { status: "written", backupPath };
241
+ }
242
+ catch (e) {
243
+ if (tmpPath !== undefined) {
244
+ try {
245
+ (0, node_fs_1.rmSync)(tmpPath, { force: true });
246
+ }
247
+ catch { /* best-effort cleanup — the failure below is the real news */ }
248
+ }
249
+ return { status: "failed", reason: e instanceof Error ? e.message : String(e) };
250
+ }
251
+ }
package/dist/cli.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  *
5
5
  * Commands:
6
6
  * server Start the MCP HTTP/SSE server (persistent daemon)
7
+ * mcp Speak MCP over stdio (bridge to the local daemon or HICORTEX_SERVER_URL)
7
8
  * init Detect existing setup and configure for CC/OC
8
9
  * nightly Run capture + consolidate (manual trigger)
9
10
  * nightly --capture-only Capture only, skip consolidation
@@ -11,8 +12,12 @@
11
12
  * nightly --evict-only Memory-cap eviction only — pure DB, no LLM (#317)
12
13
  * nightly --status Show nightly pipeline health check
13
14
  * relink Resumable link-discovery pass over the entire corpus (issue #143)
14
- * dedup Cluster + merge near-duplicate memories (issue #100)
15
- * dedup --apply Execute the merge (default: dry run)
15
+ * dedup Cluster + merge near-duplicate memories (issues #100, #392)
16
+ * dedup --apply Execute the merge (default: dry run);
17
+ * losers are absorbed (kept as evidence,
18
+ * hidden from recall), not deleted
19
+ * history Show a memory's rewrite history, or roll one back (issue #384)
20
+ * history --rollback <row> Undo one rewrite + un-absorb triggers
16
21
  * status Show config, DB stats, adapter status
17
22
  * uninstall Clean removal of CC integration
18
23
  */
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * Commands:
7
7
  * server Start the MCP HTTP/SSE server (persistent daemon)
8
+ * mcp Speak MCP over stdio (bridge to the local daemon or HICORTEX_SERVER_URL)
8
9
  * init Detect existing setup and configure for CC/OC
9
10
  * nightly Run capture + consolidate (manual trigger)
10
11
  * nightly --capture-only Capture only, skip consolidation
@@ -12,8 +13,12 @@
12
13
  * nightly --evict-only Memory-cap eviction only — pure DB, no LLM (#317)
13
14
  * nightly --status Show nightly pipeline health check
14
15
  * relink Resumable link-discovery pass over the entire corpus (issue #143)
15
- * dedup Cluster + merge near-duplicate memories (issue #100)
16
- * dedup --apply Execute the merge (default: dry run)
16
+ * dedup Cluster + merge near-duplicate memories (issues #100, #392)
17
+ * dedup --apply Execute the merge (default: dry run);
18
+ * losers are absorbed (kept as evidence,
19
+ * hidden from recall), not deleted
20
+ * history Show a memory's rewrite history, or roll one back (issue #384)
21
+ * history --rollback <row> Undo one rewrite + un-absorb triggers
17
22
  * status Show config, DB stats, adapter status
18
23
  * uninstall Clean removal of CC integration
19
24
  */
@@ -34,6 +39,26 @@ switch (command) {
34
39
  });
35
40
  break;
36
41
  }
42
+ case "mcp": {
43
+ // Stdio MCP bridge (#375): speak MCP on stdin/stdout, backed by the
44
+ // daemon's SSE endpoint (or a remote server via HICORTEX_SERVER_URL).
45
+ // Registry clients (Claude Desktop, Cursor, the MCP Registry's install
46
+ // flow) launch stdio commands — this is the command the registry's
47
+ // server.json declares (`npx -y @gamaze/hicortex mcp`). Starts a local
48
+ // daemon when the target is loopback and none is running; never spawns
49
+ // for remote targets. stdout carries ONLY the MCP protocol — all
50
+ // diagnostics go to stderr (the bridge owns that discipline internally).
51
+ import("./mcp-stdio.js").then(({ runMcpStdio }) => {
52
+ runMcpStdio().catch((err) => {
53
+ console.error(err instanceof Error ? err.message : `[hicortex] mcp bridge failed: ${err}`);
54
+ process.exit(1);
55
+ });
56
+ }).catch((err) => {
57
+ console.error("[hicortex] Failed to load the mcp bridge:", err);
58
+ process.exit(1);
59
+ });
60
+ break;
61
+ }
37
62
  case "init": {
38
63
  const serverArg = process.argv.indexOf("--server");
39
64
  const serverUrl = serverArg !== -1 ? process.argv[serverArg + 1] : undefined;
@@ -251,6 +276,52 @@ switch (command) {
251
276
  });
252
277
  break;
253
278
  }
279
+ case "history": {
280
+ // Rewrite history: audit + one-command rollback (#384). Listing is
281
+ // read-only; --rollback mutates (restores prior content/status and
282
+ // un-absorbs triggers). Mirrors `dedup`: flags parsed here, the runner
283
+ // (config load + DB open + print/rollback + close) lives in
284
+ // reconsolidation.ts so this switch stays thin.
285
+ const args = process.argv.slice(3);
286
+ let rollbackId;
287
+ try {
288
+ const raw = (0, cli_args_js_1.readValueFlag)(args, "--rollback");
289
+ if (raw !== undefined) {
290
+ rollbackId = parseInt(raw, 10);
291
+ if (isNaN(rollbackId)) {
292
+ console.error("[hicortex] history: --rollback requires a numeric history row id");
293
+ process.exit(1);
294
+ }
295
+ }
296
+ }
297
+ catch {
298
+ console.error("[hicortex] history: --rollback requires a history row id (see `hicortex history <memory-id>`)");
299
+ process.exit(1);
300
+ }
301
+ let dbPath;
302
+ try {
303
+ dbPath = (0, cli_args_js_1.readValueFlag)(args, "--db");
304
+ }
305
+ catch {
306
+ console.error("[hicortex] history: --db requires a path value");
307
+ process.exit(1);
308
+ }
309
+ const positional = args.filter((a) => !a.startsWith("-") && a !== dbPath);
310
+ const memoryId = positional[0];
311
+ const historyOptions = { dbPath, memoryId, rollbackId };
312
+ import("./reconsolidation.js").then(({ runHistoryCommand }) => {
313
+ runHistoryCommand(historyOptions)
314
+ .then((code) => process.exit(code))
315
+ .catch((err) => {
316
+ console.error(err instanceof Error ? err.message : `[hicortex] history failed: ${err}`);
317
+ process.exit(1);
318
+ });
319
+ }).catch((err) => {
320
+ console.error("[hicortex] history failed:", err);
321
+ process.exit(1);
322
+ });
323
+ break;
324
+ }
254
325
  case "identity": {
255
326
  // Standing identity layer edit surface (spec §6; renamed from context in
256
327
  // 0.18 #264): show|edit against the configured server. Secondary to the
@@ -331,6 +402,7 @@ Usage: hicortex <command> [options]
331
402
 
332
403
  Commands:
333
404
  server Start the MCP HTTP/SSE server (server mode)
405
+ mcp Speak MCP over stdio (bridge to the local daemon or HICORTEX_SERVER_URL)
334
406
  init Set up Hicortex (server mode, local DB + daemon)
335
407
  Scaffolds 5 editable default memory domains (Work, Personal,
336
408
  People, Health, Finance) in ~/.hicortex/config.json
@@ -343,6 +415,9 @@ Commands:
343
415
  nightly Run nightly denoise + capture + consolidate
344
416
  relink Resumable link-discovery pass over the ENTIRE corpus (server mode)
345
417
  dedup Cluster + merge near-duplicate memories (server mode; dry run by default)
418
+ history Show a memory's rewrite history, or roll one back (server mode)
419
+ history <id> List rewrite events (read-only)
420
+ history --rollback <row> Undo one rewrite + un-absorb its triggers
346
421
  backup Snapshot the DB + identity + state to a tar.gz (online, WAL-safe)
347
422
  classify-domains Backfill content-based domain tags over the corpus (server mode, needs config.domains)
348
423
  classify-types Backfill episode→fact/decision type tags over the corpus (server mode)
@@ -368,8 +443,14 @@ Options:
368
443
  relink --batch <n> Memories per batch (default: 200)
369
444
  relink --reset Restart from the beginning (ignore saved cursor)
370
445
  dedup --apply Execute the merge (default: dry run, report only)
371
- dedup --threshold <t> Override config dedupMergeThreshold for one run
446
+ Losers are absorbed — hidden from recall, kept as
447
+ evidence (fetchable by id; dedup_log audit) — not deleted
448
+ dedup --threshold <t> Override the threshold for one run (default: config
449
+ dedupAutoMergeThreshold, legacy dedupMergeThreshold
450
+ still honored; else 0.92)
372
451
  dedup --db <path> DB path override (defaults to the configured DB)
452
+ history --rollback <row> Roll back history row <row>: restores prior content/status, un-absorbs triggers
453
+ history --db <path> DB path override (defaults to the configured DB)
373
454
  backup --out <dir> Write the artifact into <dir> as hicortex-<ISO>.tar.gz (default: <home>/backups)
374
455
  backup --stdout Stream the tar.gz to stdout (offsite pipe: hicortex backup --stdout | rclone rcat …)
375
456
  classify-domains --all Reclassify every memory (default: only NULL/stale-domain rows)
@@ -8,6 +8,7 @@ import type { Memory, ConsolidationReport } from "./types.js";
8
8
  import type { LlmClient } from "./llm.js";
9
9
  import type { EmbedFn } from "./retrieval.js";
10
10
  import { type DomainDef } from "./domain-classify.js";
11
+ import { type ReconsolidationOptions } from "./reconsolidation.js";
11
12
  /**
12
13
  * Default ceiling on total LLM calls across all classify-tier consolidation
13
14
  * stages (content-domain, link discovery, supersession) per run. This is a
@@ -343,7 +344,13 @@ budgetMaxCalls?: number,
343
344
  /** Soft cap on the corpus (#245). Nightly.ts reads `memorySoftCap` from
344
345
  * config and passes it; unset → `DEFAULT_MEMORY_SOFT_CAP` (10000). `0`
345
346
  * disables eviction (indefinite growth). */
346
- memorySoftCap?: number): Promise<ConsolidationReport>;
347
+ memorySoftCap?: number,
348
+ /** Reconsolidation-stage knobs (#384), threaded from config by nightly.ts
349
+ * (correctionMinSimilarity / correctionRewriteMinConfidence) exactly like
350
+ * supersessionOptions above; unset fields → the stage's defaults.
351
+ * Appended AFTER the pre-#384 params so every existing positional caller
352
+ * (tests, hosted nightly) keeps its argument meaning. */
353
+ reconsolidationOptions?: ReconsolidationOptions): Promise<ConsolidationReport>;
347
354
  /**
348
355
  * Calculate milliseconds until the next occurrence of a given hour (local time).
349
356
  */
@@ -66,6 +66,8 @@ const state_js_1 = require("./state.js");
66
66
  const domain_classify_js_1 = require("./domain-classify.js");
67
67
  const schema_prototypes_js_1 = require("./schema-prototypes.js");
68
68
  const nofit_js_1 = require("./nofit.js");
69
+ const reconsolidation_js_1 = require("./reconsolidation.js");
70
+ const dedup_js_1 = require("./dedup.js");
69
71
  // Default config constants (matching Python config.py)
70
72
  /**
71
73
  * Default ceiling on total LLM calls across all classify-tier consolidation
@@ -1065,7 +1067,7 @@ function parseSupersessionReply(reply) {
1065
1067
  */
1066
1068
  async function classifySupersession(llm, oldContent, newContent) {
1067
1069
  try {
1068
- const r = await llm.completeClassify(buildSupersessionPrompt(oldContent, newContent), 32);
1070
+ const r = await llm.completeClassify(buildSupersessionPrompt(oldContent, newContent));
1069
1071
  return { verdict: parseSupersessionReply(r.text), usage: r.usage };
1070
1072
  }
1071
1073
  catch {
@@ -1360,6 +1362,66 @@ function stageMemoryCapEviction(db, dryRun, cap) {
1360
1362
  `memories (corpus was ${count}, cap ${cap}).`);
1361
1363
  return { cap, evicted: victims.length };
1362
1364
  }
1365
+ /**
1366
+ * Minimal resolution-stage report for a SKIPPED (quiet-night) consolidation
1367
+ * run (#392): every stage field zero except `merges` — the deterministic
1368
+ * zone's own report — and its derived deterministic band snapshot. Keeps the
1369
+ * one-report surface intact (the zone is the only resolution work a quiet
1370
+ * night does) while telemetry's "skipped = zero LLM work" stays true. Knob
1371
+ * validation mirrors the stage's own (invalid → defaults).
1372
+ */
1373
+ async function skippedRunResolutionReport(db, dryRun, stateDir, options = {}) {
1374
+ const validNumber = (v, fallback, ok) => {
1375
+ const n = Number(v);
1376
+ return Number.isFinite(n) && ok(n) ? n : fallback;
1377
+ };
1378
+ const autoMergeThreshold = validNumber(options.autoMergeThreshold, dedup_js_1.DEFAULT_DEDUP_MERGE_THRESHOLD, (n) => n > 0 && n <= 1);
1379
+ const maxMerges = validNumber(options.maxMerges, dedup_js_1.DEFAULT_DEDUP_NIGHTLY_MAX_MERGES, (n) => n >= 0);
1380
+ const merges = await (0, dedup_js_1.runDeterministicMergeZone)(db, {
1381
+ stateDir,
1382
+ threshold: autoMergeThreshold,
1383
+ maxMerges,
1384
+ dryRun,
1385
+ acquireLock: options.acquireLock,
1386
+ });
1387
+ const bandStats = {};
1388
+ if (merges.max_merges > 0) {
1389
+ bandStats[`>=${autoMergeThreshold}`] = {
1390
+ pairs: merges.losers_merged,
1391
+ merge: merges.losers_merged,
1392
+ corrects: 0,
1393
+ supersedes: 0,
1394
+ none: 0,
1395
+ merge_below_gate: 0,
1396
+ conf_sum: merges.losers_merged,
1397
+ ...(merges.skipped_metadata_mismatch > 0
1398
+ ? { metadata_skipped: merges.skipped_metadata_mismatch }
1399
+ : {}),
1400
+ };
1401
+ }
1402
+ return {
1403
+ scanned: 0,
1404
+ pairs_evaluated: 0,
1405
+ rewritten: 0,
1406
+ absorbed: 0,
1407
+ kept_linked: 0,
1408
+ marked_superseded: 0,
1409
+ marked_retracted: 0,
1410
+ below_gate: 0,
1411
+ contract_failed: 0,
1412
+ skipped_infra: 0,
1413
+ skipped_idempotent: 0,
1414
+ explicit_verified: 0,
1415
+ explicit_divergent: 0,
1416
+ cursor: (0, state_js_1.loadState)(stateDir).reconsolidationCursor ?? 0,
1417
+ merges,
1418
+ merge_pairs_applied: 0,
1419
+ merge_below_gate: 0,
1420
+ skipped_above_ceiling: 0,
1421
+ skipped_metadata_mismatch: 0,
1422
+ band_stats: bandStats,
1423
+ };
1424
+ }
1363
1425
  async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions, supersessionOptions,
1364
1426
  /** Total LLM-call ceiling across classify-tier stages (#241). The caller
1365
1427
  * reads `consolidateMaxLlmCalls` from config and passes it; unset → the
@@ -1368,7 +1430,13 @@ budgetMaxCalls,
1368
1430
  /** Soft cap on the corpus (#245). Nightly.ts reads `memorySoftCap` from
1369
1431
  * config and passes it; unset → `DEFAULT_MEMORY_SOFT_CAP` (10000). `0`
1370
1432
  * disables eviction (indefinite growth). */
1371
- memorySoftCap) {
1433
+ memorySoftCap,
1434
+ /** Reconsolidation-stage knobs (#384), threaded from config by nightly.ts
1435
+ * (correctionMinSimilarity / correctionRewriteMinConfidence) exactly like
1436
+ * supersessionOptions above; unset fields → the stage's defaults.
1437
+ * Appended AFTER the pre-#384 params so every existing positional caller
1438
+ * (tests, hosted nightly) keeps its argument meaning. */
1439
+ reconsolidationOptions) {
1372
1440
  const start = new Date();
1373
1441
  const report = {
1374
1442
  started_at: start.toISOString(),
@@ -1407,6 +1475,13 @@ memorySoftCap) {
1407
1475
  // but the cap stage is pure DB: cheap, idempotent when under cap).
1408
1476
  report.stages.memory_cap = stageMemoryCapEviction(db, dryRun, memorySoftCap ?? exports.DEFAULT_MEMORY_SOFT_CAP);
1409
1477
  if (skip) {
1478
+ // #392: the deterministic merge zone is LLM-free, so a quiet night (zero
1479
+ // new memories → this skip) still drains a pre-existing duplicate
1480
+ // backlog — the memory_cap precedent. Results ride the ONE resolution
1481
+ // stage report (telemetry's "skipped = zero LLM work" stays true), and
1482
+ // the zone never runs twice: the main path runs it INSIDE the stage, this
1483
+ // skip path returns before that.
1484
+ report.stages.reconsolidation = await skippedRunResolutionReport(db, dryRun, stateDir, reconsolidationOptions);
1410
1485
  report.status = "skipped";
1411
1486
  report.completed_at = new Date().toISOString();
1412
1487
  return report;
@@ -1455,6 +1530,11 @@ memorySoftCap) {
1455
1530
  report.stages.hub_boost = stageHubBoost(db, dryRun);
1456
1531
  // Stage 3.7: Supersession Detection (#191 Phase B)
1457
1532
  report.stages.supersession = await stageSupersession(db, llm, budget, embedFn, dryRun, stateDir, supersessionOptions);
1533
+ // Stage 3.8: Reconsolidation (#384) — resolve corrections: rewrite
1534
+ // fact-shaped targets in place (absorbing transition-only triggers),
1535
+ // mark everything else. Rides the same shared budget under its own stage
1536
+ // label + cursor (supersession-stage pattern).
1537
+ report.stages.reconsolidation = await (0, reconsolidation_js_1.stageReconsolidation)(db, llm, budget, embedFn, dryRun, stateDir, reconsolidationOptions);
1458
1538
  // Stage 4: Decay & Prune
1459
1539
  report.stages.decay_prune = stageDecayPrune(db, dryRun);
1460
1540
  // (Memory cap eviction moved before the precheck skip — see above.)
@@ -351,3 +351,20 @@ export declare function dashboardDataHandler(getDb: () => Database.Database, get
351
351
  * 500 with the usual {error} shape (same as dashboardDataHandler).
352
352
  */
353
353
  export declare function accountHandler(getConfig: () => Record<string, unknown> | null | undefined): express.RequestHandler;
354
+ /**
355
+ * Express adapter for GET /account/token — the install's connection token for
356
+ * the console account menu (#365). SECURITY: echo-only — the caller must
357
+ * ALREADY present the token (bearer, or the localhost bypass) to receive it,
358
+ * so this endpoint grants no privilege. It exists so the menu shows the
359
+ * AUTHORITATIVE server-side token instead of trusting localStorage, which can
360
+ * be stale after token rotation and is absent entirely for browser sessions
361
+ * the hosted router authenticates via its session→bearer injection.
362
+ *
363
+ * `getToken` receives the boot-resolved PRIMARY token (config authToken ??
364
+ * HICORTEX_AUTH_TOKEN env) — the value the auth middleware itself accepts as
365
+ * current, so the menu survives rotation and never echoes the rotation-grace
366
+ * token. When no token is configured the handler answers 503 (mirrors how the
367
+ * /auth/* endpoints answer "not configured"); other failures surface as a 500
368
+ * {error} exactly like accountHandler.
369
+ */
370
+ export declare function accountTokenHandler(getToken: () => string | undefined): express.RequestHandler;
package/dist/dashboard.js CHANGED
@@ -26,6 +26,7 @@ exports.backfillSnapshots = backfillSnapshots;
26
26
  exports.handleDashboardData = handleDashboardData;
27
27
  exports.dashboardDataHandler = dashboardDataHandler;
28
28
  exports.accountHandler = accountHandler;
29
+ exports.accountTokenHandler = accountTokenHandler;
29
30
  const recall_index_js_1 = require("./recall-index.js");
30
31
  const config_read_js_1 = require("./config-read.js");
31
32
  const consolidate_js_1 = require("./consolidate.js");
@@ -526,3 +527,34 @@ function accountHandler(getConfig) {
526
527
  }
527
528
  };
528
529
  }
530
+ /**
531
+ * Express adapter for GET /account/token — the install's connection token for
532
+ * the console account menu (#365). SECURITY: echo-only — the caller must
533
+ * ALREADY present the token (bearer, or the localhost bypass) to receive it,
534
+ * so this endpoint grants no privilege. It exists so the menu shows the
535
+ * AUTHORITATIVE server-side token instead of trusting localStorage, which can
536
+ * be stale after token rotation and is absent entirely for browser sessions
537
+ * the hosted router authenticates via its session→bearer injection.
538
+ *
539
+ * `getToken` receives the boot-resolved PRIMARY token (config authToken ??
540
+ * HICORTEX_AUTH_TOKEN env) — the value the auth middleware itself accepts as
541
+ * current, so the menu survives rotation and never echoes the rotation-grace
542
+ * token. When no token is configured the handler answers 503 (mirrors how the
543
+ * /auth/* endpoints answer "not configured"); other failures surface as a 500
544
+ * {error} exactly like accountHandler.
545
+ */
546
+ function accountTokenHandler(getToken) {
547
+ return (_req, res) => {
548
+ try {
549
+ const token = getToken();
550
+ if (!token) {
551
+ res.status(503).json({ error: "no auth token configured on this install" });
552
+ return;
553
+ }
554
+ res.status(200).json({ token });
555
+ }
556
+ catch (err) {
557
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
558
+ }
559
+ };
560
+ }
package/dist/db.js CHANGED
@@ -511,6 +511,42 @@ const MIGRATIONS = [
511
511
  db.exec("UPDATE memories SET memory_type = 'learnings' WHERE memory_type = 'lesson'");
512
512
  },
513
513
  },
514
+ {
515
+ version: 14,
516
+ name: "reconsolidation_status_history",
517
+ up: (db) => {
518
+ // #384 reconsolidation. `memories.status` is the code-defined state
519
+ // vocabulary (NULL/active default; 'superseded'/'retracted' demote in
520
+ // ranking; 'corrected' = rewritten, never demotes; 'absorbed' =
521
+ // invisible to recall — no vector, no FTS row). NULL default with NO
522
+ // backfill: legacy supersessions stay link-driven (findSupersededIds
523
+ // already demotes them); a status is only ever written by the
524
+ // reconsolidation stage, an explicit ingest mark, or a rollback.
525
+ // `memory_history` records CONTENT REWRITES ONLY (before/after content,
526
+ // prior status, trigger dispositions) — one structure serving audit AND
527
+ // `hicortex history --rollback`. Marks need no history row: they are
528
+ // auditable via links + status. Idempotent: hasColumn + IF NOT EXISTS.
529
+ if (!hasColumn(db, "memories", "status")) {
530
+ db.exec("ALTER TABLE memories ADD COLUMN status TEXT");
531
+ }
532
+ db.exec(`
533
+ CREATE TABLE IF NOT EXISTS memory_history (
534
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
535
+ memory_id TEXT NOT NULL,
536
+ old_content TEXT NOT NULL,
537
+ new_content TEXT NOT NULL,
538
+ prev_status TEXT,
539
+ new_status TEXT,
540
+ triggers_json TEXT,
541
+ evidence_id TEXT,
542
+ confidence REAL,
543
+ cause TEXT NOT NULL,
544
+ created_at TEXT NOT NULL
545
+ )
546
+ `);
547
+ db.exec("CREATE INDEX IF NOT EXISTS idx_memory_history_memory ON memory_history(memory_id)");
548
+ },
549
+ },
514
550
  ];
515
551
  /**
516
552
  * Run all pending migrations against the database.