@batalabs/virlow-mcp-core 3.11.5 → 3.11.6

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 (42) hide show
  1. package/dist/cjs/api.d.ts +117 -0
  2. package/dist/cjs/api.js +113 -0
  3. package/dist/cjs/embedding-cache.d.ts +33 -0
  4. package/dist/cjs/embedding-cache.js +114 -0
  5. package/dist/cjs/exposure.d.ts +41 -0
  6. package/dist/cjs/exposure.js +77 -0
  7. package/dist/cjs/index.d.ts +15 -0
  8. package/dist/cjs/index.js +32 -0
  9. package/dist/cjs/memories-enabled.d.ts +15 -0
  10. package/dist/cjs/memories-enabled.js +32 -0
  11. package/dist/cjs/memories.d.ts +85 -0
  12. package/dist/cjs/memories.js +353 -0
  13. package/dist/cjs/memory-note.d.ts +44 -0
  14. package/dist/cjs/memory-note.js +149 -0
  15. package/dist/cjs/memory-tree.d.ts +27 -0
  16. package/dist/cjs/memory-tree.js +72 -0
  17. package/dist/cjs/notes.d.ts +108 -0
  18. package/dist/cjs/notes.js +237 -0
  19. package/dist/cjs/package.json +1 -0
  20. package/dist/cjs/vault.d.ts +34 -0
  21. package/dist/cjs/vault.js +77 -0
  22. package/dist/exposure.d.ts +1 -1
  23. package/dist/exposure.d.ts.map +1 -1
  24. package/dist/index.d.ts +15 -15
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +9 -9
  27. package/dist/index.js.map +1 -1
  28. package/dist/memories-enabled.d.ts +1 -1
  29. package/dist/memories-enabled.d.ts.map +1 -1
  30. package/dist/memories.d.ts +4 -4
  31. package/dist/memories.d.ts.map +1 -1
  32. package/dist/memories.js +3 -3
  33. package/dist/memories.js.map +1 -1
  34. package/dist/memory-tree.d.ts +1 -1
  35. package/dist/memory-tree.d.ts.map +1 -1
  36. package/dist/memory-tree.js +1 -1
  37. package/dist/memory-tree.js.map +1 -1
  38. package/dist/notes.d.ts +3 -3
  39. package/dist/notes.d.ts.map +1 -1
  40. package/dist/notes.js +1 -1
  41. package/dist/notes.js.map +1 -1
  42. package/package.json +13 -7
