@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.
@@ -34,10 +34,65 @@ export declare class InvalidSectionNameError extends Error {
34
34
  readonly names: string[];
35
35
  constructor(names: string[]);
36
36
  }
37
+ /** Resolution mode for a given agent id (spec §3). */
38
+ export type AgentMode = "override" | "global" | "off";
39
+ /** Reserved subdir under <context>/ holding per-agent sections. NOT a section. */
40
+ export declare const AGENTS_DIR = "agents";
41
+ /**
42
+ * Agent ids share the section-name allowlist: they are joined into a filesystem
43
+ * path (`context/agents/<id>`), so this is the same security contract, not just
44
+ * hygiene.
45
+ */
46
+ export declare const isValidAgentId: typeof isValidSectionName;
47
+ /**
48
+ * Sanitize a raw identity string (hostname, configured name) into a valid agent
49
+ * id, or null when nothing valid remains. Lowercase → collapse invalid runs to
50
+ * `-` → strip leading `-`/`_` → truncate to the max length, then require the
51
+ * result to pass the allowlist. "MacBook-Pro.local" → "macbook-pro-local"; a
52
+ * string of only symbols/non-ASCII → null (caller then omits `?agent=` entirely
53
+ * rather than sending an id that would 400).
54
+ */
55
+ export declare function sanitizeAgentId(raw: string): string | null;
56
+ /** The identity a client sends as `?agent=`, and where it came from. */
57
+ export interface AgentIdentity {
58
+ /** The id the client sends as `?agent=`, or null → no param (bare fetch). */
59
+ agentId: string | null;
60
+ source: "configured" | "unset" | "invalid-config";
61
+ /** The raw config value, for the configured / invalid-config cases. */
62
+ rawConfigured?: string;
63
+ }
64
+ /**
65
+ * Resolve an install's per-agent identity from its config (the SINGLE source of
66
+ * truth shared by the CC hook and `hicortex status`, so the id an install
67
+ * actually sends can never diverge from the id status reports):
68
+ * - `config.agentName` a non-empty string that sanitizes → that id
69
+ * ("configured");
70
+ * - a non-empty string that sanitizes to null → null id, "invalid-config" (the
71
+ * hook sends NO `?agent=`; status must say so);
72
+ * - absent, or empty/whitespace-only → null id, "unset". Empty string == unset
73
+ * everywhere (this is the value `init --agent-name ""` writes-then-clears to
74
+ * opt back out): CC's default is NO `?agent=`, so all CC boxes for one user
75
+ * share the global context — one user = one identity across machines.
76
+ * There is NO hostname fallback: a hostname-derived default would silently give
77
+ * every machine its own context, the opposite of the shared-identity default.
78
+ */
79
+ export declare function resolveAgentIdentity(config: Record<string, unknown>): AgentIdentity;
80
+ /**
81
+ * Normalize the raw `contextAgents` config value into a map of valid agent id →
82
+ * mode. An entry is kept only when its key passes isValidAgentId AND its value
83
+ * is one of the three modes; everything else is dropped and its key collected
84
+ * for a one-time boot warning (mirrors resolveContextClients).
85
+ */
86
+ export declare function resolveContextAgents(raw: unknown): {
87
+ agents: Record<string, AgentMode>;
88
+ dropped: string[];
89
+ };
37
90
  export interface ReadResult {
38
91
  sections: Record<string, string>;
39
92
  /** ISO timestamp of the latest included file's mtime, or null when none. */
40
93
  updatedAt: string | null;
94
+ /** Per-section file mtimeMs — needed to compute updatedAt across a merge. */
95
+ mtimes: Record<string, number>;
41
96
  }
42
97
  /**
43
98
  * Enumerate the context dir and return the served sections. Only regular files
@@ -97,6 +152,57 @@ export interface ResolvedContextClients {
97
152
  * hook can self-gate without its own config.
98
153
  */
99
154
  export declare function resolveContextClients(raw: unknown): ResolvedContextClients;
