@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.
- package/dist/cjs/api.d.ts +117 -0
- package/dist/cjs/api.js +113 -0
- package/dist/cjs/embedding-cache.d.ts +33 -0
- package/dist/cjs/embedding-cache.js +114 -0
- package/dist/cjs/exposure.d.ts +41 -0
- package/dist/cjs/exposure.js +77 -0
- package/dist/cjs/index.d.ts +15 -0
- package/dist/cjs/index.js +32 -0
- package/dist/cjs/memories-enabled.d.ts +15 -0
- package/dist/cjs/memories-enabled.js +32 -0
- package/dist/cjs/memories.d.ts +85 -0
- package/dist/cjs/memories.js +353 -0
- package/dist/cjs/memory-note.d.ts +44 -0
- package/dist/cjs/memory-note.js +149 -0
- package/dist/cjs/memory-tree.d.ts +27 -0
- package/dist/cjs/memory-tree.js +72 -0
- package/dist/cjs/notes.d.ts +108 -0
- package/dist/cjs/notes.js +237 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/vault.d.ts +34 -0
- package/dist/cjs/vault.js +77 -0
- package/dist/exposure.d.ts +1 -1
- package/dist/exposure.d.ts.map +1 -1
- package/dist/index.d.ts +15 -15
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -9
- package/dist/index.js.map +1 -1
- package/dist/memories-enabled.d.ts +1 -1
- package/dist/memories-enabled.d.ts.map +1 -1
- package/dist/memories.d.ts +4 -4
- package/dist/memories.d.ts.map +1 -1
- package/dist/memories.js +3 -3
- package/dist/memories.js.map +1 -1
- package/dist/memory-tree.d.ts +1 -1
- package/dist/memory-tree.d.ts.map +1 -1
- package/dist/memory-tree.js +1 -1
- package/dist/memory-tree.js.map +1 -1
- package/dist/notes.d.ts +3 -3
- package/dist/notes.d.ts.map +1 -1
- package/dist/notes.js +1 -1
- package/dist/notes.js.map +1 -1
- 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>;
|