@gmickel/gno 1.20.0 → 1.22.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 (55) hide show
  1. package/README.md +29 -5
  2. package/assets/skill/SKILL.md +46 -15
  3. package/package.json +2 -1
  4. package/spec/cli.md +144 -0
  5. package/spec/db/schema.sql +170 -0
  6. package/spec/evals-agentic.md +48 -0
  7. package/spec/mcp.md +22 -0
  8. package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
  9. package/spec/output-schemas/changes.schema.json +280 -0
  10. package/spec/output-schemas/document-diff.schema.json +185 -0
  11. package/spec/output-schemas/impact.schema.json +122 -0
  12. package/spec/output-schemas/publish-artifact.schema.json +284 -0
  13. package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
  14. package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
  15. package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
  16. package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
  17. package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
  18. package/src/cli/commands/changes.ts +160 -0
  19. package/src/cli/commands/context-saved.ts +189 -0
  20. package/src/cli/options.ts +8 -0
  21. package/src/cli/program.ts +195 -0
  22. package/src/core/capsule-registry.ts +279 -0
  23. package/src/core/capsule-reverification-scheduler.ts +218 -0
  24. package/src/core/capsule-reverification.ts +289 -0
  25. package/src/core/change-diff.ts +182 -0
  26. package/src/core/change-journal.ts +228 -0
  27. package/src/core/knowledge-delta.ts +395 -0
  28. package/src/core/knowledge-impact.ts +202 -0
  29. package/src/ingestion/sync.ts +214 -165
  30. package/src/mcp/tools/changes.ts +80 -0
  31. package/src/mcp/tools/index.ts +29 -0
  32. package/src/publish/artifact-validation.ts +259 -0
  33. package/src/publish/artifact.ts +234 -118
  34. package/src/publish/export-service.ts +5 -9
  35. package/src/publish/metadata.ts +195 -0
  36. package/src/sdk/client.ts +42 -0
  37. package/src/sdk/index.ts +7 -0
  38. package/src/sdk/types.ts +22 -0
  39. package/src/serve/doc-events.ts +12 -1
  40. package/src/serve/resident-runtime.ts +22 -0
  41. package/src/serve/routes/api.ts +13 -0
  42. package/src/serve/routes/changes.ts +102 -0
  43. package/src/serve/server.ts +34 -0
  44. package/src/serve/watch-service.ts +9 -0
  45. package/src/store/index.ts +21 -0
  46. package/src/store/migrations/015-document-change-journal.ts +85 -0
  47. package/src/store/migrations/016-saved-capsules.ts +131 -0
  48. package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
  49. package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
  50. package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
  51. package/src/store/migrations/index.ts +10 -0
  52. package/src/store/sqlite/adapter.ts +291 -7
  53. package/src/store/sqlite/capsule-registry-store.ts +534 -0
  54. package/src/store/sqlite/change-journal-store.ts +473 -0
  55. package/src/store/types.ts +262 -0
@@ -8,7 +8,7 @@ import type { Collection } from "../config/types";
8
8
  import type { DocumentRow, StorePort, TagRow } from "../store/types";
9
9
 
10
10
  import { parseRef } from "../core/ref-parser";
11
- import { parseFrontmatter } from "../ingestion/frontmatter";
11
+ import { parseFrontmatter, stripFrontmatter } from "../ingestion/frontmatter";
12
12
  import { getContentBatch } from "../store/content-batch";
