memtrails 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/LICENSE +21 -0
  3. package/README.md +221 -2
  4. package/dist/core/consolidate.d.ts +144 -0
  5. package/dist/core/consolidate.js +660 -0
  6. package/dist/core/embedder.d.ts +30 -0
  7. package/dist/core/embedder.js +137 -0
  8. package/dist/core/episodes.d.ts +32 -0
  9. package/dist/core/episodes.js +53 -0
  10. package/dist/core/extract.d.ts +142 -0
  11. package/dist/core/extract.js +452 -0
  12. package/dist/core/gather.d.ts +17 -0
  13. package/dist/core/gather.js +98 -0
  14. package/dist/core/graph.d.ts +48 -0
  15. package/dist/core/graph.js +208 -0
  16. package/dist/core/local-embedder.d.ts +40 -0
  17. package/dist/core/local-embedder.js +98 -0
  18. package/dist/core/model.d.ts +16 -0
  19. package/dist/core/model.js +74 -0
  20. package/dist/core/paths.d.ts +1 -0
  21. package/dist/core/paths.js +7 -0
  22. package/dist/core/profiles.d.ts +30 -0
  23. package/dist/core/profiles.js +147 -0
  24. package/dist/core/qdrant-binary.d.ts +25 -0
  25. package/dist/core/qdrant-binary.js +108 -0
  26. package/dist/core/qdrant-client.d.ts +115 -0
  27. package/dist/core/qdrant-client.js +327 -0
  28. package/dist/core/qdrant-process.d.ts +30 -0
  29. package/dist/core/qdrant-process.js +160 -0
  30. package/dist/core/rebuild.d.ts +31 -0
  31. package/dist/core/rebuild.js +109 -0
  32. package/dist/core/recall-log.d.ts +29 -0
  33. package/dist/core/recall-log.js +82 -0
  34. package/dist/core/recall.d.ts +91 -0
  35. package/dist/core/recall.js +172 -0
  36. package/dist/core/redact.d.ts +2 -0
  37. package/dist/core/redact.js +48 -0
  38. package/dist/core/reranker.d.ts +26 -0
  39. package/dist/core/reranker.js +67 -0
  40. package/dist/core/room.d.ts +20 -0
  41. package/dist/core/room.js +63 -0
  42. package/dist/core/sleep.d.ts +44 -0
  43. package/dist/core/sleep.js +199 -0
  44. package/dist/core/store.d.ts +198 -0
  45. package/dist/core/store.js +190 -0
  46. package/dist/core/text.d.ts +31 -0
  47. package/dist/core/text.js +101 -0
  48. package/dist/core/words.d.ts +32 -0
  49. package/dist/core/words.js +127 -0
  50. package/dist/core/writer.d.ts +36 -0
  51. package/dist/core/writer.js +146 -0
  52. package/dist/importers/files.d.ts +31 -0
  53. package/dist/importers/files.js +157 -0
  54. package/dist/importers/git.d.ts +30 -0
  55. package/dist/importers/git.js +118 -0
  56. package/dist/importers/index.d.ts +14 -0
  57. package/dist/importers/index.js +26 -0
  58. package/dist/importers/jsonl.d.ts +20 -0
  59. package/dist/importers/jsonl.js +65 -0
  60. package/dist/importers/mbox.d.ts +20 -0
  61. package/dist/importers/mbox.js +347 -0
  62. package/dist/importers/slack.d.ts +21 -0
  63. package/dist/importers/slack.js +134 -0
  64. package/dist/importers/text.d.ts +31 -0
  65. package/dist/importers/text.js +191 -0
  66. package/dist/index.d.ts +28 -0
  67. package/dist/index.js +22 -0
  68. package/dist/llm.d.ts +60 -0
  69. package/dist/llm.js +60 -0
  70. package/dist/memory.d.ts +106 -0
  71. package/dist/memory.js +262 -0
  72. package/dist/records.d.ts +62 -0
  73. package/dist/records.js +107 -0
  74. package/dist/tools/agent-tools.d.ts +52 -0
  75. package/dist/tools/agent-tools.js +142 -0
  76. package/package.json +78 -4
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Recall: what a search returns, an item opened whole, and exploring around a topic.
3
+ *
4
+ * `memorySearch` embeds the query and searches dense vectors (each item's key, its chunks and its cues) and a keyword
5
+ * vector at once, fused by rank and grouped by item; with dates, what happened within them first. The best 30 can be
6
+ * reordered (`rerank`: word by word on this machine, words.ts, or any cross-encoder) and fused with the search's own order.
7
+ * `memoryOpen` reads one item whole. `memoryExplore` walks the concept graph (graph.ts) from a topic or from items to
8
+ * what is related but not found directly.
9
+ */
10
+ import { pointId } from './qdrant-client.js';
11
+ import { queryText } from './embedder.js';
12
+ import { explore } from './graph.js';
13
+ import { RERANK_TOP, rerankedOrder } from './reranker.js';
14
+ import { children, cut, querySparse } from './text.js';
15
+ import { dateSpan, LONG_SUMMARY_CHUNKS } from './writer.js';
16
+ import { collectionKey } from '../records.js';
17
+ /** What search may show: active items, never a throwaway run, never superseded or archived knowledge. */
18
+ function baseFilter(kinds) {
19
+ return {
20
+ must: [{ key: 'synthetic', match: { value: false } }, { key: 'status', match: { value: 'active' } },
21
+ ...(kinds?.length ? [{ key: 'engine', match: { any: kinds } }] : [])],
22
+ };
23
+ }
24
+ async function items(d, uids) {
25
+ const got = await d.client.retrieve(uids.map((u) => pointId(u, 0)));
26
+ const byItem = new Map(got.map((p) => [String(p.payload.item), p.payload]));
27
+ return uids.flatMap((uid) => {
28
+ const p = byItem.get(uid);
29
+ if (!p)
30
+ return [];
31
+ return [{
32
+ id: uid, title: String(p.title ?? ''), summary: String(p.summary ?? ''), where: String(p.where ?? ''),
33
+ project: String(p.project ?? ''), engine: String(p.engine ?? ''), ts: Number(p.ts ?? 0), kind: String(p.kind ?? ''), score: 0,
34
+ ...(p.eventAt ? { eventAt: String(p.eventAt) } : {}), ...(p.startAt ? { startAt: String(p.startAt) } : {}),
35
+ ...(p.endAt ? { endAt: String(p.endAt) } : {}), ...(p.record ? { record: p.record } : {}),
36
+ ...(p.tier === 'stm' ? { kind: String(p.engine ?? 'record') } : {}),
37
+ }];
38
+ });
39
+ }
40
+ export const MAX_SEARCH = 200;
41
+ /** A captured request's title shorter than this says nothing on its own. */
42
+ const SHORT_TITLE = 24;
43
+ /** What happened within the dates (YYYY-MM-DD, YYYY-MM or YYYY; either end may be open): an observation by when it
44
+ * happened — its time overlapping the range — anything else by when it was written. The caller reads the dates out of
45
+ * a question; nothing here parses words. */
46
+ function withinDates(f, from, to) {
47
+ const lo = dateSpan(from)?.from ?? 0;
48
+ const hi = dateSpan(to)?.to ?? Number.MAX_SAFE_INTEGER;
49
+ return {
50
+ ...f,
51
+ must: [...(f.must ?? []), {
52
+ should: [
53
+ { must: [{ key: 'eventFrom', range: { lte: hi } }, { key: 'eventTo', range: { gte: lo } }] },
54
+ { must: [{ is_empty: { key: 'eventFrom' } }, { key: 'ts', range: { gte: lo, lte: hi } }] },
55
+ ],
56
+ }],
57
+ };
58
+ }
59
+ /** How long a search waits for its reranker before keeping its own order. */
60
+ const RERANK_WAIT_MS = 3000;
61
+ /** The best RERANK_TOP in the reranker's order fused with their own; with dates, the hits within them stay first — each
62
+ * block is reordered on its own. */
63
+ async function reranked(d, query, list, dated) {
64
+ if (!d.rerank || list.length < 2)
65
+ return list;
66
+ const top = list.slice(0, RERANK_TOP);
67
+ const scores = await d.rerank(query, top.map((it) => `${it.title}\n${it.summary}`), RERANK_WAIT_MS).catch(() => null);
68
+ if (!scores)
69
+ return list;
70
+ const score = new Map(top.map((it, i) => [it.id, scores[i]]));
71
+ const order = (xs) => rerankedOrder(xs, xs.map((it) => score.get(it.id)));
72
+ return [...order(top.filter((it) => dated.has(it.id))), ...order(top.filter((it) => !dated.has(it.id))), ...list.slice(RERANK_TOP)];
73
+ }
74
+ /** Up to this many asked for, memory_search ranks one fused list of twice as many (the room the two kinds had); past
75
+ * it — a question that gathers (memoryGather asks for MAX_SEARCH, then 30 a follow-up) — the dated observations and
76
+ * everything else are two searches of that many each, interleaved, so neither crowds the other out of the room. */
77
+ const SINGLE_LIST_UP_TO = 20;
78
+ export async function memorySearch(d, q) {
79
+ const projects = q.collections?.length ? q.collections.map(collectionKey) : undefined;
80
+ // Up to 200: a question that gathers ("everything she did with her family") needs the facts of many threads at once,
81
+ // a dated observation a line.
82
+ const limit = Math.max(1, Math.min(MAX_SEARCH, q.limit ?? 8));
83
+ const vecs = await d.embed([queryText(q.query)]);
84
+ const base = baseFilter(q.kinds);
85
+ const scoped = projects ? { ...base, must: [...(base.must ?? []), { key: 'project', match: { any: projects } }] } : base;
86
+ // Two searches at once: the dated observations, and everything else — up to `limit` of each. In one search an
87
+ // observation (one point) competed with every chunk of every long request for the same places: 78 of 200 hits on a
88
+ // list question, while the store held the facts it needed (measured on LoCoMo: 22 observation lines in the room
89
+ // against Mem0's 145).
90
+ const dense = vecs.map((v) => Array.from(v));
91
+ const sparse = querySparse([q.query]);
92
+ const observations = { key: 'kind', match: { value: 'observation' } };
93
+ const ranked = async (filter) => {
94
+ // A search for a few: one fused list over every kind, twice as deep as asked. Interleaving the kinds by rank cost
95
+ // the top of the list (all of LoCoMo, consolidated, gold evidence any@5 84.6 → 86.8 with one list); one list only as
96
+ // deep as asked lost the bottom (multi-hop all@40 57.8 → 44.7; twice as deep: 59.6). The interleave is for gathering.
97
+ if (limit <= SINGLE_LIST_UP_TO)
98
+ return d.client.fusedItems({ dense, sparse, filter, limit: 2 * limit });
99
+ const [obs, rest] = await Promise.all([
100
+ d.client.fusedItems({ dense, sparse, filter: { ...filter, must: [...(filter.must ?? []), observations] }, limit }),
101
+ d.client.fusedItems({ dense, sparse, filter: { ...filter, must_not: [...(filter.must_not ?? []), observations] }, limit }),
102
+ ]);
103
+ // Interleaved by rank, so each kind keeps its order and neither crowds out the other.
104
+ return Array.from({ length: Math.max(obs.length, rest.length) }, (_, i) => [rest[i], obs[i]]).flat().filter((h) => !!h);
105
+ };
106
+ const [inDates, open] = await Promise.all([q.from || q.to ? ranked(withinDates(scoped, q.from, q.to)) : [], ranked(scoped)]);
107
+ const dated = new Set(inDates.map((h) => h.item));
108
+ const merged = [...inDates, ...open.filter((h) => !dated.has(h.item))].slice(0, Math.max(open.length, inDates.length));
109
+ // A kept episode and its captured request hold the same text: the one found first stands for both.
110
+ const seen = new Set();
111
+ const hits = merged.filter((h) => {
112
+ const request = h.item.startsWith('ep:') ? h.item.slice(3) : h.item.startsWith('stm:') ? h.item.slice(4) : null;
113
+ if (request === null)
114
+ return true;
115
+ if (seen.has(request))
116
+ return false;
117
+ seen.add(request);
118
+ return true;
119
+ });
120
+ const list = await reranked(d, q.query, await items(d, hits.map((h) => h.item)), dated);
121
+ const score = new Map(hits.map((h) => [h.item, h.score]));
122
+ const matched = await passages(d, hits);
123
+ // A record titled by its first line, when that line is as short as "continue" or "ok", says nothing of what was found:
124
+ // it shows the start of its key instead, which is what matched.
125
+ const titled = (it) => (it.id.startsWith('stm:') && it.title.length < SHORT_TITLE ? { ...it, title: cut(it.summary.replace(/\s+/g, ' '), 100) } : it);
126
+ const out = list.map((it) => ({ ...titled(it), score: score.get(it.id) ?? 0, ...(matched.has(it.id) ? { match: matched.get(it.id) } : {}) }));
127
+ d.record?.({ ev: 'search', project: projects?.join(','), query: q.query, shown: out.map((it) => it.id) });
128
+ return out;
129
+ }
130
+ /** For each hit whose best point was a chunk of its text, that chunk — found again from the text (writer.ts chunks a
131
+ * request's whole body; an item's body, and a summary too long for one key vector). */
132
+ async function passages(d, hits) {
133
+ const chunked = hits.filter((h) => h.point && h.point !== pointId(h.item, 0));
134
+ if (!chunked.length)
135
+ return new Map();
136
+ const got = await d.client.retrieve(chunked.map((h) => pointId(h.item, 0)));
137
+ const byItem = new Map(got.map((p) => [String(p.payload.item), p.payload]));
138
+ const out = new Map();
139
+ for (const h of chunked) {
140
+ const p = byItem.get(h.item);
141
+ if (!p)
142
+ continue;
143
+ const body = String(p.body ?? '');
144
+ const summary = String(p.summary ?? '');
145
+ const kids = p.tier === 'stm' ? children(body) : [...children(body), ...(children(summary).length >= LONG_SUMMARY_CHUNKS ? children(summary) : [])];
146
+ const k = kids.findIndex((_, i) => pointId(h.item, i + 1) === h.point);
147
+ if (k >= 0)
148
+ out.set(h.item, kids[k]);
149
+ }
150
+ return out;
151
+ }
152
+ /** An agent exploring: from a topic (search finds the first items; the topic's concepts start the walk too) or from
153
+ * items, what the concept graph reaches that search did not, and the concepts around it. */
154
+ export async function memoryExplore(d, q) {
155
+ const projects = q.collections?.length ? q.collections.map(collectionKey) : undefined;
156
+ const base = baseFilter();
157
+ const filter = projects ? { ...base, must: [...(base.must ?? []), { key: 'project', match: { any: projects } }] } : base;
158
+ const r = await explore(d, { topic: q.topic, from: q.from, filter, projects, limit: Math.max(1, Math.min(20, q.limit ?? 10)) });
159
+ const [found, related] = await Promise.all([items(d, r.found.slice(0, 8)), items(d, r.related.map((x) => x.id))]);
160
+ const how = new Map(r.related.map((x) => [x.id, x]));
161
+ return { found, related: related.map((it) => ({ ...it, score: how.get(it.id).score, via: how.get(it.id).via })), concepts: r.concepts.map((c) => c.name) };
162
+ }
163
+ /** One item whole, or null. */
164
+ export async function memoryOpen(d, id) {
165
+ const got = await d.client.retrieve([pointId(id, 0)]);
166
+ const p = got[0]?.payload ?? null;
167
+ if (p) {
168
+ d.used?.([id], 'opened');
169
+ d.record?.({ ev: 'open', item: id });
170
+ }
171
+ return p;
172
+ }
@@ -0,0 +1,2 @@
1
+ /** Scrub one string. Never throws; returns `''` for a non-string. */
2
+ export declare function redactString(input: unknown): string;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Secrets are scrubbed from what memory sends to a language model and from what consolidation keeps: provider keys,
3
+ * tokens, credentials in URLs, private keys, e-mail local parts, JWTs, `key=value` secrets; home directories become `~`;
4
+ * a long high-entropy run last. The records you add are kept as you wrote them.
5
+ */
6
+ const SECRET_PATTERNS = [
7
+ [/\bsk-[A-Za-z0-9_-]{16,}/g, '<redacted:sk>'],
8
+ [/\bsk_(?:live|test)_[A-Za-z0-9]{16,}/g, '<redacted:stripe>'],
9
+ [/\bpk_(?:live|test)_[A-Za-z0-9]{16,}/g, '<redacted:stripe-pub>'],
10
+ [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '<redacted:github>'],
11
+ [/\bgh[porsu]_[A-Za-z0-9]{20,}/g, '<redacted:github>'],
12
+ [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '<redacted:slack>'],
13
+ [/\bAKIA[0-9A-Z]{16}\b/g, '<redacted:aws>'],
14
+ [/\bAIza[A-Za-z0-9_-]{20,}/g, '<redacted:google>'],
15
+ [/\b([a-z][a-z0-9+.-]*:\/\/)([^\s/:@]*):([^\s/@]+)@/gi, '$1$2:<redacted>@'],
16
+ [/https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]+/g, 'https://hooks.slack.com/services/<redacted>'],
17
+ [/https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]+/g, '<redacted:discord-webhook>'],
18
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, '<redacted:private-key>'],
19
+ [/\b[A-Za-z0-9._%+-]+@([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/g, '<redacted:email>@$1'],
20
+ [/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '<redacted:jwt>'],
21
+ [/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]{12,}/gi, '$1 <redacted>'],
22
+ [/([?&](?:token|key|api_key|apikey|secret|password|access_token)=)[^&\s"']+/gi, '$1<redacted>'],
23
+ [/\b(api[_-]?key|token|secret|password|passwd|authorization)\b(\s*[=:]\s*)[^\s&"']+/gi, '$1$2<redacted>'],
24
+ ];
25
+ const HOME_DIR_PATTERNS = [
26
+ [/\/Users\/[^/\s"')\]]+/g, '~'],
27
+ [/\/home\/[^/\s"')\]]+/g, '~'],
28
+ [/[A-Za-z]:\\Users\\[^\\\s"')\]]+/gi, '~'],
29
+ ];
30
+ /** Conservative on purpose (48+): digests and minified names are shorter. */
31
+ const HIGH_ENTROPY = /\b[A-Za-z0-9_-]{48,}\b/g;
32
+ /** Scrub one string. Never throws; returns `''` for a non-string. */
33
+ export function redactString(input) {
34
+ if (typeof input !== 'string' || input.length === 0)
35
+ return '';
36
+ let out = input;
37
+ try {
38
+ for (const [re, replacement] of SECRET_PATTERNS)
39
+ out = out.replace(re, replacement);
40
+ for (const [re, replacement] of HOME_DIR_PATTERNS)
41
+ out = out.replace(re, replacement);
42
+ out = out.replace(HIGH_ENTROPY, '<redacted:entropy>');
43
+ }
44
+ catch {
45
+ /* a pathological input must not break the caller */
46
+ }
47
+ return out;
48
+ }
@@ -0,0 +1,26 @@
1
+ export declare const MEMORY_RERANKER: {
2
+ readonly model: "bge-reranker-v2-m3";
3
+ };
4
+ /** How many of the best candidates it reads (20 lost 4.6 points of LoCoMo multi-hop against 30). */
5
+ export declare const RERANK_TOP = 30;
6
+ /** What it reads of each: the title and summary, cut here. */
7
+ export declare const RERANK_CHARS = 1500;
8
+ export interface RerankRoute {
9
+ /** The API's base, e.g. `http://localhost:8081/v1` (`/rerank` is added). */
10
+ url: string;
11
+ apiKey?: string;
12
+ /** Default `bge-reranker-v2-m3`. */
13
+ model?: string;
14
+ gzip?: boolean;
15
+ }
16
+ /** The route the environment names: MEMTRAILS_RERANK_URL, MEMTRAILS_RERANK_KEY, MEMTRAILS_RERANK_MODEL; null without one. */
17
+ export declare function resolveRerankRoute(env?: NodeJS.ProcessEnv): RerankRoute | null;
18
+ export interface RerankerDeps {
19
+ route?: () => RerankRoute | null;
20
+ fetchImpl?: typeof fetch;
21
+ }
22
+ /** Each document's relevance to the query, in the documents' order — or null (no route, refused, failed, late). */
23
+ export declare function remoteReranker(d?: RerankerDeps): (query: string, documents: string[], timeoutMs: number) => Promise<number[] | null>;
24
+ /** The candidates in the reranker's order, fused with their own (by rank, k = 10: 0.7 to the reranker, 0.3 to the
25
+ * search's own order — the measured mix); null scores leave the order as it was. */
26
+ export declare function rerankedOrder<T>(items: T[], scores: number[] | null): T[];
@@ -0,0 +1,67 @@
1
+ /**
2
+ * An optional cross-encoder reranker on a server, for search: any service that speaks the common rerank API
3
+ * (`POST {url}/rerank`, `{ model, query, documents }` → `results[{ index, relevance_score }]`, as text-embeddings-
4
+ * inference, Infinity, Jina and Cohere do). Measured with BAAI/bge-reranker-v2-m3 over the best 30 (docs/BENCHMARKS.md):
5
+ * on little text it matched word by word (words.ts), which needs no server — so it is off unless you give it one.
6
+ *
7
+ * Optional by design: no route, a refusal, an error or a late answer — and the search keeps its own order.
8
+ */
9
+ import { promisify } from 'node:util';
10
+ import { gzip } from 'node:zlib';
11
+ export const MEMORY_RERANKER = { model: 'bge-reranker-v2-m3' };
12
+ /** How many of the best candidates it reads (20 lost 4.6 points of LoCoMo multi-hop against 30). */
13
+ export const RERANK_TOP = 30;
14
+ /** What it reads of each: the title and summary, cut here. */
15
+ export const RERANK_CHARS = 1500;
16
+ /** The route the environment names: MEMTRAILS_RERANK_URL, MEMTRAILS_RERANK_KEY, MEMTRAILS_RERANK_MODEL; null without one. */
17
+ export function resolveRerankRoute(env = process.env) {
18
+ const url = env.MEMTRAILS_RERANK_URL?.trim();
19
+ if (!url)
20
+ return null;
21
+ return { url, apiKey: env.MEMTRAILS_RERANK_KEY?.trim() || undefined, model: env.MEMTRAILS_RERANK_MODEL?.trim() || undefined };
22
+ }
23
+ const compress = promisify(gzip);
24
+ /** Each document's relevance to the query, in the documents' order — or null (no route, refused, failed, late). */
25
+ export function remoteReranker(d = {}) {
26
+ const route = d.route ?? (() => resolveRerankRoute());
27
+ return async (query, documents, timeoutMs) => {
28
+ const r = route();
29
+ if (!r || !documents.length || timeoutMs < 100)
30
+ return null;
31
+ try {
32
+ const json = Buffer.from(JSON.stringify({ model: r.model ?? MEMORY_RERANKER.model, query, documents: documents.map((t) => t.slice(0, RERANK_CHARS) || ' ') }));
33
+ const body = r.gzip ? await compress(json) : json;
34
+ const res = await (d.fetchImpl ?? fetch)(`${r.url.replace(/\/+$/, '').replace(/\/rerank$/, '')}/rerank`, {
35
+ method: 'POST',
36
+ signal: AbortSignal.timeout(timeoutMs),
37
+ headers: { 'content-type': 'application/json', ...(r.gzip ? { 'content-encoding': 'gzip' } : {}), ...(r.apiKey ? { authorization: `Bearer ${r.apiKey}` } : {}) },
38
+ body: new Uint8Array(body),
39
+ });
40
+ if (!res.ok) {
41
+ await res.text().catch(() => '');
42
+ return null;
43
+ }
44
+ const reply = (await res.json());
45
+ const scores = new Array(documents.length).fill(Number.NaN);
46
+ for (const x of reply.results ?? []) {
47
+ if (typeof x.index === 'number' && x.index >= 0 && x.index < documents.length && typeof x.relevance_score === 'number')
48
+ scores[x.index] = x.relevance_score;
49
+ }
50
+ return scores.some(Number.isNaN) ? null : scores;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ };
56
+ }
57
+ /** The candidates in the reranker's order, fused with their own (by rank, k = 10: 0.7 to the reranker, 0.3 to the
58
+ * search's own order — the measured mix); null scores leave the order as it was. */
59
+ export function rerankedOrder(items, scores) {
60
+ if (!scores || scores.length !== items.length)
61
+ return items;
62
+ const byScore = items.map((_, i) => i).sort((a, b) => scores[b] - scores[a] || a - b);
63
+ const fused = new Map();
64
+ byScore.forEach((i, r) => fused.set(i, (fused.get(i) ?? 0) + 0.7 / (10 + r + 1)));
65
+ items.forEach((_, i) => fused.set(i, (fused.get(i) ?? 0) + 0.3 / (10 + i + 1)));
66
+ return items.map((_, i) => i).sort((a, b) => fused.get(b) - fused.get(a) || a - b).map((i) => items[i]);
67
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A small room filled with sentences, not memories. An agent reads a few thousand characters of what memory found; a
3
+ * memory's first 500 of them are rarely where its answer is. Each found memory's text is cut into sentences (at any
4
+ * script's end marks, no word list), each sentence scored word by word against the question, and the room filled with
5
+ * the best sentences, a memory's place in the search counting too (docs/RESEARCH.md: held out, the answer's words in
6
+ * 6,000 characters 66.0 against 57.2 for Mem0's room on raw memory).
7
+ *
8
+ * Sentences are kept packed beside the memories' own words (words.ts WordStore), by their text, so each is encoded once.
9
+ */
10
+ import { type WordEncoder, type WordStore } from './words.js';
11
+ /** [start, end) of each sentence: after `.`, `!`, `?`, their full-width forms, `。` or a line break, then a space. */
12
+ export declare function sentenceSpans(text: string): Array<[number, number]>;
13
+ export interface RoomItem {
14
+ id: string;
15
+ head: string;
16
+ text: string;
17
+ ts: number;
18
+ }
19
+ /** The room: the best sentences of the first `top` memories, fitted in `chars`, each memory's head once, oldest first. */
20
+ export declare function sentenceRoom(query: string, items: RoomItem[], encode: WordEncoder, store: WordStore, chars: number, top?: number): Promise<string>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * A small room filled with sentences, not memories. An agent reads a few thousand characters of what memory found; a
3
+ * memory's first 500 of them are rarely where its answer is. Each found memory's text is cut into sentences (at any
4
+ * script's end marks, no word list), each sentence scored word by word against the question, and the room filled with
5
+ * the best sentences, a memory's place in the search counting too (docs/RESEARCH.md: held out, the answer's words in
6
+ * 6,000 characters 66.0 against 57.2 for Mem0's room on raw memory).
7
+ *
8
+ * Sentences are kept packed beside the memories' own words (words.ts WordStore), by their text, so each is encoded once.
9
+ */
10
+ import { maxSim, packWords } from './words.js';
11
+ /** Sentences shorter than this say too little to be worth a place. */
12
+ const MIN_SENTENCE = 8;
13
+ /** A sentence longer than this is cut into pieces of it. */
14
+ const MAX_SENTENCE = 400;
15
+ /** A memory's date and kind, once, before its sentences. */
16
+ const HEAD_CHARS = 20;
17
+ /** [start, end) of each sentence: after `.`, `!`, `?`, their full-width forms, `。` or a line break, then a space. */
18
+ export function sentenceSpans(text) {
19
+ const out = [];
20
+ const push = (a, b) => {
21
+ while (a < b && /\s/.test(text[a]))
22
+ a++;
23
+ while (b > a && /\s/.test(text[b - 1]))
24
+ b--;
25
+ for (let s = a; s < b; s += MAX_SENTENCE)
26
+ out.push([s, Math.min(b, s + MAX_SENTENCE)]);
27
+ };
28
+ const end = /[.!?。!?](?=\s|$)|\n/gu;
29
+ let from = 0;
30
+ for (let m = end.exec(text); m; m = end.exec(text)) {
31
+ push(from, m.index + m[0].length);
32
+ from = m.index + m[0].length;
33
+ }
34
+ push(from, text.length);
35
+ return out.filter(([a, b]) => b - a >= MIN_SENTENCE);
36
+ }
37
+ /** The room: the best sentences of the first `top` memories, fitted in `chars`, each memory's head once, oldest first. */
38
+ export async function sentenceRoom(query, items, encode, store, chars, top = 30) {
39
+ const listed = items.slice(0, top).map((it, rank) => ({ it, rank, spans: sentenceSpans(it.text) }));
40
+ const texts = [...new Set(listed.flatMap((l) => l.spans.map(([a, b]) => l.it.text.slice(a, b))))];
41
+ const missing = texts.filter((t) => !store.get(t));
42
+ if (missing.length) {
43
+ const encoded = await encode(missing, 'document');
44
+ await store.put(missing.map((t, i) => [t, packWords(encoded[i])]));
45
+ }
46
+ const [q] = await encode([query], 'query');
47
+ const candidates = listed.flatMap((l) => l.spans.map(([a, b]) => ({
48
+ l, a, b, score: maxSim(q, store.get(l.it.text.slice(a, b))) * (1 + 2 / (1 + l.rank / 10)),
49
+ }))).sort((x, y) => y.score - x.score);
50
+ const picked = new Map();
51
+ let used = 0;
52
+ for (const c of candidates) {
53
+ const extra = c.b - c.a + 3 + (picked.has(c.l.it) ? 0 : HEAD_CHARS + c.l.it.head.length);
54
+ if (used + extra > chars)
55
+ continue;
56
+ picked.set(c.l.it, [...(picked.get(c.l.it) ?? []), [c.a, c.b]]);
57
+ used += extra;
58
+ }
59
+ return [...picked.entries()]
60
+ .sort(([x], [y]) => x.ts - y.ts)
61
+ .map(([it, spans]) => `${it.head} ${spans.sort((x, y) => x[0] - y[0]).map(([a, b]) => it.text.slice(a, b)).join(' … ')}`)
62
+ .join('\n');
63
+ }
@@ -0,0 +1,44 @@
1
+ import { type ConsolidateDeps } from './consolidate.js';
2
+ import type { MemoryItem, UsageLog } from './store.js';
3
+ export declare const PAIRS_PER_SLEEP = 1600;
4
+ export declare const ARCHIVE_BELOW = 0.02;
5
+ export declare const ARCHIVE_UNUSED_MS: number;
6
+ export declare function priority(it: Pick<MemoryItem, 'kind' | 'importance' | 'ts'>, use: {
7
+ shown: number;
8
+ opened: number;
9
+ }, now: number): number;
10
+ export interface SleepDeps extends ConsolidateDeps {
11
+ usage: UsageLog;
12
+ statePath: string;
13
+ /** Where the last sleep is kept instead of `statePath` (a server running many memories: a row per memory). */
14
+ sleepState?: {
15
+ read(): Promise<{
16
+ last?: number;
17
+ } | null>;
18
+ write(state: {
19
+ last: number;
20
+ } & SleepReport): Promise<void>;
21
+ };
22
+ /** A backfill's night: fresh items looked at this many at once (1), relate asks in flight (RELATE_IN_FLIGHT), and
23
+ * pairs a night asks about (PAIRS_PER_SLEEP). */
24
+ scanAtOnce?: number;
25
+ relateInFlight?: number;
26
+ pairsPerSleep?: number;
27
+ /** Answers applied at once (1, in order). */
28
+ applyAtOnce?: number;
29
+ }
30
+ export interface SleepReport {
31
+ looked: number;
32
+ merged: number;
33
+ superseded: number;
34
+ linked: number;
35
+ archived: number;
36
+ profiled: number;
37
+ unprofiled: number;
38
+ }
39
+ export declare class Sleep {
40
+ private readonly d;
41
+ constructor(d: SleepDeps);
42
+ private lastSleep;
43
+ run(): Promise<SleepReport>;
44
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Sleep — the daily pass that keeps long-term memory organised (LightMem's sleep-time update; MemoryOS's heat):
3
+ *
4
+ * 1. RELATE what was learned since the last sleep to every near neighbour (cosine >= 0.80, any knowledge kind, the
5
+ * same scope) — not only the nearest one consolidation looked at: from 0.93 they merge; below, the fast model
6
+ * merges the two, says one replaces the other (superseded), links them (the same subject, different facts) or
7
+ * says they differ. Measured: 0.80–0.90 held 62% duplicates, outdated items and
8
+ * contradictions, 38% that belong together — the 0.90 floor this pass had left them all;
9
+ * 2. PRIORITY for every active item = importance × recency (a half-life by kind: preferences, architecture,
10
+ * references and notes do not fade; facts and pitfalls fade in a year; episodes in three months) × use (how
11
+ * often recall showed it and an agent opened it — MemRL-style utility);
12
+ * 3. ARCHIVE what stays low and unused for half a year: out of the index, kept in the log. Nothing is forgotten
13
+ * outright: everything is remembered, efficiently;
14
+ * 4. PROFILES (profiles.ts): for each subject enough items share, what they say gathered into one dated list —
15
+ * written again when those items changed, archived when the subject no longer qualifies.
16
+ */
17
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { dirname } from 'node:path';
19
+ import { Consolidator, RELATED, relateBatches, SAME } from './consolidate.js';
20
+ import { docText } from './embedder.js';
21
+ import { pointId } from './qdrant-client.js';
22
+ import { writeProfiles } from './profiles.js';
23
+ import { writeLtm } from './writer.js';
24
+ const DAY = 24 * 60 * 60_000;
25
+ /** A fresh item's neighbours looked at, and the pairs a night asks about (a large first sleep goes on the next nights). */
26
+ const NEIGHBOURS = 8;
27
+ export const PAIRS_PER_SLEEP = 1600;
28
+ const RELATE_IN_FLIGHT = 4;
29
+ export const ARCHIVE_BELOW = 0.02;
30
+ export const ARCHIVE_UNUSED_MS = 180 * DAY;
31
+ const HALF_LIFE = {
32
+ episode: 90 * DAY, fact: 365 * DAY, pitfall: 365 * DAY,
33
+ decision: 540 * DAY, procedure: 540 * DAY, snippet: 540 * DAY, template: 540 * DAY,
34
+ };
35
+ export function priority(it, use, now) {
36
+ const half = HALF_LIFE[it.kind];
37
+ const recency = half ? 0.5 ** (Math.max(0, now - it.ts) / half) : 1;
38
+ return (it.importance / 5) * recency * (1 + Math.log1p(use.opened) + 0.3 * Math.log1p(use.shown));
39
+ }
40
+ export class Sleep {
41
+ d;
42
+ constructor(d) {
43
+ this.d = d;
44
+ }
45
+ async lastSleep() {
46
+ if (this.d.sleepState)
47
+ return (await this.d.sleepState.read())?.last ?? 0;
48
+ try {
49
+ return JSON.parse(readFileSync(this.d.statePath, 'utf8')).last ?? 0;
50
+ }
51
+ catch {
52
+ return 0;
53
+ }
54
+ }
55
+ async run() {
56
+ const now = (this.d.now ?? Date.now)();
57
+ const since = await this.lastSleep();
58
+ const r = { looked: 0, merged: 0, superseded: 0, linked: 0, archived: 0, profiled: 0, unprofiled: 0 };
59
+ // 1. What was learned since the last sleep, against its near neighbours, oldest first.
60
+ const worker = new Consolidator(this.d);
61
+ // Observations are add-only (consolidate.ts observe): two events are never one, and an older one stays, dated. A
62
+ // profile is written again from its items (4), never related to anything.
63
+ const knowledge = (m) => m.status === 'active' && m.kind !== 'episode' && m.kind !== 'note' && m.kind !== 'observation' && m.kind !== 'profile';
64
+ const fresh = [...this.d.ltm.entries()].filter(([, m]) => knowledge(m) && m.ts >= since).sort((a, b) => a[1].ts - b[1].ts).map(([uid]) => uid);
65
+ const pairs = [];
66
+ const seen = new Set();
67
+ let resumeAt = null;
68
+ const cap = this.d.pairsPerSleep ?? PAIRS_PER_SLEEP;
69
+ const look = async (it) => {
70
+ // The key vector the index already holds; embedded again only when it is not there.
71
+ const held = (await this.d.client.retrieve([pointId(it.uid, 0)], true))[0]?.vector;
72
+ const v = held ?? Array.from((await this.d.embed([docText(it.title, it.summary)]))[0]);
73
+ const must = [{ key: 'tier', match: { value: 'ltm' } }, { key: 'status', match: { value: 'active' } }];
74
+ must.push(it.scope === 'project' ? { key: 'project', match: { value: it.project } } : { key: 'scope', match: { any: ['global', 'person'] } });
75
+ const filter = { must, must_not: [{ key: 'kind', match: { any: ['episode', 'note', 'observation', 'profile'] } }] };
76
+ return (await this.d.client.neighbors(Array.from(v), filter, NEIGHBOURS)).map((n) => ({ uid: it.uid, item: n.item, score: n.score }));
77
+ };
78
+ // Oldest first, a few at once: each group's neighbours become pairs in order, and a full night stops at the next
79
+ // group, which the next night starts from.
80
+ const atOnce = Math.max(1, this.d.scanAtOnce ?? 1);
81
+ for (let i = 0; i < fresh.length; i += atOnce) {
82
+ if (this.d.aborted())
83
+ return r;
84
+ const group = fresh.slice(i, i + atOnce).map((uid) => this.d.ltm.get(uid)).filter((it) => !!it && it.status === 'active');
85
+ if (!group.length)
86
+ continue;
87
+ if (pairs.length >= cap) {
88
+ resumeAt = group[0].ts;
89
+ break;
90
+ }
91
+ r.looked += group.length;
92
+ for (const [k, near] of (await Promise.all(group.map(look))).entries()) {
93
+ const it = group[k];
94
+ for (const n of near) {
95
+ const known = n.item !== it.uid && n.score >= RELATED ? this.d.ltm.get(n.item) : null;
96
+ const key = [it.uid, n.item].sort().join(' ');
97
+ if (!known || !knowledge(known) || seen.has(key))
98
+ continue;
99
+ seen.add(key);
100
+ // The newer of the two is NEW, the older KNOWN: a contradiction retires the older.
101
+ pairs.push(it.ts >= known.ts ? { it, known, score: n.score } : { it: known, known: it, score: n.score });
102
+ }
103
+ }
104
+ }
105
+ // The fast model reads the pairs below 0.93, several asks at once; the answers are applied in order, each to the
106
+ // items as they are by then (one an earlier answer retired is left alone).
107
+ const asked = pairs.filter((p) => p.score < SAME);
108
+ const answers = new Map();
109
+ const batches = relateBatches(asked);
110
+ // As many asks in flight as allowed, the next one starting as soon as any is answered; one the model did not answer
111
+ // stops the asking, and the next night starts from its pairs.
112
+ const inFlight = Math.max(1, this.d.relateInFlight ?? RELATE_IN_FLIGHT);
113
+ let next = 0;
114
+ let unanswered = false;
115
+ await Promise.all(Array.from({ length: Math.min(inFlight, batches.length) }, async () => {
116
+ while (!unanswered && next < batches.length && !this.d.aborted()) {
117
+ const b = batches[next++];
118
+ const got = await worker.relate(b);
119
+ if (!got) {
120
+ unanswered = true;
121
+ resumeAt = Math.min(resumeAt ?? Infinity, ...b.map((p) => p.it.ts));
122
+ return;
123
+ }
124
+ b.forEach((p, n) => { const d = got.get(`p${n + 1}`); if (d)
125
+ answers.set(p, d); });
126
+ }
127
+ }));
128
+ // The answers applied — in order, one at a time, unless a backfill applies several at once — each to the two items as
129
+ // they are by then, under their lock (one an earlier answer retired is left alone).
130
+ const apply = async (p) => worker.locked([p.it.uid, p.known.uid], async () => {
131
+ const it = this.d.ltm.get(p.it.uid);
132
+ const known = this.d.ltm.get(p.known.uid);
133
+ if (!it || !known || it.status !== 'active' || known.status !== 'active')
134
+ return;
135
+ const d = p.score >= SAME ? { relation: 'same', title: '', summary: '', body: '' } : answers.get(p);
136
+ if (!d || d.relation === 'different')
137
+ return;
138
+ if (d.relation === 'related') {
139
+ await worker.link(it, known);
140
+ r.linked++;
141
+ return;
142
+ }
143
+ if (d.relation === 'contradicts') {
144
+ await worker.retire(known, it.uid);
145
+ r.superseded++;
146
+ return;
147
+ }
148
+ await worker.merge(known, it, d.title && d.summary ? { title: d.title, summary: d.summary, body: d.body || known.body } : null);
149
+ await worker.retire(it, known.uid);
150
+ r.merged++;
151
+ });
152
+ let at = 0;
153
+ await Promise.all(Array.from({ length: Math.max(1, this.d.applyAtOnce ?? 1) }, async () => {
154
+ while (at < pairs.length && !this.d.aborted())
155
+ await apply(pairs[at++]);
156
+ }));
157
+ if (this.d.aborted())
158
+ return r;
159
+ // 2–3. Priority, and the archive.
160
+ const retire = [];
161
+ for (const [uid, m] of this.d.ltm.entries()) {
162
+ if (m.status !== 'active' || m.kind === 'note' || !HALF_LIFE[m.kind])
163
+ continue;
164
+ const use = this.d.usage.of(uid);
165
+ if (priority(m, use, now) >= ARCHIVE_BELOW || now - Math.max(m.ts, use.lastUsed) < ARCHIVE_UNUSED_MS)
166
+ continue;
167
+ const it = this.d.ltm.get(uid);
168
+ if (it)
169
+ retire.push({ ...it, status: 'archived' });
170
+ }
171
+ for (let i = 0; i < retire.length && !this.d.aborted(); i += 50) {
172
+ const part = retire.slice(i, i + 50);
173
+ await writeLtm(this.d, part);
174
+ this.d.ltm.put(part);
175
+ r.archived += part.length;
176
+ }
177
+ // 4. Profiles of what is known now; one the fast model did not answer waits for the next night.
178
+ if (this.d.aborted())
179
+ return r;
180
+ const p = await writeProfiles(this.d);
181
+ r.profiled = p.profiled;
182
+ r.unprofiled = p.unprofiled;
183
+ if (p.paused)
184
+ this.d.log(`memory sleep: profiles wait for the next night (${p.paused})`);
185
+ this.d.ltm.compact();
186
+ // A night that stopped early (the pair cap, the fast model away) starts the next one where it stopped.
187
+ if (this.d.sleepState) {
188
+ await this.d.ltm.flush();
189
+ await this.d.sleepState.write({ last: resumeAt ?? now, ...r });
190
+ return r;
191
+ }
192
+ try {
193
+ mkdirSync(dirname(this.d.statePath), { recursive: true });
194
+ writeFileSync(this.d.statePath, JSON.stringify({ last: resumeAt ?? now, ...r }), { mode: 0o600 });
195
+ }
196
+ catch { /* the next sleep looks at a little more */ }
197
+ return r;
198
+ }
199
+ }