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

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 (40) hide show
  1. package/README.md +4 -0
  2. package/core/src/compact.test.ts +248 -0
  3. package/core/src/delta.test.ts +49 -0
  4. package/core/src/delta.ts +21 -0
  5. package/core/src/doctor.ts +41 -1
  6. package/core/src/history-capture.test.ts +244 -0
  7. package/core/src/history-import.test.ts +82 -0
  8. package/core/src/history-import.ts +157 -0
  9. package/core/src/history.test.ts +229 -0
  10. package/core/src/history.ts +681 -0
  11. package/core/src/mcp-manifest.ts +17 -0
  12. package/core/src/mcp.ts +28 -3
  13. package/core/src/notes.ts +6 -1
  14. package/core/src/portable-md.ts +1 -1
  15. package/core/src/schema-v29-note-versions.test.ts +63 -0
  16. package/core/src/schema-v30-note-deltas.test.ts +51 -0
  17. package/core/src/schema-v31-history-import.test.ts +25 -0
  18. package/core/src/schema.ts +234 -1
  19. package/core/src/store.ts +103 -14
  20. package/core/src/types.ts +35 -2
  21. package/core/src/vendor/fossil-delta.ts +472 -0
  22. package/package.json +1 -1
  23. package/src/cli.ts +10 -1
  24. package/src/config.ts +42 -0
  25. package/src/history-import-mcp.test.ts +94 -0
  26. package/src/history-import-routes.test.ts +91 -0
  27. package/src/history-import.test.ts +318 -0
  28. package/src/history-import.ts +482 -0
  29. package/src/mcp-http.ts +13 -0
  30. package/src/mcp-tools.ts +18 -2
  31. package/src/mirror-config.ts +38 -0
  32. package/src/mirror-manager.ts +6 -0
  33. package/src/mirror-retirement.test.ts +79 -0
  34. package/src/mirror-routes.ts +12 -2
  35. package/src/routes.ts +226 -86
  36. package/src/routing.ts +8 -3
  37. package/src/server.ts +33 -14
  38. package/src/vault-compact-routes.test.ts +257 -0
  39. package/src/vault-history-routes.test.ts +562 -0
  40. package/src/vault-store.ts +13 -0
