@batalabs/virlow-mcp-core 3.19.0 → 3.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/LICENSE +8 -18
  2. package/README.md +1 -1
  3. package/dist/api.d.ts +14 -0
  4. package/dist/api.d.ts.map +1 -1
  5. package/dist/api.js +6 -0
  6. package/dist/api.js.map +1 -1
  7. package/dist/attrs-extract.d.ts +48 -0
  8. package/dist/attrs-extract.d.ts.map +1 -0
  9. package/dist/attrs-extract.js +153 -0
  10. package/dist/attrs-extract.js.map +1 -0
  11. package/dist/attrs.d.ts +445 -0
  12. package/dist/attrs.d.ts.map +1 -0
  13. package/dist/attrs.js +604 -0
  14. package/dist/attrs.js.map +1 -0
  15. package/dist/cjs/api.d.ts +14 -0
  16. package/dist/cjs/api.js +6 -0
  17. package/dist/cjs/attrs-extract.d.ts +47 -0
  18. package/dist/cjs/attrs-extract.js +159 -0
  19. package/dist/cjs/attrs.d.ts +444 -0
  20. package/dist/cjs/attrs.js +635 -0
  21. package/dist/cjs/index.d.ts +4 -0
  22. package/dist/cjs/index.js +22 -1
  23. package/dist/cjs/memories.d.ts +79 -1
  24. package/dist/cjs/memories.js +249 -15
  25. package/dist/cjs/memory-graph.d.ts +105 -0
  26. package/dist/cjs/memory-graph.js +270 -0
  27. package/dist/cjs/memory-note.d.ts +57 -0
  28. package/dist/cjs/memory-note.js +174 -10
  29. package/dist/cjs/memory-secrets.d.ts +19 -0
  30. package/dist/cjs/memory-secrets.js +108 -0
  31. package/dist/cjs/notes.d.ts +208 -2
  32. package/dist/cjs/notes.js +721 -12
  33. package/dist/index.d.ts +4 -0
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +2 -0
  36. package/dist/index.js.map +1 -1
  37. package/dist/memories.d.ts +79 -1
  38. package/dist/memories.d.ts.map +1 -1
  39. package/dist/memories.js +249 -15
  40. package/dist/memories.js.map +1 -1
  41. package/dist/memory-graph.d.ts +106 -0
  42. package/dist/memory-graph.d.ts.map +1 -0
  43. package/dist/memory-graph.js +258 -0
  44. package/dist/memory-graph.js.map +1 -0
  45. package/dist/memory-note.d.ts +57 -0
  46. package/dist/memory-note.d.ts.map +1 -1
  47. package/dist/memory-note.js +170 -10
  48. package/dist/memory-note.js.map +1 -1
  49. package/dist/memory-secrets.d.ts +20 -0
  50. package/dist/memory-secrets.d.ts.map +1 -0
  51. package/dist/memory-secrets.js +103 -0
  52. package/dist/memory-secrets.js.map +1 -0
  53. package/dist/notes.d.ts +208 -2
  54. package/dist/notes.d.ts.map +1 -1
  55. package/dist/notes.js +722 -13
  56. package/dist/notes.js.map +1 -1
  57. package/dist/tools.d.ts +2 -2
  58. package/dist/tools.d.ts.map +1 -1
  59. package/dist/tools.js +464 -9
  60. package/dist/tools.js.map +1 -1
  61. package/package.json +15 -5
package/dist/cjs/notes.js CHANGED
@@ -3,14 +3,179 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.NotesService = exports.SEARCH_SCAN_CAP = void 0;
4
4
  const virlow_crypto_1 = require("@batalabs/virlow-crypto");
5
5
  const exposure_js_1 = require("./exposure.js");
6
+ const attrs_js_1 = require("./attrs.js");
7
+ const attrs_extract_js_1 = require("./attrs-extract.js");
8
+ const vault_js_1 = require("./vault.js");
6
9
  /** Hard ceiling on how many notes searchNotes() will decrypt+scan in a
7
10
  * single call. Keeps a single search bounded even for very large accounts;
8
- * `truncated` tells the caller when the account holds more than this. */
11
+ * `truncated` tells the caller when the account holds more than this. Also
12
+ * bounds listVisibleRows(), which backs mention-matching and backlinks(). */
9
13
  exports.SEARCH_SCAN_CAP = 500;
10
14
  const SEARCH_PAGE_LIMIT = 50;
11
15
  /** Window radius (chars) either side of the match start when building a
12
16
  * content snippet; total window is up to 2*SNIPPET_RADIUS. */
13
17
  const SNIPPET_RADIUS = 80;
