@gamaze/hicortex 0.20.2 → 0.20.4

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.
@@ -0,0 +1,308 @@
1
+ "use strict";
2
+ /**
3
+ * Hicortex stdio MCP bridge (#375) — the `hicortex mcp` subcommand.
4
+ *
5
+ * Registry/stdio MCP clients (Claude Desktop, Cursor, the MCP Registry's own
6
+ * install flow) launch a stdio command and speak MCP over stdin/stdout. The
7
+ * daemon's MCP surface is HTTP/SSE on :8787, so this module bridges the two:
8
+ * a low-level SDK `Server` over `StdioServerTransport` downstream (the client
9
+ * side) and an SDK `Client` over `SSEClientTransport` upstream (the daemon
10
+ * side), forwarding tools/list + tools/call. The daemon's MCP surface is
11
+ * tools-only (no resources/prompts registered in mcp-server.ts) and ping is
12
+ * auto-answered by the SDK Protocol base, so tools-forwarding loses nothing.
13
+ *
14
+ * WHY proxy instead of in-process stdio with direct DB access (design note,
15
+ * issue #375):
16
+ * 1. db.ts enables WAL but sets no busy_timeout — a second writer process
17
+ * (the common case: the daemon already running on server-mode installs)
18
+ * takes immediate SQLITE_BUSY during nightly consolidation's long
19
+ * transactions → tool-call failures.
20
+ * 2. createMcpServer()'s nine tool handlers close over ~10 module-level
21
+ * vars initialized by the ~250-line boot inside startServer() —
22
+ * in-process would mean refactoring the production boot path or
23
+ * duplicating it (drift).
24
+ * 3. warmEmbedder loads a 150-300 MB ONNX model per process — one per MCP
25
+ * client (Claude Desktop + CC + Cursor = 3x), vs zero for the bridge.
26
+ * 4. mcp-server.ts's own header states the model: "One process, one DB
27
+ * connection, one embedder".
28
+ *
29
+ * Target resolution (precedence): HICORTEX_SERVER_URL env → remote bridge,
30
+ * NEVER spawns anything; else config via the SAME semantics as
31
+ * learnings-identity.resolveConfig() (client-mode serverUrl → remote;
32
+ * server-mode → http://127.0.0.1:<port ?? 8787>); no usable config →
33
+ * http://127.0.0.1:8787. Token: HICORTEX_AUTH_TOKEN env → config.authToken.
34
+ * The token rides SSEClientTransport's requestInit headers (verified in the
35
+ * installed SDK 1.28: merged into BOTH the GET /sse and POST /messages).
36
+ *
37
+ * Local autostart: when the target is loopback and /health is
38
+ * connection-refused, spawn a DETACHED `cli.js server --port <n>` (unref,
39
+ * stdio ignored) and poll /health (~250 ms interval, 30 s cap). Concurrent
40
+ * bridges racing EADDRINUSE self-heal — the loser's child dies, the winner's
41
+ * daemon answers the poll, so the loop keeps polling regardless of child
42
+ * state. /health answering but not ok = a foreign or broken service on the
43
+ * port: explicit error, never a spawn. A remote target that is down is
44
+ * likewise an explicit error — we never spawn for remote URLs.
45
+ *
46
+ * STDIO DISCIPLINE: stdout carries ONLY the MCP protocol. Every diagnostic
47
+ * goes to stderr; fatal errors are a one-liner on stderr + non-zero exit
48
+ * (thrown to cli.ts's catch). Cancellation downstream→upstream rides the
49
+ * SDK-native path: the Protocol base aborts the handler's extra.signal on
50
+ * notifications/cancelled, and passing that signal into client.callTool makes
51
+ * the upstream Client emit its own notifications/cancelled with the CORRECT
52
+ * upstream request id (a verbatim forward would carry the downstream id,
53
+ * which means nothing to the daemon) — and reject the in-flight bridge call.
54
+ */
55
+ Object.defineProperty(exports, "__esModule", { value: true });
56
+ exports.isLoopbackHost = isLoopbackHost;
57
+ exports.resolveBridgeTarget = resolveBridgeTarget;
58
+ exports.resolveBridgeToken = resolveBridgeToken;
59
+ exports.probeHealthOnce = probeHealthOnce;
60
+ exports.decideAutostart = decideAutostart;
61
+ exports.ensureDaemonReady = ensureDaemonReady;
62
+ exports.runMcpStdio = runMcpStdio;
63
+ const node_child_process_1 = require("node:child_process");
64
+ const node_fs_1 = require("node:fs");
65
+ const node_path_1 = require("node:path");
66
+ const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
67
+ const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
68
+ const index_js_2 = require("@modelcontextprotocol/sdk/client/index.js");
69
+ const sse_js_1 = require("@modelcontextprotocol/sdk/client/sse.js");
70
+ const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
71
+ const learnings_identity_js_1 = require("./learnings-identity.js");
72
+ // Version for the downstream Server declaration — read from package.json
73
+ // relative to __dirname exactly like mcp-server.ts does (both compile into
74
+ // dist/, so ".." lands on the package root). The bridge reports the SAME
75
+ // identity as the daemon's own McpServer so every surface agrees.
76
+ let VERSION = "0.0.0";
77
+ try {
78
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, "..", "package.json"), "utf-8"));
79
+ VERSION = pkg.version;
80
+ }
81
+ catch { /* fallback — matches mcp-server.ts */ }
82
+ const DEFAULT_BRIDGE_PORT = 8787;
83
+ const HEALTH_PROBE_TIMEOUT_MS = 2000;
84
+ const AUTOSTART_POLL_INTERVAL_MS = 250;
85
+ const AUTOSTART_POLL_TOTAL_MS = 30_000;
86
+ /** Loopback check for URL hostnames (Node's URL keeps the brackets on [::1]). */
87
+ function isLoopbackHost(hostname) {
88
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
89
+ }
90
+ function parseTargetUrl(raw, source) {
91
+ const url = raw.trim().replace(/\/+$/, "");
92
+ let parsed;
93
+ try {
94
+ parsed = new URL(url);
95
+ }
96
+ catch {
97
+ // A user-editable env value / config value that is not a URL must fail
98
+ // with a message naming the input — not a bare "Invalid URL".
99
+ const origin = source === "option" ? "the given server URL"
100
+ : source === "env" ? "HICORTEX_SERVER_URL"
101
+ : source === "config" ? "the config serverUrl"
102
+ : "the default server URL";
103
+ throw new Error(`Cannot bridge to ${origin}: "${url}" is not a valid URL.`);
104
+ }
105
+ return {
106
+ url,
107
+ local: isLoopbackHost(parsed.hostname),
108
+ port: parseInt(parsed.port, 10) || DEFAULT_BRIDGE_PORT,
109
+ source,
110
+ };
111
+ }
112
+ /**
113
+ * Resolve the daemon/server the bridge should talk to. Precedence: explicit
114
+ * option (the runMcpStdio test seam) → HICORTEX_SERVER_URL env → config file
115
+ * (client-mode serverUrl = remote; server-mode = localhost, the
116
+ * resolveConfig() semantics already shared by both CC hooks) → default local
117
+ * 8787. Blank/whitespace env values are ignored, not mistaken for targets.
118
+ */
119
+ function resolveBridgeTarget(explicitUrl) {
120
+ if (typeof explicitUrl === "string" && explicitUrl.trim() !== "") {
121
+ return parseTargetUrl(explicitUrl, "option");
122
+ }
123
+ const envUrl = process.env.HICORTEX_SERVER_URL;
124
+ if (typeof envUrl === "string" && envUrl.trim() !== "") {
125
+ return parseTargetUrl(envUrl, "env");
126
+ }
127
+ // resolveConfig() already encodes both config shapes (client → remote
128
+ // serverUrl; server → http://127.0.0.1:<port ?? 8787>) and returns null
129
+ // when there is no usable config.
130
+ const config = (0, learnings_identity_js_1.resolveConfig)();
131
+ if (config)
132
+ return parseTargetUrl(config.serverUrl, "config");
133
+ return parseTargetUrl(`http://127.0.0.1:${DEFAULT_BRIDGE_PORT}`, "default");
134
+ }
135
+ /**
136
+ * Resolve the bearer token for the upstream connection: explicit option →
137
+ * HICORTEX_AUTH_TOKEN env → config.authToken. Undefined = no token (a local
138
+ * daemon needs none — loopback bypasses auth).
139
+ */
140
+ function resolveBridgeToken(explicitToken) {
141
+ if (typeof explicitToken === "string" && explicitToken.trim() !== "")
142
+ return explicitToken.trim();
143
+ const envToken = process.env.HICORTEX_AUTH_TOKEN;
144
+ if (typeof envToken === "string" && envToken.trim() !== "")
145
+ return envToken.trim();
146
+ return (0, learnings_identity_js_1.resolveConfig)()?.authToken;
147
+ }
148
+ /**
149
+ * One GET /health probe. Connection refused / timeout / DNS failure →
150
+ * { reachable: false }; a response that is not ok → { reachable: true, ok:
151
+ * false } — the two carry different autostart decisions, so a boolean alone
152
+ * cannot express them.
153
+ */
154
+ async function probeHealthOnce(url, timeoutMs = HEALTH_PROBE_TIMEOUT_MS) {
155
+ try {
156
+ const resp = await fetch(`${url}/health`, { signal: AbortSignal.timeout(timeoutMs) });
157
+ return { reachable: true, ok: resp.ok };
158
+ }
159
+ catch {
160
+ return { reachable: false, ok: false };
161
+ }
162
+ }
163
+ /**
164
+ * Pure decision from one health probe: healthy → bridge; refused + loopback
165
+ * → spawn a local daemon; refused + remote → fail with an actionable message
166
+ * (never spawn for remote URLs); answering-but-not-ok → fail explicitly (a
167
+ * foreign or broken service owns the port — spawning next to it cannot help).
168
+ */
169
+ function decideAutostart(probe, target) {
170
+ if (probe.ok)
171
+ return { action: "bridge" };
172
+ if (probe.reachable) {
173
+ return {
174
+ action: "fail",
175
+ reason: `Something is answering at ${target.url}/health but it is not a healthy Hicortex server. ` +
176
+ `A foreign or broken service owns that port — inspect it (e.g. lsof -i :${target.port}), ` +
177
+ `then either free the port or point HICORTEX_SERVER_URL at the real Hicortex server.`,
178
+ };
179
+ }
180
+ if (!target.local) {
181
+ return {
182
+ action: "fail",
183
+ reason: `Cannot reach the Hicortex server at ${target.url}. Start it on the server machine ` +
184
+ `(check with \`hicortex status\`, start with \`npx @gamaze/hicortex server\`) or fix HICORTEX_SERVER_URL. ` +
185
+ `If it answers 401 once up, set HICORTEX_AUTH_TOKEN to the server's auth token.`,
186
+ };
187
+ }
188
+ return { action: "spawn" };
189
+ }
190
+ /**
191
+ * Spawn a detached daemon: `node cli.js server --port <n>`. Detached + unref
192
+ * + ignored stdio — the daemon OUTLIVES this bridge (the product model; init
193
+ * installs it as a persistent daemon for exactly this) and never touches the
194
+ * bridge's stdio MCP wire. The spawned child's exit is not monitored on
195
+ * purpose: in the EADDRINUSE race (two bridges started the same missing
196
+ * daemon), the loser's child dies and the winner's daemon answers the
197
+ * /health poll — monitoring would teach us nothing actionable.
198
+ */
199
+ function defaultSpawnDaemon(port) {
200
+ const child = (0, node_child_process_1.spawn)(process.execPath, [(0, node_path_1.join)(__dirname, "cli.js"), "server", "--port", String(port)], { detached: true, stdio: "ignore" });
201
+ child.unref();
202
+ }
203
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
204
+ /**
205
+ * Ensure something healthy answers at the target before bridging: probe once,
206
+ * decide, and if spawning — poll until healthy (or the deadline). Throws on
207
+ * every fail-path (explicit error, never silent degradation).
208
+ */
209
+ async function ensureDaemonReady(target, options = {}) {
210
+ const probe = options.probeHealth ?? ((url) => probeHealthOnce(url));
211
+ const autostart = options.autostart ?? true;
212
+ const intervalMs = options.pollIntervalMs ?? AUTOSTART_POLL_INTERVAL_MS;
213
+ const totalMs = options.pollTotalMs ?? AUTOSTART_POLL_TOTAL_MS;
214
+ const initial = await probe(target.url);
215
+ const decision = decideAutostart(initial, target);
216
+ if (decision.action === "bridge")
217
+ return;
218
+ if (decision.action === "fail")
219
+ throw new Error(decision.reason);
220
+ if (!autostart) {
221
+ throw new Error(`No Hicortex server is running at ${target.url} (autostart disabled).`);
222
+ }
223
+ await (options.spawnDaemon ?? defaultSpawnDaemon)(target.port);
224
+ const deadline = Date.now() + totalMs;
225
+ while (Date.now() < deadline) {
226
+ await sleep(intervalMs);
227
+ // A mid-boot non-ok answer (daemon warming up) is NOT a foreign service —
228
+ // only the INITIAL probe's reachable-non-ok fails. Keep polling.
229
+ const current = await probe(target.url);
230
+ if (current.ok)
231
+ return;
232
+ }
233
+ throw new Error(`The Hicortex server did not become healthy at ${target.url}/health within ` +
234
+ `${Math.round(totalMs / 1000)}s of autostart. Try \`npx @gamaze/hicortex server\` in a terminal ` +
235
+ `to see the daemon's startup error, then re-run this command.`);
236
+ }
237
+ /**
238
+ * Run the stdio MCP bridge. Resolves only after the downstream transport
239
+ * closes (the lifecycle handlers then exit the process); every setup failure
240
+ * throws for cli.ts to report on stderr and exit 1.
241
+ */
242
+ async function runMcpStdio(options = {}) {
243
+ const target = resolveBridgeTarget(options.serverUrl);
244
+ const token = resolveBridgeToken(options.authToken);
245
+ await ensureDaemonReady(target, options);
246
+ // Upstream: the daemon's SSE MCP endpoint. requestInit headers ride BOTH
247
+ // the GET /sse and the POST /messages (SDK 1.28 _commonHeaders/send).
248
+ const upstream = new sse_js_1.SSEClientTransport(new URL(`${target.url}/sse`), token !== undefined ? { requestInit: { headers: { Authorization: `Bearer ${token}` } } } : {});
249
+ const client = new index_js_2.Client({ name: "hicortex-mcp-bridge", version: VERSION });
250
+ try {
251
+ await client.connect(upstream);
252
+ }
253
+ catch (err) {
254
+ // 401 from the daemon's auth middleware (remote connections; loopback is
255
+ // exempt). SseError carries the HTTP status as .code.
256
+ if (err.code === 401) {
257
+ throw new Error(`The Hicortex server at ${target.url} rejected the connection (401). ` +
258
+ `Set HICORTEX_AUTH_TOKEN to the server's auth token — it is printed by \`hicortex status\` on the server box.`);
259
+ }
260
+ throw err instanceof Error ? err : new Error(String(err));
261
+ }
262
+ // Downstream: a low-level Server over stdio advertising exactly what the
263
+ // daemon offers (tools). Ping is auto-answered by the Protocol base.
264
+ const server = new index_js_1.Server({ name: "hicortex", version: VERSION }, { capabilities: { tools: {} } });
265
+ // The proxy core — the SDK's documented proxy pattern. Forward the two
266
+ // tools requests and pass extra.signal through so a downstream
267
+ // notifications/cancelled aborts the upstream call (which emits the
268
+ // correctly-id'd cancellation to the daemon). Nothing else is forwarded
269
+ // request-wise: the daemon is tools-only and the base class answers ping.
270
+ server.setRequestHandler(types_js_1.ListToolsRequestSchema, async (_request, extra) => (await client.listTools(undefined, { signal: extra.signal })));
271
+ server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => (await client.callTool(request.params, undefined, { signal: extra.signal })));
272
+ // Upstream → downstream notifications, best-effort: the daemon's tool-list
273
+ // changes or log messages reach the client; a closed far end must not kill
274
+ // the bridge from inside a notification handler.
275
+ client.fallbackNotificationHandler = async (notification) => {
276
+ try {
277
+ await server.notification(notification);
278
+ }
279
+ catch {
280
+ // Best-effort by design.
281
+ }
282
+ };
283
+ // Lifecycle: whichever side ends first tears down the other. The `exiting`
284
+ // guard keeps our OWN client.close() (graceful path) from being read as an
285
+ // upstream loss.
286
+ let exiting = false;
287
+ const shutdown = (code) => {
288
+ if (exiting)
289
+ return;
290
+ exiting = true;
291
+ void Promise.allSettled([server.close(), client.close()]).then(() => process.exit(code));
292
+ };
293
+ // Downstream closed (the MCP client went away) → close upstream → exit 0.
294
+ server.onclose = () => shutdown(0);
295
+ // Upstream transport died → the bridge cannot serve anything → exit 1.
296
+ client.onclose = () => {
297
+ if (exiting)
298
+ return;
299
+ console.error("[hicortex] mcp: lost the connection to the Hicortex server");
300
+ shutdown(1);
301
+ };
302
+ process.once("SIGINT", () => shutdown(0));
303
+ process.once("SIGTERM", () => shutdown(0));
304
+ const downstream = options.downstream ?? new stdio_js_1.StdioServerTransport();
305
+ await server.connect(downstream);
306
+ // Diagnostics NEVER touch stdout (the MCP wire) — stderr only.
307
+ console.error(`[hicortex] mcp: bridging stdio <-> ${target.url}/sse (target: ${target.source})`);
308
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.20.2",
3
+ "version": "0.20.4",
4
4
  "description": "Persistent agent identity for AI agents \u2014 a hand-edited identity layer, nightly-distilled experience, and lessons injected every session, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, Pi, and opencode.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "hicortex": "dist/cli.js"
