@gmickel/gno 1.24.0 → 1.26.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 (96) hide show
  1. package/README.md +18 -4
  2. package/assets/skill/SKILL.md +40 -17
  3. package/assets/skill/recipes/capture-and-file.md +20 -5
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip +0 -0
  5. package/browser-extension/artifacts/gno-browser-clipper-v1.26.0.zip.sha256 +1 -0
  6. package/browser-extension/dist/PRIVACY.md +55 -0
  7. package/browser-extension/dist/chunk-vn5f663b.js +50 -0
  8. package/browser-extension/dist/chunk-ydfx5d7p.css +1 -0
  9. package/browser-extension/dist/content.js +1 -0
  10. package/browser-extension/dist/manifest.json +25 -0
  11. package/browser-extension/dist/preview.html +13 -0
  12. package/browser-extension/dist/service-worker.js +40 -0
  13. package/package.json +13 -3
  14. package/spec/cli.md +196 -4
  15. package/spec/db/schema.sql +102 -1
  16. package/spec/mcp.md +10 -0
  17. package/spec/output-schemas/browser-clip-preview.schema.json +83 -0
  18. package/spec/output-schemas/browser-clip.schema.json +586 -0
  19. package/spec/output-schemas/capture-receipt.schema.json +22 -1
  20. package/spec/output-schemas/clipper-csrf.schema.json +12 -0
  21. package/spec/output-schemas/clipper-error.schema.json +46 -0
  22. package/spec/output-schemas/clipper-pair-approval.schema.json +17 -0
  23. package/spec/output-schemas/clipper-pair-start.schema.json +26 -0
  24. package/spec/output-schemas/clipper-pair-status.schema.json +46 -0
  25. package/spec/output-schemas/clipper-revoke.schema.json +28 -0
  26. package/spec/output-schemas/mcp-capture-result.schema.json +12 -1
  27. package/spec/output-schemas/project-profile-apply.schema.json +209 -0
  28. package/spec/output-schemas/project-profile-command.schema.json +160 -0
  29. package/spec/output-schemas/query-diagnose.schema.json +7 -1
  30. package/spec/output-schemas/setup-profile-result.schema.json +87 -0
  31. package/spec/project-profile.schema.json +301 -0
  32. package/src/cli/commands/collection/add.ts +39 -45
  33. package/src/cli/commands/collection/remove.ts +28 -28
  34. package/src/cli/commands/collection/rename.ts +55 -73
  35. package/src/cli/commands/context/add.ts +37 -26
  36. package/src/cli/commands/context/rm.ts +47 -20
  37. package/src/cli/commands/init.ts +55 -125
  38. package/src/cli/commands/models/use.ts +43 -38
  39. package/src/cli/commands/profile-apply.ts +334 -0
  40. package/src/cli/commands/profile.ts +409 -0
  41. package/src/cli/commands/setup-activation.ts +205 -54
  42. package/src/cli/commands/setup-profile.ts +223 -0
  43. package/src/cli/commands/setup.ts +3 -0
  44. package/src/cli/program.ts +110 -9
  45. package/src/config/index.ts +3 -0
  46. package/src/config/project-profile.ts +367 -0
  47. package/src/config/saver.ts +16 -7
  48. package/src/config/types.ts +42 -0
  49. package/src/core/browser-clip-provenance.ts +139 -0
  50. package/src/core/browser-clip.ts +473 -0
  51. package/src/core/capture-write.ts +5 -0
  52. package/src/core/capture.ts +75 -18
  53. package/src/core/config-mutation.ts +138 -76
  54. package/src/core/config-write-lock.ts +89 -0
  55. package/src/core/context-identity.ts +16 -0
  56. package/src/core/context-resolver.ts +2 -12
  57. package/src/core/file-lock.ts +20 -6
  58. package/src/core/folder-setup-planning.ts +6 -21
  59. package/src/core/folder-setup.ts +30 -2
  60. package/src/core/path-rules.ts +53 -0
  61. package/src/core/project-affinity-surface.ts +102 -7
  62. package/src/core/project-profile-apply-state.ts +268 -0
  63. package/src/core/project-profile-apply-validation.ts +95 -0
  64. package/src/core/project-profile-apply.ts +408 -0
  65. package/src/core/project-profile-canonical.ts +71 -0
  66. package/src/core/project-profile-diff.ts +302 -0
  67. package/src/core/project-profile-discovery.ts +519 -0
  68. package/src/core/project-profile-file.ts +37 -0
  69. package/src/core/project-profile-parser.ts +98 -0
  70. package/src/core/project-profile.ts +490 -0
  71. package/src/ingestion/walker.ts +85 -44
  72. package/src/llm/cache.ts +21 -0
  73. package/src/serve/capture-service.ts +420 -0
  74. package/src/serve/clipper-body.ts +62 -0
  75. package/src/serve/clipper-capture.ts +248 -0
  76. package/src/serve/clipper-contract.ts +57 -0
  77. package/src/serve/clipper-idempotency.ts +35 -0
  78. package/src/serve/clipper-pairing.ts +297 -0
  79. package/src/serve/clipper-security-errors.ts +23 -0
  80. package/src/serve/clipper-security.ts +449 -0
  81. package/src/serve/config-sync.ts +2 -2
  82. package/src/serve/public/app.tsx +8 -1
  83. package/src/serve/public/globals.built.css +1 -1
  84. package/src/serve/public/index.html +1 -0
  85. package/src/serve/public/lib/clipper-approval.ts +206 -0
  86. package/src/serve/public/pages/ClipperPairing.tsx +210 -0
  87. package/src/serve/resident-runtime.ts +1 -0
  88. package/src/serve/routes/api.ts +20 -115
  89. package/src/serve/routes/clipper.ts +394 -0
  90. package/src/serve/server.ts +22 -0
  91. package/src/store/migrations/020-browser-clipper-security.ts +128 -0
  92. package/src/store/migrations/021-multi-context-identity.ts +37 -0
  93. package/src/store/migrations/index.ts +4 -0
  94. package/src/store/sqlite/adapter.ts +83 -0
  95. package/src/store/sqlite/clipper-store-types.ts +104 -0
  96. package/src/store/sqlite/clipper-store.ts +496 -0
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Shared resident capture planning and write execution.
3
+ *
4
+ * Browser clipping and the ordinary REST capture route both use this service so
5
+ * collision, provenance, atomic-write, sync, and receipt semantics cannot drift.
6
+ */
7
+
8
+ // node:fs/promises structure operations have no Bun equivalent.
9
+ import { mkdir } from "node:fs/promises";
10
+ // node:path has no Bun path utilities.
11
+ import { basename, dirname, join as pathJoin } from "node:path";
12
+
13
+ import type { Collection, Config } from "../config/types";
14
+ import type { PreparedBrowserClip } from "../core/browser-clip";
15
+ import type { CaptureInput, CapturePlan, CaptureSource } from "../core/capture";
16
+ import type { JobManager } from "../core/job-manager";
17
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
18
+ import type { ClipperIdempotencyPlan } from "../store/sqlite/clipper-store-types";
19
+ import type { DocumentEventBus } from "./doc-events";
20
+ import type { EmbedScheduler } from "./embed-scheduler";
21
+ import type { CollectionWatchService } from "./watch-service";
22
+
23
+ import {
24
+ buildCaptureReceipt,
25
+ extractCaptureSourceFromFrontmatter,
26
+ hashCaptureContent,
27
+ listCaptureDiskRelPaths,
28
+ planCapture,
29
+ } from "../core/capture";
30
+ import { writeCapturePlanFile } from "../core/capture-write";
31
+ import { recordContentMutation } from "../core/mutation-generations";
32
+ import {
33
+ type CollectionSyncResult,
34
+ defaultSyncService,
35
+ type SyncResult,
36
+ withContentTypeRules,
37
+ } from "../ingestion";
38
+ import { stripFrontmatter } from "../ingestion/frontmatter";
39
+ import { startJob } from "./jobs";
40
+
41
+ export interface ResidentCaptureContext {
42
+ config: Config;
43
+ scheduler: EmbedScheduler | null;
44
+ eventBus: DocumentEventBus | null;
45
+ watchService: CollectionWatchService | null;
46
+ jobManager?: JobManager;
47
+ markContentMutation?: () => void;
48
+ }
49
+
50
+ export type ResidentCapturePlanResult =
51
+ | {
52
+ ok: true;
53
+ collection: Collection;
54
+ fullPath: string;
55
+ plan: CapturePlan;
56
+ }
57
+ | {
58
+ ok: false;
59
+ code: "NOT_FOUND" | "RUNTIME" | "VALIDATION";
60
+ message: string;
61
+ status: number;
62
+ };
63
+
64
+ const listCollectionRelPaths = async (
65
+ store: Pick<SqliteAdapter, "listDocuments">,
66
+ collection: string
67
+ ): Promise<string[]> => {
68
+ const result = await store.listDocuments(collection);
69
+ if (!result.ok) throw new Error(result.error.message);
70
+ return result.value.map((entry) => entry.relPath);
71
+ };
72
+
73
+ const readCandidateClipIdentity = async (
74
+ collection: Collection,
75
+ relPath: string
76
+ ): Promise<string | null> => {
77
+ const file = Bun.file(pathJoin(collection.path, relPath));
78
+ if (!(await file.exists())) return null;
79
+ try {
80
+ return (
81
+ extractCaptureSourceFromFrontmatter(await file.text()).browserClip
82
+ ?.clipIdentity ?? null
83
+ );
84
+ } catch {
85
+ return null;
86
+ }
87
+ };
88
+
89
+ export const planResidentCapture = async (
90
+ context: ResidentCaptureContext,
91
+ store: SqliteAdapter,
92
+ input: CaptureInput,
93
+ now?: Date
94
+ ): Promise<ResidentCapturePlanResult> => {
95
+ const collection = context.config.collections.find(
96
+ (candidate) =>
97
+ candidate.name.toLowerCase() === input.collection.toLowerCase()
98
+ );
99
+ if (!collection) {
100
+ return {
101
+ ok: false,
102
+ code: "NOT_FOUND",
103
+ message: `Collection not found: ${input.collection}`,
104
+ status: 404,
105
+ };
106
+ }
107
+
108
+ let existingRelPaths: string[];
109
+ let diskRelPaths: string[];
110
+ try {
111
+ existingRelPaths = await listCollectionRelPaths(store, collection.name);
112
+ diskRelPaths = await listCaptureDiskRelPaths(collection.path);
113
+ } catch (error) {
114
+ return {
115
+ ok: false,
116
+ code: "RUNTIME",
117
+ message: error instanceof Error ? error.message : String(error),
118
+ status: 500,
119
+ };
120
+ }
121
+
122
+ try {
123
+ const normalizedInput = { ...input, collection: collection.name };
124
+ let plan = planCapture({
125
+ input: normalizedInput,
126
+ existingRelPaths,
127
+ diskRelPaths,
128
+ now,
129
+ });
130
+ if (plan.provenanceConflict && plan.source.browserClip !== undefined) {
131
+ const identity = await readCandidateClipIdentity(
132
+ collection,
133
+ plan.relPath
134
+ );
135
+ plan = planCapture({
136
+ input: normalizedInput,
137
+ existingRelPaths,
138
+ diskRelPaths,
139
+ existingProvenanceByRelPath:
140
+ identity === null ? new Map() : new Map([[plan.relPath, identity]]),
141
+ now,
142
+ });
143
+ }
144
+ return {
145
+ ok: true,
146
+ collection,
147
+ fullPath: pathJoin(collection.path, plan.relPath),
148
+ plan,
149
+ };
150
+ } catch (error) {
151
+ return {
152
+ ok: false,
153
+ code: "VALIDATION",
154
+ message: error instanceof Error ? error.message : String(error),
155
+ status: 409,
156
+ };
157
+ }
158
+ };
159
+
160
+ const syncResidentCollection = async (
161
+ context: ResidentCaptureContext,
162
+ collection: Collection,
163
+ store: SqliteAdapter,
164
+ syncCollection: typeof defaultSyncService.syncCollection
165
+ ): Promise<CollectionSyncResult> => {
166
+ const result = await syncCollection(
167
+ collection,
168
+ store,
169
+ withContentTypeRules({ runUpdateCmd: false }, context.config)
170
+ );
171
+ recordContentMutation(result, context.markContentMutation);
172
+ return result;
173
+ };
174
+
175
+ export const executeResidentCapturePlan = async (
176
+ context: ResidentCaptureContext,
177
+ store: SqliteAdapter,
178
+ planned: Extract<ResidentCapturePlanResult, { ok: true }>,
179
+ dependencies: {
180
+ syncCollection?: typeof defaultSyncService.syncCollection;
181
+ } = {}
182
+ ): Promise<{ body: unknown; status: number }> => {
183
+ const { collection, fullPath, plan } = planned;
184
+ if (plan.provenanceConflict) {
185
+ return {
186
+ body: buildCaptureReceipt({
187
+ plan,
188
+ absPath: fullPath,
189
+ sync: {
190
+ status: "skipped",
191
+ reason:
192
+ "Existing capture has absent or different browser provenance.",
193
+ },
194
+ }),
195
+ status: 409,
196
+ };
197
+ }
198
+ if (plan.openedExisting) {
199
+ const existingDocument = await store.getDocument(
200
+ collection.name,
201
+ plan.relPath
202
+ );
203
+ if (!existingDocument.ok) {
204
+ throw new Error(existingDocument.error.message);
205
+ }
206
+ return {
207
+ body: buildCaptureReceipt({
208
+ plan,
209
+ absPath: fullPath,
210
+ docid: existingDocument.value?.docid,
211
+ sync: existingDocument.value
212
+ ? { status: "completed" }
213
+ : {
214
+ status: "skipped",
215
+ reason: "Existing file is not indexed yet.",
216
+ },
217
+ }),
218
+ status: 200,
219
+ };
220
+ }
221
+
222
+ await mkdir(dirname(fullPath), { recursive: true });
223
+ context.watchService?.suppress(fullPath);
224
+ await writeCapturePlanFile(plan, fullPath);
225
+ const gnoUri = `gno://${collection.name}/${plan.relPath}`;
226
+ const syncCollection =
227
+ dependencies.syncCollection ??
228
+ defaultSyncService.syncCollection.bind(defaultSyncService);
229
+ const jobResult = await startJob(
230
+ "sync",
231
+ async (): Promise<SyncResult> => {
232
+ const result = await syncResidentCollection(
233
+ context,
234
+ collection,
235
+ store,
236
+ syncCollection
237
+ );
238
+ context.scheduler?.notifySyncComplete([plan.relPath]);
239
+ context.eventBus?.emit({
240
+ type: "document-changed",
241
+ uri: gnoUri,
242
+ collection: collection.name,
243
+ relPath: plan.relPath,
244
+ origin: "create",
245
+ changedAt: new Date().toISOString(),
246
+ });
247
+ return {
248
+ collections: [result],
249
+ totalDurationMs: result.durationMs,
250
+ totalFilesProcessed: result.filesProcessed,
251
+ totalFilesAdded: result.filesAdded,
252
+ totalFilesUpdated: result.filesUpdated,
253
+ totalFilesErrored: result.filesErrored,
254
+ totalFilesSkipped: result.filesSkipped,
255
+ };
256
+ },
257
+ context.jobManager
258
+ );
259
+
260
+ return {
261
+ body: buildCaptureReceipt({
262
+ plan,
263
+ absPath: fullPath,
264
+ sync: jobResult.ok
265
+ ? {
266
+ status: "pending",
267
+ jobId: jobResult.jobId,
268
+ reason: "Sync job started; poll /api/jobs/:id for status.",
269
+ }
270
+ : {
271
+ status: "skipped",
272
+ jobId: jobResult.activeJobId,
273
+ reason: "Sync skipped because another job is running.",
274
+ error: jobResult.error,
275
+ },
276
+ }),
277
+ status: 202,
278
+ };
279
+ };
280
+
281
+ export const browserClipIdempotencyPlan = (
282
+ planned: Extract<ResidentCapturePlanResult, { ok: true }>
283
+ ): ClipperIdempotencyPlan => {
284
+ const browserClip = planned.plan.source.browserClip;
285
+ if (!browserClip) {
286
+ throw new Error("Browser capture plan is missing browser provenance");
287
+ }
288
+ return {
289
+ collection: planned.plan.collection,
290
+ relPath: planned.plan.relPath,
291
+ collisionPolicyResult: planned.plan.collisionPolicyResult,
292
+ contentHash: planned.plan.contentHash,
293
+ clipIdentity: browserClip.clipIdentity,
294
+ };
295
+ };
296
+
297
+ export const browserClipIdempotencyPlansMatch = (
298
+ left: ClipperIdempotencyPlan,
299
+ right: ClipperIdempotencyPlan
300
+ ): boolean =>
301
+ left.collection === right.collection &&
302
+ left.relPath === right.relPath &&
303
+ left.collisionPolicyResult === right.collisionPolicyResult &&
304
+ left.contentHash === right.contentHash &&
305
+ left.clipIdentity === right.clipIdentity;
306
+
307
+ export type ResidentCaptureRecoveryResult =
308
+ | { status: "recovered"; body: unknown; statusCode: number }
309
+ | {
310
+ status: "execute";
311
+ planned: Extract<ResidentCapturePlanResult, { ok: true }>;
312
+ }
313
+ | { status: "conflict"; message: string };
314
+
315
+ const recoveredCapturePlan = (
316
+ prepared: PreparedBrowserClip,
317
+ persisted: ClipperIdempotencyPlan,
318
+ storedSource: CaptureSource
319
+ ): CapturePlan => ({
320
+ collection: persisted.collection,
321
+ relPath: persisted.relPath,
322
+ filename: basename(persisted.relPath),
323
+ content: prepared.captureInput.content ?? prepared.preview.body,
324
+ body: prepared.preview.body,
325
+ contentHash: persisted.contentHash,
326
+ title: prepared.payload.title,
327
+ tags: prepared.preview.tags,
328
+ source: storedSource,
329
+ openedExisting: persisted.collisionPolicyResult === "opened_existing",
330
+ createdWithSuffix: persisted.collisionPolicyResult === "created_with_suffix",
331
+ provenanceConflict: persisted.collisionPolicyResult === "conflict",
332
+ collisionPolicy: prepared.payload.destination.collisionPolicy,
333
+ collisionPolicyResult: persisted.collisionPolicyResult,
334
+ overwrite: persisted.collisionPolicyResult === "overwritten",
335
+ });
336
+
337
+ /**
338
+ * Reconcile a pending clipper claim without ever choosing a new destination.
339
+ * A matching exact file proves the atomic write landed; otherwise execution is
340
+ * allowed only when a fresh plan is byte-for-byte identical to the saved plan.
341
+ */
342
+ export const recoverPendingResidentBrowserClip = async (
343
+ context: ResidentCaptureContext,
344
+ store: SqliteAdapter,
345
+ prepared: PreparedBrowserClip,
346
+ persisted: ClipperIdempotencyPlan
347
+ ): Promise<ResidentCaptureRecoveryResult> => {
348
+ const collection = context.config.collections.find(
349
+ (candidate) => candidate.name === persisted.collection
350
+ );
351
+ if (!collection) {
352
+ return {
353
+ status: "conflict",
354
+ message: "Saved browser capture collection is no longer configured",
355
+ };
356
+ }
357
+
358
+ const fullPath = pathJoin(collection.path, persisted.relPath);
359
+ const file = Bun.file(fullPath);
360
+ if (await file.exists()) {
361
+ try {
362
+ const storedContent = await file.text();
363
+ const source = extractCaptureSourceFromFrontmatter(storedContent);
364
+ const browserClip = source.browserClip;
365
+ if (
366
+ source.kind === "web" &&
367
+ typeof source.capturedAt === "string" &&
368
+ browserClip?.clipIdentity === persisted.clipIdentity &&
369
+ browserClip.finalBodyHash === persisted.contentHash &&
370
+ browserClip.previewDigest === prepared.preview.digest &&
371
+ hashCaptureContent(stripFrontmatter(storedContent)) ===
372
+ persisted.contentHash
373
+ ) {
374
+ const storedSource: CaptureSource = {
375
+ ...source,
376
+ kind: source.kind,
377
+ capturedAt: source.capturedAt,
378
+ browserClip,
379
+ };
380
+ const plan = recoveredCapturePlan(prepared, persisted, storedSource);
381
+ return {
382
+ status: "recovered",
383
+ body: buildCaptureReceipt({
384
+ plan,
385
+ absPath: fullPath,
386
+ sync: {
387
+ status: "skipped",
388
+ reason:
389
+ "Recovered a completed atomic write after receipt persistence was interrupted.",
390
+ },
391
+ }),
392
+ statusCode:
393
+ persisted.collisionPolicyResult === "opened_existing" ? 200 : 202,
394
+ };
395
+ }
396
+ } catch {
397
+ // A malformed or unrelated exact file is handled as plan drift below.
398
+ }
399
+ }
400
+
401
+ const replanned = await planResidentCapture(
402
+ context,
403
+ store,
404
+ prepared.captureInput
405
+ );
406
+ if (
407
+ !replanned.ok ||
408
+ !browserClipIdempotencyPlansMatch(
409
+ browserClipIdempotencyPlan(replanned),
410
+ persisted
411
+ )
412
+ ) {
413
+ return {
414
+ status: "conflict",
415
+ message:
416
+ "Pending browser capture cannot be recovered because its destination changed",
417
+ };
418
+ }
419
+ return { status: "execute", planned: replanned };
420
+ };
@@ -0,0 +1,62 @@
1
+ /** Bounded UTF-8 JSON reader for the browser-clipper boundary. */
2
+
3
+ import { clipperSecurityErrorResponse } from "./clipper-security-errors";
4
+
5
+ export type ClipperBodyReadResult =
6
+ | { ok: true; value: unknown }
7
+ | { ok: false; response: Response };
8
+
9
+ export async function readClipperBoundedJson(
10
+ request: Request,
11
+ maxBytes: number
12
+ ): Promise<ClipperBodyReadResult> {
13
+ const declaredLength = request.headers.get("content-length");
14
+ if (
15
+ declaredLength !== null &&
16
+ (!/^\d+$/.test(declaredLength) || Number(declaredLength) > maxBytes)
17
+ ) {
18
+ return {
19
+ ok: false,
20
+ response: clipperSecurityErrorResponse("CLIPPER_BODY_TOO_LARGE"),
21
+ };
22
+ }
23
+
24
+ const reader = request.body?.getReader();
25
+ if (!reader) {
26
+ return {
27
+ ok: false,
28
+ response: clipperSecurityErrorResponse("CLIPPER_INVALID_JSON"),
29
+ };
30
+ }
31
+ const chunks: Uint8Array[] = [];
32
+ let total = 0;
33
+ while (true) {
34
+ const result = await reader.read();
35
+ if (result.done) break;
36
+ total += result.value.byteLength;
37
+ if (total > maxBytes) {
38
+ await reader.cancel();
39
+ return {
40
+ ok: false,
41
+ response: clipperSecurityErrorResponse("CLIPPER_BODY_TOO_LARGE"),
42
+ };
43
+ }
44
+ chunks.push(result.value);
45
+ }
46
+
47
+ const bytes = new Uint8Array(total);
48
+ let offset = 0;
49
+ for (const chunk of chunks) {
50
+ bytes.set(chunk, offset);
51
+ offset += chunk.byteLength;
52
+ }
53
+ try {
54
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
55
+ return { ok: true, value: JSON.parse(text) as unknown };
56
+ } catch {
57
+ return {
58
+ ok: false,
59
+ response: clipperSecurityErrorResponse("CLIPPER_INVALID_JSON"),
60
+ };
61
+ }
62
+ }