@openparachute/vault 0.7.9-rc.4 → 0.7.9-rc.5

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/core/src/types.ts CHANGED
@@ -7,6 +7,7 @@ import type { ValidationStatus } from "./schema-defaults.js";
7
7
  import type { ConformanceReport } from "./conformance.js";
8
8
  import type { FindPathResult } from "./links.js";
9
9
  import type { DoctorReport, DoctorScanOpts } from "./doctor.js";
10
+ import type { CompactResult, CompactSummary, HistoryOp, VersionRow } from "./history.js";
10
11
 
11
12
  // ---- Re-exports ----
12
13
 
@@ -481,7 +482,28 @@ export interface Store {
481
482
  */
482
483
  getNoteByPath(path: string, extension?: string): Promise<Note | null>;
483
484
  getNotes(ids: string[]): Promise<Note[]>;
484
- updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean; actor?: string | null; via?: string | null; if_updated_at?: string; tagsForSchemaResolution?: string[] }): Promise<Note>;
485
+ updateNote(id: string, updates: { content?: string; append?: string; prepend?: string; path?: string; extension?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean;
486
+ /**
487
+ * State-transition compare-and-set (vault#299 Part B). DECLARED here as
488
+ * of vault#524 — it always arrived at runtime (src/routes.ts:3214 sets
489
+ * it on a `const updates: any = {}` at :3167 and passes it at :3257, and
490
+ * noteOps.updateNote has read it at notes.ts:708 since #299) but it was
491
+ * never on the declared type, so an object LITERAL carrying it did not
492
+ * compile. Declaring it is what lets a core test construct the shape
493
+ * directly (P1, P11b) instead of laundering it through `any`.
494
+ */
495
+ state_transition?: { field: string; from: unknown; to: unknown };
496
+ /**
497
+ * INTERNAL — the `op` stamped on the version row this write captures
498
+ * (vault#524). Set ONLY by Store.restoreNoteVersion, to "restore".
499
+ * noteOps.updateNote ignores it (it reads named fields, never
500
+ * Object.keys), so it never reaches SQL. Do NOT accept it from REST or
501
+ * MCP: src/routes.ts's PATCH handler builds `updates` field by field and
502
+ * must not copy it out of the request body, and core/src/mcp.ts's
503
+ * update-note executor must not either. §10.24 pins that.
504
+ */
505
+ historyOp?: HistoryOp;
506
+ actor?: string | null; via?: string | null; if_updated_at?: string; tagsForSchemaResolution?: string[] }): Promise<Note>;
485
507
  /**
486
508
  * Set a note's `created_at` and `updated_at` explicitly. Import-only:
487
509
  * used by the portable-md round-trip path to restore timestamps from
@@ -497,7 +519,17 @@ export interface Store {
497
519
  * content. Returns counts for caller logging.
498
520
  */
499
521
  syncAllWikilinks(): Promise<{ synced: number; totalAdded: number; totalRemoved: number }>;
500
- deleteNote(id: string): Promise<void>;
522
+ /** vault#524: deletion captures a tombstone unless a blow-away import opts out. */
523
+ deleteNote(id: string, opts?: { actor?: string | null; via?: string | null; captureHistory?: boolean }): Promise<void>;
524
+ listNoteVersions(id: string, opts?: { limit?: number; offset?: number }): Promise<VersionRow[]>;
525
+ getNoteVersion(id: string, versionIx: number): Promise<(VersionRow & { content: string | null }) | null>;
526
+ restoreNoteVersion(id: string, versionIx: number, opts: { actor?: string | null; via?: string | null; if_updated_at?: string }): Promise<Note>;
527
+ eraseNoteHistory(id: string): Promise<{ versionsDeleted: number; blobsDeleted: number }>;
528
+ sweepDeletedHistory(): { notesSwept: number; versionsDeleted: number; blobsDeleted: number };
529
+ countNoteVersions(id: string): Promise<number>;
530
+ compactNote(id: string): CompactResult;
531
+ compactHistory(opts?: { noteId?: string; budgetMs?: number | null; maxNotes?: number | null }): CompactSummary;
532
+ deletedHistoryStats(): Promise<{ notes: number; versions: number; bytes: number; overflow_tombstones: number }>;
501
533
  queryNotes(opts: QueryOpts): Promise<Note[]>;
