@gmickel/gno 1.7.1 → 1.9.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 (51) hide show
  1. package/README.md +10 -1
  2. package/assets/skill/SKILL.md +41 -0
  3. package/assets/skill/cli-reference.md +34 -0
  4. package/assets/skill/examples.md +37 -0
  5. package/assets/skill/mcp-reference.md +21 -0
  6. package/package.json +1 -1
  7. package/src/cli/commands/capture.ts +282 -0
  8. package/src/cli/commands/index-cmd.ts +17 -6
  9. package/src/cli/commands/shared.ts +17 -1
  10. package/src/cli/commands/update.ts +17 -6
  11. package/src/cli/options.ts +2 -0
  12. package/src/cli/program.ts +64 -0
  13. package/src/config/content-types.ts +140 -0
  14. package/src/config/defaults.ts +1 -0
  15. package/src/config/index.ts +14 -0
  16. package/src/config/loader.ts +11 -2
  17. package/src/config/types.ts +37 -1
  18. package/src/core/capture-write.ts +38 -0
  19. package/src/core/capture.ts +746 -0
  20. package/src/core/config-mutation.ts +14 -2
  21. package/src/core/file-ops.ts +21 -2
  22. package/src/core/note-presets.ts +61 -5
  23. package/src/ingestion/frontmatter.ts +77 -2
  24. package/src/ingestion/index.ts +2 -0
  25. package/src/ingestion/sync-options.ts +29 -0
  26. package/src/ingestion/sync.ts +104 -16
  27. package/src/ingestion/types.ts +14 -0
  28. package/src/mcp/tools/add-collection.ts +8 -5
  29. package/src/mcp/tools/capture.ts +137 -191
  30. package/src/mcp/tools/index-cmd.ts +13 -7
  31. package/src/mcp/tools/index.ts +43 -9
  32. package/src/mcp/tools/sync.ts +12 -6
  33. package/src/mcp/tools/workspace-write.ts +16 -10
  34. package/src/pipeline/hybrid.ts +2 -0
  35. package/src/pipeline/search.ts +2 -0
  36. package/src/pipeline/types.ts +2 -0
  37. package/src/pipeline/vsearch.ts +4 -0
  38. package/src/sdk/client.ts +156 -20
  39. package/src/sdk/index.ts +2 -0
  40. package/src/sdk/types.ts +6 -0
  41. package/src/serve/background-runtime.ts +18 -4
  42. package/src/serve/config-sync.ts +9 -2
  43. package/src/serve/public/components/CaptureModal.tsx +248 -10
  44. package/src/serve/public/globals.built.css +1 -1
  45. package/src/serve/routes/api.ts +238 -26
  46. package/src/serve/server.ts +12 -0
  47. package/src/serve/watch-service.ts +12 -2
  48. package/src/store/migrations/009-content-type-rule-fingerprint.ts +36 -0
  49. package/src/store/migrations/index.ts +12 -1
  50. package/src/store/sqlite/adapter.ts +29 -14
  51. package/src/store/types.ts +6 -0
