@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 CHANGED
@@ -253,6 +253,7 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
253
253
  | `numCtx` | Context window for ollama (default 8192, one value for all phases). Scoring uses ~850 tokens, so 2048 is ample; distill/reflect/classify need more for `detectChunkSize`'s chunk sizing. |
254
254
  | `enableThinking` | Toggle the model's internal reasoning ("thinking") stream for OpenAI-compatible endpoints (default false). Only meaningful for local chat-template-aware servers (ollama, mlx-lm); leave unset for cloud OpenAI/OpenRouter/Groq endpoints (they 400 on the unknown `chat_template_kwargs` field). |
255
255
  | `maxTokens` | Max output tokens for all phases (default 8192). A ceiling, not a target — the model stops early when done. |
256
+ | `classifyMaxTokens` | Max output tokens for the classify tier — the short JSON-verdict calls (correction/supersession verdicts, rewrite contracts, type and domain tag classification). Default 1024. A ceiling, not a target: raise it when a reasoning-style model spends the budget on internal reasoning and returns empty verdicts; `maxTokens` keeps governing the heavy phases (distill, reflect). |
256
257
  | `ollamaFlushEvery` | Flush ollama's accumulated memory every N scoring calls. **Off by default (0)** — opt-in only for an **ollama** install whose runner RSS growth (~171 MB/call) swap-thrashes long consolidations on a RAM-constrained box; N=15 caps a cycle at ~2.5 GB. Gated on the provider being ollama (local **or** remote) — no effect for non-ollama providers. Only you can judge whether your ollama endpoint actually suffers the growth (a managed/cloud ollama host may not), so it stays off until you set it. |
257
258
  | `ollamaFlushWaitMs` | Milliseconds to wait after an ollama flush for the runner to exit + release memory (default 180000 = 3 min). |
258
259
  | `llmTimeoutMs` | The ONE timeout ceiling on every LLM call in every phase (default 900000 = 15 min). The LLM request paths disable the HTTP client's hidden 5-minute response-header timer, so this knob is the only bound — one place to tune when the endpoint is slow, no per-phase special cases. |
@@ -303,7 +304,9 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
303
304
  | `recallMinPromptChars` | Prompts shorter than this skip the recall index (default: 20) |
304
305
  | `recallTitleChars` | Chars of each memory's first line shown in an index entry (default: 100, range 40–400). Reverted from 150 on 2026-08-03: a full-corpus relevance eval found 100 and 150 statistically identical while 100 saves ~13% of the block's tokens |
305
306
  | `sessionIntentWeight` | Blend weight of the session-intent rolling centroid in the recall search vector: `query = (1-w)·prompt + w·centroid` (default: 0.33; set 0 to disable — pure-prompt recall, the kill-switch). The first turn of a session searches with pure prompt and seeds the centroid; subsequent turns blend so recall follows the session's intent instead of being query-literal. The EMA rate (0.4) is a shipped constant, not configurable |
306
- | `dedupMergeThreshold` | Minimum cosine similarity for `hicortex dedup` to cluster memories as near-duplicates (default: 0.92) |
307
+ | `dedupAutoMergeThreshold` | The deterministic merge ceiling of the unified resolution pass: memory pairs at/above this cosine merge automatically (zero LLM) via the dedup core's clustering; pairs between `correctionMinSimilarity` and this value get the one merge/corrects/supersedes/none verdict. Also the default threshold for `hicortex dedup` (default: 0.92) |
308
+ | `dedupMergeThreshold` | Legacy alias for `dedupAutoMergeThreshold`, still honored when the newer key is absent |
309
+ | `dedupNightlyMaxMerges` | Pacing cap on merge operations per nightly run — deterministic-zone clusters plus verdict-confirmed pair merges count against one cap, so a large duplicate backlog drains over a few nights (default: 250; `0` disables the merge machinery) |
307
310
  | `supersessionMinSimilarity` | Minimum cosine similarity for a nightly supersession candidate pair (default: 0.80) |
