@gmickel/gno 1.19.0 → 1.21.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 (85) hide show
  1. package/README.md +28 -8
  2. package/assets/skill/SKILL.md +73 -27
  3. package/assets/skill/mcp-reference.md +7 -2
  4. package/assets/skill/recipes/citation-and-provenance.md +32 -9
  5. package/package.json +1 -1
  6. package/spec/cli.md +142 -17
  7. package/spec/db/schema.sql +170 -0
  8. package/spec/evals-agentic.md +87 -5
  9. package/spec/mcp.md +75 -3
  10. package/spec/output-schemas/ask.schema.json +198 -0
  11. package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
  12. package/spec/output-schemas/changes.schema.json +280 -0
  13. package/spec/output-schemas/claim-verification.schema.json +291 -0
  14. package/spec/output-schemas/context-capsule-v1.schema.json +36 -1
  15. package/spec/output-schemas/document-diff.schema.json +185 -0
  16. package/spec/output-schemas/impact.schema.json +122 -0
  17. package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
  18. package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
  19. package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
  20. package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
  21. package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
  22. package/src/app/context-runtime-contract.ts +10 -5
  23. package/src/app/context-runtime-input.ts +29 -1
  24. package/src/app/context-runtime-types.ts +4 -0
  25. package/src/app/context-runtime.ts +5 -1
  26. package/src/app/context-surface.ts +4 -0
  27. package/src/app/verified-ask.ts +291 -0
  28. package/src/cli/commands/ask-format.ts +255 -0
  29. package/src/cli/commands/ask.ts +40 -149
  30. package/src/cli/commands/changes.ts +160 -0
  31. package/src/cli/commands/context-saved.ts +189 -0
  32. package/src/cli/options.ts +8 -0
  33. package/src/cli/program.ts +227 -1
  34. package/src/core/capsule-registry.ts +279 -0
  35. package/src/core/capsule-reverification-scheduler.ts +218 -0
  36. package/src/core/capsule-reverification.ts +289 -0
  37. package/src/core/change-diff.ts +182 -0
  38. package/src/core/change-journal.ts +228 -0
  39. package/src/core/context-budget.ts +6 -0
  40. package/src/core/context-capsule-retrieval-schema.ts +4 -0
  41. package/src/core/context-capsule-schema.ts +17 -0
  42. package/src/core/context-capsule-validation.ts +3 -2
  43. package/src/core/context-capsule.ts +18 -0
  44. package/src/core/context-compiler.ts +33 -21
  45. package/src/core/context-evidence.ts +6 -0
  46. package/src/core/knowledge-delta.ts +395 -0
  47. package/src/core/knowledge-impact.ts +202 -0
  48. package/src/core/retrieval-trace-evidence-origin.ts +3 -0
  49. package/src/core/retrieval-trace-session.ts +15 -2
  50. package/src/ingestion/sync.ts +214 -165
  51. package/src/llm/errors.ts +10 -1
  52. package/src/llm/httpGeneration.ts +11 -1
  53. package/src/llm/nodeLlamaCpp/generation.ts +54 -10
  54. package/src/llm/types.ts +6 -0
  55. package/src/mcp/tools/ask.ts +228 -0
  56. package/src/mcp/tools/changes.ts +80 -0
  57. package/src/mcp/tools/context.ts +28 -7
  58. package/src/mcp/tools/index.ts +38 -0
  59. package/src/pipeline/claim-verification-schema.ts +235 -0
  60. package/src/pipeline/claim-verification.ts +487 -0
  61. package/src/pipeline/claim-verifier.ts +474 -0
  62. package/src/pipeline/types.ts +25 -0
  63. package/src/sdk/client.ts +77 -2
  64. package/src/sdk/index.ts +7 -0
  65. package/src/sdk/types.ts +22 -0
  66. package/src/serve/doc-events.ts +12 -1
  67. package/src/serve/public/components/AskVerificationPanel.tsx +189 -0
  68. package/src/serve/public/globals.built.css +1 -1
  69. package/src/serve/public/pages/Ask.tsx +42 -4
  70. package/src/serve/resident-runtime.ts +22 -0
  71. package/src/serve/routes/api.ts +162 -3
  72. package/src/serve/routes/changes.ts +102 -0
  73. package/src/serve/server.ts +34 -0
  74. package/src/serve/watch-service.ts +9 -0
  75. package/src/store/index.ts +21 -0
  76. package/src/store/migrations/015-document-change-journal.ts +85 -0
  77. package/src/store/migrations/016-saved-capsules.ts +131 -0
  78. package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
  79. package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
  80. package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
  81. package/src/store/migrations/index.ts +10 -0
  82. package/src/store/sqlite/adapter.ts +291 -7
  83. package/src/store/sqlite/capsule-registry-store.ts +534 -0
  84. package/src/store/sqlite/change-journal-store.ts +473 -0
  85. package/src/store/types.ts +262 -0
