@gamaze/hicortex 0.11.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,243 @@
1
+ "use strict";
2
+ /**
3
+ * context-cli — `hicortex context show|edit`, the secondary/headless edit
4
+ * surface for the standing context layer (spec 2026-07-12 §6). The Web UI
5
+ * (`/context/ui`) is primary; this exists for boxes without a browser.
6
+ *
7
+ * hicortex context show [name] GET /context → print all sections, or one
8
+ * hicortex context edit <name> GET section → $EDITOR → PUT if changed
9
+ *
10
+ * URL/token resolution mirrors lessons-context.ts:44-49 (client mode →
11
+ * config.serverUrl; server mode → http://127.0.0.1:<port>; token from
12
+ * config.authToken) — explicitly NOT the hardcoded 127.0.0.1:8787 of
13
+ * status.ts. Fails soft with a clear message + non-zero exit on any server
14
+ * error, distinguishing a down server from an HTTP error (esp. 404 = server
15
+ * too old / wrong endpoint), like the OC plugin's describeGetFailure.
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.ContextCliError = void 0;
19
+ exports.resolveContextTarget = resolveContextTarget;
20
+ exports.loadConfig = loadConfig;
21
+ exports.sectionChanged = sectionChanged;
22
+ exports.getContext = getContext;
23
+ exports.putSection = putSection;
24
+ exports.formatAllSections = formatAllSections;
25
+ exports.formatOneSection = formatOneSection;
26
+ exports.runEdit = runEdit;
27
+ exports.runContextCommand = runContextCommand;
28
+ const node_fs_1 = require("node:fs");
29
+ const node_path_1 = require("node:path");
30
+ const node_os_1 = require("node:os");
31
+ const node_child_process_1 = require("node:child_process");
32
+ const context_store_js_1 = require("./context-store.js");
33
+ const DEFAULT_PORT = 8787;
34
+ const REQUEST_TIMEOUT_MS = 5000;
35
+ /** Thrown for any expected, user-facing failure. cli.ts prints .message + exits 1. */
36
+ class ContextCliError extends Error {
37
+ }
38
+ exports.ContextCliError = ContextCliError;
39
+ /** Home dir holding config.json. HICORTEX_HOME override is a headless/test seam. */
40
+ function hicortexHome() {
41
+ return process.env.HICORTEX_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), ".hicortex");
42
+ }
43
+ /**
44
+ * Resolve the server URL + token from a parsed config object. Pure + exported
45
+ * so it is unit-testable without a live config. Follows lessons-context.ts.
46
+ */
47
+ function resolveContextTarget(config) {
48
+ const baseUrl = config.mode === "client" && typeof config.serverUrl === "string"
49
+ ? config.serverUrl.replace(/\/+$/, "")
50
+ : `http://127.0.0.1:${config.port ?? DEFAULT_PORT}`;
51
+ const authToken = typeof config.authToken === "string" ? config.authToken : undefined;
52
+ return { baseUrl, authToken };
53
+ }
54
+ /** Read ~/.hicortex/config.json (or $HICORTEX_HOME/config.json). Missing → {}. */
55
+ function loadConfig() {
56
+ try {
57
+ return JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(hicortexHome(), "config.json"), "utf-8"));
58
+ }
59
+ catch {
60
+ return {};
61
+ }
62
+ }
63
+ /** The Save decision: PUT only when the edited content differs. Pure + tested. */
64
+ function sectionChanged(before, after) {
65
+ return before !== after;
66
+ }
67
+ function authHeaders(token) {
68
+ return token ? { Authorization: `Bearer ${token}` } : {};
69
+ }
70
+ /**
71
+ * Human-readable failure. status === null ⇒ the fetch itself threw (server
72
+ * unreachable). 404 is called out because it means the server predates the
73
+ * /context layer, not a network fault — mirrors index.ts describeGetFailure.
74
+ */
75
+ function describeFailure(status, bodyError) {
76
+ const suffix = bodyError ? `: ${bodyError}` : "";
77
+ if (status === null)
78
+ return "server unreachable (connection refused or timed out)";
79
+ if (status === 404)
80
+ return `HTTP 404 — /context not found; the server is likely too old (needs 0.12+)${suffix}`;
81
+ if (status === 401)
82
+ return `HTTP 401 — unauthorized; check authToken in config${suffix}`;
83
+ return `server returned HTTP ${status}${suffix}`;
84
+ }
85
+ async function readBodyError(resp) {
86
+ try {
87
+ const j = (await resp.json());
88
+ return typeof j?.error === "string" ? j.error : undefined;
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ /** GET /context. Throws ContextCliError with a clear message on any failure. */
95
+ async function getContext(target) {
96
+ let resp;
97
+ try {
98
+ resp = await fetch(`${target.baseUrl}/context`, {
99
+ headers: authHeaders(target.authToken),
100
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
101
+ });
102
+ }
103
+ catch {
104
+ throw new ContextCliError(`GET /context failed — ${describeFailure(null)}`);
105
+ }
106
+ if (!resp.ok) {
107
+ throw new ContextCliError(`GET /context failed — ${describeFailure(resp.status, await readBodyError(resp))}`);
108
+ }
109
+ return (await resp.json());
110
+ }
111
+ /** PUT one section. Throws ContextCliError with a clear message on any failure. */
112
+ async function putSection(target, name, content) {
113
+ let resp;
114
+ try {
115
+ resp = await fetch(`${target.baseUrl}/context`, {
116
+ method: "PUT",
117
+ headers: { "Content-Type": "application/json", ...authHeaders(target.authToken) },
118
+ body: JSON.stringify({ sections: { [name]: content } }),
119
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
120
+ });
121
+ }
122
+ catch {
123
+ throw new ContextCliError(`PUT /context failed — ${describeFailure(null)}`);
124
+ }
125
+ if (!resp.ok) {
126
+ throw new ContextCliError(`PUT /context failed — ${describeFailure(resp.status, await readBodyError(resp))}`);
127
+ }
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // Pure formatters (tested directly; no I/O)
131
+ // ---------------------------------------------------------------------------
132
+ /** Readable rendering of every section + the resolved clients line (show, no name). */
133
+ function formatAllSections(data) {
134
+ const names = Object.keys(data.sections);
135
+ const parts = [];
136
+ if (names.length === 0) {
137
+ parts.push("(no context sections)");
138
+ }
139
+ else {
140
+ for (const name of names) {
141
+ const body = data.sections[name];
142
+ parts.push(`## ${name}`, "", body.endsWith("\n") ? body.trimEnd() : body, "");
143
+ }
144
+ }
145
+ parts.push(`clients: ${data.clients.join(", ") || "(none)"}`);
146
+ return parts.join("\n");
147
+ }
148
+ /** Raw markdown for one section (show <name>), or null if it does not exist. */
149
+ function formatOneSection(data, name) {
150
+ if (!(name in data.sections))
151
+ return null;
152
+ return data.sections[name];
153
+ }
154
+ // ---------------------------------------------------------------------------
155
+ // Editor seam
156
+ // ---------------------------------------------------------------------------
157
+ /** Ordered editor candidates: $EDITOR → nano → vi (spec §6). */
158
+ function candidateEditors() {
159
+ const list = [];
160
+ const env = process.env.EDITOR?.trim();
161
+ if (env)
162
+ list.push(env);
163
+ list.push("nano", "vi");
164
+ return list;
165
+ }
166
+ const defaultSpawn = (file) => {
167
+ for (const ed of candidateEditors()) {
168
+ const [cmd, ...prefixArgs] = ed.split(/\s+/);
169
+ const r = (0, node_child_process_1.spawnSync)(cmd, [...prefixArgs, file], { stdio: "inherit" });
170
+ if (r.error) {
171
+ if (r.error.code === "ENOENT")
172
+ continue; // not installed → next
173
+ return false; // spawned but failed for another reason
174
+ }
175
+ return true; // editor ran (any exit code — e.g. `:q` in vi)
176
+ }
177
+ return false;
178
+ };
179
+ // ---------------------------------------------------------------------------
180
+ // Commands
181
+ // ---------------------------------------------------------------------------
182
+ async function runShow(name) {
183
+ const data = await getContext(resolveContextTarget(loadConfig()));
184
+ if (name) {
185
+ const body = formatOneSection(data, name);
186
+ if (body === null) {
187
+ const avail = Object.keys(data.sections).join(", ") || "(none)";
188
+ process.stderr.write(`Section '${name}' not found. Available: ${avail}\n`);
189
+ process.exit(1);
190
+ }
191
+ process.stdout.write(body.endsWith("\n") ? body : body + "\n");
192
+ return;
193
+ }
194
+ process.stdout.write(formatAllSections(data) + "\n");
195
+ }
196
+ /**
197
+ * `edit <name>`: validate name (fast-fail; the server enforces too) → fetch
198
+ * current content → $EDITOR on a temp file → PUT only if changed. Temp file is
199
+ * always cleaned up. `spawn` is injectable for tests.
200
+ */
201
+ async function runEdit(name, spawn = defaultSpawn) {
202
+ if (!(0, context_store_js_1.isValidSectionName)(name)) {
203
+ throw new ContextCliError(`Invalid section name '${name}'. Must match ^[a-z0-9][a-z0-9_-]*$ (max ${context_store_js_1.SECTION_NAME_MAX} chars).`);
204
+ }
205
+ const target = resolveContextTarget(loadConfig());
206
+ const data = await getContext(target);
207
+ const before = data.sections[name] ?? "";
208
+ const dir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "hicortex-ctx-"));
209
+ const file = (0, node_path_1.join)(dir, `${name}.md`);
210
+ try {
211
+ (0, node_fs_1.writeFileSync)(file, before, "utf-8");
212
+ const ran = spawn(file);
213
+ if (!ran) {
214
+ throw new ContextCliError("No editor available. Set $EDITOR, or install nano or vi.");
215
+ }
216
+ const after = (0, node_fs_1.readFileSync)(file, "utf-8");
217
+ if (!sectionChanged(before, after)) {
218
+ process.stdout.write("no changes\n");
219
+ return;
220
+ }
221
+ await putSection(target, name, after);
222
+ process.stdout.write(`Saved section '${name}'.\n`);
223
+ }
224
+ finally {
225
+ (0, node_fs_1.rmSync)(dir, { recursive: true, force: true });
226
+ }
227
+ }
228
+ /** Dispatch for `hicortex context <sub>`. Throws ContextCliError on bad usage/failure. */
229
+ async function runContextCommand(args) {
230
+ const sub = args[0];
231
+ switch (sub) {
232
+ case "show":
233
+ await runShow(args[1]);
234
+ return;
235
+ case "edit":
236
+ if (!args[1])
237
+ throw new ContextCliError("Usage: hicortex context edit <name>");
238
+ await runEdit(args[1]);
239
+ return;
240
+ default:
241
+ throw new ContextCliError("Usage: hicortex context <show [name] | edit <name>>");
242
+ }
243
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Context layer (L2) — file-backed storage (0.12.0, spec 2026-07-12).
3
+ *
4
+ * The context layer is hand-edited Markdown ("who you are + how to work") stored
5
+ * as plain files in `<hicortex-home>/context/*.md` — one file per section, one
6
+ * Web UI tab. It lives OUTSIDE the memories table: never distilled, scored,
7
+ * decayed, or pruned. As plain files this guarantee is structural — no code in
8
+ * consolidate.ts / distiller.ts references this directory.
9
+ *
10
+ * This module is pure file-layer logic (no express) so it is unit-testable and
11
+ * reusable by the CLI. The daemon resolves `<hicortex-home>` exactly as it
12
+ * resolves the DB dir (dirname of the resolved DB path) and passes the context
13
+ * dir in — nothing here hardcodes `~`.
14
+ *
15
+ * Security contract (spec §1): client-supplied section names are NEVER joined
16
+ * into a filesystem path except after passing the strict allowlist; the server
17
+ * itself appends `.md`. Reads skip symlinks (lstat, not stat). Writes validate
18
+ * ALL names before touching the disk (atomic request semantics), go via
19
+ * temp-file-then-rename, and keep a one-generation `<name>.md.bak` undo.
20
+ */
21
+ /** A valid section name: lowercase alnum start, then alnum / `_` / `-`. */
22
+ export declare const SECTION_NAME_RE: RegExp;
23
+ /** Max section-name length (excludes the server-appended `.md`). */
24
+ export declare const SECTION_NAME_MAX = 64;
25
+ /**
26
+ * True when `name` is a valid section name — matches the allowlist and is
27
+ * 1..64 chars. Rejects traversal (`../x`, `a/b`), uppercase, leading `_`/`-`,
28
+ * empty, and over-long names. The server appends `.md`; the name is never
29
+ * otherwise joined into a path.
30
+ */
31
+ export declare function isValidSectionName(name: string): boolean;
32
+ /** Thrown by writeSections when any supplied name fails the allowlist. */
33
+ export declare class InvalidSectionNameError extends Error {
34
+ readonly names: string[];
35
+ constructor(names: string[]);
36
+ }
37
+ export interface ReadResult {
38
+ sections: Record<string, string>;
39
+ /** ISO timestamp of the latest included file's mtime, or null when none. */
40
+ updatedAt: string | null;
41
+ }
42
+ /**
43
+ * Enumerate the context dir and return the served sections. Only regular files
44
+ * whose basename (sans `.md`) passes the allowlist are included; symlinks are
45
+ * skipped (lstat). `<name>.md.bak` and temp files never match the `*.md` filter.
46
+ *
47
+ * Fail-soft: a missing dir (fresh install) returns `{ sections: {}, updatedAt:
48
+ * null }` and NEVER creates the dir or throws.
49
+ */
50
+ export declare function readSections(dir: string): ReadResult;
51
+ /**
52
+ * Partial upsert of the named sections. Contract (spec §1):
53
+ * - Validate ALL names first; any invalid → throw InvalidSectionNameError and
54
+ * write NOTHING (atomic request semantics).
55
+ * - Only the named sections are touched; omitted sections stay untouched
56
+ * (omission is not deletion — deletion is filesystem-only).
57
+ * - Each write goes temp-file-then-rename in the same dir (no half-applied
58
+ * reads on GET-during-PUT).
59
+ * - After committing a write over an EXISTING file, the prior content is kept
60
+ * as `<name>.md.bak` (one-generation undo; `.bak` never matches the read
61
+ * `*.md` filter).
62
+ * - Empty-string content is allowed (clears the file).
63
+ * Creates the dir (recursive) on first write.
64
+ *
65
+ * Failure semantics:
66
+ * - Invalid names → nothing written (validated up front).
67
+ * - All new content is written to temp files FIRST (phase A); if any temp
68
+ * write fails (disk full / EIO), nothing is committed and temps are cleaned
69
+ * up. This makes the common failure atomic. A rename failure during the
70
+ * commit loop (phase B) — rare for a same-dir rename — can still leave
71
+ * earlier sections of a MULTI-section write committed; single-section PUT
72
+ * (the norm from the UI/CLI) is fully all-or-nothing.
73
+ * - The `.bak` is written via its own temp+rename and only AFTER the main
74
+ * rename commits, so (a) a failed write never destroys the existing undo
75
+ * generation and (b) a symlink planted at the `.bak` path is replaced, never
76
+ * written THROUGH (never-follow-symlinks-on-write).
77
+ */
78
+ export declare function writeSections(dir: string, sections: Record<string, string>): void;
79
+ /** Total UTF-8 byte size of all sections (used for the >16 KB warn). */
80
+ export declare function totalBytes(sections: Record<string, string>): number;
81
+ /** Warn threshold: this layer bypasses token budgeting and injects every session. */
82
+ export declare const CONTEXT_SIZE_WARN_BYTES = 16384;
83
+ /** Harness names that may inject the context layer. */
84
+ export declare const KNOWN_CONTEXT_CLIENTS: readonly ["cc", "hermes", "oc"];
85
+ export interface ResolvedContextClients {
86
+ /** The resolved, de-duped list of known client names. */
87
+ clients: string[];
88
+ /** Unknown names dropped from an array value (for a one-time boot warning). */
89
+ dropped: string[];
90
+ }
91
+ /**
92
+ * Normalize the raw `contextClients` config value (spec §2):
93
+ * - `"all"` (any case) → ["cc","hermes","oc"]
94
+ * - array → lowercase, keep known names (de-duped), collect dropped unknowns
95
+ * - missing / non-array-non-"all" → default ["cc"]
96
+ * The resolved list is echoed by GET /context as `clients` so each harness's
97
+ * hook can self-gate without its own config.
98
+ */
99
+ export declare function resolveContextClients(raw: unknown): ResolvedContextClients;
100
+ export interface HandlerResult {
101
+ status: number;
102
+ body: unknown;
103
+ /** When set, the adapter should console.warn this (size warning). */
104
+ warn?: string;
105
+ }
106
+ /**
107
+ * GET /context. Stale-client tripwire first: recall moved to /recent, so
108
+ * project/limit/privacy on this route mean a legacy recall caller — return a
109
+ * loud 400 rather than silently degrading to an empty context-layer response.
110
+ * (A bare GET /context with no params is the legitimate context-layer read and
111
+ * is served normally — the two are indistinguishable at the wire, so a
112
+ * paramless legacy caller is covered by the migration docs, not this guard.)
113
+ */
114
+ export declare function handleContextGet(contextDir: string, clients: string[], query: Record<string, unknown>): HandlerResult;
115
+ /**
116
+ * PUT /context. Validates the body shape and section content types, then
117
+ * delegates to writeSections (which owns the name allowlist + atomicity +
118
+ * symlink safety). Throws are left to the adapter to turn into a 500.
119
+ */
120
+ export declare function handleContextPut(contextDir: string, body: unknown): HandlerResult;
@@ -0,0 +1,321 @@
1
+ "use strict";
2
+ /**
3
+ * Context layer (L2) — file-backed storage (0.12.0, spec 2026-07-12).
4
+ *
5
+ * The context layer is hand-edited Markdown ("who you are + how to work") stored
6
+ * as plain files in `<hicortex-home>/context/*.md` — one file per section, one
7
+ * Web UI tab. It lives OUTSIDE the memories table: never distilled, scored,
8
+ * decayed, or pruned. As plain files this guarantee is structural — no code in
9
+ * consolidate.ts / distiller.ts references this directory.
10
+ *
11
+ * This module is pure file-layer logic (no express) so it is unit-testable and
12
+ * reusable by the CLI. The daemon resolves `<hicortex-home>` exactly as it
13
+ * resolves the DB dir (dirname of the resolved DB path) and passes the context
14
+ * dir in — nothing here hardcodes `~`.
15
+ *
16
+ * Security contract (spec §1): client-supplied section names are NEVER joined
17
+ * into a filesystem path except after passing the strict allowlist; the server
18
+ * itself appends `.md`. Reads skip symlinks (lstat, not stat). Writes validate
19
+ * ALL names before touching the disk (atomic request semantics), go via
20
+ * temp-file-then-rename, and keep a one-generation `<name>.md.bak` undo.
21
+ */
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;
24
+ exports.isValidSectionName = isValidSectionName;
25
+ exports.readSections = readSections;
26
+ exports.writeSections = writeSections;
27
+ exports.totalBytes = totalBytes;
28
+ exports.resolveContextClients = resolveContextClients;
29
+ exports.handleContextGet = handleContextGet;
30
+ exports.handleContextPut = handleContextPut;
31
+ const node_fs_1 = require("node:fs");
32
+ const node_path_1 = require("node:path");
33
+ const node_crypto_1 = require("node:crypto");
34
+ // ---------------------------------------------------------------------------
35
+ // Section-name allowlist (security contract)
36
+ // ---------------------------------------------------------------------------
37
+ /** A valid section name: lowercase alnum start, then alnum / `_` / `-`. */
38
+ exports.SECTION_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
39
+ /** Max section-name length (excludes the server-appended `.md`). */
40
+ exports.SECTION_NAME_MAX = 64;
41
+ /**
42
+ * True when `name` is a valid section name — matches the allowlist and is
43
+ * 1..64 chars. Rejects traversal (`../x`, `a/b`), uppercase, leading `_`/`-`,
44
+ * empty, and over-long names. The server appends `.md`; the name is never
45
+ * otherwise joined into a path.
46
+ */
47
+ function isValidSectionName(name) {
48
+ return typeof name === "string" && name.length >= 1 && name.length <= exports.SECTION_NAME_MAX && exports.SECTION_NAME_RE.test(name);
49
+ }
50
+ /** Thrown by writeSections when any supplied name fails the allowlist. */
51
+ class InvalidSectionNameError extends Error {
52
+ names;
53
+ constructor(names) {
54
+ super(`Invalid section name(s): ${names.join(", ")}`);
55
+ this.names = names;
56
+ this.name = "InvalidSectionNameError";
57
+ }
58
+ }
59
+ exports.InvalidSectionNameError = InvalidSectionNameError;
60
+ /**
61
+ * Enumerate the context dir and return the served sections. Only regular files
62
+ * whose basename (sans `.md`) passes the allowlist are included; symlinks are
63
+ * skipped (lstat). `<name>.md.bak` and temp files never match the `*.md` filter.
64
+ *
65
+ * Fail-soft: a missing dir (fresh install) returns `{ sections: {}, updatedAt:
66
+ * null }` and NEVER creates the dir or throws.
67
+ */
68
+ function readSections(dir) {
69
+ let entries;
70
+ try {
71
+ entries = (0, node_fs_1.readdirSync)(dir);
72
+ }
73
+ catch (err) {
74
+ // ENOENT = fresh install (dir not created yet) → fail-soft empty. Any other
75
+ // error (EACCES, EIO, ENOTDIR) is a real fault: surface it (the route turns
76
+ // it into a 500) rather than masquerading a permissions problem as "empty".
77
+ if (err.code === "ENOENT")
78
+ return { sections: {}, updatedAt: null };
79
+ throw err;
80
+ }
81
+ const sections = {};
82
+ let latestMtimeMs = 0;
83
+ for (const file of entries) {
84
+ // Full-suffix check: only ".md" — excludes "<name>.md.bak" and temp files.
85
+ if (!file.endsWith(".md"))
86
+ continue;
87
+ const name = file.slice(0, -".md".length);
88
+ if (!isValidSectionName(name))
89
+ continue;
90
+ const full = (0, node_path_1.join)(dir, file);
91
+ let st;
92
+ try {
93
+ // lstat (not stat): a symlink must be skipped, not followed.
94
+ st = (0, node_fs_1.lstatSync)(full);
95
+ }
96
+ catch {
97
+ continue;
98
+ }
99
+ if (st.isSymbolicLink() || !st.isFile())
100
+ continue;
101
+ try {
102
+ sections[name] = (0, node_fs_1.readFileSync)(full, "utf-8");
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ if (st.mtimeMs > latestMtimeMs)
108
+ latestMtimeMs = st.mtimeMs;
109
+ }
110
+ const updatedAt = Object.keys(sections).length > 0 ? new Date(latestMtimeMs).toISOString() : null;
111
+ return { sections, updatedAt };
112
+ }
113
+ // ---------------------------------------------------------------------------
114
+ // Write
115
+ // ---------------------------------------------------------------------------
116
+ /**
117
+ * Partial upsert of the named sections. Contract (spec §1):
118
+ * - Validate ALL names first; any invalid → throw InvalidSectionNameError and
119
+ * write NOTHING (atomic request semantics).
120
+ * - Only the named sections are touched; omitted sections stay untouched
121
+ * (omission is not deletion — deletion is filesystem-only).
122
+ * - Each write goes temp-file-then-rename in the same dir (no half-applied
123
+ * reads on GET-during-PUT).
124
+ * - After committing a write over an EXISTING file, the prior content is kept
125
+ * as `<name>.md.bak` (one-generation undo; `.bak` never matches the read
126
+ * `*.md` filter).
127
+ * - Empty-string content is allowed (clears the file).
128
+ * Creates the dir (recursive) on first write.
129
+ *
130
+ * Failure semantics:
131
+ * - Invalid names → nothing written (validated up front).
132
+ * - All new content is written to temp files FIRST (phase A); if any temp
133
+ * write fails (disk full / EIO), nothing is committed and temps are cleaned
134
+ * up. This makes the common failure atomic. A rename failure during the
135
+ * commit loop (phase B) — rare for a same-dir rename — can still leave
136
+ * earlier sections of a MULTI-section write committed; single-section PUT
137
+ * (the norm from the UI/CLI) is fully all-or-nothing.
138
+ * - The `.bak` is written via its own temp+rename and only AFTER the main
139
+ * rename commits, so (a) a failed write never destroys the existing undo
140
+ * generation and (b) a symlink planted at the `.bak` path is replaced, never
141
+ * written THROUGH (never-follow-symlinks-on-write).
142
+ */
143
+ function writeSections(dir, sections) {
144
+ const invalid = Object.keys(sections).filter((n) => !isValidSectionName(n));
145
+ if (invalid.length > 0)
146
+ throw new InvalidSectionNameError(invalid);
147
+ (0, node_fs_1.mkdirSync)(dir, { recursive: true });
148
+ const suffix = `${process.pid}-${(0, node_crypto_1.randomBytes)(6).toString("hex")}`;
149
+ const staged = [];
150
+ try {
151
+ // Phase A — write every section to a temp file. The temp name never ends
152
+ // in exactly ".md", so a leftover temp is invisible to readSections. If any
153
+ // write throws, the finally block cleans up and NOTHING is committed.
154
+ for (const [name, content] of Object.entries(sections)) {
155
+ const target = (0, node_path_1.join)(dir, `${name}.md`);
156
+ const tmp = (0, node_path_1.join)(dir, `.${name}.md.tmp-${suffix}`);
157
+ (0, node_fs_1.writeFileSync)(tmp, content, "utf-8");
158
+ staged.push({ name, tmp, target });
159
+ }
160
+ // Phase B — commit. Capture prior content BEFORE the rename, back it up
161
+ // only AFTER the rename succeeds.
162
+ for (const { name, tmp, target } of staged) {
163
+ let prior = null;
164
+ try {
165
+ const st = (0, node_fs_1.lstatSync)(target);
166
+ // Real file → keep its content for the undo. A symlink is neither
167
+ // followed nor read through: renameSync below atomically replaces the
168
+ // symlink itself with our regular file, neutralizing a planted link.
169
+ if (st.isFile() && !st.isSymbolicLink())
170
+ prior = (0, node_fs_1.readFileSync)(target);
171
+ }
172
+ catch {
173
+ // no prior file
174
+ }
175
+ (0, node_fs_1.renameSync)(tmp, target); // commit (atomic; replaces a symlink, never follows it)
176
+ if (prior !== null) {
177
+ // Update the one-generation undo via temp+rename so we never write
178
+ // through a planted symlink at the .bak path. Best-effort: a backup
179
+ // failure must not fail the (already committed) write.
180
+ const bak = (0, node_path_1.join)(dir, `${name}.md.bak`);
181
+ const bakTmp = (0, node_path_1.join)(dir, `.${name}.md.bak.tmp-${suffix}`);
182
+ try {
183
+ (0, node_fs_1.writeFileSync)(bakTmp, prior);
184
+ (0, node_fs_1.renameSync)(bakTmp, bak);
185
+ }
186
+ catch {
187
+ try {
188
+ if ((0, node_fs_1.existsSync)(bakTmp))
189
+ (0, node_fs_1.unlinkSync)(bakTmp);
190
+ }
191
+ catch { /* ignore */ }
192
+ }
193
+ }
194
+ }
195
+ }
196
+ finally {
197
+ // Remove any temp that never got renamed into place (phase-A/B failure).
198
+ for (const { tmp } of staged) {
199
+ try {
200
+ if ((0, node_fs_1.existsSync)(tmp))
201
+ (0, node_fs_1.unlinkSync)(tmp);
202
+ }
203
+ catch { /* ignore */ }
204
+ }
205
+ }
206
+ }
207
+ /** Total UTF-8 byte size of all sections (used for the >16 KB warn). */
208
+ function totalBytes(sections) {
209
+ let total = 0;
210
+ for (const content of Object.values(sections))
211
+ total += Buffer.byteLength(content, "utf-8");
212
+ return total;
213
+ }
214
+ /** Warn threshold: this layer bypasses token budgeting and injects every session. */
215
+ exports.CONTEXT_SIZE_WARN_BYTES = 16_384;
216
+ // ---------------------------------------------------------------------------
217
+ // Config — contextClients normalization
218
+ // ---------------------------------------------------------------------------
219
+ /** Harness names that may inject the context layer. */
220
+ exports.KNOWN_CONTEXT_CLIENTS = ["cc", "hermes", "oc"];
221
+ /**
222
+ * Normalize the raw `contextClients` config value (spec §2):
223
+ * - `"all"` (any case) → ["cc","hermes","oc"]
224
+ * - array → lowercase, keep known names (de-duped), collect dropped unknowns
225
+ * - missing / non-array-non-"all" → default ["cc"]
226
+ * The resolved list is echoed by GET /context as `clients` so each harness's
227
+ * hook can self-gate without its own config.
228
+ */
229
+ function resolveContextClients(raw) {
230
+ if (typeof raw === "string" && raw.toLowerCase() === "all") {
231
+ return { clients: [...exports.KNOWN_CONTEXT_CLIENTS], dropped: [] };
232
+ }
233
+ if (Array.isArray(raw)) {
234
+ const known = new Set(exports.KNOWN_CONTEXT_CLIENTS);
235
+ const clients = [];
236
+ const dropped = [];
237
+ for (const item of raw) {
238
+ if (typeof item !== "string") {
239
+ dropped.push(String(item));
240
+ continue;
241
+ }
242
+ const lower = item.toLowerCase();
243
+ // "all" as an array member expands to every known client — the array is
244
+ // the natural form of the documented "all" value, so ["all"] must mean
245
+ // all, not an empty list.
246
+ if (lower === "all") {
247
+ for (const k of exports.KNOWN_CONTEXT_CLIENTS)
248
+ if (!clients.includes(k))
249
+ clients.push(k);
250
+ continue;
251
+ }
252
+ if (known.has(lower)) {
253
+ if (!clients.includes(lower))
254
+ clients.push(lower);
255
+ }
256
+ else {
257
+ dropped.push(item);
258
+ }
259
+ }
260
+ return { clients, dropped };
261
+ }
262
+ return { clients: ["cc"], dropped: [] };
263
+ }
264
+ // ---------------------------------------------------------------------------
265
+ // HTTP-shape handlers (real logic behind GET/PUT /context)
266
+ // ---------------------------------------------------------------------------
267
+ //
268
+ // These are the actual request handlers, expressed as pure functions over
269
+ // plain inputs so they are unit-tested directly (no mirror app that can drift
270
+ // from mcp-server.ts). mcp-server.ts wires req/res to them and nothing else.
271
+ /** Recall query params whose presence means a stale pre-0.12 recall caller. */
272
+ const RECALL_PARAMS = ["project", "limit", "privacy"];
273
+ /**
274
+ * GET /context. Stale-client tripwire first: recall moved to /recent, so
275
+ * project/limit/privacy on this route mean a legacy recall caller — return a
276
+ * loud 400 rather than silently degrading to an empty context-layer response.
277
+ * (A bare GET /context with no params is the legitimate context-layer read and
278
+ * is served normally — the two are indistinguishable at the wire, so a
279
+ * paramless legacy caller is covered by the migration docs, not this guard.)
280
+ */
281
+ function handleContextGet(contextDir, clients, query) {
282
+ if (RECALL_PARAMS.some((p) => p in query)) {
283
+ return {
284
+ status: 400,
285
+ body: { error: "recall moved to /recent — GET /context now serves the standing context layer (0.12)" },
286
+ };
287
+ }
288
+ const { sections, updatedAt } = readSections(contextDir);
289
+ return { status: 200, body: { sections, updated_at: updatedAt, clients } };
290
+ }
291
+ /**
292
+ * PUT /context. Validates the body shape and section content types, then
293
+ * delegates to writeSections (which owns the name allowlist + atomicity +
294
+ * symlink safety). Throws are left to the adapter to turn into a 500.
295
+ */
296
+ function handleContextPut(contextDir, body) {
297
+ const sections = body?.sections;
298
+ if (!sections || typeof sections !== "object" || Array.isArray(sections)) {
299
+ return { status: 400, body: { error: "Missing or invalid 'sections' object" } };
300
+ }
301
+ for (const [name, content] of Object.entries(sections)) {
302
+ if (typeof content !== "string") {
303
+ return { status: 400, body: { error: `Section '${name}' content must be a string` } };
304
+ }
305
+ }
306
+ try {
307
+ writeSections(contextDir, sections);
308
+ }
309
+ catch (err) {
310
+ if (err instanceof InvalidSectionNameError) {
311
+ return { status: 400, body: { error: `Invalid section name(s): ${err.names.join(", ")}` } };
312
+ }
313
+ throw err; // real I/O fault → adapter returns 500
314
+ }
315
+ const { sections: onDisk, updatedAt } = readSections(contextDir);
316
+ const bytes = totalBytes(onDisk);
317
+ const warn = bytes > exports.CONTEXT_SIZE_WARN_BYTES
318
+ ? `Context layer total size ${bytes} bytes exceeds ${exports.CONTEXT_SIZE_WARN_BYTES} — injected into every session; consider trimming.`
319
+ : undefined;
320
+ return { status: 200, body: { ok: true, updated_at: updatedAt }, warn };
321
+ }