155
+ /**
156
+ * Classify the on-disk state of `context/agents/<id>`, hardened against a
157
+ * symlink planted at EITHER the `agents` ROOT or the `<id>` leaf — lstat/readdir
158
+ * follow a symlinked intermediate path component, so checking only the leaf
159
+ * would let a symlinked root escape the context root entirely. BOTH must be
160
+ * real, non-symlink directories.
161
+ * - "absent" — the root or the leaf does not exist (fresh install / no dir for
162
+ * this agent). A normal, safe "no agent sections" case.
163
+ * - "real" — both are real directories → safe to read/write.
164
+ * - "unsafe" — the root or leaf exists but is a symlink or non-directory →
165
+ * never read/write through it (treat as no agent sections).
166
+ * Fail-explicit: ENOENT is the ONLY swallowed error; EACCES/EIO/ENOTDIR etc.
167
+ * rethrow (a permissions fault must never masquerade as "empty", matching
168
+ * readSections). The single source of truth for the four call sites below.
169
+ */
170
+ export declare function agentDirState(contextDir: string, agentId: string): "absent" | "real" | "unsafe";
171
+ /**
172
+ * Resolve the mode for a request `?agent=<id>` (spec §3):
173
+ * 1. config entry present → that mode;
174
+ * 2. else `context/agents/<id>/` present as a REAL directory (root + leaf both
175
+ * real, non-symlink) → "override" (dropping in a dir is intent, no config);
176
+ * 3. else → "global".
177
+ * A symlinked/non-dir root or leaf never counts as "present" (agentDirState).
178
+ */
179
+ export declare function resolveAgentMode(contextDir: string, agentId: string, contextAgents: Record<string, AgentMode>): AgentMode;
180
+ /**
181
+ * Enumerate the agents the UI selector should offer: the union of allowlisted
182
+ * REAL directories under `context/agents/` and every configured `contextAgents`
183
+ * key, each mapped to its resolved mode (config wins over presence). A missing
184
+ * agents dir (fresh install) yields the config keys only; a symlinked/non-dir
185
+ * agents root is treated as "no dirs" (config keys only).
186
+ */
187
+ export declare function listAgents(contextDir: string, contextAgents: Record<string, AgentMode>): Record<string, AgentMode>;
188
+ export interface ResolvedRead {
189
+ sections: Record<string, string>;
190
+ updatedAt: string | null;
191
+ /** Echoed only when an agent was requested (agentId non-null). */
192
+ agent?: string;
193
+ mode?: AgentMode;
194
+ /** Per-section provenance, present only in override mode (feeds the UI). */
195
+ origins?: Record<string, "global" | "agent">;
196
+ }
197
+ /**
198
+ * Read the sections that apply to `agentId` (null → a plain global read).
199
+ * off → {}; global → the global set; override → per-section merge of the global
200
+ * set under the agent's own sections, with `updatedAt` taken across only the
201
+ * files that WON the merge. The agent dir is lstat-guarded immediately before
202
+ * reading (A1): a symlinked or non-directory `agents/<id>` contributes NO agent
203
+ * sections regardless of how override was selected (config or presence).
204
+ */
205
+ export declare function readResolvedSections(contextDir: string, agentId: string | null, contextAgents: Record<string, AgentMode>): ResolvedRead;
100
206
  export interface HandlerResult {
101
207
  status: number;
102
208
  body: unknown;
@@ -111,10 +217,10 @@ export interface HandlerResult {
111
217
  * is served normally — the two are indistinguishable at the wire, so a
112
218
  * paramless legacy caller is covered by the migration docs, not this guard.)
113
219
  */
114
- export declare function handleContextGet(contextDir: string, clients: string[], query: Record<string, unknown>): HandlerResult;
220
+ export declare function handleContextGet(contextDir: string, clients: string[], query: Record<string, unknown>, contextAgents?: Record<string, AgentMode>): HandlerResult;
115
221
  /**
116
222
  * PUT /context. Validates the body shape and section content types, then
117
223
  * delegates to writeSections (which owns the name allowlist + atomicity +
118
224
  * symlink safety). Throws are left to the adapter to turn into a 500.
119
225
  */
120
- export declare function handleContextPut(contextDir: string, body: unknown): HandlerResult;
226
+ export declare function handleContextPut(contextDir: string, body: unknown, query?: Record<string, unknown>, contextAgents?: Record<string, AgentMode>): HandlerResult;
@@ -20,12 +20,19 @@
20
20
  * temp-file-then-rename, and keep a one-generation `<name>.md.bak` undo.
21
21
  */
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
- exports.KNOWN_CONTEXT_CLIENTS = exports.CONTEXT_SIZE_WARN_BYTES = exports.InvalidSectionNameError = exports.SECTION_NAME_MAX = exports.SECTION_NAME_RE = void 0;
23
+ exports.KNOWN_CONTEXT_CLIENTS = exports.CONTEXT_SIZE_WARN_BYTES = exports.isValidAgentId = exports.AGENTS_DIR = exports.InvalidSectionNameError = exports.SECTION_NAME_MAX = exports.SECTION_NAME_RE = void 0;
24
24
  exports.isValidSectionName = isValidSectionName;
25
+ exports.sanitizeAgentId = sanitizeAgentId;
26
+ exports.resolveAgentIdentity = resolveAgentIdentity;
27
+ exports.resolveContextAgents = resolveContextAgents;
25
28
  exports.readSections = readSections;
26
29
  exports.writeSections = writeSections;
27
30
  exports.totalBytes = totalBytes;
28
31
  exports.resolveContextClients = resolveContextClients;
32
+ exports.agentDirState = agentDirState;
33
+ exports.resolveAgentMode = resolveAgentMode;
34
+ exports.listAgents = listAgents;
35
+ exports.readResolvedSections = readResolvedSections;
29
36
  exports.handleContextGet = handleContextGet;
30
37
  exports.handleContextPut = handleContextPut;
31
38
  const node_fs_1 = require("node:fs");
@@ -57,6 +64,79 @@ class InvalidSectionNameError extends Error {
57
64
  }
58
65
  }
