@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.
- package/assets/dashboard.html +64 -13
- package/dist/calibration.d.ts +31 -0
- package/dist/calibration.js +38 -1
- package/dist/capture.d.ts +7 -0
- package/dist/capture.js +10 -1
- package/dist/dashboard.d.ts +32 -7
- package/dist/dashboard.js +50 -10
- package/dist/db.js +45 -0
- package/dist/dedup.js +2 -2
- package/dist/distill-queue.d.ts +203 -0
- package/dist/distill-queue.js +440 -0
- package/dist/health.d.ts +13 -1
- package/dist/health.js +6 -1
- package/dist/hosted-boot.d.ts +1 -1
- package/dist/index.js +17 -2
- package/dist/learnings-identity.js +20 -1
- package/dist/localhost-bypass.js +1 -1
- package/dist/mcp-server.js +92 -7
- package/dist/nightly.js +88 -1
- package/dist/nofit.d.ts +1 -1
- package/dist/nofit.js +1 -1
- package/dist/schema-prototypes.d.ts +1 -1
- package/dist/schema-prototypes.js +1 -1
- package/dist/status.d.ts +11 -0
- package/dist/status.js +25 -0
- package/dist/types.d.ts +20 -0
- package/hermes-plugin/hicortex/README.md +13 -6
- package/hermes-plugin/hicortex/__init__.py +7 -0
- package/hermes-plugin/hicortex/client.py +64 -2
- package/hermes-plugin/hicortex/plugin.yaml +1 -1
- package/hermes-plugin/hicortex/provider.py +24 -1
- package/opencode-plugin/hicortex/index.ts +24 -1
- package/package.json +2 -1
- package/pi-extension/hicortex/index.ts +24 -1
- package/server.json +2 -2
- package/dist/eval/decay-eval.d.ts +0 -111
- package/dist/eval/decay-eval.js +0 -214
- package/dist/eval/dups.d.ts +0 -100
- package/dist/eval/dups.js +0 -174
- package/dist/eval/eval-clock.d.ts +0 -32
- package/dist/eval/eval-clock.js +0 -47
- package/dist/eval/eval-db.d.ts +0 -25
- package/dist/eval/eval-db.js +0 -67
- package/dist/eval/graph-eval.d.ts +0 -89
- package/dist/eval/graph-eval.js +0 -246
- package/dist/eval/importance-eval.d.ts +0 -85
- package/dist/eval/importance-eval.js +0 -286
- package/dist/eval/planted-eval.d.ts +0 -30
- package/dist/eval/planted-eval.js +0 -122
- package/dist/eval/planted-fixtures.d.ts +0 -107
- package/dist/eval/planted-fixtures.js +0 -283
- package/dist/eval/planted-harness.d.ts +0 -183
- package/dist/eval/planted-harness.js +0 -651
- package/dist/eval/ranking-battery.d.ts +0 -125
- package/dist/eval/ranking-battery.js +0 -289
- package/dist/eval/ranking-eval.d.ts +0 -61
- package/dist/eval/ranking-eval.js +0 -554
- package/dist/eval/ranking-fixtures.d.ts +0 -117
- package/dist/eval/ranking-fixtures.js +0 -485
- package/dist/eval/recall-sweep.d.ts +0 -87
- package/dist/eval/recall-sweep.js +0 -1030
- package/dist/eval/reflection-census.d.ts +0 -19
- package/dist/eval/reflection-census.js +0 -25
- package/dist/eval/relevance-eval.d.ts +0 -178
- package/dist/eval/relevance-eval.js +0 -2240
- package/dist/eval/run-eval.d.ts +0 -20
- 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.
|
|
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
|