@openparachute/vault 0.6.0-rc.1 → 0.6.1
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.
- package/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/content-range-routes.test.ts +178 -0
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- 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
|
|
29
|
-
*
|
|
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
|
|
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
|
-
*
|
|
221
|
-
*
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 (
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|