@gamaze/hicortex 0.23.1 → 0.24.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.
Files changed (67) hide show
  1. package/assets/dashboard.html +64 -13
  2. package/dist/calibration.d.ts +31 -0
  3. package/dist/calibration.js +38 -1
  4. package/dist/capture.d.ts +7 -0
  5. package/dist/capture.js +10 -1
  6. package/dist/dashboard.d.ts +32 -7
  7. package/dist/dashboard.js +50 -10
  8. package/dist/db.js +45 -0
  9. package/dist/dedup.js +2 -2
  10. package/dist/distill-queue.d.ts +203 -0
  11. package/dist/distill-queue.js +440 -0
  12. package/dist/health.d.ts +13 -1
  13. package/dist/health.js +6 -1
  14. package/dist/hosted-boot.d.ts +1 -1
  15. package/dist/index.js +17 -2
  16. package/dist/learnings-identity.js +20 -1
  17. package/dist/localhost-bypass.js +1 -1
  18. package/dist/mcp-server.js +92 -7
  19. package/dist/nightly.js +88 -1
  20. package/dist/nofit.d.ts +1 -1
  21. package/dist/nofit.js +1 -1
  22. package/dist/schema-prototypes.d.ts +1 -1
  23. package/dist/schema-prototypes.js +1 -1
  24. package/dist/status.d.ts +11 -0
  25. package/dist/status.js +25 -0
  26. package/dist/types.d.ts +20 -0
  27. package/hermes-plugin/hicortex/README.md +13 -6
  28. package/hermes-plugin/hicortex/__init__.py +7 -0
  29. package/hermes-plugin/hicortex/client.py +64 -2
  30. package/hermes-plugin/hicortex/plugin.yaml +1 -1
  31. package/hermes-plugin/hicortex/provider.py +24 -1
  32. package/opencode-plugin/hicortex/index.ts +24 -1
  33. package/package.json +2 -1
  34. package/pi-extension/hicortex/index.ts +24 -1
  35. package/server.json +2 -2
  36. package/dist/eval/decay-eval.d.ts +0 -111
  37. package/dist/eval/decay-eval.js +0 -214
  38. package/dist/eval/dups.d.ts +0 -100
  39. package/dist/eval/dups.js +0 -174
  40. package/dist/eval/eval-clock.d.ts +0 -32
  41. package/dist/eval/eval-clock.js +0 -47
  42. package/dist/eval/eval-db.d.ts +0 -25
  43. package/dist/eval/eval-db.js +0 -67
  44. package/dist/eval/graph-eval.d.ts +0 -89
  45. package/dist/eval/graph-eval.js +0 -246
  46. package/dist/eval/importance-eval.d.ts +0 -85
  47. package/dist/eval/importance-eval.js +0 -286
  48. package/dist/eval/planted-eval.d.ts +0 -30
  49. package/dist/eval/planted-eval.js +0 -122
  50. package/dist/eval/planted-fixtures.d.ts +0 -107
  51. package/dist/eval/planted-fixtures.js +0 -283
  52. package/dist/eval/planted-harness.d.ts +0 -183
  53. package/dist/eval/planted-harness.js +0 -651
  54. package/dist/eval/ranking-battery.d.ts +0 -125
  55. package/dist/eval/ranking-battery.js +0 -289
  56. package/dist/eval/ranking-eval.d.ts +0 -61
  57. package/dist/eval/ranking-eval.js +0 -554
  58. package/dist/eval/ranking-fixtures.d.ts +0 -117
  59. package/dist/eval/ranking-fixtures.js +0 -485
  60. package/dist/eval/recall-sweep.d.ts +0 -87
  61. package/dist/eval/recall-sweep.js +0 -1030
  62. package/dist/eval/reflection-census.d.ts +0 -19
  63. package/dist/eval/reflection-census.js +0 -25
  64. package/dist/eval/relevance-eval.d.ts +0 -178
  65. package/dist/eval/relevance-eval.js +0 -2240
  66. package/dist/eval/run-eval.d.ts +0 -20
  67. package/dist/eval/run-eval.js +0 -299
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The durable distill inbox + its drain (#529).
3
+ *
4
+ * Delivery and compute are separated: `POST /distill` in queue mode stores the
5
+ * REDACTED segment in the `distill_queue` table (migration v24) and answers
6
+ * 201 immediately — no LLM call, no probe, no chunk-size detection, no token
7
+ * gate (all of those move to the drain). The nightly's drain stage then
8
+ * distills queued items oldest-first, round-robin per client, before
9
+ * consolidation, so all distill LLM traffic happens inside the owner's
10
+ * scheduled runs instead of on the clients' drifting capture grids.
11
+ *
12
+ * This module holds BOTH halves as pure units:
13
+ * - the inbox STORE (enqueue, dedup mirrors, depth/age stats, delete) — real
14
+ * functions the /distill handler calls directly;
15
+ * - the DRAIN — dependency-injected (LlmClient-like object, embed fn,
16
+ * yield-probe, token-budget gate, run deadline, sleep) so the whole stage
17
+ * is unit-testable with no HTTP and no real LLM. Every LLM call goes
18
+ * through `distillSession` (distiller.ts) on the injected client — never a
19
+ * bespoke fetch — so the drain inherits the #337 undici dispatcher, #355
20
+ * single-flight, the retry ladder, and the circuit breaker by construction.
21
+ *
22
+ * Ordering contract (spec #529): oldest-first (rowid = arrival order),
23
+ * round-robin per client key (`source_machine` + `source_agent`) so one
24
+ * client's giant backlog cannot starve the others, and a session's segments
25
+ * in arrival order (natural — a client POSTs its segments sequentially, so
26
+ * ascending rowid within a client is per-session ascending).
27
+ *
28
+ * Checkpointing contract: per item, ALL memory inserts and the inbox-row
29
+ * delete commit in ONE transaction. An interrupted run (crash, deadline,
30
+ * endpoint outage) leaves processed items done and the rest queued — the
31
+ * segment-exact dedup keys (`<sid>#<segment_id>#<i>`) make the retry
32
+ * idempotent, dup-over-loss.
33
+ */
34
+ import type Database from "better-sqlite3";
35
+ import { detectChunkSize } from "./distiller.js";
36
+ import type { LlmUsage, LlmConfig } from "./llm.js";
37
+ import type { RunDeadline } from "./run-deadline.js";
38
+ /** One queued delivery, as stored in `distill_queue`. */
39
+ export interface DistillQueueRow {
40
+ id: number;
41
+ session_id: string | null;
42
+ segment_id: string;
43
+ source_agent: string | null;
44
+ source_agent_id: string | null;
45
+ source_domain: string | null;
46
+ source_machine: string | null;
47
+ project: string | null;
48
+ session_date: string | null;
49
+ privacy: string | null;
50
+ text: string;
51
+ arrived_at: string;
52
+ }
53
+ /**
54
+ * The wire fields of one /distill POST, as the queue-mode handler resolved
55
+ * them (post-redaction). `session_date` defaults to today AT ENQUEUE TIME —
56
+ * the sync path stamps `new Date()` at delivery, and the queue must not let a
57
+ * segment that waited ~12 h in the inbox drift to the drain-day's date.
58
+ */
59
+ export interface EnqueueDistillInput {
60
+ /** REDACTED conversation text (the handler redacts before enqueueing). */
61
+ text: string;
62
+ source_agent?: unknown;
63
+ source_agent_id?: unknown;
64
+ source_domain?: unknown;
65
+ source_machine?: unknown;
66
+ project?: unknown;
67
+ session_id?: unknown;
68
+ segment_id?: unknown;
69
+ session_date?: unknown;
70
+ privacy?: unknown;
71
+ }
72
+ /**
73
+ * Insert one delivery into the inbox. Idempotent on the UNIQUE(session_id,
74
+ * segment_id) index — a re-POST of the SAME key (only reachable through a
75
+ * race: the handler's prechecks already answered the sequential re-POST with
76
+ * a 200 skip) reports `{ duplicate: true }` and inserts nothing. Posts with
77
+ * no session_id have no dedup key by construction and always insert (SQLite
78
+ * unique indexes treat NULLs as distinct).
79
+ */
80
+ export declare function enqueueDistill(db: Database.Database, input: EnqueueDistillInput): {
81
+ duplicate: boolean;
82
+ };
83
+ /**
84
+ * Inbox mirror of the segment-exact delivery precheck: how many QUEUED rows
85
+ * match this exact `session_id` + `segment_id`. The handler adds this to
86
+ * `countExistingSegment` so a queued-but-undistilled segment's re-POST
87
+ * answers 200 skipped — the client's cursor advances and the segment is
88
+ * never queued twice.
89
+ */
90
+ export declare function countQueuedSegment(db: Database.Database, sessionId: string, segmentId: string): number;
91
+ /**
92
+ * Inbox mirror of the legacy session-level precheck: how many QUEUED rows
93
+ * belong to this session (whole-session OR any segment) — a legacy
94
+ * whole-session re-POST while any part of the session sits queued skips.
95
+ */
96
+ export declare function countQueuedSession(db: Database.Database, sessionId: string): number;
97
+ /** Queue depth (rows pending distillation). */
98
+ export declare function queueDepth(db: Database.Database): number;
99
+ /** Age (ms) of the oldest queued item; null when the inbox is empty. */
100
+ export declare function oldestQueueAgeMs(db: Database.Database, now?: number): number | null;
101
+ /** The visibility shape shared by `hicortex status`, /health/detail, and the
102
+ * dashboard payload: depth + oldest-item age in hours (null = empty). */
103
+ export interface QueueStats {
104
+ depth: number;
105
+ oldest_age_hours: number | null;
106
+ }
107
+ export declare function readQueueStats(db: Database.Database, now?: number): QueueStats;
108
+ /**
109
+ * #529 bootstrap carve-out: a FRESH brain (empty memories table AND empty
110
+ * inbox) distills synchronously, exactly as pre-#529 — the first-ever
111
+ * delivery must not wait ~12 h for the next scheduled run. Anything else
112
+ * (memories exist OR rows are already queued) is ordinary queue mode.
113
+ */
114
+ export declare function isFreshBrain(db: Database.Database): boolean;
115
+ /**
116
+ * The ONE branch condition into the sync flow (owner ruling #529: kill
117
+ * switch and bootstrap carve-out share it — no third path):
118
+ *
119
+ * queueMode = distillQueue enabled && NOT a fresh brain
120
+ *
121
+ * `distillQueueEnabled` is the already-strict-boolean-resolved kill switch
122
+ * (`readStrictBoolean(config, "distillQueue") !== false`, default ON); false
123
+ * lands here always → today's synchronous distill byte-for-byte.
124
+ */
125
+ export declare function resolveQueueMode(db: Database.Database, distillQueueEnabled: boolean): boolean;
126
+ /**
127
+ * The structural minimum the drain needs from an LLM client: `complete()` is
128
+ * the ONE surface (#405). Real `LlmClient` satisfies it; tests inject a stub.
129
+ * (LlmClient has private fields, so it cannot be the parameter type itself —
130
+ * distillSession only ever calls `complete()` on the value we pass.)
131
+ */
132
+ export interface DrainLlm {
133
+ complete(prompt: string): Promise<{
134
+ text: string;
135
+ usage?: LlmUsage;
136
+ }>;
137
+ }
138
+ /** One yield-probe outcome (the HTTP shape is the caller's concern — nightly). */
139
+ export type YieldStatus = "busy" | "idle" | "unreachable";
140
+ export interface DrainOptions {
141
+ /** The LLM client all distill calls go through (null = no LLM configured). */
142
+ llm: DrainLlm | null;
143
+ /** Its config — drives the cached chunk-size detection, like the handler. */
144
+ llmConfig: LlmConfig | null;
145
+ /** Embed fn for the distilled entries (the nightly's embedder). */
146
+ embed: (text: string) => Promise<Float32Array>;
147
+ /**
148
+ * #5 token-budget gate, checked BETWEEN items (and once before the first
149
+ * item — zero LLM calls when already over the monthly cap). Injected so the
150
+ * drain unit never touches state.json; the nightly passes
151
+ * `() => isTokenBudgetExceeded(stateDir)`.
152
+ */
153
+ budgetExceeded?: () => boolean;
154
+ /** The ONE run deadline (#405) — `hit("drain")` between items. */
155
+ deadline?: RunDeadline;
156
+ /**
157
+ * #529 yield signal: the configured `drainYieldUrl`. Absent/unset ⇒ never
158
+ * wait. Provided with `probeBusy`, the drain waits out "busy" answers
159
+ * between items (bounded by the deadline + the wait cap).
160
+ */
161
+ yieldUrl?: string;
162
+ /** The probe itself (HTTP lives in nightly.ts; tests inject a fake). */
163
+ probeBusy?: (url: string) => Promise<YieldStatus>;
164
+ /** Injectable clock-wait so tests exercise busy-loops instantly. */
165
+ sleep?: (ms: number) => Promise<void>;
166
+ /**
167
+ * Injectable chunk-size detector (tests pin it; production defaults to the
168
+ * real detectChunkSize, cached per endpoint like the /distill handler).
169
+ */
170
+ detectChunkSize?: typeof detectChunkSize;
171
+ }
172
+ export interface DrainReport {
173
+ /**
174
+ * "empty" (nothing queued — the common case right after a clean run),
175
+ * "completed" (every item done), "deferred" (run deadline fired; the rest
176
+ * stays queued for the next scheduled run), "budget" (monthly token cap;
177
+ * zero LLM calls past the refusal), "no_llm" (no LLM configured — items
178
+ * stay queued, consolidation reports its own no_llm), or "endpoint_down"
179
+ * (the first item's distillation threw after the ladder/breaker — never a
180
+ * tight retry loop; processed items stay done).
181
+ */
182
+ outcome: "empty" | "completed" | "deferred" | "budget" | "no_llm" | "endpoint_down";
183
+ /** Items fully processed (distilled or duplicate-skipped) this run. */
184
+ processed: number;
185
+ /** Of those, items that were already stored and only had their row deleted. */
186
+ duplicates: number;
187
+ /** Memories inserted this run. */
188
+ memories: number;
189
+ /** Items still queued after this run. */
190
+ remaining: number;
191
+ /** Tokens metered this drain (incl. a failed item's partial usage). */
192
+ usage: {
193
+ prompt: number;
194
+ completion: number;
195
+ total: number;
196
+ };
197
+ }
198
+ /**
199
+ * Drain the distill inbox. See the module doc for the ordering + checkpoint
200
+ * contracts. Pure w.r.t. its injected dependencies — no HTTP, no state.json
201
+ * reads (the nightly records the reported usage against the monthly meter).
202
+ */
203
+ export declare function drainDistillQueue(db: Database.Database, opts: DrainOptions): Promise<DrainReport>;
@@ -0,0 +1,440 @@
1
+ "use strict";
2
+ /**
3
+ * The durable distill inbox + its drain (#529).
4
+ *
5
+ * Delivery and compute are separated: `POST /distill` in queue mode stores the
6
+ * REDACTED segment in the `distill_queue` table (migration v24) and answers
7
+ * 201 immediately — no LLM call, no probe, no chunk-size detection, no token
8
+ * gate (all of those move to the drain). The nightly's drain stage then
9
+ * distills queued items oldest-first, round-robin per client, before
10
+ * consolidation, so all distill LLM traffic happens inside the owner's
11
+ * scheduled runs instead of on the clients' drifting capture grids.
12
+ *
13
+ * This module holds BOTH halves as pure units:
14
+ * - the inbox STORE (enqueue, dedup mirrors, depth/age stats, delete) — real
15
+ * functions the /distill handler calls directly;
16
+ * - the DRAIN — dependency-injected (LlmClient-like object, embed fn,
17
+ * yield-probe, token-budget gate, run deadline, sleep) so the whole stage
18
+ * is unit-testable with no HTTP and no real LLM. Every LLM call goes
19
+ * through `distillSession` (distiller.ts) on the injected client — never a
20
+ * bespoke fetch — so the drain inherits the #337 undici dispatcher, #355
21
+ * single-flight, the retry ladder, and the circuit breaker by construction.
22
+ *
23
+ * Ordering contract (spec #529): oldest-first (rowid = arrival order),
24
+ * round-robin per client key (`source_machine` + `source_agent`) so one
25
+ * client's giant backlog cannot starve the others, and a session's segments
26
+ * in arrival order (natural — a client POSTs its segments sequentially, so
27
+ * ascending rowid within a client is per-session ascending).
28
+ *
29
+ * Checkpointing contract: per item, ALL memory inserts and the inbox-row
30
+ * delete commit in ONE transaction. An interrupted run (crash, deadline,
31
+ * endpoint outage) leaves processed items done and the rest queued — the
32
+ * segment-exact dedup keys (`<sid>#<segment_id>#<i>`) make the retry
33
+ * idempotent, dup-over-loss.
34
+ */
35
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
36
+ if (k2 === undefined) k2 = k;
37
+ var desc = Object.getOwnPropertyDescriptor(m, k);
38
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
39
+ desc = { enumerable: true, get: function() { return m[k]; } };
40
+ }
41
+ Object.defineProperty(o, k2, desc);
42
+ }) : (function(o, m, k, k2) {
43
+ if (k2 === undefined) k2 = k;
44
+ o[k2] = m[k];
45
+ }));
46
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
47
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
48
+ }) : function(o, v) {
49
+ o["default"] = v;
50
+ });
51
+ var __importStar = (this && this.__importStar) || (function () {
52
+ var ownKeys = function(o) {
53
+ ownKeys = Object.getOwnPropertyNames || function (o) {
54
+ var ar = [];
55
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
56
+ return ar;
57
+ };
58
+ return ownKeys(o);
59
+ };
60
+ return function (mod) {
61
+ if (mod && mod.__esModule) return mod;
62
+ var result = {};
63
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
64
+ __setModuleDefault(result, mod);
65
+ return result;
66
+ };
67
+ })();
68
+ Object.defineProperty(exports, "__esModule", { value: true });
69
+ exports.enqueueDistill = enqueueDistill;
70
+ exports.countQueuedSegment = countQueuedSegment;
71
+ exports.countQueuedSession = countQueuedSession;
72
+ exports.queueDepth = queueDepth;
73
+ exports.oldestQueueAgeMs = oldestQueueAgeMs;
74
+ exports.readQueueStats = readQueueStats;
75
+ exports.isFreshBrain = isFreshBrain;
76
+ exports.resolveQueueMode = resolveQueueMode;
77
+ exports.drainDistillQueue = drainDistillQueue;
78
+ const distiller_js_1 = require("./distiller.js");
79
+ const calibration_js_1 = require("./calibration.js");
80
+ const storage = __importStar(require("./storage.js"));
81
+ const dedup_js_1 = require("./dedup.js");
82
+ /** Normalize an unknown wire value into a nullable string column. */
83
+ function str(v) {
84
+ return typeof v === "string" && v.length > 0 ? v : null;
85
+ }
86
+ /**
87
+ * Insert one delivery into the inbox. Idempotent on the UNIQUE(session_id,
88
+ * segment_id) index — a re-POST of the SAME key (only reachable through a
89
+ * race: the handler's prechecks already answered the sequential re-POST with
90
+ * a 200 skip) reports `{ duplicate: true }` and inserts nothing. Posts with
91
+ * no session_id have no dedup key by construction and always insert (SQLite
92
+ * unique indexes treat NULLs as distinct).
93
+ */
94
+ function enqueueDistill(db, input) {
95
+ const result = db
96
+ .prepare(`INSERT OR IGNORE INTO distill_queue
97
+ (session_id, segment_id, source_agent, source_agent_id, source_domain,
98
+ source_machine, project, session_date, privacy, text, arrived_at)
99
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
100
+ .run(str(input.session_id), typeof input.segment_id === "string" ? input.segment_id : "", str(input.source_agent), str(input.source_agent_id), str(input.source_domain), str(input.source_machine), str(input.project),
101
+ // Resolve the date ONCE at delivery (mirrors the sync handler): absent
102
+ // or non-string becomes today, and the drain reuses the stored value so
103
+ // a queued segment keeps its session date.
104
+ str(input.session_date) ?? new Date().toISOString().slice(0, 10), str(input.privacy), input.text, new Date().toISOString());
105
+ return { duplicate: result.changes === 0 };
106
+ }
107
+ /**
108
+ * Inbox mirror of the segment-exact delivery precheck: how many QUEUED rows
109
+ * match this exact `session_id` + `segment_id`. The handler adds this to
110
+ * `countExistingSegment` so a queued-but-undistilled segment's re-POST
111
+ * answers 200 skipped — the client's cursor advances and the segment is
112
+ * never queued twice.
113
+ */
114
+ function countQueuedSegment(db, sessionId, segmentId) {
115
+ return db
116
+ .prepare("SELECT COUNT(*) AS c FROM distill_queue WHERE session_id = ? AND segment_id = ?")
117
+ .get(sessionId, segmentId).c;
118
+ }
119
+ /**
120
+ * Inbox mirror of the legacy session-level precheck: how many QUEUED rows
121
+ * belong to this session (whole-session OR any segment) — a legacy
122
+ * whole-session re-POST while any part of the session sits queued skips.
123
+ */
124
+ function countQueuedSession(db, sessionId) {
125
+ return db
126
+ .prepare("SELECT COUNT(*) AS c FROM distill_queue WHERE session_id = ?")
127
+ .get(sessionId).c;
128
+ }
129
+ /** Queue depth (rows pending distillation). */
130
+ function queueDepth(db) {
131
+ return db.prepare("SELECT COUNT(*) AS c FROM distill_queue").get().c;
132
+ }
133
+ /** Age (ms) of the oldest queued item; null when the inbox is empty. */
134
+ function oldestQueueAgeMs(db, now = Date.now()) {
135
+ const row = db
136
+ .prepare("SELECT MIN(arrived_at) AS a FROM distill_queue")
137
+ .get();
138
+ if (!row.a)
139
+ return null;
140
+ const t = new Date(row.a).getTime();
141
+ // Unparseable stamp (clock corruption at write time) reads as age 0 — the
142
+ // honest floor — rather than NaN leaking into status/dashboard output.
143
+ return Number.isFinite(t) ? Math.max(0, now - t) : 0;
144
+ }
145
+ function readQueueStats(db, now = Date.now()) {
146
+ const age = oldestQueueAgeMs(db, now);
147
+ return {
148
+ depth: queueDepth(db),
149
+ oldest_age_hours: age === null ? null : Math.round((age / 3_600_000) * 10) / 10,
150
+ };
151
+ }
152
+ /** Delete one inbox row (its id from `distill_queue`). */
153
+ function deleteQueuedRow(db, id) {
154
+ db.prepare("DELETE FROM distill_queue WHERE id = ?").run(id);
155
+ }
156
+ // ---------------------------------------------------------------------------
157
+ // Queue-mode decision — shared by the /distill handler and its mirror tests
158
+ // ---------------------------------------------------------------------------
159
+ /**
160
+ * #529 bootstrap carve-out: a FRESH brain (empty memories table AND empty
161
+ * inbox) distills synchronously, exactly as pre-#529 — the first-ever
162
+ * delivery must not wait ~12 h for the next scheduled run. Anything else
163
+ * (memories exist OR rows are already queued) is ordinary queue mode.
164
+ */
165
+ function isFreshBrain(db) {
166
+ const mem = db
167
+ .prepare("SELECT EXISTS(SELECT 1 FROM memories LIMIT 1) AS e")
168
+ .get();
169
+ if (mem.e === 1)
170
+ return false;
171
+ const queued = db
172
+ .prepare("SELECT EXISTS(SELECT 1 FROM distill_queue LIMIT 1) AS e")
173
+ .get();
174
+ return queued.e === 0;
175
+ }
176
+ /**
177
+ * The ONE branch condition into the sync flow (owner ruling #529: kill
178
+ * switch and bootstrap carve-out share it — no third path):
179
+ *
180
+ * queueMode = distillQueue enabled && NOT a fresh brain
181
+ *
182
+ * `distillQueueEnabled` is the already-strict-boolean-resolved kill switch
183
+ * (`readStrictBoolean(config, "distillQueue") !== false`, default ON); false
184
+ * lands here always → today's synchronous distill byte-for-byte.
185
+ */
186
+ function resolveQueueMode(db, distillQueueEnabled) {
187
+ return distillQueueEnabled && !isFreshBrain(db);
188
+ }
189
+ /** Chunk-size cache, keyed per endpoint exactly like the handler's. */
190
+ const chunkSizeCache = new Map();
191
+ async function resolveDrainChunkSize(opts) {
192
+ const cfg = opts.llmConfig;
193
+ const detect = opts.detectChunkSize ?? distiller_js_1.detectChunkSize;
194
+ const cacheKey = `${cfg.provider}/${cfg.model}@${cfg.baseUrl}`;
195
+ if (!chunkSizeCache.has(cacheKey)) {
196
+ chunkSizeCache.set(cacheKey, await detect(cfg.provider, cfg.model, cfg.baseUrl, cfg.numCtx));
197
+ }
198
+ return chunkSizeCache.get(cacheKey);
199
+ }
200
+ /** Client key for round-robin: machine + agent (the spec's fairness unit). */
201
+ function clientKey(row) {
202
+ return `${row.source_machine ?? ""}|${row.source_agent ?? ""}`;
203
+ }
204
+ const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
205
+ /**
206
+ * Wait out a busy yield signal between items. Returns true to proceed with
207
+ * the item, false when the run deadline fired mid-wait (the caller stops the
208
+ * drain — never starts a fresh LLM call past the deadline). Fail-open by
209
+ * design: unset URL, absent probe, or an unreachable signal all proceed
210
+ * (unreachable logs ONE warn per run). The per-item wait is bounded by
211
+ * DRAIN_YIELD_WAIT_CAP_MS so a stuck "busy" answer cannot pin the run.
212
+ */
213
+ async function waitForYield(opts, warned) {
214
+ if (!opts.yieldUrl || !opts.probeBusy)
215
+ return true;
216
+ const sleep = opts.sleep ?? sleepMs;
217
+ let waited = 0;
218
+ for (;;) {
219
+ let status;
220
+ try {
221
+ status = await opts.probeBusy(opts.yieldUrl);
222
+ }
223
+ catch {
224
+ status = "unreachable"; // a throwing probe is the same fail-open class
225
+ }
226
+ if (status === "busy") {
227
+ if (opts.deadline?.expired())
228
+ return false;
229
+ if (waited >= calibration_js_1.DRAIN_YIELD_WAIT_CAP_MS) {
230
+ console.warn(`[hicortex] drain: yield signal busy for ${Math.round(waited / 60_000)} min ` +
231
+ `(cap ${Math.round(calibration_js_1.DRAIN_YIELD_WAIT_CAP_MS / 60_000)} min) — proceeding fail-open`);
232
+ return true;
233
+ }
234
+ await sleep(calibration_js_1.DRAIN_YIELD_POLL_MS);
235
+ waited += calibration_js_1.DRAIN_YIELD_POLL_MS;
236
+ continue;
237
+ }
238
+ if (status === "unreachable" && !warned.unreachable) {
239
+ warned.unreachable = true; // ONE warn per run, not per item
240
+ console.warn(`[hicortex] drain: yield signal at ${opts.yieldUrl} unreachable — proceeding (fail-open)`);
241
+ }
242
+ return true;
243
+ }
244
+ }
245
+ /**
246
+ * Resolve the memories' `created_at` — TOTAL (never throws) and computed
247
+ * BEFORE distillation starts. An unparseable `session_date` (enqueue stores
248
+ * any non-empty string verbatim) made `new Date(...).toISOString()` throw
249
+ * AFTER the LLM calls had already run: the catch mislabeled it endpoint_down,
250
+ * the row stayed queued, and every scheduled run stopped at it again — a
251
+ * poison row (#530 follow-up). Unparseable → ONE warn naming the session and
252
+ * the bad value, then the row's delivery timestamp (`arrived_at`, always a
253
+ * full ISO written by enqueueDistill). The sync path (mcp-server.ts) has the
254
+ * same latent throw — pre-existing parity; this fix is drain-only by review
255
+ * scope.
256
+ */
257
+ function resolveDrainCreatedAt(item) {
258
+ const stamp = item.session_date ?? new Date().toISOString().slice(0, 10);
259
+ const t = new Date(stamp).getTime();
260
+ if (Number.isFinite(t))
261
+ return new Date(stamp).toISOString();
262
+ console.warn(`[hicortex] drain: unparseable session_date ${JSON.stringify(item.session_date)} on ` +
263
+ `session ${item.session_id ?? "(no session id)"} — using the delivery timestamp instead`);
264
+ return item.arrived_at;
265
+ }
266
+ /**
267
+ * Drain the distill inbox. See the module doc for the ordering + checkpoint
268
+ * contracts. Pure w.r.t. its injected dependencies — no HTTP, no state.json
269
+ * reads (the nightly records the reported usage against the monthly meter).
270
+ */
271
+ async function drainDistillQueue(db, opts) {
272
+ const report = {
273
+ outcome: "empty",
274
+ processed: 0,
275
+ duplicates: 0,
276
+ memories: 0,
277
+ remaining: 0,
278
+ usage: { prompt: 0, completion: 0, total: 0 },
279
+ };
280
+ const rows = db
281
+ .prepare("SELECT * FROM distill_queue ORDER BY rowid ASC")
282
+ .all();
283
+ if (rows.length === 0)
284
+ return report; // the healthy steady state: depth 0
285
+ // No LLM configured: delivery still queued (queue mode needs no LLM), but
286
+ // distillation cannot run. Report + defer — items stay queued; the
287
+ // consolidation block reports its own no_llm right after.
288
+ if (!opts.llm || !opts.llmConfig) {
289
+ console.warn(`[hicortex] Distill inbox holds ${rows.length} item(s) but no LLM is configured — ` +
290
+ `deferring (run npx @gamaze/hicortex init)`);
291
+ report.outcome = "no_llm";
292
+ report.remaining = rows.length;
293
+ return report;
294
+ }
295
+ // Fair order: per-client queues in first-arrival order, each preserving
296
+ // rowid order (= arrival order = per-session ascending), cycled strictly —
297
+ // client A's head, client B's head, A's next, B's next, ...
298
+ const byClient = new Map();
299
+ for (const row of rows) {
300
+ const key = clientKey(row);
301
+ const list = byClient.get(key);
302
+ if (list)
303
+ list.push(row);
304
+ else
305
+ byClient.set(key, [row]);
306
+ }
307
+ const queues = [...byClient.values()];
308
+ console.log(`[hicortex] Distill inbox: ${rows.length} item(s) from ${queues.length} client(s) — draining`);
309
+ const chunkSize = await resolveDrainChunkSize(opts);
310
+ const warned = { unreachable: false };
311
+ let stopped;
312
+ outer: while (queues.some((q) => q.length > 0)) {
313
+ for (const q of queues) {
314
+ if (q.length === 0)
315
+ continue;
316
+ const item = q.shift();
317
+ // #405: the ONE run deadline, BETWEEN items — a safe boundary by
318
+ // construction (each item commits atomically, so a stop here leaves
319
+ // processed items done and the rest queued, dup-over-loss on retry).
320
+ if (opts.deadline?.hit("drain")) {
321
+ stopped = "deferred";
322
+ break outer;
323
+ }
324
+ // #5 token gate, BETWEEN items (and here, before the very first call) —
325
+ // a refusal leaves the rest queued for the next period; consolidation
326
+ // still runs (its own budget accounting is separate).
327
+ if (opts.budgetExceeded?.()) {
328
+ console.warn(`[hicortex] drain: token budget exceeded — ${rows.length - report.processed} item(s) stay queued`);
329
+ stopped = "budget";
330
+ break outer;
331
+ }
332
+ // #529 interactive-yield: wait out a busy signal before the next item.
333
+ if (!(await waitForYield(opts, warned))) {
334
+ opts.deadline?.hit("drain"); // record the stage deferral (idempotent)
335
+ stopped = "deferred";
336
+ break outer;
337
+ }
338
+ // Distill-time dedup re-check (the delivery-time checks ran before
339
+ // enqueue, but the world moved since): a now-duplicate segment skips
340
+ // cleanly — its inbox row is deleted and nothing is re-distilled.
341
+ if (item.session_id) {
342
+ const dup = item.segment_id !== ""
343
+ ? (0, dedup_js_1.countExistingSegment)(db, item.session_id, item.segment_id) > 0
344
+ : (0, dedup_js_1.countExistingSession)(db, item.session_id) > 0;
345
+ if (dup) {
346
+ db.transaction(() => deleteQueuedRow(db, item.id))();
347
+ report.processed++;
348
+ report.duplicates++;
349
+ continue;
350
+ }
351
+ }
352
+ // Distill → embed → ONE transaction (inserts + row delete). Per-item
353
+ // usage accrues even on throw (some chunks' LLM calls already happened)
354
+ // — the caller records it against the monthly meter, same as the sync
355
+ // path's finally.
356
+ const usage = { prompt: 0, completion: 0, total: 0 };
357
+ const label = item.segment_id !== ""
358
+ ? item.segment_id
359
+ : item.session_id ?? undefined;
360
+ // BEFORE distillation — see resolveDrainCreatedAt: a throwing date
361
+ // computation after the LLM calls is the poison row.
362
+ const createdAt = resolveDrainCreatedAt(item);
363
+ try {
364
+ // Cast: DrainLlm is the structural surface distillSession uses.
365
+ const llm = opts.llm;
366
+ const entries = await (0, distiller_js_1.distillSession)(llm, item.text, item.project ?? "unknown", item.session_date ?? new Date().toISOString().slice(0, 10), chunkSize, [], (u) => {
367
+ usage.prompt += u.prompt_tokens ?? 0;
368
+ usage.completion += u.completion_tokens ?? 0;
369
+ usage.total += u.total_tokens ?? 0;
370
+ }, label);
371
+ // Phase 1 — embed every entry up front; ANY failure throws BEFORE the
372
+ // transaction, so nothing is stored and the row stays queued.
373
+ const sourcePrefix = item.session_id
374
+ ? `${item.session_id}${item.segment_id !== "" ? `#${item.segment_id}` : ""}`
375
+ : undefined;
376
+ const toStore = [];
377
+ for (let i = 0; i < entries.length; i++) {
378
+ const entry = entries[i];
379
+ if (typeof entry !== "object" || !entry.content || !entry.content.trim())
380
+ continue;
381
+ toStore.push({
382
+ content: entry.content,
383
+ memoryType: entry.memoryType,
384
+ embedding: await opts.embed(entry.content),
385
+ i,
386
+ });
387
+ }
388
+ // Phase 2 — inserts + the inbox-row delete in ONE transaction: the
389
+ // item is "done" only when both land (per-item checkpointing; a crash
390
+ // between items never sees a half-done item).
391
+ const commit = db.transaction(() => {
392
+ for (const { content, memoryType, embedding, i } of toStore) {
393
+ storage.insertMemory(db, content, embedding, {
394
+ // Same normalizations the sync handler applies at insert time —
395
+ // the row kept the wire fields verbatim.
396
+ sourceAgent: item.source_agent ?? "unknown",
397
+ sourceAgentId: item.source_agent_id,
398
+ sourceDomain: item.source_domain,
399
+ sourceMachine: storage.sanitizeSourceMachine(item.source_machine),
400
+ sourceSession: sourcePrefix ? `${sourcePrefix}#${i}` : undefined,
401
+ project: item.project ?? undefined,
402
+ memoryType,
403
+ privacy: item.privacy,
404
+ createdAt,
405
+ });
406
+ }
407
+ deleteQueuedRow(db, item.id);
408
+ });
409
+ commit();
410
+ report.processed++;
411
+ report.memories += toStore.length;
412
+ report.usage.prompt += usage.prompt;
413
+ report.usage.completion += usage.completion;
414
+ report.usage.total += usage.total;
415
+ console.log(`[hicortex] Drained ${label ?? "segment"}: ${toStore.length} memories`);
416
+ }
417
+ catch (err) {
418
+ // Endpoint failure (ladder + breaker exhausted inside LlmClient) or
419
+ // an embed/insert failure: stop the drain CLEANLY — never retry this
420
+ // item in-run, never a tight loop. Processed items stay done; this
421
+ // and the remaining items stay queued for the next scheduled run.
422
+ report.usage.prompt += usage.prompt;
423
+ report.usage.completion += usage.completion;
424
+ report.usage.total += usage.total;
425
+ console.error(`[hicortex] drain: distilling ${label ?? "segment"} failed — ` +
426
+ `${err instanceof Error ? (err.stack ?? err.message) : String(err)}. ` +
427
+ `Stopping the drain; remaining items stay queued.`);
428
+ stopped = "endpoint_down";
429
+ break outer;
430
+ }
431
+ }
432
+ }
433
+ report.outcome = stopped ?? "completed";
434
+ report.remaining = queueDepth(db);
435
+ if (report.outcome === "completed") {
436
+ console.log(`[hicortex] Distill inbox drained: ${report.processed} item(s), ` +
437
+ `${report.memories} memories, ${report.duplicates} duplicate skip(s)`);
438
+ }
439
+ return report;
440
+ }
package/dist/health.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * bearer-token auth middleware (localhost bypasses as usual) so an
12
12
  * operator running `hicortex status` on the server box, or a co-located
