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/core/extract.d.ts
CHANGED
|
@@ -111,6 +111,8 @@ export interface ExtractOptions {
|
|
|
111
111
|
observations?: boolean;
|
|
112
112
|
/** Passages of other conversations related to this part (consolidate.ts): what a description refers to is named. */
|
|
113
113
|
known?: string[];
|
|
114
|
+
/** Aborted when the answer is no longer wanted: passed to each ask, and no further part is asked. */
|
|
115
|
+
signal?: AbortSignal;
|
|
114
116
|
}
|
|
115
117
|
/** Read one window. `unavailable`/`busy` come back as they are, so the worker can pause. */
|
|
116
118
|
export declare function extractWindow(win: ReadableRequest[], meta: {
|
|
@@ -133,6 +135,7 @@ export declare function observeWindow(win: ReadableRequest[], before: ReadableRe
|
|
|
133
135
|
ask?: Ask;
|
|
134
136
|
chars: number;
|
|
135
137
|
known?: string[];
|
|
138
|
+
signal?: AbortSignal;
|
|
136
139
|
}): Promise<{
|
|
137
140
|
observations: Observation[];
|
|
138
141
|
} | {
|
package/dist/core/extract.js
CHANGED
|
@@ -39,15 +39,15 @@ what was decided, done, built, changed, fixed, deployed or found, and when. One
|
|
|
39
39
|
several in one, none left out to keep the list short. Facts said in passing inside another request count; a small or
|
|
40
40
|
one-off event counts; when in doubt, keep it. In a conversation between people, the replies are another person's words:
|
|
41
41
|
what they tell about themselves counts as much. For each:
|
|
42
|
-
- text: 10 to 60 words that stand alone — the event or fact itself, as told ("
|
|
43
|
-
|
|
42
|
+
- text: 10 to 60 words that stand alone — the event or fact itself, as told ("Rosa took her nephews kayaking on Lake
|
|
43
|
+
Bled"), never "On <date>, X said that …" (the date has its own field). Name who and what (names, never "he",
|
|
44
44
|
"she", "it", "they"); keep every proper noun, title, number and quoted phrase exactly. A time said relatively stays
|
|
45
45
|
as said, with the date it means, read off the Calendar line of the day it was said: "last week (the week before 26 June
|
|
46
46
|
2023)", "yesterday (7 May 2023)", "on Friday (12 April 2024)". When the part names something only by description
|
|
47
|
-
("the book you recommended", "my home country", "the kids") and KNOWN FROM OTHER CONVERSATIONS names it, write the name too ("
|
|
48
|
-
"
|
|
47
|
+
("the book you recommended", "my home country", "the kids") and KNOWN FROM OTHER CONVERSATIONS names it, write the name too ("The Overstory",
|
|
48
|
+
"Portugal", "her three children") — never a fact from KNOWN that this part does not tell. What the person or a speaker
|
|
49
49
|
says about their own life is true as told: no commentary on what the conversation did or did not confirm. A plan is
|
|
50
|
-
written as a plan ("
|
|
50
|
+
written as a plan ("Rosa planned to take the kids to the aquarium"). When it replaces something said before, say both
|
|
51
51
|
(now X — before, Y).
|
|
52
52
|
- about: who or what it is about, named as the conversation names them ("the person" for the one whose requests these
|
|
53
53
|
are, unless the conversation names them).
|
|
@@ -336,6 +336,7 @@ export function partsOf(text, max = WINDOW_CHARS) {
|
|
|
336
336
|
return out;
|
|
337
337
|
}
|
|
338
338
|
const outcomeIn = (x) => (OUTCOMES.includes(String(x?.outcome)) ? x.outcome : undefined);
|
|
339
|
+
const STOPPED = { unavailable: 'the ask was stopped, so this part of the conversation waits to be consolidated' };
|
|
339
340
|
/** What the model reads before the part: where it comes from, the calendar of its days, and what other conversations
|
|
340
341
|
* say that it may refer to. */
|
|
341
342
|
function headerOf(win, meta, known) {
|
|
@@ -349,10 +350,13 @@ function headerOf(win, meta, known) {
|
|
|
349
350
|
}
|
|
350
351
|
async function askExtraction(prompt, opts) {
|
|
351
352
|
const withObservations = opts.observations !== false;
|
|
353
|
+
if (opts.signal?.aborted)
|
|
354
|
+
return STOPPED;
|
|
352
355
|
const r = await (opts.ask ?? defaultAsk)({
|
|
353
356
|
purpose: 'memory', system: withObservations ? SYSTEM : SYSTEM_WITHOUT_OBSERVATIONS, prompt: redactString(prompt),
|
|
354
357
|
schema: (withObservations ? SCHEMA : SCHEMA_WITHOUT_OBSERVATIONS), name: 'memory_consolidation',
|
|
355
358
|
maxTokens: EXTRACT_MAX_TOKENS, timeoutMs: 300_000, consequence: 'so this part of the conversation waits to be consolidated',
|
|
359
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
356
360
|
});
|
|
357
361
|
if ('unavailable' in r || 'busy' in r)
|
|
358
362
|
return r;
|
|
@@ -440,9 +444,12 @@ export async function observeWindow(win, before, meta, opts) {
|
|
|
440
444
|
for (const [k, text] of parts.entries()) {
|
|
441
445
|
const earlier = k === 0 ? context : parts[k - 1].slice(-Math.floor(opts.chars / 2));
|
|
442
446
|
const prompt = `${header}\n\n${earlier ? `JUST BEFORE (context only):\n${earlier}\n\n` : ''}TO READ${parts.length > 1 ? ` (part ${k + 1} of ${parts.length})` : ''}:\n${text}`;
|
|
447
|
+
if (opts.signal?.aborted)
|
|
448
|
+
return STOPPED;
|
|
443
449
|
const r = await (opts.ask ?? defaultAsk)({
|
|
444
450
|
purpose: 'memory', system: OBSERVE_SYSTEM, prompt: redactString(prompt), schema: OBSERVE_SCHEMA,
|
|
445
451
|
name: 'memory_observations', maxTokens: 16_000, timeoutMs: 300_000, consequence: 'so this part of the conversation waits to be consolidated',
|
|
452
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
446
453
|
});
|
|
447
454
|
if ('unavailable' in r || 'busy' in r)
|
|
448
455
|
return r;
|
package/dist/core/gather.d.ts
CHANGED
|
@@ -10,6 +10,9 @@ export interface Gathered {
|
|
|
10
10
|
/** With `notes`: what the kept memories say that answers the question, a few short lines (empty otherwise). Show them
|
|
11
11
|
* before the memories, as notes on them: on LoCoMo this took 79.5 to 80.9 (docs/BENCHMARKS.md). */
|
|
12
12
|
notes: string[];
|
|
13
|
+
/** Set when the model did not read some candidates (none configured, unreachable, busy): why. They are all kept, by
|
|
14
|
+
* rank — the result is then a wide search, not a gathered set. */
|
|
15
|
+
paused?: string;
|
|
13
16
|
}
|
|
14
17
|
/** `from`/`to`: the time the question asks about ("on 10 April 2022", "in October 2023"). What happened then is searched
|
|
15
18
|
* for besides everything else and read first — not instead: a question also names dates it only counts from ("as of
|
package/dist/core/gather.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Gathering: a SET of memories for a question, not a top-k.
|
|
3
3
|
*
|
|
4
|
-
* A question that counts, lists, totals or compares ("how many
|
|
4
|
+
* A question that counts, lists, totals or compares ("how many plumbers did we call", "what did I spend on camera gear")
|
|
5
5
|
* needs every instance, and one ranked below the cut is a wrong answer however good the rest are — measured on
|
|
6
6
|
* LongMemEval's multi-session questions: every fact our answers missed was in the store, as a dated observation, and not
|
|
7
7
|
* in the room. Mem0 and Graphiti hand the reader a fixed number by similarity; here the fast model reads them:
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* the model did not answer keeps all of its candidates.
|
|
15
15
|
*/
|
|
16
16
|
import { redactString } from './redact.js';
|
|
17
|
-
import { defaultAsk } from '../llm.js';
|
|
17
|
+
import { allOrAbort, defaultAsk } from '../llm.js';
|
|
18
18
|
import { PROFILE_SHOWN } from './profiles.js';
|
|
19
19
|
import { memorySearch, MAX_SEARCH } from './recall.js';
|
|
20
20
|
import { cut } from './text.js';
|
|
@@ -34,7 +34,7 @@ cancelled, sold, replaced, ended, a new amount) or dates it. When unsure, keep i
|
|
|
34
34
|
wrong, an extra one does not.
|
|
35
35
|
|
|
36
36
|
In "searches", write up to ${MORE} short searches for instances the question could count that none of these memories
|
|
37
|
-
shows — other kinds, other words for the same thing (for "
|
|
37
|
+
shows — other kinds, other words for the same thing (for "camera gear": "lens", "tripod", "memory card") —
|
|
38
38
|
in the language of the question. Leave it empty when nothing is missing. Never translate.`;
|
|
39
39
|
const SCHEMA = {
|
|
40
40
|
type: 'object', additionalProperties: false, required: ['keep', 'searches'],
|
|
@@ -48,7 +48,7 @@ const SCHEMA = {
|
|
|
48
48
|
const NOTES_RULE = `
|
|
49
49
|
|
|
50
50
|
In "notes", write what the memories you kept say that answers the question, in a few short lines: every instance with
|
|
51
|
-
its date as the memories give it ("
|
|
51
|
+
its date as the memories give it ("Concerts Lena went to: a jazz trio at the Blue Room (2024-03-02), the orchestra's Mahler night (2024-04-19)"),
|
|
52
52
|
names, numbers and words as written. Only what they state, as plain statements: never write that something is not said,
|
|
53
53
|
not given, unclear or unknown — leave it out instead, since other memories may hold it — and never a guess. Leave notes
|
|
54
54
|
empty when the memories you kept say nothing about it. In the language of the question.`;
|
|
@@ -70,24 +70,28 @@ async function pick(ask, question, list, notes = false) {
|
|
|
70
70
|
const size = notes ? NOTES_PART : PART;
|
|
71
71
|
for (let i = 0; i < list.length; i += size)
|
|
72
72
|
parts.push(list.slice(i, i + size));
|
|
73
|
-
|
|
73
|
+
// The parts asked at once; one that throws stops the others (allOrAbort).
|
|
74
|
+
const answers = await allOrAbort(parts.map((part) => async (signal) => {
|
|
74
75
|
const prompt = `Question: ${question}\n\nMemories:\n${part.map((h, n) => line(h, n + 1)).join('\n')}`;
|
|
75
76
|
const a = await ask({
|
|
76
77
|
purpose: 'memory', system: notes ? SYSTEM + NOTES_RULE : SYSTEM, prompt: redactString(prompt),
|
|
77
78
|
schema: (notes ? NOTES_SCHEMA : SCHEMA),
|
|
78
79
|
name: 'memory_gather', maxTokens: 40 * part.length + (notes ? 1400 : 600), timeoutMs: 120_000, consequence: 'so these memories are kept unread',
|
|
80
|
+
signal,
|
|
79
81
|
});
|
|
80
82
|
if (!('answer' in a))
|
|
81
|
-
return { keep: part.map((h) => h.id), searches: [], notes: [] };
|
|
83
|
+
return { keep: part.map((h) => h.id), searches: [], notes: [], paused: 'unavailable' in a ? a.unavailable : 'the fast model is busy' };
|
|
82
84
|
const got = a.answer;
|
|
83
85
|
const ids = Array.isArray(got.keep) ? got.keep.filter((x) => typeof x === 'string') : [];
|
|
84
|
-
|
|
86
|
+
// A small model can quote the line it means ("[m3] (2023-06-03) …"), so the label is looked for, not assumed to be all of it.
|
|
87
|
+
const keep = ids.map((x) => part[Number(/\bm(\d+)/.exec(x)?.[1] ?? 0) - 1]?.id).filter((x) => !!x);
|
|
85
88
|
const searches = Array.isArray(got.searches) ? got.searches.filter((x) => typeof x === 'string' && !!x.trim()) : [];
|
|
86
89
|
const said = notes && Array.isArray(got.notes) ? got.notes.filter((x) => typeof x === 'string' && !!x.trim()).map((x) => redactString(x.trim())) : [];
|
|
87
|
-
return { keep, searches, notes: said };
|
|
90
|
+
return { keep, searches, notes: said, paused: undefined };
|
|
88
91
|
}));
|
|
92
|
+
const paused = answers.find((a) => a.paused !== undefined)?.paused;
|
|
89
93
|
return { keep: new Set(answers.flatMap((a) => a.keep)), searches: [...new Set(answers.flatMap((a) => a.searches.map((s) => s.trim())))],
|
|
90
|
-
notes: answers.flatMap((a) => a.notes) };
|
|
94
|
+
notes: answers.flatMap((a) => a.notes), ...(paused !== undefined ? { paused } : {}) };
|
|
91
95
|
}
|
|
92
96
|
/** `from`/`to`: the time the question asks about ("on 10 April 2022", "in October 2023"). What happened then is searched
|
|
93
97
|
* for besides everything else and read first — not instead: a question also names dates it only counts from ("as of
|
|
@@ -114,5 +118,7 @@ export async function memoryGather(d, q, o) {
|
|
|
114
118
|
const all = [...first, ...more];
|
|
115
119
|
const kept = all.filter((h) => one.keep.has(h.id) || two.keep.has(h.id));
|
|
116
120
|
const keptIds = new Set(kept.map((h) => h.id));
|
|
117
|
-
|
|
121
|
+
const paused = one.paused ?? two.paused;
|
|
122
|
+
return { found: [...kept, ...all.filter((h) => !keptIds.has(h.id))], kept: kept.map((h) => h.id), searches, notes: [...one.notes, ...two.notes],
|
|
123
|
+
...(paused !== undefined ? { paused } : {}) };
|
|
118
124
|
}
|
package/dist/core/model.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type DownloadOptions } from './qdrant-binary.js';
|
|
1
2
|
export declare const MODEL_REPO = "onnx-community/embeddinggemma-300m-ONNX";
|
|
2
3
|
export declare const MODEL_REVISION = "5090578d9565bb06545b4552f76e6bc2c93e4a66";
|
|
3
4
|
export declare const MODEL_FILES: ReadonlyArray<{
|
|
@@ -6,11 +7,15 @@ export declare const MODEL_FILES: ReadonlyArray<{
|
|
|
6
7
|
sha256: string;
|
|
7
8
|
}>;
|
|
8
9
|
export declare function defaultModelDir(): string;
|
|
10
|
+
/** Every file in place at its pinned size, the marker written after the last of them was verified, and no one else could
|
|
11
|
+
* have changed any of them since (the folders, the files and the marker this user's alone, as the Qdrant binary's). */
|
|
9
12
|
export declare function modelReady(dir?: string): boolean;
|
|
10
|
-
/**
|
|
11
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The model in `dir` (fetched and checked unless it is already there); the directory, ready for `modelDir`. Each file
|
|
15
|
+
* is fetched to a temp of its own, checked on disk and renamed into place; one run at a time per `dir` (withLock), and
|
|
16
|
+
* the marker is taken away first, so a set left half-replaced is never taken as ready.
|
|
17
|
+
*/
|
|
18
|
+
export declare function ensureModel(opts?: DownloadOptions & {
|
|
12
19
|
dir?: string;
|
|
13
20
|
log?: (line: string) => void;
|
|
14
|
-
fetchImpl?: typeof fetch;
|
|
15
|
-
signal?: AbortSignal;
|
|
16
21
|
}): Promise<string>;
|
package/dist/core/model.js
CHANGED
|
@@ -4,12 +4,10 @@
|
|
|
4
4
|
* pinned size and SHA-256. 218 MB. The model is Google's, under the Gemma Terms of Use (ai.google.dev/gemma/terms);
|
|
5
5
|
* memtrails does not ship it.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
8
|
-
import { createWriteStream, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { chmodSync, existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
9
8
|
import { dirname, join } from 'node:path';
|
|
10
|
-
import { Readable } from 'node:stream';
|
|
11
|
-
import { pipeline } from 'node:stream/promises';
|
|
12
9
|
import { cacheDir } from './paths.js';
|
|
10
|
+
import { downloadVerified, ownedSafely, privateDir, withLock } from './qdrant-binary.js';
|
|
13
11
|
export const MODEL_REPO = 'onnx-community/embeddinggemma-300m-ONNX';
|
|
14
12
|
export const MODEL_REVISION = '5090578d9565bb06545b4552f76e6bc2c93e4a66';
|
|
15
13
|
export const MODEL_FILES = [
|
|
@@ -22,53 +20,54 @@ export function defaultModelDir() {
|
|
|
22
20
|
return join(cacheDir(), `embeddinggemma-300m-${MODEL_REVISION.slice(0, 8)}`);
|
|
23
21
|
}
|
|
24
22
|
const marker = (dir) => join(dir, '.verified');
|
|
23
|
+
/** The model's folder and the folders its files are in. */
|
|
24
|
+
const folders = (dir) => [...new Set([dir, ...MODEL_FILES.map((f) => dirname(join(dir, f.rel)))])];
|
|
25
25
|
const stamp = MODEL_FILES.map((f) => `${f.rel}:${f.size}:${f.sha256}`).join('\n');
|
|
26
|
+
/** Every file in place at its pinned size, the marker written after the last of them was verified, and no one else could
|
|
27
|
+
* have changed any of them since (the folders, the files and the marker this user's alone, as the Qdrant binary's). */
|
|
26
28
|
export function modelReady(dir = defaultModelDir()) {
|
|
27
29
|
try {
|
|
28
|
-
return readFileSync(marker(dir), 'utf8') === stamp &&
|
|
30
|
+
return readFileSync(marker(dir), 'utf8') === stamp &&
|
|
31
|
+
MODEL_FILES.every((f) => existsSync(join(dir, f.rel)) && statSync(join(dir, f.rel)).size === f.size) &&
|
|
32
|
+
[...folders(dir), marker(dir), ...MODEL_FILES.map((f) => join(dir, f.rel))].every(ownedSafely);
|
|
29
33
|
}
|
|
30
34
|
catch {
|
|
31
35
|
return false;
|
|
32
36
|
}
|
|
33
37
|
}
|
|
34
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The model in `dir` (fetched and checked unless it is already there); the directory, ready for `modelDir`. Each file
|
|
40
|
+
* is fetched to a temp of its own, checked on disk and renamed into place; one run at a time per `dir` (withLock), and
|
|
41
|
+
* the marker is taken away first, so a set left half-replaced is never taken as ready.
|
|
42
|
+
*/
|
|
35
43
|
export async function ensureModel(opts = {}) {
|
|
36
44
|
const dir = opts.dir ?? defaultModelDir();
|
|
37
45
|
if (modelReady(dir))
|
|
38
46
|
return dir;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
catch (err) {
|
|
61
|
-
rmSync(part, { force: true });
|
|
62
|
-
throw err;
|
|
47
|
+
// Made private, or refused when others could change them: what is verified in them is loaded later. A set already
|
|
48
|
+
// there in folders made before this is ready once they are private (checked again under the lock).
|
|
49
|
+
if (!opts.dir)
|
|
50
|
+
privateDir(cacheDir());
|
|
51
|
+
for (const d of folders(dir))
|
|
52
|
+
privateDir(d);
|
|
53
|
+
return withLock(dir, async () => {
|
|
54
|
+
if (modelReady(dir))
|
|
55
|
+
return dir; // another run finished it while this one waited
|
|
56
|
+
opts.log?.(`[memtrails] fetching ${MODEL_REPO} (218 MB, Gemma Terms of Use)`);
|
|
57
|
+
rmSync(marker(dir), { force: true });
|
|
58
|
+
for (const f of MODEL_FILES) {
|
|
59
|
+
const path = join(dir, f.rel);
|
|
60
|
+
const { file, tmp } = await downloadVerified(`https://huggingface.co/${MODEL_REPO}/resolve/${MODEL_REVISION}/${f.rel}`, dirname(path), f, opts);
|
|
61
|
+
try {
|
|
62
|
+
renameSync(file, path);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
66
|
+
}
|
|
63
67
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
renameSync(part, path);
|
|
70
|
-
}
|
|
71
|
-
writeFileSync(marker(dir), stamp);
|
|
72
|
-
opts.log?.('[memtrails] embedding model ready');
|
|
73
|
-
return dir;
|
|
68
|
+
writeFileSync(marker(dir), stamp, { mode: 0o644 });
|
|
69
|
+
chmodSync(marker(dir), 0o644); // whatever the umask: a marker others could write is not trusted
|
|
70
|
+
opts.log?.('[memtrails] embedding model ready');
|
|
71
|
+
return dir;
|
|
72
|
+
}, { signal: opts.signal });
|
|
74
73
|
}
|
package/dist/core/profiles.d.ts
CHANGED
package/dist/core/profiles.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Profiles — what memory knows about one subject, gathered in one place (sleep's last step).
|
|
3
3
|
*
|
|
4
|
-
* A question about a subject whose facts were told over many conversations ("when did
|
|
5
|
-
* did
|
|
4
|
+
* A question about a subject whose facts were told over many conversations ("when did Dana join the choir, and what
|
|
5
|
+
* did she sing there?") needs the dated facts of all of them, and a top 10 holds a few. For every concept enough long-term
|
|
6
6
|
* items share — at least PROFILE_MIN_ITEMS of a project's items, and on at most PROFILE_BROAD of them (not the person
|
|
7
7
|
* themselves, not the project's own name) — the fast model gathers what they say into one dated list of facts, word for
|
|
8
8
|
* word: a `profile` item, keyed and searched like any other, whose provenance is every source it read.
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { createHash } from 'node:crypto';
|
|
19
19
|
import { redactString } from './redact.js';
|
|
20
|
-
import { defaultAsk } from '../llm.js';
|
|
20
|
+
import { allOrAbort, defaultAsk } from '../llm.js';
|
|
21
|
+
import { stamped } from './consolidate.js';
|
|
21
22
|
import { conceptKey, cut } from './text.js';
|
|
22
23
|
import { writeLtm } from './writer.js';
|
|
23
24
|
export const PROFILE_MIN_ITEMS = 3;
|
|
@@ -86,15 +87,15 @@ export function profileSubjects(items, sizes) {
|
|
|
86
87
|
return out;
|
|
87
88
|
}
|
|
88
89
|
const promptOf = (s) => `SUBJECT: ${s.concept}\n\nMEMORIES:\n${s.read.map((it) => `- (${when(it)}) ${it.title}: ${cut(it.summary.replace(/\s+/g, ' '), PROFILE_LINE)}`).join('\n')}`;
|
|
89
|
-
function profileItem(s, title, facts) {
|
|
90
|
+
function profileItem(s, title, facts, now) {
|
|
90
91
|
const newest = s.read[s.read.length - 1];
|
|
91
|
-
return {
|
|
92
|
+
return stamped({
|
|
92
93
|
uid: s.uid, kind: 'profile', scope: s.scope, project: s.project, engine: newest.engine, session: null, cwd: newest.cwd,
|
|
93
94
|
where: s.concept, ts: Math.max(...s.read.map((it) => it.ts)), firstTs: Math.min(...s.read.map((it) => it.firstTs || it.ts)),
|
|
94
95
|
title: cut(title.trim() || s.concept, 120), summary: facts.join('\n'), body: '', files: [], appliesWhen: '',
|
|
95
96
|
confidence: 'curated', importance: 3, status: 'active', provenance: [...new Set(s.read.flatMap((it) => it.provenance))],
|
|
96
97
|
seen: 1, fingerprint: s.fingerprint, concepts: [s.concept],
|
|
97
|
-
};
|
|
98
|
+
}, now);
|
|
98
99
|
}
|
|
99
100
|
/** Profiles written for the subjects whose items changed (at most `max`, several asks at once), and archived for the
|
|
100
101
|
* subjects that no longer qualify. An ask the model did not answer stops the night's profiles; the next night asks
|
|
@@ -122,12 +123,14 @@ export async function writeProfiles(d, max = PROFILES_PER_SLEEP) {
|
|
|
122
123
|
}
|
|
123
124
|
const todo = subjects.filter((s) => { const m = d.ltm.meta(s.uid); return m?.status !== 'active' || m.fp !== s.fingerprint; }).slice(0, max);
|
|
124
125
|
let next = 0;
|
|
125
|
-
|
|
126
|
-
|
|
126
|
+
// A lane that throws stops the others, their asks too (allOrAbort).
|
|
127
|
+
await allOrAbort(Array.from({ length: Math.min(PROFILE_IN_FLIGHT, todo.length) }, () => async (signal) => {
|
|
128
|
+
while (!r.paused && next < todo.length && !d.aborted() && !signal.aborted) {
|
|
127
129
|
const s = todo[next++];
|
|
128
130
|
const a = await (d.ask ?? defaultAsk)({
|
|
129
131
|
purpose: 'memory', system: PROFILE_SYSTEM, prompt: redactString(promptOf(s)), schema: PROFILE_SCHEMA,
|
|
130
132
|
name: 'memory_profile', maxTokens: 3000, timeoutMs: 180_000, consequence: "so this subject's profile waits for the next night",
|
|
133
|
+
signal,
|
|
131
134
|
});
|
|
132
135
|
if (!('answer' in a)) {
|
|
133
136
|
r.paused = 'unavailable' in a ? a.unavailable : 'busy';
|
|
@@ -137,7 +140,7 @@ export async function writeProfiles(d, max = PROFILES_PER_SLEEP) {
|
|
|
137
140
|
const facts = (Array.isArray(answer.facts) ? answer.facts : []).filter((f) => typeof f === 'string' && !!f.trim()).map((f) => f.trim());
|
|
138
141
|
if (!facts.length)
|
|
139
142
|
continue;
|
|
140
|
-
const it = profileItem(s, typeof answer.title === 'string' ? answer.title : '', facts);
|
|
143
|
+
const it = profileItem(s, typeof answer.title === 'string' ? answer.title : '', facts, (d.now ?? Date.now)());
|
|
141
144
|
await writeLtm(d, [it]);
|
|
142
145
|
d.ltm.put([it]);
|
|
143
146
|
r.profiled++;
|
|
@@ -12,14 +12,61 @@ export declare function qdrantAsset(platform?: string, arch?: string): QdrantAss
|
|
|
12
12
|
export declare function qdrantDir(): string;
|
|
13
13
|
export declare function qdrantBinaryPath(a?: QdrantAsset | null): string | null;
|
|
14
14
|
export declare const QDRANT_RELEASE_BASE = "https://github.com/qdrant/qdrant/releases/download/v1.20.0";
|
|
15
|
-
/**
|
|
15
|
+
/**
|
|
16
|
+
* Run only what this user alone could have put there: the cache, the folder, the binary and its marker owned by this
|
|
17
|
+
* user, no symlink, writable by no one else. The archive's hash is pinned but not the unpacked binary's (that needs every
|
|
18
|
+
* platform's archive to compute), so on a shared machine this is what stops a binary swapped in after unpacking.
|
|
19
|
+
* Windows has no owner bits; there the user's profile ACLs keep others out.
|
|
20
|
+
*/
|
|
21
|
+
export declare function ownedSafely(path: string): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* The folder made (0700) or made private, and this user's alone — or refused. What is verified in it is run or loaded
|
|
24
|
+
* later: a folder others could write to lets them swap a binary between its check and its run, or a download between
|
|
25
|
+
* its hash and its unpacking. (`mkdirSync`'s mode applies only to a folder it makes.)
|
|
26
|
+
*/
|
|
27
|
+
export declare function privateDir(dir: string): void;
|
|
28
|
+
/** The binary is in place, its archive was verified when it was unpacked, and no one else could have changed it since. */
|
|
16
29
|
export declare function qdrantReady(a?: QdrantAsset | null): boolean;
|
|
30
|
+
/** A download given up after this long without a byte (the answer's headers included). */
|
|
31
|
+
export declare const DOWNLOAD_STALL_MS = 60000;
|
|
32
|
+
export interface DownloadOptions {
|
|
33
|
+
signal?: AbortSignal;
|
|
34
|
+
fetchImpl?: typeof fetch;
|
|
35
|
+
stallMs?: number;
|
|
36
|
+
}
|
|
17
37
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
38
|
+
* `url` fetched into a fresh temp folder inside `dir`, never to a fixed name: two first runs at once (two processes, or
|
|
39
|
+
* two memories opened together in one) wrote the same `.part` and could rename each other's half-written file. The
|
|
40
|
+
* file is then checked as it is on disk, not as it streamed past, against its pinned size and SHA-256. Returns the
|
|
41
|
+
* file and its folder; the caller renames the file into place and removes the folder. Throws (leaving nothing) on a
|
|
42
|
+
* failure, a mismatch, or `stallMs` without a byte.
|
|
20
43
|
*/
|
|
21
|
-
export declare function
|
|
44
|
+
export declare function downloadVerified(url: string, dir: string, pin: {
|
|
45
|
+
size: number;
|
|
46
|
+
sha256: string;
|
|
47
|
+
}, o?: DownloadOptions): Promise<{
|
|
48
|
+
file: string;
|
|
49
|
+
tmp: string;
|
|
50
|
+
}>;
|
|
51
|
+
/** A lock not touched for this long is a dead holder's (the holder touches it every quarter of this). */
|
|
52
|
+
export declare const LOCK_STALE_MS = 120000;
|
|
53
|
+
/**
|
|
54
|
+
* `f` run holding `<path>.lock`, created with O_EXCL: a second first run, in another process or this one, waits and
|
|
55
|
+
* then finds the first one's result. A lock whose process is gone, or untouched for `staleMs`, is taken over. Nothing
|
|
56
|
+
* rests on the lock for correctness (every file is verified in a private temp and renamed into place); it keeps two
|
|
57
|
+
* runs from fetching the same hundreds of MB, and from swapping folders under each other.
|
|
58
|
+
*/
|
|
59
|
+
export declare function withLock<T>(path: string, f: () => Promise<T>, o?: {
|
|
22
60
|
signal?: AbortSignal;
|
|
61
|
+
staleMs?: number;
|
|
62
|
+
pollMs?: number;
|
|
63
|
+
}): Promise<T>;
|
|
64
|
+
/**
|
|
65
|
+
* Fetch, verify and unpack the binary unless it is already there. Throws on a network failure or stall, a size or
|
|
66
|
+
* hash mismatch (the download is deleted), a platform Qdrant does not ship for, or a folder others could change; the
|
|
67
|
+
* caller retries with backoff. `asset` is for tests: another archive with its own pins.
|
|
68
|
+
*/
|
|
69
|
+
export declare function ensureQdrant(opts?: DownloadOptions & {
|
|
23
70
|
log?: (line: string) => void;
|
|
24
|
-
|
|
71
|
+
asset?: QdrantAsset;
|
|
25
72
|
}): Promise<string>;
|