@gmickel/gno 1.20.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 (49) hide show
  1. package/README.md +19 -4
  2. package/assets/skill/SKILL.md +46 -15
  3. package/package.json +1 -1
  4. package/spec/cli.md +100 -0
  5. package/spec/db/schema.sql +170 -0
  6. package/spec/mcp.md +22 -0
  7. package/spec/output-schemas/capsule-reverified-event.schema.json +47 -0
  8. package/spec/output-schemas/changes.schema.json +280 -0
  9. package/spec/output-schemas/document-diff.schema.json +185 -0
  10. package/spec/output-schemas/impact.schema.json +122 -0
  11. package/spec/output-schemas/saved-capsule-list.schema.json +16 -0
  12. package/spec/output-schemas/saved-capsule-registration.schema.json +172 -0
  13. package/spec/output-schemas/saved-capsule-reverification.schema.json +59 -0
  14. package/spec/output-schemas/saved-capsule-unwatch.schema.json +16 -0
  15. package/spec/output-schemas/saved-capsule-watch.schema.json +17 -0
  16. package/src/cli/commands/changes.ts +160 -0
  17. package/src/cli/commands/context-saved.ts +189 -0
  18. package/src/cli/options.ts +8 -0
  19. package/src/cli/program.ts +195 -0
  20. package/src/core/capsule-registry.ts +279 -0
  21. package/src/core/capsule-reverification-scheduler.ts +218 -0
  22. package/src/core/capsule-reverification.ts +289 -0
  23. package/src/core/change-diff.ts +182 -0
  24. package/src/core/change-journal.ts +228 -0
  25. package/src/core/knowledge-delta.ts +395 -0
  26. package/src/core/knowledge-impact.ts +202 -0
  27. package/src/ingestion/sync.ts +214 -165
  28. package/src/mcp/tools/changes.ts +80 -0
  29. package/src/mcp/tools/index.ts +29 -0
  30. package/src/sdk/client.ts +42 -0
  31. package/src/sdk/index.ts +7 -0
  32. package/src/sdk/types.ts +22 -0
  33. package/src/serve/doc-events.ts +12 -1
  34. package/src/serve/resident-runtime.ts +22 -0
  35. package/src/serve/routes/api.ts +13 -0
  36. package/src/serve/routes/changes.ts +102 -0
  37. package/src/serve/server.ts +34 -0
  38. package/src/serve/watch-service.ts +9 -0
  39. package/src/store/index.ts +21 -0
  40. package/src/store/migrations/015-document-change-journal.ts +85 -0
  41. package/src/store/migrations/016-saved-capsules.ts +131 -0
  42. package/src/store/migrations/017-document-change-retention-counters.ts +33 -0
  43. package/src/store/migrations/018-saved-capsule-registration-epoch.ts +24 -0
  44. package/src/store/migrations/019-saved-capsule-registration-generation.ts +53 -0
  45. package/src/store/migrations/index.ts +10 -0
  46. package/src/store/sqlite/adapter.ts +291 -7
  47. package/src/store/sqlite/capsule-registry-store.ts +534 -0
  48. package/src/store/sqlite/change-journal-store.ts +473 -0
  49. package/src/store/types.ts +262 -0
@@ -308,6 +308,7 @@ export function createProgram(): Command {
308
308
  wireTagsCommands(program);
309
309
  wireLinksCommands(program);
310
310
  wireGraphCommand(program);
311
+ wireKnowledgeDeltaCommands(program);
311
312
  wireMcpCommand(program);
312
313
  wireSkillCommands(program);
313
314
  wireDaemonCommand(program);
@@ -2088,6 +2089,106 @@ function wireManagementCommands(program: Command): void {
2088
2089
  }
2089
2090
  );
2090
2091
 
