@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
@@ -0,0 +1,635 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SCHEMA_MAX_BYTES = exports.ATTRS_MAX_BYTES = exports.TAGS_PROP = exports.CREATED = exports.LAST_CHANGED = exports.HOME_PRESET_LABELS = exports.HOME_PRESETS = exports.DEFAULT_HOME_WIDGETS = exports.HOME_SUMMARY_MAX = exports.HOME_TEXT_MAX = exports.HOME_TITLE_MAX = exports.PINNED_MAX = void 0;
4
+ exports.isPropertyFilter = isPropertyFilter;
5
+ exports.matchesFilter = matchesFilter;
6
+ exports.matchesAll = matchesAll;
7
+ exports.isHomeSort = isHomeSort;
8
+ exports.spanOf = spanOf;
9
+ exports.isHomeWidget = isHomeWidget;
10
+ exports.withHomeWidgets = withHomeWidgets;
11
+ exports.newWidgetId = newWidgetId;
12
+ exports.homeWidgets = homeWidgets;
13
+ exports.isHomePreset = isHomePreset;
14
+ exports.presetWidgets = presetWidgets;
15
+ exports.isPinned = isPinned;
16
+ exports.withPinToggled = withPinToggled;
17
+ exports.isNoteDate = isNoteDate;
18
+ exports.formatProp = formatProp;
19
+ exports.emptyAttrs = emptyAttrs;
20
+ exports.emptySchema = emptySchema;
21
+ exports.newPropId = newPropId;
22
+ exports.sortedProps = sortedProps;
23
+ exports.defineProp = defineProp;
24
+ exports.updateProp = updateProp;
25
+ exports.removeProp = removeProp;
26
+ exports.reorderProps = reorderProps;
27
+ exports.encodeAttrs = encodeAttrs;
28
+ exports.decodeAttrs = decodeAttrs;
29
+ exports.encodeSchema = encodeSchema;
30
+ exports.decodeSchema = decodeSchema;
31
+ exports.tagsFromAttrs = tagsFromAttrs;
32
+ exports.withTags = withTags;
33
+ const virlow_crypto_1 = require("@batalabs/virlow-crypto");
34
+ const FILTER_OPS = ['is', 'has', 'checked', 'unchecked', 'empty', 'notEmpty'];
35
+ function isPropertyFilter(value) {
36
+ if (!isRecord(value))
37
+ return false;
38
+ if (typeof value.propId !== 'string' || value.propId === '')
39
+ return false;
40
+ if (!FILTER_OPS.includes(value.op))
41
+ return false;
42
+ if (value.op === 'is' || value.op === 'has')
43
+ return typeof value.value === 'string';
44
+ return value.value === undefined;
45
+ }
46
+ /** Whether a value counts as absent. An empty string and an empty list read as
47
+ * nothing to the person looking at the row, so they filter as nothing too. */
48
+ function isEmptyValue(value) {
49
+ if (value === undefined)
50
+ return true;
51
+ switch (value.t) {
52
+ case 'text':
53
+ case 'url':
54
+ case 'select':
55
+ case 'date':
56
+ return value.v === '';
57
+ case 'multiSelect':
58
+ case 'relation':
59
+ return value.v.length === 0;
60
+ case 'number':
61
+ return Number.isNaN(value.v);
62
+ case 'checkbox':
63
+ // A checkbox is never absent: false is an answer, not a blank.
64
+ return false;
65
+ default:
66
+ return true;
67
+ }
68
+ }
69
+ function matchesFilter(attrs, filter) {
70
+ const value = attrs?.props[filter.propId];
71
+ switch (filter.op) {
72
+ case 'is':
73
+ return value?.t === 'select' && value.v === filter.value;
74
+ case 'has':
75
+ return value?.t === 'multiSelect' && value.v.includes(filter.value);
76
+ case 'checked':
77
+ return value?.t === 'checkbox' && value.v === true;
78
+ case 'unchecked':
79
+ // Unticked and never touched are the same thing to the reader, so a note
80
+ // with no value for the box counts as unchecked rather than dropping out
81
+ // of both halves of the filter.
82
+ return value === undefined || (value.t === 'checkbox' && value.v === false);
83
+ case 'empty':
84
+ return isEmptyValue(value);
85
+ case 'notEmpty':
86
+ return !isEmptyValue(value);
87
+ default:
88
+ return true;
89
+ }
90
+ }
91
+ /** Every filter passes. Filters across different properties are an AND: each
92
+ * one narrows what the last one left, which is what picking two of them from
93
+ * a menu looks like it should do. */
94
+ function matchesAll(attrs, filters) {
95
+ return (filters ?? []).every((filter) => matchesFilter(attrs, filter));
96
+ }
97
+ function isHomeSort(value) {
98
+ return (isRecord(value) &&
99
+ typeof value.by === 'string' &&
100
+ value.by !== '' &&
101
+ (value.dir === 'asc' || value.dir === 'desc'));
102
+ }
103
+ const PINNED_LOOKS = ['list', 'notes'];
104
+ /** The most notes one pinned block holds. Past this it is a listing, and a
105
+ * table with a filter is the block for a listing. */
106
+ exports.PINNED_MAX = 50;
107
+ /** The longest a table block's title may be: a heading, not a description. */
108
+ exports.HOME_TITLE_MAX = 60;
109
+ /** The longest a `text` widget may be. A home is a surface, not a document;
110
+ * anything longer belongs in a note. */
111
+ exports.HOME_TEXT_MAX = 280;
112
+ /** The longest a `summary` may be. Room for a paragraph or two of context,
113
+ * and short of the point where the dashboard becomes a document. */
114
+ exports.HOME_SUMMARY_MAX = 2000;
115
+ const HOME_KINDS = [
116
+ 'counts',
117
+ 'recent',
118
+ 'starred',
119
+ 'breakdown',
120
+ 'upcoming',
121
+ 'text',
122
+ 'summary',
123
+ 'table',
124
+ 'calendar',
125
+ 'board',
126
+ 'activity',
127
+ 'stale',
128
+ 'tasks',
129
+ 'pinned',
130
+ 'quick',
131
+ ];
132
+ const SPANS = ['half', 'full'];
133
+ /**
134
+ * A block's width when it has not been given one.
135
+ *
136
+ * A table's columns and a month grid need the room; a stat row and a short
137
+ * list do not, and pairing them is what makes the page read as a dashboard
138
+ * rather than a column of cards.
139
+ */
140
+ const DEFAULT_SPAN = {
141
+ counts: 'half',
142
+ recent: 'half',
143
+ starred: 'half',
144
+ breakdown: 'half',
145
+ upcoming: 'half',
146
+ text: 'full',
147
+ summary: 'full',
148
+ table: 'full',
149
+ calendar: 'full',
150
+ board: 'full',
151
+ activity: 'half',
152
+ stale: 'half',
153
+ tasks: 'half',
154
+ pinned: 'half',
155
+ quick: 'full',
156
+ };
157
+ function spanOf(widget) {
158
+ return widget.span ?? DEFAULT_SPAN[widget.kind] ?? 'half';
159
+ }
160
+ /** True for a block this build can draw. Exported so the MCP layer can refuse
161
+ * one at the tool boundary rather than writing it and skipping it on read. */
162
+ function isHomeWidget(value) {
163
+ if (!isRecord(value))
164
+ return false;
165
+ if (typeof value.id !== 'string')
166
+ return false;
167
+ if (!HOME_KINDS.includes(value.kind))
168
+ return false;
169
+ // An unknown span is dropped rather than rendered at some guessed width.
170
+ if (value.span !== undefined && !SPANS.includes(value.span))
171
+ return false;
172
+ if (value.kind === 'breakdown' ||
173
+ value.kind === 'upcoming' ||
174
+ value.kind === 'calendar' ||
175
+ value.kind === 'board') {
176
+ return typeof value.propId === 'string';
177
+ }
178
+ if (value.kind === 'table' || value.kind === 'tasks') {
179
+ // A malformed filter is refused rather than ignored: a table that silently
180
+ // shows everything is wrong in the way that is hardest to notice.
181
+ if (value.filters !== undefined) {
182
+ if (!Array.isArray(value.filters) || !value.filters.every(isPropertyFilter))
183
+ return false;
184
+ }
185
+ if (value.kind === 'table') {
186
+ if (value.sort !== undefined && !isHomeSort(value.sort))
187
+ return false;
188
+ if (value.title !== undefined) {
189
+ if (typeof value.title !== 'string' || value.title.length > exports.HOME_TITLE_MAX)
190
+ return false;
191
+ }
192
+ }
193
+ return true;
194
+ }
195
+ if (value.kind === 'pinned') {
196
+ if (!Array.isArray(value.noteIds) ||
197
+ value.noteIds.length > exports.PINNED_MAX ||
198
+ !value.noteIds.every((id) => typeof id === 'string' && id !== ''))
199
+ return false;
200
+ if (value.look !== undefined && !PINNED_LOOKS.includes(value.look))
201
+ return false;
202
+ if (value.colors !== undefined) {
203
+ if (!isRecord(value.colors))
204
+ return false;
205
+ if (!Object.values(value.colors).every((c) => typeof c === 'string' && c.length <= 24))
206
+ return false;
207
+ }
208
+ return true;
209
+ }
210
+ if (value.kind === 'text')
211
+ return typeof value.body === 'string';
212
+ if (value.kind === 'summary') {
213
+ if (typeof value.body !== 'string')
214
+ return false;
215
+ return value.at === undefined || typeof value.at === 'string';
216
+ }
217
+ return true;
218
+ }
219
+ /**
220
+ * What a workspace shows when nobody has arranged its home.
221
+ *
222
+ * A default rather than an empty screen: a workspace should be worth opening
223
+ * the first time, and these three need no configuration to mean something.
224
+ */
225
+ exports.DEFAULT_HOME_WIDGETS = [
226
+ { id: 'default-counts', kind: 'counts' },
227
+ { id: 'default-recent', kind: 'recent', limit: 5 },
228
+ { id: 'default-starred', kind: 'starred', limit: 5 },
229
+ ];
230
+ /**
231
+ * The widgets to draw, dropping any this build cannot read.
232
+ *
233
+ * Mirrors sortedProps: a newer client may write a kind this one does not
234
+ * know, and the right answer is to skip it rather than to throw away the
235
+ * whole home or crash rendering it.
236
+ */
237
+ /**
238
+ * A home with `widgets` written out in full.
239
+ *
240
+ * The first edit to a workspace that has never been arranged has to write the
241
+ * defaults down alongside the change. Otherwise removing a default block does
242
+ * nothing: homeWidgets would see an empty list, fall back, and put it
243
+ * straight back. Materialising turns "no opinion" into "this arrangement",
244
+ * which is what an edit means.
245
+ */
246
+ function withHomeWidgets(schema, widgets) {
247
+ return { ...schema, home: { ...schema.home, widgets } };
248
+ }
249
+ /** A widget id that will not collide with another in the same home. */
250
+ function newWidgetId() {
251
+ const bytes = crypto.getRandomValues(new Uint8Array(6));
252
+ return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
253
+ }
254
+ function homeWidgets(schema,
255
+ /** The workspace's properties. Given them, an unarranged home leads with a
256
+ * calendar when there is a date property to place notes on. Omitted, the
257
+ * default is the plain three, which is what a caller with no schema to
258
+ * hand should get. */
259
+ defs) {
260
+ const configured = schema?.home?.widgets;
261
+ if (!Array.isArray(configured))
262
+ return defaultWidgets(defs);
263
+ const known = configured.filter(isHomeWidget);
264
+ return known.length > 0 ? known : defaultWidgets(defs);
265
+ }
266
+ /**
267
+ * What an unarranged workspace shows.
268
+ *
269
+ * A calendar first: dates are what people open a workspace to see, and a
270
+ * month grid answers "what is happening here" in a way three counts cannot.
271
+ */
272
+ function defaultWidgets(defs) {
273
+ // A workspace's own date property when it has one, and otherwise the notes'
274
+ // edit times, so every workspace opens on a calendar rather than only the
275
+ // ones that have been configured.
276
+ const date = defs?.find((def) => def.type === 'date');
277
+ return [
278
+ {
279
+ id: 'default-calendar',
280
+ kind: 'calendar',
281
+ propId: date ? date.id : exports.LAST_CHANGED,
282
+ },
283
+ ...exports.DEFAULT_HOME_WIDGETS,
284
+ ];
285
+ }
286
+ /**
287
+ * Starting arrangements.
288
+ *
289
+ * An empty page is where most people stall: every block exists, and none of
290
+ * them says which six to put together for a project. A preset is that
291
+ * answer written down, built from the workspace's own properties where it
292
+ * has them (a board wants a select, a calendar wants a date) and from the
293
+ * notes' own dates where it does not, so every preset draws something on
294
+ * the workspace it is applied to. Applying one replaces the arrangement,
295
+ * and the one it replaced is kept for undo like any other change.
296
+ */
297
+ exports.HOME_PRESETS = ['project', 'research', 'journal'];
298
+ exports.HOME_PRESET_LABELS = {
299
+ project: {
300
+ name: 'Project',
301
+ blurb: 'Open tasks, a board by stage, a calendar, and what has gone quiet.',
302
+ },
303
+ research: {
304
+ name: 'Research',
305
+ blurb: 'Pinned sources, the latest additions, and a calendar of when things arrived.',
306
+ },
307
+ journal: {
308
+ name: 'Journal',
309
+ blurb: 'This week, recent entries, open tasks, and how often you write.',
310
+ },
311
+ };
312
+ function isHomePreset(value) {
313
+ return exports.HOME_PRESETS.includes(value);
314
+ }
315
+ function presetWidgets(preset, defs = [], id = newWidgetId) {
316
+ const select = defs.find((d) => d.type === 'select');
317
+ const date = defs.find((d) => d.type === 'date');
318
+ switch (preset) {
319
+ case 'project':
320
+ return [
321
+ { id: id(), kind: 'tasks', limit: 10, span: 'half' },
322
+ { id: id(), kind: 'counts', span: 'half' },
323
+ select
324
+ ? { id: id(), kind: 'board', propId: select.id, span: 'full' }
325
+ : { id: id(), kind: 'table', limit: 10, span: 'full' },
326
+ { id: id(), kind: 'calendar', propId: date ? date.id : exports.LAST_CHANGED, span: 'full' },
327
+ { id: id(), kind: 'pinned', noteIds: [], look: 'notes', span: 'half' },
328
+ { id: id(), kind: 'stale', days: 30, limit: 5, span: 'half' },
329
+ ];
330
+ case 'research':
331
+ return [
332
+ { id: id(), kind: 'pinned', noteIds: [], look: 'notes', span: 'half' },
333
+ { id: id(), kind: 'recent', limit: 10, span: 'half' },
334
+ {
335
+ id: id(),
336
+ kind: 'table',
337
+ title: 'Latest additions',
338
+ limit: 10,
339
+ sort: { by: exports.CREATED, dir: 'desc' },
340
+ span: 'full',
341
+ },
342
+ select
343
+ ? { id: id(), kind: 'breakdown', propId: select.id, span: 'half' }
344
+ : { id: id(), kind: 'activity', weeks: 12, span: 'half' },
345
+ { id: id(), kind: 'tasks', limit: 10, span: 'half' },
346
+ { id: id(), kind: 'calendar', propId: exports.CREATED, span: 'full' },
347
+ ];
348
+ case 'journal':
349
+ return [
350
+ { id: id(), kind: 'quick', span: 'full' },
351
+ { id: id(), kind: 'calendar', propId: exports.LAST_CHANGED, range: 'week', span: 'full' },
352
+ { id: id(), kind: 'recent', limit: 7, span: 'half' },
353
+ { id: id(), kind: 'tasks', limit: 10, span: 'half' },
354
+ { id: id(), kind: 'activity', weeks: 26, span: 'half' },
355
+ { id: id(), kind: 'stale', days: 14, limit: 5, span: 'half' },
356
+ ];
357
+ default:
358
+ return defaultWidgets(defs);
359
+ }
360
+ }
361
+ /** True when a note is on one of this home's Pinned blocks. */
362
+ function isPinned(schema, noteId) {
363
+ return homeWidgets(schema).some((w) => w.kind === 'pinned' && w.noteIds.includes(noteId));
364
+ }
365
+ /**
366
+ * Pins a note to a workspace's home, or unpins it: what "Pin to dashboard" in
367
+ * a note's menu writes.
368
+ *
369
+ * Unpinning takes the note off every Pinned block it is on. Pinning puts it
370
+ * on the first block with room, and when the home has no Pinned block yet it
371
+ * adds one, as sticky notes, at the top: pinning from a note is a request to
372
+ * see it on the dashboard, and a pin into a block nobody can see is not that.
373
+ * An unarranged home is written out as its defaults plus the new block, so
374
+ * nothing already on it disappears.
375
+ *
376
+ * Null when every Pinned block is full, which the caller has to say.
377
+ */
378
+ function withPinToggled(schema, defs, noteId) {
379
+ const widgets = homeWidgets(schema, defs);
380
+ if (isPinned(schema, noteId)) {
381
+ return withHomeWidgets(schema, widgets.map((w) => w.kind === 'pinned' && w.noteIds.includes(noteId)
382
+ ? { ...w, noteIds: w.noteIds.filter((id) => id !== noteId) }
383
+ : w));
384
+ }
385
+ const target = widgets.find((w) => w.kind === 'pinned' && w.noteIds.length < exports.PINNED_MAX);
386
+ if (target) {
387
+ return withHomeWidgets(schema, widgets.map((w) => w === target && w.kind === 'pinned'
388
+ ? { ...w, noteIds: [...w.noteIds, noteId] }
389
+ : w));
390
+ }
391
+ if (widgets.some((w) => w.kind === 'pinned'))
392
+ return null;
393
+ return withHomeWidgets(schema, [
394
+ { id: newWidgetId(), kind: 'pinned', noteIds: [noteId], look: 'notes' },
395
+ ...widgets,
396
+ ]);
397
+ }
398
+ /**
399
+ * The reserved property id meaning "place notes by when they last changed".
400
+ *
401
+ * Not a property: it is a fact every note already carries. A calendar could
402
+ * previously only be drawn by a workspace that had defined a date property,
403
+ * which made "calendar by default" false for exactly the workspaces that had
404
+ * never set anything up, which is most of them at the point somebody first
405
+ * opens a dashboard. A calendar of when you last touched things needs no
406
+ * setup and answers a real question.
407
+ *
408
+ * A calendar in this mode is read only: you cannot decide when a note was
409
+ * last edited.
410
+ */
411
+ exports.LAST_CHANGED = '@lastChanged';
412
+ /**
413
+ * The reserved property id meaning "place notes by when they were created".
414
+ *
415
+ * The other half of the same idea as LAST_CHANGED. They answer different
416
+ * questions and people want both: what have I touched lately, and what did I
417
+ * start when. Neither needs a workspace to have defined anything, and neither
418
+ * can be dragged, because you cannot decide when a note came into existence.
419
+ */
420
+ exports.CREATED = '@created';
421
+ /** True for the built-in date sources, which come from the note itself rather
422
+ * than from a property somebody defined. */
423
+ function isNoteDate(propId) {
424
+ return propId === exports.LAST_CHANGED || propId === exports.CREATED;
425
+ }
426
+ /** The reserved property id that carries what used to be the plaintext tags column. */
427
+ exports.TAGS_PROP = 'tags';
428
+ /** Matches the server's cap on the encrypted attrs blob (HTTP 413
429
+ * `ATTRS_TOO_LARGE`), measured as UTF-8 bytes of the ciphertext string. The
430
+ * single source of truth for both mcp-core (notes.ts) and the web app
431
+ * (src/lib/attrs/next.ts), so the two never drift apart. */
432
+ exports.ATTRS_MAX_BYTES = 16384;
433
+ /** Matches the server's cap on the encrypted folder schema blob (HTTP 413
434
+ * `SCHEMA_TOO_LARGE`), measured as UTF-8 bytes of the ciphertext string. Lives
435
+ * beside ATTRS_MAX_BYTES for the same reason: mcp-core (notes.ts) and the web
436
+ * app (src/hooks/useFolderSchema.ts) both refuse an oversize blob before
437
+ * sending it, and two copies of the number would eventually disagree. */
438
+ exports.SCHEMA_MAX_BYTES = 32768;
439
+ const DATE = new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
440
+ function day(value) {
441
+ // Date-only strings are parsed as UTC; formatting them in the local zone
442
+ // would show the day before for anyone west of Greenwich.
443
+ const iso = /^\d{4}-\d{2}-\d{2}$/.test(value) ? `${value}T12:00:00Z` : value;
444
+ const parsed = new Date(iso);
445
+ return Number.isNaN(parsed.getTime()) ? '' : DATE.format(parsed);
446
+ }
447
+ function formatProp(def, value) {
448
+ if (!value)
449
+ return '';
450
+ switch (value.t) {
451
+ case 'text':
452
+ case 'url':
453
+ return value.v;
454
+ case 'number':
455
+ return String(value.v);
456
+ case 'checkbox':
457
+ return value.v ? 'Yes' : 'No';
458
+ case 'date':
459
+ return value.end ? `${day(value.v)} to ${day(value.end)}` : day(value.v);
460
+ case 'select':
461
+ return def.options?.find((o) => o.id === value.v)?.name ?? '';
462
+ case 'multiSelect':
463
+ return value.v.map((id) => def.options?.find((o) => o.id === id)?.name).filter(Boolean).join(', ');
464
+ case 'relation':
465
+ return value.v.length === 1 ? '1 note' : `${value.v.length} notes`;
466
+ default:
467
+ return '';
468
+ }
469
+ }
470
+ function emptyAttrs(now = new Date().toISOString()) {
471
+ return { v: 1, props: {}, links: [], mentions: [], updated: now };
472
+ }
473
+ function emptySchema() {
474
+ return { v: 1, properties: [], views: { default: 'list' } };
475
+ }
476
+ const PROP_TYPES = ['text', 'number', 'select', 'multiSelect', 'date', 'checkbox', 'url', 'relation'];
477
+ /** 12 hex characters, enough entropy that two properties never collide. */
478
+ function newPropId() {
479
+ const bytes = crypto.getRandomValues(new Uint8Array(6));
480
+ return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
481
+ }
482
+ function isPropDefinition(value) {
483
+ if (!isRecord(value))
484
+ return false;
485
+ return (typeof value.id === 'string' &&
486
+ typeof value.name === 'string' &&
487
+ PROP_TYPES.includes(value.type) &&
488
+ typeof value.order === 'number');
489
+ }
490
+ /** Definitions in display order. Unknown shapes are dropped rather than
491
+ * thrown on: a newer client may write a type this one does not know. */
492
+ function sortedProps(schema) {
493
+ const list = Array.isArray(schema?.properties) ? schema.properties : [];
494
+ return list
495
+ .filter(isPropDefinition)
496
+ .slice()
497
+ .sort((a, b) => a.order - b.order);
498
+ }
499
+ /**
500
+ * Everything in `properties` that `sortedProps` drops: an entry whose type
501
+ * this build does not know, or whose shape it cannot read. A newer client
502
+ * writes those, and this one must hand them back untouched.
503
+ *
504
+ * Every mutator below rebuilds `properties`, and rebuilding it from
505
+ * `sortedProps` alone deleted these: renaming one property in the web app
506
+ * silently destroyed a property the desktop build had defined. The same vault
507
+ * is edited by three clients, and `setProperties` already keeps unknown attrs
508
+ * fields, so dropping them here made the codec disagree with itself.
509
+ *
510
+ * They are re-appended at the end and never renumbered. Their `order` belongs
511
+ * to a definition this build cannot read, and rewriting it would be guessing.
512
+ */
513
+ function unreadableProps(schema) {
514
+ const list = Array.isArray(schema?.properties) ? schema.properties : [];
515
+ return list.filter((p) => !isPropDefinition(p));
516
+ }
517
+ /** The id an entry carries, whether or not this build can read the rest of
518
+ * it, so an explicit remove can still find it. */
519
+ function idOf(prop) {
520
+ const id = prop.id;
521
+ return typeof id === 'string' ? id : undefined;
522
+ }
523
+ /** Appends a new property definition, assigning it a fresh id (unless one is
524
+ * given) and an order past the current last property. Never mutates schema. */
525
+ function defineProp(schema, def) {
526
+ const props = sortedProps(schema);
527
+ const last = props[props.length - 1];
528
+ const order = last === undefined ? 0 : last.order + 1;
529
+ const { id, ...rest } = def;
530
+ return { ...schema, properties: [...props, { ...rest, id: id ?? newPropId(), order }, ...unreadableProps(schema)] };
531
+ }
532
+ /** Patches a property definition by id, keeping its id fixed regardless of
533
+ * what the patch contains. Never mutates schema. */
534
+ function updateProp(schema, id, patch) {
535
+ return {
536
+ ...schema,
537
+ properties: [
538
+ ...sortedProps(schema).map((p) => (p.id === id ? { ...p, ...patch, id: p.id } : p)),
539
+ // Never patched: a patch is written against a type this build knows,
540
+ // and applying it to a definition it cannot read would corrupt it.
541
+ ...unreadableProps(schema),
542
+ ],
543
+ };
544
+ }
545
+ /** Removes a property definition by id. Never mutates schema. */
546
+ function removeProp(schema, id) {
547
+ return {
548
+ ...schema,
549
+ properties: [
550
+ ...sortedProps(schema).filter((p) => p.id !== id),
551
+ // Keeping the unreadable ones is about not deleting them by accident.
552
+ // A remove naming that id is not an accident, so it takes effect.
553
+ ...unreadableProps(schema).filter((p) => idOf(p) !== id),
554
+ ],
555
+ };
556
+ }
557
+ /** Reassigns order so the definitions match orderedIds; any definition whose
558
+ * id is not in the list keeps its relative position after the known ones.
559
+ * Never mutates schema. */
560
+ function reorderProps(schema, orderedIds) {
561
+ const props = sortedProps(schema);
562
+ const rank = new Map(orderedIds.map((id, i) => [id, i]));
563
+ const next = props
564
+ .slice()
565
+ .sort((a, b) => (rank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (rank.get(b.id) ?? Number.MAX_SAFE_INTEGER))
566
+ .map((p, i) => ({ ...p, order: i }));
567
+ return { ...schema, properties: [...next, ...unreadableProps(schema)] };
568
+ }
569
+ function isRecord(value) {
570
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
571
+ }
572
+ function stringArray(value) {
573
+ return Array.isArray(value) ? value.filter((x) => typeof x === 'string') : [];
574
+ }
575
+ /**
576
+ * The type says v: 1, but a blob written by a future client may carry a
577
+ * higher version number. That numeric value is preserved on purpose, not
578
+ * downgraded, so a round trip through this reader does not lose it; only a
579
+ * missing or non-numeric v falls back to 1. The cast is needed because the
580
+ * field's declared type is the literal 1, not number.
581
+ */
582
+ function decodedVersion(r) {
583
+ return (typeof r.v === 'number' && Number.isFinite(r.v) ? r.v : 1);
584
+ }
585
+ /** Normalize a decoded object into a NoteAttrsV1, keeping unknown fields. */
586
+ function asAttrs(raw) {
587
+ const r = isRecord(raw) ? raw : {};
588
+ return {
589
+ ...r,
590
+ v: decodedVersion(r),
591
+ props: isRecord(r.props) ? r.props : {},
592
+ links: stringArray(r.links),
593
+ mentions: stringArray(r.mentions),
594
+ ...(r.aliases !== undefined ? { aliases: stringArray(r.aliases) } : {}),
595
+ updated: typeof r.updated === 'string' ? r.updated : new Date(0).toISOString(),
596
+ };
597
+ }
598
+ function asSchema(raw) {
599
+ const r = isRecord(raw) ? raw : {};
600
+ const views = isRecord(r.views) ? r.views : {};
601
+ const def = views.default;
602
+ // 'home' was briefly one of these. It is not a way of listing notes, so it
603
+ // moved to the editor pane and out of this union; a workspace saved as
604
+ // 'home' by that build clamps to 'list' here rather than rendering nothing.
605
+ const allowed = ['list', 'table', 'board', 'calendar'];
606
+ return {
607
+ ...r,
608
+ v: decodedVersion(r),
609
+ properties: Array.isArray(r.properties) ? r.properties : [],
610
+ views: { ...views, default: allowed.includes(def) ? def : 'list' },
611
+ };
612
+ }
613
+ async function encodeAttrs(key, attrs) {
614
+ return (0, virlow_crypto_1.encryptStringBlob)(key, JSON.stringify(attrs));
615
+ }
616
+ async function decodeAttrs(key, blob) {
617
+ if (!blob)
618
+ return null;
619
+ return asAttrs(JSON.parse(await (0, virlow_crypto_1.decryptStringBlob)(key, blob)));
620
+ }
621
+ async function encodeSchema(key, schema) {
622
+ return (0, virlow_crypto_1.encryptStringBlob)(key, JSON.stringify(schema));
623
+ }
624
+ async function decodeSchema(key, blob) {
625
+ if (!blob)
626
+ return null;
627
+ return asSchema(JSON.parse(await (0, virlow_crypto_1.decryptStringBlob)(key, blob)));
628
+ }
629
+ function tagsFromAttrs(attrs) {
630
+ const value = attrs?.props[exports.TAGS_PROP];
631
+ return value && value.t === 'multiSelect' ? [...value.v] : [];
632
+ }
633
+ function withTags(attrs, tags, now = new Date().toISOString()) {
634
+ return { ...attrs, props: { ...attrs.props, [exports.TAGS_PROP]: { t: 'multiSelect', v: [...tags] } }, updated: now };
635
+ }
@@ -14,6 +14,10 @@ export type { Exposure } from './exposure.js';
14
14
  export { EmbeddingCache } from './embedding-cache.js';
15
15
  export type { EmbeddingCacheLike } from './embedding-cache.js';
16
16
  export { MEMORIES_DISABLED_MESSAGE, memoriesAllowed } from './memories-enabled.js';
17
+ export { ATTRS_MAX_BYTES, TAGS_PROP, decodeAttrs, decodeSchema, defineProp, emptyAttrs, emptySchema, encodeAttrs, encodeSchema, newPropId, removeProp, reorderProps, sortedProps, tagsFromAttrs, updateProp, withTags, } from './attrs.js';
18
+ export type { FolderSchemaV1, NoteAttrsV1, PropDefinition, PropOption, PropType, PropValue, TagsValue } from './attrs.js';
19
+ export { buildTitleMatcher, extractLinkIds, htmlToText } from './attrs-extract.js';
20
+ export type { TitleEntry, TitleMatcher } from './attrs-extract.js';
17
21
  /**
18
22
  * The MCP tool surface is NOT exported here. It lives at the `/tools` subpath,
19
23
  * which is ESM-only, because it depends on @modelcontextprotocol/sdk — an
package/dist/cjs/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.memoriesAllowed = exports.MEMORIES_DISABLED_MESSAGE = exports.EmbeddingCache = exports.filteredNotice = exports.computeExposure = exports.resolveMemoryTree = exports.ensureNamespace = exports.MEMORIES_ROOT_NAME = exports.toCodeEnvelope = exports.serializeMemoryBody = exports.parseMemoryNote = exports.mergeMeta = exports.MEMORY_NOTE_TYPE = exports.SYNC_PAGE_LIMIT = exports.MemoryStore = exports.SEARCH_SCAN_CAP = exports.NotesService = exports.Vault = exports.LockedError = exports.VirlowApi = exports.ApiError = void 0;
3
+ exports.htmlToText = exports.extractLinkIds = exports.buildTitleMatcher = exports.withTags = exports.updateProp = exports.tagsFromAttrs = exports.sortedProps = exports.reorderProps = exports.removeProp = exports.newPropId = exports.encodeSchema = exports.encodeAttrs = exports.emptySchema = exports.emptyAttrs = exports.defineProp = exports.decodeSchema = exports.decodeAttrs = exports.TAGS_PROP = exports.ATTRS_MAX_BYTES = exports.memoriesAllowed = exports.MEMORIES_DISABLED_MESSAGE = exports.EmbeddingCache = exports.filteredNotice = exports.computeExposure = exports.resolveMemoryTree = exports.ensureNamespace = exports.MEMORIES_ROOT_NAME = exports.toCodeEnvelope = exports.serializeMemoryBody = exports.parseMemoryNote = exports.mergeMeta = exports.MEMORY_NOTE_TYPE = exports.SYNC_PAGE_LIMIT = exports.MemoryStore = exports.SEARCH_SCAN_CAP = exports.NotesService = exports.Vault = exports.LockedError = exports.VirlowApi = exports.ApiError = void 0;
4
4
  var api_js_1 = require("./api.js");
5
5
  Object.defineProperty(exports, "ApiError", { enumerable: true, get: function () { return api_js_1.ApiError; } });
6
6
  Object.defineProperty(exports, "VirlowApi", { enumerable: true, get: function () { return api_js_1.VirlowApi; } });
@@ -31,6 +31,27 @@ Object.defineProperty(exports, "EmbeddingCache", { enumerable: true, get: functi
31
31
  var memories_enabled_js_1 = require("./memories-enabled.js");
32
32
  Object.defineProperty(exports, "MEMORIES_DISABLED_MESSAGE", { enumerable: true, get: function () { return memories_enabled_js_1.MEMORIES_DISABLED_MESSAGE; } });
33
33
  Object.defineProperty(exports, "memoriesAllowed", { enumerable: true, get: function () { return memories_enabled_js_1.memoriesAllowed; } });
34
+ var attrs_js_1 = require("./attrs.js");
35
+ Object.defineProperty(exports, "ATTRS_MAX_BYTES", { enumerable: true, get: function () { return attrs_js_1.ATTRS_MAX_BYTES; } });
36
+ Object.defineProperty(exports, "TAGS_PROP", { enumerable: true, get: function () { return attrs_js_1.TAGS_PROP; } });
37
+ Object.defineProperty(exports, "decodeAttrs", { enumerable: true, get: function () { return attrs_js_1.decodeAttrs; } });
38
+ Object.defineProperty(exports, "decodeSchema", { enumerable: true, get: function () { return attrs_js_1.decodeSchema; } });
39
+ Object.defineProperty(exports, "defineProp", { enumerable: true, get: function () { return attrs_js_1.defineProp; } });
40
+ Object.defineProperty(exports, "emptyAttrs", { enumerable: true, get: function () { return attrs_js_1.emptyAttrs; } });
41
+ Object.defineProperty(exports, "emptySchema", { enumerable: true, get: function () { return attrs_js_1.emptySchema; } });
42
+ Object.defineProperty(exports, "encodeAttrs", { enumerable: true, get: function () { return attrs_js_1.encodeAttrs; } });
43
+ Object.defineProperty(exports, "encodeSchema", { enumerable: true, get: function () { return attrs_js_1.encodeSchema; } });
44
+ Object.defineProperty(exports, "newPropId", { enumerable: true, get: function () { return attrs_js_1.newPropId; } });
45
+ Object.defineProperty(exports, "removeProp", { enumerable: true, get: function () { return attrs_js_1.removeProp; } });
46
+ Object.defineProperty(exports, "reorderProps", { enumerable: true, get: function () { return attrs_js_1.reorderProps; } });
47
+ Object.defineProperty(exports, "sortedProps", { enumerable: true, get: function () { return attrs_js_1.sortedProps; } });
48
+ Object.defineProperty(exports, "tagsFromAttrs", { enumerable: true, get: function () { return attrs_js_1.tagsFromAttrs; } });
49
+ Object.defineProperty(exports, "updateProp", { enumerable: true, get: function () { return attrs_js_1.updateProp; } });
50
+ Object.defineProperty(exports, "withTags", { enumerable: true, get: function () { return attrs_js_1.withTags; } });
51
+ var attrs_extract_js_1 = require("./attrs-extract.js");
52
+ Object.defineProperty(exports, "buildTitleMatcher", { enumerable: true, get: function () { return attrs_extract_js_1.buildTitleMatcher; } });
53
+ Object.defineProperty(exports, "extractLinkIds", { enumerable: true, get: function () { return attrs_extract_js_1.extractLinkIds; } });
54
+ Object.defineProperty(exports, "htmlToText", { enumerable: true, get: function () { return attrs_extract_js_1.htmlToText; } });
34
55
  /**
35
56
  * The MCP tool surface is NOT exported here. It lives at the `/tools` subpath,
36
57
  * which is ESM-only, because it depends on @modelcontextprotocol/sdk — an