@gmickel/gno 1.17.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +18 -4
  2. package/assets/skill/SKILL.md +44 -1
  3. package/assets/skill/mcp-reference.md +21 -0
  4. package/package.json +3 -2
  5. package/spec/AGENTS.md +83 -0
  6. package/spec/CLAUDE.md +83 -0
  7. package/spec/bench-fixture.schema.json +137 -0
  8. package/spec/cli.md +2894 -0
  9. package/spec/db/schema.sql +442 -0
  10. package/spec/evals-agentic.md +510 -0
  11. package/spec/evals.md +1106 -0
  12. package/spec/mcp.md +2229 -0
  13. package/spec/output-schemas/activation-verification.schema.json +515 -0
  14. package/spec/output-schemas/ask.schema.json +366 -0
  15. package/spec/output-schemas/backlinks.schema.json +131 -0
  16. package/spec/output-schemas/bench-result.schema.json +120 -0
  17. package/spec/output-schemas/capture-receipt.schema.json +143 -0
  18. package/spec/output-schemas/collection-list.schema.json +45 -0
  19. package/spec/output-schemas/context-capsule-v1.schema.json +691 -0
  20. package/spec/output-schemas/context-capsule-verification.schema.json +1338 -0
  21. package/spec/output-schemas/context-list.schema.json +21 -0
  22. package/spec/output-schemas/doctor.schema.json +313 -0
  23. package/spec/output-schemas/error.schema.json +30 -0
  24. package/spec/output-schemas/expansion.schema.json +37 -0
  25. package/spec/output-schemas/get.schema.json +140 -0
  26. package/spec/output-schemas/graph-query.schema.json +99 -0
  27. package/spec/output-schemas/graph.schema.json +371 -0
  28. package/spec/output-schemas/links-list.schema.json +186 -0
  29. package/spec/output-schemas/mcp-add-collection-result.schema.json +23 -0
  30. package/spec/output-schemas/mcp-capture-result.schema.json +152 -0
  31. package/spec/output-schemas/mcp-http-error.schema.json +30 -0
  32. package/spec/output-schemas/mcp-job-list.schema.json +58 -0
  33. package/spec/output-schemas/mcp-job-status.schema.json +224 -0
  34. package/spec/output-schemas/mcp-remove-result.schema.json +39 -0
  35. package/spec/output-schemas/mcp-sync-result.schema.json +41 -0
  36. package/spec/output-schemas/mcp-tag-result.schema.json +33 -0
  37. package/spec/output-schemas/models-list.schema.json +93 -0
  38. package/spec/output-schemas/multi-get.schema.json +103 -0
  39. package/spec/output-schemas/process-status.schema.json +119 -0
  40. package/spec/output-schemas/query-diagnose.schema.json +123 -0
  41. package/spec/output-schemas/resident-status.schema.json +154 -0
  42. package/spec/output-schemas/retrieval-trace-common.schema.json +492 -0
  43. package/spec/output-schemas/retrieval-trace-delete.schema.json +16 -0
  44. package/spec/output-schemas/retrieval-trace-export.schema.json +61 -0
  45. package/spec/output-schemas/retrieval-trace-filters.schema.json +139 -0
  46. package/spec/output-schemas/retrieval-trace-judgment.schema.json +15 -0
  47. package/spec/output-schemas/retrieval-trace-list.schema.json +18 -0
  48. package/spec/output-schemas/retrieval-trace-payloads.schema.json +178 -0
  49. package/spec/output-schemas/retrieval-trace-purge.schema.json +31 -0
  50. package/spec/output-schemas/retrieval-trace-qrels.schema.json +303 -0
  51. package/spec/output-schemas/retrieval-trace-replay.schema.json +286 -0
  52. package/spec/output-schemas/retrieval-trace-show.schema.json +69 -0
  53. package/spec/output-schemas/retrieval-trace-summary.schema.json +65 -0
  54. package/spec/output-schemas/search-result.schema.json +154 -0
  55. package/spec/output-schemas/search-results.schema.json +338 -0
  56. package/spec/output-schemas/similar.schema.json +84 -0
  57. package/spec/output-schemas/status.schema.json +676 -0
  58. package/spec/output-schemas/tags-list.schema.json +48 -0
  59. package/src/app/context-runtime-types.ts +3 -0
  60. package/src/app/context-runtime.ts +15 -1
  61. package/src/cli/commands/ask.ts +106 -36
  62. package/src/cli/commands/context-build.ts +56 -9
  63. package/src/cli/commands/daemon.ts +69 -2
  64. package/src/cli/commands/get.ts +64 -3
  65. package/src/cli/commands/models/pull.ts +13 -3
  66. package/src/cli/commands/query.ts +62 -23
  67. package/src/cli/commands/replay.ts +140 -0
  68. package/src/cli/commands/search.ts +48 -3
  69. package/src/cli/commands/shared.ts +3 -1
  70. package/src/cli/commands/status.ts +2 -0
  71. package/src/cli/commands/trace.ts +200 -0
  72. package/src/cli/commands/vsearch.ts +75 -53
  73. package/src/cli/detach.ts +37 -20
  74. package/src/cli/program.ts +329 -27
  75. package/src/config/index.ts +12 -0
  76. package/src/config/retrieval-traces.ts +56 -0
  77. package/src/config/types.ts +41 -0
  78. package/src/core/context-compiler.ts +11 -4
  79. package/src/core/job-manager.ts +19 -0
  80. package/src/core/mutation-generations.ts +33 -0
  81. package/src/core/retrieval-qrels.ts +405 -0
  82. package/src/core/retrieval-replay-candidate.ts +368 -0
  83. package/src/core/retrieval-replay-types.ts +109 -0
  84. package/src/core/retrieval-replay-validation.ts +89 -0
  85. package/src/core/retrieval-replay.ts +441 -0
  86. package/src/core/retrieval-trace-evidence-origin.ts +175 -0
  87. package/src/core/retrieval-trace-export.ts +113 -0
  88. package/src/core/retrieval-trace-filter-normalization.ts +27 -0
  89. package/src/core/retrieval-trace-filters.ts +19 -0
  90. package/src/core/retrieval-trace-management-helpers.ts +247 -0
  91. package/src/core/retrieval-trace-management-types.ts +132 -0
  92. package/src/core/retrieval-trace-management.ts +422 -0
  93. package/src/core/retrieval-trace-request.ts +141 -0
  94. package/src/core/retrieval-trace-session.ts +494 -0
  95. package/src/core/retrieval-trace.ts +472 -0
  96. package/src/llm/cache.ts +13 -3
  97. package/src/llm/nodeLlamaCpp/adapter.ts +10 -1
  98. package/src/llm/nodeLlamaCpp/lifecycle.ts +71 -0
  99. package/src/mcp/context.ts +161 -0
  100. package/src/mcp/http-security.ts +477 -0
  101. package/src/mcp/http-session.ts +272 -0
  102. package/src/mcp/http-transport.ts +370 -0
  103. package/src/mcp/resources/index.ts +141 -134
  104. package/src/mcp/server.ts +19 -79
  105. package/src/mcp/tools/add-collection.ts +3 -1
  106. package/src/mcp/tools/capture.ts +3 -0
  107. package/src/mcp/tools/clear-collection-embeddings.ts +2 -0
  108. package/src/mcp/tools/context.ts +68 -16
  109. package/src/mcp/tools/embed.ts +62 -52
  110. package/src/mcp/tools/get.ts +35 -1
  111. package/src/mcp/tools/index-cmd.ts +88 -74
  112. package/src/mcp/tools/index.ts +96 -2
  113. package/src/mcp/tools/query.ts +95 -64
  114. package/src/mcp/tools/remove-collection.ts +2 -0
  115. package/src/mcp/tools/search.ts +36 -13
  116. package/src/mcp/tools/status.ts +11 -0
  117. package/src/mcp/tools/sync.ts +16 -14
  118. package/src/mcp/tools/trace.ts +143 -0
  119. package/src/mcp/tools/vsearch.ts +71 -38
  120. package/src/mcp/tools/workspace-write.ts +7 -3
  121. package/src/pipeline/answer.ts +167 -26
  122. package/src/pipeline/graph-retrieval.ts +15 -1
  123. package/src/pipeline/hybrid.ts +151 -43
  124. package/src/pipeline/search.ts +36 -3
  125. package/src/pipeline/trace-metadata.ts +47 -0
  126. package/src/pipeline/types.ts +43 -0
  127. package/src/pipeline/vsearch.ts +101 -38
  128. package/src/sdk/client.ts +380 -71
  129. package/src/sdk/documents.ts +48 -1
  130. package/src/sdk/index.ts +17 -0
  131. package/src/sdk/types.ts +28 -0
  132. package/src/serve/background-runtime.ts +12 -212
  133. package/src/serve/context-capsule.ts +67 -8
  134. package/src/serve/embed-scheduler.ts +74 -43
  135. package/src/serve/index.ts +9 -0
  136. package/src/serve/jobs.ts +78 -80
  137. package/src/serve/public/app.tsx +12 -1
  138. package/src/serve/public/components/HealthCenter.tsx +74 -1
  139. package/src/serve/public/globals.built.css +1 -1
  140. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  141. package/src/serve/public/pages/Dashboard.tsx +11 -0
  142. package/src/serve/public/pages/TraceHistory.tsx +478 -0
  143. package/src/serve/public/pages/trace-history-detail.tsx +224 -0
  144. package/src/serve/resident-admission.ts +159 -0
  145. package/src/serve/resident-background-work.ts +39 -0
  146. package/src/serve/resident-request.ts +55 -0
  147. package/src/serve/resident-runtime.ts +490 -0
  148. package/src/serve/resident-status.ts +96 -0
  149. package/src/serve/retrieval-trace.ts +28 -0
  150. package/src/serve/routes/api.ts +629 -239
  151. package/src/serve/routes/mcp.ts +69 -0
  152. package/src/serve/routes/traces.ts +156 -0
  153. package/src/serve/server.ts +276 -37
  154. package/src/serve/status-model.ts +51 -0
  155. package/src/serve/status.ts +5 -0
  156. package/src/store/index.ts +31 -0
  157. package/src/store/migrations/014-retrieval-traces.ts +303 -0
  158. package/src/store/migrations/index.ts +2 -0
  159. package/src/store/retrieval-trace-codec.ts +384 -0
  160. package/src/store/sqlite/adapter.ts +179 -10
  161. package/src/store/sqlite/retrieval-trace-management-store.ts +341 -0
  162. package/src/store/sqlite/retrieval-trace-retention.ts +349 -0
  163. package/src/store/sqlite/retrieval-trace-rows.ts +267 -0
  164. package/src/store/sqlite/retrieval-trace-store.ts +515 -0
  165. package/src/store/types.ts +297 -0
  166. package/src/store/vector/sqlite-vec.ts +76 -1
  167. package/src/store/vector/types.ts +1 -1
