@gamaze/hicortex 0.12.1 → 0.13.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 CHANGED
@@ -129,6 +129,20 @@ Beyond auto-distilled memories and lessons, Hicortex holds a **hand-edited conte
129
129
  - **Delivery:** injected into the harnesses listed in `contextClients` (default `["cc"]` — Claude Code only in v1; `"all"` or any subset of `cc`/`hermes`/`oc`).
130
130
  - **Deletion** is filesystem-only — remove the file on the server (as the daemon user).
131
131
 
132
+ ### Per-agent context (0.13)
133
+
134
+ One server serves a fleet of distinct-persona agents. Each agent can have its **own** context, resolved server-side into one of three modes:
135
+
136
+ - **`override`** (default when an `agents/<id>/` dir exists) — the agent's sections win **per section name**, falling back to the global set for any section it doesn't define.
137
+ - **`global`** — the shared global set (the 0.12 behavior).
138
+ - **`off`** — inject nothing for that agent.
139
+
140
+ - **Agent id:** Hermes and OC scope per profile/agent automatically. **CC is global by default** — it sends no `?agent=`, so all your CC machines share one global context (one user = one identity across machines). `agentName` is an explicit opt-in: set it with `init --agent-name <name>` and CC will send `?agent=<config.agentName>`; clear it with `init --agent-name ""` to return to global. The id is on the same strict allowlist as section names (it becomes a path). Shown by `hicortex status` (or `(not set — global context)` when unset).
141
+ - **Storage:** per-agent sections live at `~/.hicortex/context/agents/<id>/*.md`; the global reader never descends into `agents/`.
142
+ - **Config:** `contextAgents` maps agent id → mode; a dropped-in `agents/<id>/` dir alone means `override` with no config. Editing `contextAgents` needs a daemon restart; dropping in a dir takes effect immediately.
143
+ - **Edit:** the web editor's scope selector (`Global` | `<agent>`; inherited sections shown dimmed), or `hicortex context show|edit --agent <id>`.
144
+ - **Backward compatible:** no `?agent=` and no `contextAgents`/`agents/` dir → every agent gets the global set.
145
+
132
146
  ## CLI Commands
133
147
 
134
148
  ```bash
@@ -141,6 +155,9 @@ npx @gamaze/hicortex nightly --dry-run # Preview without changes
141
155
  npx @gamaze/hicortex classify-domains # Backfill domain tags over the corpus (see Memory Domains & Tags)
142
156
  npx @gamaze/hicortex context show [name] # Print the standing context layer (see Context Layer)
143
157
  npx @gamaze/hicortex context edit <name> # Edit a context section in $EDITOR
158
+ npx @gamaze/hicortex context show --agent <id> # Show a specific agent's resolved context (0.13)
159
+ npx @gamaze/hicortex init --agent-name <name> # Opt in to a per-agent context id (default: unset — shared global context)
160
+ npx @gamaze/hicortex init --agent-name "" # Clear it back to global context
144
161
  npx @gamaze/hicortex status # Show config, DB stats
145
162
  npx @gamaze/hicortex uninstall # Remove CC integration (keeps DB)
146
163
  ```
@@ -186,6 +203,8 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
186
203
  | `weakPrimaryFloor` | Minimum similarity for a no-fit memory to keep a weak domain association (default: 0.45) |
187
204
  | `moduleIndexTokenBudget` | Max tokens for domain index in lessons context (default: 500) |