@@ -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.6",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/cli.ts CHANGED
@@ -296,6 +296,11 @@ switch (command) {
296
296
  case "backup":
297
297
  await cmdBackup(cmdArgs);
298
298
  break;
299
+ case "history-import": {
300
+ const { runHistoryImport } = await import("./history-import.ts");
301
+ console.log(JSON.stringify(await runHistoryImport(cmdArgs), null, 2));
302
+ break;
303
+ }
299
304
  case "import":
300
305
  await cmdImport(cmdArgs);
301
306
  break;
@@ -4815,7 +4820,7 @@ async function createVault(
4815
4820
  // git-less box leaves the config written but inactive + logs an actionable
4816
4821
  // hint — it must NEVER fail the vault create. Create-time ONLY: existing
4817
4822
  // vaults are never retroactively migrated.
4818
- if (shouldEnableCreateTimeMirror(opts)) {
4823
+ if (shouldEnableCreateTimeMirror(opts) && (await import("./mirror-config.ts")).readHistoryMirrorPhase(name) === "active") {
4819
4824
  const mirrorConfig = historyPresetMirrorConfig();
4820
4825
  writeMirrorConfigForVault(name, mirrorConfig);
4821
4826
  const mirrorPath = resolveMirrorPath(vaultDir(name), mirrorConfig);
@@ -5213,6 +5218,10 @@ Import/Export:
5213
5218
  --git-push to push after commit)
5214
5219
 
5215
5220
  History:
5221
+ parachute-vault history-import plan|prepare|apply|retire|cancel --vault <name>
5222
+ Import a local git archive into note history;
5223
+ plan/prepare: --selections <file> for audited duplicates;
5224
+ see UPGRADING.md for offline cutover and recovery.
5216
5225
  parachute-vault history [--vault <name>] Show the vault's git write history (the
5217
5226
  mirror is git-backed — one file per note,
5218
5227
  so git log IS a tamper-evident history)
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
 
@@ -0,0 +1,94 @@
1
+ import { test, expect } from "bun:test";
2
+ import { handleMcp } from "./mcp-http.ts";
3
+ import type { McpToolDef } from "../core/src/mcp.ts";
4
+
5
+ async function errorEnvelope(fields: Record<string, unknown>) {
6
+ const tool: McpToolDef = {
7
+ name: "query-notes", description: "serializer boundary fixture", requiredVerb: "read",
8
+ inputSchema: { type: "object" },
9
+ execute() { throw Object.assign(new Error("Unrecoverable history"), fields); },
10
+ };
11
+ const request = new Request("http://localhost/vault/import-test/mcp", {
12
+ method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
13
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool.name, arguments: {} } }),
14
+ });
15
+ const response = await handleMcp(request, () => [tool], "import-test", "import-test", {
16
+ permission: "full", scopes: ["vault:import-test:read"], legacyDerived: false,
17
+ scoped_tags: null, vault_name: null, caller_jti: null, actor: "test", via: "api",
18
+ }, "");
19
+ return await response.json() as any;
20
+ }
21
+ test("HTTP MCP forwards valid imported history references only on the dedicated error branch", async () => {
22
+ const fields = { code: "HISTORY_UNRECOVERABLE", error_type: "history_unrecoverable", origin: "git-import", import_ix: 0, version_ix: -1 };
23
+ const rpc = await errorEnvelope(fields);
24
+ expect(rpc.result).toBeUndefined();
25
+ expect(rpc.error.code).toBe(-32602);
26
+ expect(rpc.error.data).toEqual({ error_type: "history_unrecoverable", origin: "git-import", import_ix: 0 });
27
+ for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, "0", null]) {
28
+ const bad = await errorEnvelope({ ...fields, import_ix: invalid });
29
+ expect(bad.result).toBeUndefined();
30
+ expect(bad.error.data).toEqual({ error_type: "history_unrecoverable" });
31
+ }
32
+ const generic = await errorEnvelope({ ...fields, code: "OTHER" });
33
+ expect(generic.error.data).toEqual({ error_type: "history_unrecoverable" });
34
+ const native = await errorEnvelope({ code: "HISTORY_UNRECOVERABLE", error_type: "history_unrecoverable", version_ix: 0, note_id: "note" });
35
+ expect(native.error.data).toEqual({ error_type: "history_unrecoverable" });
36
+ });
37
+
38
+ import { mkdtempSync, rmSync } from "node:fs";
39
+ import { tmpdir } from "node:os";
40
+ import { join } from "node:path";
41
+ import { writeVaultConfig } from "./config.ts";
42
+ import { getVaultStore, clearVaultStoreCache } from "./vault-store.ts";
43
+ import { generateScopedMcpTools } from "./mcp-tools.ts";
44
+ import { applyImportedNote, beginImportRun, importTargetDigest } from "../core/src/history-import.ts";
45
+ import { resolveHistoryPolicy } from "../core/src/history.ts";
46
+
47
+ test("HTTP MCP imported get/list/error keeps discriminator and rejects mixed selectors", async () => {
48
+ const saved = process.env.PARACHUTE_HOME;
49
+ const dir = mkdtempSync(join(tmpdir(), "history-import-mcp-"));
50
+ process.env.PARACHUTE_HOME = dir;
51
+ try {
52
+ writeVaultConfig({ name: "import-test", api_keys: [], created_at: new Date().toISOString() });
53
+ const store = getVaultStore("import-test");
54
+ const note = await store.createNote("live");
55
+ const run = { run_id: "test", source_fingerprint: "source", tip: "tip", options_digest: "options" };
56
+ beginImportRun(store.db, run);
57
+ applyImportedNote(store.db, {
58
+ run, noteId: note.id, policy: resolveHistoryPolicy({ min_versions: 20 }), now: Date.now(),
59
+ targetDigest: importTargetDigest(store.db, note.id),
60
+ observations: [{ content: "archive", path: null, metadata: {}, extension: "md", created_at: null, observed_at: new Date().toISOString(), commit: "commit", blob: "blob" }],
61
+ });
62
+ const auth = { permission: "full" as const, scopes: ["vault:import-test:read"], legacyDerived: false, scoped_tags: null, vault_name: null, caller_jti: null, actor: "test", via: "api" };
63
+ async function call(selector: Record<string, unknown>) {
64
+ const req = new Request("http://localhost/vault/import-test/mcp", { method: "POST", headers: { "content-type": "application/json", accept: "application/json, text/event-stream" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "query-notes", arguments: { versions: { note_id: note.id, ...selector } } } }) });
65
+ return await (await handleMcp(req, () => generateScopedMcpTools("import-test", auth), "test", "import-test", auth, "")).json() as any;
66
+ }
67
+ const got = await call({ origin: "git-import", import_ix: 0 });
68
+ const row = JSON.parse(got.result.content[0].text);
69
+ expect(row.content).toBe("archive");
70
+ expect(row.origin).toBe("git-import");
71
+ expect(row.import_ix).toBe(0);
72
+ expect(row.version_ix).toBeUndefined();
73
+ const listed = JSON.parse((await call({})).result.content[0].text);
74
+ expect(listed.versions[0].origin).toBe("git-import");
75
+ for (const selector of [{ origin: "git-import" }, { import_ix: 0 }, { version_ix: 0, origin: "git-import", import_ix: 0 }, { version_ix: -1 }]) {
76
+ const invalid = await call(selector);
77
+ expect(invalid.error).toBeDefined();
78
+ expect(invalid.result).toBeUndefined();
79
+ }
80
+ store.db.exec("UPDATE note_blobs SET encoding='unknown'");
81
+ const broken = await call({ origin: "git-import", import_ix: 0 });
82
+ expect(broken.result).toBeUndefined();
83
+ expect(broken.error.data).toEqual({ error_type: "history_unrecoverable", origin: "git-import", import_ix: 0 });
84
+ expect(broken.error.message).not.toContain("@-1");
85
+ const scoped = generateScopedMcpTools("import-test", { ...auth, scoped_tags: ["journal"] }).find(t => t.name === "query-notes")!;
86
+ const denied = await scoped.execute({ versions: { note_id: note.id, origin: "git-import", import_ix: 0 } });
87
+ expect(denied).toEqual({ error: "Note not found", error_type: "not_found", id: note.id });
88
+
89
+ } finally {
90
+ clearVaultStoreCache();
91
+ if (saved === undefined) delete process.env.PARACHUTE_HOME; else process.env.PARACHUTE_HOME = saved;
92
+ rmSync(dir, { recursive: true, force: true });
93
+ }
94
+ });