@gamaze/hicortex 0.18.2 → 0.18.3
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/dist/distiller.d.ts +4 -2
- package/dist/distiller.js +13 -5
- package/dist/hosted-boot.d.ts +61 -0
- package/dist/hosted-boot.js +72 -0
- package/dist/init.d.ts +15 -0
- package/dist/init.js +99 -8
- package/dist/localhost-bypass.d.ts +27 -0
- package/dist/localhost-bypass.js +71 -0
- package/dist/mcp-server.js +87 -2
- package/dist/token-budget.d.ts +34 -0
- package/dist/token-budget.js +131 -0
- package/dist/types.d.ts +9 -0
- package/dist/viz.d.ts +9 -1
- package/dist/viz.js +11 -2
- package/package.json +1 -1
package/dist/distiller.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Simplified from hicortex/distiller.py — messages come from agent_end hook,
|
|
4
4
|
* not from filesystem scanning.
|
|
5
5
|
*/
|
|
6
|
-
import type { LlmClient } from "./llm.js";
|
|
6
|
+
import type { LlmClient, LlmUsage } from "./llm.js";
|
|
7
7
|
import { type RedactionConfig } from "./redact.js";
|
|
8
8
|
/**
|
|
9
9
|
* Estimate a safe chunk size in chars based on the LLM provider and model.
|
|
@@ -39,7 +39,9 @@ export declare function extractConversationText(messages: unknown[], redactionCo
|
|
|
39
39
|
* discarded (full text). Callers use it to build a durable audit trail (#156);
|
|
40
40
|
* omitting it leaves gate behaviour unchanged.
|
|
41
41
|
*/
|
|
42
|
-
export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number, droppedOut?: string[]
|
|
42
|
+
export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number, droppedOut?: string[],
|
|
43
|
+
/** Called with each chunk's token usage (#5 budget metering). Optional. */
|
|
44
|
+
onUsage?: (usage: LlmUsage) => void): Promise<DistilledEntry[]>;
|
|
43
45
|
/**
|
|
44
46
|
* Reject ONLY structurally-empty distiller fragments before they become
|
|
45
47
|
* memories (#156). The distiller occasionally emits leftovers that parse into
|
package/dist/distiller.js
CHANGED
|
@@ -226,7 +226,9 @@ function extractConversationText(messages, redactionConfig) {
|
|
|
226
226
|
* discarded (full text). Callers use it to build a durable audit trail (#156);
|
|
227
227
|
* omitting it leaves gate behaviour unchanged.
|
|
228
228
|
*/
|
|
229
|
-
async function distillSession(llm, conversation, projectName, date, chunkSizeChars, droppedOut
|
|
229
|
+
async function distillSession(llm, conversation, projectName, date, chunkSizeChars, droppedOut,
|
|
230
|
+
/** Called with each chunk's token usage (#5 budget metering). Optional. */
|
|
231
|
+
onUsage) {
|
|
230
232
|
if (conversation.length < MIN_CONVERSATION_CHARS) {
|
|
231
233
|
return [];
|
|
232
234
|
}
|
|
@@ -239,7 +241,7 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
|
|
|
239
241
|
const chunkSize = chunkSizeChars ?? MAX_TRANSCRIPT_CHARS;
|
|
240
242
|
// If transcript fits in one chunk, distill directly (errors propagate)
|
|
241
243
|
if (transcript.length <= chunkSize) {
|
|
242
|
-
const { entries, dropped } = await distillChunk(llm, transcript, projectName, date);
|
|
244
|
+
const { entries, dropped } = await distillChunk(llm, transcript, projectName, date, onUsage);
|
|
243
245
|
if (droppedOut)
|
|
244
246
|
droppedOut.push(...dropped);
|
|
245
247
|
return entries;
|
|
@@ -261,7 +263,7 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
|
|
|
261
263
|
for (let i = 0; i < chunks.length; i++) {
|
|
262
264
|
console.log(`[hicortex] Chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
|
|
263
265
|
try {
|
|
264
|
-
const { entries, dropped } = await distillChunk(llm, chunks[i], projectName, date);
|
|
266
|
+
const { entries, dropped } = await distillChunk(llm, chunks[i], projectName, date, onUsage);
|
|
265
267
|
if (droppedOut)
|
|
266
268
|
droppedOut.push(...dropped);
|
|
267
269
|
for (const entry of entries) {
|
|
@@ -308,13 +310,19 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
|
|
|
308
310
|
* `dropped` carries entries the substance gate rejected (full text) so the
|
|
309
311
|
* caller can surface them in a durable audit trail (#156).
|
|
310
312
|
*/
|
|
311
|
-
async function distillChunk(llm, transcript, projectName, date) {
|
|
313
|
+
async function distillChunk(llm, transcript, projectName, date, onUsage) {
|
|
312
314
|
const prompt = (0, prompts_js_1.distillation)(projectName, date, transcript);
|
|
313
315
|
// NOTE: Intentionally no try/catch here. Transient LLM errors (network
|
|
314
316
|
// failures, 4xx/5xx, model-not-found, timeouts) propagate up to the caller
|
|
315
317
|
// so the nightly pipeline can treat them as "retry later" instead of
|
|
316
318
|
// "processed successfully with zero extractions".
|
|
317
|
-
const { text: result } = await llm.completeDistill(prompt);
|
|
319
|
+
const { text: result, usage } = await llm.completeDistill(prompt);
|
|
320
|
+
// #5: report this chunk's token usage to the caller's budget meter. Optional
|
|
321
|
+
// (absent for callers that don't meter); a missing/undefined usage (claude-cli)
|
|
322
|
+
// is a no-op — consistent with the existing design that such tenants never
|
|
323
|
+
// trip a budget.
|
|
324
|
+
if (usage && onUsage)
|
|
325
|
+
onUsage(usage);
|
|
318
326
|
if (!result)
|
|
319
327
|
return { entries: [], dropped: [] };
|
|
320
328
|
if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hosted-mode boot assertions (#110 §1-§2, #271 — Phase 0B).
|
|
3
|
+
*
|
|
4
|
+
* Pure decision function — the side-effect (console.error + process.exit) is
|
|
5
|
+
* the caller's job (mcp-server.ts at boot), so the assertion logic is unit-
|
|
6
|
+
* testable in-process without spawning a child or intercepting process.exit.
|
|
7
|
+
*
|
|
8
|
+
* INERT unless hostedMode is true (self-hosted default). When true, the server
|
|
9
|
+
* must refuse to start under either condition:
|
|
10
|
+
* - HICORTEX_DB_PATH set (a tenant must not be redirectable to an attacker-
|
|
11
|
+
* chosen DB location — path-override attack);
|
|
12
|
+
* - the localhost auth-bypass marker file present (hosted is fail-closed —
|
|
13
|
+
* no bypass; a tenant dir provisioned from a restored tar could otherwise
|
|
14
|
+
* ship with the bypass active).
|
|
15
|
+
*
|
|
16
|
+
* Spec: specs/2026-07-27-hosted-service.md §1-§2 (Phase 0B, issue #271).
|
|
17
|
+
*/
|
|
18
|
+
export interface HostedBootInput {
|
|
19
|
+
/** Resolved hostedMode flag from config (absent/false → self-hosted). */
|
|
20
|
+
hostedMode: boolean;
|
|
21
|
+
/** Whether HICORTEX_DB_PATH is currently set in the environment. */
|
|
22
|
+
dbPathEnvSet: boolean;
|
|
23
|
+
/** Whether the localhost-bypass marker file exists in the home dir. */
|
|
24
|
+
bypassMarkerPresent: boolean;
|
|
25
|
+
}
|
|
26
|
+
export type HostedBootDecision = {
|
|
27
|
+
ok: true;
|
|
28
|
+
hostedMode: boolean;
|
|
29
|
+
} | {
|
|
30
|
+
ok: false;
|
|
31
|
+
hostedMode: true;
|
|
32
|
+
reason: "db-path-override" | "bypass-marker";
|
|
33
|
+
message: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Decide whether the server may boot under hosted-mode constraints. Returns
|
|
37
|
+
* `{ok:true}` for self-hosted (always — assertions never fire) or hosted with
|
|
38
|
+
* a clean environment; returns `{ok:false, message}` when a hosted constraint
|
|
39
|
+
* is violated (caller logs + exits non-zero).
|
|
40
|
+
*/
|
|
41
|
+
export declare function checkHostedBoot(input: HostedBootInput): HostedBootDecision;
|
|
42
|
+
/**
|
|
43
|
+
* Decide whether to emit the "Localhost auth bypass is disabled" boot warning
|
|
44
|
+
* (#271 — CR warning 4). Pure: the caller owns the console.warn side-effect,
|
|
45
|
+
* so this is unit-testable across the four input combinations without spawning
|
|
46
|
+
* a process or capturing stderr.
|
|
47
|
+
*
|
|
48
|
+
* Emits ONLY in self-hosted mode when the bypass marker is absent — the upgrade
|
|
49
|
+
* path (a user who upgraded without re-running init loses the bypass and sees
|
|
50
|
+
* 401s from localhost). Returns null in every other state:
|
|
51
|
+
* - self-hosted + marker present: bypass active, nothing to warn about;
|
|
52
|
+
* - hosted + marker absent: hosted is fail-closed by design, no bypass to warn;
|
|
53
|
+
* - hosted + marker present: checkHostedBoot already refused (unreachable here
|
|
54
|
+
* when called after a passed boot decision), and the failure message is the
|
|
55
|
+
* operator-facing one — a second warning would be noise.
|
|
56
|
+
*
|
|
57
|
+
* The marker is read from the canonical Hicortex home (HICORTEX_HOME), matching
|
|
58
|
+
* where `init` writes it — NOT from stateDir, which can drift when
|
|
59
|
+
* HICORTEX_DB_PATH relocates the DB (#271 CR warning 1).
|
|
60
|
+
*/
|
|
61
|
+
export declare function shouldEmitBypassWarning(hostedMode: boolean, bypassMarkerPresent: boolean): string | null;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkHostedBoot = checkHostedBoot;
|
|
4
|
+
exports.shouldEmitBypassWarning = shouldEmitBypassWarning;
|
|
5
|
+
/**
|
|
6
|
+
* Decide whether the server may boot under hosted-mode constraints. Returns
|
|
7
|
+
* `{ok:true}` for self-hosted (always — assertions never fire) or hosted with
|
|
8
|
+
* a clean environment; returns `{ok:false, message}` when a hosted constraint
|
|
9
|
+
* is violated (caller logs + exits non-zero).
|
|
10
|
+
*/
|
|
11
|
+
function checkHostedBoot(input) {
|
|
12
|
+
// KNOWN ESCAPE HATCH (CR M1, deferred to #110 Phase 0B item #2 — Docker):
|
|
13
|
+
// HICORTEX_HOME is the same class of env-var redirect as HICORTEX_DB_PATH
|
|
14
|
+
// (paths.ts honors it → a tenant who sets it points hostedMode/marker reads
|
|
15
|
+
// at an attacker-chosen dir with no config → hostedMode reads false → every
|
|
16
|
+
// assertion bypassed). It is NOT refused here because the per-tenant Docker
|
|
17
|
+
// template (#2) may legitimately use HICORTEX_HOME to give each tenant its
|
|
18
|
+
// own home dir. Resolution belongs with #2's tenant-home provisioning: either
|
|
19
|
+
// the orchestrator sanitizes HICORTEX_HOME (container sets it, tenant can't
|
|
20
|
+
// override), or this gate refuses it once the Docker design lands. Do NOT
|
|
21
|
+
// ship a hosted tenant before that decision is made.
|
|
22
|
+
const { hostedMode, dbPathEnvSet, bypassMarkerPresent } = input;
|
|
23
|
+
if (!hostedMode)
|
|
24
|
+
return { ok: true, hostedMode: false };
|
|
25
|
+
if (dbPathEnvSet) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
hostedMode: true,
|
|
29
|
+
reason: "db-path-override",
|
|
30
|
+
message: `[hicortex] hostedMode is ON but HICORTEX_DB_PATH is set. ` +
|
|
31
|
+
`Hosted tenants must not allow DB-path overrides — refusing to start. ` +
|
|
32
|
+
`Unset HICORTEX_DB_PATH on hosted tenants.`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
if (bypassMarkerPresent) {
|
|
36
|
+
return {
|
|
37
|
+
ok: false,
|
|
38
|
+
hostedMode: true,
|
|
39
|
+
reason: "bypass-marker",
|
|
40
|
+
message: `[hicortex] hostedMode is ON but the localhost auth-bypass marker file ` +
|
|
41
|
+
`(.allow-localhost-bypass) is present. Hosted must be fail-closed — ` +
|
|
42
|
+
`refusing to start. Remove the marker file.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return { ok: true, hostedMode: true };
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Decide whether to emit the "Localhost auth bypass is disabled" boot warning
|
|
49
|
+
* (#271 — CR warning 4). Pure: the caller owns the console.warn side-effect,
|
|
50
|
+
* so this is unit-testable across the four input combinations without spawning
|
|
51
|
+
* a process or capturing stderr.
|
|
52
|
+
*
|
|
53
|
+
* Emits ONLY in self-hosted mode when the bypass marker is absent — the upgrade
|
|
54
|
+
* path (a user who upgraded without re-running init loses the bypass and sees
|
|
55
|
+
* 401s from localhost). Returns null in every other state:
|
|
56
|
+
* - self-hosted + marker present: bypass active, nothing to warn about;
|
|
57
|
+
* - hosted + marker absent: hosted is fail-closed by design, no bypass to warn;
|
|
58
|
+
* - hosted + marker present: checkHostedBoot already refused (unreachable here
|
|
59
|
+
* when called after a passed boot decision), and the failure message is the
|
|
60
|
+
* operator-facing one — a second warning would be noise.
|
|
61
|
+
*
|
|
62
|
+
* The marker is read from the canonical Hicortex home (HICORTEX_HOME), matching
|
|
63
|
+
* where `init` writes it — NOT from stateDir, which can drift when
|
|
64
|
+
* HICORTEX_DB_PATH relocates the DB (#271 CR warning 1).
|
|
65
|
+
*/
|
|
66
|
+
function shouldEmitBypassWarning(hostedMode, bypassMarkerPresent) {
|
|
67
|
+
if (!hostedMode && !bypassMarkerPresent) {
|
|
68
|
+
return ("[hicortex] Localhost auth bypass is disabled — run " +
|
|
69
|
+
"`npx @gamaze/hicortex init` to restore it.");
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
package/dist/init.d.ts
CHANGED
|
@@ -289,6 +289,21 @@ export declare function getPackageSpec(configDir?: string): string;
|
|
|
289
289
|
* later — the "looks configured but isn't" trap (#176). Never persist it.
|
|
290
290
|
*/
|
|
291
291
|
export declare function isEphemeralNpxPath(binPath: string): boolean;
|
|
292
|
+
/**
|
|
293
|
+
* Build the PATH the launchd/systemd supervisors receive (#276). Order:
|
|
294
|
+
* 1. the binary's own dir — so a SIBLING node wins for nvm/volta/npm-global
|
|
295
|
+
* installs (the version the global was installed under);
|
|
296
|
+
* 2. the dir of the node the supervisor should run under — resolved via
|
|
297
|
+
* `which node` (the symlink path, stable across upgrades); see
|
|
298
|
+
* resolveNodeDir(). This is the generic rescue: for bun/pnpm/yarn globals
|
|
299
|
+
* the bin dir has NO node sibling, and on Apple Silicon node lives in
|
|
300
|
+
* /opt/homebrew/bin. Baking the resolved node dir in fixes every package
|
|
301
|
+
* manager without enumerating them;
|
|
302
|
+
* 3. the standard locations — including /opt/homebrew/bin (Apple Silicon
|
|
303
|
+
* homebrew) as a belt-and-suspenders fallback for the no-sibling case.
|
|
304
|
+
* Deduped (preserving first-seen order); empties dropped.
|
|
305
|
+
*/
|
|
306
|
+
export declare function buildSupervisorPath(binaryArgs: string[]): string;
|
|
292
307
|
/**
|
|
293
308
|
* Install (or verify) the CC SessionStart hook that runs the canonical command
|
|
294
309
|
* `hicortex learnings-identity` (aliased as the legacy `lessons-context`,
|
package/dist/init.js
CHANGED
|
@@ -34,6 +34,7 @@ exports.writeClientConfig = writeClientConfig;
|
|
|
34
34
|
exports.scaffoldDefaultDomains = scaffoldDefaultDomains;
|
|
35
35
|
exports.getPackageSpec = getPackageSpec;
|
|
36
36
|
exports.isEphemeralNpxPath = isEphemeralNpxPath;
|
|
37
|
+
exports.buildSupervisorPath = buildSupervisorPath;
|
|
37
38
|
exports.installSessionStartHook = installSessionStartHook;
|
|
38
39
|
exports.installRecallHooks = installRecallHooks;
|
|
39
40
|
exports.runInit = runInit;
|
|
@@ -45,6 +46,7 @@ exports.formatOnCalendarLines = formatOnCalendarLines;
|
|
|
45
46
|
exports.formatLaunchdIntervals = formatLaunchdIntervals;
|
|
46
47
|
exports.formatSystemdTimerBody = formatSystemdTimerBody;
|
|
47
48
|
const paths_js_1 = require("./paths.js");
|
|
49
|
+
const localhost_bypass_js_1 = require("./localhost-bypass.js");
|
|
48
50
|
const telemetry_js_1 = require("./telemetry.js");
|
|
49
51
|
const node_fs_1 = require("node:fs");
|
|
50
52
|
const node_path_1 = require("node:path");
|
|
@@ -1134,6 +1136,10 @@ function getPackageSpec(configDir = HICORTEX_HOME) {
|
|
|
1134
1136
|
function installDaemon() {
|
|
1135
1137
|
const os = (0, node_os_1.platform)();
|
|
1136
1138
|
const binaryArgs = resolveBinaryArgs();
|
|
1139
|
+
// #276: verify the supervisor can actually run (node resolvable on the
|
|
1140
|
+
// generated PATH) before writing the plist/unit — turns a silent DOA into a
|
|
1141
|
+
// loud install-time warning.
|
|
1142
|
+
verifySupervisorRuntime(binaryArgs);
|
|
1137
1143
|
if (os === "darwin") {
|
|
1138
1144
|
return installLaunchd(binaryArgs);
|
|
1139
1145
|
}
|
|
@@ -1185,6 +1191,71 @@ function resolveBinaryArgs() {
|
|
|
1185
1191
|
const packageSpec = getPackageSpec();
|
|
1186
1192
|
return [npxPath, "-y", packageSpec];
|
|
1187
1193
|
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Build the PATH the launchd/systemd supervisors receive (#276). Order:
|
|
1196
|
+
* 1. the binary's own dir — so a SIBLING node wins for nvm/volta/npm-global
|
|
1197
|
+
* installs (the version the global was installed under);
|
|
1198
|
+
* 2. the dir of the node the supervisor should run under — resolved via
|
|
1199
|
+
* `which node` (the symlink path, stable across upgrades); see
|
|
1200
|
+
* resolveNodeDir(). This is the generic rescue: for bun/pnpm/yarn globals
|
|
1201
|
+
* the bin dir has NO node sibling, and on Apple Silicon node lives in
|
|
1202
|
+
* /opt/homebrew/bin. Baking the resolved node dir in fixes every package
|
|
1203
|
+
* manager without enumerating them;
|
|
1204
|
+
* 3. the standard locations — including /opt/homebrew/bin (Apple Silicon
|
|
1205
|
+
* homebrew) as a belt-and-suspenders fallback for the no-sibling case.
|
|
1206
|
+
* Deduped (preserving first-seen order); empties dropped.
|
|
1207
|
+
*/
|
|
1208
|
+
function buildSupervisorPath(binaryArgs) {
|
|
1209
|
+
const binDir = (0, node_path_1.dirname)(binaryArgs[0]);
|
|
1210
|
+
const nodeDir = resolveNodeDir();
|
|
1211
|
+
return [binDir, nodeDir, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
|
|
1212
|
+
.filter((d, i, a) => d && a.indexOf(d) === i)
|
|
1213
|
+
.join(":");
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* Resolve the dir of the node the supervisor should use (#276). Prefers
|
|
1217
|
+
* `which node` — the SYMLINK path, stable across version upgrades (homebrew
|
|
1218
|
+
* rotates the Cellar target but keeps /opt/homebrew/bin/node) — over
|
|
1219
|
+
* process.execPath, which on macOS is the resolved realpath (the versioned
|
|
1220
|
+
* Cellar dir, e.g. /opt/homebrew/Cellar/node/X.Y.Z/bin) and STALES on a
|
|
1221
|
+
* `brew upgrade node`, re-introducing the silent-death the fix targets. Falls
|
|
1222
|
+
* back to process.execPath's dir only if `which node` is unavailable.
|
|
1223
|
+
*/
|
|
1224
|
+
function resolveNodeDir() {
|
|
1225
|
+
try {
|
|
1226
|
+
const which = (0, node_child_process_1.execSync)("which node", { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim();
|
|
1227
|
+
if (which)
|
|
1228
|
+
return (0, node_path_1.dirname)(which);
|
|
1229
|
+
}
|
|
1230
|
+
catch { /* node not on PATH — fall through to execPath */ }
|
|
1231
|
+
return (0, node_path_1.dirname)(process.execPath);
|
|
1232
|
+
}
|
|
1233
|
+
/** Dedup flag so the supervisor-runtime warning prints once per `init` run. */
|
|
1234
|
+
let supervisorRuntimeWarned = false;
|
|
1235
|
+
/**
|
|
1236
|
+
* Install-time smoke test (#276): spawn the resolved binary with the SAME PATH
|
|
1237
|
+
* the supervisor will use and confirm it can run (`--version`). Turns the
|
|
1238
|
+
* silent-dead-on-arrival case (node unresolvable under launchd's empty PATH →
|
|
1239
|
+
* the agent dies at the `#!/usr/bin/env node` shebang with exit 127, capture
|
|
1240
|
+
* stops silently, no signal in `status` because the shell PATH masks it) into a
|
|
1241
|
+
* LOUD install-time warning. Does NOT block install — the plist/unit is still
|
|
1242
|
+
* written so a PATH fix + reload recovers it without re-init.
|
|
1243
|
+
*/
|
|
1244
|
+
function verifySupervisorRuntime(binaryArgs) {
|
|
1245
|
+
if (supervisorRuntimeWarned)
|
|
1246
|
+
return;
|
|
1247
|
+
const supervisorEnv = { ...process.env, PATH: buildSupervisorPath(binaryArgs) };
|
|
1248
|
+
try {
|
|
1249
|
+
(0, node_child_process_1.execSync)([...binaryArgs, "--version"].join(" "), { stdio: "pipe", env: supervisorEnv });
|
|
1250
|
+
}
|
|
1251
|
+
catch {
|
|
1252
|
+
supervisorRuntimeWarned = true;
|
|
1253
|
+
console.error(" ⚠ WARNING: the scheduled daemon/nightly could not run with the generated PATH — " +
|
|
1254
|
+
"`node` was not found, so the supervisor will fail silently at runtime (capture stops). " +
|
|
1255
|
+
"Reinstall via `npm install -g @gamaze/hicortex` (recommended) or ensure node is at a " +
|
|
1256
|
+
"standard location, then re-run `npx @gamaze/hicortex init`.");
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1188
1259
|
/**
|
|
1189
1260
|
* Install (or verify) the CC SessionStart hook that runs the canonical command
|
|
1190
1261
|
* `hicortex learnings-identity` (aliased as the legacy `lessons-context`,
|
|
@@ -1305,7 +1376,7 @@ function installLaunchd(binaryArgs) {
|
|
|
1305
1376
|
// PATH must start with the binary's own directory so the sibling node
|
|
1306
1377
|
// binary (correct version for nvm installs) is found first.
|
|
1307
1378
|
// launchd has no PATH by default; without this, node itself won't be found.
|
|
1308
|
-
const
|
|
1379
|
+
const supervisorPath = buildSupervisorPath(binaryArgs);
|
|
1309
1380
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
1310
1381
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
1311
1382
|
<plist version="1.0">
|
|
@@ -1327,7 +1398,7 @@ ${programArgs}
|
|
|
1327
1398
|
<key>EnvironmentVariables</key>
|
|
1328
1399
|
<dict>
|
|
1329
1400
|
<key>PATH</key>
|
|
1330
|
-
<string>${
|
|
1401
|
+
<string>${supervisorPath}</string>
|
|
1331
1402
|
</dict>
|
|
1332
1403
|
</dict>
|
|
1333
1404
|
</plist>`;
|
|
@@ -1354,7 +1425,7 @@ function installSystemd(binaryArgs) {
|
|
|
1354
1425
|
const servicePath = (0, node_path_1.join)(unitDir, "hicortex.service");
|
|
1355
1426
|
const execStart = [...binaryArgs, "server"].join(" ");
|
|
1356
1427
|
// PATH must start with the binary's own directory (see installLaunchd for rationale).
|
|
1357
|
-
const
|
|
1428
|
+
const supervisorPath = buildSupervisorPath(binaryArgs);
|
|
1358
1429
|
const service = `[Unit]
|
|
1359
1430
|
Description=Hicortex MCP server — long-term memory for AI agents
|
|
1360
1431
|
|
|
@@ -1365,7 +1436,7 @@ Restart=on-failure
|
|
|
1365
1436
|
RestartSec=10
|
|
1366
1437
|
StandardOutput=journal
|
|
1367
1438
|
StandardError=journal
|
|
1368
|
-
Environment=PATH=${
|
|
1439
|
+
Environment=PATH=${supervisorPath}
|
|
1369
1440
|
|
|
1370
1441
|
[Install]
|
|
1371
1442
|
WantedBy=default.target
|
|
@@ -1404,6 +1475,13 @@ async function runInit(options = {}) {
|
|
|
1404
1475
|
// first — every writer downstream loads through loadConfigStrict.
|
|
1405
1476
|
if (options.repairConfig) {
|
|
1406
1477
|
quarantineMalformedConfig((0, node_path_1.join)(HICORTEX_HOME, "config.json"));
|
|
1478
|
+
// CR warning 2 (#271): repair-config is a plausible post-upgrade recovery
|
|
1479
|
+
// action, so it MUST (re)write the localhost-bypass marker itself — defensive
|
|
1480
|
+
// against a future early-return in this block. The full-init path writes it
|
|
1481
|
+
// again at line ~1615 (idempotent: same content, returns false the second
|
|
1482
|
+
// time). Never written in hosted mode (the boot assertion refuses to start
|
|
1483
|
+
// with the marker present).
|
|
1484
|
+
(0, localhost_bypass_js_1.writeLocalhostBypassMarker)(HICORTEX_HOME);
|
|
1407
1485
|
}
|
|
1408
1486
|
if (options.serverUrl) {
|
|
1409
1487
|
await runClientInit(options.serverUrl, options.agentName);
|
|
@@ -1500,6 +1578,16 @@ async function runInit(options = {}) {
|
|
|
1500
1578
|
// Classification activates automatically once an LLM is configured; until
|
|
1501
1579
|
// then domains sit inert (strict-skip path).
|
|
1502
1580
|
scaffoldDefaultDomains(configPath);
|
|
1581
|
+
// Write the localhost auth-bypass marker (#110 §2, #271 — Phase 0B). The
|
|
1582
|
+
// bypass is marker-gated from 0.18: self-hosted init writes the marker so
|
|
1583
|
+
// existing installs keep the bypass after upgrade + re-init; a hosted tenant
|
|
1584
|
+
// dir is fail-closed by default. Idempotent (overwrites an existing marker,
|
|
1585
|
+
// refreshing the note). Never written in hosted mode (the boot assertion
|
|
1586
|
+
// would refuse to start with the marker present).
|
|
1587
|
+
const markerCreated = (0, localhost_bypass_js_1.writeLocalhostBypassMarker)(HICORTEX_HOME);
|
|
1588
|
+
if (markerCreated) {
|
|
1589
|
+
console.log(" ✓ Localhost auth-bypass marker written");
|
|
1590
|
+
}
|
|
1503
1591
|
// Per-agent identity id (#179): server mode writes it ONLY when the operator
|
|
1504
1592
|
// passes --agent-name. Without the flag no agentName is written and the
|
|
1505
1593
|
// co-located CC shares the global identity (global by default). Explicit flag
|
|
@@ -1944,9 +2032,12 @@ function formatSystemdTimerBody(isInterval, intervalSec, hours, jitterSec) {
|
|
|
1944
2032
|
*/
|
|
1945
2033
|
function writeScheduleUnit(opts) {
|
|
1946
2034
|
const binaryArgs = resolveBinaryArgs();
|
|
2035
|
+
// #276: verify the scheduled nightly/capture can run before writing its unit.
|
|
2036
|
+
verifySupervisorRuntime(binaryArgs);
|
|
1947
2037
|
const os = (0, node_os_1.platform)();
|
|
1948
|
-
// PATH
|
|
1949
|
-
|
|
2038
|
+
// PATH the supervisor receives — includes the dir of the node running init
|
|
2039
|
+
// (process.execPath) so bun/pnpm/yarn globals resolve node under launchd (#276).
|
|
2040
|
+
const supervisorPath = buildSupervisorPath(binaryArgs);
|
|
1950
2041
|
// One canonical nightly log path across platforms — status output, docs,
|
|
1951
2042
|
// and support instructions all reference this single location.
|
|
1952
2043
|
const logPath = (0, node_path_1.join)(HICORTEX_HOME, "nightly.log");
|
|
@@ -1999,7 +2090,7 @@ ${scheduleBlock}
|
|
|
1999
2090
|
<key>EnvironmentVariables</key>
|
|
2000
2091
|
<dict>
|
|
2001
2092
|
<key>PATH</key>
|
|
2002
|
-
<string>${
|
|
2093
|
+
<string>${supervisorPath}</string>
|
|
2003
2094
|
</dict>
|
|
2004
2095
|
</dict>
|
|
2005
2096
|
</plist>`;
|
|
@@ -2033,7 +2124,7 @@ Type=oneshot
|
|
|
2033
2124
|
ExecStart=${execStart}
|
|
2034
2125
|
${opts.timeoutMin ? `TimeoutStartSec=${opts.timeoutMin}min\n` : ""}StandardOutput=append:${logPath}
|
|
2035
2126
|
StandardError=append:${logPath}
|
|
2036
|
-
Environment=PATH=${
|
|
2127
|
+
Environment=PATH=${supervisorPath}
|
|
2037
2128
|
Environment=HOME=${(0, node_os_1.homedir)()}
|
|
2038
2129
|
WorkingDirectory=${(0, node_os_1.homedir)()}`;
|
|
2039
2130
|
// Timer body: OnUnitActiveSec (interval, watchdog) or one OnCalendar line
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Marker filename inside the Hicortex home dir. */
|
|
2
|
+
export declare const LOCALHOST_BYPASS_MARKER = ".allow-localhost-bypass";
|
|
3
|
+
/** Marker contents — a one-line note. Its mere PRESENCE is the signal. */
|
|
4
|
+
export declare const LOCALHOST_BYPASS_MARKER_CONTENT: string;
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the marker file path for a given home dir. Defaults to the canonical
|
|
7
|
+
* Hicortex home (honors HICORTEX_HOME), so callers in tests can point the env
|
|
8
|
+
* override at a temp dir.
|
|
9
|
+
*/
|
|
10
|
+
export declare function localhostBypassMarkerPath(home?: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Does the localhost auth-bypass marker exist? Pure filesystem check — no
|
|
13
|
+
* logging, no side-effects. Used by both createAuthMiddleware (gates the
|
|
14
|
+
* bypass per-request via a boot-time capture in mcp-server.ts) and the
|
|
15
|
+
* hosted-mode boot assertion.
|
|
16
|
+
*/
|
|
17
|
+
export declare function localhostBypassEnabled(home?: string): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Write the localhost auth-bypass marker file (self-hosted init only — never
|
|
20
|
+
* in hosted mode). Idempotent: overwrites an existing marker so a re-init
|
|
21
|
+
* refreshes the explanatory note. Ensures the parent dir exists. Does NOT
|
|
22
|
+
* touch auth or any other config — just the one marker file.
|
|
23
|
+
*
|
|
24
|
+
* Returns true when a NEW marker was created (for init's "✓" reporting), false
|
|
25
|
+
* when one already existed (refreshed in place).
|
|
26
|
+
*/
|
|
27
|
+
export declare function writeLocalhostBypassMarker(home?: string): boolean;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LOCALHOST_BYPASS_MARKER_CONTENT = exports.LOCALHOST_BYPASS_MARKER = void 0;
|
|
4
|
+
exports.localhostBypassMarkerPath = localhostBypassMarkerPath;
|
|
5
|
+
exports.localhostBypassEnabled = localhostBypassEnabled;
|
|
6
|
+
exports.writeLocalhostBypassMarker = writeLocalhostBypassMarker;
|
|
7
|
+
/**
|
|
8
|
+
* Localhost auth-bypass marker file (#110 §2, #271 — Phase 0B).
|
|
9
|
+
*
|
|
10
|
+
* The localhost auth bypass in createAuthMiddleware (viz.ts) is marker-GATED
|
|
11
|
+
* from 0.18: it applies ONLY when this marker file exists in the Hicortex
|
|
12
|
+
* home dir. Self-hosted `init` writes the marker, so existing installs keep
|
|
13
|
+
* working after upgrade + re-init; a hosted tenant dir provisioned by any
|
|
14
|
+
* means (script, hand, restored tar) is fail-closed by default — no marker,
|
|
15
|
+
* no bypass, every connection (localhost included) needs the bearer token.
|
|
16
|
+
*
|
|
17
|
+
* Rationale (spec 2026-07-27 §2): with the bypass unconditional, a future
|
|
18
|
+
* `trust proxy` enablement would make `req.ip` header-spoofable and the
|
|
19
|
+
* bypass remotely triggerable. Inverting the default to "off unless marked"
|
|
20
|
+
* makes the bypass opt-in via a filesystem side-effect of self-hosted init,
|
|
21
|
+
* so a tenant home built from a bare config + DB restore cannot accidentally
|
|
22
|
+
* ship with the bypass active. The hosted-mode boot assertion (mcp-server.ts)
|
|
23
|
+
* refuses to start if BOTH hostedMode=true AND the marker is present, so even
|
|
24
|
+
* a stray marker cannot open a hosted tenant.
|
|
25
|
+
*
|
|
26
|
+
* Marker file name: `.allow-localhost-bypass` (dot-prefixed; not a secret —
|
|
27
|
+
* its mere presence is the signal; no contents needed).
|
|
28
|
+
*/
|
|
29
|
+
const node_fs_1 = require("node:fs");
|
|
30
|
+
const node_path_1 = require("node:path");
|
|
31
|
+
const paths_js_1 = require("./paths.js");
|
|
32
|
+
/** Marker filename inside the Hicortex home dir. */
|
|
33
|
+
exports.LOCALHOST_BYPASS_MARKER = ".allow-localhost-bypass";
|
|
34
|
+
/** Marker contents — a one-line note. Its mere PRESENCE is the signal. */
|
|
35
|
+
exports.LOCALHOST_BYPASS_MARKER_CONTENT = "# Written by `hicortex init` (self-hosted). Opt-in to the localhost auth\n" +
|
|
36
|
+
"# bypass. DELETE this file to require the bearer token on localhost too\n" +
|
|
37
|
+
"# (fail-closed). Hosted-mode (hostedMode:true) refuses to start with this\n" +
|
|
38
|
+
"# marker present — see specs/2026-07-27-hosted-service.md §2.\n";
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the marker file path for a given home dir. Defaults to the canonical
|
|
41
|
+
* Hicortex home (honors HICORTEX_HOME), so callers in tests can point the env
|
|
42
|
+
* override at a temp dir.
|
|
43
|
+
*/
|
|
44
|
+
function localhostBypassMarkerPath(home = (0, paths_js_1.hicortexHome)()) {
|
|
45
|
+
return (0, node_path_1.join)(home, exports.LOCALHOST_BYPASS_MARKER);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Does the localhost auth-bypass marker exist? Pure filesystem check — no
|
|
49
|
+
* logging, no side-effects. Used by both createAuthMiddleware (gates the
|
|
50
|
+
* bypass per-request via a boot-time capture in mcp-server.ts) and the
|
|
51
|
+
* hosted-mode boot assertion.
|
|
52
|
+
*/
|
|
53
|
+
function localhostBypassEnabled(home = (0, paths_js_1.hicortexHome)()) {
|
|
54
|
+
return (0, node_fs_1.existsSync)(localhostBypassMarkerPath(home));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Write the localhost auth-bypass marker file (self-hosted init only — never
|
|
58
|
+
* in hosted mode). Idempotent: overwrites an existing marker so a re-init
|
|
59
|
+
* refreshes the explanatory note. Ensures the parent dir exists. Does NOT
|
|
60
|
+
* touch auth or any other config — just the one marker file.
|
|
61
|
+
*
|
|
62
|
+
* Returns true when a NEW marker was created (for init's "✓" reporting), false
|
|
63
|
+
* when one already existed (refreshed in place).
|
|
64
|
+
*/
|
|
65
|
+
function writeLocalhostBypassMarker(home = (0, paths_js_1.hicortexHome)()) {
|
|
66
|
+
const markerPath = localhostBypassMarkerPath(home);
|
|
67
|
+
const existed = (0, node_fs_1.existsSync)(markerPath);
|
|
68
|
+
(0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(markerPath), { recursive: true });
|
|
69
|
+
(0, node_fs_1.writeFileSync)(markerPath, exports.LOCALHOST_BYPASS_MARKER_CONTENT, { mode: 0o644 });
|
|
70
|
+
return !existed;
|
|
71
|
+
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -59,6 +59,10 @@ const db_js_1 = require("./db.js");
|
|
|
59
59
|
const llm_js_1 = require("./llm.js");
|
|
60
60
|
const features_js_1 = require("./features.js");
|
|
61
61
|
const config_read_js_1 = require("./config-read.js");
|
|
62
|
+
const localhost_bypass_js_1 = require("./localhost-bypass.js");
|
|
63
|
+
const hosted_boot_js_1 = require("./hosted-boot.js");
|
|
64
|
+
const token_budget_js_1 = require("./token-budget.js");
|
|
65
|
+
const paths_js_1 = require("./paths.js");
|
|
62
66
|
const state_js_1 = require("./state.js");
|
|
63
67
|
const embedder_js_1 = require("./embedder.js");
|
|
64
68
|
const storage = __importStar(require("./storage.js"));
|
|
@@ -406,6 +410,59 @@ function createMcpServer() {
|
|
|
406
410
|
async function startServer(options = {}) {
|
|
407
411
|
const port = options.port ?? 8787;
|
|
408
412
|
const host = options.host ?? "0.0.0.0";
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
// Hosted-mode boot gate (#110 §1-§2, #271 — Phase 0B).
|
|
415
|
+
//
|
|
416
|
+
// MUST run BEFORE resolveDbPath/initDb: in hosted mode with HICORTEX_DB_PATH
|
|
417
|
+
// set, the server must refuse the attacker-chosen DB location WITHOUT first
|
|
418
|
+
// touching it. The hosted signals (hostedMode from config, bypassMarkerPresent
|
|
419
|
+
// from the marker file) do NOT depend on the DB, so reading them now is safe.
|
|
420
|
+
// CR warning 3: this block was previously after initDb, letting a hostile
|
|
421
|
+
// HICORTEX_DB_PATH create/touch a file at the chosen path before the gate.
|
|
422
|
+
//
|
|
423
|
+
// CR warning 1: the marker is a HOME-level file (like config.json, written by
|
|
424
|
+
// init to HICORTEX_HOME). Read it from hicortexHome() — NOT stateDir, which
|
|
425
|
+
// is dirname(dbPath) and drifts when HICORTEX_DB_PATH relocates the DB. The
|
|
426
|
+
// config key hostedMode likewise lives at <hicortexHome>/config.json.
|
|
427
|
+
//
|
|
428
|
+
// Decision logic lives in hosted-boot.ts (pure, unit-tested); the side-effect
|
|
429
|
+
// (console.error + process.exit) is local to boot. The marker state is
|
|
430
|
+
// captured once here and reused below to gate the localhost bypass in
|
|
431
|
+
// createAuthMiddleware (no per-request stat). CR warning 4: the upgrade-path
|
|
432
|
+
// warning is decided by the pure shouldEmitBypassWarning helper (behavior-
|
|
433
|
+
// tested), not an inline branch.
|
|
434
|
+
const bootConfig = readConfigFile((0, paths_js_1.hicortexHome)());
|
|
435
|
+
const hostedMode = (0, config_read_js_1.readStrictBoolean)(bootConfig ?? {}, "hostedMode") === true;
|
|
436
|
+
let bypassMarkerPresent = (0, localhost_bypass_js_1.localhostBypassEnabled)();
|
|
437
|
+
const bootDecision = (0, hosted_boot_js_1.checkHostedBoot)({
|
|
438
|
+
hostedMode,
|
|
439
|
+
dbPathEnvSet: !!process.env.HICORTEX_DB_PATH,
|
|
440
|
+
bypassMarkerPresent,
|
|
441
|
+
});
|
|
442
|
+
if (!bootDecision.ok) {
|
|
443
|
+
console.error(bootDecision.message);
|
|
444
|
+
process.exit(1);
|
|
445
|
+
}
|
|
446
|
+
// Upgrade migration (CR S1): self-hosted server-mode CC MCP registration
|
|
447
|
+
// carries NO bearer token (init.ts:192 — only client-mode adds the header),
|
|
448
|
+
// so it relies entirely on the localhost bypass. An existing install that
|
|
449
|
+
// upgrades without re-running init has no marker → the bypass silently
|
|
450
|
+
// disappears → every server-mode CC MCP call 401s. Auto-write the marker on
|
|
451
|
+
// first post-upgrade boot in self-hosted mode to preserve the prior
|
|
452
|
+
// unconditional-bypass behaviour. Hosted mode is untouched: checkHostedBoot
|
|
453
|
+
// refuses to start with a marker present, so this block — gated on
|
|
454
|
+
// !hostedMode — never runs for a hosted tenant. bypassMarkerPresent is
|
|
455
|
+
// reassigned so createAuthMiddleware below gates the bypass for THIS boot
|
|
456
|
+
// too (the file write and the in-memory flag stay in sync).
|
|
457
|
+
if (!hostedMode && !bypassMarkerPresent) {
|
|
458
|
+
(0, localhost_bypass_js_1.writeLocalhostBypassMarker)((0, paths_js_1.hicortexHome)());
|
|
459
|
+
bypassMarkerPresent = true;
|
|
460
|
+
console.log("[hicortex] Localhost auth-bypass marker written (upgrade migration).");
|
|
461
|
+
}
|
|
462
|
+
const bypassWarning = (0, hosted_boot_js_1.shouldEmitBypassWarning)(hostedMode, bypassMarkerPresent);
|
|
463
|
+
if (bypassWarning) {
|
|
464
|
+
console.warn(bypassWarning);
|
|
465
|
+
}
|
|
409
466
|
// Initialize core
|
|
410
467
|
const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
|
|
411
468
|
console.log(`[hicortex] Initializing database at ${dbPath}`);
|
|
@@ -431,6 +488,11 @@ async function startServer(options = {}) {
|
|
|
431
488
|
const { agentId } = (0, init_js_1.ensureAndPersistAgentId)((0, node_path_1.join)(stateDir, "config.json"));
|
|
432
489
|
savedConfig.agentId = agentId;
|
|
433
490
|
}
|
|
491
|
+
// #5: token-budget enforcement. Mode-agnostic — gates on cap > 0. Self-hosted
|
|
492
|
+
// uses config llmTokensPerMonth (default 0 = off); hosted uses HICORTEX_TOKEN_CAP
|
|
493
|
+
// env (provider-set, tenant-immutable) which takes precedence. Initialised here
|
|
494
|
+
// (after stateDir + savedConfig are known) so the warn-dedup can seed from state.
|
|
495
|
+
(0, token_budget_js_1.initTokenBudget)(stateDir, savedConfig?.llmTokensPerMonth);
|
|
434
496
|
if (savedConfig?.llmBackend === "claude-cli") {
|
|
435
497
|
const claudePath = (0, llm_js_1.findClaudeBinary)();
|
|
436
498
|
if (claudePath) {
|
|
@@ -615,7 +677,7 @@ async function startServer(options = {}) {
|
|
|
615
677
|
// /dashboard has its own shell-exemption pattern. Gives the console one entry
|
|
616
678
|
// point: http://<host>:8787/ → /dashboard.
|
|
617
679
|
app.get("/", (_req, res) => res.redirect("/dashboard"));
|
|
618
|
-
app.use((0, viz_js_1.createAuthMiddleware)(authToken, authTokenPrevious));
|
|
680
|
+
app.use((0, viz_js_1.createAuthMiddleware)(authToken, authTokenPrevious, bypassMarkerPresent));
|
|
619
681
|
// SSE transport management — each connection gets its own McpServer instance
|
|
620
682
|
const transports = new Map();
|
|
621
683
|
// Health endpoint — PUBLIC minimal probe. Unauthenticated (the auth
|
|
@@ -1027,12 +1089,27 @@ async function startServer(options = {}) {
|
|
|
1027
1089
|
const sourcePrefix = session_id
|
|
1028
1090
|
? `${session_id}${segment_id ? `#${segment_id}` : ""}`
|
|
1029
1091
|
: undefined;
|
|
1092
|
+
// #5: declared outside the try so the finally can record tokens spent even
|
|
1093
|
+
// when distillSession throws partway through (the LLM calls already happened).
|
|
1094
|
+
let distillUsage = { prompt: 0, completion: 0, total: 0 };
|
|
1030
1095
|
try {
|
|
1096
|
+
// #5: token-budget gate — refuse (429) BEFORE the LLM call if the tenant is
|
|
1097
|
+
// already at/over the monthly cap. Placed after the dedup short-circuits so
|
|
1098
|
+
// a skipped duplicate neither trips the gate nor consumes budget. The client
|
|
1099
|
+
// capture loop holds its cursor on 429 (dup-over-loss, capture.ts:303).
|
|
1100
|
+
if ((0, token_budget_js_1.isTokenBudgetExceeded)(stateDir)) {
|
|
1101
|
+
res.status(429).json({ error: "token budget exceeded", retry: "next billing period" });
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1031
1104
|
// Collect gate-dropped entries so they can ride back in the response and
|
|
1032
1105
|
// land in the caller's file-persisted nightly log (#156 audit trail); the
|
|
1033
1106
|
// server-side per-entry console.log in distillChunk stays as well.
|
|
1034
1107
|
const dropped = [];
|
|
1035
|
-
const entries = await (0, distiller_js_1.distillSession)(llm, conversationText, project ?? "unknown", date, chunkSize, dropped)
|
|
1108
|
+
const entries = await (0, distiller_js_1.distillSession)(llm, conversationText, project ?? "unknown", date, chunkSize, dropped, (u) => {
|
|
1109
|
+
distillUsage.prompt += u.prompt_tokens ?? 0;
|
|
1110
|
+
distillUsage.completion += u.completion_tokens ?? 0;
|
|
1111
|
+
distillUsage.total += u.total_tokens ?? 0;
|
|
1112
|
+
});
|
|
1036
1113
|
// Phase 1 — embed every chunk up front (async). If ANY embed fails we
|
|
1037
1114
|
// never reach the insert, so nothing is stored.
|
|
1038
1115
|
const createdAt = new Date(date).toISOString();
|
|
@@ -1090,6 +1167,14 @@ async function startServer(options = {}) {
|
|
|
1090
1167
|
res.status(500).json({ error: "Distillation failed" });
|
|
1091
1168
|
console.error(`[hicortex] /distill: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
|
|
1092
1169
|
}
|
|
1170
|
+
finally {
|
|
1171
|
+
// #5: record tokens spent against the monthly budget — in finally so a
|
|
1172
|
+
// mid-distil throw (some chunks' LLM calls already happened) still counts.
|
|
1173
|
+
// No-op when cap=0 (enforcement off) or distillUsage.total=0 (gate refused
|
|
1174
|
+
// / no chunk reached an LLM call).
|
|
1175
|
+
if (distillUsage.total > 0)
|
|
1176
|
+
(0, token_budget_js_1.recordDistillUsage)(stateDir, distillUsage);
|
|
1177
|
+
}
|
|
1093
1178
|
});
|
|
1094
1179
|
// -------------------------------------------------------------------------
|
|
1095
1180
|
// REST /update — update a memory (and re-embed when content changes).
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the effective cap: env (HICORTEX_TOKEN_CAP) takes precedence over the
|
|
3
|
+
* config key. A positive, finite env wins; otherwise the config value (0/absent
|
|
4
|
+
* = unlimited). Pure — exported for tests.
|
|
5
|
+
*/
|
|
6
|
+
export declare function resolveTokenCap(configCap: unknown): number;
|
|
7
|
+
/**
|
|
8
|
+
* Initialise at server boot (after stateDir is known). Resolves + caches the cap
|
|
9
|
+
* and seeds the 80%-warn dedup so a restart mid-period doesn't re-warn.
|
|
10
|
+
*/
|
|
11
|
+
export declare function initTokenBudget(stateDir: string, configCap: unknown): void;
|
|
12
|
+
/** The resolved monthly cap (0 = unlimited / enforcement off). */
|
|
13
|
+
export declare function getTokenCap(): number;
|
|
14
|
+
/**
|
|
15
|
+
* Pre-call check for /distill: refuse (429) when the tenant is already at/over
|
|
16
|
+
* the monthly cap. Reuses `shouldThrottleTokens(cap, period, 0)` — lastRunTokens
|
|
17
|
+
* is 0 because we cannot predict a call's cost before making it, so this refuses
|
|
18
|
+
* only when already over (a tenant exactly at the cap is refused on the next
|
|
19
|
+
* call). Reads state.json fresh so the nightly process's writes are reflected.
|
|
20
|
+
*/
|
|
21
|
+
export declare function isTokenBudgetExceeded(stateDir: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* After a successful distill, add the consumed tokens to the monthly counter and
|
|
24
|
+
* emit the 80% warning once per period. Synchronous read-modify-write via
|
|
25
|
+
* `updateState` (serializes concurrent in-process /distill; picks up the nightly
|
|
26
|
+
* process's writes via the fresh read). Accumulates the full breakdown
|
|
27
|
+
* (prompt/completion/total) so the dashboard's prompt+completion stays
|
|
28
|
+
* consistent with total (distill + consolidation).
|
|
29
|
+
*/
|
|
30
|
+
export declare function recordDistillUsage(stateDir: string, usage: {
|
|
31
|
+
prompt: number;
|
|
32
|
+
completion: number;
|
|
33
|
+
total: number;
|
|
34
|
+
}): void;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveTokenCap = resolveTokenCap;
|
|
4
|
+
exports.initTokenBudget = initTokenBudget;
|
|
5
|
+
exports.getTokenCap = getTokenCap;
|
|
6
|
+
exports.isTokenBudgetExceeded = isTokenBudgetExceeded;
|
|
7
|
+
exports.recordDistillUsage = recordDistillUsage;
|
|
8
|
+
/**
|
|
9
|
+
* Per-tenant monthly token-budget enforcement (#110 Phase 0B item #5).
|
|
10
|
+
*
|
|
11
|
+
* Limits LLM token consumption over /distill (the cost-generating path the
|
|
12
|
+
* nightly consolidation throttle did NOT cover). Mode-agnostic — gates on
|
|
13
|
+
* `cap > 0`, never on `hostedMode`:
|
|
14
|
+
* - Self-hosted: the cap is the operator's own `llmTokensPerMonth` config
|
|
15
|
+
* (default 0 = unlimited → never throttles). Protects the operator's wallet
|
|
16
|
+
* from a runaway nightly on an expensive model.
|
|
17
|
+
* - Hosted: the cap is provider-set via the `HICORTEX_TOKEN_CAP` env, which
|
|
18
|
+
* takes PRECEDENCE over config. The tenant process cannot mutate boot-time
|
|
19
|
+
* env, so a hosted tenant cannot raise its own cap (the config.json
|
|
20
|
+
* self-edit loophole is closed). Protects the provider's wallet.
|
|
21
|
+
*
|
|
22
|
+
* Reuses the existing machinery: `shouldThrottleTokens` (consolidate.ts) for the
|
|
23
|
+
* decision (incl. monthly reset), and `llmTokensThisPeriod` + `updateState`
|
|
24
|
+
* (state.ts) for the counter + atomic persistence.
|
|
25
|
+
*
|
|
26
|
+
* Concurrency: state.json is read fresh for each check and written via
|
|
27
|
+
* `updateState` (a synchronous read-modify-write; Node's single thread
|
|
28
|
+
* serializes concurrent /distill calls within the server process, so no
|
|
29
|
+
* in-process tally is needed). The nightly consolidation is a SEPARATE process
|
|
30
|
+
* that also writes state.json; a rare cross-process write collision can lose a
|
|
31
|
+
* small increment — negligible on a multi-million-token monthly budget. A
|
|
32
|
+
* DB-backed counter (WAL transactions serialize across processes) is the future
|
|
33
|
+
* hardening if it ever matters.
|
|
34
|
+
*/
|
|
35
|
+
const state_js_1 = require("./state.js");
|
|
36
|
+
const consolidate_js_1 = require("./consolidate.js");
|
|
37
|
+
/** Env override (hosted: provider-set, tenant-immutable at runtime). */
|
|
38
|
+
const TOKEN_CAP_ENV = "HICORTEX_TOKEN_CAP";
|
|
39
|
+
let cap = 0;
|
|
40
|
+
/** periodStart we last emitted the 80% warning at, to dedup within a period. */
|
|
41
|
+
let warnedPeriod = null;
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the effective cap: env (HICORTEX_TOKEN_CAP) takes precedence over the
|
|
44
|
+
* config key. A positive, finite env wins; otherwise the config value (0/absent
|
|
45
|
+
* = unlimited). Pure — exported for tests.
|
|
46
|
+
*/
|
|
47
|
+
function resolveTokenCap(configCap) {
|
|
48
|
+
const envCap = Number(process.env[TOKEN_CAP_ENV]);
|
|
49
|
+
if (Number.isFinite(envCap) && envCap > 0)
|
|
50
|
+
return envCap;
|
|
51
|
+
const cfg = Number(configCap);
|
|
52
|
+
return Number.isFinite(cfg) && cfg > 0 ? cfg : 0;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Initialise at server boot (after stateDir is known). Resolves + caches the cap
|
|
56
|
+
* and seeds the 80%-warn dedup so a restart mid-period doesn't re-warn.
|
|
57
|
+
*/
|
|
58
|
+
function initTokenBudget(stateDir, configCap) {
|
|
59
|
+
cap = resolveTokenCap(configCap);
|
|
60
|
+
if (cap > 0) {
|
|
61
|
+
const p = (0, state_js_1.loadState)(stateDir).llmTokensThisPeriod;
|
|
62
|
+
warnedPeriod = p && p.total >= cap * 0.8 ? (p.periodStart ?? null) : null;
|
|
63
|
+
// Label the source truthfully: only claim env if the env value was actually
|
|
64
|
+
// used (a malformed env falls back to config, so the label must not lie).
|
|
65
|
+
const fromEnv = Number(process.env[TOKEN_CAP_ENV]) === cap;
|
|
66
|
+
console.log(`[hicortex] Token budget: ${cap.toLocaleString()}/month${fromEnv ? " (HICORTEX_TOKEN_CAP)" : ""}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/** The resolved monthly cap (0 = unlimited / enforcement off). */
|
|
70
|
+
function getTokenCap() {
|
|
71
|
+
return cap;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Pre-call check for /distill: refuse (429) when the tenant is already at/over
|
|
75
|
+
* the monthly cap. Reuses `shouldThrottleTokens(cap, period, 0)` — lastRunTokens
|
|
76
|
+
* is 0 because we cannot predict a call's cost before making it, so this refuses
|
|
77
|
+
* only when already over (a tenant exactly at the cap is refused on the next
|
|
78
|
+
* call). Reads state.json fresh so the nightly process's writes are reflected.
|
|
79
|
+
*/
|
|
80
|
+
function isTokenBudgetExceeded(stateDir) {
|
|
81
|
+
if (cap <= 0)
|
|
82
|
+
return false;
|
|
83
|
+
const period = (0, state_js_1.loadState)(stateDir).llmTokensThisPeriod;
|
|
84
|
+
return (0, consolidate_js_1.shouldThrottleTokens)(cap, period, 0).throttle;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* After a successful distill, add the consumed tokens to the monthly counter and
|
|
88
|
+
* emit the 80% warning once per period. Synchronous read-modify-write via
|
|
89
|
+
* `updateState` (serializes concurrent in-process /distill; picks up the nightly
|
|
90
|
+
* process's writes via the fresh read). Accumulates the full breakdown
|
|
91
|
+
* (prompt/completion/total) so the dashboard's prompt+completion stays
|
|
92
|
+
* consistent with total (distill + consolidation).
|
|
93
|
+
*/
|
|
94
|
+
function recordDistillUsage(stateDir, usage) {
|
|
95
|
+
if (cap <= 0 || usage.total <= 0)
|
|
96
|
+
return;
|
|
97
|
+
let newTotal = 0;
|
|
98
|
+
let periodStart = "";
|
|
99
|
+
(0, state_js_1.updateState)((s) => {
|
|
100
|
+
const prev = s.llmTokensThisPeriod;
|
|
101
|
+
// Monthly reset (year+month) — matches shouldThrottleTokens's staleness check.
|
|
102
|
+
const stale = !prev?.periodStart ||
|
|
103
|
+
new Date(prev.periodStart).getUTCFullYear() !== new Date().getUTCFullYear() ||
|
|
104
|
+
new Date(prev.periodStart).getUTCMonth() !== new Date().getUTCMonth();
|
|
105
|
+
if (stale) {
|
|
106
|
+
s.llmTokensThisPeriod = {
|
|
107
|
+
prompt: usage.prompt,
|
|
108
|
+
completion: usage.completion,
|
|
109
|
+
total: usage.total,
|
|
110
|
+
periodStart: new Date().toISOString(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
const base = prev;
|
|
115
|
+
s.llmTokensThisPeriod = {
|
|
116
|
+
prompt: (base.prompt ?? 0) + usage.prompt,
|
|
117
|
+
completion: (base.completion ?? 0) + usage.completion,
|
|
118
|
+
total: (base.total ?? 0) + usage.total,
|
|
119
|
+
periodStart: base.periodStart,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
newTotal = s.llmTokensThisPeriod.total;
|
|
123
|
+
periodStart = s.llmTokensThisPeriod.periodStart;
|
|
124
|
+
}, stateDir);
|
|
125
|
+
// 80% warning — dedup per period (once per month per threshold crossing).
|
|
126
|
+
if (periodStart && warnedPeriod !== periodStart && newTotal >= cap * 0.8) {
|
|
127
|
+
warnedPeriod = periodStart;
|
|
128
|
+
const pct = Math.round((newTotal / cap) * 100);
|
|
129
|
+
console.warn(`[hicortex] Token usage at ${pct}% of monthly cap (${newTotal.toLocaleString()}/${cap.toLocaleString()}).`);
|
|
130
|
+
}
|
|
131
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -412,6 +412,15 @@ export interface HicortexConfig {
|
|
|
412
412
|
* so these were not surfacing in the top-k anyway).
|
|
413
413
|
*/
|
|
414
414
|
memorySoftCap?: number;
|
|
415
|
+
/**
|
|
416
|
+
* Hosted-service mode (issue #110, #271 — spec 2026-07-27 §1-§2). When true,
|
|
417
|
+
* the server enforces hosted-tenant constraints at boot: it refuses to start
|
|
418
|
+
* if `HICORTEX_DB_PATH` is set (path-override attacks) or if the localhost
|
|
419
|
+
* auth-bypass marker file is present (hosted must be fail-closed — no bypass).
|
|
420
|
+
* Absent/false (the self-hosted default) → the assertions never fire and
|
|
421
|
+
* behaviour is unchanged. Read at server boot via readStrictBoolean.
|
|
422
|
+
*/
|
|
423
|
+
hostedMode?: boolean;
|
|
415
424
|
/**
|
|
416
425
|
* Monthly fair-use ceiling on consolidation LLM token consumption (#246).
|
|
417
426
|
* Default `0` = unlimited (the self-hosted default — no cap, never throttled).
|
package/dist/viz.d.ts
CHANGED
|
@@ -45,8 +45,16 @@ export declare const VIZ_VENDOR_FILES: ReadonlySet<string>;
|
|
|
45
45
|
* always evaluated (no short-circuit), so a caller cannot learn WHICH token
|
|
46
46
|
* matched from the response timing. Absent/empty `authTokenPrevious` behaves
|
|
47
47
|
* exactly as the single-token middleware always has.
|
|
48
|
+
*
|
|
49
|
+
* `allowLocalhostBypass` (0.18, #110 §2/#271): when false (or omitted), the
|
|
50
|
+
* localhost bypass is DISABLED — localhost connections need the bearer token
|
|
51
|
+
* like any other (fail-closed). When true, localhost loopback (127.0.0.1,
|
|
52
|
+
* ::1, ::ffff:127.0.0.1) bypasses auth as before. The marker file
|
|
53
|
+
* `~/.hicortex/.allow-localhost-bypass` (written by self-hosted init) gates
|
|
54
|
+
* this — a hosted tenant dir is fail-closed by default. mcp-server.ts captures
|
|
55
|
+
* the marker state once at boot and passes it in (no per-request stat).
|
|
48
56
|
*/
|
|
49
|
-
export declare function createAuthMiddleware(authToken: string | undefined, authTokenPrevious?: string): express.RequestHandler;
|
|
57
|
+
export declare function createAuthMiddleware(authToken: string | undefined, authTokenPrevious?: string, allowLocalhostBypass?: boolean): express.RequestHandler;
|
|
50
58
|
/**
|
|
51
59
|
* Resolve the on-disk path of the viz page. Throws (fail explicitly) when the
|
|
52
60
|
* asset is missing — a broken install should surface, not degrade silently.
|
package/dist/viz.js
CHANGED
|
@@ -92,9 +92,18 @@ function safeBearerMatch(headerValue, expectedToken) {
|
|
|
92
92
|
* always evaluated (no short-circuit), so a caller cannot learn WHICH token
|
|
93
93
|
* matched from the response timing. Absent/empty `authTokenPrevious` behaves
|
|
94
94
|
* exactly as the single-token middleware always has.
|
|
95
|
+
*
|
|
96
|
+
* `allowLocalhostBypass` (0.18, #110 §2/#271): when false (or omitted), the
|
|
97
|
+
* localhost bypass is DISABLED — localhost connections need the bearer token
|
|
98
|
+
* like any other (fail-closed). When true, localhost loopback (127.0.0.1,
|
|
99
|
+
* ::1, ::ffff:127.0.0.1) bypasses auth as before. The marker file
|
|
100
|
+
* `~/.hicortex/.allow-localhost-bypass` (written by self-hosted init) gates
|
|
101
|
+
* this — a hosted tenant dir is fail-closed by default. mcp-server.ts captures
|
|
102
|
+
* the marker state once at boot and passes it in (no per-request stat).
|
|
95
103
|
*/
|
|
96
|
-
function createAuthMiddleware(authToken, authTokenPrevious) {
|
|
104
|
+
function createAuthMiddleware(authToken, authTokenPrevious, allowLocalhostBypass) {
|
|
97
105
|
const previous = authTokenPrevious && authTokenPrevious.length > 0 ? authTokenPrevious : undefined;
|
|
106
|
+
const bypassEnabled = allowLocalhostBypass === true;
|
|
98
107
|
return (req, res, next) => {
|
|
99
108
|
if (req.path === "/health")
|
|
100
109
|
return next();
|
|
@@ -142,7 +151,7 @@ function createAuthMiddleware(authToken, authTokenPrevious) {
|
|
|
142
151
|
return next();
|
|
143
152
|
}
|
|
144
153
|
const ip = req.ip ?? req.socket.remoteAddress ?? "";
|
|
145
|
-
if (ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1")
|
|
154
|
+
if (bypassEnabled && (ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"))
|
|
146
155
|
return next();
|
|
147
156
|
// Constant-time bearer check (#254). When authTokenPrevious is set, BOTH
|
|
148
157
|
// tokens are compared every request (no short-circuit) so timing cannot
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gamaze/hicortex",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.3",
|
|
4
4
|
"description": "Persistent agent identity for AI agents — a hand-edited identity layer, nightly-distilled experience, and lessons injected every session, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|