18
+ /** The one wording every "the vault locked while this was in flight" guard
19
+ * uses, so the sentence the caller reads cannot drift between them. */
20
+ function lockedError() {
21
+ return new vault_js_1.LockedError('Vault is locked. Ask the user to run the unlock tool to sign in and unlock their Virlow vault.');
22
+ }
23
+ const TRUE_WORDS = new Set(['true', 'yes', 'y', '1', 'on', 'done', 'checked']);
24
+ const FALSE_WORDS = new Set(['false', 'no', 'n', '0', 'off']);
25
+ /** A date value has to start with a calendar day; anything after it (a time,
26
+ * a zone) is dropped, since a property date is a day. */
27
+ const DATE_PREFIX = /^\d{4}-\d{2}-\d{2}/;
28
+ /** The offending value, echoed back so the caller can see what it sent.
29
+ * Errors travel to the caller and are never logged, so this is not a
30
+ * disclosure path for note content. */
31
+ function describeValue(raw) {
32
+ const text = JSON.stringify(raw);
33
+ return text === undefined ? String(raw) : text;
34
+ }
35
+ /** The definition a `values` key names, by id or by name (case-insensitive).
36
+ * Never guesses between two properties sharing a name: a schema written by an
37
+ * older client can contain duplicates, and picking one silently would write
38
+ * the value onto whichever happened to sort first. */
39
+ function findDefinition(defs, nameOrId) {
40
+ const byId = defs.find((d) => d.id === nameOrId);
41
+ if (byId)
42
+ return byId;
43
+ const wanted = nameOrId.trim().toLowerCase();
44
+ const byName = defs.filter((d) => d.name.trim().toLowerCase() === wanted);
45
+ if (byName.length > 1) {
46
+ throw new Error(`"${nameOrId}" names ${byName.length} properties in this folder. Use the id instead: ` +
47
+ `${byName.map((d) => d.id).join(', ')}.`);
48
+ }
49
+ const [only] = byName;
50
+ if (only)
51
+ return only;
52
+ const known = defs.map((d) => d.name).join(', ');
53
+ throw new Error(`This note's folder has no property "${nameOrId}". Defined properties: ${known || '(none)'}. ` +
54
+ 'Run list_properties to see them, or define_property to add one.');
55
+ }
56
+ /**
57
+ * Turns the plain JSON a model sends (`{ Stage: "Shipped", Count: 3 }`) into
58
+ * typed PropValues keyed by property id, plus the options that had to be
59
+ * invented along the way.
60
+ *
61
+ * Nothing is written here: the caller decides the write order, which matters,
62
+ * because a value naming an option id the definition does not carry is
63
+ * unreadable in the app and cannot be repaired through this tool.
64
+ */
65
+ function resolveValues(defs, values, createOption) {
66
+ const props = {};
67
+ const applied = [];
68
+ const newOptions = new Map();
69
+ /** Every option this call may resolve against: the definition's own, plus
70
+ * the ones invented for it earlier in this same call, so `["New", "New"]`
71
+ * resolves to one option rather than two with the same name. */
72
+ const pool = (def) => [
73
+ ...(def.options ?? []),
74
+ ...(newOptions.get(def.id) ?? []),
75
+ ];
76
+ const optionId = (def, given) => {
77
+ const wanted = given.trim();
78
+ if (wanted === '') {
79
+ throw new Error(`Property "${def.name}" was given an empty option name.`);
80
+ }
81
+ const options = pool(def);
82
+ const match = options.find((o) => o.id === wanted) ??
83
+ options.find((o) => o.name.trim().toLowerCase() === wanted.toLowerCase());
84
+ if (match)
85
+ return match.id;
86
+ if (!createOption) {
87
+ const names = (def.options ?? []).map((o) => o.name).join(', ');
88
+ throw new Error(`Property "${def.name}" has no option "${wanted}". Valid options: ${names || '(none defined)'}. ` +
89
+ 'Pass createOption: true to add it to the folder\'s property definition.');
90
+ }
91
+ const option = { id: (0, attrs_js_1.newPropId)(), name: wanted };
92
+ newOptions.set(def.id, [...(newOptions.get(def.id) ?? []), option]);
93
+ return option.id;
94
+ };
95
+ const optionName = (def, id) => pool(def).find((o) => o.id === id)?.name ?? id;
96
+ const asString = (def, raw) => {
97
+ if (typeof raw === 'string')
98
+ return raw;
99
+ if (typeof raw === 'number' || typeof raw === 'boolean')
100
+ return String(raw);
101
+ throw new Error(`Property "${def.name}" expects a string, got ${describeValue(raw)}.`);
102
+ };
103
+ const asStrings = (def, raw) => Array.isArray(raw) ? raw.map((item) => asString(def, item)) : [asString(def, raw)];
104
+ const coerce = (def, raw) => {
105
+ switch (def.type) {
106
+ case 'text': {
107
+ const v = asString(def, raw);
108
+ return { value: { t: 'text', v }, display: v };
109
+ }
110
+ case 'url': {
111
+ const v = asString(def, raw);
112
+ return { value: { t: 'url', v }, display: v };
113
+ }
114
+ case 'number': {
115
+ const n = typeof raw === 'number'
116
+ ? raw
117
+ : typeof raw === 'string' && raw.trim() !== ''
118
+ ? Number(raw)
119
+ : Number.NaN;
120
+ if (!Number.isFinite(n)) {
121
+ throw new Error(`Property "${def.name}" expects a number, got ${describeValue(raw)}.`);
122
+ }
123
+ return { value: { t: 'number', v: n }, display: String(n) };
124
+ }
125
+ case 'checkbox': {
126
+ const word = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
127
+ const b = typeof raw === 'boolean'
128
+ ? raw
129
+ : TRUE_WORDS.has(word)
130
+ ? true
131
+ : FALSE_WORDS.has(word)
132
+ ? false
133
+ : null;
134
+ if (b === null) {
135
+ throw new Error(`Property "${def.name}" expects true or false, got ${describeValue(raw)}.`);
136
+ }
137
+ return { value: { t: 'checkbox', v: b }, display: b ? 'Yes' : 'No' };
138
+ }
139
+ case 'date': {
140
+ const given = asString(def, raw).trim();
141
+ if (!DATE_PREFIX.test(given)) {
142
+ throw new Error(`Property "${def.name}" expects a date like 2026-09-07, got ${describeValue(raw)}.`);
143
+ }
144
+ const v = given.slice(0, 10);
145
+ return { value: { t: 'date', v }, display: v };
146
+ }
147
+ case 'select': {
148
+ const id = optionId(def, asString(def, raw));
149
+ return { value: { t: 'select', v: id }, display: optionName(def, id) };
150
+ }
151
+ case 'multiSelect': {
152
+ const ids = asStrings(def, raw).map((name) => optionId(def, name));
153
+ return {
154
+ value: { t: 'multiSelect', v: ids },
155
+ display: ids.map((id) => optionName(def, id)).join(', '),
156
+ };
157
+ }
158
+ case 'relation': {
159
+ const ids = asStrings(def, raw)
160
+ .map((id) => id.trim())
161
+ .filter((id) => id !== '');
162
+ return { value: { t: 'relation', v: ids }, display: ids.join(', ') };
163
+ }
164
+ }
165
+ };
166
+ for (const [nameOrId, raw] of Object.entries(values)) {
167
+ const def = findDefinition(defs, nameOrId);
168
+ if (raw === null) {
169
+ props[def.id] = null;
170
+ applied.push({ name: def.name, value: 'removed' });
171
+ continue;
172
+ }
173
+ const { value, display } = coerce(def, raw);
174
+ props[def.id] = value;
175
+ applied.push({ name: def.name, value: display });
176
+ }
177
+ return { props, applied, newOptions };
178
+ }
14
179
  class NotesService {
15
180
  api;
16
181
  vault;
@@ -18,6 +183,33 @@ class NotesService {
18
183
  * so re-reading/re-scanning the same note twice in one unlock doesn't
19
184
  * re-run AES-GCM decryption. Cleared whenever the vault locks. */
20
185
  decryptCache = new Map();
186
+ /** Per-unlock cache of decoded attrs, keyed the same way as decryptCache.
187
+ * Stores `null` too (a note with no attrs), so `.has()` rather than a
188
+ * truthiness check distinguishes "decoded, no attrs" from "not cached
189
+ * yet". Cleared on lock alongside decryptCache. */
190
+ attrsCache = new Map();
191
+ /** Per-unlock cache of decrypted titles only (no content), used by
192
+ * mention-matching and backlinks() so neither has to pay for a full body
193
+ * decrypt just to learn a note's title. Cleared on lock alongside the
194
+ * other caches. */
195
+ titleCache = new Map();
196
+ /** Per-unlock cache of decoded folder schemas, keyed on the folder id and
197
+ * the ciphertext itself so a schema this service just rewrote is never
198
+ * served from a stale entry. Cleared on lock alongside the other caches. */
199
+ schemaCache = new Map();
200
+ /**
201
+ * The rows listVisibleRows() last paged, with the epoch and folder-exposure
202
+ * they were computed under. Without this, update_note paged the entire note
203
+ * list on every call just to build mention titles, so one edit cost as many
204
+ * round trips as the account has pages of notes.
205
+ *
206
+ * Keyed on the exposure as well as the epoch, so flipping a folder's
207
+ * "Expose to MCP" switch takes effect on the very next call rather than
208
+ * leaving a closed folder's titles in the mention set; dropped outright on
209
+ * every write, so a note this service just created, moved, trashed or
210
+ * retitled is never missing from (or stale in) the next scan.
211
+ */
212
+ visibleRowsCache = null;
21
213
  /** Bumped every time the vault locks (see handleLock()). decryptRow()
22
214
  * captures this before its await on decryptNoteFields() and re-checks it
23
215
  * afterward, so a decrypt that was still in flight when the vault locked
@@ -36,8 +228,18 @@ class NotesService {
36
228
  }
37
229
  handleLock() {
38
230
  this.decryptCache.clear();
231
+ this.attrsCache.clear();
232
+ this.titleCache.clear();
233
+ this.schemaCache.clear();
234
+ this.visibleRowsCache = null;
39
235
  this.epoch++;
40
236
  }
237
+ /** Drops the paged row list after anything that could change which rows
238
+ * exist, where they live, or what they are called. Called after the write
239
+ * lands, so a failed write leaves the cache alone. */
240
+ invalidateVisibleRows() {
241
+ this.visibleRowsCache = null;
242
+ }
41
243
  async listNotes(params) {
42
244
  const key = this.requireSession();
43
245
  const { notes, pagination } = await this.api.listNotes({ ...params, deleted: false });
@@ -66,6 +268,76 @@ class NotesService {
66
268
  }));
67
269
  return { notes: mapped, pagination, withheld, exposure };
68
270
  }