2092
+ contextCmd
2093
+ .command("watch <file>")
2094
+ .description("Watch a saved Context Capsule for evidence changes")
2095
+ .option("--question <text>", "question associated with this Capsule")
2096
+ .option("--label <text>", "short local label")
2097
+ .option("--notify", "emit metadata-only local reverification events")
2098
+ .option("--json", "JSON output")
2099
+ .action(
2100
+ async (
2101
+ file: string,
2102
+ cmdOpts: Record<string, unknown>,
2103
+ command: Command
2104
+ ) => {
2105
+ const format = getFormat(cmdOpts);
2106
+ assertFormatSupported(CMD.contextSaved, format);
2107
+ const globals = getGlobals();
2108
+ const explicitIndexName =
2109
+ command.getOptionValueSourceWithGlobals("index") === "cli"
2110
+ ? globals.index
2111
+ : undefined;
2112
+ const { watchSavedCapsule } = await import("./commands/context-saved");
2113
+ await writeOutput(
2114
+ await watchSavedCapsule(file, {
2115
+ configPath: globals.config,
2116
+ indexName: globals.index,
2117
+ explicitIndexName,
2118
+ question: cmdOpts.question as string | undefined,
2119
+ label: cmdOpts.label as string | undefined,
2120
+ notify: Boolean(cmdOpts.notify),
2121
+ format: format === "json" ? "json" : "terminal",
2122
+ }),
2123
+ format === "json" ? "json" : "terminal"
2124
+ );
2125
+ }
2126
+ );
2127
+
2128
+ contextCmd
2129
+ .command("watches")
2130
+ .description("List watched saved Context Capsules")
2131
+ .option("--json", "JSON output")
2132
+ .action(async (cmdOpts: Record<string, unknown>) => {
2133
+ const format = getFormat(cmdOpts);
2134
+ assertFormatSupported(CMD.contextSaved, format);
2135
+ const globals = getGlobals();
2136
+ const { listWatchedCapsules } = await import("./commands/context-saved");
2137
+ await writeOutput(
2138
+ await listWatchedCapsules({
2139
+ configPath: globals.config,
2140
+ indexName: globals.index,
2141
+ format: format === "json" ? "json" : "terminal",
2142
+ }),
2143
+ format === "json" ? "json" : "terminal"
2144
+ );
2145
+ });
2146
+
2147
+ contextCmd
2148
+ .command("unwatch <registration>")
2149
+ .description("Stop watching a saved Context Capsule")
2150
+ .option("--json", "JSON output")
2151
+ .action(async (registration: string, cmdOpts: Record<string, unknown>) => {
2152
+ const format = getFormat(cmdOpts);
2153
+ assertFormatSupported(CMD.contextSaved, format);
2154
+ const globals = getGlobals();
2155
+ const { unwatchSavedCapsule } = await import("./commands/context-saved");
2156
+ await writeOutput(
2157
+ await unwatchSavedCapsule(registration, {
2158
+ configPath: globals.config,
2159
+ indexName: globals.index,
2160
+ format: format === "json" ? "json" : "terminal",
2161
+ }),
2162
+ format === "json" ? "json" : "terminal"
2163
+ );
2164
+ });
2165
+
2166
+ contextCmd
2167
+ .command("reverify <registration>")
2168
+ .description("Reverify one watched saved Context Capsule")
2169
+ .option("--json", "JSON output")
2170
+ .action(async (registration: string, cmdOpts: Record<string, unknown>) => {
2171
+ const format = getFormat(cmdOpts);
2172
+ assertFormatSupported(CMD.contextSaved, format);
2173
+ const globals = getGlobals();
2174
+ const { reverifyWatchedCapsule } =
2175
+ await import("./commands/context-saved");
2176
+ const result = await reverifyWatchedCapsule(registration, {
2177
+ configPath: globals.config,
2178
+ indexName: globals.index,
2179
+ format: format === "json" ? "json" : "terminal",
2180
+ });
2181
+ await writeOutput(result.output, format === "json" ? "json" : "terminal");
2182
+ if (result.operationStatus === "failed") {
2183
+ throw new CliError(
2184
+ "RUNTIME",
2185
+ result.errorMessage ??
2186
+ "Saved Context Capsule verification operation failed",
2187
+ { operationStatus: "failed" }
2188
+ );
2189
+ }
2190
+ });
2191
+
2091
2192
  contextCmd
2092
2193
  .command("rm <uri>")
2093
2194
  .description("Remove context item")