@@ -0,0 +1,472 @@
1
+ /** Opt-in, local-only retrieval trace recording with privacy projections. */
2
+
3
+ import { z } from "zod";
4
+
5
+ import type {
6
+ RetrievalTraceConfig,
7
+ RetrievalTraceRedactionMode,
8
+ } from "../config/retrieval-traces";
9
+ import type {
10
+ RetrievalTraceAppendResult,
11
+ RetrievalTraceEventInput,
12
+ RetrievalTraceFingerprints,
13
+ RetrievalTraceJudgmentInput,
14
+ RetrievalTraceRunInput,
15
+ RetrievalTraceTerminalStatus,
16
+ StorePort,
17
+ StoreResult,
18
+ } from "../store/types";
19
+
20
+ import {
21
+ parseRetrievalTraceEventInput,
22
+ parseRetrievalTraceJudgmentInput,
23
+ parseRetrievalTraceRunInput,
24
+ } from "../store/retrieval-trace-codec";
25
+ import { err, ok } from "../store/types";
26
+ import { canonicalizeRetrievalTraceFilters } from "./retrieval-trace-filter-normalization";
27
+
28
+ const sha256Schema = z.string().regex(/^[a-f0-9]{64}$/);
29
+ const gnoUriSchema = z
30
+ .string()
31
+ .max(4096)
32
+ .refine((value) => value.startsWith("gno://"), {
33
+ message: "Evidence URI must use the canonical gno:// reader identity",
34
+ });
35
+ const queryModeSchema = z
36
+ .object({
37
+ mode: z.enum(["term", "intent", "hyde"]),
38
+ text: z.string().max(8192),
39
+ })
40
+ .strict();
41
+ export const traceFiltersSchema = z
42
+ .object({
43
+ limit: z.number().int().positive().optional(),
44
+ minScore: z.number().min(0).max(1).optional(),
45
+ collection: z.string().max(256).optional(),
46
+ collections: z.array(z.string().min(1).max(256)).max(1000).optional(),
47
+ lang: z.string().max(64).optional(),
48
+ full: z.boolean().optional(),
49
+ lineNumbers: z.boolean().optional(),
50
+ tagsAll: z.array(z.string().max(512)).max(1000).optional(),
51
+ tagsAny: z.array(z.string().max(512)).max(1000).optional(),
52
+ since: z.string().max(128).optional(),
53
+ until: z.string().max(128).optional(),
54
+ categories: z.array(z.string().max(512)).max(1000).optional(),
55
+ author: z.string().max(1024).optional(),
56
+ intent: z.string().max(8192).optional(),
57
+ exclude: z.array(z.string().max(1024)).max(1000).optional(),
58
+ noExpand: z.boolean().optional(),
59
+ noRerank: z.boolean().optional(),
60
+ candidateLimit: z.number().int().positive().optional(),
61
+ explain: z.boolean().optional(),
62
+ graph: z.boolean().optional(),
63
+ noGraph: z.boolean().optional(),
64
+ queryLanguageHint: z.string().max(64).optional(),
65
+ queryModes: z.array(queryModeSchema).max(100).optional(),
66
+ uriPrefix: z.string().max(4096).optional(),
67
+ })
68
+ .strict();
69
+ const startTraceSchema = z
70
+ .object({
71
+ traceId: z.string().min(1).max(128).optional(),
72
+ query: z.string().min(1).max(8192),
73
+ goal: z.string().max(8192).optional(),
74
+ filters: traceFiltersSchema.default({}),
75
+ fingerprints: z
76
+ .object({
77
+ pipeline: sha256Schema,
78
+ model: sha256Schema,
79
+ config: sha256Schema,
80
+ index: sha256Schema,
81
+ })
82
+ .strict(),
83
+ })
84
+ .strict();
85
+
86
+ const evidenceRefBaseSchema = z
87
+ .object({
88
+ docid: z.string().min(1).max(256).optional(),
89
+ sourceHash: sha256Schema.optional(),
90
+ mirrorHash: sha256Schema.optional(),
91
+ uri: gnoUriSchema.optional(),
92
+ seq: z.number().int().nonnegative().optional(),
93
+ startLine: z.number().int().positive().optional(),
94
+ endLine: z.number().int().positive().optional(),
95
+ score: z.number().finite().optional(),
96
+ rank: z.number().int().positive().optional(),
97
+ plannerRank: z.number().int().positive().optional(),
98
+ passageHash: sha256Schema.optional(),
99
+ sources: z.array(z.string().min(1).max(128)).max(16).optional(),
100
+ graphExpanded: z.boolean().optional(),
101
+ })
102
+ .strict();
103
+ const refineEvidenceRef = (
104
+ value: z.infer<typeof evidenceRefBaseSchema>,
105
+ context: z.RefinementCtx
106
+ ): void => {
107
+ if (!(value.docid || value.sourceHash || value.mirrorHash || value.uri)) {
108
+ context.addIssue({
109
+ code: "custom",
110
+ message: "Evidence references require a stable document identity",
111
+ });
112
+ }
113
+ if ((value.startLine === undefined) !== (value.endLine === undefined)) {
114
+ context.addIssue({
115
+ code: "custom",
116
+ message: "Evidence line ranges require both startLine and endLine",
117
+ });
118
+ }
119
+ if (
120
+ value.startLine !== undefined &&
121
+ value.endLine !== undefined &&
122
+ value.startLine > value.endLine
123
+ ) {
124
+ context.addIssue({
125
+ code: "custom",
126
+ path: ["endLine"],
127
+ message: "Evidence endLine must not precede startLine",
128
+ });
129
+ }
130
+ };
131
+ const evidenceRefSchema = evidenceRefBaseSchema.superRefine(refineEvidenceRef);
132
+ const evidencePayloadSchema = z
133
+ .object({
134
+ evidence: z.array(evidenceRefSchema).max(10_000),
135
+ latencyMs: z.number().finite().nonnegative().optional(),
136
+ })
137
+ .strict();
138
+ const retrievalPayloadSchema = z
139
+ .object({
140
+ ranked: z.array(evidenceRefSchema).max(10_000),
141
+ latencyMs: z.number().finite().nonnegative().optional(),
142
+ capabilities: z.array(z.string().min(1).max(128)).max(100).optional(),
143
+ fallbackCodes: z.array(z.string().min(1).max(128)).max(100).optional(),
144
+ })
145
+ .strict();
146
+ const contextPayloadSchema = evidencePayloadSchema.extend({
147
+ capsuleId: z.string().min(1).max(256),
148
+ });
149
+ const runPayloadSchemas = {
150
+ retrieval: retrievalPayloadSchema,
151
+ context: contextPayloadSchema,
152
+ get: evidencePayloadSchema,
153
+ } as const;
154
+ const eventPayloadSchemas = {
155
+ query: z.object({ filterFingerprint: sha256Schema.optional() }).strict(),
156
+ retrieval: retrievalPayloadSchema,
157
+ context: contextPayloadSchema,
158
+ get: evidencePayloadSchema,
159
+ open: evidencePayloadSchema,
160
+ cite: evidencePayloadSchema,
161
+ pin: evidencePayloadSchema,
162
+ capability: z
163
+ .object({
164
+ capability: z.string().min(1).max(128),
165
+ status: z.enum(["attempted", "used", "unavailable", "failed"]),
166
+ reasonCode: z.string().min(1).max(128).optional(),
167
+ })
168
+ .strict(),
169
+ complete: z
170
+ .object({
171
+ outcome: z.enum(["completed", "partial", "failed", "cancelled"]),
172
+ latencyMs: z.number().finite().nonnegative().optional(),
173
+ })
174
+ .strict(),
175
+ } as const;
176
+ const judgmentTargetSchema = evidenceRefBaseSchema
177
+ .omit({
178
+ score: true,
179
+ rank: true,
180
+ })
181
+ .superRefine(refineEvidenceRef);
182
+
183
+ export interface StartRetrievalTraceInput {
184
+ traceId?: string;
185
+ query: string;
186
+ goal?: string;
187
+ filters?: z.input<typeof traceFiltersSchema>;
188
+ fingerprints: RetrievalTraceFingerprints;
189
+ }
190
+
191
+ export type RetrievalTraceWriteResult =
192
+ | {
193
+ recorded: false;
194
+ traceId: null;
195
+ replayCapable: false;
196
+ result: "disabled";
197
+ }
198
+ | {
199
+ recorded: true;
200
+ traceId: string;
201
+ replayCapable: boolean;
202
+ result: RetrievalTraceAppendResult;
203
+ };
204
+
205
+ interface RetrievalTraceRecorderDeps {
206
+ clock?: () => number;
207
+ idFactory?: () => string;
208
+ /** Stable local secret loaded by the caller; required for metadata labels. */
209
+ redactionSecret?: string;
210
+ }
211
+
212
+ /** Query + retrieval run + retrieval event + terminal event. */
213
+ export const MIN_RETRIEVAL_TRACE_RECORDS = 4;
214
+
215
+ const normalizeText = (value: string): string =>
216
+ value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").normalize("NFC");
217
+
218
+ const textShape = (value: string | undefined) => {
219
+ const normalized = normalizeText(value ?? "");
220
+ return {
221
+ characters: Array.from(normalized).length,
222
+ terms: normalized.trim() ? normalized.trim().split(/\s+/u).length : 0,
223
+ };
224
+ };
225
+
226
+ const sha256 = (value: string): string =>
227
+ new Bun.CryptoHasher("sha256").update(value).digest("hex");
228
+
229
+ const valueShape = (value: unknown): unknown => {
230
+ if (value === null) return { type: "null" };
231
+ if (Array.isArray(value)) {
232
+ const itemTypes = [
233
+ ...new Set(
234
+ value.map((item) =>
235
+ item === null ? "null" : Array.isArray(item) ? "array" : typeof item
236
+ )
237
+ ),
238
+ ].sort();
239
+ return { type: "array", count: value.length, itemTypes };
240
+ }
241
+ if (typeof value === "object") {
242
+ const fields: Record<string, unknown> = {};
243
+ for (const key of Object.keys(value as Record<string, unknown>).sort()) {
244
+ fields[key] = valueShape((value as Record<string, unknown>)[key]);
245
+ }
246
+ return { type: "object", fields };
247
+ }
248
+ return { type: typeof value };
249
+ };
250
+
251
+ const projectMetadataObject = (
252
+ value: Record<string, unknown>
253
+ ): Record<string, unknown> => ({ shape: valueShape(value) });
254
+
255
+ const projectPayload = (
256
+ _mode: RetrievalTraceRedactionMode,
257
+ value: Record<string, unknown>
258
+ ): Record<string, unknown> => value;
259
+
260
+ export class RetrievalTraceRecorder {
261
+ private readonly clock: () => number;
262
+ private readonly idFactory: () => string;
263
+ private readonly redactionSecret: string | undefined;
264
+
265
+ constructor(
266
+ private readonly store: StorePort,
267
+ private readonly config: RetrievalTraceConfig | undefined,
268
+ deps: RetrievalTraceRecorderDeps = {}
269
+ ) {
270
+ this.clock = deps.clock ?? Date.now;
271
+ this.idFactory = deps.idFactory ?? (() => crypto.randomUUID());
272
+ this.redactionSecret = deps.redactionSecret;
273
+ }
274
+
275
+ isEnabled(): boolean {
276
+ return this.config?.enabled === true;
277
+ }
278
+
279
+ async start(
280
+ input: StartRetrievalTraceInput
281
+ ): Promise<StoreResult<RetrievalTraceWriteResult>> {
282
+ if (
283
+ !this.config?.enabled ||
284
+ this.config.retention.maxRecordsPerTrace < MIN_RETRIEVAL_TRACE_RECORDS
285
+ ) {
286
+ return ok({
287
+ recorded: false,
288
+ traceId: null,
289
+ replayCapable: false,
290
+ result: "disabled",
291
+ });
292
+ }
293
+ const parsed = startTraceSchema.safeParse(input);
294
+ if (!parsed.success) {
295
+ return err("INVALID_INPUT", parsed.error.message, parsed.error);
296
+ }
297
+ const nowMs = this.clock();
298
+ if (!Number.isSafeInteger(nowMs) || nowMs < 0) {
299
+ return err("INVALID_INPUT", "Trace clock must return epoch milliseconds");
300
+ }
301
+ const traceId = parsed.data.traceId ?? this.idFactory();
302
+ const query = normalizeText(parsed.data.query);
303
+ const goal =
304
+ parsed.data.goal === undefined
305
+ ? undefined
306
+ : normalizeText(parsed.data.goal);
307
+ const filters = canonicalizeRetrievalTraceFilters(parsed.data.filters);
308
+ const replay = this.config.redactionMode === "replay";
309
+ const create = await this.store.createRetrievalTrace({
310
+ traceId,
311
+ schemaVersion: "1.0",
312
+ redactionMode: this.config.redactionMode,
313
+ replayCapable: replay,
314
+ queryText: replay ? query : null,
315
+ queryDigest: replay ? sha256(query) : null,
316
+ queryShape: textShape(query),
317
+ goalText: replay ? (goal ?? null) : null,
318
+ goalDigest: replay && goal !== undefined ? sha256(goal) : null,
319
+ goalShape: textShape(goal),
320
+ filters: replay ? filters : projectMetadataObject(filters),
321
+ fingerprints: parsed.data.fingerprints,
322
+ status: "open",
323
+ createdAtMs: nowMs,
324
+ updatedAtMs: nowMs,
325
+ expiresAtMs: nowMs + this.config.retention.maxAgeDays * 86_400_000,
326
+ });
327
+ if (!create.ok) return create;
328
+ const retention = await this.store.enforceRetrievalTraceRetention(
329
+ this.config.retention,
330
+ nowMs
331
+ );
332
+ if (!retention.ok) return retention;
333
+ const retained = await this.store.getRetrievalTrace(traceId);
334
+ if (!retained.ok) return retained;
335
+ if (!retained.value) {
336
+ return err(
337
+ "CONSTRAINT_VIOLATION",
338
+ `Retrieval trace ${traceId} exceeded retention limits and was evicted`
339
+ );
340
+ }
341
+ return ok({
342
+ recorded: true,
343
+ traceId,
344
+ replayCapable: replay,
345
+ result: create.value,
346
+ });
347
+ }
348
+
349
+ async appendRun(
350
+ input: RetrievalTraceRunInput
351
+ ): Promise<StoreResult<RetrievalTraceAppendResult | "disabled">> {
352
+ if (!this.config?.enabled) return ok("disabled");
353
+ const payload = runPayloadSchemas[input.kind]?.safeParse(input.payload);
354
+ if (!payload?.success) {
355
+ return err(
356
+ "INVALID_INPUT",
357
+ payload?.error.message ?? `Unknown retrieval run kind: ${input.kind}`
358
+ );
359
+ }
360
+ try {
361
+ const parsed = parseRetrievalTraceRunInput({
362
+ ...input,
363
+ payload: projectPayload(this.config.redactionMode, payload.data),
364
+ });
365
+ return await this.enforceAfterWrite(
366
+ input.traceId,
367
+ await this.store.appendRetrievalTraceRun(parsed)
368
+ );
369
+ } catch (cause) {
370
+ return invalidTraceInput(cause, "Invalid retrieval trace run");
371
+ }
372
+ }
373
+
374
+ async appendEvent(
375
+ input: RetrievalTraceEventInput
376
+ ): Promise<StoreResult<RetrievalTraceAppendResult | "disabled">> {
377
+ if (!this.config?.enabled) return ok("disabled");
378
+ const payload = eventPayloadSchemas[input.kind]?.safeParse(input.payload);
379
+ if (!payload?.success) {
380
+ return err(
381
+ "INVALID_INPUT",
382
+ payload?.error.message ?? `Unknown retrieval event kind: ${input.kind}`
383
+ );
384
+ }
385
+ try {
386
+ const parsed = parseRetrievalTraceEventInput({
387
+ ...input,
388
+ payload: projectPayload(this.config.redactionMode, payload.data),
389
+ });
390
+ return await this.enforceAfterWrite(
391
+ input.traceId,
392
+ await this.store.appendRetrievalTraceEvent(parsed)
393
+ );
394
+ } catch (cause) {
395
+ return invalidTraceInput(cause, "Invalid retrieval trace event");
396
+ }
397
+ }
398
+
399
+ async appendJudgment(
400
+ input: RetrievalTraceJudgmentInput
401
+ ): Promise<StoreResult<RetrievalTraceAppendResult | "disabled">> {
402
+ if (!this.config?.enabled) return ok("disabled");
403
+ const target = judgmentTargetSchema.safeParse(input.target);
404
+ if (!target.success) {
405
+ return err("INVALID_INPUT", target.error.message, target.error);
406
+ }
407
+ const metadata = this.config.redactionMode === "metadata";
408
+ if (metadata && !this.redactionSecret) {
409
+ return err(
410
+ "INVALID_INPUT",
411
+ "Metadata judgments require a stable local redaction secret"
412
+ );
413
+ }
414
+ try {
415
+ const parsed = parseRetrievalTraceJudgmentInput({
416
+ ...input,
417
+ targetRef: metadata
418
+ ? `redacted:${sha256(`${this.redactionSecret}\0${input.targetRef}`)}`
419
+ : input.targetRef,
420
+ target: projectPayload(this.config.redactionMode, target.data),
421
+ });
422
+ return await this.enforceAfterWrite(
423
+ input.traceId,
424
+ await this.store.appendRetrievalTraceJudgment(parsed)
425
+ );
426
+ } catch (cause) {
427
+ return invalidTraceInput(cause, "Invalid retrieval trace judgment");
428
+ }
429
+ }
430
+
431
+ async finalize(
432
+ traceId: string,
433
+ status: RetrievalTraceTerminalStatus
434
+ ): Promise<StoreResult<RetrievalTraceAppendResult | "disabled">> {
435
+ if (!this.config?.enabled) return ok("disabled");
436
+ return await this.enforceAfterWrite(
437
+ traceId,
438
+ await this.store.finalizeRetrievalTrace(traceId, status, this.clock())
439
+ );
440
+ }
441
+
442
+ private async enforceAfterWrite(
443
+ traceId: string,
444
+ result: StoreResult<RetrievalTraceAppendResult>
445
+ ): Promise<StoreResult<RetrievalTraceAppendResult>> {
446
+ if (!result.ok || !this.config?.enabled) return result;
447
+ const retained = await this.store.enforceRetrievalTraceRetention(
448
+ this.config.retention,
449
+ this.clock()
450
+ );
451
+ if (!retained.ok) return retained;
452
+ const stored = await this.store.getRetrievalTrace(traceId);
453
+ if (!stored.ok) return stored;
454
+ if (!stored.value) {
455
+ return err(
456
+ "CONSTRAINT_VIOLATION",
457
+ `Retrieval trace ${traceId} exceeded retention limits and was evicted`
458
+ );
459
+ }
460
+ return result;
461
+ }
462
+ }
463
+
464
+ const invalidTraceInput = <T>(
465
+ cause: unknown,
466
+ fallback: string
467
+ ): StoreResult<T> =>
468
+ err(
469
+ "INVALID_INPUT",
470
+ cause instanceof Error ? cause.message : fallback,
471
+ cause
472
+ );
package/src/llm/cache.ts CHANGED
@@ -46,6 +46,7 @@ const HF_PATH_PATTERN = /^([^/]+)\/([^/]+)\/(.+\.gguf)$/;
46
46
  const GGUF_MAGIC = new Uint8Array([0x47, 0x47, 0x55, 0x46]);