@@ -0,0 +1,85 @@
1
+ import { type Embedder } from '@batalabs/virlow-memory';
2
+ import { type VirlowApi } from './api.js';
3
+ import type { EmbeddingCache } from './embedding-cache.js';
4
+ import { type MemoryMeta } from './memory-note.js';
5
+ import type { Vault } from './vault.js';
6
+ export declare const SYNC_PAGE_LIMIT = 100;
7
+ export interface CachedMemory {
8
+ id: string;
9
+ label: string;
10
+ text: string;
11
+ meta: MemoryMeta;
12
+ namespace?: string;
13
+ folderId: string;
14
+ embedding: Float32Array;
15
+ updatedAt: string;
16
+ }
17
+ export interface SimilarMemory {
18
+ id: string;
19
+ label: string;
20
+ text: string;
21
+ similarity: number;
22
+ }
23
+ export type SyncResult = {
24
+ added: number;
25
+ updated: number;
26
+ removed: number;
27
+ embedded: number;
28
+ };
29
+ export declare class MemoryStore {
30
+ private readonly api;
31
+ private readonly vault;
32
+ private readonly embedder;
33
+ private readonly cacheFactory;
34
+ private readonly cache;
35
+ private tree;
36
+ private embeddings;
37
+ private embeddingsLoaded;
38
+ private syncInFlight;
39
+ private mutationChain;
40
+ /** Bumped every time clear() runs (i.e. every lock). Operations capture it
41
+ * at the top and re-check before every cache write, so anything still
42
+ * awaiting the network or a decrypt when the vault locked can never write
43
+ * decrypted plaintext back into a cache the user just emptied. */
44
+ private epoch;
45
+ constructor(api: VirlowApi, vault: Vault, embedder: Embedder, cacheFactory: (userId: string) => EmbeddingCache);
46
+ clear(): void;
47
+ get size(): number;
48
+ /** Concurrent callers share one in-flight sync so two overlapping tool
49
+ * calls never double up on network, decrypt, or embedding work. */
50
+ sync(): Promise<SyncResult>;
51
+ private doSync;
52
+ add(label: string, text: string, namespace?: string, meta?: MemoryMeta): Promise<{
53
+ action: 'stored' | 'updated';
54
+ id: string;
55
+ similar: SimilarMemory[];
56
+ }>;
57
+ search(query: string, namespace?: string, limit?: number): Promise<Array<Omit<CachedMemory, 'embedding' | 'folderId' | 'updatedAt'> & {
58
+ similarity: number;
59
+ }>>;
60
+ /** Requires a prior sync() in the same unlock to have populated the cache. */
61
+ list(namespace?: string): CachedMemory[];
62
+ update(id: string, changes: {
63
+ label?: string;
64
+ text?: string;
65
+ meta?: MemoryMeta;
66
+ }): Promise<void>;
67
+ /** Trashes the note — recoverable from the app's Trash, never hard-deleted. */
68
+ delete(id: string): Promise<void>;
69
+ /** Dedupe and search candidates: the named namespace plus the global root. */
70
+ private candidatesFor;
71
+ /** Create or update a memory note, encrypting through the note field
72
+ * helpers. `noteId === null` creates. */
73
+ private writeNote;
74
+ private decryptRow;
75
+ private requireTree;
76
+ private requireEmbeddings;
77
+ /** Persisting after a lock would write vectors the user just cleared. */
78
+ private saveEmbeddings;
79
+ private requireSession;
80
+ /** Serializes add/update/delete so two overlapping mutation tool calls never
81
+ * interleave — two concurrent near-duplicate adds must run their
82
+ * dedupe-check-then-write critical section one at a time, or both would miss
83
+ * the other's pending write and both create. */
84
+ private enqueue;
85
+ }
@@ -0,0 +1,353 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MemoryStore = exports.SYNC_PAGE_LIMIT = void 0;
4
+ const virlow_crypto_1 = require("@batalabs/virlow-crypto");
5
+ const virlow_memory_1 = require("@batalabs/virlow-memory");
6
+ const api_js_1 = require("./api.js");
7
+ const memory_note_js_1 = require("./memory-note.js");
8
+ const memory_tree_js_1 = require("./memory-tree.js");
9
+ exports.SYNC_PAGE_LIMIT = 100;
10
+ const round3 = (n) => Math.round(n * 1000) / 1000;
11
+ class MemoryStore {
12
+ api;
13
+ vault;
14
+ embedder;
15
+ cacheFactory;
16
+ cache = new Map();
17
+ tree = null;
18
+ embeddings = null;
19
+ embeddingsLoaded = false;
20
+ syncInFlight = null;
21
+ mutationChain = Promise.resolve();
22
+ /** Bumped every time clear() runs (i.e. every lock). Operations capture it
23
+ * at the top and re-check before every cache write, so anything still
24
+ * awaiting the network or a decrypt when the vault locked can never write
25
+ * decrypted plaintext back into a cache the user just emptied. */
26
+ epoch = 0;
27
+ constructor(api, vault, embedder, cacheFactory) {
28
+ this.api = api;
29
+ this.vault = vault;
30
+ this.embedder = embedder;
31
+ this.cacheFactory = cacheFactory;
32
+ this.vault.onLock = () => this.clear();
33
+ }
34
+ clear() {
35
+ this.cache.clear();
36
+ this.tree = null;
37
+ this.embeddings = null;
38
+ this.embeddingsLoaded = false;
39
+ this.epoch++;
40
+ }
41
+ get size() {
42
+ return this.cache.size;
43
+ }
44
+ /** Concurrent callers share one in-flight sync so two overlapping tool
45
+ * calls never double up on network, decrypt, or embedding work. */
46
+ sync() {
47
+ this.syncInFlight ??= this.doSync().finally(() => {
48
+ this.syncInFlight = null;
49
+ });
50
+ return this.syncInFlight;
51
+ }
52
+ async doSync() {
53
+ const key = this.requireSession();
54
+ const startEpoch = this.epoch;
55
+ const result = { added: 0, updated: 0, removed: 0, embedded: 0 };
56
+ const tree = await this.requireTree();
57
+ const embeddings = await this.requireEmbeddings(key);
58
+ const seen = new Set();
59
+ for (const folderId of tree.folderIds()) {
60
+ let page = 1;
61
+ for (;;) {
62
+ let batch;
63
+ try {
64
+ batch = await this.api.listNotes({
65
+ folderId,
66
+ deleted: false,
67
+ page,
68
+ limit: exports.SYNC_PAGE_LIMIT,
69
+ });
70
+ }
71
+ catch (err) {
72
+ // The folder was deleted between resolving the tree and walking it.
73
+ // Drop the stale tree so the next sync re-resolves, and move on.
74
+ if (err instanceof api_js_1.ApiError && err.status === 404) {
75
+ this.tree = null;
76
+ break;
77
+ }
78
+ throw err;
79
+ }
80
+ for (const row of batch.notes) {
81
+ // Hidden notes are segregated in the app; they are not memories.
82
+ if (row.hidden === true)
83
+ continue;
84
+ seen.add(row.id);
85
+ const existing = this.cache.get(row.id);
86
+ if (existing && existing.updatedAt === row.updatedAt) {
87
+ // Unchanged, but it may have been dragged to another subfolder.
88
+ if (existing.folderId !== folderId) {
89
+ const moved = {
90
+ ...existing,
91
+ folderId,
92
+ ...namespaceField(tree.namespaceOf(folderId)),
93
+ };
94
+ if (this.epoch === startEpoch)
95
+ this.cache.set(row.id, moved);
96
+ result.updated++;
97
+ }
98
+ continue;
99
+ }
100
+ const { title, content } = await this.decryptRow(key, row);
101
+ const { fact, meta } = (0, memory_note_js_1.parseMemoryNote)(content);
102
+ let embedding = embeddings.get(row.id, row.updatedAt);
103
+ if (!embedding) {
104
+ embedding = await this.embedder.embed(embeddingInput(title, fact));
105
+ result.embedded++;
106
+ }
107
+ if (this.epoch !== startEpoch)
108
+ continue;
109
+ embeddings.set(row.id, row.updatedAt, embedding);
110
+ this.cache.set(row.id, {
111
+ id: row.id,
112
+ label: title,
113
+ text: fact,
114
+ meta,
115
+ folderId,
116
+ ...namespaceField(tree.namespaceOf(folderId)),
117
+ embedding,
118
+ updatedAt: row.updatedAt,
119
+ });
120
+ if (existing)
121
+ result.updated++;
122
+ else
123
+ result.added++;
124
+ }
125
+ if (page >= batch.pagination.pages || batch.notes.length === 0)
126
+ break;
127
+ page++;
128
+ }
129
+ }
130
+ // Anything no longer in the tree was trashed, hidden, or moved out of the
131
+ // memories folder: it stops being a memory.
132
+ if (this.epoch === startEpoch) {
133
+ for (const id of [...this.cache.keys()]) {
134
+ if (seen.has(id))
135
+ continue;
136
+ this.cache.delete(id);
137
+ embeddings.delete(id);
138
+ result.removed++;
139
+ }
140
+ await embeddings.save(key);
141
+ }
142
+ return result;
143
+ }
144
+ add(label, text, namespace, meta = {}) {
145
+ return this.enqueue(async () => {
146
+ await this.sync();
147
+ const key = this.requireSession();
148
+ const startEpoch = this.epoch;
149
+ const embedding = await this.embedder.embed(embeddingInput(label, text));
150
+ const candidates = this.candidatesFor(namespace);
151
+ const hit = (0, virlow_memory_1.findDuplicate)(embedding, candidates);
152
+ if (hit) {
153
+ // Same memory said again: fold it into the existing note rather than
154
+ // accumulating near-identical copies. Its metadata carries forward.
155
+ const existing = this.cache.get(hit.id);
156
+ const merged = (0, memory_note_js_1.mergeMeta)(existing.meta, meta);
157
+ const row = await this.writeNote(key, hit.id, label, text, merged);
158
+ if (this.epoch === startEpoch) {
159
+ this.cache.set(hit.id, {
160
+ ...existing,
161
+ label,
162
+ text,
163
+ meta: merged,
164
+ embedding,
165
+ updatedAt: row.updatedAt,
166
+ });
167
+ this.embeddings?.set(hit.id, row.updatedAt, embedding);
168
+ await this.saveEmbeddings(key, startEpoch);
169
+ }
170
+ return { action: 'updated', id: hit.id, similar: [] };
171
+ }
172
+ const tree = await this.requireTree();
173
+ const folderId = namespace !== undefined
174
+ ? await (0, memory_tree_js_1.ensureNamespace)(this.api, tree, namespace)
175
+ : tree.rootId;
176
+ const stamped = {
177
+ ...meta,
178
+ source: meta.source ?? 'virlow-mcp',
179
+ created: meta.created ?? new Date().toISOString(),
180
+ };
181
+ const row = await this.writeNote(key, null, label, text, stamped, folderId);
182
+ if (this.epoch === startEpoch) {
183
+ this.cache.set(row.id, {
184
+ id: row.id,
185
+ label,
186
+ text,
187
+ meta: stamped,
188
+ folderId,
189
+ ...namespaceField(tree.namespaceOf(folderId)),
190
+ embedding,
191
+ updatedAt: row.updatedAt,
192
+ });
193
+ this.embeddings?.set(row.id, row.updatedAt, embedding);
194
+ await this.saveEmbeddings(key, startEpoch);
195
+ }
196
+ // Close enough to be worth mentioning, not close enough to merge.
197
+ const similar = (0, virlow_memory_1.findSimilar)(embedding, candidates).map(({ id, similarity }) => {
198
+ const memory = this.cache.get(id);
199
+ return {
200
+ id,
201
+ label: memory.label,
202
+ text: memory.text,
203
+ similarity: round3(similarity),
204
+ };
205
+ });
206
+ return { action: 'stored', id: row.id, similar };
207
+ });
208
+ }
209
+ async search(query, namespace, limit = 10) {
210
+ await this.sync();
211
+ this.requireSession();
212
+ const embedding = await this.embedder.embed(query);
213
+ const ranked = (0, virlow_memory_1.rankBySimilarity)(embedding, this.candidatesFor(namespace), limit);
214
+ return ranked.map(({ id, similarity }) => {
215
+ const memory = this.cache.get(id);
216
+ return {
217
+ id,
218
+ label: memory.label,
219
+ text: memory.text,
220
+ meta: memory.meta,
221
+ ...namespaceField(memory.namespace),
222
+ similarity: round3(similarity),
223
+ };
224
+ });
225
+ }
226
+ /** Requires a prior sync() in the same unlock to have populated the cache. */
227
+ list(namespace) {
228
+ this.requireSession();
229
+ return [...this.cache.values()].filter((m) => namespace === undefined || m.namespace === namespace || m.namespace === undefined);
230
+ }
231
+ update(id, changes) {
232
+ return this.enqueue(async () => {
233
+ // Sync first: without it a cache miss (fresh unlock, or a memory this
234
+ // device never saw) would overwrite the server note with defaults and
235
+ // wipe its metadata. If the id is still missing afterwards it genuinely
236
+ // does not exist — never guess-write over it.
237
+ await this.sync();
238
+ const key = this.requireSession();
239
+ const startEpoch = this.epoch;
240
+ const existing = this.cache.get(id);
241
+ if (!existing)
242
+ throw new Error(`Memory ${id} not found`);
243
+ const label = changes.label ?? existing.label;
244
+ const text = changes.text ?? existing.text;
245
+ const meta = (0, memory_note_js_1.mergeMeta)(existing.meta, changes.meta ?? {});
246
+ const row = await this.writeNote(key, id, label, text, meta);
247
+ const embedding = await this.embedder.embed(embeddingInput(label, text));
248
+ if (this.epoch !== startEpoch)
249
+ return;
250
+ this.cache.set(id, { ...existing, label, text, meta, embedding, updatedAt: row.updatedAt });
251
+ this.embeddings?.set(id, row.updatedAt, embedding);
252
+ await this.saveEmbeddings(key, startEpoch);
253
+ });
254
+ }
255
+ /** Trashes the note — recoverable from the app's Trash, never hard-deleted. */
256
+ delete(id) {
257
+ return this.enqueue(async () => {
258
+ const key = this.requireSession();
259
+ const startEpoch = this.epoch;
260
+ await this.api.updateNote(id, { deleted: true });
261
+ if (this.epoch !== startEpoch)
262
+ return;
263
+ this.cache.delete(id);
264
+ this.embeddings?.delete(id);
265
+ await this.saveEmbeddings(key, startEpoch);
266
+ });
267
+ }
268
+ /** Dedupe and search candidates: the named namespace plus the global root. */
269
+ candidatesFor(namespace) {
270
+ const result = [];
271
+ for (const memory of this.cache.values()) {
272
+ if (memory.namespace === namespace || memory.namespace === undefined) {
273
+ result.push({ id: memory.id, embedding: memory.embedding });
274
+ }
275
+ }
276
+ return result;
277
+ }
278
+ /** Create or update a memory note, encrypting through the note field
279
+ * helpers. `noteId === null` creates. */
280
+ async writeNote(key, noteId, label, text, meta, folderId) {
281
+ const body = (0, memory_note_js_1.toCodeEnvelope)((0, memory_note_js_1.serializeMemoryBody)(text, meta));
282
+ const salt = (0, virlow_crypto_1.bytesToBase64)(crypto.getRandomValues(new Uint8Array(32)));
283
+ const { encryptedTitle, encryptedContent, iv } = await (0, virlow_crypto_1.encryptNoteFields)(key, label, body);
284
+ const write = {
285
+ title: '[ENCRYPTED]',
286
+ content: '[ENCRYPTED]',
287
+ encryptedTitle,
288
+ encryptedContent,
289
+ iv,
290
+ salt,
291
+ type: 'code',
292
+ // Mirrored so the app can filter memories by tag. The frontmatter stays
293
+ // the source of truth; this column follows it on every write we make.
294
+ tags: meta.tags ?? [],
295
+ };
296
+ if (folderId !== undefined)
297
+ write.folderId = folderId;
298
+ return noteId === null
299
+ ? this.api.createNote(write)
300
+ : this.api.updateNote(noteId, write);
301
+ }
302
+ async decryptRow(key, row) {
303
+ if (row.encryptedTitle && row.encryptedContent && row.iv) {
304
+ return (0, virlow_crypto_1.decryptNoteFields)(key, row.encryptedTitle, row.encryptedContent, row.iv);
305
+ }
306
+ // A legacy plaintext note the user filed under Memories by hand.
307
+ return { title: row.title, content: row.content };
308
+ }
309
+ async requireTree() {
310
+ this.tree ??= await (0, memory_tree_js_1.resolveMemoryTree)(this.api);
311
+ return this.tree;
312
+ }
313
+ async requireEmbeddings(key) {
314
+ this.embeddings ??= this.cacheFactory(this.vault.session.userId);
315
+ if (!this.embeddingsLoaded) {
316
+ await this.embeddings.load(key);
317
+ this.embeddingsLoaded = true;
318
+ }
319
+ return this.embeddings;
320
+ }
321
+ /** Persisting after a lock would write vectors the user just cleared. */
322
+ async saveEmbeddings(key, startEpoch) {
323
+ if (this.epoch !== startEpoch)
324
+ return;
325
+ await this.embeddings?.save(key);
326
+ }
327
+ requireSession() {
328
+ const key = this.vault.session.key;
329
+ this.vault.touch();
330
+ return key;
331
+ }
332
+ /** Serializes add/update/delete so two overlapping mutation tool calls never
333
+ * interleave — two concurrent near-duplicate adds must run their
334
+ * dedupe-check-then-write critical section one at a time, or both would miss
335
+ * the other's pending write and both create. */
336
+ enqueue(fn) {
337
+ const next = this.mutationChain.then(fn, fn);
338
+ this.mutationChain = next.catch(() => { });
339
+ return next;
340
+ }
341
+ }
342
+ exports.MemoryStore = MemoryStore;
343
+ /** The metadata is deliberately excluded: it is near-identical boilerplate
344
+ * across the corpus, so embedding it would raise the similarity floor
345
+ * everywhere and quietly change what the dedupe thresholds mean. */
346
+ function embeddingInput(label, fact) {
347
+ return `${label}\n${fact}`;
348
+ }
349
+ /** `exactOptionalPropertyTypes` forbids assigning undefined to an optional
350
+ * property, so an absent namespace omits the key entirely. */
351
+ function namespaceField(namespace) {
352
+ return namespace === undefined ? {} : { namespace };
353
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Metadata carried in a memory note's YAML frontmatter. Every key is optional.
3
+ *
4
+ * Deliberately absent: `id`, `label`, and `namespace`. The note id, the note
5
+ * title, and the containing subfolder already own those, and duplicating them
6
+ * here would create a second source of truth that goes stale the moment the
7
+ * user renames or drags the note in the app.
8
+ */
9
+ export interface MemoryMeta {
10
+ /** Which agent or tool wrote the memory. */
11
+ source?: string;
12
+ tags?: string[];
13
+ /** Free-form: 'high', '0.8' — stored and editable, nothing ranks on it yet. */
14
+ confidence?: string;
15
+ /** ISO timestamp of the first write; never overwritten by later updates. */
16
+ created?: string;
17
+ }
18
+ export interface ParsedMemoryNote {
19
+ fact: string;
20
+ meta: MemoryMeta;
21
+ }
22
+ /** The app's code editor stores content as this envelope; `code` is what the
23
+ * user sees and edits in Monaco. */
24
+ export declare function toCodeEnvelope(body: string): string;
25
+ /**
26
+ * Split a decrypted note body into its fact and metadata.
27
+ *
28
+ * Total by construction: a missing fence, unparseable YAML, frontmatter that
29
+ * is not a mapping, or a note that was never a memory note at all all yield
30
+ * `{ fact: <the whole body>, meta: {} }`. A human edits these by hand, so a
31
+ * syntax error must cost them a bit of metadata, never the memory.
32
+ */
33
+ export declare function parseMemoryNote(decryptedContent: string): ParsedMemoryNote;
34
+ /**
35
+ * Canonical memory body: frontmatter then the fact, or just the fact when
36
+ * there is no metadata to record.
37
+ */
38
+ export declare function serializeMemoryBody(fact: string, meta: MemoryMeta): string;
39
+ /**
40
+ * Metadata for an update: named keys win, unnamed keys carry forward, and
41
+ * `created` is pinned to the first write so an in-place dedupe merge cannot
42
+ * make an old memory look new.
43
+ */
44
+ export declare function mergeMeta(existing: MemoryMeta, changes: MemoryMeta): MemoryMeta;
@@ -0,0 +1,149 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toCodeEnvelope = toCodeEnvelope;
4
+ exports.parseMemoryNote = parseMemoryNote;
5
+ exports.serializeMemoryBody = serializeMemoryBody;
6
+ exports.mergeMeta = mergeMeta;
7
+ const yaml_1 = require("yaml");
8
+ /** Serialisation order, so a rewrite of unchanged metadata is a no-op diff. */
9
+ const KEY_ORDER = ['source', 'tags', 'confidence', 'created'];
10
+ const FENCE = '---';
11
+ /** The app's code editor stores content as this envelope; `code` is what the
12
+ * user sees and edits in Monaco. */
13
+ function toCodeEnvelope(body) {
14
+ return JSON.stringify({ language: 'markdown', code: body, lastExecution: null });
15
+ }
16
+ function unwrapEnvelope(content) {
17
+ try {
18
+ const parsed = JSON.parse(content);
19
+ if (parsed !== null &&
20
+ typeof parsed === 'object' &&
21
+ typeof parsed.code === 'string') {
22
+ return parsed.code;
23
+ }
24
+ }
25
+ catch {
26
+ // Not JSON at all — a hand-written note, which is a perfectly good memory.
27
+ }
28
+ return content;
29
+ }
30
+ /** Keep only keys we understand, and only when their value has the right
31
+ * shape. One malformed key never discards its neighbours. */
32
+ function coerceMeta(raw) {
33
+ const meta = {};
34
+ if (typeof raw.source === 'string')
35
+ meta.source = raw.source;
36
+ if (Array.isArray(raw.tags) &&
37
+ raw.tags.every((t) => typeof t === 'string')) {
38
+ meta.tags = [...raw.tags];
39
+ }
40
+ // YAML turns `confidence: 0.8` into a number; the field is free-form text.
41
+ if (typeof raw.confidence === 'string') {
42
+ meta.confidence = raw.confidence;
43
+ }
44
+ else if (typeof raw.confidence === 'number') {
45
+ meta.confidence = String(raw.confidence);
46
+ }
47
+ if (typeof raw.created === 'string') {
48
+ meta.created = raw.created;
49
+ }
50
+ else if (raw.created instanceof Date) {
51
+ // YAML parses unquoted ISO timestamps into Dates.
52
+ meta.created = raw.created.toISOString();
53
+ }
54
+ return meta;
55
+ }
56
+ /**
57
+ * Split a decrypted note body into its fact and metadata.
58
+ *
59
+ * Total by construction: a missing fence, unparseable YAML, frontmatter that
60
+ * is not a mapping, or a note that was never a memory note at all all yield
61
+ * `{ fact: <the whole body>, meta: {} }`. A human edits these by hand, so a
62
+ * syntax error must cost them a bit of metadata, never the memory.
63
+ */
64
+ function parseMemoryNote(decryptedContent) {
65
+ // The note's `type` is deliberately not consulted: an envelope is accepted
66
+ // wherever one is found, so a memory whose type was changed in the app — or
67
+ // a plain note dropped into the folder by hand — still reads correctly.
68
+ const body = unwrapEnvelope(decryptedContent);
69
+ if (!body.startsWith(`${FENCE}\n`))
70
+ return { fact: body, meta: {} };
71
+ // Only the first fence pair is frontmatter; --- lines inside the fact are
72
+ // ordinary content.
73
+ const close = body.indexOf(`\n${FENCE}\n`, FENCE.length);
74
+ if (close === -1)
75
+ return { fact: body, meta: {} };
76
+ const front = body.slice(FENCE.length + 1, close);
77
+ const fact = body.slice(close + FENCE.length + 2);
78
+ // An empty fence is the marker `serializeMemoryBody` writes when a fact
79
+ // would otherwise be misread as its own frontmatter. Strip it and keep the
80
+ // fact whole.
81
+ if (front.trim() === '')
82
+ return { fact, meta: {} };
83
+ let raw;
84
+ try {
85
+ raw = (0, yaml_1.parse)(front);
86
+ }
87
+ catch {
88
+ return { fact: body, meta: {} };
89
+ }
90
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
91
+ return { fact: body, meta: {} };
92
+ }
93
+ return { fact, meta: coerceMeta(raw) };
94
+ }
95
+ function yamlScalar(value) {
96
+ // Quote anything YAML would otherwise reinterpret (or that would break the
97
+ // line), so a round-trip returns the same string.
98
+ return /^[A-Za-z0-9][\w .:@/+-]*$/.test(value) && !value.includes(': ')
99
+ ? value
100
+ : JSON.stringify(value);
101
+ }
102
+ /**
103
+ * Canonical memory body: frontmatter then the fact, or just the fact when
104
+ * there is no metadata to record.
105
+ */
106
+ function serializeMemoryBody(fact, meta) {
107
+ const lines = [];
108
+ for (const key of KEY_ORDER) {
109
+ const value = meta[key];
110
+ if (value === undefined)
111
+ continue;
112
+ if (key === 'tags') {
113
+ const tags = value;
114
+ if (tags.length === 0)
115
+ continue;
116
+ lines.push(`tags: [${tags.map(yamlScalar).join(', ')}]`);
117
+ }
118
+ else {
119
+ lines.push(`${key}: ${yamlScalar(value)}`);
120
+ }
121
+ }
122
+ if (lines.length === 0) {
123
+ // A fact that opens with its own fence (a pasted document with
124
+ // frontmatter, say) would be read back as metadata and lose its first
125
+ // lines. An empty fence disambiguates it.
126
+ return fact.startsWith(`${FENCE}\n`) ? `${FENCE}\n${FENCE}\n${fact}` : fact;
127
+ }
128
+ return `${FENCE}\n${lines.join('\n')}\n${FENCE}\n${fact}`;
129
+ }
130
+ /**
131
+ * Metadata for an update: named keys win, unnamed keys carry forward, and
132
+ * `created` is pinned to the first write so an in-place dedupe merge cannot
133
+ * make an old memory look new.
134
+ */
135
+ function mergeMeta(existing, changes) {
136
+ const merged = { ...existing };
137
+ if (changes.source !== undefined)
138
+ merged.source = changes.source;
139
+ if (changes.tags !== undefined)
140
+ merged.tags = [...changes.tags];
141
+ if (changes.confidence !== undefined)
142
+ merged.confidence = changes.confidence;
143
+ // Pinned to the first write, so an in-place dedupe merge cannot make an old
144
+ // memory look newly created.
145
+ if (changes.created !== undefined && existing.created === undefined) {
146
+ merged.created = changes.created;
147
+ }
148
+ return merged;
149
+ }
@@ -0,0 +1,27 @@
1
+ import { type VirlowApi } from './api.js';
2
+ /** Name given to the memories root when the server has to create it. The root
3
+ * is identified by `kind`, never by this name — the user is free to rename it. */
4
+ export declare const MEMORIES_ROOT_NAME = "Memories";
5
+ export interface MemoryTree {
6
+ rootId: string;
7
+ /** namespace name -> subfolder id (direct children of the root) */
8
+ namespaces: Map<string, string>;
9
+ /** folder id -> namespace name; undefined for the root itself */
10
+ namespaceOf(folderId: string): string | undefined;
11
+ /** every folder id whose notes are memories, root first */
12
+ folderIds(): string[];
13
+ /** Record a subfolder created after the tree was resolved. Used by
14
+ * `ensureNamespace`; callers outside this module have no reason to. */
15
+ attach(folderId: string, name: string): void;
16
+ }
17
+ /**
18
+ * Resolve the memories folder tree, creating the root if the user has none.
19
+ * The root is whichever folder carries `kind: 'memories'` — the API allows
20
+ * only one per user.
21
+ */
22
+ export declare function resolveMemoryTree(api: VirlowApi): Promise<MemoryTree>;
23
+ /**
24
+ * Folder id for `name`, creating the namespace subfolder if it does not exist
25
+ * yet. Mutates `tree` so a later call in the same session is a cache hit.
26
+ */
27
+ export declare function ensureNamespace(api: VirlowApi, tree: MemoryTree, name: string): Promise<string>;