@openparachute/vault 0.6.0-rc.1 → 0.6.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 (91) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +7 -7
  3. package/core/src/core.test.ts +279 -26
  4. package/core/src/expand-visibility.test.ts +102 -0
  5. package/core/src/expand.ts +31 -3
  6. package/core/src/indexed-fields.ts +1 -1
  7. package/core/src/link-count.test.ts +301 -0
  8. package/core/src/links.ts +97 -2
  9. package/core/src/mcp.ts +201 -33
  10. package/core/src/notes.ts +44 -8
  11. package/core/src/obsidian-alignment.test.ts +375 -0
  12. package/core/src/obsidian.ts +234 -14
  13. package/core/src/portable-md.test.ts +40 -0
  14. package/core/src/portable-md.ts +142 -16
  15. package/core/src/schema.ts +58 -11
  16. package/core/src/store.ts +69 -22
  17. package/core/src/tag-expand-axis.test.ts +301 -0
  18. package/core/src/tag-hierarchy.ts +80 -0
  19. package/core/src/tag-schemas.ts +61 -46
  20. package/core/src/triggers-store.test.ts +100 -0
  21. package/core/src/triggers-store.ts +165 -0
  22. package/core/src/types.ts +68 -4
  23. package/core/src/vault-projection.ts +20 -0
  24. package/core/src/wikilinks.ts +2 -2
  25. package/package.json +2 -3
  26. package/src/admin-spa.test.ts +100 -10
  27. package/src/admin-spa.ts +48 -3
  28. package/src/auth-hub-jwt.test.ts +8 -1
  29. package/src/auth-status.ts +2 -2
  30. package/src/auth.test.ts +39 -3
  31. package/src/auth.ts +31 -2
  32. package/src/auto-transcribe.test.ts +51 -0
  33. package/src/auto-transcribe.ts +24 -6
  34. package/src/autostart.test.ts +75 -0
  35. package/src/autostart.ts +84 -0
  36. package/src/cli.ts +434 -140
  37. package/src/config.test.ts +109 -0
  38. package/src/config.ts +157 -10
  39. package/src/export-watch.test.ts +23 -0
  40. package/src/export-watch.ts +14 -0
  41. package/src/git-preflight.test.ts +70 -0
  42. package/src/git-preflight.ts +68 -0
  43. package/src/hub-jwt.test.ts +75 -2
  44. package/src/hub-jwt.ts +43 -6
  45. package/src/init-summary.test.ts +120 -5
  46. package/src/init-summary.ts +67 -25
  47. package/src/live-match.test.ts +198 -0
  48. package/src/live-match.ts +310 -0
  49. package/src/mcp-install.test.ts +93 -0
  50. package/src/mcp-install.ts +106 -0
  51. package/src/mcp-tools.ts +80 -7
  52. package/src/mirror-config.test.ts +14 -0
  53. package/src/mirror-config.ts +11 -0
  54. package/src/mirror-import.test.ts +110 -0
  55. package/src/mirror-import.ts +71 -13
  56. package/src/mirror-manager.test.ts +51 -0
  57. package/src/mirror-manager.ts +73 -11
  58. package/src/mirror-routes.test.ts +463 -1
  59. package/src/mirror-routes.ts +474 -4
  60. package/src/oauth-discovery.test.ts +55 -0
  61. package/src/oauth-discovery.ts +24 -5
  62. package/src/routes.ts +696 -121
  63. package/src/routing.test.ts +451 -5
  64. package/src/routing.ts +113 -5
  65. package/src/scopes.ts +1 -1
  66. package/src/server.ts +66 -4
  67. package/src/storage.test.ts +162 -0
  68. package/src/subscribe.test.ts +588 -0
  69. package/src/subscribe.ts +248 -0
  70. package/src/subscriptions.ts +295 -0
  71. package/src/tag-expand-routes.test.ts +45 -0
  72. package/src/tag-scope.ts +68 -1
  73. package/src/token-store.ts +7 -7
  74. package/src/transcription-worker.test.ts +471 -5
  75. package/src/transcription-worker.ts +212 -44
  76. package/src/triggers-api.test.ts +533 -0
  77. package/src/triggers-api.ts +295 -0
  78. package/src/triggers.ts +93 -7
  79. package/src/usage.test.ts +362 -0
  80. package/src/usage.ts +318 -0
  81. package/src/vault-create.test.ts +340 -12
  82. package/src/vault-name.test.ts +61 -3
  83. package/src/vault-name.ts +62 -14
  84. package/src/vault-remove.test.ts +187 -0
  85. package/src/vault-store.ts +10 -3
  86. package/src/vault.test.ts +1353 -62
  87. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  88. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  89. package/web/ui/dist/index.html +2 -2
  90. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  91. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -25,8 +25,11 @@
