@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 +20 -1
- package/assets/context.html +115 -6
- package/dist/cli-args.d.ts +16 -0
- package/dist/cli-args.js +30 -0
- package/dist/cli.js +13 -1
- package/dist/context-cli.d.ts +14 -3
- package/dist/context-cli.js +71 -17
- package/dist/context-store.d.ts +108 -2
- package/dist/context-store.js +308 -16
- package/dist/index.js +73 -28
- package/dist/init.d.ts +31 -0
- package/dist/init.js +156 -2
- package/dist/lessons-context.d.ts +56 -0
- package/dist/lessons-context.js +77 -18
- package/dist/mcp-server.js +16 -2
- package/dist/status.d.ts +9 -0
- package/dist/status.js +29 -4
- package/hermes-plugin/hicortex/README.md +15 -2
- package/hermes-plugin/hicortex/client.py +7 -0
- package/hermes-plugin/hicortex/config.py +10 -0
- package/hermes-plugin/hicortex/plugin.yaml +2 -2
- package/hermes-plugin/hicortex/provider.py +134 -1
- package/package.json +1 -1
package/dist/context-store.d.ts
CHANGED
|
@@ -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;
|
package/dist/context-store.js
CHANGED
|
@@ -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 {
|
|
289
|
-
|
|
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
|
-
|
|
307
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
316
|
-
const bytes = totalBytes(
|
|
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
|
-
|
|
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
|
}
|