13
13
  * nightly preflight, still gets them — but a remote/anonymous caller does
14
- * not. Spec: `specs/2026-07-27-hosted-service.md` §6, Phase 0a item 5a/b.
14
+ * not. Phase 0a item 5a/b.
15
15
  *
16
16
  * 2. REST `res.status(500).json({error: err.message})` sites were echoing
17
17
  * internal detail (LLM upstream URLs, hostnames, stack frames) to the HTTP
@@ -34,6 +34,10 @@ export declare function publicHealthResponse(): {
34
34
  * standard auth middleware (localhost bypasses auth, so co-located tooling
35
35
  * — `hicortex status`, nightly preflight, `init` detect — sees it without a
36
36
  * token; a remote caller needs the bearer token).
37
+ *
38
+ * `distillQueue` (#529) is the inbox visibility signal: depth + oldest-item
39
+ * age in hours (null = empty inbox). Optional so the helper stays usable
40
+ * without a DB handle (tests) — the route always passes it.
37
41
  */
38
42
  export declare function detailedHealthResponse(opts: {
39
43
  memories: number;
@@ -41,6 +45,10 @@ export declare function detailedHealthResponse(opts: {
41
45
  dbSizeBytes: number;
42
46
  version: string;
43
47
  llmLabel: string;
48
+ distillQueue?: {
49
+ depth: number;
50
+ oldest_age_hours: number | null;
51
+ };
44
52
  }): {
45
53
  status: "ok";
46
54
  version: string;
@@ -48,6 +56,10 @@ export declare function detailedHealthResponse(opts: {
48
56
  links: number;
49
57
  db_size_kb: number;
50
58
  llm: string;
59
+ distill_queue?: {
60
+ depth: number;
61
+ oldest_age_hours: number | null;
62
+ };
51
63
  };
52
64
  /**
53
65
  * Log the full internal error detail server-side and return a generic