502
534
  /**
503
535
  * Cursor-paginated `queryNotes` (vault#313). Returns the same notes plus
@@ -601,6 +633,7 @@ export interface Store {
601
633
  renameTag(
602
634
  oldName: string,
603
635
  newName: string,
636
+ opts?: { actor?: string; via?: string },
604
637
  ): Promise<
605
638
  | {
606
639
  renamed: number;
@@ -0,0 +1,472 @@
1
+ // @ts-nocheck
2
+ /*! (c) Dmitry Chestnykh, D. Richard Hipp | BSD License | https://github.com/dchest/fossil-delta-js/ */
3
+ // Vendored from fossil-delta@2.0.0 (npm sha512-rcvnd0xjV7KNkEbXhTCsjHhONvdami+WWhTYGf4ORkPk9ixkQDmVeII96F6wBD/6qa9zBjH15bOzSARKsQ/rKg==).
4
+ // Upstream fossil-delta.ts sha256 29d795837d08ff9305e145d20611e721e52aa29969c10c39c8f9d3d51be67b6c.
5
+ // Local path: core/src/vendor/fossil-delta.ts. License: BSD 2-Clause.
6
+ // CHANGES FROM UPSTREAM:
7
+ // 1. createStringDelta / applyStringDelta / getStringDeltaTargetSize are
8
+ // DELETED. They decode raw delta bytes with TextDecoder (upstream
9
+ // :455-461, :466-477), which replaces any non-UTF-8 byte run with U+FFFD.
10
+ // MEASURED on this box: with a MID-BODY EDIT on an emoji body,
11
+ // createStringDelta round-trips throw `unknown delta operator` 189 times
12
+ // in 200 randomised cases. A pure APPEND on the same alphabet round-trips
13
+ // fine, which is why the trigger is the edit shape, not the alphabet.
14
+ // 2. This provenance header.
15
+ // 3. @ts-nocheck added as line 1: upstream does not typecheck under
16
+ // noUncheckedIndexedAccess / noFallthroughCasesInSwitch.
17
+ // Why vendored rather than a dependency: core/package.json declares no
18
+ // dependencies at all, core never imports a third-party library by house rule
19
+ // (see BunSqliteStore.embeddingProvider's doc comment, core/src/store.ts:
20
+ // "core never imports a model library itself (dependency-purity rule)"), and
21
+ // PR 1 §1 probe E set the precedent by refusing @noble/hashes. The Fossil
22
+ // delta format is a frozen, documented wire format, not a moving target.
23
+
24
+
25
+ // Fossil SCM delta compression algorithm
26
+
27
+ // We accept plain arrays of bytes or Uint8Array.
28
+ export type ByteArray = number[] | Uint8Array;
29
+
30
+ // Hash window width in bytes. Must be a power of two.
31
+ const NHASH = 16;
32
+
33
+ class RollingHash {
34
+ private a = 0; // hash (16-bit unsigned)
35
+ private b = 0; // values (16-bit unsigned)
36
+ private i = 0; // start of the hash window (16-bit unsigned)
37
+ private z = new Array(NHASH); // the values that have been hashed.
38
+
39
+ // Initialize the rolling hash using the first NHASH bytes of
40
+ // z at the given position.
41
+ init(z: ByteArray, pos: number) {
42
+ let a = 0,
43
+ b = 0;
44
+ for (let i = 0; i < NHASH; i++) {
45
+ const x = z[pos + i];
46
+ a = (a + x) & 0xffff;
47
+ b = (b + (NHASH - i) * x) & 0xffff;
48
+ this.z[i] = x;
49
+ }
50
+ this.a = a & 0xffff;
51
+ this.b = b & 0xffff;
52
+ this.i = 0;
53
+ }
54
+
55
+ // Advance the rolling hash by a single byte "c".
56
+ next(c: number) {
57
+ const old = this.z[this.i];
58
+ this.z[this.i] = c;
59
+ this.i = (this.i + 1) & (NHASH - 1);
60
+ this.a = (this.a - old + c) & 0xffff;
61
+ this.b = (this.b - NHASH * old + this.a) & 0xffff;
62
+ }
63
+
64
+ // Return a 32-bit hash value.
65
+ value() {
66
+ return ((this.a & 0xffff) | ((this.b & 0xffff) << 16)) >>> 0;
67
+ }
68
+ }
69
+
70
+ const zDigits =
71
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz~"
72
+ .split("")
73
+ .map(function (x) {
74
+ return x.charCodeAt(0);
75
+ });
76
+
77
+ const zValue = [
78
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
79
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
80
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1,
81
+ -1, -1, -1, -1, -1, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23,
82
+ 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, -1, -1, -1, -1, 36, -1, 37,
83
+ 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56,
84
+ 57, 58, 59, 60, 61, 62, -1, -1, -1, 63, -1,
85
+ ];
86
+
87
+ // Reader reads bytes, chars, ints from array.
88
+ class Reader {
89
+ public a: ByteArray;
90
+ public pos: number;
91
+
92
+ constructor(array: ByteArray) {
93
+ this.a = array; // source array
94
+ this.pos = 0; // current position in array
95
+ }
96
+
97
+ haveBytes() {
98
+ return this.pos < this.a.length;
99
+ }
100
+
101
+ getByte() {
102
+ const b = this.a[this.pos];
103
+ this.pos++;
104
+ if (this.pos > this.a.length) throw new RangeError("out of bounds");
105
+ return b;
106
+ }
107
+
108
+ getChar() {
109
+ return String.fromCharCode(this.getByte());
110
+ }
111
+
112
+ // Read base64-encoded unsigned integer.
113
+ getInt() {
114
+ let v = 0;
115
+ let c: number;
116
+ while (this.haveBytes() && (c = zValue[0x7f & this.getByte()]) >= 0) {
117
+ v = (v << 6) + c;
118
+ }
119
+ this.pos--;
120
+ return v >>> 0;
121
+ }
122
+ }
123
+
124
+ // Write writes an array.
125
+ class Writer {
126
+ private a: number[] = [];
127
+
128
+ toByteArray<T extends ByteArray>(sourceType: T): T {
129
+ if (Array.isArray(sourceType)) {
130
+ return this.a as T;
131
+ }
132
+ return new Uint8Array(this.a) as T;
133
+ }
134
+
135
+ putByte(b: number) {
136
+ this.a.push(b & 0xff);
137
+ }
138
+
139
+ // Write an ASCII character (s is a one-char string).
140
+ putChar(s: string) {
141
+ this.putByte(s.charCodeAt(0));
142
+ }
143
+
144
+ // Write a base64 unsigned integer.
145
+ putInt(v: number) {
146
+ const zBuf: number[] = [];
147
+ if (v === 0) {
148
+ this.putChar("0");
149
+ return;
150
+ }
151
+ let i = 0;
152
+ for (; v > 0; i++, v >>>= 6) {
153
+ zBuf.push(zDigits[v & 0x3f]);
154
+ }
155
+ for (let j = i - 1; j >= 0; j--) {
156
+ this.putByte(zBuf[j]);
157
+ }
158
+ }
159
+
160
+ // Copy from array at start to end.
161
+ putArray(a: ByteArray, start: number, end: number) {
162
+ // TODO: optimize
163
+ for (let i = start; i < end; i++) this.a.push(a[i]);
164
+ }
165
+ }
166
+
167
+ // Return the number digits in the base64 representation of a positive integer.
168
+ function digitCount(v: number) {
169
+ let i: number, x: number;
170
+ for (i = 1, x = 64; v >= x; i++, x <<= 6) {
171
+ /* nothing */
172
+ }
173
+ return i;
174
+ }
175
+
176
+ // Return a 32-bit checksum of the array.
177
+ function checksum(arr: ByteArray): number {
178
+ let sum0 = 0,
179
+ sum1 = 0,
180
+ sum2 = 0,
181
+ sum3 = 0,
182
+ z = 0,
183
+ N = arr.length;
184
+ //TODO measure if this unrolling is helpful.
185
+ while (N >= 16) {
186
+ sum0 = (sum0 + arr[z + 0]) | 0;
187
+ sum1 = (sum1 + arr[z + 1]) | 0;
188
+ sum2 = (sum2 + arr[z + 2]) | 0;
189
+ sum3 = (sum3 + arr[z + 3]) | 0;
190
+
191
+ sum0 = (sum0 + arr[z + 4]) | 0;
192
+ sum1 = (sum1 + arr[z + 5]) | 0;
193
+ sum2 = (sum2 + arr[z + 6]) | 0;
194
+ sum3 = (sum3 + arr[z + 7]) | 0;
195
+
196
+ sum0 = (sum0 + arr[z + 8]) | 0;
197
+ sum1 = (sum1 + arr[z + 9]) | 0;
198
+ sum2 = (sum2 + arr[z + 10]) | 0;
199
+ sum3 = (sum3 + arr[z + 11]) | 0;
200
+
201
+ sum0 = (sum0 + arr[z + 12]) | 0;
202
+ sum1 = (sum1 + arr[z + 13]) | 0;
203
+ sum2 = (sum2 + arr[z + 14]) | 0;
204
+ sum3 = (sum3 + arr[z + 15]) | 0;
205
+
206
+ z += 16;
207
+ N -= 16;
208
+ }
209
+ while (N >= 4) {
210
+ sum0 = (sum0 + arr[z + 0]) | 0;
211
+ sum1 = (sum1 + arr[z + 1]) | 0;
212
+ sum2 = (sum2 + arr[z + 2]) | 0;
213
+ sum3 = (sum3 + arr[z + 3]) | 0;
214
+ z += 4;
215
+ N -= 4;
216
+ }
217
+ sum3 = (((((sum3 + (sum2 << 8)) | 0) + (sum1 << 16)) | 0) + (sum0 << 24)) | 0;
218
+ switch (N) {
219
+ case 3:
220
+ sum3 = (sum3 + (arr[z + 2] << 8)) | 0; /* falls through */
221
+ case 2:
222
+ sum3 = (sum3 + (arr[z + 1] << 16)) | 0; /* falls through */
223
+ case 1:
224
+ sum3 = (sum3 + (arr[z + 0] << 24)) | 0; /* falls through */
225
+ }
226
+ return sum3 >>> 0;
227
+ }
228
+
229
+ /**
230
+ * Create a new delta array of bytes from source byte array to target byte array.
231
+ */
232
+ export function createDelta<T extends ByteArray>(source: T, target: T): T {
233
+ const zDelta = new Writer();
234
+ const lenOut = target.length;
235
+ const lenSrc = source.length;
236
+ let lastRead = -1;
237
+
238
+ zDelta.putInt(lenOut);
239
+ zDelta.putChar("\n");
240
+
241
+ // If the source is very small, it means that we have no
242
+ // chance of ever doing a copy command. Just output a single
243
+ // literal segment for the entire target and exit.
244
+ if (lenSrc <= NHASH) {
245
+ zDelta.putInt(lenOut);
246
+ zDelta.putChar(":");
247
+ zDelta.putArray(target, 0, lenOut);
248
+ zDelta.putInt(checksum(target));
249
+ zDelta.putChar(";");
250
+ return zDelta.toByteArray(source);
251
+ }
252
+
253
+ // Compute the hash table used to locate matching sections in the source.
254
+ const nHash = Math.ceil(lenSrc / NHASH);
255
+ const collide = new Array(nHash);
256
+ const landmark = new Array(nHash);
257
+ for (let i = 0; i < collide.length; i++) {
258
+ collide[i] = -1;
259
+ }
260
+ for (let i = 0; i < landmark.length; i++) {
261
+ landmark[i] = -1;
262
+ }
263
+ let hv: number;
264
+ const h = new RollingHash();
265
+ for (let i = 0; i < lenSrc - NHASH; i += NHASH) {
266
+ h.init(source, i);
267
+ hv = h.value() % nHash;
268
+ collide[i / NHASH] = landmark[hv];
269
+ landmark[hv] = i / NHASH;
270
+ }
271
+
272
+ let base = 0;
273
+ let iSrc: number,
274
+ iBlock: number,
275
+ bestCnt: number,
276
+ bestOfst: number,
277
+ bestLitsz: number;
278
+ while (base + NHASH < lenOut) {
279
+ bestOfst = 0;
280
+ bestLitsz = 0;
281
+ h.init(target, base);
282
+ let i = 0; // Trying to match a landmark against zOut[base+i]
283
+ bestCnt = 0;
284
+ while (1) {
285
+ let limit = 250;
286
+ hv = h.value() % nHash;
287
+ iBlock = landmark[hv];
288
+ while (iBlock >= 0 && limit-- > 0) {
289
+ //
290
+ // The hash window has identified a potential match against
291
+ // landmark block iBlock. But we need to investigate further.
292
+ //
293
+ // Look for a region in zOut that matches zSrc. Anchor the search
294
+ // at zSrc[iSrc] and zOut[base+i]. Do not include anything prior to
295
+ // zOut[base] or after zOut[outLen] nor anything after zSrc[srcLen].
296
+ //
297
+ // Set cnt equal to the length of the match and set ofst so that
298
+ // zSrc[ofst] is the first element of the match. litsz is the number
299
+ // of characters between zOut[base] and the beginning of the match.
300
+ // sz will be the overhead (in bytes) needed to encode the copy
301
+ // command. Only generate copy command if the overhead of the
302
+ // copy command is less than the amount of literal text to be copied.
303
+ //
304
+ let cnt: number, ofst: number, litsz: number;
305
+ let j: number, k: number, x: number, y: number;
306
+ let sz: number;
307
+
308
+ // Beginning at iSrc, match forwards as far as we can.
309
+ // j counts the number of characters that match.
310
+ iSrc = iBlock * NHASH;
311
+ for (
312
+ j = 0, x = iSrc, y = base + i;
313
+ x < lenSrc && y < lenOut;
314
+ j++, x++, y++
315
+ ) {
316
+ if (source[x] !== target[y]) break;
317
+ }
318
+ j--;
319
+
320
+ // Beginning at iSrc-1, match backwards as far as we can.
321
+ // k counts the number of characters that match.
322
+ for (k = 1; k < iSrc && k <= i; k++) {
323
+ if (source[iSrc - k] !== target[base + i - k]) break;
324
+ }
325
+ k--;
326
+
327
+ // Compute the offset and size of the matching region.
328
+ ofst = iSrc - k;
329
+ cnt = j + k + 1;
330
+ litsz = i - k; // Number of bytes of literal text before the copy
331
+ // sz will hold the number of bytes needed to encode the "insert"
332
+ // command and the copy command, not counting the "insert" text.
333
+ sz = digitCount(i - k) + digitCount(cnt) + digitCount(ofst) + 3;
334
+ if (cnt >= sz && cnt > bestCnt) {
335
+ // Remember this match only if it is the best so far and it
336
+ // does not increase the file size.
337
+ bestCnt = cnt;
338
+ bestOfst = iSrc - k;
339
+ bestLitsz = litsz;
340
+ }
341
+
342
+ // Check the next matching block
343
+ iBlock = collide[iBlock];
344
+ }
345
+
346
+ // We have a copy command that does not cause the delta to be larger
347
+ // than a literal insert. So add the copy command to the delta.
348
+ if (bestCnt > 0) {
349
+ if (bestLitsz > 0) {
350
+ // Add an insert command before the copy.
351
+ zDelta.putInt(bestLitsz);
352
+ zDelta.putChar(":");
353
+ zDelta.putArray(target, base, base + bestLitsz);
354
+ base += bestLitsz;
355
+ }
356
+ base += bestCnt;
357
+ zDelta.putInt(bestCnt);
358
+ zDelta.putChar("@");
359
+ zDelta.putInt(bestOfst);
360
+ zDelta.putChar(",");
361
+ if (bestOfst + bestCnt - 1 > lastRead) {
362
+ lastRead = bestOfst + bestCnt - 1;
363
+ }
364
+ bestCnt = 0;
365
+ break;
366
+ }
367
+
368
+ // If we reach this point, it means no match is found so far
369
+ if (base + i + NHASH >= lenOut) {
370
+ // We have reached the end and have not found any
371
+ // matches. Do an "insert" for everything that does not match
372
+ zDelta.putInt(lenOut - base);
373
+ zDelta.putChar(":");
374
+ zDelta.putArray(target, base, base + lenOut - base);
375
+ base = lenOut;
376
+ break;
377
+ }
378
+
379
+ // Advance the hash by one character. Keep looking for a match.
380
+ h.next(target[base + i + NHASH]);
381
+ i++;
382
+ }
383
+ }
384
+ // Output a final "insert" record to get all the text at the end of
385
+ // the file that does not match anything in the source.
386
+ if (base < lenOut) {
387
+ zDelta.putInt(lenOut - base);
388
+ zDelta.putChar(":");
389
+ zDelta.putArray(target, base, base + lenOut - base);
390
+ }
391
+ // Output the final checksum record.
392
+ zDelta.putInt(checksum(target));
393
+ zDelta.putChar(";");
394
+ return zDelta.toByteArray(source);
395
+ }
396
+
397
+ /**
398
+ * Return the size (in bytes) of the target from applying a delta.
399
+ */
400
+ export function getDeltaTargetSize(delta: ByteArray): number {
401
+ const zDelta = new Reader(delta);
402
+ const size = zDelta.getInt();
403
+ if (zDelta.getChar() !== "\n") {
404
+ throw new Error("size integer not terminated by '\\n'");
405
+ }
406
+ return size;
407
+ }
408
+
409
+ export type Options = {
410
+ verifyChecksum?: boolean;
411
+ };
412
+
413
+ /**
414
+ * Apply a delta byte array to a source byte array, returning the target byte array.
415
+ */
416
+ export function applyDelta<T extends ByteArray>(
417
+ source: T,
418
+ delta: T,
419
+ opts?: Options
420
+ ): T {
421
+ let limit: number,
422
+ total = 0;
423
+ const zDelta = new Reader(delta);
424
+ const lenSrc = source.length;
425
+ const lenDelta = delta.length;
426
+
427
+ limit = zDelta.getInt();
428
+ if (zDelta.getChar() !== "\n")
429
+ throw new Error("size integer not terminated by '\\n'");
430
+ const zOut = new Writer();
431
+ while (zDelta.haveBytes()) {
432
+ let cnt = zDelta.getInt();
433
+ let ofst: number;
434
+
435
+ switch (zDelta.getChar()) {
436
+ case "@":
437
+ ofst = zDelta.getInt();
438
+ if (zDelta.haveBytes() && zDelta.getChar() !== ",")
439
+ throw new Error("copy command not terminated by ','");
440
+ total += cnt;
441
+ if (total > limit) throw new Error("copy exceeds output file size");
442
+ if (ofst + cnt > lenSrc)
443
+ throw new Error("copy extends past end of input");
444
+ zOut.putArray(source, ofst, ofst + cnt);
445
+ break;
446
+
447
+ case ":":
448
+ total += cnt;
449
+ if (total > limit)
450
+ throw new Error(
451
+ "insert command gives an output larger than predicted"
452
+ );
453
+ if (cnt > lenDelta)
454
+ throw new Error("insert count exceeds size of delta");
455
+ zOut.putArray(zDelta.a, zDelta.pos, zDelta.pos + cnt);
456
+ zDelta.pos += cnt;
457
+ break;
458
+
459
+ case ";":
460
+ const out = zOut.toByteArray(source);
461
+ if ((!opts || opts.verifyChecksum !== false) && cnt !== checksum(out))
462
+ throw new Error("bad checksum");
463
+ if (total !== limit)
464
+ throw new Error("generated size does not match predicted size");
465
+ return out;
466
+
467
+ default:
468
+ throw new Error("unknown delta operator");
469
+ }
470
+ }
471
+ throw new Error("unterminated delta");
472
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.9-rc.4",
3
+ "version": "0.7.9-rc.5",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/config.ts CHANGED
@@ -233,6 +233,22 @@ export interface VaultConfig {
233
233
  auto_transcribe?: {
234
234
  enabled?: boolean;
235
235
  };
236
+ /** Note history policy; read once when the vault store opens (vault#524). */
237
+ history?: {
238
+ enabled?: boolean;
239
+ min_versions?: number;
240
+ max_versions?: number;
241
+ max_age_days?: number;
242
+ deleted_retention_days?: number | null;
243
+ /** Defaults: enabled=true, ratio=3, min=10, run=24, bytes=8 MiB, budget=250ms, notes=50. */
244
+ compact_enabled?: boolean;
245
+ compact_ratio?: number;
246
+ compact_min_versions?: number;
247
+ compact_run_length?: number;
248
+ max_bytes_per_note?: number | null;
249
+ compact_budget_ms?: number;
250
+ compact_max_notes?: number;
251
+ };
236
252
  }
237
253
 
238
254
  // ---------------------------------------------------------------------------
@@ -536,6 +552,13 @@ function serializeVaultConfig(config: VaultConfig): string {
536
552
  lines.push(`audio_retention: ${config.audio_retention}`);
537
553
  }
538
554
 
555
+ // Per-vault history policy: serialize only explicitly configured fields.
556
+ if (config.history) {
557
+ lines.push("history:");
558
+ for (const key of ["enabled", "min_versions", "max_versions", "max_age_days", "deleted_retention_days", "compact_enabled", "compact_ratio", "compact_min_versions", "compact_run_length", "max_bytes_per_note", "compact_budget_ms", "compact_max_notes"] as const) {
559
+ if (config.history[key] !== undefined) lines.push(` ${key}: ${config.history[key]}`);
560
+ }
561
+ }
539
562
  // Per-vault auto-transcribe override. Serialized as a nested block so future
540
563
  // fields can grow under it (mirrors the GlobalConfig shape). Only emitted
541
564
  // when `enabled` is explicitly set — an unset vault falls back to global.
@@ -696,6 +719,25 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
696
719
  }