271
+ /**
272
+ * A folder the way a view shows it: every note in it, with the value it
273
+ * carries for each of the folder's property definitions.
274
+ *
275
+ * The program's AI-parity principle is that every capability the UI gains,
276
+ * the tools gain in the same epic. The app can now read a folder as a table,
277
+ * a board or a calendar; without this an assistant could read and write
278
+ * individual properties but never see the shape the user is looking at, so
279
+ * "which of these is still in review" meant reading every note in turn.
280
+ *
281
+ * Values come from the attrs blob already on each list row, so a folder of
282
+ * two thousand notes answers without fetching or decrypting a single body.
283
+ * That is the same constraint the views are built to, and it is why this
284
+ * reads rows rather than calling readNote per note.
285
+ *
286
+ * Hidden notes and unexposed folders are filtered exactly as listNotes does,
287
+ * and for the same reasons; the counts it withholds are reported here too so
288
+ * a filtered folder is never mistaken for a small one.
289
+ */
290
+ async listFolderView(params) {
291
+ const key = this.requireSession();
292
+ const folder = await this.requireFolder(params.folderId);
293
+ const defs = (0, attrs_js_1.sortedProps)(await this.decodeSchemaFor(key, folder));
294
+ const { notes, pagination } = await this.api.listNotes({
295
+ folderId: params.folderId,
296
+ page: params.page,
297
+ limit: params.limit,
298
+ deleted: false,
299
+ });
300
+ const exposure = await this.exposure();
301
+ const notHidden = notes.filter((row) => row.hidden !== true);
302
+ const visible = notHidden.filter((row) => exposure.allows(row.folderId));
303
+ const withheld = {
304
+ inClosedFolders: notHidden.length - visible.length,
305
+ };
306
+ const rows = await Promise.all(visible.map(async (row) => {
307
+ const attrs = await this.decodeAttrsFor(key, row);
308
+ const values = {};
309
+ for (const def of defs) {
310
+ values[def.id] = (0, attrs_js_1.formatProp)(def, attrs?.props?.[def.id]);
311
+ }
312
+ return {
313
+ id: row.id,
314
+ title: await this.decryptTitleFor(key, row),
315
+ values,
316
+ };
317
+ }));
318
+ // Sorting on the formatted value, as the table does, so a select orders by
319
+ // its option name rather than by an option id nobody chose. An empty value
320
+ // sorts last, so asking for an order never buries the rows that have data.
321
+ const sortedBy = params.sortBy
322
+ ? (defs.find((d) => d.id === params.sortBy) ??
323
+ defs.find((d) => d.name.toLowerCase() === params.sortBy.toLowerCase()) ??
324
+ null)
325
+ : null;
326
+ if (sortedBy) {
327
+ rows.sort((a, b) => {
328
+ const ka = a.values[sortedBy.id] ?? '';
329
+ const kb = b.values[sortedBy.id] ?? '';
330
+ if (ka === kb)
331
+ return 0;
332
+ if (ka === '')
333
+ return 1;
334
+ if (kb === '')
335
+ return -1;
336
+ return ka.localeCompare(kb);
337
+ });
338
+ }
339
+ return { defs, rows, pagination, withheld, sortedBy };
340
+ }
69
341
  async readNote(id) {
70
342
  const key = this.requireSession();
71
343
  const row = await this.api.getNote(id);
@@ -74,12 +346,19 @@ class NotesService {
74
346
  const exposure = await this.exposure();
75
347
  this.requireExposed(exposure, row.folderId, id);
76
348
  const { title, content } = await this.decryptRow(key, row);
77
- return { id: row.id, title, content, folderId: row.folderId };
349
+ const attrs = await this.decodeAttrsFor(key, row);
350
+ return { id: row.id, title, content, folderId: row.folderId, attrs };
78
351
  }
79
352
  async createNote(title, content, folderId, type) {
80
353
  const key = this.requireSession();
354
+ // Same guard as updateNote(): captured before the encrypt await so a
355
+ // lock landing mid-create is caught before the write, not after.
356
+ const startEpoch = this.epoch;
81
357
  const salt = (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32)));