188
205
  | `contextClients` | Which harnesses inject the [context layer](#context-layer) at session start (default `["cc"]`; `"all"` or any subset of `cc`/`hermes`/`oc`) |
206
+ | `contextAgents` | Per-agent context modes (0.13): `{ "<id>": "override" \| "global" \| "off" }`. Absent + no `agents/<id>/` dir → every agent gets the global set. Boot-time (restart to apply) — see [Per-agent context](#per-agent-context-013) |
207
+ | `agentName` | This install's per-agent context id sent as `?agent=`. **Unset by default** (CC shares the global context — no `?agent=` sent). Explicit opt-in via `init --agent-name <name>`; `init --agent-name ""` clears it. An empty/whitespace value equals unset |
189
208
  | `nightlyHour` | Local hour (0–23) for the nightly job installed by `init` (defaults: client 2, server 3). Applied on fresh installs; existing schedules are never overwritten |
190
209
  | `telemetry` | Anonymous usage telemetry, `false` to opt out |
191
210
 
@@ -199,7 +218,7 @@ Full docs: [hicortex.gamaze.com/docs/configuration.html](https://hicortex.gamaze
199
218
  | `/distill` | POST | Yes | Canonical capture endpoint (0.9.0+). Accepts denoised session text (`text` string or `messages` array), distills server-side, stores. Used by both server-mode and client-mode nightly jobs. |
200
219
  | `/search` | GET | Yes | Semantic memory search |
201
220
  | `/recent` | GET | Yes | Recent memories, queryless recall (renamed from `/context` in 0.12) |
202
- | `/context` | GET / PUT | Yes | Standing [context layer](#context-layer): read all sections / partial-upsert named sections. Recall-style query params on GET → 400 (use `/recent`) |
221
+ | `/context` | GET / PUT | Yes | Standing [context layer](#context-layer): read all sections / partial-upsert named sections. `?agent=<id>` selects a [per-agent scope](#per-agent-context-013) (server resolves override/global/off + merge); invalid id → 400. Recall-style query params on GET → 400 (use `/recent`) |
203
222
  | `/context/ui` | GET | No* | Web editor for the context layer (shell served without auth, like `/viz`; data via `/context`) |
204
223
  | `/lessons` | GET | Yes | Lessons + memory index (used by CC SessionStart hook) |
205
224
  | `/ingest` | POST | Yes | Legacy: accept a single pre-distilled memory from older clients |
@@ -77,6 +77,13 @@
77
77
  header h1 { font-size: 15px; font-weight: 600; white-space: nowrap; }
78
78
  header .meta { color: var(--text-dim); font-size: 12px; white-space: nowrap; }
79
79
  header .spacer { flex: 1; }
80
+ header select, header #newagent {
81
+ background: var(--bg); color: var(--text);
82
+ border: 1px solid var(--panel-border); border-radius: 5px;
83
+ padding: 4px 6px; font: inherit; font-size: 12px;
84
+ }
85
+ header #newagent { width: 120px; }
86
+ header .btn.small { padding: 4px 10px; font-size: 12px; }
80
87
 
81
88
  main { flex: 1; display: flex; flex-direction: column; padding: 14px 18px; gap: 12px; min-height: 0; }
82
89
 
@@ -95,7 +102,9 @@
95
102
  color: var(--text); background: var(--tab-active);
96
103
  border-color: var(--panel-border); border-bottom: 1px solid var(--tab-active);
97
104
  }
105
+ .tab.inherited { opacity: 0.6; font-style: italic; }
98
106
  .tab .dirty { color: var(--accent); margin-left: 5px; }
107
+ .tab .inh { color: var(--text-dim); margin-left: 5px; font-size: 11px; }
99
108
 
100
109
  #addwrap { margin-left: auto; display: flex; gap: 6px; align-items: center; }
101
110
  #addname {
@@ -161,8 +170,13 @@
161
170
  <body>
162
171
  <header>
163
172
  <h1>Hicortex — context</h1>
173
+ <label class="meta" for="scope">scope</label>
174
+ <select id="scope" title="Which agent's context to edit"></select>
175
+ <input type="text" id="newagent" placeholder="new-agent-id" autocomplete="off" spellcheck="false">
176
+ <button class="btn secondary small" id="addagent">Add agent</button>
164
177
  <span class="meta" id="clients"></span>
165
178
  <div class="spacer"></div>
179
+ <span class="meta" id="mode"></span>
166
180
  <span class="meta" id="updated"></span>
167
181
  </header>
168
182
 
@@ -255,6 +269,10 @@
255
269
  var msgEl = document.getElementById("msg");
256
270
  var clientsEl = document.getElementById("clients");
257
271
  var updatedEl = document.getElementById("updated");
272
+ var modeEl = document.getElementById("mode");
273
+ var scopeSel = document.getElementById("scope");
274
+ var newAgentInput = document.getElementById("newagent");
275
+ var addAgentBtn = document.getElementById("addagent");
258
276
  var overlay = document.getElementById("overlay");
259
277
  var tokenInput = document.getElementById("token-input");
260
278
  var tokenErr = document.getElementById("token-err");
@@ -267,6 +285,20 @@
267
285
  var active = null; // active section name
268
286
  var afterAuth = null; // action to retry after a successful token prompt
269
287
 
288
+ // Per-agent scope (0.13). scope = null → the global set; otherwise an agent
289
+ // id, and edits target PUT /context?agent=<id>. `agents` is the id -> mode
290
+ // map from the bare (global) GET; `origins` is the id -> "global"|"agent"
291
+ // provenance from an agent-scope GET (used to dim inherited tabs).
292
+ var scope = null;
293
+ var agents = {};
294
+ var origins = {};
295
+
296
+ // ?agent= query for the active scope. Written so the literal fetch("/context"
297
+ // survives the self-containment test; the server does the resolution.
298
+ function scopeQS() {
299
+ return scope ? "?agent=" + encodeURIComponent(scope) : "";
300
+ }
301
+
270
302
  function setMsg(text, cls) {
271
303
  msgEl.textContent = text || "";
272
304
  msgEl.className = cls || "dim";
@@ -288,9 +320,18 @@
288
320
 
289
321
  order.forEach(function (name) {
290
322
  var b = document.createElement("button");
291
- b.className = "tab" + (name === active ? " active" : "");
323
+ // In an agent scope, a section still coming from the global set is
324
+ // inherited — dim it and mark it. Saving it PUTs to the agent (override).
325
+ var inherited = scope && origins[name] === "global";
326
+ b.className = "tab" + (name === active ? " active" : "") + (inherited ? " inherited" : "");
292
327
  b.type = "button";
293
328
  b.appendChild(document.createTextNode(name));
329
+ if (inherited) {
330
+ var inh = document.createElement("span");
331
+ inh.className = "inh";
332
+ inh.textContent = "(inherited)";
333
+ b.appendChild(inh);
334
+ }
294
335
  if (isDirty(name)) {
295
336
  var dot = document.createElement("span");
296
337
  dot.className = "dirty";
@@ -316,13 +357,33 @@
316
357
  area.focus();
317
358
  }
318
359
 
319
- function updateMeta(clients, updatedAt) {
360
+ function updateMeta(clients, updatedAt, mode) {
320
361
  if (Array.isArray(clients)) {
321
362
  clientsEl.textContent = clients.length ? "clients: " + clients.join(", ") : "clients: none";
322
363
  }
323
364
  if (updatedAt !== undefined) {
324
365
  updatedEl.textContent = updatedAt ? "updated " + updatedAt : "";
325
366
  }
367
+ if (mode !== undefined) {
368
+ modeEl.textContent = mode ? "mode: " + mode : "";
369
+ }
370
+ }
371
+
372
+ // Rebuild the scope <select> from the known agents map. Options are added via
373
+ // textContent only (never innerHTML); the current scope stays selected.
374
+ function populateScopeOptions() {
375
+ while (scopeSel.firstChild) scopeSel.removeChild(scopeSel.firstChild);
376
+ var globalOpt = document.createElement("option");
377
+ globalOpt.value = "";
378
+ globalOpt.textContent = "Global";
379
+ scopeSel.appendChild(globalOpt);
380
+ Object.keys(agents).sort().forEach(function (id) {
381
+ var o = document.createElement("option");
382
+ o.value = id;
383
+ o.textContent = id + " (" + agents[id] + ")";
384
+ scopeSel.appendChild(o);
385
+ });
386
+ scopeSel.value = scope || "";
326
387
  }
327
388
 
328
389
  // =========================================================================
@@ -330,7 +391,7 @@
330
391
  // =========================================================================
331
392
  function load() {
332
393
  setMsg("Loading…", "dim");
333
- fetch("/context", { headers: authHeaders() })
394
+ fetch("/context" + scopeQS(), { headers: authHeaders() })
334
395
  .then(function (resp) {
335
396
  if (resp.status === 401) { afterAuth = load; showTokenPrompt(); throw new Error("unauthorized"); }
336
397
  if (!resp.ok) {
@@ -342,6 +403,11 @@
342
403
  })
343
404
  .then(function (data) {
344
405
  overlay.classList.remove("open");
406
+ // The bare (global) response carries the `agents` map; an agent-scope
407
+ // response carries `origins`. Retain the last-known agents list when the
408
+ // current response omits it so the selector stays populated.
409
+ if (scope === null && data.agents && typeof data.agents === "object") agents = data.agents;
410
+ origins = (data.origins && typeof data.origins === "object") ? data.origins : {};
345
411
  var incoming = data.sections || {};
346
412
  var names = Object.keys(incoming).sort();
347
413
  sections = {};
@@ -353,7 +419,8 @@
353
419
  // Keep the active tab if it still exists, else pick the first.
354
420
  if (!active || order.indexOf(active) < 0) active = order.length ? order[0] : null;
355
421
  if (active) area.value = sections[active].draft;
356
- updateMeta(data.clients, data.updated_at || null);
422
+ updateMeta(data.clients, data.updated_at || null, data.mode || "");
423
+ populateScopeOptions();
357
424
  renderTabs();
358
425
  setMsg(order.length ? "" : "", "dim");
359
426
  })
@@ -382,7 +449,7 @@
382
449
 
383
450
  saveBtn.disabled = true;
384
451
  setMsg("Saving…", "dim");
385
- fetch("/context", {
452
+ fetch("/context" + scopeQS(), {
386
453
  method: "PUT",
387
454
  headers: authHeaders({ "Content-Type": "application/json" }),
388
455
  body: JSON.stringify(payload),
@@ -402,7 +469,13 @@
402
469
  overlay.classList.remove("open");
403
470
  var s = sections[name];
404
471
  if (s) { s.saved = value; s.isNew = false; }
405
- updateMeta(undefined, body.updated_at || null);
472
+ // In an agent scope the just-saved section is now an override (no longer
473
+ // inherited); a first save also registers the agent in the selector.
474
+ if (scope) {
475
+ origins[name] = "agent";
476
+ if (!agents[scope]) { agents[scope] = body.mode || "override"; populateScopeOptions(); }
477
+ }
478
+ updateMeta(undefined, body.updated_at || null, body.mode);
406
479
  setMsg("Saved" + (body.updated_at ? " · " + body.updated_at : ""), "ok");
407
480
  renderTabs();
408
481
  })
@@ -436,6 +509,39 @@
436
509
  setMsg("New section “" + name + "” — edit and Save to create it.", "dim");
437
510
  }
438
511
 
512
+ // =========================================================================
513
+ // Scope switching — reload the selected agent's (or the global) context.
514
+ // Warns before discarding unsaved edits in the current scope.
515
+ // =========================================================================
516
+ function anyDirty() {
517
+ return order.some(function (n) { return isDirty(n); });
518
+ }
519
+
520
+ function switchScope(next) {
521
+ if (anyDirty() && !window.confirm("Discard unsaved changes and switch scope?")) {
522
+ scopeSel.value = scope || "";
523
+ return;
524
+ }
525
+ scope = next || null;
526
+ active = null; // let load() pick the first tab in the new scope
527
+ load();
528
+ }
529
+
530
+ function addAgent() {
531
+ var id = newAgentInput.value.trim();
532
+ if (!validName(id)) {
533
+ setMsg("Invalid agent id — lowercase letters, digits, - or _ (max 64, no leading - or _).", "err");
534
+ newAgentInput.focus();
535
+ return;
536
+ }
537
+ newAgentInput.value = "";
538
+ // The agent dir is created server-side on the first Save; until then the
539
+ // scope resolves to the inherited global set. Register it locally so the
540
+ // selector shows it immediately.
541
+ if (!agents[id]) agents[id] = "override";
542
+ switchScope(id);
543
+ }
544
+
439
545
  // =========================================================================
440
546
  // Token prompt (remote only — localhost bypasses auth)
441
547
  // =========================================================================
@@ -466,6 +572,9 @@
466
572
  saveBtn.addEventListener("click", save);
467
573
  addBtn.addEventListener("click", addSection);
468
574
  addName.addEventListener("keydown", function (e) { if (e.key === "Enter") addSection(); });
575
+ scopeSel.addEventListener("change", function () { switchScope(scopeSel.value); });
576
+ addAgentBtn.addEventListener("click", addAgent);
577
+ newAgentInput.addEventListener("keydown", function (e) { if (e.key === "Enter") addAgent(); });
469
578
  area.addEventListener("input", function () {
470
579
  if (active && sections[active]) sections[active].draft = area.value;
471
580
  renderTabs();
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Tiny, dependency-free argv helpers for cli.ts. Kept in their own module so
3
+ * cli.ts can import them statically without pulling in a heavy command module
4
+ * (its command handlers stay lazily `import()`-ed).
5
+ */
6
+ /** Thrown when a value-flag is present but missing its value. */
7
+ export declare class MissingFlagValueError extends Error {
8
+ }
9
+ /**
10
+ * Read a required value for `flag` from `argv`. Returns undefined when the flag
11
+ * is absent; throws MissingFlagValueError when it is present without a value
12
+ * (end of args) OR the next token is itself a flag (starts with "-"). The
13
+ * latter guard stops `--agent-name --server <url>` from silently swallowing
14
+ * `--server` as the agent name — a typo must be loud.
15
+ */
16
+ export declare function readValueFlag(argv: string[], flag: string): string | undefined;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ /**
3
+ * Tiny, dependency-free argv helpers for cli.ts. Kept in their own module so
4
+ * cli.ts can import them statically without pulling in a heavy command module
5
+ * (its command handlers stay lazily `import()`-ed).
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.MissingFlagValueError = void 0;
9
+ exports.readValueFlag = readValueFlag;
10
+ /** Thrown when a value-flag is present but missing its value. */
11
+ class MissingFlagValueError extends Error {
12
+ }
13
+ exports.MissingFlagValueError = MissingFlagValueError;
14
+ /**
15
+ * Read a required value for `flag` from `argv`. Returns undefined when the flag
16
+ * is absent; throws MissingFlagValueError when it is present without a value
17
+ * (end of args) OR the next token is itself a flag (starts with "-"). The
18
+ * latter guard stops `--agent-name --server <url>` from silently swallowing
19
+ * `--server` as the agent name — a typo must be loud.
20
+ */
21
+ function readValueFlag(argv, flag) {
22
+ const i = argv.indexOf(flag);
23
+ if (i === -1)
24
+ return undefined;
25
+ const val = argv[i + 1];
26
+ if (val === undefined || val.startsWith("-")) {
27
+ throw new MissingFlagValueError(`${flag} requires a value`);
28
+ }
29
+ return val;
30
+ }
package/dist/cli.js CHANGED
@@ -14,6 +14,7 @@
14
14
  * uninstall Clean removal of CC integration
15
15
  */
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
+ const cli_args_js_1 = require("./cli-args.js");
17
18
  const command = process.argv[2];
18
19
  switch (command) {
19
20
  case "server": {
@@ -32,8 +33,16 @@ switch (command) {
32
33
  case "init": {
33
34
  const serverArg = process.argv.indexOf("--server");
34
35
  const serverUrl = serverArg !== -1 ? process.argv[serverArg + 1] : undefined;
36
+ let agentName;
37
+ try {
38
+ agentName = (0, cli_args_js_1.readValueFlag)(process.argv, "--agent-name");
39
+ }
40
+ catch {
41
+ console.error("[hicortex] init: --agent-name requires a value, e.g. --agent-name lenovo");
42
+ process.exit(1);
43
+ }
35
44
  import("./init.js").then(({ runInit }) => {
36
- runInit({ serverUrl }).catch((err) => {
45
+ runInit({ serverUrl, agentName }).catch((err) => {
37
46
  console.error("[hicortex] Init failed:", err);
38
47
  process.exit(1);
39
48
  });
@@ -170,6 +179,8 @@ Commands:
170
179
  Scaffolds 5 editable default memory domains (Work, Personal,
171
180
  People, Health, Finance) in ~/.hicortex/config.json
172
181
  init --server <url> Set up as client (remote server)
182
+ init --agent-name <name> Opt in to a per-agent context id (default: unset — shared global context)
183
+ Pass --agent-name "" to clear it back to global
173
184
  nightly Run nightly denoise + capture + consolidate
174
185
  relink Resumable link-discovery pass over the ENTIRE corpus (server mode)
175
186
  classify-domains Backfill content-based domain tags over the corpus (server mode, needs config.domains)
@@ -192,6 +203,7 @@ Options:
192
203
  classify-domains --reset Restart from the beginning (ignore saved cursor)
193
204
  context show [name] Print all context sections, or just <name> (raw, pipeable)
194
205
  context edit <name> Edit a section in $EDITOR; PUT only if changed
206
+ context … --agent <id> Target a per-agent scope instead of the global set
195
207
 
196
208
  Examples:
197
209
  npx @gamaze/hicortex server
@@ -33,11 +33,14 @@ export interface ContextGetResponse {
33
33
  sections: Record<string, string>;
34
34
  updated_at: string | null;
35
35
  clients: string[];
36
+ /** Present only for an agent-scoped read (0.13). */
37
+ agent?: string;
38
+ mode?: string;
36
39
  }
37
40
  /** GET /context. Throws ContextCliError with a clear message on any failure. */
38
- export declare function getContext(target: ContextServerTarget): Promise<ContextGetResponse>;
41
+ export declare function getContext(target: ContextServerTarget, agent?: string): Promise<ContextGetResponse>;
39
42
  /** PUT one section. Throws ContextCliError with a clear message on any failure. */
40
- export declare function putSection(target: ContextServerTarget, name: string, content: string): Promise<void>;
43
+ export declare function putSection(target: ContextServerTarget, name: string, content: string, agent?: string): Promise<void>;
41
44
  /** Readable rendering of every section + the resolved clients line (show, no name). */
42
45
  export declare function formatAllSections(data: ContextGetResponse): string;
43
46
  /** Raw markdown for one section (show <name>), or null if it does not exist. */
@@ -53,6 +56,14 @@ export type EditorSpawn = (file: string) => boolean;
53
56
  * current content → $EDITOR on a temp file → PUT only if changed. Temp file is
54
57
  * always cleaned up. `spawn` is injectable for tests.
55
58
  */
56
- export declare function runEdit(name: string, spawn?: EditorSpawn): Promise<void>;
59
+ export declare function runEdit(name: string, spawn?: EditorSpawn, agent?: string): Promise<void>;
60
+ /**
61
+ * Split out a `--agent <id>` flag (anywhere in argv) from the positional args.
62
+ * The flag omitted → the global scope.
63
+ */
64
+ export declare function extractAgentFlag(args: string[]): {
65
+ agent?: string;
66
+ rest: string[];
67
+ };
57
68
  /** Dispatch for `hicortex context <sub>`. Throws ContextCliError on bad usage/failure. */
58
69
  export declare function runContextCommand(args: string[]): Promise<void>;
@@ -24,6 +24,7 @@ exports.putSection = putSection;
24
24
  exports.formatAllSections = formatAllSections;
25
25
  exports.formatOneSection = formatOneSection;
26
26
  exports.runEdit = runEdit;
27
+ exports.extractAgentFlag = extractAgentFlag;
27
28
  exports.runContextCommand = runContextCommand;
28
29
  const node_fs_1 = require("node:fs");
29
30
  const node_path_1 = require("node:path");
@@ -61,6 +62,10 @@ function loadConfig() {
61
62
  function sectionChanged(before, after) {
62
63
  return before !== after;
63
64
  }
65
+ /** Append `?agent=<id>` to a /context URL when an agent scope is targeted. */
66
+ function contextUrl(baseUrl, agent) {
67
+ return `${baseUrl}/context${agent ? `?agent=${encodeURIComponent(agent)}` : ""}`;
68
+ }
64
69
  function authHeaders(token) {
65
70
  return token ? { Authorization: `Bearer ${token}` } : {};
66
71
  }
@@ -89,10 +94,10 @@ async function readBodyError(resp) {
89
94
  }
90
95
  }
91
96
  /** GET /context. Throws ContextCliError with a clear message on any failure. */
92
- async function getContext(target) {
97
+ async function getContext(target, agent) {
93
98
  let resp;
94
99
  try {
95
- resp = await fetch(`${target.baseUrl}/context`, {
100
+ resp = await fetch(contextUrl(target.baseUrl, agent), {
96
101
  headers: authHeaders(target.authToken),
97
102
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
98
103
  });
@@ -103,13 +108,20 @@ async function getContext(target) {
103
108
  if (!resp.ok) {
104
109
  throw new ContextCliError(`GET /context failed — ${describeFailure(resp.status, await readBodyError(resp))}`);
105
110
  }
106
- return (await resp.json());
111
+ const data = (await resp.json());
112
+ // Old-server echo guard: a pre-0.13 server ignores ?agent= and answers 200
113
+ // with the GLOBAL set. Editing off that (then PUTting) would clobber shared
114
+ // context. Refuse unless the server echoes the agent it was asked for.
115
+ if (agent && data.agent !== agent) {
116
+ throw new ContextCliError(`server did not echo agent '${agent}' — it likely predates per-agent context (needs 0.13+)`);
117
+ }
118
+ return data;
107
119
  }
108
120
  /** PUT one section. Throws ContextCliError with a clear message on any failure. */
109
- async function putSection(target, name, content) {
121
+ async function putSection(target, name, content, agent) {
110
122
  let resp;
111
123
  try {
112
- resp = await fetch(`${target.baseUrl}/context`, {
124
+ resp = await fetch(contextUrl(target.baseUrl, agent), {
113
125
  method: "PUT",
114
126
  headers: { "Content-Type": "application/json", ...authHeaders(target.authToken) },
115
127
  body: JSON.stringify({ sections: { [name]: content } }),
@@ -122,6 +134,17 @@ async function putSection(target, name, content) {
122
134
  if (!resp.ok) {
123
135
  throw new ContextCliError(`PUT /context failed — ${describeFailure(resp.status, await readBodyError(resp))}`);
124
136
  }
137
+ // Old-server echo guard on the WRITE too: a pre-0.13 server silently accepts
138
+ // the PUT as a GLOBAL write and returns 200 without echoing `agent`. Treat a
139
+ // missing echo as a failed agent write (the global section may have been
140
+ // clobbered — surface it loudly rather than reporting a false success).
141
+ if (agent) {
142
+ const body = (await resp.json().catch(() => ({})));
143
+ if (body.agent !== agent) {
144
+ throw new ContextCliError(`PUT /context?agent=${agent} — server did not echo the agent; it likely predates per-agent ` +
145
+ `context (needs 0.13+). It may have written to the GLOBAL scope — verify the server version.`);
146
+ }
147
+ }
125
148
  }
126
149
  // ---------------------------------------------------------------------------
127
150
  // Pure formatters (tested directly; no I/O)
@@ -140,6 +163,8 @@ function formatAllSections(data) {
140
163
  }
141
164
  }
142
165
  parts.push(`clients: ${data.clients.join(", ") || "(none)"}`);
166
+ if (data.agent)
167
+ parts.push(`agent: ${data.agent}${data.mode ? ` (mode: ${data.mode})` : ""}`);
143
168
  return parts.join("\n");
144
169
  }
145
170
  /** Raw markdown for one section (show <name>), or null if it does not exist. */
@@ -176,8 +201,8 @@ const defaultSpawn = (file) => {
176
201
  // ---------------------------------------------------------------------------
177
202
  // Commands
178
203
  // ---------------------------------------------------------------------------
179
- async function runShow(name) {
180
- const data = await getContext(resolveContextTarget(loadConfig()));
204
+ async function runShow(name, agent) {
205
+ const data = await getContext(resolveContextTarget(loadConfig()), agent);
181
206
  if (name) {
182
207
  const body = formatOneSection(data, name);
183
208
  if (body === null) {
@@ -195,12 +220,12 @@ async function runShow(name) {
195
220
  * current content → $EDITOR on a temp file → PUT only if changed. Temp file is
196
221
  * always cleaned up. `spawn` is injectable for tests.
197
222
  */
198
- async function runEdit(name, spawn = defaultSpawn) {
223
+ async function runEdit(name, spawn = defaultSpawn, agent) {
199
224
  if (!(0, context_store_js_1.isValidSectionName)(name)) {
200
225
  throw new ContextCliError(`Invalid section name '${name}'. Must match ^[a-z0-9][a-z0-9_-]*$ (max ${context_store_js_1.SECTION_NAME_MAX} chars).`);
201
226
  }
202
227
  const target = resolveContextTarget(loadConfig());
203
- const data = await getContext(target);
228
+ const data = await getContext(target, agent);
204
229
  const before = data.sections[name] ?? "";
205
230
  const dir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "hicortex-ctx-"));
206
231
  const file = (0, node_path_1.join)(dir, `${name}.md`);
@@ -215,26 +240,55 @@ async function runEdit(name, spawn = defaultSpawn) {
215
240
  process.stdout.write("no changes\n");
216
241
  return;
217
242
  }
218
- await putSection(target, name, after);
219
- process.stdout.write(`Saved section '${name}'.\n`);
243
+ await putSection(target, name, after, agent);
244
+ // Saving to an agent scope creates/extends that agent's override.
245
+ process.stdout.write(`Saved section '${name}'${agent ? ` for agent '${agent}' (override)` : ""}.\n`);
220
246
  }
221
247
  finally {
222
248
  (0, node_fs_1.rmSync)(dir, { recursive: true, force: true });
223
249
  }
224
250
  }
251
+ /**
252
+ * Split out a `--agent <id>` flag (anywhere in argv) from the positional args.
253
+ * The flag omitted → the global scope.
254
+ */
255
+ function extractAgentFlag(args) {
256
+ const rest = [];
257
+ let agent;
258
+ for (let i = 0; i < args.length; i++) {
259
+ if (args[i] === "--agent") {
260
+ const val = args[i + 1];
261
+ // A missing value (end of args) or the next token being another flag is a
262
+ // typo — never let it silently fall through to the global scope.
263
+ if (val === undefined || val.startsWith("-")) {
264
+ throw new ContextCliError("--agent requires a value, e.g. --agent lenny");
265
+ }
266
+ agent = val;
267
+ i++;
268
+ continue;
269
+ }
270
+ rest.push(args[i]);
271
+ }
272
+ return { agent, rest };
273
+ }
225
274
  /** Dispatch for `hicortex context <sub>`. Throws ContextCliError on bad usage/failure. */
226
275
  async function runContextCommand(args) {
227
- const sub = args[0];
276
+ const { agent, rest } = extractAgentFlag(args);
277
+ // Fast-fail on a bad agent id before any HTTP call (the server enforces too).
278
+ if (agent !== undefined && !(0, context_store_js_1.isValidAgentId)(agent)) {
279
+ throw new ContextCliError(`Invalid agent id '${agent}'. Must match ^[a-z0-9][a-z0-9_-]*$ (max ${context_store_js_1.SECTION_NAME_MAX} chars).`);
280
+ }
281
+ const sub = rest[0];
228
282
  switch (sub) {
229
283
  case "show":
230
- await runShow(args[1]);
284
+ await runShow(rest[1], agent);
231
285
  return;
232
286
  case "edit":
233
- if (!args[1])
234
- throw new ContextCliError("Usage: hicortex context edit <name>");
235
- await runEdit(args[1]);
287
+ if (!rest[1])
288
+ throw new ContextCliError("Usage: hicortex context edit <name> [--agent <id>]");
289
+ await runEdit(rest[1], undefined, agent);
236
290
  return;
237
291
  default:
238
- throw new ContextCliError("Usage: hicortex context <show [name] | edit <name>>");
292
+ throw new ContextCliError("Usage: hicortex context <show [name] | edit <name>> [--agent <id>]");
239
293
  }
240
294
  }