697
720
  }
698
721
 
722
+ const historyStart = yaml.match(/^history:\s*$/m);
723
+ if (historyStart) {
724
+ const after = yaml.slice((historyStart.index ?? 0) + historyStart[0].length);
725
+ config.history = {};
726
+ for (const line of after.split("\n")) {
727
+ if (line.match(/^\S/) && line.trim().length > 0) break;
728
+ const enabled = line.match(/^\s+(enabled|compact_enabled):\s*(true|false)\s*$/);
729
+ if (enabled) config.history[enabled[1] as "enabled" | "compact_enabled"] = enabled[2] === "true";
730
+ const number = line.match(/^\s+(min_versions|max_versions|max_age_days|compact_min_versions|compact_run_length|compact_budget_ms|compact_max_notes):\s*(\d+)\s*$/);
731
+ if (number) config.history[number[1] as "min_versions" | "max_versions" | "max_age_days" | "compact_min_versions" | "compact_run_length" | "compact_budget_ms" | "compact_max_notes"] = Number(number[2]);
732
+ const ratio = line.match(/^\s+compact_ratio:\s*(\d+(?:\.\d+)?)\s*$/);
733
+ if (ratio) config.history.compact_ratio = Number(ratio[1]);
734
+ const bytes = line.match(/^\s+max_bytes_per_note:\s*(null|\d+)\s*$/);
735
+ if (bytes) config.history.max_bytes_per_note = bytes[1] === "null" ? null : Number(bytes[1]);
736
+ const deleted = line.match(/^\s+deleted_retention_days:\s*(null|\d+)\s*$/);
737
+ if (deleted) config.history.deleted_retention_days = deleted[1] === "null" ? null : Number(deleted[1]);
738
+ }
739
+ }
740
+
699
741
  return config;