82
358
  const { encryptedTitle, encryptedContent, iv } = await (0, virlow_crypto_1.encryptNoteFields)(key, title, content);
359
+ if (this.epoch !== startEpoch) {
360
+ throw lockedError();
361
+ }
83
362
  const body = {
84
363
  title: '[ENCRYPTED]',
85
364
  content: '[ENCRYPTED]',
@@ -93,27 +372,278 @@ class NotesService {
93
372
  if (type !== undefined)
94
373
  body.type = type;
95
374
  const row = await this.api.createNote(body);
375
+ this.invalidateVisibleRows();
96
376
  return { id: row.id };
97
377
  }
98
378
  updateNote(id, changes) {
99
379
  return this.enqueue(async () => {
100
380
  const key = this.requireSession();
381
+ // Captured before any await so a lock that lands mid-update is caught
382
+ // before the write, not after: this note's ciphertext must never be
383
+ // sent encrypted under a key the vault has since revoked.
384
+ const startEpoch = this.epoch;
101
385
  const existingRow = await this.api.getNote(id);
102
386
  const exposure = await this.exposure();
103
387
  this.requireExposed(exposure, existingRow.folderId, id);
104
388
  const current = await this.decryptRow(key, existingRow);
105
389
  const title = changes.title ?? current.title;
106
390
  const content = changes.content ?? current.content;
107
- const salt = (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32)));
108
- const { encryptedTitle, encryptedContent, iv } = await (0, virlow_crypto_1.encryptNoteFields)(key, title, content);
109
- await this.api.updateNote(id, {
110
- title: '[ENCRYPTED]',
111
- content: '[ENCRYPTED]',
112
- encryptedTitle,
113
- encryptedContent,
114
- iv,
115
- salt,
391
+ const prevAttrs = (await this.decodeAttrsFor(key, existingRow)) ?? (0, attrs_js_1.emptyAttrs)();
392
+ const props = { ...prevAttrs.props };
393
+ for (const [propKey, value] of Object.entries(changes.props ?? {})) {
394
+ if (value === null) {
395
+ delete props[propKey];
396
+ }
397
+ else {
398
+ props[propKey] = value;
399
+ }
400
+ }
401
+ const links = (0, attrs_extract_js_1.extractLinkIds)(content).filter((linkId) => linkId !== id);
402
+ // Reuses the exposure already computed above for requireExposed(),
403
+ // rather than making visibleTitles() fetch the folder list again.
404
+ const titles = await this.visibleTitles(key, id, exposure);
405
+ const mentions = (0, attrs_extract_js_1.buildTitleMatcher)(titles).find((0, attrs_extract_js_1.htmlToText)(content), new Set([id, ...links]));
406
+ const attrs = {
407
+ // `v` comes from prevAttrs (1 via emptyAttrs() when there was no
408
+ // previous blob); it is never overwritten with a literal 1 here, so
409
+ // a blob written by a newer client that bumped `v` keeps its number
410
+ // through this round trip instead of being silently downgraded.
411
+ ...prevAttrs,
412
+ props,
413
+ links,
414
+ mentions,
415
+ updated: new Date().toISOString(),
416
+ ...(changes.aliases !== undefined ? { aliases: changes.aliases } : {}),
417
+ };
418
+ const encryptedAttrs = await this.encodeAttrsChecked(key, attrs, id);
419
+ // A props-or-aliases-only edit leaves the body exactly as it was, so it
420
+ // sends only the attrs blob. Re-encrypting the unchanged title and
421
+ // content would rewrite the row's ciphertext (and its IV and salt) for
422
+ // no reason: pure work, a bigger request, a pointless revision, and one
423
+ // more chance for an unrelated failure to corrupt a body nobody asked
424
+ // to change.
425
+ const body = { encryptedAttrs };
426
+ if (changes.title !== undefined || changes.content !== undefined) {
427
+ const salt = (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32)));
428
+ const { encryptedTitle, encryptedContent, iv } = await (0, virlow_crypto_1.encryptNoteFields)(key, title, content);
429
+ body.title = '[ENCRYPTED]';
430
+ body.content = '[ENCRYPTED]';
431
+ body.encryptedTitle = encryptedTitle;
432
+ body.encryptedContent = encryptedContent;
433
+ body.iv = iv;
434
+ body.salt = salt;
435
+ }
436
+ if (this.epoch !== startEpoch) {
437
+ throw lockedError();
438
+ }
439
+ await this.api.updateNote(id, body);
440
+ this.invalidateVisibleRows();
441
+ });
442
+ }
443
+ /**
444
+ * Notes that link to `id`, built entirely from attrs, never a body
445
+ * decrypt. Scans the same folder-visible, non-hidden rows as listNotes(),
446
+ * bounded by SEARCH_SCAN_CAP like searchNotes().
447
+ *
448
+ * Unlike readNote()/decryptRow(), which let an in-flight decrypt finish and
449
+ * simply skip the cache write if the vault locked mid-flight, this
450
+ * aggregates across every visible note in one call: a lock partway through
451
+ * means the result mixes attrs decoded under a session that's since been
452
+ * revoked, which is not safe to hand back. So the whole call rejects with
453
+ * LockedError instead.
454
+ */
455
+ async backlinks(id) {
456
+ const key = this.requireSession();
457
+ const startEpoch = this.epoch;
458
+ const rows = await this.listVisibleRows();
459
+ const result = [];
460
+ for (const row of rows) {
461
+ if (row.id === id)
462
+ continue;
463
+ const attrs = await this.decodeAttrsFor(key, row);
464
+ if (attrs?.links.includes(id)) {
465
+ result.push({ id: row.id, title: await this.decryptTitleFor(key, row) });
466
+ }
467
+ }
468
+ if (this.epoch !== startEpoch) {
469
+ throw lockedError();
470
+ }
471
+ return result;
472
+ }
473
+ /** A folder's property definitions, in display order. Read-only, so no
474
+ * mutation queue and no epoch guard past the read itself. */
475
+ async listProperties(folderId) {
476
+ const key = this.requireSession();
477
+ const folder = await this.requireFolder(folderId);
478
+ return (0, attrs_js_1.sortedProps)(await this.decodeSchemaFor(key, folder));
479
+ }
480
+ /** A folder's dashboard as it stands, with the blocks it will draw. */
481
+ async readDashboard(folderId) {
482
+ const key = this.requireSession();
483
+ const folder = await this.requireFolder(folderId);
484
+ const schema = await this.decodeSchemaFor(key, folder);
485
+ return {
486
+ widgets: (0, attrs_js_1.homeWidgets)(schema, (0, attrs_js_1.sortedProps)(schema)),
487
+ defs: (0, attrs_js_1.sortedProps)(schema),
488
+ arranged: (schema.home?.widgets?.length ?? 0) > 0,
489
+ };
490
+ }
491
+ /**
492
+ * Replaces a folder's dashboard arrangement.
493
+ *
494
+ * The previous arrangement is kept alongside the new one, so a person can
495
+ * put back what an assistant changed. That matters more here than for a
496
+ * note: a note edit is visible in the note, while a dashboard edit can
497
+ * remove a block and leave nothing behind to say what was there. One level,
498
+ * deliberately: this is an undo, not a history, and the schema travels
499
+ * inside every folder read.
500
+ */
501
+ setDashboard(folderId, widgets) {
502
+ return this.enqueue(async () => {
503
+ const key = this.requireSession();
504
+ // Captured before the first await, re-checked immediately before the
505
+ // write, exactly as updateNote() does.
506
+ const startEpoch = this.epoch;
507
+ const folder = await this.requireFolder(folderId);
508
+ const schema = await this.decodeSchemaFor(key, folder);
509
+ const kept = (0, attrs_js_1.homeWidgets)(schema, (0, attrs_js_1.sortedProps)(schema));
510
+ // The date on a summary is stamped here rather than taken from the
511
+ // caller. It records when the text was actually written, which is a
512
+ // fact about this write and not a claim for an assistant to make; one
513
+ // that forgot to send it would otherwise leave a snapshot with nothing
514
+ // to say how old it is, which is the whole reason the field exists.
515
+ const now = new Date().toISOString();
516
+ const stamped = widgets.map((w) => w.kind === 'summary' ? { ...w, at: now } : w);
517
+ const next = {
518
+ ...schema,
519
+ home: { ...schema.home, widgets: stamped, previous: kept },
520
+ };
521
+ const encryptedSchema = await this.encodeSchemaChecked(key, next, folderId);
522
+ if (this.epoch !== startEpoch)
523
+ throw lockedError();
524
+ await this.api.updateFolder(folderId, { encryptedSchema });
525
+ return stamped;
526
+ });
527
+ }
528
+ /**
529
+ * Appends a property definition to a folder's schema.
530
+ *
531
+ * Refuses a name the folder already uses, rather than creating a second
532
+ * definition with it: setProperties() resolves a value's key by name, and a
533
+ * duplicate would make every later call ambiguous. The message names the
534
+ * existing id so the caller can just use it.
535
+ */
536
+ defineProperty(folderId, def) {
537
+ return this.enqueue(async () => {
538
+ const key = this.requireSession();
539
+ // Captured before the first await, re-checked immediately before the
540
+ // write, exactly as updateNote() does.
541
+ const startEpoch = this.epoch;
542
+ const folder = await this.requireFolder(folderId);
543
+ const schema = await this.decodeSchemaFor(key, folder);
544
+ const name = def.name.trim();
545
+ const clash = (0, attrs_js_1.sortedProps)(schema).find((p) => p.name.trim().toLowerCase() === name.toLowerCase());
546
+ if (clash) {
547
+ throw new Error(`Folder ${folderId} already has a property named "${clash.name}" (id ${clash.id}, ` +
548
+ `type ${clash.type}). Use that id, or choose a different name.`);
549
+ }
550
+ const id = (0, attrs_js_1.newPropId)();
551
+ const options = (def.options ?? []).map((o) => ({ id: (0, attrs_js_1.newPropId)(), name: o.name.trim() }));
552
+ const next = (0, attrs_js_1.defineProp)(schema, {
553
+ id,
554
+ name,
555
+ type: def.type,
556
+ ...(options.length > 0 ? { options } : {}),
557
+ ...(def.showInList !== undefined ? { showInList: def.showInList } : {}),
116
558
  });
559
+ // Narrowing, not a guess: defineProp() appends exactly what it was
560
+ // given, so this finds it. Looked up rather than reconstructed so the
561
+ // definition reported back is the one actually written.
562
+ const created = (0, attrs_js_1.sortedProps)(next).find((p) => p.id === id);
563
+ if (created === undefined) {
564
+ throw new Error(`Could not define property "${name}" in folder ${folderId}.`);
565
+ }
566
+ const encryptedSchema = await this.encodeSchemaChecked(key, next, folderId);
567
+ if (this.epoch !== startEpoch)
568
+ throw lockedError();
569
+ await this.api.updateFolder(folderId, { encryptedSchema });
570
+ return created;
571
+ });
572
+ }
573
+ /**
574
+ * Sets typed property values on one note, keyed by property name or id.
575
+ *
576
+ * Only `encryptedAttrs` is sent: the title and body keep the ciphertext,
577
+ * IV and salt they already had, so a property change can never damage a
578
+ * body nobody asked to change.
579
+ *
580
+ * When `createOption` invents an option for a select or multiSelect, the
581
+ * definition carrying it is written to the folder **before** the value that
582
+ * names it. Get that order wrong and a failure in between leaves a value
583
+ * pointing at an option id no definition carries, which reads as blank in
584
+ * the app and cannot be repaired from here. In this order the worst a
585
+ * failure between the two writes leaves behind is an option nobody uses
586
+ * yet, and repeating the same call finds that option by name, adds no
587
+ * duplicate, and writes the value.
588
+ */
589
+ setProperties(noteId, values, opts = {}) {
590
+ // Shares updateNote()'s queue: a property write and a body write on the
591
+ // same note must not interleave their read-merge-encrypt-write cycles.
592
+ return this.enqueue(async () => {
593
+ const key = this.requireSession();
594
+ const startEpoch = this.epoch;
595
+ if (Object.keys(values).length === 0) {
596
+ throw new Error(`No values given for note ${noteId}. Run list_properties on its folder to see what ` +
597
+ 'can be set, and pass at least one value.');
598
+ }
599
+ const row = await this.api.getNote(noteId);
600
+ const { folders } = await this.api.listFolders();
601
+ const exposure = this.exposureOf(folders);
602
+ this.requireExposed(exposure, row.folderId, noteId);
603
+ const folder = folders.find((f) => f.id === row.folderId);
604
+ if (folder === undefined) {
605
+ // Narrowing: requireExposed() above only passes for a folder that is
606
+ // in this same list, so there is always one to find.
607
+ throw new Error(`Note ${noteId}'s folder was not found.`);
608
+ }
609
+ const schema = await this.decodeSchemaFor(key, folder);
610
+ const defs = (0, attrs_js_1.sortedProps)(schema);
611
+ const { props: patch, applied, newOptions } = resolveValues(defs, values, opts.createOption === true);
612
+ // The attrs blob is built and size-checked before anything is written,
613
+ // so a note whose properties would not fit never costs the folder an
614
+ // orphan option.
615
+ const prevAttrs = (await this.decodeAttrsFor(key, row)) ?? (0, attrs_js_1.emptyAttrs)();
616
+ const props = { ...prevAttrs.props };
617
+ for (const [propId, value] of Object.entries(patch)) {
618
+ if (value === null) {
619
+ delete props[propId];
620
+ }
621
+ else {
622
+ props[propId] = value;
623
+ }
624
+ }
625
+ // `v` and every unknown field come from prevAttrs, so a blob written by
626
+ // a newer client survives this round trip (see updateNote()).
627
+ const attrs = { ...prevAttrs, props, updated: new Date().toISOString() };
628
+ const encryptedAttrs = await this.encodeAttrsChecked(key, attrs, noteId);
629
+ if (newOptions.size > 0) {
630
+ let nextSchema = schema;
631
+ for (const [defId, added] of newOptions) {
632
+ const def = defs.find((d) => d.id === defId);
633
+ nextSchema = (0, attrs_js_1.updateProp)(nextSchema, defId, {
634
+ options: [...(def?.options ?? []), ...added],
635
+ });
636
+ }
637
+ const encryptedSchema = await this.encodeSchemaChecked(key, nextSchema, folder.id);
638
+ if (this.epoch !== startEpoch)
639
+ throw lockedError();
640
+ await this.api.updateFolder(folder.id, { encryptedSchema });
641
+ }
642
+ if (this.epoch !== startEpoch)
643
+ throw lockedError();
644
+ await this.api.updateNote(noteId, { encryptedAttrs });
645
+ this.invalidateVisibleRows();
646
+ return { attrs, applied };
117
647
  });
118
648
  }
