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.
- package/CHANGELOG.md +56 -0
- package/README.md +25 -9
- package/dist/core/consolidate.d.ts +24 -9
- package/dist/core/consolidate.js +101 -49
- package/dist/core/episodes.d.ts +1 -1
- package/dist/core/episodes.js +2 -8
- package/dist/core/extract.d.ts +3 -0
- package/dist/core/extract.js +12 -5
- package/dist/core/gather.d.ts +3 -0
- package/dist/core/gather.js +16 -10
- package/dist/core/model.d.ts +9 -4
- package/dist/core/model.js +39 -40
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +12 -9
- package/dist/core/qdrant-binary.d.ts +52 -5
- package/dist/core/qdrant-binary.js +197 -48
- package/dist/core/qdrant-client.d.ts +6 -21
- package/dist/core/qdrant-client.js +36 -100
- package/dist/core/qdrant-process.d.ts +30 -2
- package/dist/core/qdrant-process.js +217 -27
- package/dist/core/rebuild.d.ts +5 -1
- package/dist/core/rebuild.js +23 -5
- package/dist/core/recall-log.d.ts +3 -0
- package/dist/core/recall-log.js +6 -1
- package/dist/core/recall.js +19 -8
- 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 +27 -16
- package/dist/core/store.d.ts +7 -1
- package/dist/core/store.js +101 -33
- package/dist/core/text.d.ts +6 -2
- package/dist/core/text.js +19 -6
- 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 +96 -15
- package/dist/importers/git.js +3 -1
- package/dist/importers/jsonl.d.ts +6 -0
- package/dist/importers/jsonl.js +10 -5
- package/dist/importers/lines.d.ts +13 -0
- package/dist/importers/lines.js +70 -0
- package/dist/importers/mbox.js +32 -12
- package/dist/importers/slack.js +5 -2
- package/dist/importers/text.js +85 -14
- package/dist/llm.d.ts +10 -8
- package/dist/llm.js +53 -7
- package/dist/memory.d.ts +7 -2
- package/dist/memory.js +284 -57
- 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/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
|
|
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(
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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:
|
|
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
|
|
250
|
+
path: join(dir, 'rebuild.json'), stm, ltm, aborted,
|
|
88
251
|
writeStm: (xs) => writeStm(base, xs), writeLtm: (xs) => writeLtm(base, xs),
|
|
89
252
|
});
|
|
90
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
189
|
-
|
|
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
|
|
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
|
|
447
|
+
await indexWords();
|
|
221
448
|
return total;
|
|
222
|
-
},
|
|
223
|
-
async
|
|
449
|
+
}),
|
|
450
|
+
sleep: () => exclusive(async () => {
|
|
224
451
|
const r = await new Sleep({ ...base, usage, statePath: join(dir, 'sleep.json') }).run();
|
|
225
|
-
await
|
|
452
|
+
await indexWords();
|
|
226
453
|
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() {
|
|
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) =>
|
|
463
|
+
search: (s) => memory.search(s),
|
|
251
464
|
open: (id) => memory.open(id),
|
|
252
|
-
explore: (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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
|
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 {};
|