700
742
  }
701
743
 
package/src/mcp-tools.ts CHANGED
@@ -5,9 +5,9 @@
5
5
  * tools operate on that vault and vault-info picks up its config directly.
6
6
  */
7
7
 
8
- import { generateMcpTools } from "../core/src/mcp.ts";
8
+ import { generateMcpTools, resolveNote } from "../core/src/mcp.ts";
9
9
  import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
10
- import { getNoteTags, getVaultMap } from "../core/src/notes.ts";
10
+ import { getNote, getNoteTags, getVaultMap } from "../core/src/notes.ts";
11
11
  import { narrowLinkWarningsForVisibility } from "../core/src/wikilinks.ts";
12
12
  import type { Note } from "../core/src/types.ts";
13
13
  import {
@@ -566,6 +566,16 @@ function applyTagScopeWrappers(
566
566
  }
567
567
  return result;
568
568
  }
569
+ if (result && typeof result === "object" && (
570
+ ("versions" in result && Array.isArray((result as any).versions) && "total" in result)
571
+ || ("version_ix" in result && "content" in result)
572
+ || ("version_ix" in result && "error" in result)
573
+ )) {
574
+ const id = (params as any).versions.note_id;
575
+ const note = resolveNote(store.db, id);
576
+ return note && noteWithinTagScope(note, allowed, rawTags)
577
+ ? result : { error: "Note not found", error_type: "not_found", id };
578
+ }
569
579
  // Possible response shapes (vault#550 added the `warnings` variants):
570
580
  // - Array (legacy list, no cursor, no warnings)
571
581
  // - `{notes, next_cursor}` (cursor mode, vault#313)