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
@@ -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
  } | {
@@ -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 ("Melanie took her family camping in the
43
- mountains"), never "On <date>, X said that …" (the date has its own field). Name who and what (names, never "he",
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 ("Becoming Nicole",
48
- "Sweden", "her three children") — never a fact from KNOWN that this part does not tell. What the person or a speaker
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 ("Melanie planned to go swimming with the kids"). When it replaces something said before, say both
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;
@@ -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
@@ -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 doctors did I see", "what did I spend on luxury items")
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 "luxury items": "designer handbag", "jewelry", "watch") —
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 ("Books Tim has read: Harry Potter (2023-08-02), The Name of the Wind (2023-08-11)"),
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
- const answers = await Promise.all(parts.map(async (part) => {
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
- const keep = ids.map((x) => part[Number(x.replace(/^m/, '')) - 1]?.id).filter((x) => !!x);
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
- return { found: [...kept, ...all.filter((h) => !keptIds.has(h.id))], kept: kept.map((h) => h.id), searches, notes: [...one.notes, ...two.notes] };
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
  }
@@ -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
- /** The model in `dir` (fetched and checked unless it is already there); the directory, ready for `modelDir`. */
11
- export declare function ensureModel(opts?: {
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>;
@@ -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 { createHash } from 'node:crypto';
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 && MODEL_FILES.every((f) => existsSync(join(dir, f.rel)));
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
- /** The model in `dir` (fetched and checked unless it is already there); the directory, ready for `modelDir`. */
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
- opts.log?.(`[memtrails] fetching ${MODEL_REPO} (218 MB, Gemma Terms of Use)`);
40
- for (const f of MODEL_FILES) {
41
- const path = join(dir, f.rel);
42
- mkdirSync(dirname(path), { recursive: true });
43
- const part = `${path}.part`;
44
- rmSync(part, { force: true });
45
- const res = await (opts.fetchImpl ?? fetch)(`https://huggingface.co/${MODEL_REPO}/resolve/${MODEL_REVISION}/${f.rel}`, { signal: opts.signal, redirect: 'follow' });
46
- if (!res.ok || !res.body)
47
- throw new Error(`HTTP ${res.status} for ${f.rel}`);
48
- const hash = createHash('sha256');
49
- let size = 0;
50
- try {
51
- await pipeline(Readable.fromWeb(res.body), async function* (src) {
52
- for await (const chunk of src) {
53
- const b = chunk;
54
- hash.update(b);
55
- size += b.length;
56
- yield b;
57
- }
58
- }, createWriteStream(part));
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
- const digest = hash.digest('hex');
65
- if (size !== f.size || digest !== f.sha256) {
66
- rmSync(part, { force: true });
67
- throw new Error(`${f.rel}: got ${size} bytes sha256 ${digest}, expected ${f.size} ${f.sha256}`);
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
  }
@@ -1,4 +1,4 @@
1
- import type { ConsolidateDeps } from './consolidate.js';
1
+ import { type ConsolidateDeps } from './consolidate.js';
2
2
  import type { MemoryItem, MemoryScope } from './store.js';
3
3
  export declare const PROFILE_MIN_ITEMS = 3;
4
4
  export declare const PROFILE_BROAD = 0.2;
@@ -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 John join the brigade, and what
5
- * did he do there?") needs the dated facts of all of them, and a top 10 holds a few. For every concept enough long-term
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
- await Promise.all(Array.from({ length: Math.min(PROFILE_IN_FLIGHT, todo.length) }, async () => {
126
- while (!r.paused && next < todo.length && !d.aborted()) {
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
- /** The binary is in place and its archive was verified when it was unpacked. */
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
- * Fetch, verify and unpack the binary unless it is already there. Throws on a network failure, a size or hash
19
- * mismatch (the download is deleted), or a platform Qdrant does not ship for; the caller retries with backoff.
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 ensureQdrant(opts?: {
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
- fetchImpl?: typeof fetch;
71
+ asset?: QdrantAsset;
25
72
  }): Promise<string>;