@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,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hicortex stdio MCP bridge (#375) — the `hicortex mcp` subcommand.
|
|
3
|
+
*
|
|
4
|
+
* Registry/stdio MCP clients (Claude Desktop, Cursor, the MCP Registry's own
|
|
5
|
+
* install flow) launch a stdio command and speak MCP over stdin/stdout. The
|
|
6
|
+
* daemon's MCP surface is HTTP/SSE on :8787, so this module bridges the two:
|
|
7
|
+
* a low-level SDK `Server` over `StdioServerTransport` downstream (the client
|
|
8
|
+
* side) and an SDK `Client` over `SSEClientTransport` upstream (the daemon
|
|
9
|
+
* side), forwarding tools/list + tools/call. The daemon's MCP surface is
|
|
10
|
+
* tools-only (no resources/prompts registered in mcp-server.ts) and ping is
|
|
11
|
+
* auto-answered by the SDK Protocol base, so tools-forwarding loses nothing.
|
|
12
|
+
*
|
|
13
|
+
* WHY proxy instead of in-process stdio with direct DB access (design note,
|
|
14
|
+
* issue #375):
|
|
15
|
+
* 1. db.ts enables WAL but sets no busy_timeout — a second writer process
|
|
16
|
+
* (the common case: the daemon already running on server-mode installs)
|
|
17
|
+
* takes immediate SQLITE_BUSY during nightly consolidation's long
|
|
18
|
+
* transactions → tool-call failures.
|
|
19
|
+
* 2. createMcpServer()'s nine tool handlers close over ~10 module-level
|
|
20
|
+
* vars initialized by the ~250-line boot inside startServer() —
|
|
21
|
+
* in-process would mean refactoring the production boot path or
|
|
22
|
+
* duplicating it (drift).
|
|
23
|
+
* 3. warmEmbedder loads a 150-300 MB ONNX model per process — one per MCP
|
|
24
|
+
* client (Claude Desktop + CC + Cursor = 3x), vs zero for the bridge.
|
|
25
|
+
* 4. mcp-server.ts's own header states the model: "One process, one DB
|
|
26
|
+
* connection, one embedder".
|
|
27
|
+
*
|
|
28
|
+
* Target resolution (precedence): HICORTEX_SERVER_URL env → remote bridge,
|
|
29
|
+
* NEVER spawns anything; else config via the SAME semantics as
|
|
30
|
+
* learnings-identity.resolveConfig() (client-mode serverUrl → remote;
|
|
31
|
+
* server-mode → http://127.0.0.1:<port ?? 8787>); no usable config →
|
|
32
|
+
* http://127.0.0.1:8787. Token: HICORTEX_AUTH_TOKEN env → config.authToken.
|
|
33
|
+
* The token rides SSEClientTransport's requestInit headers (verified in the
|
|
34
|
+
* installed SDK 1.28: merged into BOTH the GET /sse and POST /messages).
|
|
35
|
+
*
|
|
36
|
+
* Local autostart: when the target is loopback and /health is
|
|
37
|
+
* connection-refused, spawn a DETACHED `cli.js server --port <n>` (unref,
|
|
38
|
+
* stdio ignored) and poll /health (~250 ms interval, 30 s cap). Concurrent
|
|
39
|
+
* bridges racing EADDRINUSE self-heal — the loser's child dies, the winner's
|
|
40
|
+
* daemon answers the poll, so the loop keeps polling regardless of child
|
|
41
|
+
* state. /health answering but not ok = a foreign or broken service on the
|
|
42
|
+
* port: explicit error, never a spawn. A remote target that is down is
|
|
43
|
+
* likewise an explicit error — we never spawn for remote URLs.
|
|
44
|
+
*
|
|
45
|
+
* STDIO DISCIPLINE: stdout carries ONLY the MCP protocol. Every diagnostic
|
|
46
|
+
* goes to stderr; fatal errors are a one-liner on stderr + non-zero exit
|
|
47
|
+
* (thrown to cli.ts's catch). Cancellation downstream→upstream rides the
|
|
48
|
+
* SDK-native path: the Protocol base aborts the handler's extra.signal on
|
|
49
|
+
* notifications/cancelled, and passing that signal into client.callTool makes
|
|
50
|
+
* the upstream Client emit its own notifications/cancelled with the CORRECT
|
|
51
|
+
* upstream request id (a verbatim forward would carry the downstream id,
|
|
52
|
+
* which means nothing to the daemon) — and reject the in-flight bridge call.
|
|
53
|
+
*/
|
|
54
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
55
|
+
/** Where the bridge target came from — surfaces in the startup diagnostic. */
|
|
56
|
+
export type BridgeTargetSource = "option" | "env" | "config" | "default";
|
|
57
|
+
export interface BridgeTarget {
|
|
58
|
+
/** Base URL, trailing slashes stripped (endpoints append /sse, /health). */
|
|
59
|
+
url: string;
|
|
60
|
+
/** Loopback target → eligible for local autostart. */
|
|
61
|
+
local: boolean;
|
|
62
|
+
/** Port of the resolved URL — what an autospawned daemon must listen on. */
|
|
63
|
+
port: number;
|
|
64
|
+
source: BridgeTargetSource;
|
|
65
|
+
}
|
|
66
|
+
/** Loopback check for URL hostnames (Node's URL keeps the brackets on [::1]). */
|
|
67
|
+
export declare function isLoopbackHost(hostname: string): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the daemon/server the bridge should talk to. Precedence: explicit
|
|
70
|
+
* option (the runMcpStdio test seam) → HICORTEX_SERVER_URL env → config file
|
|
71
|
+
* (client-mode serverUrl = remote; server-mode = localhost, the
|
|
72
|
+
* resolveConfig() semantics already shared by both CC hooks) → default local
|
|
73
|
+
* 8787. Blank/whitespace env values are ignored, not mistaken for targets.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolveBridgeTarget(explicitUrl?: string): BridgeTarget;
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the bearer token for the upstream connection: explicit option →
|
|
78
|
+
* HICORTEX_AUTH_TOKEN env → config.authToken. Undefined = no token (a local
|
|
79
|
+
* daemon needs none — loopback bypasses auth).
|
|
80
|
+
*/
|
|
81
|
+
export declare function resolveBridgeToken(explicitToken?: string): string | undefined;
|
|
82
|
+
export interface HealthProbe {
|
|
83
|
+
/** Any HTTP response arrived (even a non-200 one). */
|
|
84
|
+
reachable: boolean;
|
|
85
|
+
/** The endpoint answered ok — a healthy Hicortex /health. */
|
|
86
|
+
ok: boolean;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* One GET /health probe. Connection refused / timeout / DNS failure →
|
|
90
|
+
* { reachable: false }; a response that is not ok → { reachable: true, ok:
|
|
91
|
+
* false } — the two carry different autostart decisions, so a boolean alone
|
|
92
|
+
* cannot express them.
|
|
93
|
+
*/
|
|
94
|
+
export declare function probeHealthOnce(url: string, timeoutMs?: number): Promise<HealthProbe>;
|
|
95
|
+
export type AutostartDecision = {
|
|
96
|
+
action: "bridge";
|
|
97
|
+
} | {
|
|
98
|
+
action: "spawn";
|
|
99
|
+
} | {
|
|
100
|
+
action: "fail";
|
|
101
|
+
reason: string;
|
|
102
|
+
};
|
|
103
|
+
/**
|
|
104
|
+
* Pure decision from one health probe: healthy → bridge; refused + loopback
|
|
105
|
+
* → spawn a local daemon; refused + remote → fail with an actionable message
|
|
106
|
+
* (never spawn for remote URLs); answering-but-not-ok → fail explicitly (a
|
|
107
|
+
* foreign or broken service owns the port — spawning next to it cannot help).
|
|
108
|
+
*/
|
|
109
|
+
export declare function decideAutostart(probe: HealthProbe, target: BridgeTarget): AutostartDecision;
|
|
110
|
+
export interface EnsureDaemonOptions {
|
|
111
|
+
/** Local autostart toggle (default true). Remote targets never spawn. */
|
|
112
|
+
autostart?: boolean;
|
|
113
|
+
probeHealth?: (url: string) => Promise<HealthProbe>;
|
|
114
|
+
spawnDaemon?: (port: number) => Promise<void> | void;
|
|
115
|
+
/** Poll pacing overrides — small values keep the timeout test fast. */
|
|
116
|
+
pollIntervalMs?: number;
|
|
117
|
+
pollTotalMs?: number;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Ensure something healthy answers at the target before bridging: probe once,
|
|
121
|
+
* decide, and if spawning — poll until healthy (or the deadline). Throws on
|
|
122
|
+
* every fail-path (explicit error, never silent degradation).
|
|
123
|
+
*/
|
|
124
|
+
export declare function ensureDaemonReady(target: BridgeTarget, options?: EnsureDaemonOptions): Promise<void>;
|
|
125
|
+
export interface McpStdioOptions extends EnsureDaemonOptions {
|
|
126
|
+
/** Explicit target URL (test seam; normally resolved from env/config). */
|
|
127
|
+
serverUrl?: string;
|
|
128
|
+
/** Explicit bearer token (test seam; normally env → config). */
|
|
129
|
+
authToken?: string;
|
|
130
|
+
/** Injectable downstream transport (test seam; default: real stdio). */
|
|
131
|
+
downstream?: Transport;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Run the stdio MCP bridge. Resolves only after the downstream transport
|
|
135
|
+
* closes (the lifecycle handlers then exit the process); every setup failure
|
|
136
|
+
* throws for cli.ts to report on stderr and exit 1.
|
|
137
|
+
*/
|
|
138
|
+
export declare function runMcpStdio(options?: McpStdioOptions): Promise<void>;
|
|
@@ -0,0 +1,313 @@
|
|
|
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
|
+
// #383: forward the DAEMON's initialize-result instructions verbatim — the
|
|
265
|
+
// daemon owns the text and the memoryInstructions gate, so the two surfaces
|
|
266
|
+
// cannot diverge and no config read is duplicated in the bridge (a
|
|
267
|
+
// pre-#383 remote daemon simply has none to forward; undefined omits the
|
|
268
|
+
// field from the bridge's own initialize result).
|
|
269
|
+
const server = new index_js_1.Server({ name: "hicortex", version: VERSION }, { capabilities: { tools: {} }, instructions: client.getInstructions() });
|
|
270
|
+
// The proxy core — the SDK's documented proxy pattern. Forward the two
|
|
271
|
+
// tools requests and pass extra.signal through so a downstream
|
|
272
|
+
// notifications/cancelled aborts the upstream call (which emits the
|
|
273
|
+
// correctly-id'd cancellation to the daemon). Nothing else is forwarded
|
|
274
|
+
// request-wise: the daemon is tools-only and the base class answers ping.
|
|
275
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, async (_request, extra) => (await client.listTools(undefined, { signal: extra.signal })));
|
|
276
|
+
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => (await client.callTool(request.params, undefined, { signal: extra.signal })));
|
|
277
|
+
// Upstream → downstream notifications, best-effort: the daemon's tool-list
|
|
278
|
+
// changes or log messages reach the client; a closed far end must not kill
|
|
279
|
+
// the bridge from inside a notification handler.
|
|
280
|
+
client.fallbackNotificationHandler = async (notification) => {
|
|
281
|
+
try {
|
|
282
|
+
await server.notification(notification);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Best-effort by design.
|
|
286
|
+
}
|
|
287
|
+
};
|
|
288
|
+
// Lifecycle: whichever side ends first tears down the other. The `exiting`
|
|
289
|
+
// guard keeps our OWN client.close() (graceful path) from being read as an
|
|
290
|
+
// upstream loss.
|
|
291
|
+
let exiting = false;
|
|
292
|
+
const shutdown = (code) => {
|
|
293
|
+
if (exiting)
|
|
294
|
+
return;
|
|
295
|
+
exiting = true;
|
|
296
|
+
void Promise.allSettled([server.close(), client.close()]).then(() => process.exit(code));
|
|
297
|
+
};
|
|
298
|
+
// Downstream closed (the MCP client went away) → close upstream → exit 0.
|
|
299
|
+
server.onclose = () => shutdown(0);
|
|
300
|
+
// Upstream transport died → the bridge cannot serve anything → exit 1.
|
|
301
|
+
client.onclose = () => {
|
|
302
|
+
if (exiting)
|
|
303
|
+
return;
|
|
304
|
+
console.error("[hicortex] mcp: lost the connection to the Hicortex server");
|
|
305
|
+
shutdown(1);
|
|
306
|
+
};
|
|
307
|
+
process.once("SIGINT", () => shutdown(0));
|
|
308
|
+
process.once("SIGTERM", () => shutdown(0));
|
|
309
|
+
const downstream = options.downstream ?? new stdio_js_1.StdioServerTransport();
|
|
310
|
+
await server.connect(downstream);
|
|
311
|
+
// Diagnostics NEVER touch stdout (the MCP wire) — stderr only.
|
|
312
|
+
console.error(`[hicortex] mcp: bridging stdio <-> ${target.url}/sse (target: ${target.source})`);
|
|
313
|
+
}
|
|
@@ -18,11 +18,29 @@
|
|
|
18
18
|
* The section name is RESERVED: PUT /identity rejects it, and the synthetic
|
|
19
19
|
* text overrides any user file of the same name (enforced means enforced).
|
|
20
20
|
* Off-switch: config `memoryInstructions: false`.
|
|
21
|
+
*
|
|
22
|
+
* Since #383 the file is also the source for the MCP standing instructions
|
|
23
|
+
* (the initialize-result `instructions` field) — the same policy, shaped for
|
|
24
|
+
* passive MCP clients, behind the same off-switch.
|
|
21
25
|
*/
|
|
22
26
|
export declare const MEMORY_SECTION_NAME = "memory";
|
|
23
27
|
/** The product-authored instruction text. Keep compact (~120 tokens): it is
|
|
24
28
|
* injected once per session into every agent on the fleet. */
|
|
25
29
|
export declare function renderMemoryInstructions(): string;
|
|
30
|
+
/** The MCP-client-shaped sibling (#383): standing instructions for the MCP
|
|
31
|
+
* `instructions` field in the initialize result — the MCP-native
|
|
32
|
+
* SessionStart. Passive MCP clients (Claude Desktop etc.) run no hooks and
|
|
33
|
+
* see no injected sections, so this field is the ONLY product-owned
|
|
34
|
+
* guidance their model ever receives; the 2026-09-10 field test showed the
|
|
35
|
+
* memory going entirely unused without it. Compact by construction (same
|
|
36
|
+
* ~120-token budget as the identity sibling) and shares the two policy
|
|
37
|
+
* sentences verbatim — one source, two surfaces. Drops the hook-only
|
|
38
|
+
* surfaces (the `## Memory recall (auto)` index) those clients never see. */
|
|
39
|
+
export declare function renderMcpInstructions(): string;
|
|
40
|
+
/** The config gate as a pure function (#383): enabled → the rendered text,
|
|
41
|
+
* disabled → undefined (the SDK omits the field from the initialize result,
|
|
42
|
+
* so `memoryInstructions: false` silences BOTH surfaces with one switch). */
|
|
43
|
+
export declare function resolveMcpInstructions(enabled: boolean): string | undefined;
|
|
26
44
|
/** True for the reserved product section name (case-insensitive guard —
|
|
27
45
|
* section names are lowercase by allowlist, but be safe). */
|
|
28
46
|
export declare function isReservedSectionName(name: unknown): boolean;
|
|
@@ -19,13 +19,25 @@
|
|
|
19
19
|
* The section name is RESERVED: PUT /identity rejects it, and the synthetic
|
|
20
20
|
* text overrides any user file of the same name (enforced means enforced).
|
|
21
21
|
* Off-switch: config `memoryInstructions: false`.
|
|
22
|
+
*
|
|
23
|
+
* Since #383 the file is also the source for the MCP standing instructions
|
|
24
|
+
* (the initialize-result `instructions` field) — the same policy, shaped for
|
|
25
|
+
* passive MCP clients, behind the same off-switch.
|
|
22
26
|
*/
|
|
23
27
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
24
28
|
exports.MEMORY_SECTION_NAME = void 0;
|
|
25
29
|
exports.renderMemoryInstructions = renderMemoryInstructions;
|
|
30
|
+
exports.renderMcpInstructions = renderMcpInstructions;
|
|
31
|
+
exports.resolveMcpInstructions = resolveMcpInstructions;
|
|
26
32
|
exports.isReservedSectionName = isReservedSectionName;
|
|
27
33
|
exports.injectMemorySection = injectMemorySection;
|
|
28
34
|
exports.MEMORY_SECTION_NAME = "memory";
|
|
35
|
+
/** Capture policy, shared by BOTH instruction surfaces (identity section +
|
|
36
|
+
* MCP initialize result) so they can never disagree. Sentence only — each
|
|
37
|
+
* renderer prefixes its own bullet marker. */
|
|
38
|
+
const CAPTURE_POLICY = "Capture is automatic (nightly). Do not manually ingest routine content — `hicortex_ingest` is for explicitly requested learnings only.";
|
|
39
|
+
/** Infrastructure policy, shared by BOTH instruction surfaces (same rule). */
|
|
40
|
+
const INFRA_POLICY = "Never inspect, test, or modify memory/plugin/gateway infrastructure (configs, services, tokens). If a memory tool seems missing or broken, say so and stop.";
|
|
29
41
|
/** The product-authored instruction text. Keep compact (~120 tokens): it is
|
|
30
42
|
* injected once per session into every agent on the fleet. */
|
|
31
43
|
function renderMemoryInstructions() {
|
|
@@ -34,10 +46,35 @@ function renderMemoryInstructions() {
|
|
|
34
46
|
"- A `## Memory recall (auto)` index may arrive with prompts: it is a MENU, not content. Fetch a full memory with `hicortex_get(id)` when the entry could change how you handle the current task.",
|
|
35
47
|
"- Recall before assuming: `hicortex_search` for prior decisions/facts/preferences, `hicortex_recent` to catch up on a project.",
|
|
36
48
|
"- Cite any memory you rely on by id + date, and mark it `FETCHED` (you read the full memory via `hicortex_get`) or `SNIPPET` (the one-line entry only). Don't present a SNIPPET citation as established. On conflicts, newer memories supersede older.",
|
|
37
|
-
|
|
38
|
-
|
|
49
|
+
`- ${CAPTURE_POLICY}`,
|
|
50
|
+
`- ${INFRA_POLICY}`,
|
|
39
51
|
].join("\n");
|
|
40
52
|
}
|
|
53
|
+
/** The MCP-client-shaped sibling (#383): standing instructions for the MCP
|
|
54
|
+
* `instructions` field in the initialize result — the MCP-native
|
|
55
|
+
* SessionStart. Passive MCP clients (Claude Desktop etc.) run no hooks and
|
|
56
|
+
* see no injected sections, so this field is the ONLY product-owned
|
|
57
|
+
* guidance their model ever receives; the 2026-09-10 field test showed the
|
|
58
|
+
* memory going entirely unused without it. Compact by construction (same
|
|
59
|
+
* ~120-token budget as the identity sibling) and shares the two policy
|
|
60
|
+
* sentences verbatim — one source, two surfaces. Drops the hook-only
|
|
61
|
+
* surfaces (the `## Memory recall (auto)` index) those clients never see. */
|
|
62
|
+
function renderMcpInstructions() {
|
|
63
|
+
return [
|
|
64
|
+
"This server is the user's persistent long-term memory, shared by all their agents and sessions: what was learned, decided, or corrected survives.",
|
|
65
|
+
"CALL `hicortex_search` BEFORE answering questions about the user's history, projects, or preferences — before assuming, guessing, or asking the user something that may already be known.",
|
|
66
|
+
"Call `hicortex_recent` at the start of substantive work on a project to catch up on its latest state.",
|
|
67
|
+
"Search results are one-line summaries — fetch the full memory with `hicortex_get` when it could change how you handle the task, and cite what you rely on (id + date). On conflicts, newer memories supersede older.",
|
|
68
|
+
`- ${CAPTURE_POLICY}`,
|
|
69
|
+
`- ${INFRA_POLICY}`,
|
|
70
|
+
].join("\n");
|
|
71
|
+
}
|
|
72
|
+
/** The config gate as a pure function (#383): enabled → the rendered text,
|
|
73
|
+
* disabled → undefined (the SDK omits the field from the initialize result,
|
|
74
|
+
* so `memoryInstructions: false` silences BOTH surfaces with one switch). */
|
|
75
|
+
function resolveMcpInstructions(enabled) {
|
|
76
|
+
return enabled ? renderMcpInstructions() : undefined;
|
|
77
|
+
}
|
|
41
78
|
/** True for the reserved product section name (case-insensitive guard —
|
|
42
79
|
* section names are lowercase by allowlist, but be safe). */
|
|
43
80
|
function isReservedSectionName(name) {
|
package/dist/nightly.js
CHANGED
|
@@ -735,7 +735,20 @@ async function runNightly(options = {}) {
|
|
|
735
735
|
// #241: config-driven total LLM-call ceiling (default 5000, was 200).
|
|
736
736
|
(0, config_read_js_1.readPositiveConfig)(savedConfig ?? {}, "consolidateMaxLlmCalls", consolidate_js_1.CONSOLIDATE_MAX_LLM_CALLS),
|
|
737
737
|
// #245: soft cap on the corpus (default 10000; 0 disables eviction).
|
|
738
|
-
memorySoftCapResolved
|
|
738
|
+
memorySoftCapResolved, {
|
|
739
|
+
// #384 reconsolidation knobs — threaded exactly like the
|
|
740
|
+
// supersession pair above; the stage validates and falls back
|
|
741
|
+
// to its defaults (0.75 / 0.80) on invalid/absent values.
|
|
742
|
+
minSimilarity: savedConfig?.correctionMinSimilarity,
|
|
743
|
+
rewriteMinConfidence: savedConfig?.correctionRewriteMinConfidence,
|
|
744
|
+
// #392 unified-resolution knobs: the deterministic-merge
|
|
745
|
+
// ceiling (legacy dedupMergeThreshold honored when the new
|
|
746
|
+
// key is absent) and the pacing cap. Same validation posture
|
|
747
|
+
// — the stage defaults to 0.92 / 250.
|
|
748
|
+
autoMergeThreshold: (savedConfig?.dedupAutoMergeThreshold ??
|
|
749
|
+
savedConfig?.dedupMergeThreshold),
|
|
750
|
+
maxMerges: savedConfig?.dedupNightlyMaxMerges,
|
|
751
|
+
});
|
|
739
752
|
console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
|
|
740
753
|
(report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
|
|
741
754
|
consolidationStatus = report.status;
|