@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,108 @@
1
+ "use strict";
2
+ /**
3
+ * Refuses memories that contain credentials, before anything is written.
4
+ *
5
+ * Memories are captured automatically, so the rule cannot depend on the AI
6
+ * remembering not to save a key: a memory is encrypted, but it is recalled
7
+ * into prompts and shown in the app, and a secret has no business in either.
8
+ * The check is on-device and returns a reason, so the AI can say what it
9
+ * left out instead of retrying with the secret reworded.
10
+ *
11
+ * Deliberately narrow: patterns that are credentials with near certainty,
12
+ * plus long high-entropy tokens. A false positive costs one memory; a false
13
+ * negative puts a live key into every future prompt that recalls it.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.SecretInMemoryError = void 0;
17
+ exports.findSecret = findSecret;
18
+ exports.assertNoSecret = assertNoSecret;
19
+ const PATTERNS = [
20
+ { what: 'a private key', test: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
21
+ { what: 'an AWS access key', test: /\b(AKIA|ASIA)[0-9A-Z]{16}\b/ },
22
+ { what: 'a GitHub token', test: /\b(ghp|gho|ghu|ghs|ghr|github_pat)_[A-Za-z0-9_]{20,}\b/ },
23
+ { what: 'a Slack token', test: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/ },
24
+ { what: 'a Stripe key', test: /\b(sk|rk|pk)_(live|test)_[A-Za-z0-9]{16,}\b/ },
25
+ { what: 'a Stripe webhook secret', test: /\bwhsec_[A-Za-z0-9]{16,}\b/ },
26
+ { what: 'an API key', test: /\bsk-(ant-|proj-)?[A-Za-z0-9_-]{20,}\b/ },
27
+ { what: 'a Google API key', test: /\bAIza[0-9A-Za-z_-]{35}\b/ },
28
+ { what: 'a JSON web token', test: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/ },
29
+ {
30
+ what: 'a password',
31
+ test: /\b(password|passwd|pwd|passphrase|api[_ -]?key|secret|token)\s*[:=]\s*\S{6,}/i,
32
+ },
33
+ { what: 'a connection string with a password', test: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s@/]{3,}@/i },
34
+ ];
35
+ /** Luhn-valid runs of 13 to 19 digits, spaces or dashes allowed. */
36
+ function hasCardNumber(text) {
37
+ for (const match of text.matchAll(/\b(?:\d[ -]?){13,19}\b/g)) {
38
+ const digits = match[0].replace(/\D/g, '');
39
+ if (digits.length < 13 || digits.length > 19)
40
+ continue;
41
+ let sum = 0;
42
+ for (let i = 0; i < digits.length; i++) {
43
+ let d = Number(digits[digits.length - 1 - i]);
44
+ if (i % 2 === 1) {
45
+ d *= 2;
46
+ if (d > 9)
47
+ d -= 9;
48
+ }
49
+ sum += d;
50
+ }
51
+ if (sum % 10 === 0)
52
+ return true;
53
+ }
54
+ return false;
55
+ }
56
+ /** Shannon entropy per character. */
57
+ function entropy(token) {
58
+ const counts = new Map();
59
+ for (const ch of token)
60
+ counts.set(ch, (counts.get(ch) ?? 0) + 1);
61
+ let bits = 0;
62
+ for (const n of counts.values()) {
63
+ const p = n / token.length;
64
+ bits -= p * Math.log2(p);
65
+ }
66
+ return bits;
67
+ }
68
+ /** A long run of mixed letters and digits with no structure a person
69
+ * writes: what an unlabelled key or token looks like. Ids like UUIDs and
70
+ * hex hashes are common in notes and are left alone. */
71
+ function hasHighEntropyToken(text) {
72
+ for (const [token] of text.matchAll(/[A-Za-z0-9+/_=-]{32,}/g)) {
73
+ if (/^[0-9a-f-]+$/i.test(token))
74
+ continue; // uuid or hex hash
75
+ if (!/[a-z]/.test(token) || !/[A-Z]/.test(token) || !/[0-9]/.test(token))
76
+ continue;
77
+ if (entropy(token) >= 4.2)
78
+ return true;
79
+ }
80
+ return false;
81
+ }
82
+ /** What secret the text holds, or null when it holds none. */
83
+ function findSecret(text) {
84
+ for (const pattern of PATTERNS) {
85
+ if (pattern.test.test(text))
86
+ return pattern.what;
87
+ }
88
+ if (hasCardNumber(text))
89
+ return 'a card number';
90
+ if (hasHighEntropyToken(text))
91
+ return 'what looks like a key or token';
92
+ return null;
93
+ }
94
+ class SecretInMemoryError extends Error {
95
+ constructor(what) {
96
+ super(`Not saved: this memory contains ${what}. Memories are recalled into prompts, so credentials are never ` +
97
+ 'stored. Save the fact without the secret (for example "the API key is in 1Password"), not the secret.');
98
+ this.name = 'SecretInMemoryError';
99
+ }
100
+ }
101
+ exports.SecretInMemoryError = SecretInMemoryError;
102
+ function assertNoSecret(...texts) {
103
+ for (const text of texts) {
104
+ const what = findSecret(text);
105
+ if (what)
106
+ throw new SecretInMemoryError(what);
107
+ }
108
+ }
@@ -1,10 +1,21 @@
1
1
  import { type Exposure } from './exposure.js';