119
649
  async searchNotes(query, limit = 20) {
@@ -171,6 +701,7 @@ class NotesService {
171
701
  this.requireExposed(exposure, folderId, id);
172
702
  }
173
703
  await this.api.updateNote(id, { folderId });
704
+ this.invalidateVisibleRows();
174
705
  }
175
706
  async setStar(id, starred) {
176
707
  this.requireSession();
@@ -178,6 +709,7 @@ class NotesService {
178
709
  const exposure = await this.exposure();
179
710
  this.requireExposed(exposure, existingRow.folderId, id);
180
711
  await this.api.updateNote(id, { starred });
712
+ this.invalidateVisibleRows();
181
713
  }
182
714
  async archiveNote(id, archived) {
183
715
  this.requireSession();
@@ -185,6 +717,7 @@ class NotesService {
185
717
  const exposure = await this.exposure();
186
718
  this.requireExposed(exposure, existingRow.folderId, id);
187
719
  await this.api.updateNote(id, { archived });
720
+ this.invalidateVisibleRows();
188
721
  }
189
722
  async trashNote(id) {
190
723
  this.requireSession();
@@ -192,6 +725,7 @@ class NotesService {
192
725
  const exposure = await this.exposure();
193
726
  this.requireExposed(exposure, existingRow.folderId, id);
194
727
  await this.api.updateNote(id, { deleted: true });
728
+ this.invalidateVisibleRows();
195
729
  }
196
730
  async listFolders() {
197
731
  this.requireSession();
@@ -210,7 +744,22 @@ class NotesService {
210
744
  * respect it. */
211
745
  async exposure() {
212
746
  const { folders } = await this.api.listFolders();
213
- return (0, exposure_js_1.computeExposure)(folders);
747
+ return this.exposureOf(folders);
748
+ }
749
+ /** The same computation for a caller that already has the folder list and
750
+ * needs a folder out of it as well as the exposure (the property tools do:
751
+ * a folder's schema and its exposure come from the same row). */
752
+ exposureOf(folders) {
753
+ // Everything computeExposure() actually reads, in list order. Any change
754
+ // to it changes which notes are visible, so it invalidates the paged row
755
+ // cache keyed on this string. The key travels on the exposure object
756
+ // rather than in an instance field: two calls can be in flight at once,
757
+ // and a field would let the second one's key be stamped onto rows the
758
+ // first one filtered under a different exposure.
759
+ const key = folders
760
+ .map((f) => `${f.id}/${f.parentId ?? ''}/${f.mcpEnabled ? 1 : 0}/${f.kind ?? ''}`)
761
+ .join('|');
762
+ return { ...(0, exposure_js_1.computeExposure)(folders), key };
214
763
  }
215
764
  /** Throws unless `folderId` is exposed to MCP. Every method that reads or
216
765
  * mutates a specific note (by id) must call this — knowing a note's id is
@@ -246,6 +795,166 @@ class NotesService {
246
795
  }
247
796
  return fields;
248
797
  }
798
+ /** Decode (with per-unlock caching) a note's attrs blob. Mirrors
799
+ * decryptRow()'s epoch guard: a decode still in flight when the vault
800
+ * locks resolves for its own caller but never lands in the cache. */
801
+ async decodeAttrsFor(key, row) {
802
+ // Keyed on the encrypted blob itself, not just id:updatedAt like
803
+ // decryptCache: title/content changes always bump updatedAt together with
804
+ // their ciphertext, but an attrs-only write (or a caller handing in a row
805
+ // it patched in memory) can leave updatedAt behind while encryptedAttrs
806
+ // changed, and a stale hit here would silently serve the wrong props.
807
+ const cacheKey = `${row.id}:${row.updatedAt}:${row.encryptedAttrs ?? ''}`;
808
+ if (this.attrsCache.has(cacheKey)) {
809
+ return this.attrsCache.get(cacheKey) ?? null;
810
+ }
811
+ const startEpoch = this.epoch;
812
+ const attrs = await (0, attrs_js_1.decodeAttrs)(key, row.encryptedAttrs);
813
+ if (this.epoch === startEpoch) {
814
+ this.attrsCache.set(cacheKey, attrs);
815
+ }
816
+ return attrs;
817
+ }
818
+ /** Decrypt (with per-unlock caching) just a note's title, without touching
819
+ * its body, the cheaper primitive backlinks() and mention-matching need. */
820
+ async decryptTitleFor(key, row) {
821
+ if (!row.encryptedTitle) {
822
+ return row.title;
823
+ }
824
+ const cacheKey = `${row.id}:${row.updatedAt}`;
825
+ const cached = this.titleCache.get(cacheKey);
826
+ if (cached !== undefined)
827
+ return cached;
828
+ const startEpoch = this.epoch;
829
+ const title = await (0, virlow_crypto_1.decryptTitleField)(key, row.encryptedTitle, row.iv ?? '');
830
+ if (this.epoch === startEpoch) {
831
+ this.titleCache.set(cacheKey, title);
832
+ }
833
+ return title;
834
+ }
835
+ /** The folder-visible, non-hidden rows a mention/backlink scan is allowed to
836
+ * see, same exposure rules as listNotes()/searchNotes(), bounded by
837
+ * SEARCH_SCAN_CAP for the same reason searchNotes() is. Accepts an
838
+ * already-computed `exposure` so a caller that has just checked it (as
839
+ * updateNote() has, for requireExposed()) does not pay for a second
840
+ * listFolders() round trip. */
841
+ async listVisibleRows(exposure) {
842
+ const exp = exposure ?? (await this.exposure());
843
+ const startEpoch = this.epoch;
844
+ const key = `${startEpoch}:${exp.key}`;
845
+ if (this.visibleRowsCache?.key === key)
846
+ return this.visibleRowsCache.rows;
847
+ const rows = [];
848
+ let page = 1;
849
+ for (;;) {
850
+ const { notes, pagination } = await this.api.listNotes({ page, limit: SEARCH_PAGE_LIMIT, deleted: false });
851
+ for (const row of notes) {
852
+ if (row.hidden === true)
853
+ continue;
854
+ // Archived notes are out of the way by the user's own choice, so an
855
+ // archived note is not a backlink source and not a mention target.
856
+ // The web index excludes them for the same reason; without this,
857
+ // get_backlinks and the web disagreed about the same vault.
858
+ if (row.archived === true)
859
+ continue;
860
+ if (!exp.allows(row.folderId))
861
+ continue;
862
+ rows.push(row);
863
+ if (rows.length >= exports.SEARCH_SCAN_CAP) {
864
+ this.cacheVisibleRows(key, startEpoch, rows);
865
+ return rows;
866
+ }
867
+ }
868
+ if (notes.length === 0 || page >= pagination.pages)
869
+ break;
870
+ page++;
871
+ }
872
+ this.cacheVisibleRows(key, startEpoch, rows);
873
+ return rows;
874
+ }
875
+ /** Same guard as decryptRow(): a paging run that was still in flight when
876
+ * the vault locked must not seed the cache for the session that follows. */
877
+ cacheVisibleRows(key, startEpoch, rows) {
878
+ if (this.epoch !== startEpoch)
879
+ return;
880
+ this.visibleRowsCache = { key, rows };
881
+ }
882
+ /** Title entries for mention-matching against a note being saved: one entry
883
+ * per other folder-visible note's title, plus one more per alias in its
884
+ * attrs (an alias is another name mentions should recognize, same as the
885
+ * title). Titles come from decryptTitleFor() and aliases from
886
+ * decodeAttrsFor() (the same per-epoch caches backlinks() uses), so this
887
+ * costs no body decryption. */
888
+ async visibleTitles(key, excludeId, exposure) {
889
+ const rows = await this.listVisibleRows(exposure);
890
+ const entries = [];
891
+ for (const row of rows) {
892
+ if (row.id === excludeId)
893
+ continue;
894
+ entries.push({ id: row.id, title: await this.decryptTitleFor(key, row) });
895
+ const attrs = await this.decodeAttrsFor(key, row);
896
+ for (const alias of attrs?.aliases ?? []) {
897
+ entries.push({ id: row.id, title: alias });
898
+ }
899
+ }
900
+ return entries;
901
+ }
902
+ /** The folder a property tool was pointed at, refused unless it exists and
903
+ * the user has opened it to MCP. Knowing a folder id is no more consent
904
+ * than knowing a note id is (see requireExposed). */
905
+ async requireFolder(folderId) {
906
+ const { folders } = await this.api.listFolders();
907
+ const folder = folders.find((f) => f.id === folderId);
908
+ if (folder === undefined) {
909
+ throw new Error(`There is no folder ${folderId}. Run list_folders to see the folders exposed to MCP.`);
910
+ }
911
+ if (!this.exposureOf(folders).allows(folder.id)) {
912
+ throw new Error(`Folder ${folderId} is not exposed to MCP. Turn on "Expose to MCP" in that folder's ` +
913
+ 'settings in the Virlow app to access it here.');
914
+ }
915
+ return folder;
916
+ }
917
+ /** Decode (with per-unlock caching) a folder's property definitions. A
918
+ * folder that has never had any decodes to an empty schema rather than
919
+ * null, so every caller can treat the two the same way. */
920
+ async decodeSchemaFor(key, folder) {
921
+ if (!folder.encryptedSchema)
922
+ return (0, attrs_js_1.emptySchema)();
923
+ const cacheKey = `${folder.id}:${folder.encryptedSchema}`;
924
+ const cached = this.schemaCache.get(cacheKey);
925
+ if (cached)
926
+ return cached;
927
+ const startEpoch = this.epoch;
928
+ const schema = (await (0, attrs_js_1.decodeSchema)(key, folder.encryptedSchema)) ?? (0, attrs_js_1.emptySchema)();
929
+ // Same guard as decryptRow(): a decode still in flight when the vault
930
+ // locks resolves for its own caller but never seeds the next session.
931
+ if (this.epoch === startEpoch) {
932
+ this.schemaCache.set(cacheKey, schema);
933
+ }
934
+ return schema;
935
+ }
936
+ /** Encrypts a folder schema and enforces the server's size cap
937
+ * client-side, the way encodeAttrsChecked() does for a note's attrs. */
938
+ async encodeSchemaChecked(key, schema, folderId) {
939
+ const encryptedSchema = await (0, attrs_js_1.encodeSchema)(key, schema);
940
+ const byteLength = new TextEncoder().encode(encryptedSchema).length;
941
+ if (byteLength > attrs_js_1.SCHEMA_MAX_BYTES) {
942
+ throw new Error(`Folder ${folderId}'s property definitions are too large to save (${byteLength} bytes, ` +
943
+ `limit ${attrs_js_1.SCHEMA_MAX_BYTES}). Remove some properties or options and try again.`);
944
+ }
945
+ return encryptedSchema;
946
+ }
947
+ /** Encrypts attrs and enforces the server's size cap client-side: an
948
+ * oversized blob is refused with a clear message rather than sent and
949
+ * bounced back as an HTTP 413 `ATTRS_TOO_LARGE`. */
950
+ async encodeAttrsChecked(key, attrs, id) {
951
+ const encryptedAttrs = await (0, attrs_js_1.encodeAttrs)(key, attrs);
952
+ const byteLength = new TextEncoder().encode(encryptedAttrs).length;
953
+ if (byteLength > attrs_js_1.ATTRS_MAX_BYTES) {
954
+ throw new Error(`Note ${id}'s properties and aliases are too large to save (${byteLength} bytes, limit ${attrs_js_1.ATTRS_MAX_BYTES}). Remove some properties, tags, or aliases and try again.`);
955
+ }
956
+ return encryptedAttrs;
957
+ }
249
958
  buildSnippet(title, content, contentMatchIndex) {
250
959
  if (contentMatchIndex === -1) {
251
960
  return title;