@@ -3088,6 +3189,100 @@ function wireGraphCommand(program: Command): void {
3088
3189
  );
3089
3190
  }
3090
3191
 
3192
+ function wireKnowledgeDeltaCommands(program: Command): void {
3193
+ program
3194
+ .command("changes")
3195
+ .description("List retained metadata-only document changes")
3196
+ .option("--since <time-or-cursor>", "ISO-8601 time or opaque cursor")
3197
+ .option("-c, --collection <name>", "filter by collection")
3198
+ .option("-n, --limit <num>", "maximum changes", "100")
3199
+ .option("--json", "JSON output")
3200
+ .action(async (cmdOpts: Record<string, unknown>) => {
3201
+ const format = getFormat(cmdOpts);
3202
+ assertFormatSupported(CMD.changes, format);
3203
+ const deltaFormat = format === "json" ? "json" : "terminal";
3204
+ const globals = getGlobals();
3205
+ const { changes, formatChanges } = await import("./commands/changes");
3206
+ const result = await changes(
3207
+ {
3208
+ since: cmdOpts.since as string | undefined,
3209
+ collection: cmdOpts.collection as string | undefined,
3210
+ limit: parsePositiveInt("limit", cmdOpts.limit),
3211
+ },
3212
+ { configPath: globals.config, indexName: globals.index }
3213
+ );
3214
+ if (!result.success) {
3215
+ throw new CliError(
3216
+ result.isValidation ? "VALIDATION" : "RUNTIME",
3217
+ result.error
3218
+ );
3219
+ }
3220
+ await writeOutput(formatChanges(result.data, deltaFormat), deltaFormat);
3221
+ });
3222
+
3223
+ program
3224
+ .command("diff <doc>")
3225
+ .description("Show one retained metadata-only structural change")
3226
+ .option("--change <id>", "opaque change ID")
3227
+ .option("--json", "JSON output")
3228
+ .action(async (doc: string, cmdOpts: Record<string, unknown>) => {
3229
+ const format = getFormat(cmdOpts);
3230
+ assertFormatSupported(CMD.diff, format);
3231
+ const deltaFormat = format === "json" ? "json" : "terminal";
3232
+ const globals = getGlobals();
3233
+ const { diff, formatDiff } = await import("./commands/changes");
3234
+ const result = await diff(doc, cmdOpts.change as string | undefined, {
3235
+ configPath: globals.config,
3236
+ indexName: globals.index,
3237
+ });
3238
+ if (!result.success) {
3239
+ throw new CliError(
3240
+ result.isValidation ? "VALIDATION" : "RUNTIME",
3241
+ result.error
3242
+ );
3243
+ }
3244
+ await writeOutput(formatDiff(result.data, deltaFormat), deltaFormat);
3245
+ });
3246
+
3247
+ program
3248
+ .command("impact <doc>")
3249
+ .description("Find bounded inbound knowledge dependencies")
3250
+ .option("--max-depth <n>", "maximum dependency depth", "3")
3251
+ .option("--max-nodes <n>", "maximum returned nodes", "100")
3252
+ .option("--max-edges <n>", "maximum traversed evidence edges", "250")
3253
+ .option("--frontier-limit <n>", "maximum frontier width", "100")
3254
+ .option("--visited-limit <n>", "maximum visited rows", "500")
3255
+ .option("--json", "JSON output")
3256
+ .action(async (doc: string, cmdOpts: Record<string, unknown>) => {
3257
+ const format = getFormat(cmdOpts);
3258
+ assertFormatSupported(CMD.impact, format);
3259
+ const deltaFormat = format === "json" ? "json" : "terminal";
3260
+ const globals = getGlobals();
3261
+ const { impact, formatImpact } = await import("./commands/changes");
3262
+ const result = await impact(
3263
+ doc,
3264
+ {
3265
+ maxDepth: parsePositiveInt("max-depth", cmdOpts.maxDepth),
3266
+ maxNodes: parsePositiveInt("max-nodes", cmdOpts.maxNodes),
3267
+ maxEdges: parsePositiveInt("max-edges", cmdOpts.maxEdges),
3268
+ frontierLimit: parsePositiveInt(
3269
+ "frontier-limit",
3270
+ cmdOpts.frontierLimit
3271
+ ),
3272
+ visitedLimit: parsePositiveInt("visited-limit", cmdOpts.visitedLimit),
3273
+ },
3274
+ { configPath: globals.config, indexName: globals.index }
3275
+ );
3276
+ if (!result.success) {
3277
+ throw new CliError(
3278
+ result.isValidation ? "VALIDATION" : "RUNTIME",
3279
+ result.error
3280
+ );
3281
+ }
3282
+ await writeOutput(formatImpact(result.data, deltaFormat), deltaFormat);
3283
+ });
3284
+ }
3285
+
3091
3286
  // ─────────────────────────────────────────────────────────────────────────────
