@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/compact.test.ts +248 -0
- package/core/src/delta.test.ts +49 -0
- package/core/src/delta.ts +21 -0
- package/core/src/doctor.ts +41 -1
- package/core/src/history-capture.test.ts +244 -0
- package/core/src/history.test.ts +229 -0
- package/core/src/history.ts +679 -0
- package/core/src/mcp-manifest.ts +11 -0
- package/core/src/mcp.ts +21 -3
- package/core/src/notes.ts +6 -1
- package/core/src/portable-md.ts +1 -1
- package/core/src/schema-v29-note-versions.test.ts +63 -0
- package/core/src/schema-v30-note-deltas.test.ts +51 -0
- package/core/src/schema.ts +202 -1
- package/core/src/store.ts +103 -14
- package/core/src/types.ts +35 -2
- package/core/src/vendor/fossil-delta.ts +472 -0
- package/package.json +1 -1
- package/src/config.ts +42 -0
- package/src/mcp-tools.ts +12 -2
- package/src/routes.ts +207 -86
- package/src/routing.ts +8 -3
- package/src/vault-compact-routes.test.ts +257 -0
- package/src/vault-history-routes.test.ts +562 -0
- package/src/vault-store.ts +11 -0
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;
|
|
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
|
-
|
|
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
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)
|