@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/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, /context, /ingest, /lessons
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, /context, /ingest, /lessons
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: "Search failed: server unreachable" };
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: "hicortex_context",
214
- description: "Get recent context memories, optionally filtered by project. Useful to recall what happened recently.",
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(`/context${qs ? `?${qs}` : ""}`, 10000);
247
+ const { data, status } = await serverGet(`/recent${qs ? `?${qs}` : ""}`, 10000);
231
248
  if (!data)
232
- return { error: "Context search failed: server unreachable" };
249
+ return { error: `Recent recall failed: ${describeGetFailure(status, "/recent")}` };
233
250
  return formatToolResults(data.results ?? []);
234
251
  }
235
252
  catch (err) {
236
- return { error: `Context search failed: ${err instanceof Error ? err.message : String(err)}` };
253
+ return { error: `Recent recall failed: ${err instanceof Error ? err.message : String(err)}` };
237
254
  }
238
255
  },
239
- }), { name: "hicortex_context" });
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: "Lessons fetch failed: server unreachable" };
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: "Index fetch failed: server unreachable" };
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: "Graph query failed: server unreachable" };
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
- "hicortex_context",
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, mcp__hicortex__hicortex_context, mcp__hicortex__hicortex_lessons
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, mcp__hicortex__hicortex_context, mcp__hicortex__hicortex_lessons
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/context work).\n" +
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 lessons fetch for the CC SessionStart hook.
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, GETs /lessons,
6
- * and prints a compact Markdown block to stdout so CC picks it up as
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 lessons from the configured server and return a formatted Markdown
15
- * block, or null on any failure (caller should print nothing and exit 0).
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>;
@@ -1,15 +1,26 @@
1
1
  "use strict";
2
2
  /**
3
- * lessons-context — query-time lessons fetch for the CC SessionStart hook.
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, GETs /lessons,
7
- * and prints a compact Markdown block to stdout so CC picks it up as
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
- * Fetch lessons from the configured server and return a formatted Markdown
26
- * block, or null on any failure (caller should print nothing and exit 0).
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
- async function fetchLessonsContext() {
29
- let config = {};
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)(HICORTEX_HOME, "config.json"), "utf-8"));
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
- // Determine server URL: client mode uses serverUrl; server mode uses localhost.
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
- const authToken = config.authToken;
42
- let data;
43
- try {
44
- const resp = await fetch(`${serverUrl}/lessons`, {
45
- headers: authToken ? { "Authorization": `Bearer ${authToken}` } : {},
46
- signal: AbortSignal.timeout(3000),
47
- });
48
- if (!resp.ok)
49
- return null;
50
- data = await resp.json();
51
- }
52
- catch {
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)(HICORTEX_HOME).moduleIndex;
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 `hicortex_context` at session start for recent project state.");
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
+ }
@@ -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
- // -- hicortex_context --
113
- server.tool("hicortex_context", "Get recent context memories, optionally filtered by project. Useful to recall what happened recently.", {
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: `Context search failed: ${err instanceof Error ? err.message : String(err)}` }], isError: true };
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 / context: ENABLED ║");
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 /context — recent context memories, optionally filtered by project.
571
- app.get("/context", (req, res) => {
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
- const resultLimit = rawLimit && Number.isFinite(rawLimit) ? rawLimit : 10;
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 (500) — the shared resultLimit default of
882
- // 10 is for neighbors/hubs. exportGraph clamps to the hard max (2000).
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);