@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.
@@ -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's consolidation (#246). Undefined
46
- * in lockstep with `tokens_by_stage` (and on backfill rows, which can't
47
- * reconstruct a per-run meter).
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
- /** Per-stage breakdown of `tokens` (#246). Undefined on backfill rows. */
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). Undefined = no metered run. */
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 consolidation actually metered tokens this run.
102
- // Absent on capture-only / throttled / no-LLM / no-metered-call runs — the
103
- // page treats undefined as "no data for this day", matching adoption.
104
- ...(delta.tokensThisRun !== undefined ? { tokens: delta.tokensThisRun } : {}),
105
- ...(delta.tokensByStage !== undefined ? { tokens_by_stage: delta.tokensByStage } : {}),
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. Both fields
422
- // are forwarded together — the page renders either the breakdown or nothing.
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
+ }
@@ -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[]): Promise<DistilledEntry[]>;
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 binDir = (0, node_path_1.dirname)(binaryArgs[0]);
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>${binDir}:/usr/local/bin:/usr/bin:/bin</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 binDir = (0, node_path_1.dirname)(binaryArgs[0]);
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=${binDir}:/usr/local/bin:/usr/bin:/bin
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 must start with the binary's own directory (see installLaunchd for rationale).
1949
- const binDir = (0, node_path_1.dirname)(binaryArgs[0]);
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>${binDir}:/usr/local/bin:/usr/bin:/bin</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=${binDir}:/usr/local/bin:/usr/bin:/bin
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