@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.
- package/README.md +16 -2
- package/assets/context.html +487 -0
- package/dist/cli.js +18 -0
- package/dist/context-cli.d.ts +58 -0
- package/dist/context-cli.js +243 -0
- package/dist/context-store.d.ts +120 -0
- package/dist/context-store.js +321 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +37 -20
- package/dist/init.js +22 -9
- package/dist/lessons-context.d.ts +20 -7
- package/dist/lessons-context.js +127 -29
- package/dist/mcp-server.js +69 -7
- package/dist/nightly-status.js +6 -0
- package/dist/nightly.js +19 -0
- package/dist/uninstall.js +29 -13
- package/dist/viz.d.ts +15 -0
- package/dist/viz.js +48 -0
- package/hermes-plugin/hicortex/README.md +4 -5
- package/hermes-plugin/hicortex/__init__.py +1 -1
- package/hermes-plugin/hicortex/client.py +2 -2
- package/hermes-plugin/hicortex/plugin.yaml +2 -2
- package/hermes-plugin/hicortex/provider.py +8 -21
- package/package.json +1 -1
- package/skills/hicortex-memory/SKILL.md +1 -1
|
@@ -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
|
+
}
|