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