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.
Files changed (48) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +20 -13
  3. package/dist/core/consolidate.d.ts +23 -8
  4. package/dist/core/consolidate.js +52 -25
  5. package/dist/core/episodes.d.ts +1 -1
  6. package/dist/core/episodes.js +2 -8
  7. package/dist/core/extract.js +5 -5
  8. package/dist/core/gather.d.ts +7 -0
  9. package/dist/core/gather.js +38 -14
  10. package/dist/core/model.d.ts +8 -4
  11. package/dist/core/model.js +29 -40
  12. package/dist/core/profiles.d.ts +1 -1
  13. package/dist/core/profiles.js +7 -6
  14. package/dist/core/qdrant-binary.d.ts +39 -5
  15. package/dist/core/qdrant-binary.js +185 -48
  16. package/dist/core/qdrant-client.d.ts +6 -18
  17. package/dist/core/qdrant-client.js +38 -90
  18. package/dist/core/qdrant-process.d.ts +22 -1
  19. package/dist/core/qdrant-process.js +190 -22
  20. package/dist/core/rebuild.d.ts +5 -1
  21. package/dist/core/rebuild.js +19 -4
  22. package/dist/core/recall-log.d.ts +3 -0
  23. package/dist/core/recall-log.js +6 -1
  24. package/dist/core/recall.js +14 -5
  25. package/dist/core/redact.d.ts +8 -0
  26. package/dist/core/redact.js +48 -9
  27. package/dist/core/sleep.d.ts +4 -3
  28. package/dist/core/sleep.js +19 -11
  29. package/dist/core/store.d.ts +7 -1
  30. package/dist/core/store.js +101 -33
  31. package/dist/core/text.d.ts +4 -2
  32. package/dist/core/text.js +17 -5
  33. package/dist/core/words.js +6 -2
  34. package/dist/core/writer.d.ts +2 -1
  35. package/dist/core/writer.js +9 -3
  36. package/dist/importers/files.js +53 -10
  37. package/dist/importers/git.js +3 -1
  38. package/dist/importers/mbox.js +22 -7
  39. package/dist/importers/slack.js +5 -2
  40. package/dist/importers/text.js +85 -14
  41. package/dist/llm.d.ts +1 -8
  42. package/dist/llm.js +11 -5
  43. package/dist/memory.d.ts +9 -3
  44. package/dist/memory.js +216 -48
  45. package/dist/records.js +6 -3
  46. package/dist/tools/agent-tools.d.ts +2 -5
  47. package/dist/tools/agent-tools.js +37 -21
  48. 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
- // Embedding: here, or on a server.
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 local = !e.server || e.modelDir
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
- const embed = e.server ? remoteEmbedder({ route: () => e.server }) : local.embed;
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
- const base = { client, embed, stm, ltm, log, aborted: () => false, ask };
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
- : encode ? wordReranker(encode, words) : undefined;
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: empty while the logs are not (a new Qdrant, a deleted folder), it is written again.
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: () => false,
212
+ path: join(dir, 'rebuild.json'), stm, ltm, aborted,
88
213
  writeStm: (xs) => writeStm(base, xs), writeLtm: (xs) => writeLtm(base, xs),
89
214
  });
90
- if (rebuild.pending() || (!(await client.count()) && (stm.size() || ltm.size()))) {
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
- async remove(ids) {
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
- await client.deleteItems(uids.flatMap((u) => [stmItem(u), `ep:${u}`]));
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 consolidate() {
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 memory.indexWords();
391
+ await indexWords();
221
392
  return total;
222
- },
223
- async sleep() {
393
+ }),
394
+ sleep: () => exclusive(async () => {
224
395
  const r = await new Sleep({ ...base, usage, statePath: join(dir, 'sleep.json') }).run();
225
- await memory.indexWords();
396
+ await indexWords();
226
397
  return r;
227
- },
228
- async indexWords() {
229
- if (!encode)
230
- return 0;
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
- async close() {
256
- usage.flush();
257
- await proc?.stopAndWait();
258
- await local?.close();
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 start = timeOf(c.at) ?? now;
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: at ?? start + n * 60_000 }, now, n);
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: CrossMemoryHit[], order?: 'relevance' | 'time'): string;
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
- return { name, description, inputSchema, jsonSchema: z.toJSONSchema(z.object(inputSchema)), handler: handler };
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 ${cutAt(h.summary, PROFILE_SHOWN).replace(/\n/g, '\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
- function memoryTools(p) {
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
- const it = await p.open(args.id).catch(() => null);
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), and the texts a merge folded into it.
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
- const absorbed = Array.isArray(it.absorbed) && it.absorbed.length
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.2.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",