8
8
  },
9
+ "mcpName": "io.github.gamaze-labs/hicortex",
9
10
  "openclaw": {
10
11
  "extensions": [
11
12
  "./dist/index.js"
@@ -32,6 +33,7 @@
32
33
  "opencode-plugin/",
33
34
  "openclaw.plugin.json",
34
35
  "domains.example.json",
36
+ "server.json",
35
37
  "README.md",
36
38
  "THIRD_PARTY_NOTICES.md"
37
39
  ],
@@ -76,4 +78,4 @@
76
78
  "tar-stream": "^2.2.0",
77
79
  "undici": "^8.10.0"
78
80
  }
79
- }
81
+ }
package/server.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
+ "name": "io.github.gamaze-labs/hicortex",
4
+ "title": "Hicortex",
5
+ "description": "Shared fleet memory for AI agents \u2014 what one agent learns, the whole fleet knows.",
6
+ "version": "0.20.4",
7
+ "packages": [
8
+ {
9
+ "registryType": "npm",
10
+ "identifier": "@gamaze/hicortex",
11
+ "version": "0.20.4",
12
+ "transport": {
13
+ "type": "stdio",
14
+ "command": "npx",
15
+ "args": [
16
+ "-y",
17
+ "@gamaze/hicortex",
18
+ "mcp"
19
+ ]
20
+ },
21
+ "environmentVariables": [
22
+ {
23
+ "name": "HICORTEX_AUTH_TOKEN",
24
+ "description": "Bearer token for a remote Hicortex server (server prints it via `hicortex status`). Optional \u2014 a local server needs no token.",
25
+ "isRequired": false,
26
+ "format": "string",
27
+ "isSecret": true
28
+ },
29
+ {
30
+ "name": "HICORTEX_SERVER_URL",
31
+ "description": "URL of a remote Hicortex server (e.g. https://your-server:8787). Optional \u2014 omit for a local server on this machine.",
32
+ "isRequired": false,
33
+ "format": "string",
34
+ "isSecret": false
35
+ }
36
+ ]
37
+ }
38
+ ],
39
+ "repository": {
40
+ "url": "https://github.com/gamaze-labs/hicortex",
41
+ "source": "github"
42
+ },
43
+ "websiteUrl": "https://hicortex.gamaze.com"
44
+ }