@gamaze/hicortex 0.12.1 → 0.13.1

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/distiller.js CHANGED
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.detectChunkSize = detectChunkSize;
9
9
  exports.extractConversationText = extractConversationText;
10
10
  exports.distillSession = distillSession;
11
+ exports.hasMinimalSubstance = hasMinimalSubstance;
11
12
  const prompts_js_1 = require("./prompts.js");
12
13
  const redact_js_1 = require("./redact.js");
13
14
  const MAX_TRANSCRIPT_CHARS = 80_000;
@@ -215,8 +216,12 @@ function extractConversationText(messages, redactionConfig) {
215
216
  * Send filtered conversation to LLM for knowledge extraction.
216
217
  * For large transcripts, chunks into segments to avoid overwhelming small models.
217
218
  * Returns an array of memory entries to ingest, or empty array if nothing worth extracting.
219
+ *
220
+ * `droppedOut`, when provided, is filled with every entry the substance gate
221
+ * discarded (full text). Callers use it to build a durable audit trail (#156);
222
+ * omitting it leaves gate behaviour unchanged.
218
223
  */
219
- async function distillSession(llm, conversation, projectName, date, chunkSizeChars) {
224
+ async function distillSession(llm, conversation, projectName, date, chunkSizeChars, droppedOut) {
220
225
  if (conversation.length < MIN_CONVERSATION_CHARS) {
221
226
  return [];
222
227
  }
@@ -229,7 +234,10 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
229
234
  const chunkSize = chunkSizeChars ?? MAX_TRANSCRIPT_CHARS;
230
235
  // If transcript fits in one chunk, distill directly (errors propagate)
231
236
  if (transcript.length <= chunkSize) {
232
- return distillChunk(llm, transcript, projectName, date);
237
+ const { entries, dropped } = await distillChunk(llm, transcript, projectName, date);
238
+ if (droppedOut)
239
+ droppedOut.push(...dropped);
240
+ return entries;
233
241
  }
234
242
  // Chunk large transcripts and distill each segment.
235
243
  //
@@ -248,7 +256,9 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
248
256
  for (let i = 0; i < chunks.length; i++) {
249
257
  console.log(`[hicortex] Chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
250
258
  try {
251
- const entries = await distillChunk(llm, chunks[i], projectName, date);
259
+ const { entries, dropped } = await distillChunk(llm, chunks[i], projectName, date);
260
+ if (droppedOut)
261
+ droppedOut.push(...dropped);
252
262
  for (const entry of entries) {
253
263
  // Deduplicate by normalized content
254
264
  const key = entry.toLowerCase().replace(/\s+/g, " ").slice(0, 100);
@@ -279,13 +289,17 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
279
289
  * Distill a single chunk of conversation text.
280
290
  *
281
291
  * Behaviour contract:
282
- * - Returns `[]` for legitimate empty results (NO_EXTRACT, empty LLM response,
283
- * transcript produced no entries). These are terminal states — the chunk was
284
- * processed successfully, there's just nothing worth keeping.
292
+ * - Returns `{entries: [], dropped: []}` for legitimate empty results
293
+ * (NO_EXTRACT, empty LLM response, transcript produced no entries). These are
294
+ * terminal states — the chunk was processed successfully, there's just
295
+ * nothing worth keeping.
285
296
  * - Throws for transient errors (LLM unreachable, HTTP 4xx/5xx, timeout, model
286
297
  * not found, rate limit). These MUST propagate so the nightly pipeline can
287
298
  * distinguish "nothing to extract" from "try again later" and avoid
288
299
  * advancing the last-run watermark past sessions it never actually processed.
300
+ *
301
+ * `dropped` carries entries the substance gate rejected (full text) so the
302
+ * caller can surface them in a durable audit trail (#156).
289
303
  */
290
304
  async function distillChunk(llm, transcript, projectName, date) {
291
305
  const prompt = (0, prompts_js_1.distillation)(projectName, date, transcript);
@@ -295,11 +309,24 @@ async function distillChunk(llm, transcript, projectName, date) {
295
309
  // "processed successfully with zero extractions".
296
310
  const result = await llm.completeDistill(prompt);
297
311
  if (!result)
298
- return [];
312
+ return { entries: [], dropped: [] };
299
313
  if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
300
- return [];
314
+ return { entries: [], dropped: [] };
301
315
  }
302
- return parseDistilledEntries(result);
316
+ const parsed = parseDistilledEntries(result);
317
+ const entries = [];
318
+ const dropped = [];
319
+ for (const entry of parsed) {
320
+ (hasMinimalSubstance(entry) ? entries : dropped).push(entry);
321
+ }
322
+ if (dropped.length > 0) {
323
+ for (const d of dropped) {
324
+ const preview = d.length > 120 ? `${d.slice(0, 120)}…` : d;
325
+ console.log(`[hicortex] Substance gate: dropped "${preview}"`);
326
+ }
327
+ console.log(`[hicortex] Substance gate: dropped ${dropped.length}/${parsed.length} content-free fragment(s)`);
328
+ }
329
+ return { entries, dropped };
303
330
  }
304
331
  /**
305
332
  * Split transcript text into chunks at natural boundaries (double newlines).
@@ -330,6 +357,51 @@ function splitIntoChunks(text, maxChars) {
330
357
  }
331
358
  return chunks.filter((c) => c.length >= MIN_CONVERSATION_CHARS);
332
359
  }
360
+ // Entries longer than this trivially carry substance; the cap short-circuits
361
+ // the checks below and bounds every regex to a small input, so no pathological
362
+ // input can make the gate expensive (#156).
363
+ const MAX_GATE_LENGTH = 2000;
364
+ /**
365
+ * Reject ONLY structurally-empty distiller fragments before they become
366
+ * memories (#156). The distiller occasionally emits leftovers that parse into
367
+ * entries but carry no recallable content:
368
+ * - bare section prefixes: "[Specific AI Content:]", "[Facts Learned]"
369
+ * - echoed template placeholders: "[decision]: [reasoning] (2026-07-05)"
370
+ * - pseudo-header bullets: "**Facts Learned:**"
371
+ * - metadata-only lines: "(2026-07-05)"
372
+ *
373
+ * PRECISION OVER RECALL — deliberate trade: the gate rejects only shapes that
374
+ * are structurally empty of content, never on a length or word-count threshold.
375
+ * A kept artifact ("Classification: WORK" style) is cheaply pruned later by the
376
+ * no-fit decay path; a wrongly-dropped genuine memory is unrecoverable. So when
377
+ * in doubt, keep. Consequence documented for the reviewer: metadata lines like
378
+ * "Classification: WORK" now PASS the gate — that is intended.
379
+ *
380
+ * Stripping is scoped and anchored (one leading section prefix, one trailing
381
+ * date stamp), never global, so bracketed payloads ("use [ollama] not
382
+ * [claude-cli]") and content-bearing dates ("deadline moved (2026-08-01)")
383
+ * survive. Stripping affects only this gate's decision, never stored text.
384
+ */
385
+ function hasMinimalSubstance(entry) {
386
+ const raw = entry.trim();
387
+ if (raw.length > MAX_GATE_LENGTH)
388
+ return true;
389
+ // Strip ONE leading section prefix (anchored + length-bounded, never global).
390
+ let body = raw.replace(/^\[[^\]]{0,80}\]\s*/, "");
391
+ // Strip ONE trailing date stamp (anchored to end).
392
+ body = body.replace(/\(\s*\d{4}-\d{2}-\d{2}\s*\)\s*$/, "");
393
+ // Markdown decoration.
394
+ body = body.replace(/[*_`#>]/g, " ").trim();
395
+ if (!body)
396
+ return false; // metadata-only line or bare section prefix
397
+ if (/:$/.test(body))
398
+ return false; // pseudo-header: "Facts Learned:"
399
+ // Pure placeholder echo — nothing but bracketed tokens and separators,
400
+ // e.g. "[decision]: [reasoning]".
401
+ if (/^(?:\[[^\]]{0,80}\]|[\s:.,;–-])+$/.test(body))
402
+ return false;
403
+ return true;
404
+ }
333
405
  /**
334
406
  * Parse distilled markdown into individual memory entry strings.
335
407
  * Each section item becomes a separate memory.
package/dist/index.js CHANGED
@@ -23,6 +23,8 @@ const paths_js_1 = require("./paths.js");
23
23
  const features_js_1 = require("./features.js");
24
24
  const extensions_js_1 = require("./extensions.js");
25
25
  const state_js_1 = require("./state.js");
26
+ const context_store_js_1 = require("./context-store.js");
27
+ const lessons_context_js_1 = require("./lessons-context.js");
26
28
  const node_fs_1 = require("node:fs");
27
29
  const node_path_1 = require("node:path");
28
30
  const node_os_1 = require("node:os");
@@ -31,7 +33,10 @@ const node_os_1 = require("node:os");
31
33
  // ---------------------------------------------------------------------------
32
34
  const DEFAULT_SERVER_URL = "http://127.0.0.1:8787";
33
35
  const LESSONS_TIMEOUT_MS = 3000;
36
+ const CONTEXT_TIMEOUT_MS = 3000;
34
37
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
38
+ /** Harness name this plugin injects for — used to self-gate on GET /context `clients`. */
39
+ const THIS_HARNESS = "oc";
35
40
  // ---------------------------------------------------------------------------
36
41
  // Module state — initialized in registerService.start()
37
42
  // ---------------------------------------------------------------------------
@@ -95,6 +100,54 @@ async function serverPost(path, body, timeoutMs) {
95
100
  }
96
101
  }
97
102
  // ---------------------------------------------------------------------------
103
+ // Context layer (L2) — per-agent standing context (0.13)
104
+ // ---------------------------------------------------------------------------
105
+ /**
106
+ * Fetch GET /context (per-agent when an id is supplied) and build the
107
+ * `## Context` block via the shared gate (gateAndRenderContext), or null when
108
+ * nothing should be injected. The old-server guard is required only when an
109
+ * agent id was actually sent (amendment A2 — a bare fetch skips it). The server
110
+ * does the merge; the plugin stays dumb (no client-side mode logic).
111
+ */
112
+ async function fetchOcContextBlock(agentId) {
113
+ const path = agentId ? `/context?agent=${encodeURIComponent(agentId)}` : "/context";
114
+ const { data } = await serverGet(path, CONTEXT_TIMEOUT_MS);
115
+ if (!data)
116
+ return null;
117
+ return (0, lessons_context_js_1.gateAndRenderContext)(data, THIS_HARNESS, { requireAgentEcho: agentId !== null });
118
+ }
119
+ /**
120
+ * Fetch /lessons and build the `## Hicortex Lessons` block, or null on any
121
+ * failure or when no lessons survive selection. Preserves the pre-0.13 lesson
122
+ * output; the caller prepends the `## Context` block and adds separators.
123
+ */
124
+ async function buildLessonsBlock(project) {
125
+ const { data } = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
126
+ if (!data || !data.lessons || data.lessons.length === 0)
127
+ return null;
128
+ const maxLessons = (0, features_js_1.lessonsLimit)();
129
+ const state = (0, state_js_1.loadState)(hicortexHome);
130
+ const moduleIndex = data.moduleIndex ?? state.moduleIndex;
131
+ const selected = await (0, extensions_js_1.getLessonSelector)().select(data.lessons, {
132
+ maxLessons,
133
+ project,
134
+ moduleIndex,
135
+ });
136
+ if (selected.length === 0)
137
+ return null;
138
+ const formatted = selected.map((l) => {
139
+ const titleMatch = l.content.match(/## Lesson: (.+)/);
140
+ const typeMatch = l.content.match(/\*\*Type:\*\* (\w+)/);
141
+ const severityMatch = l.content.match(/\*\*Severity:\*\* (\w+)/);
142
+ const title = titleMatch ? titleMatch[1] : l.content.slice(0, 150);
143
+ const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
144
+ return `- ${title}${meta ? ` (${meta})` : ""}`;
145
+ });
146
+ return (`## Hicortex Lessons (auto-injected from long-term memory)\n` +
147
+ `These are actionable lessons learned from past sessions:\n\n` +
148
+ formatted.join("\n"));
149
+ }
150
+ // ---------------------------------------------------------------------------
98
151
  // Tool result formatter
99
152
  // ---------------------------------------------------------------------------
100
153
  function formatToolResults(results) {
@@ -158,40 +211,32 @@ exports.default = {
158
211
  },
159
212
  });
160
213
  // -----------------------------------------------------------------------
161
- // Hook: before_agent_start — fetch lessons from server (fail-soft)
214
+ // Hook: before_agent_start — fetch context + lessons from server (fail-soft)
162
215
  // -----------------------------------------------------------------------
163
216
  api.on("before_agent_start", async (_event, ctx) => {
217
+ // Outer guard: the hook must NEVER throw (a rejection could block the
218
+ // agent). `ctx` itself can be nullish on some gateway variants, and the
219
+ // synchronous sanitize below runs before any per-fetch .catch — so the
220
+ // whole body is wrapped, not just the fetches.
164
221
  try {
165
- // One fetch build context and check cap from the same response.
166
- const { data } = await serverGet("/lessons", LESSONS_TIMEOUT_MS);
167
- if (!data || !data.lessons || data.lessons.length === 0)
168
- return {};
169
- const maxLessons = (0, features_js_1.lessonsLimit)();
170
- const state = (0, state_js_1.loadState)(hicortexHome);
171
- const moduleIndex = data.moduleIndex ?? state.moduleIndex;
172
- const selected = await (0, extensions_js_1.getLessonSelector)().select(data.lessons, {
173
- maxLessons,
174
- project: ctx.project,
175
- moduleIndex,
176
- });
177
- if (selected.length === 0)
222
+ // Per-agent context id: sanitize the OC agent id (a symbols-only id
223
+ // sanitizes to null bare /context → global set). Null id never sends
224
+ // ?agent=, so an old server behaves exactly as before.
225
+ const agentId = (0, context_store_js_1.sanitizeAgentId)(ctx?.agentId ?? "");
226
+ // Fetch both concurrently with INDEPENDENT fail-soft: a /context
227
+ // failure must never cost the lessons block, and vice versa. The
228
+ // `## Context` block (standing context, 0.13) is prepended before
229
+ // `## Hicortex Lessons`, mirroring the CC hook.
230
+ const [contextBlock, lessonsBlock] = await Promise.all([
231
+ fetchOcContextBlock(agentId).catch(() => null),
232
+ buildLessonsBlock(ctx?.project).catch(() => null),
233
+ ]);
234
+ const blocks = [contextBlock, lessonsBlock].filter((b) => b !== null && b !== "");
235
+ if (blocks.length === 0)
178
236
  return {};
179
- const formatted = selected.map((l) => {
180
- const titleMatch = l.content.match(/## Lesson: (.+)/);
181
- const typeMatch = l.content.match(/\*\*Type:\*\* (\w+)/);
182
- const severityMatch = l.content.match(/\*\*Severity:\*\* (\w+)/);
183
- const title = titleMatch ? titleMatch[1] : l.content.slice(0, 150);
184
- const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
185
- return `- ${title}${meta ? ` (${meta})` : ""}`;
186
- });
187
- const context = `\n\n## Hicortex Lessons (auto-injected from long-term memory)\n` +
188
- `These are actionable lessons learned from past sessions:\n\n` +
189
- formatted.join("\n") +
190
- "\n";
191
- return { appendSystemContext: context };
237
+ return { appendSystemContext: `\n\n${blocks.join("\n\n")}\n` };
192
238
  }
193
239
  catch {
194
- // Fail-soft — a broken lessons fetch must not block the agent
195
240
  return {};
196
241
  }
197
242
  });
package/dist/init.d.ts CHANGED
@@ -16,12 +16,27 @@
16
16
  * - Install CC custom commands (/learn, /hicortex-activate)
17
17
  */
18
18
  import type { DomainDef } from "./types.js";
19
+ /**
20
+ * Classify `claude mcp list` output for the hicortex entry. Pure (testable):
21
+ * - "missing" — hicortex not listed → registration didn't take
22
+ * - "connected" — listed AND reachable (✓/✔/Connected shown)
23
+ * - "registered" — listed but connection not confirmed (server down / not restarted)
24
+ */
25
+ export declare function parseMcpListStatus(mcpListOutput: string): "connected" | "registered" | "missing";
19
26
  /**
20
27
  * Parse a KEY=VALUE env file (e.g. ~/.hermes/.env or ~/.claude/settings.json env block).
21
28
  * Handles: comments (#), quoted values, empty lines.
22
29
  * Exported for testability.
23
30
  */
24
31
  export declare function parseEnvFile(content: string): Record<string, string>;
32
+ /**
33
+ * True when an LLM is already persisted and `init` must NOT re-run provider
34
+ * selection: a named/flat backend, a flat baseUrl+apiKey pair, OR a nested-only
35
+ * `models.score` (model or baseUrl). The last clause (0.13.1) stops init from
36
+ * walking a nested-only config back through selection and writing flat keys that
37
+ * a `models.score` would then silently shadow (nested > flat).
38
+ */
39
+ export declare function isLlmConfigured(config: Record<string, unknown>): boolean;
25
40
  /**
26
41
  * Generate a random auth token in the format hctx-<32 hex chars>.
27
42
  * Exported for testability.
@@ -37,6 +52,29 @@ export declare function persistAuthToken(configPath: string): {
37
52
  token: string;
38
53
  generated: boolean;
39
54
  };
55
+ /**
56
+ * Decide the per-agent context id to persist at init (#179; CC default = global,
57
+ * owner decision 20.07.2026). `agentName` is an explicit opt-in only — there is
58
+ * NO hostname default, so an install with no `--agent-name` sends no `?agent=`
59
+ * and shares the global context (one user = one identity across machines).
60
+ *
61
+ * Empty string == unset everywhere: `--agent-name ""` (or whitespace-only) is
62
+ * the explicit way to opt BACK OUT — it CLEARS any existing `agentName` key and
63
+ * returns to global, rather than erroring as an invalid id.
64
+ * - explicit `--agent-name <non-empty>` → the sanitized flag (error if it
65
+ * sanitizes to null, so a bad flag is loud rather than silently ignored);
66
+ * - explicit `--agent-name ""` / whitespace-only → `clear` (remove the key);
67
+ * - no flag but an existing non-empty `agentName` → keep it untouched
68
+ * (non-clobber like persistAuthToken / scaffoldDefaultDomains);
69
+ * - no flag, no (non-empty) existing value → do not write (global by default).
70
+ * Pure + exported for testability; never touches disk.
71
+ */
72
+ export declare function decideAgentName(existing: unknown, flag: string | undefined): {
73
+ write: boolean;
74
+ value: string | null;
75
+ clear?: boolean;
76
+ error?: string;
77
+ };
40
78
  /**
41
79
  * Generic default memory domains scaffolded by server-mode init (issue #150).
42
80
  * Deliberately broad, high-level spheres — an editable STARTING POINT, not a
@@ -83,6 +121,7 @@ export declare function isEphemeralNpxPath(binPath: string): boolean;
83
121
  export declare function installSessionStartHook(settingsPath?: string): void;
84
122
  export declare function runInit(options?: {
85
123
  serverUrl?: string;
124
+ agentName?: string;
86
125
  }): Promise<void>;
87
126
  /**
88
127
  * Resolve the nightly hour (0–23, local time) for the generated schedule.
package/dist/init.js CHANGED
@@ -18,9 +18,12 @@
18
18
  */
19
19
  Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.GENERIC_DEFAULT_DOMAINS = void 0;
21
+ exports.parseMcpListStatus = parseMcpListStatus;
21
22
  exports.parseEnvFile = parseEnvFile;
23
+ exports.isLlmConfigured = isLlmConfigured;
22
24
  exports.generateAuthToken = generateAuthToken;
23
25
  exports.persistAuthToken = persistAuthToken;
26
+ exports.decideAgentName = decideAgentName;
24
27
  exports.scaffoldDefaultDomains = scaffoldDefaultDomains;
25
28
  exports.isEphemeralNpxPath = isEphemeralNpxPath;
26
29
  exports.installSessionStartHook = installSessionStartHook;
@@ -34,6 +37,7 @@ const node_child_process_1 = require("node:child_process");
34
37
  const node_readline_1 = require("node:readline");
35
38
  const node_crypto_1 = require("node:crypto");
36
39
  const claude_md_js_1 = require("./claude-md.js");
40
+ const context_store_js_1 = require("./context-store.js");
37
41
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
38
42
  const CC_SETTINGS = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "settings.json");
39
43
  const CC_COMMANDS_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "commands");
@@ -157,6 +161,54 @@ function registerCcMcp(serverUrl) {
157
161
  }
158
162
  // Add MCP tool permissions to settings.json so users don't get prompted
159
163
  allowHicortexTools();
164
+ // Post-install verification (finding #5): a registration that wrote files
165
+ // but didn't actually take can still look "done". Best-effort confirm and,
166
+ // either way, tell the user exactly how to check.
167
+ verifyCcMcp();
168
+ }
169
+ /**
170
+ * Classify `claude mcp list` output for the hicortex entry. Pure (testable):
171
+ * - "missing" — hicortex not listed → registration didn't take
172
+ * - "connected" — listed AND reachable (✓/✔/Connected shown)
173
+ * - "registered" — listed but connection not confirmed (server down / not restarted)
174
+ */
175
+ function parseMcpListStatus(mcpListOutput) {
176
+ // `claude mcp list` prints one line per server: "hicortex: <url> (SSE) - <status>".
177
+ // Anchor on the "hicortex:" line prefix (not a bare word match) so a
178
+ // differently-named MCP like "hicortex-foo" can't be mistaken for our entry,
179
+ // and read status from THAT line only.
180
+ const line = mcpListOutput.split(/\r?\n/).find((l) => /^\s*hicortex:/.test(l));
181
+ if (!line)
182
+ return "missing";
183
+ return /(✓|✔|connected)/i.test(line) ? "connected" : "registered";
184
+ }
185
+ /**
186
+ * Best-effort post-install check that the hicortex MCP is actually registered
187
+ * (and, when reachable, connected). NEVER fails init — if the claude CLI is
188
+ * absent (e.g. registration went via the ~/.claude.json fallback), we can't
189
+ * query it, so we just tell the user how to confirm. Closes the "looks
190
+ * configured but isn't, with no verification" gap.
191
+ */
192
+ function verifyCcMcp() {
193
+ let out;
194
+ try {
195
+ out = (0, node_child_process_1.execSync)("claude mcp list", { encoding: "utf-8", stdio: "pipe" });
196
+ }
197
+ catch {
198
+ console.log(" ℹ After restarting Claude Code, confirm with `claude mcp list` (hicortex should show ✓ Connected).");
199
+ return;
200
+ }
201
+ switch (parseMcpListStatus(out)) {
202
+ case "connected":
203
+ console.log(" ✓ Verified: hicortex MCP registered and connected");
204
+ break;
205
+ case "registered":
206
+ console.log(" ✓ Verified: hicortex MCP registered — restart Claude Code, then `claude mcp list` should show it Connected");
207
+ break;
208
+ case "missing":
209
+ console.log(" ⚠ Could NOT confirm the hicortex MCP registration — run `claude mcp list`; if it's absent, re-run init.");
210
+ break;
211
+ }
160
212
  }
161
213
  function allowHicortexTools() {
162
214
  let settings = {};
@@ -442,6 +494,18 @@ function mergeByKey(candidates) {
442
494
  }
443
495
  return [...seen.values()];
444
496
  }
497
+ /**
498
+ * True when an LLM is already persisted and `init` must NOT re-run provider
499
+ * selection: a named/flat backend, a flat baseUrl+apiKey pair, OR a nested-only
500
+ * `models.score` (model or baseUrl). The last clause (0.13.1) stops init from
501
+ * walking a nested-only config back through selection and writing flat keys that
502
+ * a `models.score` would then silently shadow (nested > flat).
503
+ */
504
+ function isLlmConfigured(config) {
505
+ const modelsScore = config.models?.score;
506
+ const hasModelsScore = Boolean(modelsScore?.model || modelsScore?.baseUrl);
507
+ return Boolean(config.llmBackend || (config.llmApiKey && config.llmBaseUrl) || hasModelsScore);
508
+ }
445
509
  /**
446
510
  * Detect or ask for LLM config and persist to ~/.hicortex/config.json.
447
511
  * The daemon can't inherit shell env vars, so we persist here.
@@ -457,8 +521,8 @@ async function persistLlmConfig() {
457
521
  config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
458
522
  }
459
523
  catch { /* new file */ }