59
66
  exports.InvalidSectionNameError = InvalidSectionNameError;
67
+ /** Reserved subdir under <context>/ holding per-agent sections. NOT a section. */
68
+ exports.AGENTS_DIR = "agents";
69
+ /**
70
+ * Agent ids share the section-name allowlist: they are joined into a filesystem
71
+ * path (`context/agents/<id>`), so this is the same security contract, not just
72
+ * hygiene.
73
+ */
74
+ exports.isValidAgentId = isValidSectionName;
75
+ /**
76
+ * Sanitize a raw identity string (hostname, configured name) into a valid agent
77
+ * id, or null when nothing valid remains. Lowercase → collapse invalid runs to
78
+ * `-` → strip leading `-`/`_` → truncate to the max length, then require the
79
+ * result to pass the allowlist. "MacBook-Pro.local" → "macbook-pro-local"; a
80
+ * string of only symbols/non-ASCII → null (caller then omits `?agent=` entirely
81
+ * rather than sending an id that would 400).
82
+ */
83
+ function sanitizeAgentId(raw) {
84
+ if (typeof raw !== "string")
85
+ return null;
86
+ const cleaned = raw
87
+ .toLowerCase()
88
+ .replace(/[^a-z0-9_-]+/g, "-")
89
+ .replace(/^[-_]+/, "")
90
+ .slice(0, exports.SECTION_NAME_MAX);
91
+ return isValidSectionName(cleaned) ? cleaned : null;
92
+ }
93
+ /**
94
+ * Resolve an install's per-agent identity from its config (the SINGLE source of
95
+ * truth shared by the CC hook and `hicortex status`, so the id an install
96
+ * actually sends can never diverge from the id status reports):
97
+ * - `config.agentName` a non-empty string that sanitizes → that id
98
+ * ("configured");
99
+ * - a non-empty string that sanitizes to null → null id, "invalid-config" (the
100
+ * hook sends NO `?agent=`; status must say so);
101
+ * - absent, or empty/whitespace-only → null id, "unset". Empty string == unset
102
+ * everywhere (this is the value `init --agent-name ""` writes-then-clears to
103
+ * opt back out): CC's default is NO `?agent=`, so all CC boxes for one user
104
+ * share the global context — one user = one identity across machines.
105
+ * There is NO hostname fallback: a hostname-derived default would silently give
106
+ * every machine its own context, the opposite of the shared-identity default.
107
+ */
108
+ function resolveAgentIdentity(config) {
109
+ const value = config.agentName;
110
+ // Empty / whitespace-only is treated as absent, not as an invalid id.
111
+ const raw = typeof value === "string" && value.trim() !== "" ? value : null;
112
+ if (raw !== null) {
113
+ const s = sanitizeAgentId(raw);
114
+ return s ? { agentId: s, source: "configured", rawConfigured: raw }
115
+ : { agentId: null, source: "invalid-config", rawConfigured: raw };
116
+ }
117
+ return { agentId: null, source: "unset" };
118
+ }
119
+ /**
120
+ * Normalize the raw `contextAgents` config value into a map of valid agent id →
121
+ * mode. An entry is kept only when its key passes isValidAgentId AND its value
122
+ * is one of the three modes; everything else is dropped and its key collected
123
+ * for a one-time boot warning (mirrors resolveContextClients).
124
+ */
125
+ function resolveContextAgents(raw) {
126
+ const agents = {};
127
+ const dropped = [];
128
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
129
+ return { agents, dropped };
130
+ for (const [key, value] of Object.entries(raw)) {
131
+ if ((0, exports.isValidAgentId)(key) && (value === "override" || value === "global" || value === "off")) {
132
+ agents[key] = value;
133
+ }
134
+ else {
135
+ dropped.push(key);
136
+ }
137
+ }
138
+ return { agents, dropped };
139
+ }
60
140
  /**
61
141
  * Enumerate the context dir and return the served sections. Only regular files
62
142
  * whose basename (sans `.md`) passes the allowlist are included; symlinks are
@@ -75,13 +155,17 @@ function readSections(dir) {
75
155
  // error (EACCES, EIO, ENOTDIR) is a real fault: surface it (the route turns
76
156
  // it into a 500) rather than masquerading a permissions problem as "empty".
77
157
  if (err.code === "ENOENT")
78
- return { sections: {}, updatedAt: null };
158
+ return { sections: {}, updatedAt: null, mtimes: {} };
79
159
  throw err;
80
160
  }
81
161
  const sections = {};
162
+ const mtimes = {};
82
163
  let latestMtimeMs = 0;
83
164
  for (const file of entries) {
84
165
  // Full-suffix check: only ".md" — excludes "<name>.md.bak" and temp files.
166
+ // A directory (e.g. the reserved `agents/` subdir) also never matches the
167
+ // `.md` filter, and even a dir literally named `x.md` fails the isFile()
168
+ // check below — so the per-agent store is skipped by the global read.
85
169
  if (!file.endsWith(".md"))
86
170
  continue;
87
171
  const name = file.slice(0, -".md".length);
@@ -104,11 +188,12 @@ function readSections(dir) {
104
188
  catch {
105
189
  continue;
106
190
  }
191
+ mtimes[name] = st.mtimeMs;
107
192
  if (st.mtimeMs > latestMtimeMs)
108
193
  latestMtimeMs = st.mtimeMs;
109
194
  }
110
195
  const updatedAt = Object.keys(sections).length > 0 ? new Date(latestMtimeMs).toISOString() : null;
111
- return { sections, updatedAt };
196
+ return { sections, updatedAt, mtimes };
112
197
  }
113
198
  // ---------------------------------------------------------------------------
114
199
  // Write
@@ -262,6 +347,141 @@ function resolveContextClients(raw) {
262
347
  return { clients: ["cc"], dropped: [] };
263
348
  }
264
349
  // ---------------------------------------------------------------------------
350
+ // Per-agent resolution (0.13)
351
+ // ---------------------------------------------------------------------------
352
+ /**
353
+ * Classify the on-disk state of `context/agents/<id>`, hardened against a
354
+ * symlink planted at EITHER the `agents` ROOT or the `<id>` leaf — lstat/readdir
355
+ * follow a symlinked intermediate path component, so checking only the leaf
356
+ * would let a symlinked root escape the context root entirely. BOTH must be
357
+ * real, non-symlink directories.
358
+ * - "absent" — the root or the leaf does not exist (fresh install / no dir for
359
+ * this agent). A normal, safe "no agent sections" case.
360
+ * - "real" — both are real directories → safe to read/write.
361
+ * - "unsafe" — the root or leaf exists but is a symlink or non-directory →
362
+ * never read/write through it (treat as no agent sections).
363
+ * Fail-explicit: ENOENT is the ONLY swallowed error; EACCES/EIO/ENOTDIR etc.
364
+ * rethrow (a permissions fault must never masquerade as "empty", matching
365
+ * readSections). The single source of truth for the four call sites below.
366
+ */
367
+ function agentDirState(contextDir, agentId) {
368
+ const root = (0, node_path_1.join)(contextDir, exports.AGENTS_DIR);
369
+ let rootSt;
370
+ try {
371
+ rootSt = (0, node_fs_1.lstatSync)(root);
372
+ }
373
+ catch (err) {
374
+ if (err.code === "ENOENT")
375
+ return "absent";
376
+ throw err;
377
+ }
378
+ if (rootSt.isSymbolicLink() || !rootSt.isDirectory())
379
+ return "unsafe";
380
+ let leafSt;
381
+ try {
382
+ leafSt = (0, node_fs_1.lstatSync)((0, node_path_1.join)(root, agentId));
383
+ }
384
+ catch (err) {
385
+ if (err.code === "ENOENT")
386
+ return "absent";
387
+ throw err;
388
+ }
389
+ if (leafSt.isSymbolicLink() || !leafSt.isDirectory())
390
+ return "unsafe";
391
+ return "real";
392
+ }
393
+ /**
394
+ * Resolve the mode for a request `?agent=<id>` (spec §3):
395
+ * 1. config entry present → that mode;
396
+ * 2. else `context/agents/<id>/` present as a REAL directory (root + leaf both
397
+ * real, non-symlink) → "override" (dropping in a dir is intent, no config);
398
+ * 3. else → "global".
399
+ * A symlinked/non-dir root or leaf never counts as "present" (agentDirState).
400
+ */
401
+ function resolveAgentMode(contextDir, agentId, contextAgents) {
402
+ const configured = contextAgents[agentId];
403
+ if (configured)
404
+ return configured;
405
+ return agentDirState(contextDir, agentId) === "real" ? "override" : "global";
406
+ }
407
+ /**
408
+ * Enumerate the agents the UI selector should offer: the union of allowlisted
409
+ * REAL directories under `context/agents/` and every configured `contextAgents`
410
+ * key, each mapped to its resolved mode (config wins over presence). A missing
411
+ * agents dir (fresh install) yields the config keys only; a symlinked/non-dir
412
+ * agents root is treated as "no dirs" (config keys only).
413
+ */
414
+ function listAgents(contextDir, contextAgents) {
415
+ const result = {};
416
+ const agentsRoot = (0, node_path_1.join)(contextDir, exports.AGENTS_DIR);
417
+ // Guard the root itself before readdir would follow a symlinked root.
418
+ let rootReal = false;
419
+ try {
420
+ const st = (0, node_fs_1.lstatSync)(agentsRoot);
421
+ rootReal = st.isDirectory() && !st.isSymbolicLink();
422
+ }
423
+ catch (err) {
424
+ if (err.code !== "ENOENT")
425
+ throw err; // fail-explicit
426
+ }
427
+ if (rootReal) {
428
+ for (const entry of (0, node_fs_1.readdirSync)(agentsRoot)) {
429
+ if (!(0, exports.isValidAgentId)(entry))
430
+ continue;
431
+ // Real leaf confirmed by the shared guard → its mode is config-or-override
432
+ // (config wins over presence); no second lstat via resolveAgentMode.
433
+ if (agentDirState(contextDir, entry) === "real") {
434
+ result[entry] = contextAgents[entry] ?? "override";
435
+ }
436
+ }
437
+ }
438
+ for (const id of Object.keys(contextAgents)) {
439
+ if (!(id in result))
440
+ result[id] = contextAgents[id];
441
+ }
442
+ return result;
443
+ }
444
+ /**
445
+ * Read the sections that apply to `agentId` (null → a plain global read).
446
+ * off → {}; global → the global set; override → per-section merge of the global
447
+ * set under the agent's own sections, with `updatedAt` taken across only the
448
+ * files that WON the merge. The agent dir is lstat-guarded immediately before
449
+ * reading (A1): a symlinked or non-directory `agents/<id>` contributes NO agent
450
+ * sections regardless of how override was selected (config or presence).
451
+ */
452
+ function readResolvedSections(contextDir, agentId, contextAgents) {
453
+ if (agentId === null) {
454
+ const g = readSections(contextDir);
455
+ return { sections: g.sections, updatedAt: g.updatedAt };
456
+ }
457
+ const mode = resolveAgentMode(contextDir, agentId, contextAgents);
458
+ if (mode === "off") {
459
+ return { sections: {}, updatedAt: null, agent: agentId, mode };
460
+ }
461
+ const global = readSections(contextDir);
462
+ if (mode === "global") {
463
+ return { sections: global.sections, updatedAt: global.updatedAt, agent: agentId, mode };
464
+ }
465
+ // override — read the agent dir, but only if root + leaf are real dirs
466
+ // (agentDirState closes the config-forced-override + symlinked-root holes).
467
+ let agentRead = { sections: {}, updatedAt: null, mtimes: {} };
468
+ if (agentDirState(contextDir, agentId) === "real") {
469
+ agentRead = readSections((0, node_path_1.join)(contextDir, exports.AGENTS_DIR, agentId));
470
+ }
471
+ const sections = { ...global.sections, ...agentRead.sections };
472
+ const origins = {};
473
+ let latestMtimeMs = 0;
474
+ for (const name of Object.keys(sections)) {
475
+ const fromAgent = name in agentRead.sections;
476
+ origins[name] = fromAgent ? "agent" : "global";
477
+ const mt = fromAgent ? agentRead.mtimes[name] : global.mtimes[name];
478
+ if (mt && mt > latestMtimeMs)
479
+ latestMtimeMs = mt;
480
+ }
481
+ const updatedAt = Object.keys(sections).length > 0 ? new Date(latestMtimeMs).toISOString() : null;
482
+ return { sections, updatedAt, agent: agentId, mode, origins };
483
+ }
484
+ // ---------------------------------------------------------------------------
265
485
  // HTTP-shape handlers (real logic behind GET/PUT /context)