25
25
  * (Whisper API shape). Response is `{ text: string }`.
26
26
  * 3. On success:
27
27
  * - If `note.metadata.transcribe_stub === true`, replace the
28
- * `_Transcript pending._` placeholder with the transcript, or the
29
- * whole note body if the placeholder is absent. Clear the stub marker.
28
+ * `_Transcript pending._` placeholder (or a prior `_Transcription
29
+ * unavailable._` failure marker, on a retry) with the transcript. If
30
+ * neither marker is present (user edited the note while pending),
31
+ * APPEND the transcript rather than overwriting the body. Clear the
32
+ * stub marker.
30
33
  * - Mark `attachment.metadata.transcribe_status = "done"` and record
31
34
  * `transcript` + `transcribe_done_at`.
32
35
  * - If the vault's `audio_retention` is `"until_transcribed"`, unlink
@@ -50,13 +53,13 @@
50
53
 
51
54
  import { join, normalize } from "path";
52
55
  import { existsSync, readFileSync, unlinkSync } from "fs";
53
- import type { Store, Attachment } from "../core/src/types.ts";
56
+ import type { Store, Attachment, Note } from "../core/src/types.ts";
54
57
  import type { HookRegistry } from "../core/src/hooks.ts";
55
58
  import { appendContextPart, fetchContextEntries, type ContextPayload } from "./context.ts";
56
59
  import type { TriggerIncludeContext } from "./config.ts";
57
60
  import { upsertTranscriptNote } from "./transcript-note.ts";
58
61
 
59
- /** Placeholder pattern written by Lens's voice-memo stub. */
62
+ /** Placeholder pattern written by the voice-memo capture stub. */
60
63
  const TRANSCRIPT_PLACEHOLDER = /_Transcript pending\._/;
61
64
 
62
65
  /**
@@ -65,9 +68,33 @@ const TRANSCRIPT_PLACEHOLDER = /_Transcript pending\._/;
65
68
  * Lens's now-removed scribe client; owning it here means a failed upload
66
69
  * stops reading "Transcript pending" forever regardless of which client
67
70
  * uploaded the audio.
71
+ *
72
+ * NOTE: the notes-ui status chip (parachute-surface TranscriptionStatus.tsx)
73
+ * keys off this exact string, so don't change the copy without a coordinated
74
+ * change there. A friendlier "retry available" copy + chip affordance is a
75
+ * tracked parachute-surface follow-up.
68
76
  */
69
77
  const TRANSCRIPT_UNAVAILABLE = "_Transcription unavailable._";
70
78
 
79
+ /**
80
+ * On a successful (re)transcription of a legacy in-body memo, the transcript
81
+ * replaces whichever marker is currently in the body — the original
82
+ * `_Transcript pending._` on a first-try success, OR `_Transcription
83
+ * unavailable._` if a prior attempt failed and we're now retrying. Matching
84
+ * both means a retried success lands in the same spot a first-try success
85
+ * would, preserving the surrounding capture body (the `![[memo]]` embed,
86
+ * the `_Recorded …_` line, the header).
87
+ *
88
+ * Deliberately NO `/g` flag — `.replace` swaps only the FIRST match. A
89
+ * canonical capture body holds exactly one marker, so first-match is the
90
+ * correct target. `applyFailureMarker`'s includes-guard (no-op when the
91
+ * marker is already present) prevents markers accumulating across repeated
92
+ * terminal failures, so the body never carries two of the same marker. A
93
+ * hand-edited body that somehow contains both markers patches only the
94
+ * first — accepted (degenerate, operator-induced).
95
+ */
96
+ const TRANSCRIPT_SUCCESS_TARGET = /_Transcript pending\._|_Transcription unavailable\._/;
97
+
71
98
  /**
72
99
  * Default sweep cadence (ms). The sweep is the safety net for backoff-
73
100
  * queued items, items that arrived while the server was down, or dispatches
@@ -202,6 +229,100 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
202
229
  */
