memtrails 0.2.0 → 0.4.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.
- package/CHANGELOG.md +39 -0
- package/README.md +20 -13
- package/dist/core/consolidate.d.ts +23 -8
- package/dist/core/consolidate.js +52 -25
- package/dist/core/episodes.d.ts +1 -1
- package/dist/core/episodes.js +2 -8
- package/dist/core/extract.js +5 -5
- package/dist/core/gather.d.ts +7 -0
- package/dist/core/gather.js +38 -14
- package/dist/core/model.d.ts +8 -4
- package/dist/core/model.js +29 -40
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +7 -6
- package/dist/core/qdrant-binary.d.ts +39 -5
- package/dist/core/qdrant-binary.js +185 -48
- package/dist/core/qdrant-client.d.ts +6 -18
- package/dist/core/qdrant-client.js +38 -90
- package/dist/core/qdrant-process.d.ts +22 -1
- package/dist/core/qdrant-process.js +190 -22
- package/dist/core/rebuild.d.ts +5 -1
- package/dist/core/rebuild.js +19 -4
- package/dist/core/recall-log.d.ts +3 -0
- package/dist/core/recall-log.js +6 -1
- package/dist/core/recall.js +14 -5
- package/dist/core/redact.d.ts +8 -0
- package/dist/core/redact.js +48 -9
- package/dist/core/sleep.d.ts +4 -3
- package/dist/core/sleep.js +19 -11
- package/dist/core/store.d.ts +7 -1
- package/dist/core/store.js +101 -33
- package/dist/core/text.d.ts +4 -2
- package/dist/core/text.js +17 -5
- package/dist/core/words.js +6 -2
- package/dist/core/writer.d.ts +2 -1
- package/dist/core/writer.js +9 -3
- package/dist/importers/files.js +53 -10
- package/dist/importers/git.js +3 -1
- package/dist/importers/mbox.js +22 -7
- package/dist/importers/slack.js +5 -2
- package/dist/importers/text.js +85 -14
- package/dist/llm.d.ts +1 -8
- package/dist/llm.js +11 -5
- package/dist/memory.d.ts +9 -3
- package/dist/memory.js +216 -48
- package/dist/records.js +6 -3
- package/dist/tools/agent-tools.d.ts +2 -5
- package/dist/tools/agent-tools.js +37 -21
- package/package.json +1 -1
package/dist/memory.js
CHANGED
|
@@ -15,20 +15,20 @@
|
|
|
15
15
|
* are written again from the logs.
|
|
16
16
|
*/
|
|
17
17
|
import { join, resolve } from 'node:path';
|
|
18
|
-
import { mkdirSync } from 'node:fs';
|
|
18
|
+
import { chmodSync, closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, writeSync } from 'node:fs';
|
|
19
19
|
import { Consolidator } from './core/consolidate.js';
|
|
20
|
-
import { remoteEmbedder } from './core/embedder.js';
|
|
20
|
+
import { remoteEmbedder, resolveEmbedRoute } from './core/embedder.js';
|
|
21
21
|
import { memoryGather } from './core/gather.js';
|
|
22
22
|
import { loadLocalEmbedder } from './core/local-embedder.js';
|
|
23
23
|
import { ensureModel } from './core/model.js';
|
|
24
24
|
import { ensureQdrant } from './core/qdrant-binary.js';
|
|
25
|
-
import { COLLECTION, QdrantClient } from './core/qdrant-client.js';
|
|
26
|
-
import { QdrantProcess } from './core/qdrant-process.js';
|
|
25
|
+
import { COLLECTION, INDEX_LAYOUT, QdrantClient } from './core/qdrant-client.js';
|
|
26
|
+
import { alive, bootTime, QdrantProcess, sameBoot } from './core/qdrant-process.js';
|
|
27
27
|
import { memoryExplore, memoryOpen, memorySearch } from './core/recall.js';
|
|
28
28
|
import { RecallBook } from './core/recall-log.js';
|
|
29
29
|
import { episodeBody } from './core/episodes.js';
|
|
30
30
|
import { MemoryRebuild } from './core/rebuild.js';
|
|
31
|
-
import { remoteReranker } from './core/reranker.js';
|
|
31
|
+
import { remoteReranker, resolveRerankRoute } from './core/reranker.js';
|
|
32
32
|
import { sentenceRoom, sentenceSpans } from './core/room.js';
|
|
33
33
|
import { Sleep } from './core/sleep.js';
|
|
34
34
|
import { ltmLog, stmLog, UsageBook } from './core/store.js';
|
|
@@ -41,16 +41,133 @@ const BATCH = 32;
|
|
|
41
41
|
const ROOM_TEXT = 1500;
|
|
42
42
|
const ROOM_CHARS = 6000;
|
|
43
43
|
const RECORD_LIMIT = 2_000_000;
|
|
44
|
+
/** In the home: who has it open (lockHome), and the index layout its Qdrant collection was written in. */
|
|
45
|
+
const LOCK_FILE = 'memory.lock';
|
|
46
|
+
const LAYOUT_FILE = 'index.json';
|
|
47
|
+
/** The homes this process holds, released if it exits without close(). */
|
|
48
|
+
const holding = new Map();
|
|
49
|
+
let exitHook = false;
|
|
50
|
+
/** Who holds a lock: its live pid, 0 when it is being written this instant, null when nobody (stale or gone). */
|
|
51
|
+
function lockHolder(path) {
|
|
52
|
+
let text;
|
|
53
|
+
try {
|
|
54
|
+
text = readFileSync(path, 'utf8');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { text: '', pid: null };
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const l = JSON.parse(text);
|
|
61
|
+
const pid = Number(l.pid);
|
|
62
|
+
return { text, pid: sameBoot(l.boot) && alive(pid) ? pid : null };
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// Not written yet by the opener that just made it — or torn by a crash, and then old.
|
|
66
|
+
try {
|
|
67
|
+
return { text, pid: Date.now() - statSync(path).mtimeMs < 10_000 ? 0 : null };
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return { text, pid: null };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/** One process at a time in a home: two would run two Qdrants on one storage and append to the logs at offsets the
|
|
75
|
+
* other does not know. The lock is made exclusively (O_EXCL) with its pid and the boot; one whose process is gone, or
|
|
76
|
+
* ran before a reboot (its pid may be another's now), is taken over. Returns its release. */
|
|
77
|
+
function lockHome(dir) {
|
|
78
|
+
const path = join(dir, LOCK_FILE);
|
|
79
|
+
for (let attempt = 0;; attempt++) {
|
|
80
|
+
let fd;
|
|
81
|
+
try {
|
|
82
|
+
fd = openSync(path, 'wx', 0o600);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (err.code !== 'EEXIST')
|
|
86
|
+
throw err;
|
|
87
|
+
const { text, pid } = lockHolder(path);
|
|
88
|
+
if (pid !== null || attempt > 0) {
|
|
89
|
+
throw new Error(`memory ${dir} is open${pid ? ` in process ${pid}` : ''}: close it there first (if no process has it, delete ${path})`);
|
|
90
|
+
}
|
|
91
|
+
// Stale: taken over — unless another opener took it over since it was read.
|
|
92
|
+
try {
|
|
93
|
+
if (readFileSync(path, 'utf8') === text)
|
|
94
|
+
rmSync(path, { force: true });
|
|
95
|
+
}
|
|
96
|
+
catch { /* gone already */ }
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
writeSync(fd, JSON.stringify({ pid: process.pid, boot: bootTime() }));
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
closeSync(fd);
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
const release = () => {
|
|
108
|
+
if (holding.get(path) !== release)
|
|
109
|
+
return;
|
|
110
|
+
holding.delete(path);
|
|
111
|
+
try {
|
|
112
|
+
if (JSON.parse(readFileSync(path, 'utf8')).pid === process.pid)
|
|
113
|
+
rmSync(path, { force: true });
|
|
114
|
+
}
|
|
115
|
+
catch { /* gone */ }
|
|
116
|
+
};
|
|
117
|
+
holding.set(path, release);
|
|
118
|
+
if (!exitHook) {
|
|
119
|
+
exitHook = true;
|
|
120
|
+
process.on('exit', () => { for (const r of [...holding.values()])
|
|
121
|
+
r(); });
|
|
122
|
+
}
|
|
123
|
+
return release;
|
|
124
|
+
}
|
|
125
|
+
/** The layout the index was written in (qdrant-client.ts INDEX_LAYOUT); null when not written down. */
|
|
126
|
+
function indexLayout(dir) {
|
|
127
|
+
try {
|
|
128
|
+
return Number(JSON.parse(readFileSync(join(dir, LAYOUT_FILE), 'utf8')).layout);
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function saveIndexLayout(dir) {
|
|
135
|
+
const path = join(dir, LAYOUT_FILE);
|
|
136
|
+
writeFileSync(`${path}.tmp`, JSON.stringify({ layout: INDEX_LAYOUT }), { mode: 0o600 });
|
|
137
|
+
renameSync(`${path}.tmp`, path);
|
|
138
|
+
}
|
|
44
139
|
export async function openMemory(o) {
|
|
45
140
|
const dir = resolve(o.home);
|
|
46
|
-
mkdirSync(dir, { recursive: true });
|
|
47
141
|
const log = o.log ?? (() => { });
|
|
48
|
-
//
|
|
142
|
+
// Private: the logs, the index and the word vectors are everything that was added (a shared machine).
|
|
143
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
144
|
+
try {
|
|
145
|
+
chmodSync(dir, 0o700);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
log(`[memtrails] ${dir} could not be made private: ${err.message}`);
|
|
149
|
+
}
|
|
150
|
+
const release = lockHome(dir);
|
|
151
|
+
const started = { proc: null, local: null };
|
|
152
|
+
try {
|
|
153
|
+
return await openIn(dir, o, log, started, release);
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
await started.proc?.stopAndWait();
|
|
157
|
+
await started.local?.close().catch(() => undefined);
|
|
158
|
+
release();
|
|
159
|
+
throw err;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function openIn(dir, o, log, started, release) {
|
|
163
|
+
// Embedding: here, or on a server — the options' own, else the one the environment names.
|
|
49
164
|
const e = o.embedding ?? {};
|
|
50
|
-
const
|
|
165
|
+
const server = e.server ?? (e.modelDir ? null : resolveEmbedRoute());
|
|
166
|
+
const local = !server || e.modelDir
|
|
51
167
|
? await loadLocalEmbedder(e.modelDir ?? await ensureModel({ log }), { threads: e.threads ?? 2 })
|
|
52
168
|
: null;
|
|
53
|
-
|
|
169
|
+
started.local = local;
|
|
170
|
+
const embed = server ? remoteEmbedder({ route: () => server }) : local.embed;
|
|
54
171
|
const encode = local?.words ?? null;
|
|
55
172
|
const words = new WordStore(join(dir, 'words.bin'));
|
|
56
173
|
await words.load();
|
|
@@ -64,6 +181,7 @@ export async function openMemory(o) {
|
|
|
64
181
|
}
|
|
65
182
|
else {
|
|
66
183
|
proc = new QdrantProcess({ binary: q && 'binary' in q ? q.binary : await ensureQdrant({ log }), dir: join(dir, 'qdrant'), log });
|
|
184
|
+
started.proc = proc;
|
|
67
185
|
await proc.start();
|
|
68
186
|
endpoint = () => proc.current();
|
|
69
187
|
}
|
|
@@ -75,24 +193,56 @@ export async function openMemory(o) {
|
|
|
75
193
|
await ltm.load();
|
|
76
194
|
const usage = new UsageBook(join(dir, 'usage.json'));
|
|
77
195
|
const book = new RecallBook(join(dir, 'recall.jsonl'));
|
|
196
|
+
void book.trim(); // the documented cap: once an open, and every so many events while open (recall-log.ts)
|
|
78
197
|
const ask = typeof o.llm === 'function' ? o.llm : o.llm ? openAiAsk(o.llm) : defaultAsk;
|
|
79
|
-
|
|
198
|
+
/** Set by close(): what runs stops at its next step. */
|
|
199
|
+
let closing = false;
|
|
200
|
+
const aborted = () => closing;
|
|
201
|
+
const base = { client, embed, stm, ltm, log, aborted, ask };
|
|
202
|
+
const rerankEnv = o.rerank === undefined ? resolveRerankRoute() : null;
|
|
80
203
|
const rerank = o.rerank === false ? undefined
|
|
81
204
|
: typeof o.rerank === 'function' ? o.rerank
|
|
82
205
|
: o.rerank && typeof o.rerank === 'object' ? remoteReranker({ route: () => o.rerank })
|
|
83
|
-
:
|
|
206
|
+
: rerankEnv ? remoteReranker({ route: () => rerankEnv })
|
|
207
|
+
: encode ? wordReranker(encode, words) : undefined;
|
|
84
208
|
const deps = { client, embed, rerank, used: (ids, what) => usage.note(ids, what), record: (ev) => book.record(ev) };
|
|
85
|
-
// The index is derived:
|
|
209
|
+
// The index is derived: written again from the logs when it is empty while they are not (a new Qdrant, a deleted
|
|
210
|
+
// folder), or was written in another layout — or in one not written down (an index from before the layouts were).
|
|
86
211
|
const rebuild = new MemoryRebuild({
|
|
87
|
-
path: join(dir, 'rebuild.json'), stm, ltm, aborted
|
|
212
|
+
path: join(dir, 'rebuild.json'), stm, ltm, aborted,
|
|
88
213
|
writeStm: (xs) => writeStm(base, xs), writeLtm: (xs) => writeLtm(base, xs),
|
|
89
214
|
});
|
|
90
|
-
|
|
215
|
+
const indexed = await client.count();
|
|
216
|
+
const layout = indexLayout(dir);
|
|
217
|
+
if (layout !== INDEX_LAYOUT) {
|
|
218
|
+
if (indexed) {
|
|
219
|
+
log(`[memtrails] the index was written in layout ${layout ?? 'unknown'}, not ${INDEX_LAYOUT}: writing it again from the logs`);
|
|
220
|
+
rebuild.begin(); // before the drop: a crash after it resumes
|
|
221
|
+
await client.dropCollection();
|
|
222
|
+
await client.ensureCollection();
|
|
223
|
+
}
|
|
224
|
+
saveIndexLayout(dir);
|
|
225
|
+
}
|
|
226
|
+
if (rebuild.pending() || (!indexed && (stm.size() || ltm.size()))) {
|
|
91
227
|
log('[memtrails] writing the index from the logs');
|
|
92
228
|
if (!rebuild.pending())
|
|
93
229
|
rebuild.begin();
|
|
94
230
|
await rebuild.run();
|
|
95
231
|
}
|
|
232
|
+
// consolidate, sleep, rebuild, indexWords and remove one at a time: each reads the logs and writes the index from
|
|
233
|
+
// them — two consolidations pay the model twice and write stale copies over each other, a rebuild clears what
|
|
234
|
+
// another is writing, a remove during a consolidation would see its record's episode written back after it.
|
|
235
|
+
let queue = Promise.resolve();
|
|
236
|
+
const exclusive = (job) => {
|
|
237
|
+
const run = queue.then(() => {
|
|
238
|
+
if (closing)
|
|
239
|
+
throw new Error('memory is closed');
|
|
240
|
+
return job();
|
|
241
|
+
});
|
|
242
|
+
queue = run.catch(() => undefined);
|
|
243
|
+
return run;
|
|
244
|
+
};
|
|
245
|
+
let closed = null;
|
|
96
246
|
/** The text word by word scores an item by: its key, as search returns it. */
|
|
97
247
|
const keyText = (title, summary) => `${title}\n${summary}`;
|
|
98
248
|
/** A found item's whole text for the room: a record's own, or what consolidation wrote. */
|
|
@@ -110,7 +260,7 @@ export async function openMemory(o) {
|
|
|
110
260
|
if (!encode)
|
|
111
261
|
return 0;
|
|
112
262
|
const todo = [...new Set(texts)].filter((t) => t.trim() && !words.get(t));
|
|
113
|
-
for (let i = 0; i < todo.length; i += BATCH) {
|
|
263
|
+
for (let i = 0; i < todo.length && !closing; i += BATCH) {
|
|
114
264
|
const part = todo.slice(i, i + BATCH);
|
|
115
265
|
const encoded = await encode(part, 'document');
|
|
116
266
|
await words.put(part.map((t, n) => [t, packWords(encoded[n])]));
|
|
@@ -132,6 +282,22 @@ export async function openMemory(o) {
|
|
|
132
282
|
stm.compact();
|
|
133
283
|
return { added: fresh.length, unchanged: total - fresh.length };
|
|
134
284
|
};
|
|
285
|
+
/** Unlocked: consolidate and sleep run it inside their own turn. */
|
|
286
|
+
const indexWords = async () => {
|
|
287
|
+
if (!encode)
|
|
288
|
+
return 0;
|
|
289
|
+
const texts = [...stm.all()].flatMap((x) => wordsOfEntries([x]));
|
|
290
|
+
for (const [uid, m] of ltm.entries()) {
|
|
291
|
+
if (m.status !== 'active')
|
|
292
|
+
continue;
|
|
293
|
+
const it = ltm.get(uid);
|
|
294
|
+
if (!it)
|
|
295
|
+
continue;
|
|
296
|
+
const room = `${it.title}\n${it.summary}\n${it.body}`.slice(0, ROOM_TEXT);
|
|
297
|
+
texts.push(keyText(it.title, it.summary), ...sentenceSpans(room).map(([a, b]) => room.slice(a, b)));
|
|
298
|
+
}
|
|
299
|
+
return encodeMissing(texts);
|
|
300
|
+
};
|
|
135
301
|
const memory = {
|
|
136
302
|
async add(records) {
|
|
137
303
|
const list = Array.isArray(records) ? records : [records];
|
|
@@ -172,14 +338,19 @@ export async function openMemory(o) {
|
|
|
172
338
|
await flush();
|
|
173
339
|
return total;
|
|
174
340
|
},
|
|
175
|
-
|
|
341
|
+
remove: (ids) => exclusive(async () => {
|
|
176
342
|
const uids = ids.map((x) => recordUid(typeof x === 'string' ? { id: x } : x)).filter((u) => stm.meta(u));
|
|
177
343
|
if (!uids.length)
|
|
178
344
|
return 0;
|
|
179
|
-
|
|
345
|
+
const episodes = uids.map((u) => `ep:${u}`);
|
|
346
|
+
await client.deleteItems([...uids.map(stmItem), ...episodes]);
|
|
180
347
|
stm.remove(uids);
|
|
348
|
+
// A kept episode is the record's own text: gone from the long-term log too, or a rebuild indexes it again and the
|
|
349
|
+
// profiles go on reading it.
|
|
350
|
+
ltm.remove(episodes);
|
|
351
|
+
await Promise.all([stm.flush(), ltm.flush()]);
|
|
181
352
|
return uids.length;
|
|
182
|
-
},
|
|
353
|
+
}),
|
|
183
354
|
search: (s) => memorySearch(deps, s),
|
|
184
355
|
searchText: async (s) => searchText(await memorySearch(deps, s), s.order ?? 'relevance'),
|
|
185
356
|
async room(s) {
|
|
@@ -201,11 +372,11 @@ export async function openMemory(o) {
|
|
|
201
372
|
return sources.length ? { ...p, sources } : p;
|
|
202
373
|
},
|
|
203
374
|
explore: (s) => memoryExplore(deps, s),
|
|
204
|
-
gather: (s) => memoryGather(deps, s, { ask, ...(s.limit ? { limit: s.limit } : {}) }),
|
|
205
|
-
async
|
|
375
|
+
gather: (s, g) => memoryGather(deps, s, { ask, ...(s.limit ? { limit: s.limit } : {}), ...(g?.notes ? { notes: true } : {}) }),
|
|
376
|
+
consolidate: () => exclusive(async () => {
|
|
206
377
|
const worker = new Consolidator({ ...base, concurrency: o.concurrency ?? 4 });
|
|
207
378
|
const total = { windows: 0, added: 0, merged: 0, updated: 0, superseded: 0, linked: 0, dropped: 0, episodes: 0, observed: 0, cued: 0 };
|
|
208
|
-
for (let round = 0; round < 1000 && worker.pending().length; round++) {
|
|
379
|
+
for (let round = 0; round < 1000 && !closing && worker.pending().length; round++) {
|
|
209
380
|
const r = await worker.run(64);
|
|
210
381
|
for (const k of Object.keys(total))
|
|
211
382
|
if (typeof r[k] === 'number')
|
|
@@ -217,45 +388,42 @@ export async function openMemory(o) {
|
|
|
217
388
|
if (!r.windows)
|
|
218
389
|
break;
|
|
219
390
|
}
|
|
220
|
-
await
|
|
391
|
+
await indexWords();
|
|
221
392
|
return total;
|
|
222
|
-
},
|
|
223
|
-
async
|
|
393
|
+
}),
|
|
394
|
+
sleep: () => exclusive(async () => {
|
|
224
395
|
const r = await new Sleep({ ...base, usage, statePath: join(dir, 'sleep.json') }).run();
|
|
225
|
-
await
|
|
396
|
+
await indexWords();
|
|
226
397
|
return r;
|
|
227
|
-
},
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const texts = [...stm.all()].flatMap((x) => wordsOfEntries([x]));
|
|
232
|
-
for (const [uid, m] of ltm.entries()) {
|
|
233
|
-
if (m.status !== 'active')
|
|
234
|
-
continue;
|
|
235
|
-
const it = ltm.get(uid);
|
|
236
|
-
if (!it)
|
|
237
|
-
continue;
|
|
238
|
-
const room = `${it.title}\n${it.summary}\n${it.body}`.slice(0, ROOM_TEXT);
|
|
239
|
-
texts.push(keyText(it.title, it.summary), ...sentenceSpans(room).map(([a, b]) => room.slice(a, b)));
|
|
240
|
-
}
|
|
241
|
-
return encodeMissing(texts);
|
|
242
|
-
},
|
|
243
|
-
async rebuild() {
|
|
398
|
+
}),
|
|
399
|
+
indexWords: () => exclusive(indexWords),
|
|
400
|
+
rebuild: () => exclusive(async () => {
|
|
401
|
+
rebuild.begin(); // before the drop: a crash, or a close, after it resumes at the next open
|
|
244
402
|
await client.dropCollection();
|
|
245
403
|
await client.ensureCollection();
|
|
246
|
-
rebuild.begin();
|
|
247
404
|
await rebuild.run();
|
|
248
|
-
},
|
|
405
|
+
}),
|
|
249
406
|
tools: () => buildAgentTools({
|
|
250
407
|
search: (s) => memorySearch(deps, s),
|
|
251
408
|
open: (id) => memory.open(id),
|
|
252
409
|
explore: (s) => memoryExplore(deps, s),
|
|
253
410
|
}),
|
|
254
411
|
stats: () => ({ records: stm.size(), items: [...ltm.entries()].filter(([, m]) => m.status === 'active').length, words: words.size }),
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
412
|
+
close() {
|
|
413
|
+
closed ??= (async () => {
|
|
414
|
+
closing = true;
|
|
415
|
+
await queue; // the job running stops at its next step; those waiting are refused
|
|
416
|
+
try {
|
|
417
|
+
await book.settled();
|
|
418
|
+
usage.flush();
|
|
419
|
+
await proc?.stopAndWait();
|
|
420
|
+
await local?.close();
|
|
421
|
+
}
|
|
422
|
+
finally {
|
|
423
|
+
release();
|
|
424
|
+
}
|
|
425
|
+
})();
|
|
426
|
+
return closed;
|
|
259
427
|
},
|
|
260
428
|
};
|
|
261
429
|
return memory;
|
package/dist/records.js
CHANGED
|
@@ -70,7 +70,7 @@ export function entryOf(r, now, turn = 0) {
|
|
|
70
70
|
* Turns before the first request (an assistant that opened) are the first record's own.
|
|
71
71
|
*/
|
|
72
72
|
export function conversationEntries(c, now) {
|
|
73
|
-
const
|
|
73
|
+
const began = timeOf(c.at);
|
|
74
74
|
const out = [];
|
|
75
75
|
let request = [];
|
|
76
76
|
let reply = [];
|
|
@@ -79,10 +79,13 @@ export function conversationEntries(c, now) {
|
|
|
79
79
|
if (!request.length && !reply.length)
|
|
80
80
|
return;
|
|
81
81
|
const n = out.length;
|
|
82
|
+
// A time only the caller gave is part of the fingerprint: one defaulted from when it is added would make the same
|
|
83
|
+
// conversation added again a changed one — read, embedded and consolidated again.
|
|
84
|
+
const given = at ?? (began === undefined ? undefined : began + n * 60_000);
|
|
82
85
|
const e = entryOf({ id: `${c.id}#${n}`, source: c.source, collection: c.collection, thread: c.id, kind: 'conversation',
|
|
83
86
|
title: request.join('\n').split('\n').find((l) => l.trim())?.trim() || reply.join('\n').split('\n').find((l) => l.trim())?.trim() || c.id,
|
|
84
|
-
text: request.join('\n'), at:
|
|
85
|
-
out.push({ ...e, request: request.join('\n') || e.title, reply: reply.join('\n'), said: reply.join('\n') });
|
|
87
|
+
text: request.join('\n'), at: given }, now, n);
|
|
88
|
+
out.push({ ...e, ts: given ?? now + n * 60_000, request: request.join('\n') || e.title, reply: reply.join('\n'), said: reply.join('\n') });
|
|
86
89
|
request = [];
|
|
87
90
|
reply = [];
|
|
88
91
|
at = undefined;
|
|
@@ -33,7 +33,6 @@ export interface ToolProvider {
|
|
|
33
33
|
limit?: number;
|
|
34
34
|
}): Promise<Explored>;
|
|
35
35
|
}
|
|
36
|
-
type CrossMemoryHit = RecalledItem;
|
|
37
36
|
/** A date as a reader reckons with it — the weekday too ("the Friday before …"): 2023-07-14 → "Fri 14 Jul 2023". */
|
|
38
37
|
export declare function readableDate(d: string): string;
|
|
39
38
|
/** How long something lasted, as a reader reckons with it: "since Feb 2022", "Nov 2021 – May 2022". */
|
|
@@ -41,12 +40,10 @@ export declare function lastedText(h: {
|
|
|
41
40
|
startAt?: unknown;
|
|
42
41
|
endAt?: unknown;
|
|
43
42
|
}): string;
|
|
44
|
-
/** All of a profile — what memory knows about one subject, one dated fact a line (core/profiles.ts) — up to this:
|
|
45
|
-
* measured, a profile cut like any other item lost most of its gain. */
|
|
46
|
-
export declare const PROFILE_SHOWN = 1500;
|
|
47
43
|
/** What memory_search shows: an observation as one dated line (when it happened, and when it was told when that
|
|
48
44
|
* differs), a profile whole (up to PROFILE_SHOWN), anything else as its title line and summary — shorter when many
|
|
49
45
|
* are asked for. Best first, or by time. */
|
|
50
|
-
export declare function searchText(hits:
|
|
46
|
+
export declare function searchText(hits: RecalledItem[], order?: 'relevance' | 'time'): string;
|
|
47
|
+
/** memory_search, memory_open and memory_explore (when the provider explores) over a memory. */
|
|
51
48
|
export declare function buildAgentTools(p: ToolProvider): AgentTool[];
|
|
52
49
|
export {};
|
|
@@ -6,8 +6,19 @@
|
|
|
6
6
|
* const tools = memory.tools(); // hand them to an MCP server, or to a model's tool calling
|
|
7
7
|
*/
|
|
8
8
|
import { z } from 'zod';
|
|
9
|
+
import { PROFILE_SHOWN } from '../core/profiles.js';
|
|
10
|
+
import { cut } from '../core/text.js';
|
|
11
|
+
import { dateSpan } from '../core/writer.js';
|
|
9
12
|
function tool(name, description, inputSchema, handler) {
|
|
10
|
-
|
|
13
|
+
const schema = z.object(inputSchema);
|
|
14
|
+
// Checked here too: a caller doing function calling hands the model's arguments straight to the handler.
|
|
15
|
+
const checked = async (args, extra) => {
|
|
16
|
+
const got = schema.safeParse(args ?? {});
|
|
17
|
+
if (!got.success)
|
|
18
|
+
return text(`Invalid arguments: ${got.error.issues.map((i) => `${i.path.join('.') || 'input'}: ${i.message}`).join('; ')}`, true);
|
|
19
|
+
return handler(got.data, extra);
|
|
20
|
+
};
|
|
21
|
+
return { name, description, inputSchema, jsonSchema: z.toJSONSchema(schema), handler: checked };
|
|
11
22
|
}
|
|
12
23
|
const DESCRIPTIONS = {
|
|
13
24
|
memory_search: 'Search memory: everything it was given (e-mails, messages, notes, documents, conversations, commits, …) and '
|
|
@@ -20,12 +31,18 @@ const DESCRIPTIONS = {
|
|
|
20
31
|
+ 'people, places, systems, names — and links between memories, to what a search for the words does not find. Returns '
|
|
21
32
|
+ 'what search found, what is related, and the concepts around it to explore next.',
|
|
22
33
|
};
|
|
34
|
+
/** What a model may send: a question, a topic or an id is a line, not a document; a few dozen collections at most. */
|
|
35
|
+
const TEXT_MAX = 2000;
|
|
36
|
+
const COLLECTIONS_MAX = 50;
|
|
23
37
|
const day = (ts) => (ts ? new Date(ts).toISOString().slice(0, 10) : '');
|
|
24
38
|
const hitLine = (h, n) => `${n + 1}. ${h.title} (${h.kind === 'episode' ? 'earlier work' : h.kind} · ${day(h.ts)}${h.record?.author ? ` · ${h.record.author}` : ''} · id ${h.id})`;
|
|
25
39
|
const WEEKDAY = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
26
40
|
const MONTH = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
27
41
|
/** A date as a reader reckons with it — the weekday too ("the Friday before …"): 2023-07-14 → "Fri 14 Jul 2023". */
|
|
28
42
|
export function readableDate(d) {
|
|
43
|
+
// Not a date (2023-13, 2023-02-30): shown as written rather than as "undefined 2023" or a day that does not exist.
|
|
44
|
+
if (!dateSpan(d))
|
|
45
|
+
return d;
|
|
29
46
|
const [y, m, dd] = d.split('-').map(Number);
|
|
30
47
|
if (!y)
|
|
31
48
|
return '';
|
|
@@ -44,10 +61,6 @@ export function lastedText(h) {
|
|
|
44
61
|
}
|
|
45
62
|
/** When a hit happened, for ordering by time: an observation's own date, else when it was learned. */
|
|
46
63
|
const when = (h) => h.eventAt || day(h.ts);
|
|
47
|
-
/** All of a profile — what memory knows about one subject, one dated fact a line (core/profiles.ts) — up to this:
|
|
48
|
-
* measured, a profile cut like any other item lost most of its gain. */
|
|
49
|
-
export const PROFILE_SHOWN = 1500;
|
|
50
|
-
const cutAt = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
51
64
|
/** What memory_search shows: an observation as one dated line (when it happened, and when it was told when that
|
|
52
65
|
* differs), a profile whole (up to PROFILE_SHOWN), anything else as its title line and summary — shorter when many
|
|
53
66
|
* are asked for. Best first, or by time. */
|
|
@@ -57,7 +70,7 @@ export function searchText(hits, order = 'relevance') {
|
|
|
57
70
|
const compact = list.length > 20;
|
|
58
71
|
const lines = list.map((h, n) => {
|
|
59
72
|
if (h.kind === 'profile')
|
|
60
|
-
return `${hitLine(h, n)}\n ${
|
|
73
|
+
return `${hitLine(h, n)}\n ${cut(h.summary, PROFILE_SHOWN).replace(/\n/g, '\n ')}`;
|
|
61
74
|
if (h.kind !== 'observation') {
|
|
62
75
|
// The passage that matched, when it was not the start: a fact said deep in a long request is what was found.
|
|
63
76
|
const matched = h.match ? ` … ${h.match.replace(/\s+/g, ' ')}` : '';
|
|
@@ -74,11 +87,12 @@ export function searchText(hits, order = 'relevance') {
|
|
|
74
87
|
});
|
|
75
88
|
return `${order === 'time' ? 'Oldest first — the date is when it happened (and when it was told, when that differs).\n' : ''}${lines.join('\n')}`;
|
|
76
89
|
}
|
|
77
|
-
|
|
90
|
+
/** memory_search, memory_open and memory_explore (when the provider explores) over a memory. */
|
|
91
|
+
export function buildAgentTools(p) {
|
|
78
92
|
return [
|
|
79
93
|
tool('memory_search', DESCRIPTIONS.memory_search, {
|
|
80
|
-
query: z.string().describe('What you need to know, in plain words.'),
|
|
81
|
-
collections: z.array(z.string()).optional().describe('Only these collections (default: all).'),
|
|
94
|
+
query: z.string().max(TEXT_MAX).describe('What you need to know, in plain words.'),
|
|
95
|
+
collections: z.array(z.string().max(TEXT_MAX)).max(COLLECTIONS_MAX).optional().describe('Only these collections (default: all).'),
|
|
82
96
|
limit: z.number().int().min(1).max(200).optional().describe('How many of each to return — dated observations, and other memories (default 20; 100 to 200 when you gather everything on a topic, list or count things, or need their dates).'),
|
|
83
97
|
order: z.enum(['relevance', 'time']).optional().describe('Best first (default), or oldest first — for what happened when, and in what order.'),
|
|
84
98
|
from: z.string().regex(/^\d{4}(-\d{2}(-\d{2})?)?$/).optional().describe('Only what happened on or after this date (YYYY-MM-DD, YYYY-MM or YYYY) — when the request names a time ("in June 2023", "last week").'),
|
|
@@ -94,25 +108,30 @@ function memoryTools(p) {
|
|
|
94
108
|
return text(err.message, true);
|
|
95
109
|
}
|
|
96
110
|
}),
|
|
97
|
-
tool('memory_open', DESCRIPTIONS.memory_open, { id: z.string().describe('The memory id, as memory_search or memory_explore gave it.') }, async (args) => {
|
|
98
|
-
|
|
111
|
+
tool('memory_open', DESCRIPTIONS.memory_open, { id: z.string().max(TEXT_MAX).describe('The memory id, as memory_search or memory_explore gave it.') }, async (args) => {
|
|
112
|
+
// An index that failed is not an id that is missing: the agent should not conclude the memory is gone.
|
|
113
|
+
let it;
|
|
114
|
+
try {
|
|
115
|
+
it = await p.open(args.id);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
return text(`Memory could not be read: ${err.message}`, true);
|
|
119
|
+
}
|
|
99
120
|
if (!it)
|
|
100
121
|
return text(`There is no memory "${args.id}" (it may have been merged or cleared).`, true);
|
|
101
122
|
const files = Array.isArray(it.files) && it.files.length ? `\nFiles: ${it.files.join(', ')}` : '';
|
|
102
123
|
const told = day(Number(it.ts ?? 0));
|
|
103
124
|
const happened = [typeof it.eventAt === 'string' && it.eventAt ? readableDate(it.eventAt) : '', lastedText(it)].filter(Boolean).join(', ');
|
|
104
125
|
const when = happened ? `happened ${happened}; told ${readableDate(told)}` : told;
|
|
105
|
-
// The raw requests it was learned from, whole (the truth it is a key to)
|
|
126
|
+
// The raw requests it was learned from, whole (the truth it is a key to).
|
|
106
127
|
const sources = Array.isArray(it.sources) && it.sources.length
|
|
107
128
|
? `\n\n## Where it came from\n${it.sources.map((x) => `### ${day(x.when)} · ${x.title} (id ${x.id})\n${x.body}`).join('\n\n')}` : '';
|
|
108
|
-
|
|
109
|
-
? `\n\n## Texts merged into it\n${it.absorbed.map((x) => `### ${x.title}\n${x.summary}\n${x.body}`).join('\n\n')}` : '';
|
|
110
|
-
return text(`# ${String(it.title ?? '')}\n(${String(it.kind ?? '')} · ${when} · ${String(it.project ?? '')})\n\n${String(it.summary ?? '')}\n\n${String(it.body ?? '')}${files}${sources}${absorbed}`);
|
|
129
|
+
return text(`# ${String(it.title ?? '')}\n(${String(it.kind ?? '')} · ${when} · ${String(it.project ?? '')})\n\n${String(it.summary ?? '')}\n\n${String(it.body ?? '')}${files}${sources}`);
|
|
111
130
|
}),
|
|
112
131
|
...(p.explore ? [tool('memory_explore', DESCRIPTIONS.memory_explore, {
|
|
113
|
-
topic: z.string().optional().describe('What to explore, in plain words — a system, a project, a problem, a name.'),
|
|
114
|
-
from: z.array(z.string()).max(5).optional().describe('Memory ids to explore from, as memory_search or memory_explore gave them.'),
|
|
115
|
-
collections: z.array(z.string()).optional().describe('Only these collections (default: all).'),
|
|
132
|
+
topic: z.string().max(TEXT_MAX).optional().describe('What to explore, in plain words — a system, a project, a problem, a name.'),
|
|
133
|
+
from: z.array(z.string().max(TEXT_MAX)).max(5).optional().describe('Memory ids to explore from, as memory_search or memory_explore gave them.'),
|
|
134
|
+
collections: z.array(z.string().max(TEXT_MAX)).max(COLLECTIONS_MAX).optional().describe('Only these collections (default: all).'),
|
|
116
135
|
limit: z.number().int().min(1).max(20).optional().describe('How many related items to return (default 10).'),
|
|
117
136
|
}, async (args) => {
|
|
118
137
|
if (!args.topic?.trim() && !args.from?.length)
|
|
@@ -137,6 +156,3 @@ function memoryTools(p) {
|
|
|
137
156
|
];
|
|
138
157
|
}
|
|
139
158
|
const text = (t, isError = false) => ({ content: [{ type: 'text', text: t }], ...(isError ? { isError: true } : {}) });
|
|
140
|
-
export function buildAgentTools(p) {
|
|
141
|
-
return memoryTools(p);
|
|
142
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "memtrails",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Retrieval and exploration for any text — e-mail, chat, notes, documents, news, commits, conversations. Searchable at once, on your own machine, in any language; optional consolidation into typed knowledge with a language model.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"retrieval",
|