308
311
  | `supersessionMaxCalls` | Max classify-tier LLM calls the nightly's supersession stage spends per run (default: 30) |
309
312
  | `supersessionPenalty` | Multiplier applied to a superseded memory's `base_strength` (default: 0.5) |
package/dist/backup.d.ts CHANGED
@@ -129,19 +129,23 @@ export declare function runBackupHook(artifactPath: string, command: string | un
129
129
  */
130
130
  export declare function newestBackupArtifactMs(dir: string): number | undefined;
131
131
  /**
132
- * Prune a backup dir to the `retention` newest artifacts (#327). Matches ONLY
133
- * files named `hicortex-*.tar.gz` (the nightly/CLI artifact pattern) — anything
134
- * else in the dir (operator copies, notes) is never touched. Keeps the newest
135
- * `retention` by mtime, with the ISO filename (time-ordered by construction)
136
- * as a DESCENDING tie-break so equal mtimes resolve deterministically (the
137
- * just-written artifact is the newest and always survives); deletes the rest,
138
- * oldest first. `retention <= 0` keeps all.
132
+ * Prune a backup dir to the `retention` newest artifacts (#327). By default
133
+ * matches ONLY files named `hicortex-*.tar.gz` (the nightly/CLI artifact
134
+ * pattern); the optional `pattern` override scopes the prune to another
135
+ * product-owned filename family — e.g. the pre-dedup merge backups
136
+ * (`pre-dedup-*.db`, #392) keep their own retention count INDEPENDENT of the
137
+ * full-backup artifacts. Anything else in the dir (operator copies, notes) is
138
+ * never touched. Keeps the newest `retention` by mtime, with the ISO filename
139
+ * (time-ordered by construction) as a DESCENDING tie-break so equal mtimes
140
+ * resolve deterministically (the just-written artifact is the newest and
141
+ * always survives); deletes the rest, oldest first. `retention <= 0` keeps
142
+ * all.
139
143
  *
140
144
  * Best-effort by design: a per-file unlink failure logs and continues (a
141
145
  * stale extra artifact is cheap; failing the nightly AFTER a good backup was
142
146
  * written is not). Returns the number actually removed.
143
147
  */
144
- export declare function pruneBackupArtifacts(dir: string, retention: number): number;
148
+ export declare function pruneBackupArtifacts(dir: string, retention: number, pattern?: RegExp): number;
145
149
  export interface BackupCliOptions {
146
150
  /** `--out <dir>` — output directory (the artifact is auto-named). Takes precedence over `config.backupDir`. Mutually exclusive with stdout. */
147
151
  outDir?: string;
package/dist/backup.js CHANGED
@@ -282,19 +282,23 @@ function newestBackupArtifactMs(dir) {
282
282
  return newest;
283
283
  }
284
284
  /**
285
- * Prune a backup dir to the `retention` newest artifacts (#327). Matches ONLY
286
- * files named `hicortex-*.tar.gz` (the nightly/CLI artifact pattern) — anything
287
- * else in the dir (operator copies, notes) is never touched. Keeps the newest
288
- * `retention` by mtime, with the ISO filename (time-ordered by construction)
289
- * as a DESCENDING tie-break so equal mtimes resolve deterministically (the
290
- * just-written artifact is the newest and always survives); deletes the rest,
291
- * oldest first. `retention <= 0` keeps all.
285
+ * Prune a backup dir to the `retention` newest artifacts (#327). By default
286
+ * matches ONLY files named `hicortex-*.tar.gz` (the nightly/CLI artifact
287
+ * pattern); the optional `pattern` override scopes the prune to another
288
+ * product-owned filename family — e.g. the pre-dedup merge backups
289
+ * (`pre-dedup-*.db`, #392) keep their own retention count INDEPENDENT of the
290
+ * full-backup artifacts. Anything else in the dir (operator copies, notes) is
291
+ * never touched. Keeps the newest `retention` by mtime, with the ISO filename
292
+ * (time-ordered by construction) as a DESCENDING tie-break so equal mtimes
293
+ * resolve deterministically (the just-written artifact is the newest and
294
+ * always survives); deletes the rest, oldest first. `retention <= 0` keeps
295
+ * all.
292
296
  *
293
297
  * Best-effort by design: a per-file unlink failure logs and continues (a
294
298
  * stale extra artifact is cheap; failing the nightly AFTER a good backup was
295
299
  * written is not). Returns the number actually removed.
296
300
  */
297
- function pruneBackupArtifacts(dir, retention) {
301
+ function pruneBackupArtifacts(dir, retention, pattern = /^hicortex-.*\.tar\.gz$/) {
298
302
  if (!Number.isFinite(retention) || retention <= 0)
299
303
  return 0;
300
304
  let names;
@@ -306,7 +310,7 @@ function pruneBackupArtifacts(dir, retention) {
306
310
  return 0;
307
311
  }
308
312
  const artifacts = names
309
- .filter((n) => /^hicortex-.*\.tar\.gz$/.test(n))
313
+ .filter((n) => pattern.test(n))
310
314
  .map((n) => {
311
315
  const abs = (0, node_path_1.join)(dir, n);
312
316
  let mtimeMs = 0;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Claude Desktop auto-configuration (#381) — the init-side writer for the
3
+ * `claude_desktop_config.json` MCP entry.
4
+ *
5
+ * Claude Desktop's ONLY stdio MCP route is this hand-edited file (no CLI, no
6
+ * plugin discovery), which is why Desktop never had an init step while CC
7
+ * (`claude mcp add`), Pi, and opencode (dropped extension files) did. The
8
+ * 0.20.4 `hicortex mcp` stdio bridge makes such an entry meaningful, so init
9
+ * now offers to write it — the same auto-detect pattern as the other
10
+ * harnesses, gated on the Claude Desktop config DIRECTORY existing (the
11
+ * config FILE may not exist yet; creating it fresh is in scope).
12
+ *
13
+ * Two properties dominate the design:
14
+ *
15
+ * 1. IT IS ANOTHER APP'S FILE. `claude_desktop_config.json` is owned by
16
+ * Claude Desktop and carries the user's other servers and app settings.
17
+ * The write is therefore merge-safe (preserve EVERY top-level key and
18
+ * every other server; touch only `mcpServers.hicortex`), takes a
19
+ * timestamped `.bak` copy of the existing file first, lands via
20
+ * tmp-write + JSON-validate + rename in the same directory (atomic),
21
+ * and REFUSES — touching nothing, not even a backup — when the existing
22
+ * file is not valid JSON. Unlike our own config.json there is no repair
23
+ * flow here (quarantening another app's config would break IT); the
24
+ * user fixes the file by hand with the snippet init prints.
25
+ *
26
+ * 2. THE COMMAND MUST BE AN ABSOLUTE NPX PATH. Desktop is a GUI app and
27
+ * does not inherit the shell PATH — a bare "npx" command is the #1
28
+ * Desktop MCP failure mode. Resolution walks PATH plus the common
29
+ * install locations and rejects npm's ephemeral `/_npx/` cache (#176: a
30
+ * path that dies on the next cache GC — the entry would break silently
31
+ * weeks later). Unresolvable → manual instructions, nothing written.
32
+ * Windows .cmd shims cannot be spawned by Electron without a shell, so
33
+ * they are wrapped in `cmd /c`.
34
+ *
35
+ * Every helper is pure/parametrised (platform/env/home/exists injected) so
36
+ * the suite covers darwin/win32/linux without touching a real machine. This
37
+ * module deliberately imports NOTHING from init.ts (init imports this — no
38
+ * cycle); the package spec is passed in from init's getPackageSpec().
39
+ */
40
+ /**
41
+ * The Claude Desktop config directory for this platform, or null where no
42
+ * Desktop build exists (Linux → silent skip). Parametrised so tests cover
43
+ * the platform matrix without a real machine. Detection in init is
44
+ * `desktopConfigDir() !== null && existsSync(dir)` — the config FILE itself
45
+ * may legitimately not exist yet.
46
+ */
47
+ export declare function desktopConfigDir(platform?: NodeJS.Platform, env?: Record<string, string | undefined>, home?: string): string | null;
48
+ export interface DesktopNpxResolveOptions {
49
+ /** The PATH string to search (default: process.env.PATH). */
50
+ pathEnv?: string;
51
+ /** Platform under test (default: the real one). */
52
+ platform?: NodeJS.Platform;
53
+ /** Home dir for the ~-relative common locations (default: homedir()). */
54
+ home?: string;
55
+ /** Env for APPDATA on win32 (default: process.env). */
56
+ env?: Record<string, string | undefined>;
57
+ /** Existence seam (default: existsSync) — unit tests pass fixture sets. */
58
+ exists?: (candidatePath: string) => boolean;
59
+ }
60
+ /**
61
+ * Resolve an ABSOLUTE npx path for the Desktop entry, or null when nothing
62
+ * durable exists (init then prints manual instructions and writes nothing).
63
+ * Rejects npm's ephemeral npx cache in BOTH separator spellings — unix
64
+ * `/_npx/` and Windows `\_npx\` — a bare includes("/_npx/") would let the
65
+ * win32 form through (#176: the path dies on the next cache GC).
66
+ */
67
+ export declare function resolveDesktopNpxPath(options?: DesktopNpxResolveOptions): string | null;
68
+ /** The `mcpServers.hicortex` value — a Claude Desktop stdio server entry. */
69
+ export interface DesktopServerEntry {
70
+ command: string;
71
+ args: string[];
72
+ /** Only in remote mode; absent (not empty) in local/loopback mode. */
73
+ env?: Record<string, string>;
74
+ }
75
+ /**
76
+ * Build the stdio entry for the `hicortex mcp` bridge. NO "type" field —
77
+ * stdio is implied for Desktop entries (the CC `.claude.json` writer needs
78
+ * `"type":"sse"`; this is the other shape). A `.cmd`/`.bat` shim is wrapped
79
+ * in `cmd /c` because Electron spawns without a shell and cannot exec a cmd
80
+ * script directly. An empty/absent env omits the key entirely — a local
81
+ * entry must carry no env (the bridge autostarts the daemon; loopback
82
+ * bypasses auth).
83
+ */
84
+ export declare function buildDesktopServerEntry(npxPath: string, packageSpec: string, env?: Record<string, string>): DesktopServerEntry;
85
+ /**
86
+ * Loopback check for a server URL — decides whether the Desktop entry needs
87
+ * an env block at all (local: none, the bridge resolves + autostarts the
88
+ * daemon; remote: URL + token). Mirrors mcp-stdio's isLoopbackHost (kept
89
+ * local rather than imported to avoid dragging the SDK into init's graph).
90
+ * An unparseable URL is NOT local — fail toward carrying the env, which
91
+ * still works everywhere the URL is real.
92
+ */
93
+ export declare function isLocalServerUrl(url: string): boolean;
94
+ export type DesktopMergeResult = {
95
+ ok: true;
96
+ config: Record<string, unknown>;
97
+ } | {
98
+ ok: false;
99
+ reason: string;
100
+ };
101
+ /**
102
+ * Parse the existing config text and merge our entry in, preserving every
103
+ * top-level key and every other server. `rawText === null` means "no file"
104
+ * (fresh install — a config containing only our entry is created). Malformed
105
+ * JSON, a non-object document, or a non-object `mcpServers` value returns
106
+ * `{ ok: false }` — the caller refuses to write, so the ORIGINAL file and
107
+ * its bytes are what the user keeps.
108
+ */
109
+ export declare function mergeDesktopServerConfig(rawText: string | null, entry: DesktopServerEntry): DesktopMergeResult;
110
+ export type DesktopWriteResult = {
111
+ status: "written";
112
+ backupPath?: string;
113
+ } | {
114
+ status: "refused";
115
+ reason: string;
116
+ } | {
117
+ status: "failed";
118
+ reason: string;
119
+ };
120
+ /**
121
+ * Orchestrate the merge-safe, atomic write of our entry into
122
+ * `claude_desktop_config.json`:
123
+ *
124
+ * 1. Read the existing file (ENOENT → fresh-install path).
125
+ * 2. Merge; a malformed/shape-refused file → `{ status: "refused" }` with
126
+ * NOTHING touched — no backup, no tmp, no bytes changed.
127
+ * 3. Copy the existing file to `<path>.bak-<ISO-colons-stripped>` BEFORE
128
+ * writing (the quarantineMalformedConfig naming convention).
129
+ * 4. Write the serialized payload to a tmp file in the SAME directory
130
+ * (same filesystem → the rename is atomic), JSON-parse the exact bytes
131
+ * that landed on disk, then renameSync over the target.
132
+ *
133
+ * Any unexpected I/O error returns `{ status: "failed" }` after removing the
134
+ * tmp file — a half-written tmp must never masquerade as a config. Never
135
+ * throws; the init wiring turns every non-"written" result into a printed
136
+ * warning and init continues.
137
+ */
138
+ export declare function writeDesktopServerConfig(configPath: string, entry: DesktopServerEntry): DesktopWriteResult;
@@ -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
@@ -12,8 +12,12 @@
12
12
  * nightly --evict-only Memory-cap eviction only — pure DB, no LLM (#317)
13
13
  * nightly --status Show nightly pipeline health check
14
14
  * 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)
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
17
21
  * status Show config, DB stats, adapter status
18
22
  * uninstall Clean removal of CC integration
19
23
  */
package/dist/cli.js CHANGED
@@ -13,8 +13,12 @@
13
13
  * nightly --evict-only Memory-cap eviction only — pure DB, no LLM (#317)
14
14
  * nightly --status Show nightly pipeline health check
15
15
  * relink Resumable link-discovery pass over the entire corpus (issue #143)
16
- * dedup Cluster + merge near-duplicate memories (issue #100)
17
- * 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
18
22
  * status Show config, DB stats, adapter status
19
23
  * uninstall Clean removal of CC integration
20
24
  */
@@ -272,6 +276,52 @@ switch (command) {
272
276
  });
273
277
  break;
274
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
+ }
275
325
  case "identity": {
276
326
  // Standing identity layer edit surface (spec §6; renamed from context in
277
327
  // 0.18 #264): show|edit against the configured server. Secondary to the
@@ -365,6 +415,9 @@ Commands:
365
415
  nightly Run nightly denoise + capture + consolidate
366
416
  relink Resumable link-discovery pass over the ENTIRE corpus (server mode)
367
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
368
421
  backup Snapshot the DB + identity + state to a tar.gz (online, WAL-safe)
369
422
  classify-domains Backfill content-based domain tags over the corpus (server mode, needs config.domains)
370
423
  classify-types Backfill episode→fact/decision type tags over the corpus (server mode)
@@ -390,8 +443,14 @@ Options:
390
443
  relink --batch <n> Memories per batch (default: 200)
391
444
  relink --reset Restart from the beginning (ignore saved cursor)
392
445
  dedup --apply Execute the merge (default: dry run, report only)
393
- 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)
394
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)
395
454
  backup --out <dir> Write the artifact into <dir> as hicortex-<ISO>.tar.gz (default: <home>/backups)
396
455
  backup --stdout Stream the tar.gz to stdout (offsite pipe: hicortex backup --stdout | rclone rcat …)
397
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
  */