203
230
  const inFlightAttachments = new Set<string>();
204
231
 
232
+ /**
233
+ * Apply a surgical note transform under optimistic concurrency (vault#435).
234
+ *
235
+ * The worker's marker/transcript writes are read-modify-write cycles
236
+ * (`getNote` → transform → `updateNote`). Without a precondition, a user
237
+ * edit landing between the read and the write is silently clobbered —
238
+ * the same static-write/stale-read class as vault#208.
239
+ *
240
+ * `transform(note)` returns the surgical update to apply (`content` and/or
241
+ * `metadata`), or `null` when the fresh state means there's nothing to do
242
+ * (e.g. the stub was cleared, or the marker is already present — the
243
+ * idempotency guards from #434 live inside the transform, so they re-run
244
+ * against whatever we re-read). The transform MUST be pure w.r.t. the note
245
+ * it's handed — it's invoked once per read, and re-invoked on the fresh
246
+ * read after a conflict.
247
+ *
248
+ * Policy on conflict (worker = resilient, never crash the sweep):
249
+ * 1. First write conflicts → re-read, re-run the transform against fresh
250
+ * content, write with the fresh precondition.
251
+ * 2. Second write also conflicts → fall back to a precondition-less write
252
+ * ONLY when `safeWithoutPrecondition(freshNote)` says the transform is
253
+ * still safe against the latest content (e.g. the surgical-replace
254
+ * target is still present, or an append is always-safe). Otherwise
255
+ * skip + log — better to leave the note as the user last left it than
256
+ * to blind-overwrite a third concurrent edit.
257
+ *
258
+ * All errors are logged + swallowed: a note-write failure must not mask the
259
+ * attachment-level result we already recorded, nor crash the sweep.
260
+ */
261
+ async function applyNoteTransformWithOC(
262
+ store: Store,
263
+ noteId: string,
264
+ op: string,
265
+ transform: (note: Note) => { content?: string; metadata?: Record<string, unknown> } | null,
266
+ safeWithoutPrecondition: (note: Note) => boolean,
267
+ ): Promise<void> {
268
+ try {
269
+ const note = await store.getNote(noteId);
270
+ if (!note) return;
271
+ const update = transform(note);
272
+ if (update === null) return;
273
+
274
+ try {
275
+ await store.updateNote(note.id, {
276
+ ...update,
277
+ skipUpdatedAt: true,
278
+ if_updated_at: note.updatedAt,
279
+ });
280
+ return;
281
+ } catch (err: any) {
282
+ if (!err || err.code !== "CONFLICT") throw err;
283
+ }
284
+
285
+ // Conflict — a user edit landed between read and write. Re-read,
286
+ // re-apply the same surgical transform against the fresh content, and
287
+ // write with the fresh precondition.
288
+ const fresh = await store.getNote(noteId);
289
+ if (!fresh) return;
290
+ const reUpdate = transform(fresh);
291
+ if (reUpdate === null) return;
292
+
293
+ try {
294
+ await store.updateNote(fresh.id, {
295
+ ...reUpdate,
296
+ skipUpdatedAt: true,
297
+ if_updated_at: fresh.updatedAt,
298
+ });
299
+ return;
300
+ } catch (err: any) {
301
+ if (!err || err.code !== "CONFLICT") throw err;
302
+ }
303
+
304
+ // Double conflict (a third edit raced the retry). Last resort: apply
305
+ // without a precondition ONLY if the transform is still safe against
306
+ // the latest content. Otherwise skip — don't clobber the user.
307
+ const latest = await store.getNote(noteId);
308
+ if (!latest) return;
309
+ if (!safeWithoutPrecondition(latest)) {
310
+ logger.error(
311
+ `[transcribe] ${op}: note ${noteId} kept changing under us (double conflict); skipping to avoid clobbering a concurrent edit`,
312
+ );
313
+ return;
314
+ }
315
+ const finalUpdate = transform(latest);
316
+ if (finalUpdate === null) return;
317
+ await store.updateNote(latest.id, {
318
+ ...finalUpdate,
319
+ skipUpdatedAt: true,
320
+ });
321
+ } catch (err) {
322
+ logger.error(`[transcribe] ${op}: failed to apply to note ${noteId}:`, err);
323
+ }
324
+ }
325
+
205
326
  async function processOne(vault: string, attachment: Attachment): Promise<void> {
206
327
  // Dedupe: another path (sweep vs hook kick, or a duplicate dispatch)
207
328
  // is already working this attachment. Drop — its result is durable
@@ -217,33 +338,58 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
217
338
 
218
339
  /**
219
340
  * On a terminal failure (maxAttempts exhausted, or audio file missing),
220
- * swap the stub placeholder for the "unavailable" marker — otherwise
221
- * Lens's voice memo sits reading "Transcript pending" forever. Mirrors
222
- * the success-path note write in shape: only touches the note when
341
+ * record the "unavailable" marker on the note — otherwise the voice memo
342
+ * sits reading "Transcript pending" forever. Only touches the note when
223
343
  * `transcribe_stub === true`, clears the stub marker, uses `skipUpdatedAt`
224
344
  * so the note's modification time still reflects user intent. Errors
225
345
  * are logged and swallowed so a note-write failure doesn't mask the
226
346
  * attachment failure we're trying to record.
347
+ *
348
+ * Body policy (finding F — never destroy content):
349
+ * - Placeholder PRESENT → surgical replace of `_Transcript pending._`
350
+ * with the marker. The `![[memo]]` embed + any surrounding text survive.
351
+ * - Marker ALREADY PRESENT → no-op (idempotent; a double-terminal-failure
352
+ * must not stack markers).
353
+ * - Otherwise (placeholder absent — the user edited the note while it was
354
+ * pending) → APPEND `\n\n` + marker to the existing content. The old
355
+ * code full-replaced the body here, destroying the embed AND the user's
356
+ * edits. We append instead so nothing is lost. If the content is empty,
357
+ * the marker alone becomes the body (avoids a leading blank line).
227
358
  */
228
359
  async function applyFailureMarker(store: Store, noteId: string): Promise<void> {
229
- const note = await store.getNote(noteId);
230
- if (!note) return;
231
- const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
232
- if (noteMeta.transcribe_stub !== true) return;
233
-
234
- const body = TRANSCRIPT_PLACEHOLDER.test(note.content)
235
- ? note.content.replace(TRANSCRIPT_PLACEHOLDER, TRANSCRIPT_UNAVAILABLE)
236
- : TRANSCRIPT_UNAVAILABLE;
237
- const { transcribe_stub: _drop, ...restMeta } = noteMeta;
238
- try {
239
- await store.updateNote(note.id, {
240
- content: body,
241
- metadata: restMeta,
242
- skipUpdatedAt: true,
243
- });
244
- } catch (err) {
245
- logger.error(`[transcribe] failed to apply failure marker to note ${note.id}:`, err);
246
- }
360
+ // OC-guarded (vault#435): the read-transform-write below is re-run against
361
+ // fresh content on a conflict so a concurrent user edit isn't clobbered.
362
+ // The transform is pure w.r.t. the note it's handed; the stub-set and
363
+ // marker-already-present idempotency guards re-evaluate on the re-read.
364
+ await applyNoteTransformWithOC(
365
+ store,
366
+ noteId,
367
+ "apply-failure-marker",
368
+ (note) => {
369
+ const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
370
+ if (noteMeta.transcribe_stub !== true) return null;
371
+
372
+ let body: string;
373
+ if (TRANSCRIPT_PLACEHOLDER.test(note.content)) {
374
+ body = note.content.replace(TRANSCRIPT_PLACEHOLDER, TRANSCRIPT_UNAVAILABLE);
375
+ } else if (note.content.includes(TRANSCRIPT_UNAVAILABLE)) {
376
+ // Marker already present nothing to do. Clear the stub and
377
+ // return without rewriting the body so we don't stack markers.
378
+ body = note.content;
379
+ } else {
380
+ body = note.content.length > 0
381
+ ? `${note.content}\n\n${TRANSCRIPT_UNAVAILABLE}`
382
+ : TRANSCRIPT_UNAVAILABLE;
383
+ }
384
+ const { transcribe_stub: _drop, ...restMeta } = noteMeta;
385
+ return { content: body, metadata: restMeta };
386
+ },
387
+ // Last-resort (double-conflict) safety: only blind-write while the note
388
+ // still carries the stub opt-in. If a racing edit cleared it, the user
389
+ // opted out — skip rather than re-stamp the marker. The body transform
390
+ // itself is non-destructive (surgical replace / no-op / append).
391
+ (note) => ((note.metadata as Record<string, unknown> | undefined)?.transcribe_stub === true),
392
+ );
247
393
  }
248
394
 
249
395
  /**
@@ -411,26 +557,48 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
411
557
  logger.error(`[transcribe] failed to write transcript note for attachment ${attachment.id}:`, err);
412
558
  }
413
559
  } else {
414
- // Legacy stub-patching path (Lens voice memo flow).
415
- const note = await store.getNote(attachment.noteId);
416
- if (note) {
417
- const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
418
- if (noteMeta.transcribe_stub === true) {
419
- const body = TRANSCRIPT_PLACEHOLDER.test(note.content)
420
- ? note.content.replace(TRANSCRIPT_PLACEHOLDER, transcript)
421
- : transcript;
422
- const { transcribe_stub: _drop, ...restMeta } = noteMeta;
423
- try {
424
- await store.updateNote(note.id, {
425
- content: body,
426
- metadata: restMeta,
427
- skipUpdatedAt: true,
428
- });
429
- } catch (err) {
430
- logger.error(`[transcribe] failed to apply transcript to note ${note.id}:`, err);
560
+ // Legacy stub-patching path (voice memo flow). Only acts when the note
561
+ // still carries the `transcribe_stub` opt-in — a user edit clearing it
562
+ // before the transcript arrives opts out of the overwrite. OC-guarded
563
+ // (vault#435): re-applied against fresh content on a conflict so a
564
+ // concurrent user edit isn't clobbered.
565
+ await applyNoteTransformWithOC(
566
+ store,
567
+ attachment.noteId,
568
+ "apply-transcript",
569
+ (note) => {
570
+ const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
571
+ if (noteMeta.transcribe_stub !== true) return null;
572
+ // Body policy (finding F — never destroy content):
573
+ // - placeholder OR failure-marker present → surgical replace in
574
+ // place (a retried success replaces the `_Transcription
575
+ // unavailable._` marker, landing exactly where a first-try
576
+ // success would). The embed + surrounding capture body survive.
577
+ // - neither present (user edited the note while pending) → APPEND
578
+ // the transcript instead of full-replacing the body, so the
579
+ // user's edits + the `![[memo]]` embed are preserved. The old
580
+ // code full-replaced here, which destroyed both.
581
+ let body: string;
582
+ if (TRANSCRIPT_SUCCESS_TARGET.test(note.content)) {
583
+ // Function replacer, NOT a string — speech-to-text is arbitrary
584
+ // user content, and String.replace treats `$&`, `$\``, `$'`,
585
+ // `$1`-`$9` as special patterns in a string replacement. A
586
+ // transcript containing `$&` would otherwise inject the matched
587
+ // marker text into the body. `() => transcript` returns the text
588
+ // verbatim.
589
+ body = note.content.replace(TRANSCRIPT_SUCCESS_TARGET, () => transcript);
590
+ } else {
591
+ body = note.content.length > 0
592
+ ? `${note.content}\n\n${transcript}`
593
+ : transcript;
431
594
  }
432
- }
433
- }
595
+ const { transcribe_stub: _drop, ...restMeta } = noteMeta;
596
+ return { content: body, metadata: restMeta };
597
+ },
598
+ // Last-resort (double-conflict) safety: only blind-write while the
599
+ // stub opt-in survives. A racing edit that cleared it opts out.
600
+ (note) => ((note.metadata as Record<string, unknown> | undefined)?.transcribe_stub === true),
601
+ );
434
602
  }
435
603
 
436
604
  // Always record the transcript on the attachment, even if the note