2
+ import { type HomeWidget, type NoteAttrsV1, type PropDefinition, type PropType, type PropValue } from './attrs.js';
2
3
  import type { VirlowApi } from './api.js';
3
- import type { Vault } from './vault.js';
4
+ import { type Vault } from './vault.js';
4
5
  /** Hard ceiling on how many notes searchNotes() will decrypt+scan in a
5
6
  * single call. Keeps a single search bounded even for very large accounts;
6
- * `truncated` tells the caller when the account holds more than this. */
7
+ * `truncated` tells the caller when the account holds more than this. Also
8
+ * bounds listVisibleRows(), which backs mention-matching and backlinks(). */
7
9
  export declare const SEARCH_SCAN_CAP = 500;
10
+ /** One property a setProperties() call actually changed, said the way the
11
+ * caller asked for it: the definition's name, and option names rather than
12
+ * the option ids stored in the note. A caller that had to read ids back to
13
+ * confirm its own write would be reading something it never wrote. */
14
+ export interface AppliedProp {
15
+ name: string;
16
+ /** The stored value rendered for a human, or `removed` when it was cleared. */
17
+ value: string;
18
+ }
8
19
  export declare class NotesService {
9
20
  private readonly api;
10
21
  private readonly vault;
@@ -12,6 +23,33 @@ export declare class NotesService {
12
23
  * so re-reading/re-scanning the same note twice in one unlock doesn't
13
24
  * re-run AES-GCM decryption. Cleared whenever the vault locks. */
14
25
  private readonly decryptCache;
26
+ /** Per-unlock cache of decoded attrs, keyed the same way as decryptCache.
27
+ * Stores `null` too (a note with no attrs), so `.has()` rather than a
28
+ * truthiness check distinguishes "decoded, no attrs" from "not cached
29
+ * yet". Cleared on lock alongside decryptCache. */
30
+ private readonly attrsCache;
31
+ /** Per-unlock cache of decrypted titles only (no content), used by
32
+ * mention-matching and backlinks() so neither has to pay for a full body
33
+ * decrypt just to learn a note's title. Cleared on lock alongside the
34
+ * other caches. */
35
+ private readonly titleCache;
36
+ /** Per-unlock cache of decoded folder schemas, keyed on the folder id and
37
+ * the ciphertext itself so a schema this service just rewrote is never
38
+ * served from a stale entry. Cleared on lock alongside the other caches. */
39
+ private readonly schemaCache;
40
+ /**
41
+ * The rows listVisibleRows() last paged, with the epoch and folder-exposure
42
+ * they were computed under. Without this, update_note paged the entire note
43
+ * list on every call just to build mention titles, so one edit cost as many
44
+ * round trips as the account has pages of notes.
45
+ *
46
+ * Keyed on the exposure as well as the epoch, so flipping a folder's
47
+ * "Expose to MCP" switch takes effect on the very next call rather than
48
+ * leaving a closed folder's titles in the mention set; dropped outright on
49
+ * every write, so a note this service just created, moved, trashed or
50
+ * retitled is never missing from (or stale in) the next scan.
51
+ */
52
+ private visibleRowsCache;
15
53
  /** Bumped every time the vault locks (see handleLock()). decryptRow()
16
54
  * captures this before its await on decryptNoteFields() and re-checks it
17
55
  * afterward, so a decrypt that was still in flight when the vault locked
@@ -25,6 +63,10 @@ export declare class NotesService {
25
63
  private mutationChain;
26
64
  constructor(api: VirlowApi, vault: Vault);
27
65
  private handleLock;
66
+ /** Drops the paged row list after anything that could change which rows
67
+ * exist, where they live, or what they are called. Called after the write
68
+ * lands, so a failed write leaves the cache alone. */
69
+ private invalidateVisibleRows;
28
70
  listNotes(params: {
29
71
  folderId?: string;
30
72
  starred?: boolean;
@@ -54,11 +96,54 @@ export declare class NotesService {
54
96
  };
55
97
  exposure: Exposure;
56
98
  }>;
99
+ /**
100
+ * A folder the way a view shows it: every note in it, with the value it
101
+ * carries for each of the folder's property definitions.
102
+ *
103
+ * The program's AI-parity principle is that every capability the UI gains,
104
+ * the tools gain in the same epic. The app can now read a folder as a table,
105
+ * a board or a calendar; without this an assistant could read and write
106
+ * individual properties but never see the shape the user is looking at, so
107
+ * "which of these is still in review" meant reading every note in turn.
108
+ *
109
+ * Values come from the attrs blob already on each list row, so a folder of
110
+ * two thousand notes answers without fetching or decrypting a single body.
111
+ * That is the same constraint the views are built to, and it is why this
112
+ * reads rows rather than calling readNote per note.
113
+ *
114
+ * Hidden notes and unexposed folders are filtered exactly as listNotes does,
115
+ * and for the same reasons; the counts it withholds are reported here too so
116
+ * a filtered folder is never mistaken for a small one.
117
+ */
118
+ listFolderView(params: {
119
+ folderId: string;
120
+ sortBy?: string;
121
+ page?: number;
122
+ limit?: number;
123
+ }): Promise<{
124
+ defs: PropDefinition[];
125
+ rows: Array<{
126
+ id: string;
127
+ title: string;
128
+ values: Record<string, string>;
129
+ }>;
130
+ pagination: {
131
+ page: number;
132
+ limit: number;
133
+ total: number;
134
+ pages: number;
135
+ };
136
+ withheld: {
137
+ inClosedFolders: number;
138
+ };
139
+ sortedBy: PropDefinition | null;
140
+ }>;
57
141
  readNote(id: string): Promise<{
58
142
  id: string;
59
143
  title: string;
60
144
  content: string;
61
145
  folderId: string | null;
146
+ attrs: NoteAttrsV1 | null;
62
147
  }>;
63
148
  createNote(title: string, content: string, folderId?: string | null, type?: string): Promise<{
64
149
  id: string;
@@ -66,7 +151,85 @@ export declare class NotesService {
66
151
  updateNote(id: string, changes: {
67
152
  title?: string;
68
153
  content?: string;
154
+ /** Merged into the existing props; a value of `null` deletes that key. */
155
+ props?: Record<string, PropValue | null>;
156
+ /** Replaces the existing aliases outright when provided. */
157
+ aliases?: string[];
69
158
  }): Promise<void>;
159
+ /**
160
+ * Notes that link to `id`, built entirely from attrs, never a body
161
+ * decrypt. Scans the same folder-visible, non-hidden rows as listNotes(),
162
+ * bounded by SEARCH_SCAN_CAP like searchNotes().
163
+ *
164
+ * Unlike readNote()/decryptRow(), which let an in-flight decrypt finish and
165
+ * simply skip the cache write if the vault locked mid-flight, this
166
+ * aggregates across every visible note in one call: a lock partway through
167
+ * means the result mixes attrs decoded under a session that's since been
168
+ * revoked, which is not safe to hand back. So the whole call rejects with
169
+ * LockedError instead.
170
+ */
171
+ backlinks(id: string): Promise<Array<{
172
+ id: string;
173
+ title: string;
174
+ }>>;
175
+ /** A folder's property definitions, in display order. Read-only, so no
176
+ * mutation queue and no epoch guard past the read itself. */
177
+ listProperties(folderId: string): Promise<PropDefinition[]>;
178
+ /** A folder's dashboard as it stands, with the blocks it will draw. */
179
+ readDashboard(folderId: string): Promise<{
180
+ widgets: HomeWidget[];
181
+ defs: PropDefinition[];
182
+ arranged: boolean;
183
+ }>;
184
+ /**
185
+ * Replaces a folder's dashboard arrangement.
186
+ *
187
+ * The previous arrangement is kept alongside the new one, so a person can
188
+ * put back what an assistant changed. That matters more here than for a
189
+ * note: a note edit is visible in the note, while a dashboard edit can
190
+ * remove a block and leave nothing behind to say what was there. One level,
191
+ * deliberately: this is an undo, not a history, and the schema travels
192
+ * inside every folder read.
193
+ */
194
+ setDashboard(folderId: string, widgets: HomeWidget[]): Promise<HomeWidget[]>;
195
+ /**
196
+ * Appends a property definition to a folder's schema.
197
+ *
198
+ * Refuses a name the folder already uses, rather than creating a second
199
+ * definition with it: setProperties() resolves a value's key by name, and a
200
+ * duplicate would make every later call ambiguous. The message names the
201
+ * existing id so the caller can just use it.
202
+ */
203
+ defineProperty(folderId: string, def: {
204
+ name: string;
205
+ type: PropType;
206
+ options?: {
207
+ name: string;
208
+ }[];
209
+ showInList?: boolean;
210
+ }): Promise<PropDefinition>;
211
+ /**
212
+ * Sets typed property values on one note, keyed by property name or id.
213
+ *
214
+ * Only `encryptedAttrs` is sent: the title and body keep the ciphertext,
215
+ * IV and salt they already had, so a property change can never damage a
216
+ * body nobody asked to change.
217
+ *
218
+ * When `createOption` invents an option for a select or multiSelect, the
219
+ * definition carrying it is written to the folder **before** the value that
220
+ * names it. Get that order wrong and a failure in between leaves a value
221
+ * pointing at an option id no definition carries, which reads as blank in
222
+ * the app and cannot be repaired from here. In this order the worst a
223
+ * failure between the two writes leaves behind is an option nobody uses
224
+ * yet, and repeating the same call finds that option by name, adds no
225
+ * duplicate, and writes the value.
226
+ */
227
+ setProperties(noteId: string, values: Record<string, unknown>, opts?: {
228
+ createOption?: boolean;
229
+ }): Promise<{
230
+ attrs: NoteAttrsV1;
231
+ applied: AppliedProp[];
232
+ }>;
70
233
  searchNotes(query: string, limit?: number): Promise<{
71
234
  results: Array<{
72
235
  id: string;
@@ -92,6 +255,10 @@ export declare class NotesService {
92
255
  * flip the switch in the app at any moment and the next tool call must
93
256
  * respect it. */
94
257
  private exposure;
258
+ /** The same computation for a caller that already has the folder list and
259
+ * needs a folder out of it as well as the exposure (the property tools do:
260
+ * a folder's schema and its exposure come from the same row). */
261
+ private exposureOf;
95
262
  /** Throws unless `folderId` is exposed to MCP. Every method that reads or
96
263
  * mutates a specific note (by id) must call this — knowing a note's id is
97
264
  * not consent to bypass the "Expose to MCP" folder switch. */
@@ -103,6 +270,45 @@ export declare class NotesService {
103
270
  * row. Legacy rows (no encryptedTitle) carry their real content directly
104
271
  * in the plaintext title/content columns. */
105
272
  private decryptRow;
273
+ /** Decode (with per-unlock caching) a note's attrs blob. Mirrors
274
+ * decryptRow()'s epoch guard: a decode still in flight when the vault
275
+ * locks resolves for its own caller but never lands in the cache. */
276
+ private decodeAttrsFor;
277
+ /** Decrypt (with per-unlock caching) just a note's title, without touching
278
+ * its body, the cheaper primitive backlinks() and mention-matching need. */
279
+ private decryptTitleFor;
280
+ /** The folder-visible, non-hidden rows a mention/backlink scan is allowed to
281
+ * see, same exposure rules as listNotes()/searchNotes(), bounded by
282
+ * SEARCH_SCAN_CAP for the same reason searchNotes() is. Accepts an
283
+ * already-computed `exposure` so a caller that has just checked it (as
284
+ * updateNote() has, for requireExposed()) does not pay for a second
285
+ * listFolders() round trip. */
286
+ private listVisibleRows;
287
+ /** Same guard as decryptRow(): a paging run that was still in flight when
288
+ * the vault locked must not seed the cache for the session that follows. */
289
+ private cacheVisibleRows;
290
+ /** Title entries for mention-matching against a note being saved: one entry
291
+ * per other folder-visible note's title, plus one more per alias in its
292
+ * attrs (an alias is another name mentions should recognize, same as the
293
+ * title). Titles come from decryptTitleFor() and aliases from
294
+ * decodeAttrsFor() (the same per-epoch caches backlinks() uses), so this
295
+ * costs no body decryption. */
296
+ private visibleTitles;
297
+ /** The folder a property tool was pointed at, refused unless it exists and
298
+ * the user has opened it to MCP. Knowing a folder id is no more consent
299
+ * than knowing a note id is (see requireExposed). */
300
+ private requireFolder;
301
+ /** Decode (with per-unlock caching) a folder's property definitions. A
302
+ * folder that has never had any decodes to an empty schema rather than
303
+ * null, so every caller can treat the two the same way. */
304
+ private decodeSchemaFor;
305
+ /** Encrypts a folder schema and enforces the server's size cap
306
+ * client-side, the way encodeAttrsChecked() does for a note's attrs. */
307
+ private encodeSchemaChecked;
308
+ /** Encrypts attrs and enforces the server's size cap client-side: an
309
+ * oversized blob is refused with a clear message rather than sent and
310
+ * bounced back as an HTTP 413 `ATTRS_TOO_LARGE`. */
311
+ private encodeAttrsChecked;
106
312
  private buildSnippet;
107
313
  private requireSession;
108
314
  /** Chains `fn` onto the mutation queue so overlapping calls run one at a