@gamaze/hicortex 0.11.0 → 0.12.0
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 +16 -2
- package/assets/context.html +487 -0
- package/assets/viz.html +3 -1
- package/dist/cli.js +18 -0
- package/dist/context-cli.d.ts +58 -0
- package/dist/context-cli.js +243 -0
- package/dist/context-store.d.ts +120 -0
- package/dist/context-store.js +321 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +37 -20
- package/dist/init.js +22 -9
- package/dist/lessons-context.d.ts +20 -7
- package/dist/lessons-context.js +127 -29
- package/dist/mcp-server.js +75 -10
- package/dist/nightly-status.js +6 -0
- package/dist/nightly.js +19 -0
- package/dist/uninstall.js +29 -13
- package/dist/viz.d.ts +15 -0
- package/dist/viz.js +48 -0
- package/hermes-plugin/hicortex/README.md +4 -5
- package/hermes-plugin/hicortex/__init__.py +1 -1
- package/hermes-plugin/hicortex/client.py +2 -2
- package/hermes-plugin/hicortex/plugin.yaml +2 -2
- package/hermes-plugin/hicortex/provider.py +8 -21
- package/package.json +1 -1
- package/skills/hicortex-memory/SKILL.md +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Responsibilities (recall-only adapter, like the Hermes plugin):
|
|
12
12
|
* - before_agent_start → GET /lessons (fail-soft, 3s timeout) → inject context
|
|
13
|
-
* - Tools → HTTP proxies to /search, /
|
|
13
|
+
* - Tools → HTTP proxies to /search, /recent, /ingest, /lessons
|
|
14
14
|
*
|
|
15
15
|
* CAPTURE IS NOT THIS PLUGIN'S JOB. OpenClaw persists sessions at
|
|
16
16
|
* ~/.openclaw/agents/<agentId>/sessions/*.jsonl in the Pi v3 format; the
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Responsibilities (recall-only adapter, like the Hermes plugin):
|
|
13
13
|
* - before_agent_start → GET /lessons (fail-soft, 3s timeout) → inject context
|
|
14
|
-
* - Tools → HTTP proxies to /search, /
|
|
14
|
+
* - Tools → HTTP proxies to /search, /recent, /ingest, /lessons
|
|
15
15
|
*
|
|
16
16
|
* CAPTURE IS NOT THIS PLUGIN'S JOB. OpenClaw persists sessions at
|
|
17
17
|
* ~/.openclaw/agents/<agentId>/sessions/*.jsonl in the Pi v3 format; the
|
|
@@ -50,13 +50,30 @@ async function serverGet(path, timeoutMs) {
|
|
|
50
50
|
signal: AbortSignal.timeout(timeoutMs),
|
|
51
51
|
});
|
|
52
52
|
if (!resp.ok)
|
|
53
|
-
return null;
|
|
54
|
-
return await resp.json();
|
|
53
|
+
return { data: null, status: resp.status };
|
|
54
|
+
return { data: await resp.json(), status: resp.status };
|
|
55
55
|
}
|
|
56
56
|
catch {
|
|
57
|
-
return null;
|
|
57
|
+
return { data: null, status: null };
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* Human-readable GET failure. Distinguishes a down server from an HTTP error —
|
|
62
|
+
* in particular a 404, so plugin/server version skew reads as a version
|
|
63
|
+
* problem, not a network one. The /context→/recent rename hint (0.12) is
|
|
64
|
+
* added only for /recent, where it is the overwhelmingly likely cause.
|
|
65
|
+
*/
|
|
66
|
+
function describeGetFailure(status, endpoint) {
|
|
67
|
+
if (status === null)
|
|
68
|
+
return "server unreachable";
|
|
69
|
+
if (status === 404) {
|
|
70
|
+
const renameHint = endpoint.startsWith("/recent")
|
|
71
|
+
? " (0.12 renamed /context to /recent)"
|
|
72
|
+
: "";
|
|
73
|
+
return `HTTP 404 — ${endpoint} not found on the server; likely plugin/server version skew${renameHint}. Upgrade the server first.`;
|
|
74
|
+
}
|
|
75
|
+
return `server returned HTTP ${status}`;
|
|
76
|
+
}
|
|
60
77
|
async function serverPost(path, body, timeoutMs) {
|
|
61
78
|
try {
|
|
62
79
|
const resp = await fetch(`${serverUrl}${path}`, {
|
|
@@ -145,7 +162,7 @@ exports.default = {
|
|
|
145
162
|
api.on("before_agent_start", async (_event, ctx) => {
|
|
146
163
|
try {
|
|
147
164
|
// One fetch — build context and check cap from the same response.
|
|
148
|
-
const data = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
|
|
165
|
+
const { data } = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
|
|
149
166
|
if (!data || !data.lessons || data.lessons.length === 0)
|
|
150
167
|
return {};
|
|
151
168
|
const maxLessons = (0, features_js_1.lessonsLimit)();
|
|
@@ -199,9 +216,9 @@ exports.default = {
|
|
|
199
216
|
params.set("limit", String(args.limit));
|
|
200
217
|
if (args.project)
|
|
201
218
|
params.set("project", args.project);
|
|
202
|
-
const data = await serverGet(`/search?${params}`, 10000);
|
|
219
|
+
const { data, status } = await serverGet(`/search?${params}`, 10000);
|
|
203
220
|
if (!data)
|
|
204
|
-
return { error:
|
|
221
|
+
return { error: `Search failed: ${describeGetFailure(status, "/search")}` };
|
|
205
222
|
return formatToolResults(data.results ?? []);
|
|
206
223
|
}
|
|
207
224
|
catch (err) {
|
|
@@ -210,8 +227,8 @@ exports.default = {
|
|
|
210
227
|
},
|
|
211
228
|
}), { name: "hicortex_search" });
|
|
212
229
|
api.registerTool((_ctx) => ({
|
|
213
|
-
name: "
|
|
214
|
-
description: "Get recent
|
|
230
|
+
name: "hicortex_recent",
|
|
231
|
+
description: "Get recent memories, optionally filtered by project. Queryless recall of the latest memories by project, ranked by importance. Useful to catch up on what happened recently.",
|
|
215
232
|
parameters: {
|
|
216
233
|
type: "object",
|
|
217
234
|
properties: {
|
|
@@ -227,16 +244,16 @@ exports.default = {
|
|
|
227
244
|
if (args?.limit)
|
|
228
245
|
params.set("limit", String(args.limit));
|
|
229
246
|
const qs = params.toString();
|
|
230
|
-
const data = await serverGet(`/
|
|
247
|
+
const { data, status } = await serverGet(`/recent${qs ? `?${qs}` : ""}`, 10000);
|
|
231
248
|
if (!data)
|
|
232
|
-
return { error:
|
|
249
|
+
return { error: `Recent recall failed: ${describeGetFailure(status, "/recent")}` };
|
|
233
250
|
return formatToolResults(data.results ?? []);
|
|
234
251
|
}
|
|
235
252
|
catch (err) {
|
|
236
|
-
return { error: `
|
|
253
|
+
return { error: `Recent recall failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
237
254
|
}
|
|
238
255
|
},
|
|
239
|
-
}), { name: "
|
|
256
|
+
}), { name: "hicortex_recent" });
|
|
240
257
|
api.registerTool((_ctx) => ({
|
|
241
258
|
name: "hicortex_ingest",
|
|
242
259
|
description: "Store a new memory in long-term storage. Use for important facts, decisions, or lessons.",
|
|
@@ -284,9 +301,9 @@ exports.default = {
|
|
|
284
301
|
},
|
|
285
302
|
async execute(_callId, args, _ctx) {
|
|
286
303
|
try {
|
|
287
|
-
const data = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
|
|
304
|
+
const { data, status } = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
|
|
288
305
|
if (!data)
|
|
289
|
-
return { error:
|
|
306
|
+
return { error: `Lessons fetch failed: ${describeGetFailure(status, "/lessons")}` };
|
|
290
307
|
const lessons = data.lessons ?? [];
|
|
291
308
|
if (lessons.length === 0) {
|
|
292
309
|
return { content: [{ type: "text", text: "No lessons found." }] };
|
|
@@ -308,9 +325,9 @@ exports.default = {
|
|
|
308
325
|
},
|
|
309
326
|
async execute(_callId, _args, _ctx) {
|
|
310
327
|
try {
|
|
311
|
-
const data = await serverGet("/index", 10000);
|
|
328
|
+
const { data, status } = await serverGet("/index", 10000);
|
|
312
329
|
if (!data)
|
|
313
|
-
return { error:
|
|
330
|
+
return { error: `Index fetch failed: ${describeGetFailure(status, "/index")}` };
|
|
314
331
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
315
332
|
}
|
|
316
333
|
catch (err) {
|
|
@@ -350,9 +367,9 @@ exports.default = {
|
|
|
350
367
|
params.set("domain", args.domain);
|
|
351
368
|
if (args.relationship)
|
|
352
369
|
params.set("relationship", args.relationship);
|
|
353
|
-
const data = await serverGet(`/graph?${params}`, 10000);
|
|
370
|
+
const { data, status } = await serverGet(`/graph?${params}`, 10000);
|
|
354
371
|
if (!data)
|
|
355
|
-
return { error:
|
|
372
|
+
return { error: `Graph query failed: ${describeGetFailure(status, "/graph")}` };
|
|
356
373
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
357
374
|
}
|
|
358
375
|
catch (err) {
|
|
@@ -432,7 +449,7 @@ exports.default = {
|
|
|
432
449
|
// ---------------------------------------------------------------------------
|
|
433
450
|
const HICORTEX_TOOLS = [
|
|
434
451
|
"hicortex_search",
|
|
435
|
-
"
|
|
452
|
+
"hicortex_recent",
|
|
436
453
|
"hicortex_ingest",
|
|
437
454
|
"hicortex_lessons",
|
|
438
455
|
"hicortex_index",
|
package/dist/init.js
CHANGED
|
@@ -188,7 +188,7 @@ function installCcCommands() {
|
|
|
188
188
|
name: learn
|
|
189
189
|
description: Save an explicit learning/insight to Hicortex long-term memory. Immediate storage, no nightly wait. Use when you discover something worth remembering across sessions.
|
|
190
190
|
argument-hint: <learning to save>
|
|
191
|
-
allowed-tools: mcp__hicortex__hicortex_ingest, mcp__hicortex__hicortex_search,
|
|
191
|
+
allowed-tools: mcp__hicortex__hicortex_ingest, mcp__hicortex__hicortex_search, mcp__hicortex__hicortex_recent, mcp__hicortex__hicortex_lessons
|
|
192
192
|
---
|
|
193
193
|
|
|
194
194
|
# Save Learning to Hicortex
|
|
@@ -234,7 +234,7 @@ Becomes a call to hicortex_ingest with:
|
|
|
234
234
|
name: hicortex-activate
|
|
235
235
|
description: Register a Hicortex commercial license key. Personal and noncommercial use is free; commercial use requires a per-seat license from hicortex.gamaze.com.
|
|
236
236
|
argument-hint: <license-key>
|
|
237
|
-
allowed-tools: Bash(mkdir:*), Bash(echo:*), Bash(launchctl:*), Bash(systemctl:*), Bash(curl:*), mcp__hicortex__hicortex_ingest, mcp__hicortex__hicortex_search,
|
|
237
|
+
allowed-tools: Bash(mkdir:*), Bash(echo:*), Bash(launchctl:*), Bash(systemctl:*), Bash(curl:*), mcp__hicortex__hicortex_ingest, mcp__hicortex__hicortex_search, mcp__hicortex__hicortex_recent, mcp__hicortex__hicortex_lessons
|
|
238
238
|
---
|
|
239
239
|
|
|
240
240
|
# Register Hicortex Commercial License
|
|
@@ -640,7 +640,7 @@ async function persistLlmConfig() {
|
|
|
640
640
|
await options[selectedIdx].save();
|
|
641
641
|
const selectedLabel = options[selectedIdx].label;
|
|
642
642
|
if (selectedLabel.startsWith("Skip")) {
|
|
643
|
-
console.log(" No LLM configured — server will run recall-only (search/lessons/
|
|
643
|
+
console.log(" No LLM configured — server will run recall-only (search/lessons/recent work).\n" +
|
|
644
644
|
" To enable capture and consolidation later, run: npx @gamaze/hicortex init");
|
|
645
645
|
}
|
|
646
646
|
else {
|
|
@@ -1282,10 +1282,12 @@ function installNightlyCron(hour) {
|
|
|
1282
1282
|
const hh = String(hour).padStart(2, "0");
|
|
1283
1283
|
// PATH must start with the binary's own directory (see installLaunchd for rationale).
|
|
1284
1284
|
const binDir = (0, node_path_1.dirname)(binaryArgs[0]);
|
|
1285
|
+
// One canonical nightly log path across platforms — status output, docs,
|
|
1286
|
+
// and support instructions all reference this single location.
|
|
1287
|
+
const logPath = (0, node_path_1.join)(HICORTEX_HOME, "nightly.log");
|
|
1285
1288
|
if (os === "darwin") {
|
|
1286
1289
|
const plistDir = (0, node_path_1.join)((0, node_os_1.homedir)(), "Library", "LaunchAgents");
|
|
1287
1290
|
const plistPath = (0, node_path_1.join)(plistDir, "com.gamaze.hicortex-nightly.plist");
|
|
1288
|
-
const logPath = (0, node_path_1.join)(HICORTEX_HOME, "nightly.log");
|
|
1289
1291
|
// Never overwrite an existing schedule — users tune these (multi-slot
|
|
1290
1292
|
// capture windows, quiet hours). Fresh installs only.
|
|
1291
1293
|
if ((0, node_fs_1.existsSync)(plistPath)) {
|
|
@@ -1341,18 +1343,18 @@ ${programArgs}
|
|
|
1341
1343
|
const configDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "systemd", "user");
|
|
1342
1344
|
const servicePath = (0, node_path_1.join)(configDir, "hicortex-nightly.service");
|
|
1343
1345
|
const timerPath = (0, node_path_1.join)(configDir, "hicortex-nightly.timer");
|
|
1344
|
-
// Never overwrite an existing schedule — users tune these. Fresh installs only.
|
|
1345
|
-
if ((0, node_fs_1.existsSync)(timerPath)) {
|
|
1346
|
-
console.log(` ✓ Nightly timer already installed — leaving existing schedule as-is`);
|
|
1347
|
-
return;
|
|
1348
|
-
}
|
|
1349
1346
|
const execStart = [...binaryArgs, "nightly"].join(" ");
|
|
1347
|
+
// File logging, not journal: oneshot runs on machines with a volatile
|
|
1348
|
+
// journal (e.g. Raspberry Pi defaults) otherwise fail without a trace.
|
|
1349
|
+
// Same log path as the macOS plist. append: needs systemd ≥ 240 (2018).
|
|
1350
1350
|
const service = `[Unit]
|
|
1351
1351
|
Description=Hicortex Nightly (distill + POST)
|
|
1352
1352
|
|
|
1353
1353
|
[Service]
|
|
1354
1354
|
Type=oneshot
|
|
1355
1355
|
ExecStart=${execStart}
|
|
1356
|
+
StandardOutput=append:${logPath}
|
|
1357
|
+
StandardError=append:${logPath}
|
|
1356
1358
|
Environment=PATH=${binDir}:/usr/local/bin:/usr/bin:/bin
|
|
1357
1359
|
Environment=HOME=${(0, node_os_1.homedir)()}
|
|
1358
1360
|
WorkingDirectory=${(0, node_os_1.homedir)()}`;
|
|
@@ -1366,7 +1368,18 @@ Persistent=true
|
|
|
1366
1368
|
[Install]
|
|
1367
1369
|
WantedBy=timers.target`;
|
|
1368
1370
|
(0, node_fs_1.mkdirSync)(configDir, { recursive: true });
|
|
1371
|
+
// The .service file is ours — always refresh it so fixes (like file
|
|
1372
|
+
// logging) reach existing installs. The .timer holds the user-tuned
|
|
1373
|
+
// schedule and is never overwritten.
|
|
1369
1374
|
(0, node_fs_1.writeFileSync)(servicePath, service);
|
|
1375
|
+
if ((0, node_fs_1.existsSync)(timerPath)) {
|
|
1376
|
+
try {
|
|
1377
|
+
(0, node_child_process_1.execSync)("systemctl --user daemon-reload", { stdio: "pipe" });
|
|
1378
|
+
}
|
|
1379
|
+
catch { /* fine */ }
|
|
1380
|
+
console.log(` ✓ Nightly service refreshed — existing timer schedule kept as-is`);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1370
1383
|
(0, node_fs_1.writeFileSync)(timerPath, timer);
|
|
1371
1384
|
try {
|
|
1372
1385
|
(0, node_child_process_1.execSync)("systemctl --user daemon-reload", { stdio: "pipe" });
|
|
@@ -1,17 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lessons-context — query-time
|
|
2
|
+
* lessons-context — query-time context injection for the CC SessionStart hook.
|
|
3
3
|
*
|
|
4
4
|
* Replaces file-based injection (injectLessons / injectLessonsFromServer).
|
|
5
|
-
* Reads ~/.hicortex/config.json to find the server URL,
|
|
6
|
-
* and prints a compact Markdown block to stdout so CC
|
|
7
|
-
* session context
|
|
5
|
+
* Reads ~/.hicortex/config.json to find the server URL, then fetches TWO
|
|
6
|
+
* endpoints concurrently and prints a compact Markdown block to stdout so CC
|
|
7
|
+
* picks it up as session context:
|
|
8
|
+
*
|
|
9
|
+
* GET /context → the standing context layer (user info + rules; 0.12).
|
|
10
|
+
* Injected as a `## Context` block ONLY when this harness
|
|
11
|
+
* ("cc") is in the server-resolved `clients` list (self-gate).
|
|
12
|
+
* GET /lessons → episodic memory lessons + memory index, rendered as the
|
|
13
|
+
* existing `## Hicortex Memory` block.
|
|
14
|
+
*
|
|
15
|
+
* The two fetches run in Promise.all, each with its OWN 3 s timeout and
|
|
16
|
+
* INDEPENDENT fail-soft: a /context failure must never cost the lessons block,
|
|
17
|
+
* and vice versa. Sequential fetches would double worst-case SessionStart
|
|
18
|
+
* latency (~6 s) — see spec §7.
|
|
8
19
|
*
|
|
9
20
|
* Fail-soft by design: ANY failure (missing config, network error, non-2xx,
|
|
10
21
|
* parse error) results in silent exit-0. A broken hook must never block a
|
|
11
|
-
* CC session.
|
|
22
|
+
* CC session, and a broken /context fetch must never blank the whole output.
|
|
12
23
|
*/
|
|
13
24
|
/**
|
|
14
|
-
* Fetch
|
|
15
|
-
*
|
|
25
|
+
* Fetch context + lessons concurrently and return the combined Markdown block,
|
|
26
|
+
* or null when neither yields anything (nothing to inject; caller prints
|
|
27
|
+
* nothing and exits 0). The `## Context` block is prepended before the existing
|
|
28
|
+
* `## Hicortex Memory` block.
|
|
16
29
|
*/
|
|
17
30
|
export declare function fetchLessonsContext(): Promise<string | null>;
|
package/dist/lessons-context.js
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
-
* lessons-context — query-time
|
|
3
|
+
* lessons-context — query-time context injection for the CC SessionStart hook.
|
|
4
4
|
*
|
|
5
5
|
* Replaces file-based injection (injectLessons / injectLessonsFromServer).
|
|
6
|
-
* Reads ~/.hicortex/config.json to find the server URL,
|
|
7
|
-
* and prints a compact Markdown block to stdout so CC
|
|
8
|
-
* session context
|
|
6
|
+
* Reads ~/.hicortex/config.json to find the server URL, then fetches TWO
|
|
7
|
+
* endpoints concurrently and prints a compact Markdown block to stdout so CC
|
|
8
|
+
* picks it up as session context:
|
|
9
|
+
*
|
|
10
|
+
* GET /context → the standing context layer (user info + rules; 0.12).
|
|
11
|
+
* Injected as a `## Context` block ONLY when this harness
|
|
12
|
+
* ("cc") is in the server-resolved `clients` list (self-gate).
|
|
13
|
+
* GET /lessons → episodic memory lessons + memory index, rendered as the
|
|
14
|
+
* existing `## Hicortex Memory` block.
|
|
15
|
+
*
|
|
16
|
+
* The two fetches run in Promise.all, each with its OWN 3 s timeout and
|
|
17
|
+
* INDEPENDENT fail-soft: a /context failure must never cost the lessons block,
|
|
18
|
+
* and vice versa. Sequential fetches would double worst-case SessionStart
|
|
19
|
+
* latency (~6 s) — see spec §7.
|
|
9
20
|
*
|
|
10
21
|
* Fail-soft by design: ANY failure (missing config, network error, non-2xx,
|
|
11
22
|
* parse error) results in silent exit-0. A broken hook must never block a
|
|
12
|
-
* CC session.
|
|
23
|
+
* CC session, and a broken /context fetch must never blank the whole output.
|
|
13
24
|
*/
|
|
14
25
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
26
|
exports.fetchLessonsContext = fetchLessonsContext;
|
|
@@ -19,41 +30,53 @@ const node_os_1 = require("node:os");
|
|
|
19
30
|
const features_js_1 = require("./features.js");
|
|
20
31
|
const extensions_js_1 = require("./extensions.js");
|
|
21
32
|
const state_js_1 = require("./state.js");
|
|
22
|
-
const HICORTEX_HOME = (0, node_path_1.join)((0, node_os_1.homedir)(), ".hicortex");
|
|
23
33
|
const DEFAULT_PORT = 8787;
|
|
34
|
+
/** Harness name this hook injects for — used to self-gate on GET /context `clients`. */
|
|
35
|
+
const THIS_HARNESS = "cc";
|
|
24
36
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
37
|
+
* Resolve the Hicortex home. Honors HICORTEX_HOME (matches the HICORTEX_DB_PATH
|
|
38
|
+
* env convention in db.ts) so headless/scratch installs can point elsewhere;
|
|
39
|
+
* defaults to ~/.hicortex. Resolved per-call so tests and env changes apply.
|
|
27
40
|
*/
|
|
28
|
-
|
|
29
|
-
|
|
41
|
+
function hicortexHome() {
|
|
42
|
+
return process.env.HICORTEX_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), ".hicortex");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read ~/.hicortex/config.json and resolve the server URL + auth token, or
|
|
46
|
+
* null when there is no usable config (server not set up yet — fail soft).
|
|
47
|
+
*/
|
|
48
|
+
function resolveConfig() {
|
|
49
|
+
const home = hicortexHome();
|
|
50
|
+
let config;
|
|
30
51
|
try {
|
|
31
|
-
config = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(
|
|
52
|
+
config = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(home, "config.json"), "utf-8"));
|
|
32
53
|
}
|
|
33
54
|
catch {
|
|
34
|
-
// No config file — server not set up yet. Fail soft.
|
|
35
55
|
return null;
|
|
36
56
|
}
|
|
37
|
-
//
|
|
57
|
+
// Client mode uses serverUrl; server mode uses localhost.
|
|
38
58
|
const serverUrl = config.mode === "client" && typeof config.serverUrl === "string"
|
|
39
59
|
? config.serverUrl.replace(/\/+$/, "")
|
|
40
60
|
: `http://127.0.0.1:${config.port ?? DEFAULT_PORT}`;
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
|
|
61
|
+
return { serverUrl, authToken: config.authToken, home };
|
|
62
|
+
}
|
|
63
|
+
function authHeaders(authToken) {
|
|
64
|
+
return authToken ? { "Authorization": `Bearer ${authToken}` } : {};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Fetch /lessons and build the `## Hicortex Memory` block, or null on any
|
|
68
|
+
* failure (missing/non-2xx/parse). Preserves the pre-0.12 behavior exactly.
|
|
69
|
+
*/
|
|
70
|
+
async function fetchLessonsBlock(cfg) {
|
|
71
|
+
const resp = await fetch(`${cfg.serverUrl}/lessons`, {
|
|
72
|
+
headers: authHeaders(cfg.authToken),
|
|
73
|
+
signal: AbortSignal.timeout(3000),
|
|
74
|
+
});
|
|
75
|
+
if (!resp.ok)
|
|
53
76
|
return null;
|
|
54
|
-
|
|
77
|
+
const data = await resp.json();
|
|
55
78
|
const maxLessons = (0, features_js_1.lessonsLimit)();
|
|
56
|
-
const moduleIndex = data.moduleIndex ?? (0, state_js_1.loadState)(
|
|
79
|
+
const moduleIndex = data.moduleIndex ?? (0, state_js_1.loadState)(cfg.home).moduleIndex;
|
|
57
80
|
// The SessionStart hook runs in the session's working directory, whose last
|
|
58
81
|
// path component matches the capture-side CC project name convention
|
|
59
82
|
// (transcript-reader's decodeProjectDirName also takes the last component).
|
|
@@ -71,12 +94,11 @@ async function fetchLessonsContext() {
|
|
|
71
94
|
const parts = ["## Hicortex Memory", ""];
|
|
72
95
|
parts.push("You have access to shared long-term memory across all agents and sessions.");
|
|
73
96
|
parts.push("BEFORE making decisions, search memory: `hicortex_search` for prior decisions on the same topic.");
|
|
74
|
-
parts.push("Use `
|
|
97
|
+
parts.push("Use `hicortex_recent` at session start for recent project state.");
|
|
75
98
|
if (lessonLines.length > 0) {
|
|
76
99
|
parts.push("", "### Lessons (updated nightly)");
|
|
77
100
|
parts.push(...lessonLines);
|
|
78
101
|
}
|
|
79
|
-
// Memory index
|
|
80
102
|
const { index } = data;
|
|
81
103
|
if (moduleIndex && moduleIndex.domains.length > 0) {
|
|
82
104
|
parts.push("", "### Memory Index");
|
|
@@ -95,3 +117,79 @@ async function fetchLessonsContext() {
|
|
|
95
117
|
}
|
|
96
118
|
return parts.join("\n");
|
|
97
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Title-case a section name for its heading: split on `-`/`_`, capitalize each
|
|
122
|
+
* word ("user" → "User", "my_notes" → "My Notes").
|
|
123
|
+
*/
|
|
124
|
+
function titleCaseSection(name) {
|
|
125
|
+
return name
|
|
126
|
+
.split(/[-_]+/)
|
|
127
|
+
.filter(Boolean)
|
|
128
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
129
|
+
.join(" ");
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Stable section ordering: `user` first, then `rules` (the seeded primary
|
|
133
|
+
* sections, spec §8), then every other section alphabetically. Server-side
|
|
134
|
+
* enumeration order (readdirSync) is FS-dependent, so we sort here for a
|
|
135
|
+
* deterministic injection block.
|
|
136
|
+
*/
|
|
137
|
+
function orderSectionNames(names) {
|
|
138
|
+
const primaries = ["user", "rules"].filter((p) => names.includes(p));
|
|
139
|
+
const rest = names.filter((n) => n !== "user" && n !== "rules").sort();
|
|
140
|
+
return [...primaries, ...rest];
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Fetch /context and build the `## Context` block, or null when nothing should
|
|
144
|
+
* be injected: non-2xx, this harness not in `clients`, no sections, or all
|
|
145
|
+
* sections empty. Throws propagate to the caller's fail-soft catch.
|
|
146
|
+
*/
|
|
147
|
+
async function fetchContextBlock(cfg) {
|
|
148
|
+
const resp = await fetch(`${cfg.serverUrl}/context`, {
|
|
149
|
+
headers: authHeaders(cfg.authToken),
|
|
150
|
+
signal: AbortSignal.timeout(3000),
|
|
151
|
+
});
|
|
152
|
+
if (!resp.ok)
|
|
153
|
+
return null;
|
|
154
|
+
const data = await resp.json();
|
|
155
|
+
// Self-gate: only inject when this harness is in the server-resolved list.
|
|
156
|
+
const clients = Array.isArray(data.clients) ? data.clients : [];
|
|
157
|
+
if (!clients.includes(THIS_HARNESS))
|
|
158
|
+
return null;
|
|
159
|
+
const sections = data.sections;
|
|
160
|
+
if (!sections || typeof sections !== "object" || Array.isArray(sections))
|
|
161
|
+
return null;
|
|
162
|
+
const names = orderSectionNames(Object.keys(sections));
|
|
163
|
+
const bodyParts = [];
|
|
164
|
+
for (const name of names) {
|
|
165
|
+
const body = sections[name];
|
|
166
|
+
if (typeof body !== "string" || body.trim() === "")
|
|
167
|
+
continue;
|
|
168
|
+
bodyParts.push(`### ${titleCaseSection(name)}`, "", body.trim());
|
|
169
|
+
}
|
|
170
|
+
if (bodyParts.length === 0)
|
|
171
|
+
return null;
|
|
172
|
+
return ["## Context", "", ...bodyParts].join("\n");
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Fetch context + lessons concurrently and return the combined Markdown block,
|
|
176
|
+
* or null when neither yields anything (nothing to inject; caller prints
|
|
177
|
+
* nothing and exits 0). The `## Context` block is prepended before the existing
|
|
178
|
+
* `## Hicortex Memory` block.
|
|
179
|
+
*/
|
|
180
|
+
async function fetchLessonsContext() {
|
|
181
|
+
const cfg = resolveConfig();
|
|
182
|
+
if (!cfg)
|
|
183
|
+
return null;
|
|
184
|
+
// Independent fail-soft: each branch degrades to null without affecting the
|
|
185
|
+
// other. Promise.all runs them concurrently — each carries its own 3 s
|
|
186
|
+
// timeout, so worst-case latency stays ~3 s, not ~6 s (spec §7).
|
|
187
|
+
const [contextBlock, lessonsBlock] = await Promise.all([
|
|
188
|
+
fetchContextBlock(cfg).catch(() => null),
|
|
189
|
+
fetchLessonsBlock(cfg).catch(() => null),
|
|
190
|
+
]);
|
|
191
|
+
const blocks = [contextBlock, lessonsBlock].filter((b) => b !== null && b !== "");
|
|
192
|
+
if (blocks.length === 0)
|
|
193
|
+
return null;
|
|
194
|
+
return blocks.join("\n\n");
|
|
195
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -50,6 +50,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
50
50
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
51
|
exports.startServer = startServer;
|
|
52
52
|
const express_1 = __importDefault(require("express"));
|
|
53
|
+
const node_path_1 = require("node:path");
|
|
53
54
|
const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
54
55
|
const sse_js_1 = require("@modelcontextprotocol/sdk/server/sse.js");
|
|
55
56
|
const zod_1 = require("zod");
|
|
@@ -61,6 +62,7 @@ const embedder_js_1 = require("./embedder.js");
|
|
|
61
62
|
const storage = __importStar(require("./storage.js"));
|
|
62
63
|
const graph_js_1 = require("./graph.js");
|
|
63
64
|
const viz_js_1 = require("./viz.js");
|
|
65
|
+
const context_store_js_1 = require("./context-store.js");
|
|
64
66
|
const retrieval = __importStar(require("./retrieval.js"));
|
|
65
67
|
const seed_lesson_js_1 = require("./seed-lesson.js");
|
|
66
68
|
const distiller_js_1 = require("./distiller.js");
|
|
@@ -76,6 +78,9 @@ let llmConfig = null;
|
|
|
76
78
|
// immediate abort ("strict", default) or a fallback to the base model ("local").
|
|
77
79
|
let distillFallbackMode = "strict";
|
|
78
80
|
let stateDir = "";
|
|
81
|
+
// Resolved contextClients list (spec §2) — the harness names allowed to inject
|
|
82
|
+
// the standing context layer. Echoed by GET /context so each hook self-gates.
|
|
83
|
+
let contextClients = ["cc"];
|
|
79
84
|
// Cache detectChunkSize results keyed by "<provider>/<model>@<baseUrl>" so we
|
|
80
85
|
// probe each endpoint once per server boot rather than once per /distill request.
|
|
81
86
|
const chunkSizeCache = new Map();
|
|
@@ -109,8 +114,8 @@ function createMcpServer() {
|
|
|
109
114
|
return { content: [{ type: "text", text: `Search failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
110
115
|
}
|
|
111
116
|
});
|
|
112
|
-
// --
|
|
113
|
-
server.tool("
|
|
117
|
+
// -- hicortex_recent --
|
|
118
|
+
server.tool("hicortex_recent", "Get recent memories, optionally filtered by project. Queryless recall of the latest memories by project, ranked by importance. Useful to catch up on what happened recently.", {
|
|
114
119
|
project: zod_1.z.string().optional().describe("Filter by project name"),
|
|
115
120
|
limit: zod_1.z.coerce.number().optional().describe("Max results (default 10)"),
|
|
116
121
|
}, async ({ project, limit }) => {
|
|
@@ -121,7 +126,7 @@ function createMcpServer() {
|
|
|
121
126
|
return { content: [{ type: "text", text: formatResults(results) }] };
|
|
122
127
|
}
|
|
123
128
|
catch (err) {
|
|
124
|
-
return { content: [{ type: "text", text: `
|
|
129
|
+
return { content: [{ type: "text", text: `Recent recall failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
|
|
125
130
|
}
|
|
126
131
|
});
|
|
127
132
|
// -- hicortex_ingest --
|
|
@@ -387,7 +392,7 @@ async function startServer(options = {}) {
|
|
|
387
392
|
console.warn("╔══════════════════════════════════════════════════════════════╗");
|
|
388
393
|
console.warn("║ NO LLM CONFIGURED — running in recall-only mode ║");
|
|
389
394
|
console.warn("║ ║");
|
|
390
|
-
console.warn("║ search / lessons /
|
|
395
|
+
console.warn("║ search / lessons / recent: ENABLED ║");
|
|
391
396
|
console.warn("║ /distill (capture) and consolidation: DISABLED ║");
|
|
392
397
|
console.warn("║ ║");
|
|
393
398
|
console.warn("║ To enable capture, run: ║");
|
|
@@ -422,6 +427,15 @@ async function startServer(options = {}) {
|
|
|
422
427
|
console.warn("[hicortex] WARNING: no authToken configured — remote connections will be rejected " +
|
|
423
428
|
"(localhost still works). Run `npx @gamaze/hicortex init` to generate a token.");
|
|
424
429
|
}
|
|
430
|
+
// Context layer (0.12): resolve which harnesses may inject the standing
|
|
431
|
+
// context. Warn once per boot on unknown names so typos (e.g. "herms")
|
|
432
|
+
// surface instead of silently dropping.
|
|
433
|
+
const resolvedClients = (0, context_store_js_1.resolveContextClients)(savedConfig?.contextClients);
|
|
434
|
+
contextClients = resolvedClients.clients;
|
|
435
|
+
if (resolvedClients.dropped.length > 0) {
|
|
436
|
+
console.warn(`[hicortex] Ignoring unknown contextClients: ${resolvedClients.dropped.join(", ")} ` +
|
|
437
|
+
`(known: cc, hermes, oc)`);
|
|
438
|
+
}
|
|
425
439
|
// Express app
|
|
426
440
|
const app = (0, express_1.default)();
|
|
427
441
|
// Raise the body limit — whole-session denoised transcripts exceed the 100 kB default.
|
|
@@ -431,7 +445,7 @@ async function startServer(options = {}) {
|
|
|
431
445
|
const origin = req.headers.origin;
|
|
432
446
|
if (origin) {
|
|
433
447
|
res.setHeader("Access-Control-Allow-Origin", origin);
|
|
434
|
-
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
448
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS");
|
|
435
449
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Accept, Authorization");
|
|
436
450
|
res.setHeader("Access-Control-Allow-Credentials", "true");
|
|
437
451
|
res.setHeader("Vary", "Origin");
|
|
@@ -567,8 +581,8 @@ async function startServer(options = {}) {
|
|
|
567
581
|
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
568
582
|
}
|
|
569
583
|
});
|
|
570
|
-
// REST /
|
|
571
|
-
app.get("/
|
|
584
|
+
// REST /recent — recent memories, optionally filtered by project.
|
|
585
|
+
app.get("/recent", (req, res) => {
|
|
572
586
|
if (!db) {
|
|
573
587
|
res.status(503).json({ error: "Server not initialized" });
|
|
574
588
|
return;
|
|
@@ -586,6 +600,44 @@ async function startServer(options = {}) {
|
|
|
586
600
|
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
587
601
|
}
|
|
588
602
|
});
|
|
603
|
+
// -------------------------------------------------------------------------
|
|
604
|
+
// REST /context — standing context layer (0.12, spec 2026-07-12).
|
|
605
|
+
//
|
|
606
|
+
// GET → { sections, updated_at, clients } read from <hicortex-home>/context/.
|
|
607
|
+
// PUT → partial upsert of named sections (allowlisted names, atomic).
|
|
608
|
+
//
|
|
609
|
+
// This is NOT recall. The recall endpoint that previously held this name is
|
|
610
|
+
// now /recent (§Naming). Stale-client tripwire: old recall callers always
|
|
611
|
+
// send project/limit/privacy query params; context-layer callers never do —
|
|
612
|
+
// so those params on GET /context return a loud, self-explaining 400 instead
|
|
613
|
+
// of silently degrading recall to an empty {sections} response.
|
|
614
|
+
//
|
|
615
|
+
// Auth is the standard model (bearer; localhost bypass) via the shared
|
|
616
|
+
// middleware — no special-casing here.
|
|
617
|
+
// -------------------------------------------------------------------------
|
|
618
|
+
// Thin adapters: all logic (tripwire, validation, allowlist, atomicity,
|
|
619
|
+
// symlink safety, size warn) lives in the pure handlers in context-store.ts,
|
|
620
|
+
// which the tests exercise directly — no mirror-app drift.
|
|
621
|
+
app.get("/context", (req, res) => {
|
|
622
|
+
try {
|
|
623
|
+
const r = (0, context_store_js_1.handleContextGet)((0, node_path_1.join)(stateDir, "context"), contextClients, req.query);
|
|
624
|
+
res.status(r.status).json(r.body);
|
|
625
|
+
}
|
|
626
|
+
catch (err) {
|
|
627
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
app.put("/context", (req, res) => {
|
|
631
|
+
try {
|
|
632
|
+
const r = (0, context_store_js_1.handleContextPut)((0, node_path_1.join)(stateDir, "context"), req.body);
|
|
633
|
+
if (r.warn)
|
|
634
|
+
console.warn(`[hicortex] ${r.warn}`);
|
|
635
|
+
res.status(r.status).json(r.body);
|
|
636
|
+
}
|
|
637
|
+
catch (err) {
|
|
638
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
639
|
+
}
|
|
640
|
+
});
|
|
589
641
|
// REST /distill — canonical capture endpoint (0.9.0+).
|
|
590
642
|
// Every machine (including the server itself) POSTs denoised session text here.
|
|
591
643
|
// The server distills, embeds, stores. Body limit: 25 MB (raised at app init).
|
|
@@ -822,7 +874,9 @@ async function startServer(options = {}) {
|
|
|
822
874
|
return;
|
|
823
875
|
}
|
|
824
876
|
const rawLimit = req.query.limit ? Number(req.query.limit) : undefined;
|
|
825
|
-
|
|
877
|
+
// Floor + minimum 1: negative/fractional values would otherwise reach SQL
|
|
878
|
+
// LIMIT (negative = unlimited in SQLite; fractional = binding error).
|
|
879
|
+
const resultLimit = rawLimit && Number.isFinite(rawLimit) && rawLimit >= 1 ? Math.floor(rawLimit) : 10;
|
|
826
880
|
const filterDomain = typeof req.query.domain === "string" && req.query.domain ? req.query.domain : undefined;
|
|
827
881
|
const filterRelationship = typeof req.query.relationship === "string" && req.query.relationship ? req.query.relationship : undefined;
|
|
828
882
|
try {
|
|
@@ -878,8 +932,9 @@ async function startServer(options = {}) {
|
|
|
878
932
|
}
|
|
879
933
|
minStrength = v;
|
|
880
934
|
}
|
|
881
|
-
// Export has its own default (
|
|
882
|
-
// 10 is for neighbors/hubs. exportGraph clamps
|
|
935
|
+
// Export has its own default (EXPORT_DEFAULT_LIMIT) — the shared
|
|
936
|
+
// resultLimit default of 10 is for neighbors/hubs. exportGraph clamps
|
|
937
|
+
// to EXPORT_MAX_LIMIT.
|
|
883
938
|
const exportLimit = rawLimit && Number.isFinite(rawLimit) ? rawLimit : graph_js_1.EXPORT_DEFAULT_LIMIT;
|
|
884
939
|
res.json((0, graph_js_1.exportGraph)(db, {
|
|
885
940
|
domain: filterDomain,
|
|
@@ -913,6 +968,16 @@ async function startServer(options = {}) {
|
|
|
913
968
|
// (static third-party code from the npm tarball, no data) — the exemption
|
|
914
969
|
// lives in createAuthMiddleware next to the /viz one.
|
|
915
970
|
app.get("/viz/vendor/:file", (0, viz_js_1.vizVendorHandler)());
|
|
971
|
+
// GET /context/ui — standing-context editor page (0.12, spec 2026-07-12 §5).
|
|
972
|
+
//
|
|
973
|
+
// The PRIMARY edit surface for the context layer. Self-contained HTML (inline
|
|
974
|
+
// CSS/JS, zero external requests) served from assets/context.html; builds one
|
|
975
|
+
// tab per section from GET /context and saves via PUT /context. The page
|
|
976
|
+
// SHELL is public (exempted in createAuthMiddleware, like /viz — it carries
|
|
977
|
+
// no data); the GET/PUT /context data calls stay bearer-only (localhost
|
|
978
|
+
// bypass). The page collects the token client-side: ?token= URL param
|
|
979
|
+
// (stripped on load) or an in-page prompt on 401, persisted in localStorage.
|
|
980
|
+
app.get("/context/ui", (0, viz_js_1.contextUiHandler)());
|
|
916
981
|
// SSE endpoint — each connection gets its own McpServer + transport
|
|
917
982
|
app.get("/sse", async (req, res) => {
|
|
918
983
|
const transport = new sse_js_1.SSEServerTransport("/messages", res);
|