@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.
- package/README.md +20 -1
- package/assets/dashboard.html +318 -8
- package/assets/identity.html +349 -8
- package/assets/viz.html +352 -8
- 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 +7 -2
- package/dist/cli.js +84 -3
- package/dist/consolidate.d.ts +8 -1
- package/dist/consolidate.js +82 -2
- package/dist/dashboard.d.ts +17 -0
- package/dist/dashboard.js +32 -0
- 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 +94 -13
- package/dist/mcp-stdio.d.ts +138 -0
- package/dist/mcp-stdio.js +313 -0
- package/dist/memory-instructions.d.ts +18 -0
- package/dist/memory-instructions.js +39 -2
- package/dist/nightly.js +14 -1
- package/dist/reconsolidation.d.ts +323 -0
- package/dist/reconsolidation.js +1226 -0
- package/dist/retrieval.d.ts +14 -0
- package/dist/retrieval.js +41 -3
- package/dist/state.d.ts +23 -1
- package/dist/storage.d.ts +25 -0
- package/dist/storage.js +49 -7
- package/dist/type-classify.js +4 -2
- package/dist/types.d.ts +188 -0
- package/hermes-plugin/hicortex/provider.py +29 -17
- package/opencode-plugin/hicortex/index.ts +7 -7
- package/package.json +4 -2
- package/pi-extension/hicortex/index.ts +7 -7
- 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 (
|
|
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 (
|
|
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
|
-
|
|
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)
|
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
|
*/
|
package/dist/consolidate.js
CHANGED
|
@@ -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)
|
|
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.)
|
package/dist/dashboard.d.ts
CHANGED
|
@@ -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.
|