@@ -0,0 +1,746 @@
1
+ /**
2
+ * Shared capture planning, provenance, and receipt contracts.
3
+ *
4
+ * Server-side capture adapters call this before transport-specific write/sync.
5
+ *
6
+ * @module src/core/capture
7
+ */
8
+
9
+ // node:path has no Bun equivalent
10
+ import { posix as pathPosix } from "node:path";
11
+
12
+ import { buildUri } from "../app/constants";
13
+ import {
14
+ resolveNoteCreatePlan,
15
+ type NoteCollisionPolicy,
16
+ } from "./note-creation";
17
+ import {
18
+ getNotePreset,
19
+ resolveNotePreset,
20
+ type NotePresetId,
21
+ } from "./note-presets";
22
+ import { normalizeTag, validateTag } from "./tags";
23
+ import { validateRelPath } from "./validation";
24
+
25
+ export const CAPTURE_MAX_TEXT_BYTES = 1024 * 1024;
26
+
27
+ export type CaptureSourceKind =
28
+ | "direct"
29
+ | "web"
30
+ | "email"
31
+ | "meeting"
32
+ | "chat"
33
+ | "file"
34
+ | "api"
35
+ | "unknown";
36
+
37
+ export type CaptureStatus =
38
+ | "not_requested"
39
+ | "pending"
40
+ | "running"
41
+ | "completed"
42
+ | "skipped"
43
+ | "failed"
44
+ | "unknown";
45
+
46
+ export type CaptureCollisionPolicyResult =
47
+ | "created"
48
+ | "opened_existing"
49
+ | "created_with_suffix"
50
+ | "overwritten"
51
+ | "conflict";
52
+
53
+ export interface CaptureSource {
54
+ kind: CaptureSourceKind;
55
+ title?: string;
56
+ url?: string;
57
+ uri?: string;
58
+ docid?: string;
59
+ mime?: string;
60
+ ext?: string;
61
+ author?: string;
62
+ observedAt?: string;
63
+ capturedAt: string;
64
+ externalId?: string;
65
+ }
66
+
67
+ export interface CaptureIndexStatus {
68
+ status: CaptureStatus;
69
+ jobId?: string | null;
70
+ reason?: string;
71
+ error?: string;
72
+ }
73
+
74
+ export interface CaptureReceipt {
75
+ uri: string;
76
+ docid?: string;
77
+ collection: string;
78
+ relPath: string;
79
+ absPath?: string;
80
+ created: boolean;
81
+ openedExisting: boolean;
82
+ createdWithSuffix: boolean;
83
+ overwritten?: boolean;
84
+ contentHash: string;
85
+ source: CaptureSource;
86
+ tags: string[];
87
+ sync: CaptureIndexStatus;
88
+ embed: CaptureIndexStatus;
89
+ collisionPolicyResult: CaptureCollisionPolicyResult;
90
+ serverInstanceId?: string;
91
+ }
92
+
93
+ export interface CaptureInput {
94
+ collection: string;
95
+ content?: string;
96
+ title?: string;
97
+ relPath?: string;
98
+ folderPath?: string;
99
+ collisionPolicy?: NoteCollisionPolicy;
100
+ presetId?: NotePresetId;
101
+ tags?: string[];
102
+ source?: Partial<CaptureSource>;
103
+ overwrite?: boolean;
104
+ }
105
+
106
+ export type PublicCaptureInput = Omit<CaptureInput, "overwrite">;
107
+
108
+ export interface CapturePlan {
109
+ collection: string;
110
+ relPath: string;
111
+ filename: string;
112
+ content: string;
113
+ body: string;
114
+ contentHash: string;
115
+ title: string;
116
+ tags: string[];
117
+ source: CaptureSource;
118
+ openedExisting: boolean;
119
+ createdWithSuffix: boolean;
120
+ collisionPolicy: NoteCollisionPolicy;
121
+ collisionPolicyResult: CaptureCollisionPolicyResult;
122
+ overwrite: boolean;
123
+ }
124
+
125
+ export interface PlanCaptureOptions {
126
+ input: CaptureInput;
127
+ existingRelPaths: Iterable<string>;
128
+ diskRelPaths?: Iterable<string>;
129
+ now?: Date;
130
+ }
131
+
132
+ const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)(?:\r?\n)?---(?:\r?\n|$)/;
133
+ const VALID_SOURCE_KINDS = new Set<CaptureSourceKind>([
134
+ "direct",
135
+ "web",
136
+ "email",
137
+ "meeting",
138
+ "chat",
139
+ "file",
140
+ "api",
141
+ "unknown",
142
+ ]);
143
+ const VALID_COLLISION_POLICIES = new Set<NoteCollisionPolicy>([
144
+ "error",
145
+ "open_existing",
146
+ "create_with_suffix",
147
+ ]);
148
+ const URL_SOURCE_FIELDS = new Set(["url", "uri"]);
149
+ const LEGACY_SOURCE_FIELD_MAP: Record<string, keyof CaptureSource> = {
150
+ gno_source_docid: "docid",
151
+ gno_source_uri: "uri",
152
+ gno_source_mime: "mime",
153
+ gno_source_ext: "ext",
154
+ };
155
+ const CAPTURE_SOURCE_STRING_KEYS = new Set([
156
+ "title",
157
+ "url",
158
+ "uri",
159
+ "docid",
160
+ "mime",
161
+ "ext",
162
+ "author",
163
+ "externalId",
164
+ ]);
165
+
166
+ function normalizeContentForHash(content: string): string {
167
+ return content.replace(/\r\n/g, "\n").trim();
168
+ }
169
+
170
+ export function hashCaptureContent(content: string): string {
171
+ return new Bun.CryptoHasher("sha256")
172
+ .update(normalizeContentForHash(content))
173
+ .digest("hex");
174
+ }
175
+
176
+ export async function listCaptureDiskRelPaths(
177
+ collectionPath: string
178
+ ): Promise<string[]> {
179
+ const paths: string[] = [];
180
+ const glob = new Bun.Glob("**/*");
181
+ for await (const relPath of glob.scan({
182
+ cwd: collectionPath,
183
+ onlyFiles: true,
184
+ followSymlinks: false,
185
+ })) {
186
+ try {
187
+ paths.push(validateRelPath(relPath.split("\\").join("/")));
188
+ } catch {
189
+ // Ignore unsafe/unrepresentable disk paths for collision planning.
190
+ }
191
+ }
192
+ return paths;
193
+ }
194
+
195
+ function validateTextContent(content: string): void {
196
+ if (content.includes("\0")) {
197
+ throw new Error("Capture content contains a NUL byte.");
198
+ }
199
+ for (let index = 0; index < content.length; index += 1) {
200
+ const code = content.charCodeAt(index);
201
+ const isAllowedWhitespace = code === 9 || code === 10 || code === 13;
202
+ if ((code < 32 || code === 127) && !isAllowedWhitespace) {
203
+ throw new Error("Capture content must be text, not binary-like data.");
204
+ }
205
+ }
206
+ if (new TextEncoder().encode(content).byteLength > CAPTURE_MAX_TEXT_BYTES) {
207
+ throw new Error(
208
+ `Capture content exceeds ${CAPTURE_MAX_TEXT_BYTES} byte limit.`
209
+ );
210
+ }
211
+ }
212
+
213
+ function normalizeCollisionPolicy(
214
+ policy: CaptureInput["collisionPolicy"] | undefined,
215
+ fallback: NoteCollisionPolicy
216
+ ): NoteCollisionPolicy {
217
+ if (policy === undefined) {
218
+ return fallback;
219
+ }
220
+ if (!VALID_COLLISION_POLICIES.has(policy)) {
221
+ throw new Error(
222
+ "collisionPolicy must be one of: error, open_existing, create_with_suffix"
223
+ );
224
+ }
225
+ return policy;
226
+ }
227
+
228
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
229
+ return (
230
+ typeof value === "object" &&
231
+ value !== null &&
232
+ !Array.isArray(value) &&
233
+ Object.getPrototypeOf(value) === Object.prototype
234
+ );
235
+ }
236
+
237
+ function normalizeIsoDate(value: string, field: string): string {
238
+ const parsed = new Date(value);
239
+ if (Number.isNaN(parsed.getTime())) {
240
+ throw new Error(`${field} must be an ISO-like date/time.`);
241
+ }
242
+ return parsed.toISOString();
243
+ }
244
+
245
+ function normalizeSource(
246
+ source: Partial<CaptureSource> | undefined,
247
+ capturedAt: string
248
+ ): CaptureSource {
249
+ if (source !== undefined && !isPlainObject(source)) {
250
+ throw new Error("source must be an object.");
251
+ }
252
+ const kind = source?.kind ?? "direct";
253
+ if (!VALID_SOURCE_KINDS.has(kind)) {
254
+ throw new Error(`Unsupported source.kind: ${kind}`);
255
+ }
256
+
257
+ const normalized: CaptureSource = {
258
+ kind,
259
+ capturedAt,
260
+ };
261
+
262
+ for (const [key, value] of Object.entries(source ?? {})) {
263
+ if (value === undefined || value === null || key === "kind") {
264
+ continue;
265
+ }
266
+ if (key === "capturedAt") {
267
+ normalized.capturedAt = normalizeIsoDate(
268
+ String(value),
269
+ "source.capturedAt"
270
+ );
271
+ continue;
272
+ }
273
+ if (key === "observedAt") {
274
+ normalized.observedAt = normalizeIsoDate(
275
+ String(value),
276
+ "source.observedAt"
277
+ );
278
+ continue;
279
+ }
280
+ if (URL_SOURCE_FIELDS.has(key)) {
281
+ try {
282
+ new URL(String(value));
283
+ } catch {
284
+ throw new Error(`source.${key} must be a valid URL.`);
285
+ }
286
+ }
287
+ if (CAPTURE_SOURCE_STRING_KEYS.has(key)) {
288
+ normalized[
289
+ key as keyof Pick<
290
+ CaptureSource,
291
+ | "title"
292
+ | "url"
293
+ | "uri"
294
+ | "docid"
295
+ | "mime"
296
+ | "ext"
297
+ | "author"
298
+ | "externalId"
299
+ >
300
+ ] = String(value);
301
+ }
302
+ }
303
+
304
+ return normalized;
305
+ }
306
+
307
+ function normalizeCaptureTags(tags: string[] | undefined): string[] {
308
+ if (tags !== undefined && !Array.isArray(tags)) {
309
+ throw new Error("tags must be an array of strings.");
310
+ }
311
+ const normalized: string[] = [];
312
+ for (const tag of tags ?? []) {
313
+ if (typeof tag !== "string") {
314
+ throw new Error("tags must be an array of strings.");
315
+ }
316
+ const value = normalizeTag(tag);
317
+ if (!validateTag(value)) {
318
+ throw new Error(
319
+ `Invalid tag "${tag}". Tags must be lowercase, alphanumeric with hyphens/dots/slashes.`
320
+ );
321
+ }
322
+ normalized.push(value);
323
+ }
324
+ return [...new Set(normalized)];
325
+ }
326
+
327
+ function chooseTitle(input: CaptureInput, fallback: string): string {
328
+ return (
329
+ input.title?.trim() ||
330
+ input.source?.title?.trim() ||
331
+ pathPosix.basename(input.relPath ?? "").replace(/\.[^.]+$/u, "") ||
332
+ fallback
333
+ );
334
+ }
335
+
336
+ function generatedCaptureRelPath(
337
+ capturedAt: string,
338
+ contentHash: string
339
+ ): string {
340
+ const day = capturedAt.slice(0, 10);
341
+ return `inbox/${day}/capture-${contentHash.slice(0, 12)}.md`;
342
+ }
343
+
344
+ function buildExistingSet(
345
+ indexedRelPaths: Iterable<string>,
346
+ diskRelPaths: Iterable<string> | undefined
347
+ ): Set<string> {
348
+ const existing = new Set<string>();
349
+ for (const relPath of indexedRelPaths) {
350
+ existing.add(validateRelPath(relPath));
351
+ }
352
+ for (const relPath of diskRelPaths ?? []) {
353
+ existing.add(validateRelPath(relPath));
354
+ }
355
+ return existing;
356
+ }
357
+
358
+ function splitFrontmatter(source: string): {
359
+ lines: string[];
360
+ body: string;
361
+ hasFrontmatter: boolean;
362
+ } {
363
+ const match = FRONTMATTER_REGEX.exec(source);
364
+ if (!match) {
365
+ return { lines: [], body: source, hasFrontmatter: false };
366
+ }
367
+ return {
368
+ lines: (match[1] ?? "").split("\n"),
369
+ body: source.slice(match[0].length),
370
+ hasFrontmatter: true,
371
+ };
372
+ }
373
+
374
+ function stripYamlString(value: string): string {
375
+ const trimmed = value.trim();
376
+ if (
377
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
378
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
379
+ ) {
380
+ try {
381
+ return JSON.parse(trimmed);
382
+ } catch {
383
+ return trimmed.slice(1, -1);
384
+ }
385
+ }
386
+ return trimmed;
387
+ }
388
+
389
+ function parseInlineTagList(value: string): string[] {
390
+ const trimmed = value.trim();
391
+ if (!trimmed || trimmed === "[]") {
392
+ return [];
393
+ }
394
+ if (!(trimmed.startsWith("[") && trimmed.endsWith("]"))) {
395
+ return [stripYamlString(trimmed)];
396
+ }
397
+ return trimmed
398
+ .slice(1, -1)
399
+ .split(",")
400
+ .map((tag) => stripYamlString(tag))
401
+ .filter((tag) => tag.length > 0);
402
+ }
403
+
404
+ function readFrontmatterTags(lines: string[]): string[] {
405
+ const tags: string[] = [];
406
+ for (let index = 0; index < lines.length; index += 1) {
407
+ const line = lines[index];
408
+ if (!line?.startsWith("tags:")) {
409
+ continue;
410
+ }
411
+ tags.push(...parseInlineTagList(line.slice("tags:".length)));
412
+ for (
413
+ let nestedIndex = index + 1;
414
+ nestedIndex < lines.length;
415
+ nestedIndex += 1
416
+ ) {
417
+ const nested = lines[nestedIndex];
418
+ if (!nested?.startsWith(" ")) {
419
+ break;
420
+ }
421
+ const item = nested.trim();
422
+ if (item.startsWith("-")) {
423
+ const tag = stripYamlString(item.slice(1));
424
+ if (tag) {
425
+ tags.push(tag);
426
+ }
427
+ }
428
+ }
429
+ }
430
+ return normalizeCaptureTags(tags);
431
+ }
432
+
433
+ function shouldSkipNestedFrontmatterLine(line: string): boolean {
434
+ return line.startsWith(" ") || line.trim() === "";
435
+ }
436
+
437
+ export function extractCaptureSourceFromFrontmatter(
438
+ content: string
439
+ ): Partial<CaptureSource> {
440
+ const { lines } = splitFrontmatter(content);
441
+ const source: Partial<CaptureSource> = {};
442
+ for (let index = 0; index < lines.length; index += 1) {
443
+ const line = lines[index];
444
+ if (line === undefined) {
445
+ continue;
446
+ }
447
+ const colonIndex = line.indexOf(":");
448
+ if (colonIndex <= 0) {
449
+ continue;
450
+ }
451
+ const key = line.slice(0, colonIndex).trim();
452
+ const rawValue = line.slice(colonIndex + 1).trim();
453
+ const legacyKey = LEGACY_SOURCE_FIELD_MAP[key];
454
+ if (legacyKey && rawValue) {
455
+ switch (legacyKey) {
456
+ case "docid":
457
+ source.docid = stripYamlString(rawValue);
458
+ break;
459
+ case "uri":
460
+ source.uri = stripYamlString(rawValue);
461
+ break;
462
+ case "mime":
463
+ source.mime = stripYamlString(rawValue);
464
+ break;
465
+ case "ext":
466
+ source.ext = stripYamlString(rawValue);
467
+ break;
468
+ }
469
+ continue;
470
+ }
471
+ if (key !== "source") {
472
+ continue;
473
+ }
474
+ for (
475
+ let nestedIndex = index + 1;
476
+ nestedIndex < lines.length;
477
+ nestedIndex += 1
478
+ ) {
479
+ const nested = lines[nestedIndex];
480
+ if (!nested?.startsWith(" ")) {
481
+ break;
482
+ }
483
+ const nestedColon = nested.indexOf(":");
484
+ if (nestedColon <= 0) {
485
+ continue;
486
+ }
487
+ const nestedKey = nested
488
+ .slice(0, nestedColon)
489
+ .trim() as keyof CaptureSource;
490
+ const nestedValue = nested.slice(nestedColon + 1).trim();
491
+ if (nestedValue) {
492
+ source[nestedKey] = stripYamlString(nestedValue) as never;
493
+ }
494
+ }
495
+ }
496
+ return source;
497
+ }
498
+
499
+ function sourceFrontmatterLines(source: CaptureSource): string[] {
500
+ const lines = ["source:"];
501
+ for (const [key, value] of Object.entries(source)) {
502
+ if (value === undefined || value === "") {
503
+ continue;
504
+ }
505
+ lines.push(` ${key}: ${JSON.stringify(value)}`);
506
+ }
507
+ return lines;
508
+ }
509
+
510
+ export function mergeCaptureFrontmatter(input: {
511
+ content: string;
512
+ source: CaptureSource;
513
+ tags: string[];
514
+ title?: string;
515
+ }): string {
516
+ return mergeCaptureFrontmatterAndTags(input).content;
517
+ }
518
+
519
+ function mergeCaptureFrontmatterAndTags(input: {
520
+ content: string;
521
+ source: CaptureSource;
522
+ tags: string[];
523
+ title?: string;
524
+ }): { content: string; tags: string[] } {
525
+ const { lines, body, hasFrontmatter } = splitFrontmatter(input.content);
526
+ const nextLines: string[] = [];
527
+ let skippingSource = false;
528
+ let skippingTags = false;
529
+ let hasTitle = false;
530
+ const mergedTags = [
531
+ ...new Set([...readFrontmatterTags(lines), ...input.tags]),
532
+ ];
533
+
534
+ for (const line of lines) {
535
+ if (skippingSource) {
536
+ if (shouldSkipNestedFrontmatterLine(line)) {
537
+ continue;
538
+ }
539
+ skippingSource = false;
540
+ }
541
+ if (skippingTags) {
542
+ if (shouldSkipNestedFrontmatterLine(line)) {
543
+ continue;
544
+ }
545
+ skippingTags = false;
546
+ }
547
+
548
+ if (line.startsWith("source:")) {
549
+ skippingSource = true;
550
+ continue;
551
+ }
552
+ if (line.startsWith("tags:")) {
553
+ skippingTags = true;
554
+ continue;
555
+ }
556
+ if (line.startsWith("title:")) {
557
+ hasTitle = true;
558
+ }
559
+ nextLines.push(line);
560
+ }
561
+
562
+ if (input.title && !hasTitle) {
563
+ nextLines.unshift(`title: ${JSON.stringify(input.title)}`);
564
+ }
565
+ if (mergedTags.length > 0) {
566
+ nextLines.push("tags:");
567
+ for (const tag of mergedTags) {
568
+ nextLines.push(` - ${JSON.stringify(tag)}`);
569
+ }
570
+ }
571
+ nextLines.push(...sourceFrontmatterLines(input.source));
572
+
573
+ const normalizedBody = hasFrontmatter ? body : input.content;
574
+ return {
575
+ content:
576
+ `---\n${nextLines.join("\n")}\n---\n\n${normalizedBody.trimStart()}`.trimEnd() +
577
+ "\n",
578
+ tags: mergedTags,
579
+ };
580
+ }
581
+
582
+ function buildCaptureContent(input: {
583
+ captureInput: CaptureInput;
584
+ title: string;
585
+ tags: string[];
586
+ source: CaptureSource;
587
+ }): { content: string; body: string; tags: string[] } {
588
+ const presetId = input.captureInput.presetId;
589
+ if (presetId && !getNotePreset(presetId)) {
590
+ throw new Error(`Unknown presetId: ${presetId}`);
591
+ }
592
+
593
+ const body = input.captureInput.content;
594
+ if (body !== undefined) {
595
+ validateTextContent(body);
596
+ }
597
+ if (!presetId && (!body || body.trim().length === 0)) {
598
+ throw new Error(
599
+ "Capture content is required unless presetId scaffolds it."
600
+ );
601
+ }
602
+
603
+ const resolvedPreset = resolveNotePreset({
604
+ presetId,
605
+ title: input.title,
606
+ tags: input.tags,
607
+ frontmatter: {
608
+ source: [],
609
+ },
610
+ body,
611
+ });
612
+ const rawContent =
613
+ resolvedPreset?.content ?? body ?? `# ${input.title || "Untitled"}\n`;
614
+ const merged = mergeCaptureFrontmatterAndTags({
615
+ content: rawContent,
616
+ source: input.source,
617
+ tags: resolvedPreset?.tags ?? input.tags,
618
+ title: input.title,
619
+ });
620
+ return {
621
+ content: merged.content,
622
+ body: body ?? resolvedPreset?.body ?? "",
623
+ tags: merged.tags,
624
+ };
625
+ }
626
+
627
+ export function planCapture(options: PlanCaptureOptions): CapturePlan {
628
+ const capturedAt = (options.now ?? new Date()).toISOString();
629
+ const source = normalizeSource(options.input.source, capturedAt);
630
+ const title = chooseTitle(options.input, "Captured Note");
631
+ const tags = normalizeCaptureTags(options.input.tags);
632
+ const {
633
+ content,
634
+ body,
635
+ tags: contentTags,
636
+ } = buildCaptureContent({
637
+ captureInput: options.input,
638
+ title,
639
+ tags,
640
+ source,
641
+ });
642
+ const contentHash = hashCaptureContent(body || content);
643
+ const generatedRelPath =
644
+ !options.input.relPath && !options.input.folderPath && !options.input.title
645
+ ? generatedCaptureRelPath(source.capturedAt, contentHash)
646
+ : undefined;
647
+ const existing = buildExistingSet(
648
+ options.existingRelPaths,
649
+ options.diskRelPaths
650
+ );
651
+ const collisionPolicy = normalizeCollisionPolicy(
652
+ options.input.collisionPolicy,
653
+ generatedRelPath ? "open_existing" : "error"
654
+ );
655
+ const overwrite = options.input.overwrite === true;
656
+ const createPlan = resolveNoteCreatePlan(
657
+ {
658
+ collection: options.input.collection,
659
+ relPath: options.input.relPath ?? generatedRelPath,
660
+ title,
661
+ folderPath: options.input.folderPath,
662
+ collisionPolicy: overwrite ? "error" : collisionPolicy,
663
+ },
664
+ overwrite ? [] : existing
665
+ );
666
+ const overwritten = overwrite && existing.has(createPlan.relPath);
667
+
668
+ return {
669
+ collection: options.input.collection,
670
+ relPath: createPlan.relPath,
671
+ filename: createPlan.filename,
672
+ content,
673
+ body,
674
+ contentHash,
675
+ title,
676
+ tags: contentTags,
677
+ source,
678
+ openedExisting: createPlan.openedExisting,
679
+ createdWithSuffix: createPlan.createdWithSuffix,
680
+ collisionPolicy,
681
+ collisionPolicyResult: overwritten
682
+ ? "overwritten"
683
+ : createPlan.openedExisting
684
+ ? "opened_existing"
685
+ : createPlan.createdWithSuffix
686
+ ? "created_with_suffix"
687
+ : "created",
688
+ overwrite,
689
+ };
690
+ }
691
+
692
+ export function buildCaptureReceipt(input: {
693
+ plan: CapturePlan;
694
+ absPath?: string;
695
+ docid?: string;
696
+ sync?: CaptureIndexStatus;
697
+ embed?: CaptureIndexStatus;
698
+ overwritten?: boolean;
699
+ serverInstanceId?: string;
700
+ }): CaptureReceipt {
701
+ const overwritten = input.overwritten ?? false;
702
+ return {
703
+ uri: buildUri(input.plan.collection, input.plan.relPath),
704
+ docid: input.docid,
705
+ collection: input.plan.collection,
706
+ relPath: input.plan.relPath,
707
+ absPath: input.absPath,
708
+ created: !input.plan.openedExisting && !overwritten,
709
+ openedExisting: input.plan.openedExisting,
710
+ createdWithSuffix: input.plan.createdWithSuffix,
711
+ overwritten,
712
+ contentHash: input.plan.contentHash,
713
+ source: input.plan.source,
714
+ tags: input.plan.tags,
715
+ sync: input.sync ?? { status: "not_requested" },
716
+ embed: input.embed ?? {
717
+ status: "not_requested",
718
+ reason: "Capture does not embed automatically.",
719
+ },
720
+ collisionPolicyResult: overwritten
721
+ ? "overwritten"
722
+ : input.plan.collisionPolicyResult,
723
+ serverInstanceId: input.serverInstanceId,
724
+ };
725
+ }
726
+
727
+ export function buildLegacyEditableCopySource(input: {
728
+ docid: string;
729
+ uri: string;
730
+ mime: string;
731
+ ext: string;
732
+ capturedAt?: string;
733
+ }): CaptureSource {
734
+ return {
735
+ kind: "file",
736
+ docid: input.docid,
737
+ uri: input.uri,
738
+ mime: input.mime,
739
+ ext: input.ext,
740
+ capturedAt: input.capturedAt ?? new Date().toISOString(),
741
+ };
742
+ }
743
+
744
+ export function serializeCaptureReceipt(receipt: CaptureReceipt): string {
745
+ return JSON.stringify(receipt, null, 2);
746
+ }