memtrails 0.3.0 → 0.4.1

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 (53) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +25 -9
  3. package/dist/core/consolidate.d.ts +24 -9
  4. package/dist/core/consolidate.js +101 -49
  5. package/dist/core/episodes.d.ts +1 -1
  6. package/dist/core/episodes.js +2 -8
  7. package/dist/core/extract.d.ts +3 -0
  8. package/dist/core/extract.js +12 -5
  9. package/dist/core/gather.d.ts +3 -0
  10. package/dist/core/gather.js +16 -10
  11. package/dist/core/model.d.ts +9 -4
  12. package/dist/core/model.js +39 -40
  13. package/dist/core/profiles.d.ts +1 -1
  14. package/dist/core/profiles.js +12 -9
  15. package/dist/core/qdrant-binary.d.ts +52 -5
  16. package/dist/core/qdrant-binary.js +197 -48
  17. package/dist/core/qdrant-client.d.ts +6 -21
  18. package/dist/core/qdrant-client.js +36 -100
  19. package/dist/core/qdrant-process.d.ts +30 -2
  20. package/dist/core/qdrant-process.js +217 -27
  21. package/dist/core/rebuild.d.ts +5 -1
  22. package/dist/core/rebuild.js +23 -5
  23. package/dist/core/recall-log.d.ts +3 -0
  24. package/dist/core/recall-log.js +6 -1
  25. package/dist/core/recall.js +19 -8
  26. package/dist/core/redact.d.ts +8 -0
  27. package/dist/core/redact.js +48 -9
  28. package/dist/core/sleep.d.ts +4 -3
  29. package/dist/core/sleep.js +27 -16
  30. package/dist/core/store.d.ts +7 -1
  31. package/dist/core/store.js +101 -33
  32. package/dist/core/text.d.ts +6 -2
  33. package/dist/core/text.js +19 -6
  34. package/dist/core/words.js +6 -2
  35. package/dist/core/writer.d.ts +2 -1
  36. package/dist/core/writer.js +9 -3
  37. package/dist/importers/files.js +96 -15
  38. package/dist/importers/git.js +3 -1
  39. package/dist/importers/jsonl.d.ts +6 -0
  40. package/dist/importers/jsonl.js +10 -5
  41. package/dist/importers/lines.d.ts +13 -0
  42. package/dist/importers/lines.js +70 -0
  43. package/dist/importers/mbox.js +32 -12
  44. package/dist/importers/slack.js +5 -2
  45. package/dist/importers/text.js +85 -14
  46. package/dist/llm.d.ts +10 -8
  47. package/dist/llm.js +53 -7
  48. package/dist/memory.d.ts +7 -2
  49. package/dist/memory.js +284 -57
  50. package/dist/records.js +6 -3
  51. package/dist/tools/agent-tools.d.ts +2 -5
  52. package/dist/tools/agent-tools.js +37 -21
  53. package/package.json +1 -1
package/dist/llm.js CHANGED
@@ -5,34 +5,77 @@
5
5
  * `openAiAsk` speaks the OpenAI Responses API, so any provider or local server that serves it works (`baseUrl`). Bring
6
6
  * any other model by passing your own `Ask`. Every call's tokens can be counted (`onUsage`).
7
7
  */
8
+ import { redactString } from './core/redact.js';
9
+ const OPENAI = 'https://api.openai.com/v1';
8
10
  const why = (reason, req) => `${reason}${req.consequence ? `, ${req.consequence}` : ''}`;
