@gamaze/hicortex 0.20.4 → 0.20.6
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 +84 -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 +20 -1
- package/dist/reconsolidation.d.ts +362 -0
- package/dist/reconsolidation.js +1349 -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 +198 -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
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
|
-
| `
|
|
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).
|
|
133
|
-
* files named `hicortex-*.tar.gz` (the nightly/CLI artifact
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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).
|
|
286
|
-
* files named `hicortex-*.tar.gz` (the nightly/CLI artifact
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
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) =>
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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)
|
package/dist/consolidate.d.ts
CHANGED
|
@@ -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
|
|
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
|
*/
|