3092
3287
  // Serve Command (web UI)
3093
3288
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,279 @@
1
+ /** Metadata-only registry for explicitly saved Context Capsule files. */
2
+
3
+ // node:path resolve has no Bun path utility equivalent.
4
+ import { resolve } from "node:path";
5
+
6
+ import type {
7
+ SavedCapsuleNotificationPreference,
8
+ SavedCapsuleRegistrationRecord,
9
+ StorePort,
10
+ StoreResult,
11
+ } from "../store/types";
12
+ import type { ContextCapsuleV1 } from "./context-capsule";
13
+
14
+ import { DEFAULT_INDEX_NAME, stripUriIndex } from "../app/constants";
15
+ import { canonicalizeIndexName } from "../app/index-name";
16
+ import { decodeDocumentChangeCursor } from "./change-journal";
17
+ import { sha256Text } from "./context-capsule-validation";
18
+ import { parseCanonicalContextCapsuleForVerification } from "./context-verifier";
19
+ import { canonicalVerifierJson } from "./context-verifier-canonical";
20
+
21
+ const MAX_CAPSULE_BYTES = 16 * 1024 * 1024;
22
+ const MAX_EVIDENCE_REFERENCES = 10_000;
23
+ const MAX_QUESTION_BYTES = 8192;
24
+ const MAX_LABEL_BYTES = 512;
25
+ const UTF8_ENCODER = new TextEncoder();
26
+ const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
27
+
28
+ type RegistryStore = Pick<
29
+ StorePort,
30
+ | "deleteSavedCapsuleRegistration"
31
+ | "getSavedCapsuleRegistration"
32
+ | "listDocumentChanges"
33
+ | "listSavedCapsuleRegistrations"
34
+ | "upsertSavedCapsuleRegistration"
35
+ >;
36
+
37
+ export type SavedCapsuleRegistryErrorCode =
38
+ | "capsule_file_changed"
39
+ | "capsule_file_missing"
40
+ | "capsule_file_too_large"
41
+ | "capsule_read_failed"
42
+ | "invalid_filter"
43
+ | "invalid_metadata"
44
+ | "registration_not_found"
45
+ | "store_failed";
46
+
47
+ export class SavedCapsuleRegistryError extends Error {
48
+ readonly code: SavedCapsuleRegistryErrorCode;
49
+
50
+ constructor(
51
+ code: SavedCapsuleRegistryErrorCode,
52
+ message: string,
53
+ cause?: unknown
54
+ ) {
55
+ super(message, cause === undefined ? undefined : { cause });
56
+ this.name = "SavedCapsuleRegistryError";
57
+ this.code = code;
58
+ }
59
+ }
60
+
61
+ export interface RegisterSavedCapsuleInput {
62
+ filePath: string;
63
+ question?: string;
64
+ label?: string;
65
+ notificationPreference?: SavedCapsuleNotificationPreference;
66
+ }
67
+
68
+ export interface LoadedSavedCapsule {
69
+ capsule: ContextCapsuleV1;
70
+ fileHash: string;
71
+ filePath: string;
72
+ raw: string;
73
+ }
74
+
75
+ const unwrapStore = <T>(result: StoreResult<T>, operation: string): T => {
76
+ if (result.ok) return result.value;
77
+ throw new SavedCapsuleRegistryError(
78
+ "store_failed",
79
+ `${operation}: ${result.error.message}`,
80
+ result.error.cause
81
+ );
82
+ };
83
+
84
+ const boundedOptionalText = (
85
+ value: string | undefined,
86
+ field: "question" | "label",
87
+ maxBytes: number
88
+ ): string | null => {
89
+ if (value === undefined) return null;
90
+ const normalized = value.trim().normalize("NFC");
91
+ if (
92
+ normalized.length === 0 ||
93
+ UTF8_ENCODER.encode(normalized).byteLength > maxBytes
94
+ ) {
95
+ throw new SavedCapsuleRegistryError(
96
+ "invalid_metadata",
97
+ `${field} must be non-empty and at most ${maxBytes} UTF-8 bytes`
98
+ );
99
+ }
100
+ return normalized;
101
+ };
102
+
103
+ export const loadSavedCapsuleFile = async (
104
+ filePath: string,
105
+ expectedFileHash?: string
106
+ ): Promise<LoadedSavedCapsule> => {
107
+ const canonicalPath = resolve(filePath);
108
+ const file = Bun.file(canonicalPath);
109
+ if (!(await file.exists())) {
110
+ throw new SavedCapsuleRegistryError(
111
+ "capsule_file_missing",
112
+ "Saved Context Capsule file is missing"
113
+ );
114
+ }
115
+ if (file.size < 1 || file.size > MAX_CAPSULE_BYTES) {
116
+ throw new SavedCapsuleRegistryError(
117
+ "capsule_file_too_large",
118
+ `Saved Context Capsule must be between 1 and ${MAX_CAPSULE_BYTES} bytes`
119
+ );
120
+ }
121
+ try {
122
+ const raw = UTF8_DECODER.decode(await file.arrayBuffer());
123
+ const fileHash = sha256Text(raw);
124
+ if (expectedFileHash !== undefined && fileHash !== expectedFileHash) {
125
+ throw new SavedCapsuleRegistryError(
126
+ "capsule_file_changed",
127
+ "Saved Context Capsule file changed after registration"
128
+ );
129
+ }
130
+ const capsule = parseCanonicalContextCapsuleForVerification(
131
+ JSON.parse(raw) as unknown
132
+ );
133
+ if (capsule.evidence.length > MAX_EVIDENCE_REFERENCES) {
134
+ throw new SavedCapsuleRegistryError(
135
+ "capsule_file_too_large",
136
+ `Saved Context Capsule exceeds ${MAX_EVIDENCE_REFERENCES} evidence references`
137
+ );
138
+ }
139
+ return {
140
+ capsule,
141
+ fileHash,
142
+ filePath: canonicalPath,
143
+ raw,
144
+ };
145
+ } catch (cause) {
146
+ if (cause instanceof SavedCapsuleRegistryError) throw cause;
147
+ throw new SavedCapsuleRegistryError(
148
+ "capsule_read_failed",
149
+ cause instanceof Error
150
+ ? `Saved Context Capsule is invalid: ${cause.message}`
151
+ : "Saved Context Capsule is invalid",
152
+ cause
153
+ );
154
+ }
155
+ };
156
+
157
+ const assertRuntimeIndex = (
158
+ capsule: ContextCapsuleV1,
159
+ runtimeIndexName: string
160
+ ): string => {
161
+ const effective = canonicalizeIndexName(
162
+ runtimeIndexName || DEFAULT_INDEX_NAME
163
+ );
164
+ if (effective !== capsule.scope.indexName) {
165
+ throw new SavedCapsuleRegistryError(
166
+ "invalid_filter",
167
+ `Context Capsule index ${capsule.scope.indexName} does not match runtime index ${effective}`
168
+ );
169
+ }
170
+ return effective;
171
+ };
172
+
173
+ const latestSequence = async (store: RegistryStore): Promise<number> => {
174
+ const page = unwrapStore(
175
+ await store.listDocumentChanges({ limit: 1 }),
176
+ "Failed to read the document change journal"
177
+ );
178
+ return decodeDocumentChangeCursor(page.latestCursor);
179
+ };
180
+
181
+ /** Register an explicit file without persisting or rewriting its body. */
182
+ export const registerSavedCapsule = async (
183
+ store: RegistryStore,
184
+ runtimeIndexName: string,
185
+ input: RegisterSavedCapsuleInput,
186
+ nowMs: number = Date.now()
187
+ ): Promise<SavedCapsuleRegistrationRecord> => {
188
+ // Capture the conservative high-water mark before reading the caller-owned
189
+ // file. Any journal change concurrent with file loading then remains newer
190
+ // than the registration and cannot be skipped by the resident scheduler.
191
+ const sequence = await latestSequence(store);
192
+ const loaded = await loadSavedCapsuleFile(input.filePath);
193
+ const indexName = assertRuntimeIndex(loaded.capsule, runtimeIndexName);
194
+ const registrationId = `capsule-${sha256Text(loaded.filePath).slice(0, 40)}`;
195
+ const existing = unwrapStore(
196
+ await store.getSavedCapsuleRegistration(registrationId),
197
+ "Failed to read saved Context Capsule registration"
198
+ );
199
+ return unwrapStore(
200
+ await store.upsertSavedCapsuleRegistration({
201
+ registrationId,
202
+ filePath: loaded.filePath,
203
+ fileHash: loaded.fileHash,
204
+ capsuleId: loaded.capsule.capsuleId,
205
+ indexName,
206
+ question: boundedOptionalText(
207
+ input.question,
208
+ "question",
209
+ MAX_QUESTION_BYTES
210
+ ),
211
+ label: boundedOptionalText(input.label, "label", MAX_LABEL_BYTES),
212
+ notificationPreference: input.notificationPreference ?? "none",
213
+ registeredAtMs: existing?.registeredAtMs ?? nowMs,
214
+ updatedAtMs: nowMs,
215
+ lastAttemptedSequence: sequence,
216
+ evidence: loaded.capsule.evidence
217
+ .map((evidence) => ({
218
+ evidenceId: evidence.evidenceId,
219
+ canonicalUri: stripUriIndex(evidence.uri),
220
+ collection: evidence.collection,
221
+ sourceHash: evidence.sourceHash,
222
+ mirrorHash: evidence.mirrorHash,
223
+ passageHash: evidence.passageHash,
224
+ }))
225
+ .sort((left, right) =>
226
+ left.evidenceId < right.evidenceId
227
+ ? -1
228
+ : left.evidenceId > right.evidenceId
229
+ ? 1
230
+ : 0
231
+ ),
232
+ }),
233
+ "Failed to register saved Context Capsule"
234
+ );
235
+ };
236
+
237
+ export const listSavedCapsules = async (
238
+ store: RegistryStore
239
+ ): Promise<SavedCapsuleRegistrationRecord[]> =>
240
+ unwrapStore(
241
+ await store.listSavedCapsuleRegistrations(),
242
+ "Failed to list saved Context Capsules"
243
+ );
244
+
245
+ export const getSavedCapsule = async (
246
+ store: RegistryStore,
247
+ registrationId: string
248
+ ): Promise<SavedCapsuleRegistrationRecord> => {
249
+ const registration = unwrapStore(
250
+ await store.getSavedCapsuleRegistration(registrationId),
251
+ "Failed to read saved Context Capsule"
252
+ );
253
+ if (!registration) {
254
+ throw new SavedCapsuleRegistryError(
255
+ "registration_not_found",
256
+ "Saved Context Capsule registration not found"
257
+ );
258
+ }
259
+ return registration;
260
+ };
261
+
262
+ export const unregisterSavedCapsule = async (
263
+ store: RegistryStore,
264
+ registrationId: string
265
+ ): Promise<void> => {
266
+ const deleted = unwrapStore(
267
+ await store.deleteSavedCapsuleRegistration(registrationId),
268
+ "Failed to remove saved Context Capsule"
269
+ );
270
+ if (!deleted) {
271
+ throw new SavedCapsuleRegistryError(
272
+ "registration_not_found",
273
+ "Saved Context Capsule registration not found"
274
+ );
275
+ }
276
+ };
277
+
278
+ export const canonicalSavedCapsuleRegistryJson = (value: unknown): string =>
279
+ canonicalVerifierJson(value);