47
47
 
48
48
  type ModelFileOwner = "cache" | "user";
49
+ type ResolveModelFile = typeof import("node-llama-cpp").resolveModelFile;
49
50
 
50
51
  type ValidatedCachedPath =
51
52
  | { ok: true; path: string }
@@ -268,11 +269,16 @@ const MANIFEST_VERSION = "1.0" as const;
268
269
  export class ModelCache {
269
270
  readonly dir: string;
270
271
  private readonly manifestPath: string;
272
+ private readonly resolveModelFileFn?: ResolveModelFile;
271
273
  private manifest: Manifest | null = null;
272
274
 
273
- constructor(cacheDir?: string) {
275
+ constructor(
276
+ cacheDir?: string,
277
+ deps?: { resolveModelFile?: ResolveModelFile }
278
+ ) {
274
279
  this.dir = cacheDir ?? getModelsCachePath();
275
280
  this.manifestPath = join(this.dir, "manifest.json");
281
+ this.resolveModelFileFn = deps?.resolveModelFile;
276
282
  }
277
283
 
278
284
  /**
@@ -324,7 +330,8 @@ export class ModelCache {
324
330
  uri: string,
325
331
  type: ModelType,
326
332
  onProgress?: ProgressCallback,
327
- force?: boolean
333
+ force?: boolean,
334
+ signal?: AbortSignal
328
335
  ): Promise<LlmResult<string>> {
329
336
  const parsed = parseModelUri(uri);
330
337
  if (!parsed.ok) {
@@ -364,7 +371,9 @@ export class ModelCache {
364
371
  }
365
372
 
366
373
  try {
367
- const { resolveModelFile } = await import("node-llama-cpp");
374
+ const resolveModelFile =
375
+ this.resolveModelFileFn ??
376
+ (await import("node-llama-cpp")).resolveModelFile;
368
377
 
369
378
  // Convert to node-llama-cpp format (handles quantization shorthand)
370
379
  // node-llama-cpp needs hf: prefix to identify HuggingFace models
@@ -399,6 +408,7 @@ export class ModelCache {
399
408
  }
400
409
  }
401
410
  : undefined,
411
+ signal,
402
412
  });
403
413
 
404
414
  const validation = await validateGgufFile(resolvedPath, uri, "cache");
@@ -27,7 +27,11 @@ import {
27
27
  } from "../registry";
28
28
  import { NodeLlamaCppEmbedding } from "./embedding";
29
29
  import { NodeLlamaCppGeneration } from "./generation";
30
- import { getModelManager, type ModelManager } from "./lifecycle";
30
+ import {
31
+ getModelManager,
32
+ type ModelLease,
33
+ type ModelManager,
34
+ } from "./lifecycle";
31
35
  import { NodeLlamaCppRerank } from "./rerank";
32
36
 
33
37
  // ─────────────────────────────────────────────────────────────────────────────
@@ -218,6 +222,11 @@ export class LlmAdapter {
218
222
  return this.manager;
219
223
  }
220
224
 
225
+ /** Acquire an idempotent request lease without transferring manager ownership. */
226
+ acquireModelLease(): ModelLease {
227
+ return this.manager.acquireLease();
228
+ }
229
+
221
230
  /**
222
231
  * Dispose all resources.
223
232
  */
@@ -35,6 +35,21 @@ interface CachedModel {
35
35
  loadedAt: number;
36
36
  }
37
37
 
38
+ export interface ModelLease {
39
+ release(): void;
40
+ }
41
+
42
+ export interface ModelLifecycleStats {
43
+ activeLeases: number;
44
+ leaseAcquisitions: number;
45
+ leaseReleases: number;
46
+ loadedModels: number;
47
+ loadAttempts: number;
48
+ loadSuccesses: number;
49
+ loadFailures: number;
50
+ inflightLoads: number;
51
+ }
52
+
38
53
  let invalidGpuModeWarned = false;
39
54
  let invalidBuildModeWarned = false;
40
55
  let gpuFallbackWarned = false;
@@ -134,7 +149,14 @@ export class ModelManager {
134
149
  new Map();
135
150
  private readonly inflightLoads: Map<string, Promise<LlmResult<LoadedModel>>> =
136
151
  new Map();
152
+ private readonly leaseDrainWaiters = new Set<() => void>();
137
153
  private readonly config: ModelConfig;
154
+ private activeLeases = 0;
155
+ private leaseAcquisitions = 0;
156
+ private leaseReleases = 0;
157
+ private loadAttempts = 0;
158
+ private loadSuccesses = 0;
159
+ private loadFailures = 0;
138
160
 
139
161
  constructor(config: ModelConfig) {
140
162
  this.config = config;
@@ -263,6 +285,7 @@ export class ModelManager {
263
285
  uri: string,
264
286
  type: ModelType
265
287
  ): Promise<LlmResult<LoadedModel>> {
288
+ this.loadAttempts += 1;
266
289
  const timeoutMs = this.config.loadTimeout;
267
290
  let timeoutId: ReturnType<typeof setTimeout> | null = null;
268
291
  let timedOut = false;
@@ -299,6 +322,7 @@ export class ModelManager {
299
322
 
300
323
  this.models.set(uri, cachedModel);
301
324
  this.setDisposalTimer(uri);
325
+ this.loadSuccesses += 1;
302
326
 
303
327
  return {
304
328
  ok: true,
@@ -310,6 +334,7 @@ export class ModelManager {
310
334
  },
311
335
  };
312
336
  } catch (e) {
337
+ this.loadFailures += 1;
313
338
  // Clear timeout on error
314
339
  if (timeoutId) {
315
340
  clearTimeout(timeoutId);
@@ -356,6 +381,42 @@ export class ModelManager {
356
381
  return model;
357
382
  }
358
383
 
384
+ /** Keep warm models alive while one request owns model-backed ports. */
385
+ acquireLease(): ModelLease {
386
+ this.activeLeases += 1;
387
+ this.leaseAcquisitions += 1;
388
+ for (const timer of this.disposalTimers.values()) clearTimeout(timer);
389
+ this.disposalTimers.clear();
390
+
391
+ let released = false;
392
+ return {
393
+ release: () => {
394
+ if (released) return;
395
+ released = true;
396
+ this.activeLeases = Math.max(0, this.activeLeases - 1);
397
+ this.leaseReleases += 1;
398
+ if (this.activeLeases === 0) {
399
+ for (const resolve of this.leaseDrainWaiters) resolve();
400
+ this.leaseDrainWaiters.clear();
401
+ for (const uri of this.models.keys()) this.setDisposalTimer(uri);
402
+ }
403
+ },
404
+ };
405
+ }
406
+
407
+ getLifecycleStats(): ModelLifecycleStats {
408
+ return {
409
+ activeLeases: this.activeLeases,
410
+ leaseAcquisitions: this.leaseAcquisitions,
411
+ leaseReleases: this.leaseReleases,
412
+ loadedModels: this.models.size,
413
+ loadAttempts: this.loadAttempts,
414
+ loadSuccesses: this.loadSuccesses,
415
+ loadFailures: this.loadFailures,
416
+ inflightLoads: this.inflightLoads.size,
417
+ };
418
+ }
419
+
359
420
  /**
360
421
  * Check if a model is loaded.
361
422
  */
@@ -367,6 +428,10 @@ export class ModelManager {
367
428
  * Dispose a specific model.
368
429
  */
369
430
  async dispose(uri: string): Promise<void> {
431
+ if (this.activeLeases > 0) {
432
+ this.resetDisposalTimer(uri);
433
+ return;
434
+ }
370
435
  const cached = this.models.get(uri);
371
436
  if (!cached) {
372
437
  return;
@@ -393,6 +458,11 @@ export class ModelManager {
393
458
  * Dispose all loaded models.
394
459
  */
395
460
  async disposeAll(): Promise<void> {
461
+ if (this.activeLeases > 0) {
462
+ await new Promise<void>((resolve) => this.leaseDrainWaiters.add(resolve));
463
+ }
464
+ await Promise.allSettled(this.inflightLoads.values());
465
+
396
466
  // Clear all timers
397
467
  for (const timer of this.disposalTimers.values()) {
398
468
  clearTimeout(timer);
@@ -429,6 +499,7 @@ export class ModelManager {
429
499
  // ───────────────────────────────────────────────────────────────────────────
430
500
 
431
501
  private setDisposalTimer(uri: string): void {
502
+ if (this.activeLeases > 0) return;
432
503
  const timer = setTimeout(() => {
433
504
  this.dispose(uri).catch(() => {
434
505
  // Ignore disposal errors in timer callback