266
486
  // ---------------------------------------------------------------------------
267
487
  //
@@ -270,6 +490,21 @@ function resolveContextClients(raw) {
270
490
  // from mcp-server.ts). mcp-server.ts wires req/res to them and nothing else.
271
491
  /** Recall query params whose presence means a stale pre-0.12 recall caller. */
272
492
  const RECALL_PARAMS = ["project", "limit", "privacy"];
493
+ /**
494
+ * Extract and validate the `?agent=` query param. Absent → global (agentId
495
+ * null, no error). Present but not a plain string (express gives `string[]` for
496
+ * `?agent=a&agent=b`) or failing the allowlist → a 400 error string (never a
497
+ * silent fallback to global — a typo must be loud, spec §1).
498
+ */
499
+ function extractAgentParam(query) {
500
+ if (!("agent" in query))
501
+ return { agentId: null };
502
+ const raw = query.agent;
503
+ if (typeof raw !== "string" || !(0, exports.isValidAgentId)(raw)) {
504
+ return { agentId: null, error: "Invalid 'agent' — must match ^[a-z0-9][a-z0-9_-]*$ (max 64 chars)" };
505
+ }
506
+ return { agentId: raw };
507
+ }
273
508
  /**
274
509
  * GET /context. Stale-client tripwire first: recall moved to /recent, so
275
510
  * project/limit/privacy on this route mean a legacy recall caller — return a
@@ -278,22 +513,46 @@ const RECALL_PARAMS = ["project", "limit", "privacy"];
278
513
  * is served normally — the two are indistinguishable at the wire, so a
279
514
  * paramless legacy caller is covered by the migration docs, not this guard.)
280
515
  */
281
- function handleContextGet(contextDir, clients, query) {
516
+ function handleContextGet(contextDir, clients, query, contextAgents = {}) {
282
517
  if (RECALL_PARAMS.some((p) => p in query)) {
283
518
  return {
284
519
  status: 400,
285
520
  body: { error: "recall moved to /recent — GET /context now serves the standing context layer (0.12)" },
286
521
  };
287
522
  }
288
- const { sections, updatedAt } = readSections(contextDir);
289
- return { status: 200, body: { sections, updated_at: updatedAt, clients } };
523
+ const { agentId, error } = extractAgentParam(query);
524
+ if (error)
525
+ return { status: 400, body: { error } };
526
+ // No agent → the plain global read plus the additive `agents` map the UI
527
+ // selector needs (backward compatible: existing callers ignore unknown keys).
528
+ if (agentId === null) {
529
+ const { sections, updatedAt } = readSections(contextDir);
530
+ return {
531
+ status: 200,
532
+ body: { sections, updated_at: updatedAt, clients, agents: listAgents(contextDir, contextAgents) },
533
+ };
534
+ }
535
+ const resolved = readResolvedSections(contextDir, agentId, contextAgents);
536
+ const body = {
537
+ sections: resolved.sections,
538
+ updated_at: resolved.updatedAt,
539
+ clients,
540
+ agent: resolved.agent,
541
+ mode: resolved.mode,
542
+ };
543
+ if (resolved.origins)
544
+ body.origins = resolved.origins;
545
+ return { status: 200, body };
290
546
  }
291
547
  /**
292
548
  * PUT /context. Validates the body shape and section content types, then
293
549
  * delegates to writeSections (which owns the name allowlist + atomicity +
294
550
  * symlink safety). Throws are left to the adapter to turn into a 500.
295
551
  */
296
- function handleContextPut(contextDir, body) {
552
+ function handleContextPut(contextDir, body, query = {}, contextAgents = {}) {
553
+ const { agentId, error } = extractAgentParam(query);
554
+ if (error)
555
+ return { status: 400, body: { error } };
297
556
  const sections = body?.sections;
298
557
  if (!sections || typeof sections !== "object" || Array.isArray(sections)) {
299
558
  return { status: 400, body: { error: "Missing or invalid 'sections' object" } };
@@ -303,19 +562,52 @@ function handleContextPut(contextDir, body) {
303
562
  return { status: 400, body: { error: `Section '${name}' content must be a string` } };
304
563
  }
305
564
  }
306
- try {
307
- writeSections(contextDir, sections);
565
+ const targetDir = agentId === null ? contextDir : (0, node_path_1.join)(contextDir, exports.AGENTS_DIR, agentId);
566
+ if (agentId !== null) {
567
+ // A1 write-path guard: never write through a symlinked/non-dir root or leaf
568
+ // (agentDirState checks both). "absent" is fine — writeSections creates it.
569
+ if (agentDirState(contextDir, agentId) === "unsafe") {
570
+ return { status: 400, body: { error: "agent context path exists but is not a directory" } };
571
+ }
572
+ // Black-hole guard: if config FORCES off/global for this agent, sections
573
+ // written under agents/<id>/ could never be served (resolution ignores the
574
+ // dir). Reject loudly rather than accept a write that silently vanishes.
575
+ // No config entry → writing creates the dir ⇒ override ⇒ served (allowed).
576
+ const configMode = contextAgents[agentId];
577
+ if (configMode === "off" || configMode === "global") {
578
+ return {
579
+ status: 409,
580
+ body: {
581
+ error: `config contextAgents['${agentId}']='${configMode}' — sections written here would never be served; ` +
582
+ `set it to 'override' (or remove the entry) first`,
583
+ },
584
+ };
585
+ }
308
586
  }
309
- catch (err) {
310
- if (err instanceof InvalidSectionNameError) {
311
- return { status: 400, body: { error: `Invalid section name(s): ${err.names.join(", ")}` } };
587
+ // Skip the write entirely when nothing is supplied: writeSections would
588
+ // otherwise mkdir the (agent) dir for a no-op PUT, silently flipping an
589
+ // agent to override via presence. Reading the resolved view still reflects
590
+ // current disk state.
591
+ if (Object.keys(sections).length > 0) {
592
+ try {
593
+ writeSections(targetDir, sections);
594
+ }
595
+ catch (err) {
596
+ if (err instanceof InvalidSectionNameError) {
597
+ return { status: 400, body: { error: `Invalid section name(s): ${err.names.join(", ")}` } };
598
+ }
599
+ throw err; // real I/O fault → adapter returns 500
312
600
  }
313
- throw err; // real I/O fault → adapter returns 500
314
601
  }
315
- const { sections: onDisk, updatedAt } = readSections(contextDir);
316
- const bytes = totalBytes(onDisk);
602
+ const resolved = readResolvedSections(contextDir, agentId, contextAgents);
603
+ const bytes = totalBytes(resolved.sections);
317
604
  const warn = bytes > exports.CONTEXT_SIZE_WARN_BYTES
318
605
  ? `Context layer total size ${bytes} bytes exceeds ${exports.CONTEXT_SIZE_WARN_BYTES} — injected into every session; consider trimming.`
319
606
  : undefined;
320
- return { status: 200, body: { ok: true, updated_at: updatedAt }, warn };
607
+ const respBody = { ok: true, updated_at: resolved.updatedAt };
608
+ if (agentId !== null) {
609
+ respBody.agent = resolved.agent;
610
+ respBody.mode = resolved.mode;
611
+ }
612
+ return { status: 200, body: respBody, warn };
321
613
  }
@@ -26,5 +26,31 @@ export declare function extractConversationText(messages: unknown[], redactionCo
26
26
  * Send filtered conversation to LLM for knowledge extraction.
27
27
  * For large transcripts, chunks into segments to avoid overwhelming small models.
28
28
  * Returns an array of memory entries to ingest, or empty array if nothing worth extracting.
29
+ *
30
+ * `droppedOut`, when provided, is filled with every entry the substance gate
31
+ * discarded (full text). Callers use it to build a durable audit trail (#156);
32
+ * omitting it leaves gate behaviour unchanged.
33
+ */
34
+ export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number, droppedOut?: string[]): Promise<string[]>;
35
+ /**
36
+ * Reject ONLY structurally-empty distiller fragments before they become
37
+ * memories (#156). The distiller occasionally emits leftovers that parse into
38
+ * entries but carry no recallable content:
39
+ * - bare section prefixes: "[Specific AI Content:]", "[Facts Learned]"
40
+ * - echoed template placeholders: "[decision]: [reasoning] (2026-07-05)"
41
+ * - pseudo-header bullets: "**Facts Learned:**"
42
+ * - metadata-only lines: "(2026-07-05)"
43
+ *
44
+ * PRECISION OVER RECALL — deliberate trade: the gate rejects only shapes that
45
+ * are structurally empty of content, never on a length or word-count threshold.
46
+ * A kept artifact ("Classification: WORK" style) is cheaply pruned later by the
47
+ * no-fit decay path; a wrongly-dropped genuine memory is unrecoverable. So when
48
+ * in doubt, keep. Consequence documented for the reviewer: metadata lines like
49
+ * "Classification: WORK" now PASS the gate — that is intended.
50
+ *
51
+ * Stripping is scoped and anchored (one leading section prefix, one trailing
52
+ * date stamp), never global, so bracketed payloads ("use [ollama] not
53
+ * [claude-cli]") and content-bearing dates ("deadline moved (2026-08-01)")
54
+ * survive. Stripping affects only this gate's decision, never stored text.
29
55
  */
30
- export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number): Promise<string[]>;
56
+ export declare function hasMinimalSubstance(entry: string): boolean;