@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/cjs/api.d.ts CHANGED
@@ -6,6 +6,14 @@ export interface AuthTokens {
6
6
  accessToken: string;
7
7
  refreshToken: string;
8
8
  }
9
+ /** What `/api/auth/refresh` answers, minus the refresh token this client keeps. */
10
+ export interface RefreshBody {
11
+ user: {
12
+ id: string;
13
+ email: string;
14
+ };
15
+ accessToken: string;
16
+ }
9
17
  export type LoginResult = {
10
18
  mfaRequired: true;
11
19
  mfaToken: string;
@@ -38,6 +46,8 @@ export interface ApiNote {
38
46
  hidden: boolean | null;
39
47
  createdAt: string;
40
48
  updatedAt: string;
49
+ /** Encrypted v2 blob of the note's structured attrs (props, links, mentions). */
50
+ encryptedAttrs: string | null;
41
51
  }
42
52
  export interface NoteWrite {
43
53
  title: string;
@@ -52,6 +62,7 @@ export interface NoteWrite {
52
62
  starred?: boolean;
53
63
  archived?: boolean;
54
64
  deleted?: boolean;
65
+ encryptedAttrs?: string | null;
55
66
  }
56
67
  export interface ApiFolder {
57
68
  id: string;
@@ -63,14 +74,26 @@ export interface ApiFolder {
63
74
  /** The user's "Expose to MCP" switch. False by default — nothing in this
64
75
  * folder is shown to AI tools until they turn it on. */
65
76
  mcpEnabled: boolean;
77
+ /** Encrypted v2 blob of the folder's property and view definitions. */
78
+ encryptedSchema: string | null;
66
79
  }
67
80
  export declare class VirlowApi {
68
81
  private readonly baseUrl;
69
82
  private tokens;
70
83
  private refreshInFlight;
71
84
  onTokensRotated?: (tokens: AuthTokens) => void;
85
+ /** Fired when a refresh is refused outright and the session is gone. */
86
+ onTokensCleared?: () => void;
72
87
  constructor(baseUrl: string);
73
88
  setTokens(tokens: AuthTokens | null): void;
89
+ getTokens(): AuthTokens | null;
90
+ /**
91
+ * Rotate the refresh token exactly once no matter how many callers arrive
92
+ * while a rotation is pending. The API revokes the whole token family when a
93
+ * replaced refresh token is presented again, so two concurrent refreshes are
94
+ * not a race, they are a forced sign-out.
95
+ */
96
+ refreshSession(): Promise<RefreshBody>;
74
97
  private request;
75
98
  private refresh;
76
99
  private doRefresh;
@@ -135,4 +158,13 @@ export declare class VirlowApi {
135
158
  folders: ApiFolder[];
136
159
  }>;
137
160
  createFolder(name: string, parentId?: string | null, kind?: 'memories'): Promise<ApiFolder>;
161
+ /** Partial update of a folder. Only `encryptedSchema` is used from here so
162
+ * far, and it is forwarded as an already-encrypted blob: nothing in this
163
+ * client encrypts, and nothing on the server can read it. */
164
+ updateFolder(id: string, body: {
165
+ name?: string;
166
+ parentId?: string | null;
167
+ mcpEnabled?: boolean;
168
+ encryptedSchema?: string | null;
169
+ }): Promise<ApiFolder>;
138
170
  }
package/dist/cjs/api.js CHANGED
@@ -60,12 +60,29 @@ class VirlowApi {
60
60
  tokens = null;
61
61
  refreshInFlight = null;
62
62
  onTokensRotated;
63
+ /** Fired when a refresh is refused outright and the session is gone. */
64
+ onTokensCleared;
63
65
  constructor(baseUrl) {
64
66
  this.baseUrl = baseUrl;
65
67
  }
66
68
  setTokens(tokens) {
67
69
  this.tokens = tokens;
68
70
  }
71
+ getTokens() {
72
+ return this.tokens;
73
+ }
74
+ /**
75
+ * Rotate the refresh token exactly once no matter how many callers arrive
76
+ * while a rotation is pending. The API revokes the whole token family when a
77
+ * replaced refresh token is presented again, so two concurrent refreshes are
78
+ * not a race, they are a forced sign-out.
79
+ */
80
+ refreshSession() {
81
+ this.refreshInFlight ??= this.doRefresh().finally(() => {
82
+ this.refreshInFlight = null;
83
+ });
84
+ return this.refreshInFlight;
85
+ }
69
86
  async request(method, path, body, opts = {}) {
70
87
  const { auth = true, retryOn401 = true } = opts;
71
88
  const headers = { 'content-type': 'application/json' };
@@ -89,17 +106,27 @@ class VirlowApi {
89
106
  return data;
90
107
  }
91
108
  refresh() {
92
- this.refreshInFlight ??= this.doRefresh().finally(() => {
93
- this.refreshInFlight = null;
94
- });
95
- return this.refreshInFlight;
109
+ return this.refreshSession().then(() => undefined);
96
110
  }
97
111
  async doRefresh() {
98
112
  if (!this.tokens)
99
113
  throw new ApiError(401, 'Not authenticated');
100
- const rotated = await this.request('POST', '/api/auth/refresh', { refreshToken: this.tokens.refreshToken }, { auth: false, retryOn401: false });
114
+ let rotated;
115
+ try {
116
+ rotated = await this.request('POST', '/api/auth/refresh', { refreshToken: this.tokens.refreshToken }, { auth: false, retryOn401: false });
117
+ }
118
+ catch (err) {
119
+ // Only a definitive refusal ends the session. A network failure or a 5xx
120
+ // while the API restarts must leave the tokens in place for a retry.
121
+ if (err instanceof ApiError && err.status === 401) {
122
+ this.tokens = null;
123
+ this.onTokensCleared?.();
124
+ }
125
+ throw err;
126
+ }
101
127
  this.tokens = { accessToken: rotated.accessToken, refreshToken: rotated.refreshToken };
102
128
  this.onTokensRotated?.(this.tokens);
129
+ return { user: rotated.user, accessToken: rotated.accessToken };
103
130
  }
104
131
  login(email, password) {
105
132
  return this.request('POST', '/api/auth/login', { email, password }, { auth: false });
@@ -189,5 +216,11 @@ class VirlowApi {
189
216
  body.kind = kind;
190
217
  return this.request('POST', '/api/folders', body);
191
218
  }
219
+ /** Partial update of a folder. Only `encryptedSchema` is used from here so
220
+ * far, and it is forwarded as an already-encrypted blob: nothing in this
221
+ * client encrypts, and nothing on the server can read it. */
222
+ updateFolder(id, body) {
223
+ return this.request('PUT', `/api/folders/${id}`, body);
224
+ }
192
225
  }
193
226
  exports.VirlowApi = VirlowApi;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Node-side twins of the web's src/lib/attrs/extract.ts. Kept in this shared
3
+ * package (not copied into mcp-core in a form the web could not later reuse)
4
+ * so the two can eventually converge on one implementation.
5
+ *
6
+ * The web module prefers DOMParser and falls back to this regex/tokenizer
7
+ * path only when DOMParser is unavailable. mcp-core runs in Node, which has
8
+ * no DOMParser, so it always takes that fallback path; the logic here is
9
+ * meant to produce the same answers as the web's fallback for the same
10
+ * input, not to reimplement its DOMParser branch.
11
+ */
12
+ /** Ids of every `<span data-type="noteLink" data-note-id="...">` in `html`,
13
+ * unique, in document order. Attribute order within the tag does not matter. */
14
+ export declare function extractLinkIds(html: string): string[];
15
+ /** Minimal entity decoding for the no-DOMParser text extraction below,
16
+ * covering the common cases the editor actually emits. */
17
+ export declare function decodeEntities(text: string): string;
18
+ /** Flattens note HTML to plain text for mention matching: block-level
19
+ * closing tags and `<br>` become a space (so words either side of a block
20
+ * boundary do not run together), remaining tags are stripped, and entities
21
+ * are decoded. */
22
+ export declare function htmlToText(html: string): string;
23
+ export interface TitleEntry {
24
+ id: string;
25
+ title: string;
26
+ }
27
+ export interface TitleMatcher {
28
+ find(text: string, excludeIds?: Set<string>): string[];
29
+ }
30
+ /** Split into word tokens (letters, digits, underscore); punctuation and
31
+ * spaces separate. Titles are tokenized with the same tokenizer as body text
32
+ * so punctuation like "Follow-up", "Node.js" or "(draft)" indexes the same
33
+ * way it appears in prose. */
34
+ export declare function tokenize(text: string): string[];
35
+ /**
36
+ * Titles are indexed by their first lowercase token. At each token position
37
+ * in the text we look up only the titles that start with that token. Before
38
+ * doing the O(title length) positional check, a token-set pre-filter built
39
+ * once per `find` call rejects, in O(1) per word, any candidate whose words
40
+ * do not all occur somewhere in the text; a candidate that fails this
41
+ * pre-filter is rejected without walking its words. This keeps `find` linear
42
+ * in text tokens for the pre-filter, with positional work spent only on
43
+ * candidates that could plausibly match.
44
+ */
45
+ export declare function buildTitleMatcher(entries: TitleEntry[], opts?: {
46
+ minLength?: number;
47
+ }): TitleMatcher;
@@ -0,0 +1,159 @@
1
+ "use strict";
2
+ /**
3
+ * Node-side twins of the web's src/lib/attrs/extract.ts. Kept in this shared
4
+ * package (not copied into mcp-core in a form the web could not later reuse)
5
+ * so the two can eventually converge on one implementation.
6
+ *
7
+ * The web module prefers DOMParser and falls back to this regex/tokenizer
8
+ * path only when DOMParser is unavailable. mcp-core runs in Node, which has
9
+ * no DOMParser, so it always takes that fallback path; the logic here is
10
+ * meant to produce the same answers as the web's fallback for the same
11
+ * input, not to reimplement its DOMParser branch.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.extractLinkIds = extractLinkIds;
15
+ exports.decodeEntities = decodeEntities;
16
+ exports.htmlToText = htmlToText;
17
+ exports.tokenize = tokenize;
18
+ exports.buildTitleMatcher = buildTitleMatcher;
19
+ /**
20
+ * Matches one opening `<span ...>` tag. A naive `[^>]*` up to the first `>`
21
+ * would stop inside a quoted attribute value that happens to contain a
22
+ * literal `>` (for example `data-note-title="A > B"`), truncating the tag
23
+ * and losing whatever attribute came after it, such as data-note-id. This
24
+ * alternates "not a quote or '>'", a double-quoted run, or a single-quoted
25
+ * run, so only the real closing '>' outside any quotes ends the match.
26
+ */
27
+ const SPAN_TAG = /<span\b((?:[^"'>]|"[^"]*"|'[^']*')*)>/g;
28
+ const ATTR = /([a-zA-Z_:][-\w:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
29
+ /** Attribute name/value pairs from inside one tag's attribute list (the
30
+ * captured group of SPAN_TAG), independent of the order they appear in. */
31
+ function parseAttrs(attrString) {
32
+ const attrs = {};
33
+ for (const m of attrString.matchAll(ATTR)) {
34
+ attrs[m[1]] = m[2] ?? m[3] ?? '';
35
+ }
36
+ return attrs;
37
+ }
38
+ /** Ids of every `<span data-type="noteLink" data-note-id="...">` in `html`,
39
+ * unique, in document order. Attribute order within the tag does not matter. */
40
+ function extractLinkIds(html) {
41
+ const ids = [];
42
+ const seen = new Set();
43
+ for (const m of html.matchAll(SPAN_TAG)) {
44
+ const attrs = parseAttrs(m[1] ?? '');
45
+ if (attrs['data-type'] !== 'noteLink')
46
+ continue;
47
+ const id = attrs['data-note-id'] ?? '';
48
+ if (id && !seen.has(id)) {
49
+ seen.add(id);
50
+ ids.push(id);
51
+ }
52
+ }
53
+ return ids;
54
+ }
55
+ const BLOCK_END = /<\/(p|div|h[1-6]|li|blockquote|pre|td|th|tr|section|article)>|<br\s*\/?>/gi;
56
+ /** Minimal entity decoding for the no-DOMParser text extraction below,
57
+ * covering the common cases the editor actually emits. */
58
+ function decodeEntities(text) {
59
+ return text
60
+ .replace(/&lt;/g, '<')
61
+ .replace(/&gt;/g, '>')
62
+ .replace(/&quot;/g, '"')
63
+ .replace(/&#39;/g, "'")
64
+ .replace(/&nbsp;/g, ' ')
65
+ .replace(/&amp;/g, '&');
66
+ }
67
+ /** Flattens note HTML to plain text for mention matching: block-level
68
+ * closing tags and `<br>` become a space (so words either side of a block
69
+ * boundary do not run together), remaining tags are stripped, and entities
70
+ * are decoded. */
71
+ function htmlToText(html) {
72
+ const spaced = html.replace(BLOCK_END, ' ');
73
+ return decodeEntities(spaced.replace(/<[^>]+>/g, '')).trim();
74
+ }
75
+ const WORD = /[\p{L}\p{N}_]/u;
76
+ /** Split into word tokens (letters, digits, underscore); punctuation and
77
+ * spaces separate. Titles are tokenized with the same tokenizer as body text
78
+ * so punctuation like "Follow-up", "Node.js" or "(draft)" indexes the same
79
+ * way it appears in prose. */
80
+ function tokenize(text) {
81
+ const tokens = [];
82
+ let current = '';
83
+ for (const ch of text) {
84
+ if (WORD.test(ch))
85
+ current += ch;
86
+ else if (current) {
87
+ tokens.push(current);
88
+ current = '';
89
+ }
90
+ }
91
+ if (current)
92
+ tokens.push(current);
93
+ return tokens;
94
+ }
95
+ /**
96
+ * Titles are indexed by their first lowercase token. At each token position
97
+ * in the text we look up only the titles that start with that token. Before
98
+ * doing the O(title length) positional check, a token-set pre-filter built
99
+ * once per `find` call rejects, in O(1) per word, any candidate whose words
100
+ * do not all occur somewhere in the text; a candidate that fails this
101
+ * pre-filter is rejected without walking its words. This keeps `find` linear
102
+ * in text tokens for the pre-filter, with positional work spent only on
103
+ * candidates that could plausibly match.
104
+ */
105
+ function buildTitleMatcher(entries, opts = {}) {
106
+ const minLength = opts.minLength ?? 3;
107
+ const byFirstWord = new Map();
108
+ for (const { id, title } of entries) {
109
+ const words = tokenize(title.trim().toLowerCase());
110
+ if (words.length === 0)
111
+ continue;
112
+ const joinedLength = words.reduce((sum, word) => sum + word.length, 0);
113
+ if (joinedLength < minLength)
114
+ continue;
115
+ const list = byFirstWord.get(words[0]) ?? [];
116
+ list.push({ id, words });
117
+ byFirstWord.set(words[0], list);
118
+ }
119
+ for (const list of byFirstWord.values())
120
+ list.sort((a, b) => b.words.length - a.words.length);
121
+ return {
122
+ find(text, excludeIds) {
123
+ const tokens = tokenize(text.toLowerCase());
124
+ const tokenSet = new Set(tokens);
125
+ const found = [];
126
+ const seen = new Set();
127
+ for (let i = 0; i < tokens.length; i++) {
128
+ const candidates = byFirstWord.get(tokens[i]);
129
+ if (!candidates)
130
+ continue;
131
+ for (const { id, words } of candidates) {
132
+ if (seen.has(id) || excludeIds?.has(id))
133
+ continue;
134
+ let allWordsPresent = true;
135
+ for (let w = 1; w < words.length; w++) {
136
+ if (!tokenSet.has(words[w])) {
137
+ allWordsPresent = false;
138
+ break;
139
+ }
140
+ }
141
+ if (!allWordsPresent)
142
+ continue;
143
+ let ok = true;
144
+ for (let k = 1; k < words.length; k++) {
145
+ if (tokens[i + k] !== words[k]) {
146
+ ok = false;
147
+ break;
148
+ }
149
+ }
150
+ if (ok) {
151
+ seen.add(id);
152
+ found.push(id);
153
+ }
154
+ }
155
+ }
156
+ return found;
157
+ },
158
+ };
159
+ }