@gamaze/hicortex 0.18.3 → 0.19.1

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
+ }
package/dist/index.d.ts CHANGED
@@ -23,13 +23,37 @@
23
23
  * machine's Hicortex nightly reads them via oc-transcript-reader.ts —
24
24
  * canonical nightly-from-logs, same as CC JSONL and Hermes state.db.
25
25
  */
26
- import type { MemorySearchResult } from "./types.js";
26
+ import type { HicortexConfig, MemorySearchResult } from "./types.js";
27
27
  export declare function formatToolResults(results: MemorySearchResult[]): {
28
28
  content: Array<{
29
29
  type: string;
30
30
  text: string;
31
31
  }>;
32
32
  };
33
+ /**
34
+ * Resolve the plugin's config from the raw `ctx.config` OC hands the service
35
+ * (the ENTIRE openclaw.json — verified against gateway-cli createServiceContext:
36
+ * config = params.cfg — not a per-plugin section). Stateless: touches no module
37
+ * state, never mutates the input, never throws (console.warn is its only side
38
+ * effect). Three branches, first match wins:
39
+ *
40
+ * 1. `plugins.entries.hicortex.config` — the canonical OC per-plugin
41
+ * section. Only eligible when it is a non-null object with ≥1 OWN key:
42
+ * OC scaffolds `config: {}` for an installed-but-unconfigured plugin,
43
+ * and that empty object must not shadow real config further down.
44
+ * 2. `hicortex` at the top level — but only when `typeof === "object"`:
45
+ * a string/bool/number there (e.g. `"hicortex": true` as a feature
46
+ * toggle) is not a config and is skipped, not cast.
47
+ * 3. the top level itself — bare keys (`serverUrl`, `authToken`, …) at the
48
+ * root of openclaw.json; the pre-0.19 shape, kept for backcompat.
49
+ *
50
+ * `serverUrl` and `authToken` are validated at this boundary: a present but
51
+ * non-string (or empty-string) value warns naming the key and degrades —
52
+ * serverUrl falls back to DEFAULT_SERVER_URL, authToken to undefined. No
53
+ * throw paths: a malformed config degrades to defaults instead of leaving
54
+ * the plugin half-initialized.
55
+ */
56
+ export declare function resolveOcPluginConfig(raw: unknown): HicortexConfig;
33
57
  declare const _default: {
34
58
  id: string;
35
59
  name: string;
package/dist/index.js CHANGED
@@ -26,6 +26,7 @@
26
26
  */
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.formatToolResults = formatToolResults;
29
+ exports.resolveOcPluginConfig = resolveOcPluginConfig;
29
30
  const paths_js_1 = require("./paths.js");
30
31
  const features_js_1 = require("./features.js");
31
32
  const extensions_js_1 = require("./extensions.js");
@@ -256,6 +257,104 @@ function formatToolResults(results) {
256
257
  return { content: [{ type: "text", text }] };
257
258
  }
258
259
  // ---------------------------------------------------------------------------
260
+ // Config resolution
261
+ // ---------------------------------------------------------------------------
262
+ /** Object (not null, not array) → itself as a record; anything else → undefined. */
263
+ function isRecord(v) {
264
+ return typeof v === "object" && v !== null && !Array.isArray(v)
265
+ ? v
266
+ : undefined;
267
+ }
268
+ function isNonEmptyString(v) {
269
+ return typeof v === "string" && v.length > 0;
270
+ }
271
+ /** Human name for a config value that failed validation. Only ever called
272
+ * with INVALID values (non-strings and empty strings), so the string branch
273
+ * means "empty string". */
274
+ function describeInvalid(v) {
275
+ if (v === null)
276
+ return "null";
277
+ if (Array.isArray(v))
278
+ return "an array";
279
+ if (typeof v === "string")
280
+ return "an empty string";
281
+ return typeof v === "object" ? "an object" : `a ${typeof v}`;
282
+ }
283
+ /**
284
+ * Resolve the plugin's config from the raw `ctx.config` OC hands the service
285
+ * (the ENTIRE openclaw.json — verified against gateway-cli createServiceContext:
286
+ * config = params.cfg — not a per-plugin section). Stateless: touches no module
287
+ * state, never mutates the input, never throws (console.warn is its only side
288
+ * effect). Three branches, first match wins:
289
+ *
290
+ * 1. `plugins.entries.hicortex.config` — the canonical OC per-plugin
291
+ * section. Only eligible when it is a non-null object with ≥1 OWN key:
292
+ * OC scaffolds `config: {}` for an installed-but-unconfigured plugin,
293
+ * and that empty object must not shadow real config further down.
294
+ * 2. `hicortex` at the top level — but only when `typeof === "object"`:
295
+ * a string/bool/number there (e.g. `"hicortex": true` as a feature
296
+ * toggle) is not a config and is skipped, not cast.
297
+ * 3. the top level itself — bare keys (`serverUrl`, `authToken`, …) at the
298
+ * root of openclaw.json; the pre-0.19 shape, kept for backcompat.
299
+ *
300
+ * `serverUrl` and `authToken` are validated at this boundary: a present but
301
+ * non-string (or empty-string) value warns naming the key and degrades —
302
+ * serverUrl falls back to DEFAULT_SERVER_URL, authToken to undefined. No
303
+ * throw paths: a malformed config degrades to defaults instead of leaving
304
+ * the plugin half-initialized.
305
+ */
306
+ function resolveOcPluginConfig(raw) {
307
+ const warn = (msg) => console.warn(`[hicortex] WARNING: ${msg}`);
308
+ const full = isRecord(raw) ?? {};
309
+ // Branch 1 — isRecord at every level: a missing key, string, array, or
310
+ // null anywhere in the chain just falls through to the next branch.
311
+ const plugins = isRecord(full.plugins);
312
+ const entries = isRecord(plugins?.entries);
313
+ const entry = isRecord(entries?.hicortex);
314
+ const entryConfig = isRecord(entry?.config);
315
+ const nested = entryConfig !== undefined && Object.keys(entryConfig).length > 0
316
+ ? entryConfig
317
+ : undefined;
318
+ // Branch 2 — top-level `hicortex`, object-guarded (see doc block).
319
+ const hicortexObj = isRecord(full.hicortex);
320
+ const winner = nested ?? hicortexObj ?? full;
321
+ const winnerPath = nested !== undefined
322
+ ? "plugins.entries.hicortex.config"
323
+ : hicortexObj !== undefined ? "hicortex" : "the top level of openclaw.json";
324
+ // Copy, never the caller's object: sanitizing below must not mutate
325
+ // ctx.config, and pluginConfig must not alias OC's config state.
326
+ const resolved = { ...winner };
327
+ const rawUrl = winner.serverUrl;
328
+ if (rawUrl !== undefined && !isNonEmptyString(rawUrl)) {
329
+ warn(`plugin config key "serverUrl" must be a non-empty string (got ${describeInvalid(rawUrl)}) ` +
330
+ `— falling back to ${DEFAULT_SERVER_URL}`);
331
+ resolved.serverUrl = DEFAULT_SERVER_URL;
332
+ }
333
+ const rawToken = winner.authToken;
334
+ if (rawToken !== undefined && !isNonEmptyString(rawToken)) {
335
+ warn(`plugin config key "authToken" must be a non-empty string (got ${describeInvalid(rawToken)}) — ignoring it`);
336
+ resolved.authToken = undefined;
337
+ }
338
+ // Shadow detection (F2) — two configs disagreeing, surfaced instead of
339
+ // silently honoring one of them. Case 1: an OC-scaffolded EMPTY
340
+ // plugins.entries.hicortex.config was skipped while a bare top-level
341
+ // serverUrl exists (the exact shape the ≥1-own-key rule exists for).
342
+ // Gate on the ACTUAL winner (re-CR F1): in the compound shape (empty
343
+ // nested config + a top-level hicortex object + bare serverUrl) the hicortex
344
+ // object wins — the warn must not claim the bare key is being used.
345
+ if (nested === undefined && entryConfig !== undefined && winner === full && isNonEmptyString(full.serverUrl)) {
346
+ warn(`plugins.entries.hicortex.config is empty, so the top-level serverUrl is used instead — ` +
347
+ `remove the empty config section or move serverUrl into it`);
348
+ }
349
+ // Case 2: a real nested/hicortex section won the chain but carries no valid
350
+ // serverUrl, while a bare top-level serverUrl is set and will NOT be read.
351
+ if (winner !== full && !isNonEmptyString(rawUrl) && isNonEmptyString(full.serverUrl)) {
352
+ warn(`top-level serverUrl is set but ${winnerPath} takes precedence and has no valid serverUrl — ` +
353
+ `the plugin will NOT use the top-level value; move serverUrl into ${winnerPath}`);
354
+ }
355
+ return resolved;
356
+ }
357
+ // ---------------------------------------------------------------------------
259
358
  // Plugin export
260
359
  // ---------------------------------------------------------------------------
261
360
  exports.default = {
@@ -269,12 +368,19 @@ exports.default = {
269
368
  api.registerService({
270
369
  id: "hicortex-service",
271
370
  async start(ctx) {
272
- const config = (ctx.config ?? {});
371
+ // OC passes the ENTIRE openclaw.json as ctx.config (verified against
372
+ // gateway-cli createServiceContext: config = params.cfg), not a
373
+ // per-plugin section. resolveOcPluginConfig picks our section out of
374
+ // it (branch order documented on the function) and validates the
375
+ // scalar keys at the boundary. It never throws, so a malformed config
376
+ // can only degrade (warn + default), never half-initialize the plugin.
377
+ const config = resolveOcPluginConfig(ctx.config);
273
378
  pluginConfig = config;
274
379
  const log = ctx.logger
275
380
  ? (msg) => ctx.logger.info(msg)
276
381
  : console.log;
277
- // Resolve server URL and auth token from plugin config
382
+ // Resolve server URL and auth token from plugin config (both already
383
+ // validated strings — or absent, hence the ?? default)
278
384
  serverUrl = (config.serverUrl ?? DEFAULT_SERVER_URL).replace(/\/+$/, "");
279
385
  authToken = config.authToken;
280
386
  // Use stateDir from context so tests can redirect state writes
@@ -10,7 +10,25 @@
10
10
  * GET /sse — SSE stream for MCP clients
11
11
  * POST /messages — message endpoint for MCP clients
12
12
  */
13
+ import express from "express";
13
14
  import type { MemorySearchResult } from "./types.js";
15
+ /**
16
+ * Resolve the request body-size limit in MB (#7). Pure — exported for tests.
17
+ * Precedence: an explicit config value > hosted-mode default (5) > self-hosted
18
+ * default (25, the historical fixed value → no regression). A finite positive
19
+ * config value wins; invalid/absent falls through.
20
+ */
21
+ export declare function resolveBodyLimitMb(configVal: unknown, hostedMode: boolean): number;
22
+ /**
23
+ * Express error middleware (#7): translate express.json's default HTML 413
24
+ * (entity.too.large) into a consistent JSON response. Catches body-parser
25
+ * errors only — which express.json emits BEFORE any route runs — so by
26
+ * registration order (this sits ahead of the routes) it never intercepts an
27
+ * error thrown inside a route handler; those reach Express's default handler.
28
+ * The `status === 413 || type === "entity.too.large"` check is defense-in-depth
29
+ * on top of that ordering. Exported so tests exercise the real handler.
30
+ */
31
+ export declare function makeBodyLimitErrorHandler(limitMb: number): express.ErrorRequestHandler;
14
32
  export declare function startServer(options?: {
15
33
  port?: number;
16
34
  host?: string;
@@ -48,6 +48,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
48
48
  return (mod && mod.__esModule) ? mod : { "default": mod };
49
49
  };
50
50
  Object.defineProperty(exports, "__esModule", { value: true });
51
+ exports.resolveBodyLimitMb = resolveBodyLimitMb;
52
+ exports.makeBodyLimitErrorHandler = makeBodyLimitErrorHandler;
51
53
  exports.startServer = startServer;
52
54
  exports.formatResults = formatResults;
53
55
  const express_1 = __importDefault(require("express"));
@@ -407,6 +409,38 @@ function createMcpServer() {
407
409
  // ---------------------------------------------------------------------------
408
410
  // HTTP server with SSE transport
409
411
  // ---------------------------------------------------------------------------
412
+ /**
413
+ * Resolve the request body-size limit in MB (#7). Pure — exported for tests.
414
+ * Precedence: an explicit config value > hosted-mode default (5) > self-hosted
415
+ * default (25, the historical fixed value → no regression). A finite positive
416
+ * config value wins; invalid/absent falls through.
417
+ */
418
+ function resolveBodyLimitMb(configVal, hostedMode) {
419
+ const cfg = Number(configVal);
420
+ if (Number.isFinite(cfg) && cfg > 0)
421
+ return cfg;
422
+ return hostedMode ? 5 : 25;
423
+ }
424
+ /**
425
+ * Express error middleware (#7): translate express.json's default HTML 413
426
+ * (entity.too.large) into a consistent JSON response. Catches body-parser
427
+ * errors only — which express.json emits BEFORE any route runs — so by
428
+ * registration order (this sits ahead of the routes) it never intercepts an
429
+ * error thrown inside a route handler; those reach Express's default handler.
430
+ * The `status === 413 || type === "entity.too.large"` check is defense-in-depth
431
+ * on top of that ordering. Exported so tests exercise the real handler.
432
+ */
433
+ function makeBodyLimitErrorHandler(limitMb) {
434
+ return (err, _req, res, next) => {
435
+ const status = err.status;
436
+ const type = err.type;
437
+ if (status === 413 || type === "entity.too.large") {
438
+ res.status(413).json({ error: "request body too large", limit_mb: limitMb });
439
+ return;
440
+ }
441
+ next(err);
442
+ };
443
+ }
410
444
  async function startServer(options = {}) {
411
445
  const port = options.port ?? 8787;
412
446
  const host = options.host ?? "0.0.0.0";
@@ -493,6 +527,12 @@ async function startServer(options = {}) {
493
527
  // env (provider-set, tenant-immutable) which takes precedence. Initialised here
494
528
  // (after stateDir + savedConfig are known) so the warn-dedup can seed from state.
495
529
  (0, token_budget_js_1.initTokenBudget)(stateDir, savedConfig?.llmTokensPerMonth);
530
+ // #7: request body-size limit. Config key wins; else 5 MB hosted / 25 MB
531
+ // self-hosted (the prior fixed value → no regression). Guards the OOM vector
532
+ // (the body is fully parsed into memory before the distiller truncates to 80K
533
+ // chars). Legitimate capture segments are ≤60K chars (~200KB), so this never
534
+ // constrains real flow — it's an abuse/backstop. Oversized → 413.
535
+ const bodyLimitMb = resolveBodyLimitMb(savedConfig?.distillBodyLimitMb, hostedMode);
496
536
  if (savedConfig?.llmBackend === "claude-cli") {
497
537
  const claudePath = (0, llm_js_1.findClaudeBinary)();
498
538
  if (claudePath) {
@@ -636,7 +676,11 @@ async function startServer(options = {}) {
636
676
  // Express app
637
677
  const app = (0, express_1.default)();
638
678
  // Raise the body limit — whole-session denoised transcripts exceed the 100 kB default.
639
- app.use(express_1.default.json({ limit: "25mb" }));
679
+ app.use(express_1.default.json({ limit: `${bodyLimitMb}mb` }));
680
+ // #7: JSON 413 on body-limit exceed (see makeBodyLimitErrorHandler). Server-side
681
+ // only — the client capture loop treats 413 like any non-2xx (holds cursor);
682
+ // it never fires for legitimate capture (segments ≤200KB ≪ the limit).
683
+ app.use(makeBodyLimitErrorHandler(bodyLimitMb));
640
684
  // CORS: reflect ONLY explicitly-allowlisted origins (config.corsAllowedOrigins),
641
685
  // and never send Access-Control-Allow-Credentials. Reflecting any origin with
642
686
  // credentials — combined with the localhost auth bypass and the default 0.0.0.0
@@ -997,7 +1041,8 @@ async function startServer(options = {}) {
997
1041
  });
998
1042
  // REST /distill — canonical capture endpoint (0.9.0+).
999
1043
  // Every machine (including the server itself) POSTs denoised session text here.
1000
- // The server distills, embeds, stores. Body limit: 25 MB (raised at app init).
1044
+ // The server distills, embeds, stores. Body limit: see `distillBodyLimitMb`
1045
+ // (default 25 MB self-hosted / 5 MB hosted); oversized → 413 (#7).
1001
1046
  //
1002
1047
  // Accepts text (string, preferred nightly path) OR messages (array, legacy).
1003
1048
  // Performs session-level dedup when session_id is present without segment_id.
@@ -1161,6 +1206,12 @@ async function startServer(options = {}) {
1161
1206
  ids,
1162
1207
  distilled: ids.length,
1163
1208
  dropped: dropped.map((d) => (d.length > 120 ? `${d.slice(0, 120)}…` : d)),
1209
+ // #287: this segment's metered usage — the same breakdown
1210
+ // recordDistillUsage accrues below. Lets the capturing nightly
1211
+ // attribute distill tokens in its dashboard snapshot
1212
+ // (new_this_run.tokens_by_stage.distill). Always present (zeros when
1213
+ // no chunk reached an LLM call); pre-#287 clients ignore it.
1214
+ usage: distillUsage,
1164
1215
  });
1165
1216
  }
1166
1217
  catch (err) {
@@ -1452,6 +1503,13 @@ async function startServer(options = {}) {
1452
1503
  // express adapter that injects the live db + config. STRICTLY view-only —
1453
1504
  // no mutation endpoints on the dashboard surface.
1454
1505
  app.get("/dashboard/data", (0, dashboard_js_1.dashboardDataHandler)(() => db, () => readConfigFile(stateDir)));
1506
+ // GET /account — account identity for the console nav (name/org/plan from
1507
+ // config). The LIGHTWEIGHT twin of the account block inside /dashboard/data:
1508
+ // the /viz and /identity/ui pages need only this, not the metric payload;
1509
+ // also the natural whoami for the future OAuth session (#292). Bearer-only
1510
+ // (standard auth middleware, no shell exemption — it carries data); localhost
1511
+ // bypass applies. Handler lives in src/dashboard.ts next to its twin.
1512
+ app.get("/account", (0, dashboard_js_1.accountHandler)(() => readConfigFile(stateDir)));
1455
1513
  // SSE endpoint — each connection gets its own McpServer + transport
1456
1514
  app.get("/sse", async (req, res) => {
1457
1515
  const transport = new sse_js_1.SSEServerTransport("/messages", res);
@@ -30,7 +30,7 @@ exports.MEMORY_SECTION_NAME = "memory";
30
30
  * injected once per session into every agent on the fleet. */
31
31
  function renderMemoryInstructions() {
32
32
  return [
33
- "Your long-term memory is Hicortex — shared across all agents and sessions.",
33
+ "Hicortex is your persistent identity and long-term memory: what you learn, decide, and correct survives every session, compaction, and model switch one memory shared by all your agents.",
34
34
  "- A `## Memory recall (auto)` index may arrive with prompts: it is a MENU, not content. Fetch a full memory with `hicortex_get(id)` when the entry could change how you handle the current task.",
35
35
  "- Recall before assuming: `hicortex_search` for prior decisions/facts/preferences, `hicortex_recent` to catch up on a project.",
36
36
  "- Cite any memory you rely on by id + date, and mark it `FETCHED` (you read the full memory via `hicortex_get`) or `SNIPPET` (the one-line entry only). Don't present a SNIPPET citation as established. On conflicts, newer memories supersede older.",