11
+ /** A wait that an abort ends early. */
12
+ const pause = (ms, signal) => new Promise((ok) => {
13
+ if (signal?.aborted)
14
+ return ok();
15
+ const done = () => { clearTimeout(t); signal?.removeEventListener('abort', done); ok(); };
16
+ const t = setTimeout(done, ms);
17
+ signal?.addEventListener('abort', done, { once: true });
18
+ });
19
+ /**
20
+ * `Promise.all` for asks side by side, each handed a signal: the first that throws aborts the others — an ask that
21
+ * honours `signal` stops, and none is paid for an answer no one reads — and they have all settled when it is thrown.
22
+ * `outer` (the caller's) aborts them too.
23
+ */
24
+ export async function allOrAbort(jobs, outer) {
25
+ const c = new AbortController();
26
+ const follow = () => c.abort(outer?.reason);
27
+ if (outer?.aborted)
28
+ follow();
29
+ else
30
+ outer?.addEventListener('abort', follow, { once: true });
31
+ // Each started in order, at once, as Promise.all over async functions would.
32
+ const runs = jobs.map(async (job) => job(c.signal));
33
+ try {
34
+ return await Promise.all(runs);
35
+ }
36
+ catch (err) {
37
+ c.abort(err);
38
+ await Promise.allSettled(runs);
39
+ throw err;
40
+ }
41
+ finally {
42
+ outer?.removeEventListener('abort', follow);
43
+ }
44
+ }
9
45
  export function openAiAsk(o = {}) {
10
46
  return async (req) => {
11
47
  const model = req.model ?? o.model ?? process.env.MEMTRAILS_MODEL?.trim();
12
- const key = o.apiKey ?? (process.env.MEMTRAILS_LLM_API_KEY?.trim() || process.env.OPENAI_API_KEY?.trim());
48
+ const base = (o.baseUrl ?? (process.env.MEMTRAILS_LLM_BASE_URL?.trim() || OPENAI)).replace(/\/+$/, '');
49
+ // An OpenAI key goes to OpenAI only: with another address set, it would hand the key to whoever serves that.
50
+ const key = o.apiKey ?? (process.env.MEMTRAILS_LLM_API_KEY?.trim() || (base === OPENAI ? process.env.OPENAI_API_KEY?.trim() : undefined));
13
51
  if (!model)
14
52
  return { unavailable: why('no model is set (the model option, or MEMTRAILS_MODEL)', req) };
15
53
  if (!key)
16
- return { unavailable: why('no API key is set (the apiKey option, MEMTRAILS_LLM_API_KEY or OPENAI_API_KEY)', req) };
17
- const base = (o.baseUrl ?? (process.env.MEMTRAILS_LLM_BASE_URL?.trim() || 'https://api.openai.com/v1')).replace(/\/+$/, '');
54
+ return { unavailable: why(`no API key is set (the apiKey option or MEMTRAILS_LLM_API_KEY${base === OPENAI ? ', or OPENAI_API_KEY' : ''})`, req) };
18
55
  const body = JSON.stringify({
19
56
  model, instructions: req.system, input: req.prompt, max_output_tokens: req.maxTokens ?? 12_000,
20
57
  ...(o.reasoningEffort ? { reasoning: { effort: o.reasoningEffort } } : {}),
21
58
  text: { format: { type: 'json_schema', name: req.name ?? 'memory', strict: true, schema: req.schema } },
22
59
  });
23
60
  const tries = Math.max(1, (o.retries ?? 5) + 1);
61
+ const stopped = { unavailable: why('the ask was stopped', req) };
24
62
  let last = '';
25
63
  for (let attempt = 0; attempt < tries; attempt++) {
26
64
  if (attempt)
27
- await new Promise((ok) => setTimeout(ok, Math.min(60_000, 1000 * 2 ** (attempt - 1)) * (0.5 + Math.random())));
65
+ await pause(Math.min(60_000, 1000 * 2 ** (attempt - 1)) * (0.5 + Math.random()), req.signal);
66
+ if (req.signal?.aborted)
67
+ return stopped;
68
+ const timeout = AbortSignal.timeout(req.timeoutMs ?? 180_000);
28
69
  let res;
29
70
  try {
30
71
  res = await (o.fetchImpl ?? fetch)(`${base}/responses`, {
31
- method: 'POST', body, signal: AbortSignal.timeout(req.timeoutMs ?? 180_000),
72
+ method: 'POST', body, signal: req.signal ? AbortSignal.any([req.signal, timeout]) : timeout,
32
73
  headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
33
74
  });
34
75
  }
35
76
  catch (err) {
77
+ if (req.signal?.aborted)
78
+ return stopped;
36
79
  last = err.message || err.name;
37
80
  continue;
38
81
  }
@@ -40,8 +83,11 @@ export function openAiAsk(o = {}) {
40
83
  last = `HTTP ${res.status}`;
41
84
  continue;
42
85
  }
43
- if (!res.ok)
44
- return { unavailable: why(`the model answered HTTP ${res.status}`, req) };
86
+ if (!res.ok) {
87
+ // What the provider said (a wrong model name, a schema it refuses) — not only its status.
88
+ const said = redactString((await res.text().catch(() => '')).replace(/\s+/g, ' ').trim()).slice(0, 300);
89
+ return { unavailable: why(`the model answered HTTP ${res.status}${said ? `: ${said}` : ''}`, req) };
90
+ }
45
91
  try {
46
92
  const r = (await res.json());
47
93
  o.onUsage?.({ model: r.model ?? model, name: req.name, inputTokens: r.usage?.input_tokens ?? 0,
package/dist/memory.d.ts CHANGED
@@ -14,14 +14,16 @@ export interface MemoryOptions {
14
14
  * - default: on this machine, the model fetched on first use into the cache (218 MB);
15
15
  * - `{ modelDir }`: on this machine, from a directory you provide;
16
16
  * - `{ server }`: a server that serves it over the OpenAI embeddings API (text-embeddings-inference, Ollama, …).
17
- * Word by word then needs `{ modelDir }` too, or it is off. */
17
+ * Word by word then needs `{ modelDir }` too, or it is off.
18
+ * With neither `server` nor `modelDir`, MEMTRAILS_EMBED_URL (+ _KEY, _MODEL) names a server when it is set. */
18
19
  embedding?: {
19
20
  modelDir?: string;
20
21
  server?: EmbedRoute;
21
22
  threads?: number;
22
23
  };
23
24
  /** How the best 30 of a search are reordered: `'words'` (default with a local model: word by word, no server), a
24
- * cross-encoder server (`RerankRoute`), your own function, or `false` for the search's own order. */
25
+ * cross-encoder server (`RerankRoute`), your own function, or `false` for the search's own order. Not given:
26
+ * MEMTRAILS_RERANK_URL (+ _KEY, _MODEL) names a server when it is set. */
25
27
  rerank?: 'words' | RerankRoute | RecallDeps['rerank'] | false;
26
28
  /** The language model for consolidate, sleep and gather: your own `Ask`, or options for the OpenAI-compatible one.
27
29
  * Default: the one the environment names (MEMTRAILS_MODEL and a key). */
@@ -103,6 +105,9 @@ export interface Memory {
103
105
  items: number;
104
106
  words: number;
105
107
  };
108
+ /** Stops a consolidate, sleep, rebuild or indexWords at its next step (a rebuild resumes at the next open) and waits
109
+ * for it and for the adds and searches in flight, then releases Qdrant, the model and the home. Every call after it
110
+ * is refused ("memory is closed"). */
106
111
  close(): Promise<void>;
107
112
  }
108
113
  export declare function openMemory(o: MemoryOptions): Promise<Memory>;
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,171 @@ 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
+ }
139
+ /** Turns shared, and a turn alone: many share at once; one alone waits until no one shares, and keeps new sharers out
140
+ * while it waits, so a stream of them never holds it off. */
141
+ function turns() {
142
+ let sharing = 0;
143
+ let alone = false;
144
+ let waiting = 0;
145
+ let wakers = [];
146
+ const wake = () => { const w = wakers; wakers = []; for (const f of w)
147
+ f(); };
148
+ const until = async (ok) => { while (!ok())
149
+ await new Promise((r) => wakers.push(r)); };
150
+ return {
151
+ async shared(job) {
152
+ await until(() => !alone && !waiting);
153
+ sharing++;
154
+ try {
155
+ return await job();
156
+ }
157
+ finally {
158
+ sharing--;
159
+ wake();
160
+ }
161
+ },
162
+ async alone(job) {
163
+ waiting++;
164
+ await until(() => !alone && !sharing);
165
+ waiting--;
166
+ alone = true;
167
+ try {
168
+ return await job();
169
+ }
170
+ finally {
171
+ alone = false;
172
+ wake();
173
+ }
174
+ },
175
+ };
176
+ }
44
177
  export async function openMemory(o) {
45
178
  const dir = resolve(o.home);
46
- mkdirSync(dir, { recursive: true });
47
179
  const log = o.log ?? (() => { });
48
- // Embedding: here, or on a server.
180
+ // Private: the logs, the index and the word vectors are everything that was added (a shared machine).
181
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
182
+ try {
183
+ chmodSync(dir, 0o700);
184
+ }
185
+ catch (err) {
186
+ log(`[memtrails] ${dir} could not be made private: ${err.message}`);
187
+ }
188
+ const release = lockHome(dir);
189
+ const started = { proc: null, local: null };
190
+ try {
191
+ return await openIn(dir, o, log, started, release);
192
+ }
193
+ catch (err) {
194
+ await started.proc?.stopAndWait();
195
+ await started.local?.close().catch(() => undefined);
196
+ release();
197
+ throw err;
198
+ }
199
+ }
200
+ async function openIn(dir, o, log, started, release) {
201
+ // Embedding: here, or on a server — the options' own, else the one the environment names.
49
202
  const e = o.embedding ?? {};
50
- const local = !e.server || e.modelDir
203
+ const server = e.server ?? (e.modelDir ? null : resolveEmbedRoute());
204
+ const local = !server || e.modelDir
51
205
  ? await loadLocalEmbedder(e.modelDir ?? await ensureModel({ log }), { threads: e.threads ?? 2 })
52
206
  : null;
53
- const embed = e.server ? remoteEmbedder({ route: () => e.server }) : local.embed;
207
+ started.local = local;
208
+ const embed = server ? remoteEmbedder({ route: () => server }) : local.embed;
54
209
  const encode = local?.words ?? null;
55
210
  const words = new WordStore(join(dir, 'words.bin'));
56
211
  await words.load();
@@ -64,6 +219,7 @@ export async function openMemory(o) {
64
219
  }
65
220
  else {
66
221
  proc = new QdrantProcess({ binary: q && 'binary' in q ? q.binary : await ensureQdrant({ log }), dir: join(dir, 'qdrant'), log });
222
+ started.proc = proc;
67
223
  await proc.start();
68
224
  endpoint = () => proc.current();
69
225
  }
@@ -75,24 +231,70 @@ export async function openMemory(o) {
75
231
  await ltm.load();
76
232
  const usage = new UsageBook(join(dir, 'usage.json'));
77
233
  const book = new RecallBook(join(dir, 'recall.jsonl'));
234
+ void book.trim(); // the documented cap: once an open, and every so many events while open (recall-log.ts)
78
235
  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 };
236
+ /** Set by close(): what runs stops at its next step. */
237
+ let closing = false;
238
+ const aborted = () => closing;
239
+ const base = { client, embed, stm, ltm, log, aborted, ask };
240
+ const rerankEnv = o.rerank === undefined ? resolveRerankRoute() : null;
80
241
  const rerank = o.rerank === false ? undefined
81
242
  : typeof o.rerank === 'function' ? o.rerank
82
243
  : o.rerank && typeof o.rerank === 'object' ? remoteReranker({ route: () => o.rerank })
83
- : encode ? wordReranker(encode, words) : undefined;
244
+ : rerankEnv ? remoteReranker({ route: () => rerankEnv })
245
+ : encode ? wordReranker(encode, words) : undefined;
84
246
  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.
247
+ // The index is derived: written again from the logs when it is empty while they are not (a new Qdrant, a deleted
248
+ // folder), or was written in another layout — or in one not written down (an index from before the layouts were).
86
249
  const rebuild = new MemoryRebuild({
87
- path: join(dir, 'rebuild.json'), stm, ltm, aborted: () => false,
250
+ path: join(dir, 'rebuild.json'), stm, ltm, aborted,
88
251
  writeStm: (xs) => writeStm(base, xs), writeLtm: (xs) => writeLtm(base, xs),
89
252
  });
90
- if (rebuild.pending() || (!(await client.count()) && (stm.size() || ltm.size()))) {
253
+ const indexed = await client.count();
254
+ const layout = indexLayout(dir);
255
+ if (layout !== INDEX_LAYOUT) {
256
+ if (indexed) {
257
+ log(`[memtrails] the index was written in layout ${layout ?? 'unknown'}, not ${INDEX_LAYOUT}: writing it again from the logs`);
258
+ rebuild.begin(); // before the drop: a crash after it resumes
259
+ await client.dropCollection();
260
+ await client.ensureCollection();
261
+ }
262
+ saveIndexLayout(dir);
263
+ }
264
+ if (rebuild.pending() || (!indexed && (stm.size() || ltm.size()))) {
91
265
  log('[memtrails] writing the index from the logs');
92
266
  if (!rebuild.pending())
93
267
  rebuild.begin();
94
268
  await rebuild.run();
95
269
  }
270
+ // consolidate, sleep, rebuild, indexWords and remove one at a time: each reads the logs and writes the index from
271
+ // them — two consolidations pay the model twice and write stale copies over each other, a rebuild clears what
272
+ // another is writing, a remove during a consolidation would see its record's episode written back after it.
273
+ let queue = Promise.resolve();
274
+ const exclusive = (job) => {
275
+ const run = queue.then(() => {
276
+ if (closing)
277
+ throw new Error('memory is closed');
278
+ return job();
279
+ });
280
+ queue = run.catch(() => undefined);
281
+ return run;
282
+ };
283
+ let closed = null;
284
+ /** Every call (add, search, open, …) shares a turn; close() takes it alone, so it lets go of Qdrant, the model and the
285
+ * home only once the calls in flight are done. */
286
+ const calls = turns();
287
+ /** Adds share a turn; a rebuild takes it alone. An add beside a rebuild could write its points into the index about to
288
+ * be dropped and log its record after the rebuild read the log: logged, never indexed, and skipped as unchanged when
289
+ * added again. */
290
+ const adding = turns();
291
+ const check = () => { if (closing)
292
+ throw new Error('memory is closed'); };
293
+ /** A call's shared turn; refused once close() began, also when it waited for its turn meanwhile. */
294
+ const call = async (job) => {
295
+ check();
296
+ return calls.shared(async () => { check(); return job(); });
297
+ };
96
298
  /** The text word by word scores an item by: its key, as search returns it. */
97
299
  const keyText = (title, summary) => `${title}\n${summary}`;
98
300
  /** A found item's whole text for the room: a record's own, or what consolidation wrote. */
@@ -110,7 +312,7 @@ export async function openMemory(o) {
110
312
  if (!encode)
111
313
  return 0;
112
314
  const todo = [...new Set(texts)].filter((t) => t.trim() && !words.get(t));
113
- for (let i = 0; i < todo.length; i += BATCH) {
315
+ for (let i = 0; i < todo.length && !closing; i += BATCH) {
114
316
  const part = todo.slice(i, i + BATCH);
115
317
  const encoded = await encode(part, 'document');
116
318
  await words.put(part.map((t, n) => [t, packWords(encoded[n])]));
@@ -121,7 +323,8 @@ export async function openMemory(o) {
121
323
  const room = `${x.request}\n${x.reply}`.slice(0, ROOM_TEXT);
122
324
  return [keyText(stmTitle(x), rawKey(x)), ...sentenceSpans(room).map(([a, b]) => room.slice(a, b))];
123
325
  });
124
- const write = async (entries, total) => {
326
+ const write = (entries, total) => call(() => adding.shared(async () => {
327
+ check(); // it may have waited for a rebuild that a close stopped
125
328
  const fresh = entries.filter((x) => stm.meta(x.uid)?.fp !== x.fingerprint);
126
329
  for (let i = 0; i < fresh.length; i += BATCH) {
127
330
  const part = fresh.slice(i, i + BATCH);
@@ -131,6 +334,22 @@ export async function openMemory(o) {
131
334
  }
132
335
  stm.compact();
133
336
  return { added: fresh.length, unchanged: total - fresh.length };
337
+ }));
338
+ /** Unlocked: consolidate and sleep run it inside their own turn. */
339
+ const indexWords = async () => {
340
+ if (!encode)
341
+ return 0;
342
+ const texts = [...stm.all()].flatMap((x) => wordsOfEntries([x]));
343
+ for (const [uid, m] of ltm.entries()) {
344
+ if (m.status !== 'active')
345
+ continue;
346
+ const it = ltm.get(uid);
347
+ if (!it)
348
+ continue;
349
+ const room = `${it.title}\n${it.summary}\n${it.body}`.slice(0, ROOM_TEXT);
350
+ texts.push(keyText(it.title, it.summary), ...sentenceSpans(room).map(([a, b]) => room.slice(a, b)));
351
+ }
352
+ return encodeMissing(texts);
134
353
  };
135
354
  const memory = {
136
355
  async add(records) {
@@ -172,24 +391,32 @@ export async function openMemory(o) {
172
391
  await flush();
173
392
  return total;
174
393
  },
175
- async remove(ids) {
394
+ remove: (ids) => exclusive(async () => {
176
395
  const uids = ids.map((x) => recordUid(typeof x === 'string' ? { id: x } : x)).filter((u) => stm.meta(u));
177
396
  if (!uids.length)
178
397
  return 0;
179
- await client.deleteItems(uids.flatMap((u) => [stmItem(u), `ep:${u}`]));
398
+ const episodes = uids.map((u) => `ep:${u}`);
399
+ await client.deleteItems([...uids.map(stmItem), ...episodes]);
180
400
  stm.remove(uids);
401
+ // A kept episode is the record's own text: gone from the long-term log too, or a rebuild indexes it again and the
402
+ // profiles go on reading it.
403
+ ltm.remove(episodes);
404
+ await Promise.all([stm.flush(), ltm.flush()]);
181
405
  return uids.length;
182
- },
183
- search: (s) => memorySearch(deps, s),
184
- searchText: async (s) => searchText(await memorySearch(deps, s), s.order ?? 'relevance'),
406
+ }),
407
+ search: (s) => call(() => memorySearch(deps, s)),
408
+ searchText: (s) => call(async () => searchText(await memorySearch(deps, s), s.order ?? 'relevance')),
185
409
  async room(s) {
410
+ check();
186
411
  if (!encode)
187
412
  throw new Error('the room needs the local embedding model (embedding.modelDir, or no embedding.server)');
188
- const found = await memorySearch(deps, { ...s, limit: s.limit ?? 20 });
189
- return sentenceRoom(s.query, found.map(roomItem), encode, words, s.chars ?? ROOM_CHARS);
413
+ return call(async () => {
414
+ const found = await memorySearch(deps, { ...s, limit: s.limit ?? 20 });
415
+ return sentenceRoom(s.query, found.map(roomItem), encode, words, s.chars ?? ROOM_CHARS);
416
+ });
190
417
  },
191
418
  async open(id) {
192
- const p = await memoryOpen(deps, id);
419
+ const p = await call(() => memoryOpen(deps, id));
193
420
  if (!p)
194
421
  return null;
195
422
  // A record: its kind as given. Something learned: the records it was learned from, whole.
@@ -200,12 +427,12 @@ export async function openMemory(o) {
200
427
  .map((x) => ({ id: stmItem(x.uid), when: x.ts, title: x.title, body: episodeBody(x), ...(x.record ? { record: x.record } : {}) }));
201
428
  return sources.length ? { ...p, sources } : p;
202
429
  },
203
- explore: (s) => memoryExplore(deps, s),
204
- gather: (s, g) => memoryGather(deps, s, { ask, ...(s.limit ? { limit: s.limit } : {}), ...(g?.notes ? { notes: true } : {}) }),
205
- async consolidate() {
430
+ explore: (s) => call(() => memoryExplore(deps, s)),
431
+ gather: (s, g) => call(() => memoryGather(deps, s, { ask, ...(s.limit ? { limit: s.limit } : {}), ...(g?.notes ? { notes: true } : {}) })),
432
+ consolidate: () => exclusive(async () => {
206
433
  const worker = new Consolidator({ ...base, concurrency: o.concurrency ?? 4 });
207
434
  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++) {
435
+ for (let round = 0; round < 1000 && !closing && worker.pending().length; round++) {
209
436
  const r = await worker.run(64);
210
437
  for (const k of Object.keys(total))
211
438
  if (typeof r[k] === 'number')
@@ -217,45 +444,45 @@ export async function openMemory(o) {
217
444
  if (!r.windows)
218
445
  break;
219
446
  }
220
- await memory.indexWords();
447
+ await indexWords();
221
448
  return total;
222
- },
223
- async sleep() {
449
+ }),
450
+ sleep: () => exclusive(async () => {
224
451
  const r = await new Sleep({ ...base, usage, statePath: join(dir, 'sleep.json') }).run();
225
- await memory.indexWords();
452
+ await indexWords();
226
453
  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() {
454
+ }),
455
+ indexWords: () => exclusive(indexWords),
456
+ rebuild: () => exclusive(() => adding.alone(async () => {
457
+ rebuild.begin(); // before the drop: a crash, or a close, after it resumes at the next open
244
458
  await client.dropCollection();
245
459
  await client.ensureCollection();
246
- rebuild.begin();
247
460
  await rebuild.run();
248
- },
461
+ })),
249
462
  tools: () => buildAgentTools({
250
- search: (s) => memorySearch(deps, s),
463
+ search: (s) => memory.search(s),
251
464
  open: (id) => memory.open(id),
252
- explore: (s) => memoryExplore(deps, s),
465
+ explore: (s) => memory.explore(s),
253
466
  }),
254
467
  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();
468
+ close() {
469
+ closed ??= (async () => {
470
+ closing = true;
471
+ await queue; // the job running stops at its next step; those waiting are refused
472
+ // The adds and searches in flight end first; those waiting for their turn, and every call after, are refused.
473
+ await calls.alone(async () => {
474
+ try {
475
+ await book.settled();
476
+ usage.flush();
477
+ await proc?.stopAndWait();
478
+ await local?.close();
479
+ }
480
+ finally {
481
+ release();
482
+ }
483
+ });
484
+ })();
485
+ return closed;
259
486
  },
260
487
  };
261
488
  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 {};