460
- // Don't overwrite if LLM config already persisted
461
- if (config.llmBackend || (config.llmApiKey && config.llmBaseUrl)) {
524
+ // Don't overwrite if LLM config already persisted (incl. a nested-only config).
525
+ if (isLlmConfigured(config)) {
462
526
  console.log(` ✓ LLM config already configured`);
463
527
  return;
464
528
  }
@@ -701,6 +765,68 @@ function persistAuthToken(configPath) {
701
765
  (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
702
766
  return { token, generated: true };
703
767
  }
768
+ /**
769
+ * Decide the per-agent context id to persist at init (#179; CC default = global,
770
+ * owner decision 20.07.2026). `agentName` is an explicit opt-in only — there is
771
+ * NO hostname default, so an install with no `--agent-name` sends no `?agent=`
772
+ * and shares the global context (one user = one identity across machines).
773
+ *
774
+ * Empty string == unset everywhere: `--agent-name ""` (or whitespace-only) is
775
+ * the explicit way to opt BACK OUT — it CLEARS any existing `agentName` key and
776
+ * returns to global, rather than erroring as an invalid id.
777
+ * - explicit `--agent-name <non-empty>` → the sanitized flag (error if it
778
+ * sanitizes to null, so a bad flag is loud rather than silently ignored);
779
+ * - explicit `--agent-name ""` / whitespace-only → `clear` (remove the key);
780
+ * - no flag but an existing non-empty `agentName` → keep it untouched
781
+ * (non-clobber like persistAuthToken / scaffoldDefaultDomains);
782
+ * - no flag, no (non-empty) existing value → do not write (global by default).
783
+ * Pure + exported for testability; never touches disk.
784
+ */
785
+ function decideAgentName(existing, flag) {
786
+ if (flag !== undefined) {
787
+ // Explicit empty / whitespace-only value → clear back to global (unset).
788
+ if (flag.trim() === "")
789
+ return { write: false, value: null, clear: true };
790
+ const s = (0, context_store_js_1.sanitizeAgentId)(flag);
791
+ if (s === null) {
792
+ return {
793
+ write: false,
794
+ value: null,
795
+ error: `Invalid --agent-name '${flag}'. Must contain letters or digits and sanitize to ^[a-z0-9][a-z0-9_-]*$ (max 64 chars). Pass --agent-name "" to clear it (global context).`,
796
+ };
797
+ }
798
+ return { write: true, value: s };
799
+ }
800
+ if (typeof existing === "string" && existing.trim().length > 0)
801
+ return { write: false, value: existing };
802
+ return { write: false, value: null };
803
+ }
804
+ /** Read config.json, set agentName, write it back. Used by the server path. */
805
+ function writeAgentNameConfig(configPath, value) {
806
+ let config = {};
807
+ try {
808
+ config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
809
+ }
810
+ catch { /* new / unreadable → start fresh */ }
811
+ config.agentName = value;
812
+ (0, node_fs_1.mkdirSync)(HICORTEX_HOME, { recursive: true });
813
+ (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
814
+ }
815
+ /** Read config.json, delete any `agentName` key, write it back (server path). */
816
+ function clearAgentNameConfig(configPath) {
817
+ let config = {};
818
+ try {
819
+ config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
820
+ }
821
+ catch {
822
+ return; /* new / unreadable → nothing to clear */
823
+ }
824
+ if (!("agentName" in config))
825
+ return;
826
+ delete config.agentName;
827
+ (0, node_fs_1.mkdirSync)(HICORTEX_HOME, { recursive: true });
828
+ (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
829
+ }
704
830
  /**
705
831
  * Generic default memory domains scaffolded by server-mode init (issue #150).
706
832
  * Deliberately broad, high-level spheres — an editable STARTING POINT, not a
@@ -994,7 +1120,7 @@ async function ask(question) {
994
1120
  // ---------------------------------------------------------------------------
995
1121
  async function runInit(options = {}) {
996
1122
  if (options.serverUrl) {
997
- await runClientInit(options.serverUrl);
1123
+ await runClientInit(options.serverUrl, options.agentName);
998
1124
  return;
999
1125
  }
1000
1126
  console.log("Hicortex — Setup for Claude Code\n");
@@ -1076,6 +1202,25 @@ async function runInit(options = {}) {
1076
1202
  // Classification activates automatically once an LLM is configured; until
1077
1203
  // then domains sit inert (strict-skip path).
1078
1204
  scaffoldDefaultDomains(configPath);
1205
+ // Per-agent context id (#179): server mode writes it ONLY when the operator
1206
+ // passes --agent-name. Without the flag no agentName is written and the
1207
+ // co-located CC shares the global context (global by default). Explicit flag
1208
+ // overwrites on re-init; `--agent-name ""` clears it back to global.
1209
+ if (options.agentName !== undefined) {
1210
+ const decision = decideAgentName(undefined, options.agentName);
1211
+ if (decision.error) {
1212
+ console.error(` ✗ ${decision.error}`);
1213
+ process.exit(1);
1214
+ }
1215
+ if (decision.clear) {
1216
+ clearAgentNameConfig(configPath);
1217
+ console.log(" ✓ Agent name cleared — global context");
1218
+ }
1219
+ else if (decision.write && decision.value) {
1220
+ writeAgentNameConfig(configPath, decision.value);
1221
+ console.log(` ✓ Agent name set to '${decision.value}'`);
1222
+ }
1223
+ }
1079
1224
  // Install the nightly job (capture via localhost /distill + consolidation).
1080
1225
  // Without it a server-mode install never captures or consolidates — the
1081
1226
  // daemon only serves recall + /distill. Skips if a schedule already exists.
@@ -1137,7 +1282,7 @@ async function runInit(options = {}) {
1137
1282
  // ---------------------------------------------------------------------------
1138
1283
  // Client Mode Init
1139
1284
  // ---------------------------------------------------------------------------
1140
- async function runClientInit(serverUrl) {
1285
+ async function runClientInit(serverUrl, agentName) {
1141
1286
  console.log("Hicortex — Client Mode Setup\n");
1142
1287
  serverUrl = serverUrl.replace(/\/+$/, "");
1143
1288
  // Step 1: Verify server is reachable
@@ -1216,8 +1361,30 @@ async function runClientInit(serverUrl) {
1216
1361
  config.serverUrl = serverUrl;
1217
1362
  if (authToken)
1218
1363
  config.authToken = authToken;
1364
+ // Per-agent context id (#179): explicit opt-in only. Written ONLY when
1365
+ // --agent-name is passed; otherwise no agentName is set and the client shares
1366
+ // the global context (global by default). Re-init keeps an existing value
1367
+ // unless --agent-name is explicit; `--agent-name ""` clears it back to global.
1368
+ const nameDecision = decideAgentName(config.agentName, agentName);
1369
+ if (nameDecision.error) {
1370
+ console.error(` ✗ ${nameDecision.error}`);
1371
+ process.exit(1);
1372
+ }
1373
+ if (nameDecision.clear) {
1374
+ delete config.agentName;
1375
+ console.log(" ✓ Agent name cleared — global context");
1376
+ }
1377
+ else if (nameDecision.write && nameDecision.value) {
1378
+ config.agentName = nameDecision.value;
1379
+ if (agentName && nameDecision.value !== agentName) {
1380
+ console.log(` ℹ Agent name sanitized to '${nameDecision.value}'`);
1381
+ }
1382
+ }
1219
1383
  saveConfig(configPath, config);
1220
1384
  console.log(` ✓ Client config saved to ${configPath}`);
1385
+ if (typeof config.agentName === "string") {
1386
+ console.log(` ✓ Agent name: ${config.agentName}`);
1387
+ }
1221
1388
  // Step 4: Register CC MCP pointing to remote server
1222
1389
  if (authToken) {
1223
1390
  // Write directly with auth header
@@ -21,6 +21,62 @@
21
21
  * parse error) results in silent exit-0. A broken hook must never block a
22
22
  * CC session, and a broken /context fetch must never blank the whole output.
23
23
  */
24
+ /**
25
+ * The GET /context response shape, shared by the CC hook and the OC plugin so
26
+ * their gating cannot drift. `agent`/`mode` are echoed by a 0.13 server whenever
27
+ * `?agent=` was sent (in EVERY mode); a pre-0.13 server omits them.
28
+ */
29
+ export interface ContextResponse {
30
+ sections?: Record<string, string>;
31
+ updated_at?: string;
32
+ clients?: string[];
33
+ agent?: string;
34
+ mode?: string;
35
+ }
36
+ /**
37
+ * Title-case a section name for its heading: split on `-`/`_`, capitalize each
38
+ * word ("user" → "User", "my_notes" → "My Notes").
39
+ * Exported so the OC plugin (index.ts) renders the `## Context` block
40
+ * identically to the CC hook rather than duplicating the logic.
41
+ */
42
+ export declare function titleCaseSection(name: string): string;
43
+ /**
44
+ * Stable section ordering: `user` first, then `rules` (the seeded primary
45
+ * sections, spec §8), then every other section alphabetically. Server-side
46
+ * enumeration order (readdirSync) is FS-dependent, so we sort here for a
47
+ * deterministic injection block. Exported for reuse by the OC plugin.
48
+ */
49
+ export declare function orderSectionNames(names: string[]): string[];
50
+ /**
51
+ * Render the `## Context` block from a resolved section map, or null when there
52
+ * is nothing to inject (no sections, or every section blank after trimming).
53
+ * Pure — no gating, no I/O. Shared verbatim by the CC hook and the OC plugin so
54
+ * both harnesses emit an identical block. Sections are ordered (user, rules,
55
+ * then alphabetical) and rendered under title-cased `###` headings.
56
+ */
57
+ export declare function renderContextBlock(sections: Record<string, string>): string | null;
58
+ /**
59
+ * Gate a GET /context response and render the `## Context` block, or null when
60
+ * nothing should be injected: `harness` not in the server-resolved `clients`,
61
+ * an empty/blank section set, or — when `requireAgentEcho` — a response that
62
+ * does not echo `agent`. The SINGLE gate used by both CC and OC so the two can
63
+ * never drift (the Python Hermes plugin `provider.py::_context_block` mirrors
64
+ * this logic — keep them in sync).
65
+ *
66
+ * `requireAgentEcho` is the old-server guard, and it is the CALLER's decision:
67
+ * - OC passes `agentId !== null` — when it actually sent an id, a 0.12 server
68
+ * that ignores `?agent=` (200 global, no echo) must NOT leak global context
69
+ * into every persona; on a bare fetch (no id) the guard is off (amendment
70
+ * A2).
71
+ * - CC passes `false` ALWAYS and deliberately (see the call site): a thin CC
72
+ * client auto-upgrades via npx BEFORE bedrock does, so during the upgrade
73
+ * window it talks to a 0.12 server that cannot hold ANY per-agent config —
74
+ * global IS the operator's intended state there, and a guard would instead
75
+ * blank ALL context for every CC session in that window.
76
+ */
77
+ export declare function gateAndRenderContext(data: ContextResponse, harness: string, opts: {
78
+ requireAgentEcho: boolean;
79
+ }): string | null;
24
80
  /**
25
81
  * Fetch context + lessons concurrently and return the combined Markdown block,
26
82
  * or null when neither yields anything (nothing to inject; caller prints