@@ -21,6 +21,10 @@ import {
21
21
  SourcesTrigger,
22
22
  } from "../components/ai-elements/sources";
23
23
  import { AIModelSelector } from "../components/AIModelSelector";
24
+ import {
25
+ type AskVerification,
26
+ AskVerificationPanel,
27
+ } from "../components/AskVerificationPanel";
24
28
  import {
25
29
  ThoroughnessSelector,
26
30
  type Thoroughness,
@@ -57,6 +61,7 @@ interface PageProps {
57
61
  }
58
62
 
59
63
  interface Citation {
64
+ evidenceId?: string;
60
65
  docid: string;
61
66
  uri: string;
62
67
  startLine?: number;
@@ -88,12 +93,15 @@ interface AskResponse {
88
93
  vectorsUsed: boolean;
89
94
  answerGenerated: boolean;
90
95
  totalResults: number;
96
+ verificationRequested?: boolean;
97
+ abstained?: boolean;
91
98
  queryModes?: {
92
99
  term: number;
93
100
  intent: number;
94
101
  hyde: boolean;
95
102
  };
96
103
  };
104
+ verification?: AskVerification;
97
105
  }
98
106
 
99
107
  interface Capabilities {
@@ -139,7 +147,7 @@ function renderAnswer(
139
147
  const parts: React.ReactNode[] = [];
140
148
  let key = 0;
141
149
 
142
- const citationRegex = /\[(\d+)\]/g;
150
+ const citationRegex = /\[(?:evidence:([a-f0-9]{64})|(\d+))\]/g;
143
151
  let lastIndex = 0;
144
152
  let match: RegExpExecArray | null;
145
153
 
@@ -149,8 +157,13 @@ function renderAnswer(
149
157
  parts.push(answer.slice(lastIndex, match.index));
150
158
  }
151
159
 
152
- const citationNum = Number(match[1]);
153
- const citation = citations[citationNum - 1];
160
+ const evidenceId = match[1];
161
+ const numericCitation = match[2] ? Number(match[2]) : undefined;
162
+ const citationIndex = evidenceId
163
+ ? citations.findIndex((citation) => citation.evidenceId === evidenceId)
164
+ : (numericCitation ?? 0) - 1;
165
+ const citationNum = citationIndex + 1;
166
+ const citation = citations[citationIndex];
154
167
 
155
168
  if (citation) {
156
169
  parts.push(
@@ -186,6 +199,7 @@ export default function Ask({ navigate }: PageProps) {
186
199
  const [collections, setCollections] = useState<Collection[]>([]);
187
200
  const [thoroughness, setThoroughness] = useState<Thoroughness>("balanced");
188
201
  const [activePreset, setActivePreset] = useState("slim-tuned");
202
+ const [verify, setVerify] = useState(false);
189
203
 
190
204
  const [showAdvanced, setShowAdvanced] = useState(false);
191
205
  const [selectedCollection, setSelectedCollection] = useState("");
@@ -318,6 +332,7 @@ export default function Ask({ navigate }: PageProps) {
318
332
  const requestBody: Record<string, unknown> = {
319
333
  query: currentQuery,
320
334
  limit: 5,
335
+ verify,
321
336
  };
322
337
 
323
338
  if (selectedCollection) {
@@ -394,6 +409,7 @@ export default function Ask({ navigate }: PageProps) {
394
409
  tagsInput,
395
410
  thoroughness,
396
411
  until,
412
+ verify,
397
413
  ]
398
414
  );
399
415
 
@@ -443,6 +459,7 @@ export default function Ask({ navigate }: PageProps) {
443
459
  parseTagsCsv(tagsInput).length > 0
444
460
  ? `${tagMode}:${parseTagsCsv(tagsInput).join(",")}`
445
461
  : null,
462
+ verify ? "verified" : null,
446
463
  ].filter((pill): pill is string => Boolean(pill));
447
464
 
448
465
  return (
@@ -475,6 +492,16 @@ export default function Ask({ navigate }: PageProps) {
475
492
  value={thoroughness}
476
493
  />
477
494
 
495
+ <Button
496
+ aria-pressed={verify}
497
+ onClick={() => setVerify((value) => !value)}
498
+ size="sm"
499
+ type="button"
500
+ variant={verify ? "default" : "outline"}
501
+ >
502
+ Verify
503
+ </Button>
504
+
478
505
  <div className="h-6 w-px bg-border/40" />
479
506
 
480
507
  <AIModelSelector onPresetChange={setActivePreset} />
@@ -878,8 +905,19 @@ export default function Ask({ navigate }: PageProps) {
878
905
  </Sources>
879
906
  )}
880
907
 
908
+ {entry.response.verification && (
909
+ <AskVerificationPanel
910
+ navigate={navigate}
911
+ verification={entry.response.verification}
912
+ />
913
+ )}
914
+
881
915
  <div className="flex items-center gap-2 text-muted-foreground/60 text-xs">
882
- <span>{entry.response.results.length} results</span>
916
+ <span>
917
+ {entry.response.verification
918
+ ? `${entry.response.verification.capsule.evidence.length} evidence spans`
919
+ : `${entry.response.results.length} results`}
920
+ </span>
883
921
  {entry.response.meta.vectorsUsed && (
884
922
  <Badge
885
923
  className="font-mono text-[9px]"
@@ -31,6 +31,7 @@ import {
31
31
  isInitialized,
32
32
  loadConfig,
33
33
  } from "../config";
34
+ import { SavedCapsuleReverificationScheduler } from "../core/capsule-reverification-scheduler";
34
35
  import { acquireWriteLock } from "../core/file-lock";
35
36
  import { JobManager } from "../core/job-manager";
36
37
  import { recordContentMutation } from "../core/mutation-generations";
@@ -94,6 +95,7 @@ export interface ResidentRuntime {
94
95
  readonly toolMutex: Mutex;
95
96
  readonly readerGate: ReaderGate;
96
97
  readonly jobManager: JobManager;
98
+ readonly capsuleReverificationScheduler: SavedCapsuleReverificationScheduler;
97
99
  readonly modelManager: ModelManager;
98
100
  readonly mcpContext: ToolContext;
99
101
  readonly generations: ResidentGeneration;
@@ -266,6 +268,8 @@ export async function startResidentRuntime(
266
268
  ctxHolder.current.scheduler = scheduler;
267
269
  ctxHolder.current.eventBus = options.eventBus ?? null;
268
270
 
271
+ let capsuleReverificationScheduler: SavedCapsuleReverificationScheduler | null =
272
+ null;
269
273
  const watchService = (
270
274
  deps.watchServiceFactory ??
271
275
  ((watchOptions) => new DefaultCollectionWatchService(watchOptions))
@@ -282,6 +286,10 @@ export async function startResidentRuntime(
282
286
  });
283
287
  options.watchCallbacks?.onSyncComplete?.(event);
284
288
  },
289
+ onSettled: () => {
290
+ capsuleReverificationScheduler?.notifySyncSettled();
291
+ options.watchCallbacks?.onSettled?.();
292
+ },
285
293
  },
286
294
  syncOptions: withContentTypeRules({}, initialConfig),
287
295
  });
@@ -312,6 +320,17 @@ export async function startResidentRuntime(
312
320
  const backgroundWork = new ResidentBackgroundWork(
313
321
  () => !disposed && admission.accepting
314
322
  );
323
+ capsuleReverificationScheduler = new SavedCapsuleReverificationScheduler({
324
+ deps: {
325
+ store,
326
+ get config() {
327
+ return ctxHolder.config;
328
+ },
329
+ indexName: canonicalizeIndexName(options.index ?? DEFAULT_INDEX_NAME),
330
+ notify: (event) => options.eventBus?.emit(event),
331
+ },
332
+ startBackgroundWork: (operation) => backgroundWork.start(operation),
333
+ });
315
334
 
316
335
  const mcpContext = createToolContext({
317
336
  store,
@@ -355,6 +374,7 @@ export async function startResidentRuntime(
355
374
  toolMutex,
356
375
  readerGate,
357
376
  jobManager,
377
+ capsuleReverificationScheduler,
358
378
  modelManager,
359
379
  mcpContext,
360
380
  generations,
@@ -453,6 +473,7 @@ export async function startResidentRuntime(
453
473
  syncOptions.triggerEmbed === false
454
474
  ? null
455
475
  : await scheduler.triggerNow();
476
+ capsuleReverificationScheduler.notifySyncSettled();
456
477
  return { syncResult, embedResult };
457
478
  },
458
479
  async dispose() {
@@ -466,6 +487,7 @@ export async function startResidentRuntime(
466
487
  );
467
488
  if (deadlineReached) shutdownState = "deadline";
468
489
  await backgroundWork.cancelAndDrain();
490
+ await capsuleReverificationScheduler.dispose();
469
491
  await jobManager.shutdown().catch(() => undefined);
470
492
  await Promise.allSettled([
471
493
  Promise.resolve().then(() => watchService.dispose()),
@@ -36,6 +36,7 @@ import type { StartJobError } from "../jobs";
36
36
  import type { ResidentStatus } from "../status-model";
37
37
  import type { CollectionWatchService } from "../watch-service";
38
38
 
39
+ import { buildVerifiedAsk } from "../../app/verified-ask";
39
40
  import { modelsPull } from "../../cli/commands/models/pull";
40
41
  import {
41
42
  addCollection,
@@ -125,6 +126,7 @@ import {
125
126
  resolveModelUri,
126
127
  } from "../../llm/registry";
127
128
  import {
129
+ answerTraceTerminalStatus,
128
130
  generateGroundedAnswer,
129
131
  processAnswerResultWithTrace,
130
132
  } from "../../pipeline/answer";
@@ -158,6 +160,7 @@ import {
158
160
  withRetrievalTraceHeader,
159
161
  } from "../retrieval-trace";
160
162
  import { buildAppStatus, type StatusBuildDeps } from "../status";
163
+ import { handleChanges, handleDiff, handleImpact } from "./changes";
161
164
 
162
165
  /** Mutable context holder for hot-reloading presets */
163
166
  export interface ContextHolder {
@@ -289,14 +292,46 @@ export interface AskRequestBody {
289
292
  category?: string;
290
293
  author?: string;
291
294
  maxAnswerTokens?: number;
295
+ verify?: boolean;
296
+ contextBudgetTokens?: number;
297
+ contextBudgetBytes?: number;
298
+ minScore?: number;
292
299
  noExpand?: boolean;
293
300
  noRerank?: boolean;
301
+ graph?: boolean;
302
+ noGraph?: boolean;
294
303
  /** Comma-separated tags - filter to docs having ALL (AND) */
295
304
  tagsAll?: string;
296
305
  /** Comma-separated tags - filter to docs having ANY (OR) */
297
306
  tagsAny?: string;
298
307
  }
299
308
 
309
+ const ASK_REQUEST_KEYS = new Set<keyof AskRequestBody>([
310
+ "query",
311
+ "limit",
312
+ "collection",
313
+ "lang",
314
+ "intent",
315
+ "candidateLimit",
316
+ "exclude",
317
+ "queryModes",
318
+ "since",
319
+ "until",
320
+ "category",
321
+ "author",
322
+ "maxAnswerTokens",
323
+ "verify",
324
+ "contextBudgetTokens",
325
+ "contextBudgetBytes",
326
+ "minScore",
327
+ "noExpand",
328
+ "noRerank",
329
+ "graph",
330
+ "noGraph",
331
+ "tagsAll",
332
+ "tagsAny",
333
+ ]);
334
+
300
335
  export interface CreateCollectionRequestBody {
301
336
  path: string;
302
337
  name?: string;
@@ -512,11 +547,12 @@ async function startRestTrace(
512
547
  async function finishRestTrace(
513
548
  request: Request,
514
549
  session: RetrievalTraceSession | null,
515
- status: "completed" | "partial" | "failed",
550
+ status: "completed" | "partial" | "failed" | "cancelled",
516
551
  response: Response
517
552
  ): Promise<Response> {
518
553
  if (!session) return response;
519
- const terminalStatus = request.signal.aborted ? "cancelled" : status;
554
+ const terminalStatus =
555
+ request.signal.aborted || status === "cancelled" ? "cancelled" : status;
520
556
  const finished = await session.finish(terminalStatus);
521
557
  if (!finished.ok) {
522
558
  return withRetrievalTraceHeader(
@@ -3503,6 +3539,9 @@ export async function handleSearch(
3503
3539
  return errorResponse("VALIDATION", "Invalid JSON body");
3504
3540
  }
3505
3541
 
3542
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
3543
+ return errorResponse("VALIDATION", "Request body must be an object");
3544
+ }
3506
3545
  if (!body.query || typeof body.query !== "string") {
3507
3546
  return errorResponse("VALIDATION", "Missing or invalid query");
3508
3547
  }
@@ -4028,15 +4067,84 @@ export async function handleAsk(
4028
4067
  return errorResponse("VALIDATION", "Invalid JSON body");
4029
4068
  }
4030
4069
 
4070
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
4071
+ return errorResponse("VALIDATION", "Request body must be an object");
4072
+ }
4073
+ const unknownKey = Object.keys(body).find(
4074
+ (key) => !ASK_REQUEST_KEYS.has(key as keyof AskRequestBody)
4075
+ );
4076
+ if (unknownKey) {
4077
+ return errorResponse(
4078
+ "VALIDATION",
4079
+ `Unknown Ask request field: ${unknownKey}`
4080
+ );
4081
+ }
4031
4082
  if (!body.query || typeof body.query !== "string") {
4032
4083
  return errorResponse("VALIDATION", "Missing or invalid query");
4033
4084
  }
4034
4085
 
4086
+ for (const field of [
4087
+ "verify",
4088
+ "noExpand",
4089
+ "noRerank",
4090
+ "graph",
4091
+ "noGraph",
4092
+ ] as const) {
4093
+ if (body[field] !== undefined && typeof body[field] !== "boolean") {
4094
+ return errorResponse("VALIDATION", `${field} must be a boolean`);
4095
+ }
4096
+ }
4097
+ for (const [field, value, maximum] of [
4098
+ ["limit", body.limit, 20],
4099
+ ["candidateLimit", body.candidateLimit, 100],
4100
+ ["maxAnswerTokens", body.maxAnswerTokens, Number.MAX_SAFE_INTEGER],
4101
+ ] as const) {
4102
+ if (
4103
+ value !== undefined &&
4104
+ (!Number.isSafeInteger(value) || value < 1 || value > maximum)
4105
+ ) {
4106
+ return errorResponse(
4107
+ "VALIDATION",
4108
+ `${field} must be a positive integer${maximum < Number.MAX_SAFE_INTEGER ? ` no greater than ${maximum}` : ""}`
4109
+ );
4110
+ }
4111
+ }
4112
+
4035
4113
  const rawQuery = body.query.trim();
4036
4114
  if (!rawQuery) {
4037
4115
  return errorResponse("VALIDATION", "Query cannot be empty");
4038
4116
  }
4039
4117
 
4118
+ if (
4119
+ body.minScore !== undefined &&
4120
+ (typeof body.minScore !== "number" ||
4121
+ !Number.isFinite(body.minScore) ||
4122
+ body.minScore < 0 ||
4123
+ body.minScore > 1)
4124
+ ) {
4125
+ return errorResponse("VALIDATION", "minScore must be between 0 and 1");
4126
+ }
4127
+ if (
4128
+ body.contextBudgetTokens !== undefined &&
4129
+ (!Number.isSafeInteger(body.contextBudgetTokens) ||
4130
+ body.contextBudgetTokens < 1)
4131
+ ) {
4132
+ return errorResponse(
4133
+ "VALIDATION",
4134
+ "contextBudgetTokens must be a positive integer"
4135
+ );
4136
+ }
4137
+ if (
4138
+ body.contextBudgetBytes !== undefined &&
4139
+ (!Number.isSafeInteger(body.contextBudgetBytes) ||
4140
+ body.contextBudgetBytes < 1)
4141
+ ) {
4142
+ return errorResponse(
4143
+ "VALIDATION",
4144
+ "contextBudgetBytes must be a positive integer"
4145
+ );
4146
+ }
4147
+
4040
4148
  // Parse tag filters
4041
4149
  let tagsAll: string[] | undefined;
4042
4150
  let tagsAny: string[] | undefined;
@@ -4128,6 +4236,7 @@ export async function handleAsk(
4128
4236
  collection: body.collection,
4129
4237
  lang: body.lang,
4130
4238
  intent: body.intent?.trim() || undefined,
4239
+ minScore: body.minScore,
4131
4240
  noExpand: body.noExpand,
4132
4241
  noRerank: body.noRerank,
4133
4242
  candidateLimit:
@@ -4135,6 +4244,8 @@ export async function handleAsk(
4135
4244
  ? Math.min(body.candidateLimit, 100)
4136
4245
  : undefined,
4137
4246
  exclude,
4247
+ graph: body.graph,
4248
+ noGraph: body.noGraph,
4138
4249
  queryModes: normalizedQueryModes,
4139
4250
  tagsAll,
4140
4251
  tagsAny,
@@ -4142,6 +4253,10 @@ export async function handleAsk(
4142
4253
  until: body.until,
4143
4254
  categories,
4144
4255
  author,
4256
+ verify: body.verify,
4257
+ contextBudgetTokens: body.contextBudgetTokens,
4258
+ contextBudgetBytes: body.contextBudgetBytes,
4259
+ maxAnswerTokens: body.maxAnswerTokens,
4145
4260
  };
4146
4261
  const trace = await startRestTrace(ctx, {
4147
4262
  query: normalizedQuery,
@@ -4166,7 +4281,7 @@ export async function handleAsk(
4166
4281
  return finishRestTrace(
4167
4282
  req,
4168
4283
  trace.session,
4169
- "failed",
4284
+ req.signal.aborted ? "cancelled" : "failed",
4170
4285
  errorResponse("RUNTIME", unavailable.error.message, 500)
4171
4286
  );
4172
4287
  }
@@ -4182,6 +4297,38 @@ export async function handleAsk(
4182
4297
  );
4183
4298
  }
4184
4299
 
4300
+ if (body.verify && ctx.answerPort) {
4301
+ try {
4302
+ const verified = await buildVerifiedAsk(normalizedQuery, askOptions, {
4303
+ store: ctx.store,
4304
+ config: ctx.config,
4305
+ indexName: ctx.indexName,
4306
+ vectorIndex: ctx.vectorIndex,
4307
+ embedPort: ctx.embedPort,
4308
+ rerankPort: ctx.rerankPort,
4309
+ genPort: ctx.answerPort,
4310
+ traceSession: trace.session ?? undefined,
4311
+ });
4312
+ return finishRestTrace(
4313
+ req,
4314
+ trace.session,
4315
+ answerTraceTerminalStatus(verified.citations),
4316
+ jsonResponse(verified)
4317
+ );
4318
+ } catch (error) {
4319
+ return finishRestTrace(
4320
+ req,
4321
+ trace.session,
4322
+ "failed",
4323
+ errorResponse(
4324
+ "RUNTIME",
4325
+ error instanceof Error ? error.message : String(error),
4326
+ 500
4327
+ )
4328
+ );
4329
+ }
4330
+ }
4331
+
4185
4332
  // Run hybrid search first
4186
4333
  const searchResult = await searchHybrid(
4187
4334
  {
@@ -4976,6 +5123,18 @@ export async function routeApi(
4976
5123
  );
4977
5124
  }
4978
5125
 
5126
+ if (path === "/api/changes" && req.method === "GET") {
5127
+ return handleChanges(store, url);
5128
+ }
5129
+
5130
+ if (path === "/api/diff" && req.method === "GET") {
5131
+ return handleDiff(store, url);
5132
+ }
5133
+
5134
+ if (path === "/api/impact" && req.method === "GET") {
5135
+ return handleImpact(store, url);
5136
+ }
5137
+
4979
5138
  // Unknown API route
4980
5139
  if (path.startsWith("/api/")) {
4981
5140
  return errorResponse("NOT_FOUND", `Unknown API endpoint: ${path}`, 404);
@@ -0,0 +1,102 @@
1
+ /** Read-only REST handlers for knowledge change, diff, and impact services. */
2
+
3
+ import type { KnowledgeImpactInput } from "../../core/knowledge-delta";
4
+ import type { StorePort } from "../../store/types";
5
+
6
+ import {
7
+ analyzeKnowledgeImpact,
8
+ getKnowledgeDiff,
9
+ listKnowledgeChanges,
10
+ } from "../../core/knowledge-delta";
11
+
12
+ const errorResponse = (
13
+ code: "VALIDATION" | "RUNTIME",
14
+ message: string
15
+ ): Response =>
16
+ Response.json(
17
+ { error: { code, message } },
18
+ { status: code === "VALIDATION" ? 400 : 500 }
19
+ );
20
+
21
+ const parsePositiveInt = (
22
+ url: URL,
23
+ name: string
24
+ ): number | undefined | Response => {
25
+ const raw = url.searchParams.get(name);
26
+ if (raw === null) return;
27
+ if (!/^\d+$/.test(raw)) {
28
+ return errorResponse("VALIDATION", `${name} must be a positive integer`);
29
+ }
30
+ const value = Number(raw);
31
+ return Number.isSafeInteger(value) && value > 0
32
+ ? value
33
+ : errorResponse("VALIDATION", `${name} must be a positive integer`);
34
+ };
35
+
36
+ export async function handleChanges(
37
+ store: StorePort,
38
+ url: URL
39
+ ): Promise<Response> {
40
+ const limit = parsePositiveInt(url, "limit");
41
+ if (limit instanceof Response) return limit;
42
+ const result = await listKnowledgeChanges(store, {
43
+ since: url.searchParams.get("since") ?? undefined,
44
+ collection: url.searchParams.get("collection") ?? undefined,
45
+ limit,
46
+ });
47
+ if (!result.success) {
48
+ return errorResponse(
49
+ result.isValidation ? "VALIDATION" : "RUNTIME",
50
+ result.error
51
+ );
52
+ }
53
+ return Response.json(result.data);
54
+ }
55
+
56
+ export async function handleDiff(
57
+ store: StorePort,
58
+ url: URL
59
+ ): Promise<Response> {
60
+ const ref = url.searchParams.get("ref")?.trim();
61
+ if (!ref) return errorResponse("VALIDATION", "ref is required");
62
+ const result = await getKnowledgeDiff(
63
+ store,
64
+ ref,
65
+ url.searchParams.get("change") ?? undefined
66
+ );
67
+ if (!result.success) {
68
+ return errorResponse(
69
+ result.isValidation ? "VALIDATION" : "RUNTIME",
70
+ result.error
71
+ );
72
+ }
73
+ return Response.json(result.data);
74
+ }
75
+
76
+ export async function handleImpact(
77
+ store: StorePort,
78
+ url: URL
79
+ ): Promise<Response> {
80
+ const ref = url.searchParams.get("ref")?.trim();
81
+ if (!ref) return errorResponse("VALIDATION", "ref is required");
82
+ const input: KnowledgeImpactInput = {};
83
+ for (const [queryName, inputName] of [
84
+ ["maxDepth", "maxDepth"],
85
+ ["maxNodes", "maxNodes"],
86
+ ["maxEdges", "maxEdges"],
87
+ ["frontierLimit", "frontierLimit"],
88
+ ["visitedLimit", "visitedLimit"],
89
+ ] as const) {
90
+ const value = parsePositiveInt(url, queryName);
91
+ if (value instanceof Response) return value;
92
+ if (value !== undefined) input[inputName] = value;
93
+ }
94
+ const result = await analyzeKnowledgeImpact(store, ref, input);
95
+ if (!result.success) {
96
+ return errorResponse(
97
+ result.isValidation ? "VALIDATION" : "RUNTIME",
98
+ result.error
99
+ );
100
+ }
101
+ return Response.json(result.data);
102
+ }
@@ -69,6 +69,7 @@ import {
69
69
  handleUpdateDoc,
70
70
  handleVerifyConnector,
71
71
  } from "./routes/api";
72
+ import { handleChanges, handleDiff, handleImpact } from "./routes/changes";
72
73
  import { handleGraph, handleGraphQuery } from "./routes/graph";
73
74
  import {
74
75
  handleDocBacklinks,
@@ -968,6 +969,39 @@ export async function startServer(
968
969
  );
969
970
  },
970
971
  },
972
+ "/api/changes": {
973
+ GET: async (req: Request) => {
974
+ const url = new URL(req.url);
975
+ return withSecurityHeaders(
976
+ await handleResidentRead(runtime as ResidentRuntime, req, () =>
977
+ handleChanges(store, url)
978
+ ),
979
+ isDev
980
+ );
981
+ },
982
+ },
983
+ "/api/diff": {
984
+ GET: async (req: Request) => {
985
+ const url = new URL(req.url);
986
+ return withSecurityHeaders(
987
+ await handleResidentRead(runtime as ResidentRuntime, req, () =>
988
+ handleDiff(store, url)
989
+ ),
990
+ isDev
991
+ );
992
+ },
993
+ },
994
+ "/api/impact": {
995
+ GET: async (req: Request) => {
996
+ const url = new URL(req.url);
997
+ return withSecurityHeaders(
998
+ await handleResidentRead(runtime as ResidentRuntime, req, () =>
999
+ handleImpact(store, url)
1000
+ ),
1001
+ isDev
1002
+ );
1003
+ },
1004
+ },
971
1005
  },
972
1006
  });
973
1007
  } catch (e) {
@@ -31,6 +31,8 @@ export interface CollectionWatchCallbacks {
31
31
  relPaths: string[];
32
32
  error: unknown;
33
33
  }) => void;
34
+ /** Fires after all watcher syncs and queued paths have settled. */
35
+ onSettled?: () => void;
34
36
  }
35
37
 
36
38
  interface CollectionWatchServiceOptions {
@@ -236,6 +238,13 @@ export class CollectionWatchService {
236
238
  const remaining = this.#pendingByCollection.get(collectionName);
237
239
  if (remaining && remaining.size > 0) {
238
240
  void this.#flushCollection(collectionName);
241
+ } else if (
242
+ this.#syncing.size === 0 &&
243
+ ![...this.#pendingByCollection.values()].some(
244
+ (relPaths) => relPaths.size > 0
245
+ )
246
+ ) {
247
+ this.#callbacks?.onSettled?.();
239
248
  }
240
249
  }
241
250
  }
@@ -33,6 +33,16 @@ export type {
33
33
  CollectionRow,
34
34
  CollectionStatus,
35
35
  ContextRow,
36
+ DocumentChangeDateDelta,
37
+ DocumentChangeKind,
38
+ DocumentChangeListOptions,
39
+ DocumentChangePage,
40
+ DocumentChangePurgeResult,
41
+ DocumentChangeRetentionPolicy,
42
+ DocumentChangeRetentionResult,
43
+ DocumentChangeRow,
44
+ DocumentChangeSet,
45
+ DocumentChangeStructureDelta,
36
46
  DocumentInput,
37
47
  DocumentRow,
38
48
  FtsResult,
@@ -72,6 +82,17 @@ export type {
72
82
  RetrievalTraceRunRow,
73
83
  RetrievalTraceStatus,
74
84
  RetrievalTraceTerminalStatus,
85
+ RenameDocumentOptions,
86
+ SavedCapsuleAffectedQuestionState,
87
+ SavedCapsuleEvidenceReference,
88
+ SavedCapsuleNotificationPreference,
89
+ SavedCapsuleOperationStatus,
90
+ SavedCapsuleRegistration,
91
+ SavedCapsuleRegistrationInput,
92
+ SavedCapsuleRegistrationRecord,
93
+ SavedCapsuleReverificationState,
94
+ SavedCapsuleTriggerKind,
95
+ SavedCapsuleVerificationRecord,
75
96
  StoreError,
76
97
  StoreErrorCode,
77
98
  StorePort,