13
13
  import {
14
14
  buildEncryptedPublishArtifact,
@@ -186,11 +186,11 @@ async function exportCollectionArtifact(
186
186
  if (isPublishDisabledByFrontmatter(rawMarkdown)) {
187
187
  continue;
188
188
  }
189
+ const frontmatter = parseFrontmatter(rawMarkdown).metadata;
189
190
  const sanitized = sanitizeObsidianMarkdown(rawMarkdown);
190
191
  warnings.push(...sanitized.warnings);
191
- const markdown = sanitized.markdown;
192
+ const markdown = stripFrontmatter(sanitized.markdown);
192
193
  const tags = tagsByDocId.get(doc.id) ?? [];
193
- const frontmatter = parseFrontmatter(markdown).metadata;
194
194
  const title = deriveExportedTitle(doc);
195
195
  notes.push({
196
196
  markdown,
@@ -240,7 +240,6 @@ async function exportCollectionArtifact(
240
240
  encryptedPayload: encrypted.encryptedPayload,
241
241
  routeSlug,
242
242
  secretToken: encrypted.secretToken,
243
- source: collection.name,
244
243
  sourceType: "collection",
245
244
  });
246
245
  }
@@ -249,7 +248,6 @@ async function exportCollectionArtifact(
249
248
  homeNoteSlug: chooseHomeNoteSlug(notes),
250
249
  notes,
251
250
  routeSlug,
252
- source: collection.name,
253
251
  sourceType: "collection",
254
252
  summary,
255
253
  title,
@@ -274,11 +272,11 @@ async function exportDocumentArtifact(
274
272
  `Refused to export: ${doc.uri} has publish: false in frontmatter`
275
273
  );
276
274
  }
275
+ const frontmatter = parseFrontmatter(rawMarkdown).metadata;
277
276
  const sanitized = sanitizeObsidianMarkdown(rawMarkdown);
278
277
  warnings.push(...sanitized.warnings);
279
- const markdown = sanitized.markdown;
278
+ const markdown = stripFrontmatter(sanitized.markdown);
280
279
  const tags = await loadDocumentTags(store, doc);
281
- const frontmatter = parseFrontmatter(markdown).metadata;
282
280
  const title = options.title ?? deriveExportedTitle(doc);
283
281
  const summary =
284
282
  options.summary ?? deriveExportedSummary(markdown, frontmatter);
@@ -315,7 +313,6 @@ async function exportDocumentArtifact(
315
313
  encryptedPayload: encrypted.encryptedPayload,
316
314
  routeSlug,
317
315
  secretToken: encrypted.secretToken,
318
- source: doc.uri,
319
316
  sourceType: "note",
320
317
  });
321
318
  }
@@ -331,7 +328,6 @@ async function exportDocumentArtifact(
331
328
  },
332
329
  ],
333
330
  routeSlug,
334
- source: doc.uri,
335
331
  sourceType: "note",
336
332
  summary,
