@gamaze/hicortex 0.18.2 → 0.19.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 +60 -0
- package/assets/dashboard.html +103 -7
- package/assets/identity.html +48 -1
- package/assets/viz.html +52 -1
- package/dist/backup.d.ts +107 -0
- package/dist/backup.js +343 -0
- package/dist/capture.d.ts +20 -0
- package/dist/capture.js +9 -1
- package/dist/cli.js +30 -0
- package/dist/config-read.d.ts +28 -2
- package/dist/config-read.js +32 -2
- package/dist/consolidate.js +2 -4
- package/dist/dashboard.d.ts +71 -6
- package/dist/dashboard.js +61 -7
- 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.d.ts +18 -0
- package/dist/mcp-server.js +147 -4
- package/dist/memory-instructions.js +1 -1
- package/dist/nightly.js +104 -2
- package/dist/prompts.js +19 -4
- package/dist/telemetry.d.ts +14 -0
- package/dist/token-budget.d.ts +34 -0
- package/dist/token-budget.js +139 -0
- package/dist/type-classify.js +7 -4
- package/dist/types.d.ts +49 -0
- package/dist/viz.d.ts +9 -1
- package/dist/viz.js +11 -2
- package/package.json +6 -4
package/dist/dashboard.d.ts
CHANGED
|
@@ -42,12 +42,21 @@ export interface DashboardMetrics {
|
|
|
42
42
|
* cap; undefined on backfill rows (a stage outcome, not reconstructable). */
|
|
43
43
|
evicted?: number;
|
|
44
44
|
/**
|
|
45
|
-
* Total LLM tokens consumed by this run
|
|
46
|
-
*
|
|
47
|
-
*
|
|
45
|
+
* Total LLM tokens consumed by this run (#246 consolidation meter; #287
|
|
46
|
+
* widened to the TRUE total — distill + consolidation). Undefined in
|
|
47
|
+
* lockstep with `tokens_by_stage` (and on backfill rows, which can't
|
|
48
|
+
* reconstruct a per-run meter). Older snapshots are consolidation-only:
|
|
49
|
+
* historical rows can't be reconstructed, which is accepted (#287).
|
|
50
|
+
* Inherent under-count, same acceptance: attribution is response-based,
|
|
51
|
+
* so tokens a FAILED distill already spent (500 after spend, response
|
|
52
|
+
* lost after commit) reach the monthly meter but never a run's total —
|
|
53
|
+
* after such a night, the month's bars sum slightly below the headline.
|
|
48
54
|
*/
|
|
49
55
|
tokens?: number;
|
|
50
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Per-stage breakdown of `tokens` (#246; #287 adds a `distill` entry for
|
|
58
|
+
* capture-time distillation). Undefined on backfill rows.
|
|
59
|
+
*/
|
|
51
60
|
tokens_by_stage?: Record<string, {
|
|
52
61
|
prompt: number;
|
|
53
62
|
completion: number;
|
|
@@ -79,6 +88,19 @@ export interface DashboardMetrics {
|
|
|
79
88
|
* shape is clean on healthy runs.
|
|
80
89
|
*/
|
|
81
90
|
budget_deferred_by_stage?: Record<string, number>;
|
|
91
|
+
/**
|
|
92
|
+
* #6 backup stage outcome (Phase 0B). Present whenever the backup stage
|
|
93
|
+
* ran (full nightly); absent on capture-only / dry-run / backfill rows.
|
|
94
|
+
* `ok` is false when the snapshot OR the operator's offsite hook failed —
|
|
95
|
+
* the page flags a night the offsite copy didn't land. `bytes` is the
|
|
96
|
+
* compressed artifact size; `path` is the on-disk artifact (for "where
|
|
97
|
+
* did the last backup land?" debugging — not a restore button).
|
|
98
|
+
*/
|
|
99
|
+
backup?: {
|
|
100
|
+
ok: boolean;
|
|
101
|
+
bytes: number;
|
|
102
|
+
path?: string;
|
|
103
|
+
};
|
|
82
104
|
};
|
|
83
105
|
/** Corpus capacity (#245). `memory_soft_cap` is the configured ceiling (0 =
|
|
84
106
|
* disabled); always present in real snapshots, undefined on backfilled
|
|
@@ -102,6 +124,17 @@ export interface DashboardSnapshot {
|
|
|
102
124
|
}
|
|
103
125
|
/** The /dashboard/data response — the full payload the page renders. */
|
|
104
126
|
export interface DashboardData {
|
|
127
|
+
/**
|
|
128
|
+
* Account identity (hosted): who the viewer is, so a user holding two
|
|
129
|
+
* tenant tokens can tell whose data the page shows. Each field is null when
|
|
130
|
+
* its config key (displayName/orgName/planLabel) is absent — the page
|
|
131
|
+
* renders nothing when ALL are null (the self-hosted default).
|
|
132
|
+
*/
|
|
133
|
+
account: {
|
|
134
|
+
name: string | null;
|
|
135
|
+
org: string | null;
|
|
136
|
+
plan: string | null;
|
|
137
|
+
};
|
|
105
138
|
headline: {
|
|
106
139
|
total_memories: number;
|
|
107
140
|
uses_per_showing: number | null;
|
|
@@ -149,9 +182,10 @@ export interface DashboardData {
|
|
|
149
182
|
supersession: number;
|
|
150
183
|
added: number;
|
|
151
184
|
evicted?: number;
|
|
152
|
-
/** Total tokens consumed that run (#246
|
|
185
|
+
/** Total tokens consumed that run (#246; #287: distill + consolidation).
|
|
186
|
+
* Undefined = no metered run. */
|
|
153
187
|
tokens?: number;
|
|
154
|
-
/** Per-stage breakdown of `tokens` (#246). */
|
|
188
|
+
/** Per-stage breakdown of `tokens` (#246; #287 adds `distill`). */
|
|
155
189
|
tokens_by_stage?: Record<string, {
|
|
156
190
|
prompt: number;
|
|
157
191
|
completion: number;
|
|
@@ -207,6 +241,19 @@ export interface NightlyDelta {
|
|
|
207
241
|
completion: number;
|
|
208
242
|
total: number;
|
|
209
243
|
}>;
|
|
244
|
+
/**
|
|
245
|
+
* Distill tokens metered by the daemon across this run's capture POSTs
|
|
246
|
+
* (#287) — summed from the /distill responses by the capture loop. Merged
|
|
247
|
+
* into the snapshot so `new_this_run.tokens` is the run's TRUE total
|
|
248
|
+
* (distill + consolidation) and `distill` joins `tokens_by_stage`. Zero
|
|
249
|
+
* (a daemon predating the usage field, or nothing distilled) is a no-op:
|
|
250
|
+
* the row keeps its consolidation-only shape.
|
|
251
|
+
*/
|
|
252
|
+
distillUsage?: {
|
|
253
|
+
prompt: number;
|
|
254
|
+
completion: number;
|
|
255
|
+
total: number;
|
|
256
|
+
};
|
|
210
257
|
/**
|
|
211
258
|
* Always-on consolidation-budget usage (#255 CR). Forwarded whenever
|
|
212
259
|
* consolidation ran so the dashboard renders a continuous used/max bar.
|
|
@@ -227,6 +274,15 @@ export interface NightlyDelta {
|
|
|
227
274
|
* with `budgetExhausted`.
|
|
228
275
|
*/
|
|
229
276
|
budgetDeferredByStage?: Record<string, number>;
|
|
277
|
+
/**
|
|
278
|
+
* #6 backup stage (Phase 0B). Hoisted from the nightly backup block. Present
|
|
279
|
+
* whenever the backup stage ran (full nightly); undefined on capture-only /
|
|
280
|
+
* dry-run. `backupOk` flips to false on snapshot OR hook failure so the
|
|
281
|
+
* digest can flag a night the offsite copy didn't land.
|
|
282
|
+
*/
|
|
283
|
+
backupPath?: string;
|
|
284
|
+
backupBytes?: number;
|
|
285
|
+
backupOk?: boolean;
|
|
230
286
|
}
|
|
231
287
|
/**
|
|
232
288
|
* Write one snapshot row for `runAt` (an ISO timestamp the caller chooses —
|
|
@@ -286,3 +342,12 @@ export declare function handleDashboardData(db: Database.Database, query: {
|
|
|
286
342
|
* Failures surface as a 500 with the usual {error} shape — no silent degrade.
|
|
287
343
|
*/
|
|
288
344
|
export declare function dashboardDataHandler(getDb: () => Database.Database, getConfig: () => Record<string, unknown> | null | undefined): express.RequestHandler;
|
|
345
|
+
/**
|
|
346
|
+
* Express adapter for GET /account — the account identity ONLY (name/org/plan
|
|
347
|
+
* from config), so the /viz and /identity/ui pages can render the nav account
|
|
348
|
+
* element without pulling the full /dashboard/data payload. Same readAccount()
|
|
349
|
+
* construction as the dashboard payload — one shape, two surfaces. Also the
|
|
350
|
+
* natural whoami for the future OAuth session (#292). Failures surface as a
|
|
351
|
+
* 500 with the usual {error} shape (same as dashboardDataHandler).
|
|
352
|
+
*/
|
|
353
|
+
export declare function accountHandler(getConfig: () => Record<string, unknown> | null | undefined): express.RequestHandler;
|
package/dist/dashboard.js
CHANGED
|
@@ -25,6 +25,7 @@ exports.writeSnapshot = writeSnapshot;
|
|
|
25
25
|
exports.backfillSnapshots = backfillSnapshots;
|
|
26
26
|
exports.handleDashboardData = handleDashboardData;
|
|
27
27
|
exports.dashboardDataHandler = dashboardDataHandler;
|
|
28
|
+
exports.accountHandler = accountHandler;
|
|
28
29
|
const recall_index_js_1 = require("./recall-index.js");
|
|
29
30
|
const config_read_js_1 = require("./config-read.js");
|
|
30
31
|
const consolidate_js_1 = require("./consolidate.js");
|
|
@@ -92,17 +93,36 @@ function computeDashboardMetrics(db) {
|
|
|
92
93
|
*/
|
|
93
94
|
function writeSnapshot(db, runAt, delta, memorySoftCap) {
|
|
94
95
|
const metrics = computeDashboardMetrics(db);
|
|
96
|
+
// #287: merge the run's two meters into the customer-facing total. `tokens`
|
|
97
|
+
// = consolidation (tokensThisRun) + distill (distillUsage.total); the distill
|
|
98
|
+
// share joins the stage map under its own key. Both fields stay in lockstep —
|
|
99
|
+
// emitted when EITHER phase metered, omitted when neither did (the page
|
|
100
|
+
// treats undefined as "no data for this day"). A zero/absent distillUsage
|
|
101
|
+
// (old daemon, nothing distilled) changes nothing: tokens/tokens_by_stage
|
|
102
|
+
// come through exactly as the consolidation report produced them.
|
|
103
|
+
const hasDistill = (delta.distillUsage?.total ?? 0) > 0;
|
|
104
|
+
const metered = delta.tokensThisRun !== undefined || hasDistill;
|
|
105
|
+
const mergedTokens = metered
|
|
106
|
+
? (delta.tokensThisRun ?? 0) + (hasDistill ? delta.distillUsage.total : 0)
|
|
107
|
+
: undefined;
|
|
108
|
+
const mergedStages = metered
|
|
109
|
+
? { ...(delta.tokensByStage ?? {}), ...(hasDistill ? { distill: delta.distillUsage } : {}) }
|
|
110
|
+
: undefined;
|
|
111
|
+
// Shape fidelity: `tokens_by_stage` with zero keys never existed pre-#287
|
|
112
|
+
// (the key was simply absent) — keep it that way so consumers that treat
|
|
113
|
+
// "present" as "has a breakdown" stay right.
|
|
114
|
+
const emitStages = mergedStages && Object.keys(mergedStages).length > 0 ? mergedStages : undefined;
|
|
95
115
|
metrics.new_this_run = {
|
|
96
116
|
added: delta.added,
|
|
97
117
|
lessonsGenerated: delta.lessonsGenerated,
|
|
98
118
|
dedup: delta.dedup,
|
|
99
119
|
supersession: delta.supersession,
|
|
100
120
|
evicted: delta.evicted,
|
|
101
|
-
// #246: forward only when
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
...(
|
|
105
|
-
...(
|
|
121
|
+
// #246: forward only when a phase actually metered tokens this run. Absent
|
|
122
|
+
// on capture-only / throttled / no-LLM / no-metered-call runs — the page
|
|
123
|
+
// treats undefined as "no data for this day", matching adoption.
|
|
124
|
+
...(mergedTokens !== undefined ? { tokens: mergedTokens } : {}),
|
|
125
|
+
...(emitStages !== undefined ? { tokens_by_stage: emitStages } : {}),
|
|
106
126
|
// #255 CR: always-on usage metric — forward calls_used + max_calls
|
|
107
127
|
// whenever consolidation ran (regardless of exhaustion) so the page can
|
|
108
128
|
// render a continuous used/max bar. Presence = a run happened; absence =
|
|
@@ -121,6 +141,17 @@ function writeSnapshot(db, runAt, delta, memorySoftCap) {
|
|
|
121
141
|
: {}),
|
|
122
142
|
}
|
|
123
143
|
: {}),
|
|
144
|
+
// #6 backup stage — forwarded as a nested object only when the stage ran
|
|
145
|
+
// (backupOk !== undefined). Absent on capture-only / dry-run / backfill.
|
|
146
|
+
...(delta.backupOk !== undefined
|
|
147
|
+
? {
|
|
148
|
+
backup: {
|
|
149
|
+
ok: delta.backupOk === true,
|
|
150
|
+
bytes: delta.backupBytes ?? 0,
|
|
151
|
+
...(delta.backupPath ? { path: delta.backupPath } : {}),
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
: {}),
|
|
124
155
|
};
|
|
125
156
|
if (memorySoftCap !== undefined) {
|
|
126
157
|
metrics.capacity = { memory_soft_cap: memorySoftCap };
|
|
@@ -418,8 +449,9 @@ function handleDashboardData(db, query, config) {
|
|
|
418
449
|
supersession: dayMetrics?.new_this_run?.supersession ?? supersessionCount,
|
|
419
450
|
added: dayMetrics?.new_this_run?.added ?? sampleRows.length,
|
|
420
451
|
evicted: dayMetrics?.new_this_run?.evicted,
|
|
421
|
-
// #246: only present when the day's nightly metered tokens
|
|
422
|
-
// are forwarded together — the page
|
|
452
|
+
// #246/#287: only present when the day's nightly metered tokens (distill
|
|
453
|
+
// or consolidation). Both fields are forwarded together — the page
|
|
454
|
+
// renders either the breakdown or nothing.
|
|
423
455
|
tokens: dayMetrics?.new_this_run?.tokens,
|
|
424
456
|
tokens_by_stage: dayMetrics?.new_this_run?.tokens_by_stage,
|
|
425
457
|
// #255 CR: always-on usage metric — present whenever consolidation ran
|
|
@@ -441,6 +473,10 @@ function handleDashboardData(db, query, config) {
|
|
|
441
473
|
return {
|
|
442
474
|
status: 200,
|
|
443
475
|
body: {
|
|
476
|
+
// Account identity — read defensively like the numeric knobs above:
|
|
477
|
+
// null when absent/not a string (page renders nothing, never "null").
|
|
478
|
+
// Shared readAccount() so GET /account renders the identical shape.
|
|
479
|
+
account: (0, config_read_js_1.readAccount)(config),
|
|
444
480
|
range: rangeParam,
|
|
445
481
|
headline,
|
|
446
482
|
series,
|
|
@@ -469,3 +505,21 @@ function dashboardDataHandler(getDb, getConfig) {
|
|
|
469
505
|
}
|
|
470
506
|
};
|
|
471
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* Express adapter for GET /account — the account identity ONLY (name/org/plan
|
|
510
|
+
* from config), so the /viz and /identity/ui pages can render the nav account
|
|
511
|
+
* element without pulling the full /dashboard/data payload. Same readAccount()
|
|
512
|
+
* construction as the dashboard payload — one shape, two surfaces. Also the
|
|
513
|
+
* natural whoami for the future OAuth session (#292). Failures surface as a
|
|
514
|
+
* 500 with the usual {error} shape (same as dashboardDataHandler).
|
|
515
|
+
*/
|
|
516
|
+
function accountHandler(getConfig) {
|
|
517
|
+
return (_req, res) => {
|
|
518
|
+
try {
|
|
519
|
+
res.status(200).json({ account: (0, config_read_js_1.readAccount)(getConfig()) });
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
}
|
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
|