337
333
  title,
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Reader-safe publish metadata projection.
3
+ *
4
+ * @module src/publish/metadata
5
+ */
6
+
7
+ import type { DocumentRow, TagRow } from "../store/types";
8
+
9
+ const ALLOWED_FRONTMATTER_METADATA_KEYS = new Set([
10
+ "audience",
11
+ "canonical",
12
+ "canonicalUrl",
13
+ "canonicalURL",
14
+ "coverAlt",
15
+ "coverImage",
16
+ "icon",
17
+ "image",
18
+ "layout",
19
+ "publishedAt",
20
+ "readingTime",
21
+ "series",
22
+ "seriesOrder",
23
+ "status",
24
+ "subtitle",
25
+ "theme",
26
+ "topic",
27
+ "topics",
28
+ ]);
29
+
30
+ const PUBLIC_URL_METADATA_KEYS = new Set([
31
+ "canonical",
32
+ "canonicalUrl",
33
+ "canonicalURL",
34
+ "coverImage",
35
+ "image",
36
+ ]);
37
+
38
+ const FORBIDDEN_URI_TOKEN_PATTERN =
39
+ /(?:^|[^a-z0-9+.-])(?:file:(?:\/\/)?|gno:\/\/)/iu;
40
+ const LOCAL_PATH_TOKEN_PATTERN =
41
+ /(?:^|[\s([{"'=,:;])(?:~[/\\]|[a-z]:[/\\]|\\\\[^\\/\s]+[/\\]|\/(?:Applications|bin|dev|etc|home|Library|mnt|opt|private|proc|root|srv|sys|System|tmp|Users|usr|var|Volumes)(?:[/\\]|$))/iu;
42
+ const LOCAL_HOSTNAME_SUFFIX_PATTERN =
43
+ /(?:^|\.)(?:home|internal|lan|local|localhost)$/iu;
44
+
45
+ const containsLocalReference = (value: string): boolean =>
46
+ FORBIDDEN_URI_TOKEN_PATTERN.test(value) ||
47
+ LOCAL_PATH_TOKEN_PATTERN.test(value);
48
+
49
+ const isNonPublicIpv4 = (hostname: string): boolean => {
50
+ if (!/^\d{1,3}(?:\.\d{1,3}){3}$/u.test(hostname)) return false;
51
+ const octets = hostname.split(".").map(Number);
52
+ if (octets.some((octet) => octet > 255)) return true;
53
+ const [first = 0, second = 0, third = 0] = octets;
54
+ return (
55
+ first === 0 ||
56
+ first === 10 ||
57
+ first === 127 ||
58
+ first >= 224 ||
59
+ (first === 100 && second >= 64 && second <= 127) ||
60
+ (first === 169 && second === 254) ||
61
+ (first === 172 && second >= 16 && second <= 31) ||
62
+ (first === 192 && second === 0 && third === 0) ||
63
+ (first === 192 && second === 0 && third === 2) ||
64
+ (first === 192 && second === 168) ||
65
+ (first === 198 && (second === 18 || second === 19)) ||
66
+ (first === 198 && second === 51 && third === 100) ||
67
+ (first === 203 && second === 0 && third === 113)
68
+ );
69
+ };
70
+
71
+ const isNonPublicIpv6 = (hostname: string): boolean => {
72
+ const normalized = hostname.replace(/^\[|\]$/gu, "").toLowerCase();
73
+ if (!normalized.includes(":")) return false;
74
+ return (
75
+ normalized === "::" ||
76
+ normalized.startsWith("::") ||
77
+ normalized.startsWith("fc") ||
78
+ normalized.startsWith("fd") ||
79
+ /^fe[89ab]/u.test(normalized) ||
80
+ normalized.startsWith("ff") ||
81
+ normalized.startsWith("2001:db8:")
82
+ );
83
+ };
84
+
85
+ const isPublicHttpUrl = (value: string): boolean => {
86
+ let url: URL;
87
+ try {
88
+ url = new URL(value);
89
+ } catch {
90
+ return false;
91
+ }
92
+
93
+ if (
94
+ (url.protocol !== "http:" && url.protocol !== "https:") ||
95
+ url.username.length > 0 ||
96
+ url.password.length > 0
97
+ ) {
98
+ return false;
99
+ }
100
+
101
+ const hostname = url.hostname.replace(/\.$/u, "").toLowerCase();
102
+ if (
103
+ hostname.length === 0 ||
104
+ LOCAL_HOSTNAME_SUFFIX_PATTERN.test(hostname) ||
105
+ isNonPublicIpv4(hostname) ||
106
+ isNonPublicIpv6(hostname)
107
+ ) {
108
+ return false;
109
+ }
110
+
111
+ const isIpLiteral =
112
+ hostname.includes(":") || /^\d+(?:\.\d+){3}$/u.test(hostname);
113
+ return isIpLiteral || hostname.includes(".");
114
+ };
115
+
116
+ const isSafeMetadataValue = (key: string, value: string): boolean => {
117
+ if (containsLocalReference(value)) {
118
+ return false;
119
+ }
120
+ if (
121
+ PUBLIC_URL_METADATA_KEYS.has(key) ||
122
+ (key === "icon" && /^https?:\/\//iu.test(value))
123
+ ) {
124
+ return isPublicHttpUrl(value);
125
+ }
126
+ return true;
127
+ };
128
+
129
+ const filterReaderSafeMetadata = (
130
+ metadata: Record<string, string | string[]>
131
+ ): Record<string, string | string[]> => {
132
+ const result: Record<string, string | string[]> = {};
133
+ for (const [key, value] of Object.entries(metadata)) {
134
+ if (typeof value === "string") {
135
+ if (isSafeMetadataValue(key, value)) {
136
+ result[key] = value;
137
+ }
138
+ continue;
139
+ }
140
+
141
+ const safeValues = value.filter((entry) => isSafeMetadataValue(key, entry));
142
+ if (safeValues.length > 0) {
143
+ result[key] = safeValues;
144
+ }
145
+ }
146
+ return result;
147
+ };
148
+
149
+ export const buildExportedMetadata = (
150
+ doc: Pick<
151
+ DocumentRow,
152
+ "author" | "categories" | "contentType" | "frontmatterDate" | "languageHint"
153
+ >,
154
+ parsedFrontmatter: Record<string, unknown>,
155
+ tags: TagRow[]
156
+ ): Record<string, string | string[]> => {
157
+ const metadata: Record<string, string | string[]> = {};
158
+
159
+ if (doc.author) metadata.author = doc.author;
160
+ if (doc.contentType) metadata.contentType = doc.contentType;
161
+ if (doc.languageHint) metadata.language = doc.languageHint;
162
+ if (doc.frontmatterDate) metadata.date = doc.frontmatterDate;
163
+ if (doc.categories?.length) metadata.categories = doc.categories;
164
+
165
+ const tagValues = tags.map((tag) => tag.tag);
166
+ if (tagValues.length) metadata.tags = tagValues;
167
+
168
+ for (const [key, value] of Object.entries(parsedFrontmatter)) {
169
+ if (
170
+ key === "tags" ||
171
+ key === "title" ||
172
+ key === "summary" ||
173
+ !ALLOWED_FRONTMATTER_METADATA_KEYS.has(key)
174
+ ) {
175
+ continue;
176
+ }
177
+ if (
178
+ typeof value === "string" &&
179
+ value.trim() &&
180
+ isSafeMetadataValue(key, value.trim())
181
+ ) {
182
+ metadata[key] = value.trim();
183
+ continue;
184
+ }
185
+ if (Array.isArray(value)) {
186
+ const cleaned = value
187
+ .filter((entry): entry is string => typeof entry === "string")
188
+ .map((entry) => entry.trim())
189
+ .filter((entry) => entry.length > 0 && isSafeMetadataValue(key, entry));
190
+ if (cleaned.length > 0) metadata[key] = cleaned;
191
+ }
192
+ }
193
+
194
+ return filterReaderSafeMetadata(metadata);
195
+ };
package/src/sdk/client.ts CHANGED
@@ -40,6 +40,11 @@ import type {
40
40
  GnoRenameNoteOptions,
41
41
  GnoUpdateOptions,
42
42
  GnoVectorSearchOptions,
43
+ KnowledgeChangesResult,
44
+ KnowledgeDiffResult,
45
+ KnowledgeImpactInput,
46
+ KnowledgeImpactResult,
47
+ ListKnowledgeChangesInput,
43
48
  } from "./types";
44
49
 
45
50
  import {
@@ -84,6 +89,12 @@ import {
84
89
  planRenameRefactor,
85
90
  } from "../core/file-refactors";
86
91
  import { resolveEffectiveIndex } from "../core/indexed-reference";
92
+ import {
93
+ analyzeKnowledgeImpact,
94
+ getKnowledgeDiff,
95
+ listKnowledgeChanges,
96
+ type KnowledgeDeltaServiceResult,
97
+ } from "../core/knowledge-delta";
87
98
  import { resolveNoteCreatePlan } from "../core/note-creation";
88
99
  import { resolveNotePreset } from "../core/note-presets";
89
100
  import { RetrievalTraceManagementService } from "../core/retrieval-trace-management";
@@ -174,6 +185,11 @@ function unwrapTraceStore<T>(result: StoreResult<T>): T {
174
185
  });
175
186
  }
176
187
 
188
+ function unwrapKnowledgeDelta<T>(result: KnowledgeDeltaServiceResult<T>): T {
189
+ if (result.success) return result.data;
190
+ throw sdkError(result.isValidation ? "VALIDATION" : "STORE", result.error);
191
+ }
192
+
177
193
  async function resolveClientState(
178
194
  options: GnoClientInitOptions = {}
179
195
  ): Promise<OpenedClientState> {
@@ -1067,6 +1083,32 @@ class GnoClientImpl implements GnoClient {
1067
1083
  };
1068
1084
  }
1069
1085
 
1086
+ async changes(
1087
+ options: ListKnowledgeChangesInput = {}
1088
+ ): Promise<KnowledgeChangesResult> {
1089
+ this.assertOpen();
1090
+ return unwrapKnowledgeDelta(
1091
+ await listKnowledgeChanges(this.store, options)
1092
+ );
1093
+ }
1094
+
1095
+ async diff(ref: string, changeId?: string): Promise<KnowledgeDiffResult> {
1096
+ this.assertOpen();
1097
+ return unwrapKnowledgeDelta(
1098
+ await getKnowledgeDiff(this.store, ref, changeId)
1099
+ );
1100
+ }
1101
+
1102
+ async impact(
1103
+ ref: string,
1104
+ options: KnowledgeImpactInput = {}
1105
+ ): Promise<KnowledgeImpactResult> {
1106
+ this.assertOpen();
1107
+ return unwrapKnowledgeDelta(
1108
+ await analyzeKnowledgeImpact(this.store, ref, options)
1109
+ );
1110
+ }
1111
+
1070
1112
  async status(): Promise<IndexStatus> {
1071
1113
  this.assertOpen();
1072
1114
  return unwrapStore(
package/src/sdk/index.ts CHANGED
@@ -54,6 +54,13 @@ export type {
54
54
  GnoSkippedDocument,
55
55
  GnoUpdateOptions,
56
56
  GnoVectorSearchOptions,
57
+ KnowledgeChange,
58
+ KnowledgeChangesResult,
59
+ KnowledgeDiffResult,
60
+ KnowledgeImpactEvidenceStep,
61
+ KnowledgeImpactInput,
62
+ KnowledgeImpactResult,
63
+ ListKnowledgeChangesInput,
57
64
  } from "./types";
58
65
  export {
59
66
  ContextCapsuleContractError,
package/src/sdk/types.ts CHANGED
@@ -17,6 +17,13 @@ import type {
17
17
  } from "../core/context-capsule";
18
18
  import type { ContextEvidenceErrorCode } from "../core/context-evidence";
19
19
  import type { ContextVerifierErrorCode } from "../core/context-verifier";
20
+ import type {
21
+ KnowledgeChangesResult,
22
+ KnowledgeDiffResult,
23
+ KnowledgeImpactInput,
24
+ KnowledgeImpactResult,
25
+ ListKnowledgeChangesInput,
26
+ } from "../core/knowledge-delta";
20
27
  import type { NoteCollisionPolicy } from "../core/note-creation";
21
28
  import type { NotePresetId } from "../core/note-presets";
22
29
  import type {
@@ -52,6 +59,15 @@ export type {
52
59
  SyncResult,
53
60
  };
54
61
  export type { AskOptions, HybridSearchOptions } from "../pipeline/types";
62
+ export type {
63
+ KnowledgeChange,
64
+ KnowledgeChangesResult,
65
+ KnowledgeDiffResult,
66
+ KnowledgeImpactEvidenceStep,
67
+ KnowledgeImpactInput,
68
+ KnowledgeImpactResult,
69
+ ListKnowledgeChangesInput,
70
+ } from "../core/knowledge-delta";
55
71
  export type { Collection, Config as GnoConfig, Context } from "../config/types";
56
72
  export type { GetResponse as GnoGetResult } from "../cli/commands/get";
57
73
  export type {
@@ -233,6 +249,12 @@ export interface GnoClient {
233
249
  list(
234
250
  options?: GnoListOptions
235
251
  ): Promise<import("../cli/commands/ls").LsResponse>;
252
+ changes(options?: ListKnowledgeChangesInput): Promise<KnowledgeChangesResult>;
253
+ diff(ref: string, changeId?: string): Promise<KnowledgeDiffResult>;
254
+ impact(
255
+ ref: string,
256
+ options?: KnowledgeImpactInput
257
+ ): Promise<KnowledgeImpactResult>;
236
258
  status(): Promise<IndexStatus>;
237
259
  listRetrievalTraces(
238
260
  options?: RetrievalTraceListRequest
@@ -1,6 +1,6 @@
1
1
  export type DocumentEventOrigin = "watcher" | "save" | "create";
2
2
 
3
- export interface DocumentEvent {
3
+ export interface DocumentChangedEvent {
4
4
  type: "document-changed";
5
5
  uri: string;
6
6
  collection: string;
@@ -9,6 +9,17 @@ export interface DocumentEvent {
9
9
  changedAt: string;
10
10
  }
11
11
 
12
+ export interface CapsuleReverifiedEvent {
13
+ type: "capsule-reverified";
14
+ registrationId: string;
15
+ capsuleId: string;
16
+ operationStatus: "completed" | "failed";
17
+ affectedQuestionState: "unaffected" | "affected" | "unknown";
18
+ changedAt: string;
19
+ }
20
+
21
+ export type DocumentEvent = DocumentChangedEvent | CapsuleReverifiedEvent;
22
+
12
23
  export interface DocumentEventBusState {
13
24
  connectedClients: number;
14
25
  retryMs: number;
@@ -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()),
@@ -160,6 +160,7 @@ import {
160
160
  withRetrievalTraceHeader,
161
161
  } from "../retrieval-trace";
162
162
  import { buildAppStatus, type StatusBuildDeps } from "../status";
163
+ import { handleChanges, handleDiff, handleImpact } from "./changes";
163
164
 
164
165
  /** Mutable context holder for hot-reloading presets */
165
166
  export interface ContextHolder {
@@ -5122,6 +5123,18 @@ export async function routeApi(
5122
5123
  );
5123
5124
  }
5124
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
+
5125
5138
  // Unknown API route
5126
5139
  if (path.startsWith("/api/")) {
5127
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
  }