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.
- package/CHANGELOG.md +30 -0
- package/LICENSE +21 -0
- package/README.md +221 -2
- package/dist/core/consolidate.d.ts +144 -0
- package/dist/core/consolidate.js +660 -0
- package/dist/core/embedder.d.ts +30 -0
- package/dist/core/embedder.js +137 -0
- package/dist/core/episodes.d.ts +32 -0
- package/dist/core/episodes.js +53 -0
- package/dist/core/extract.d.ts +142 -0
- package/dist/core/extract.js +452 -0
- package/dist/core/gather.d.ts +17 -0
- package/dist/core/gather.js +98 -0
- package/dist/core/graph.d.ts +48 -0
- package/dist/core/graph.js +208 -0
- package/dist/core/local-embedder.d.ts +40 -0
- package/dist/core/local-embedder.js +98 -0
- package/dist/core/model.d.ts +16 -0
- package/dist/core/model.js +74 -0
- package/dist/core/paths.d.ts +1 -0
- package/dist/core/paths.js +7 -0
- package/dist/core/profiles.d.ts +30 -0
- package/dist/core/profiles.js +147 -0
- package/dist/core/qdrant-binary.d.ts +25 -0
- package/dist/core/qdrant-binary.js +108 -0
- package/dist/core/qdrant-client.d.ts +115 -0
- package/dist/core/qdrant-client.js +327 -0
- package/dist/core/qdrant-process.d.ts +30 -0
- package/dist/core/qdrant-process.js +160 -0
- package/dist/core/rebuild.d.ts +31 -0
- package/dist/core/rebuild.js +109 -0
- package/dist/core/recall-log.d.ts +29 -0
- package/dist/core/recall-log.js +82 -0
- package/dist/core/recall.d.ts +91 -0
- package/dist/core/recall.js +172 -0
- package/dist/core/redact.d.ts +2 -0
- package/dist/core/redact.js +48 -0
- package/dist/core/reranker.d.ts +26 -0
- package/dist/core/reranker.js +67 -0
- package/dist/core/room.d.ts +20 -0
- package/dist/core/room.js +63 -0
- package/dist/core/sleep.d.ts +44 -0
- package/dist/core/sleep.js +199 -0
- package/dist/core/store.d.ts +198 -0
- package/dist/core/store.js +190 -0
- package/dist/core/text.d.ts +31 -0
- package/dist/core/text.js +101 -0
- package/dist/core/words.d.ts +32 -0
- package/dist/core/words.js +127 -0
- package/dist/core/writer.d.ts +36 -0
- package/dist/core/writer.js +146 -0
- package/dist/importers/files.d.ts +31 -0
- package/dist/importers/files.js +157 -0
- package/dist/importers/git.d.ts +30 -0
- package/dist/importers/git.js +118 -0
- package/dist/importers/index.d.ts +14 -0
- package/dist/importers/index.js +26 -0
- package/dist/importers/jsonl.d.ts +20 -0
- package/dist/importers/jsonl.js +65 -0
- package/dist/importers/mbox.d.ts +20 -0
- package/dist/importers/mbox.js +347 -0
- package/dist/importers/slack.d.ts +21 -0
- package/dist/importers/slack.js +134 -0
- package/dist/importers/text.d.ts +31 -0
- package/dist/importers/text.js +191 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +22 -0
- package/dist/llm.d.ts +60 -0
- package/dist/llm.js +60 -0
- package/dist/memory.d.ts +106 -0
- package/dist/memory.js +262 -0
- package/dist/records.d.ts +62 -0
- package/dist/records.js +107 -0
- package/dist/tools/agent-tools.d.ts +52 -0
- package/dist/tools/agent-tools.js +142 -0
- package/package.json +78 -4
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
export type MemoryKind = 'decision' | 'procedure' | 'snippet' | 'template' | 'pitfall' | 'fact' | 'architecture' | 'preference' | 'reference' | 'episode' | 'note' | 'observation'
|
|
2
|
+
/** What memory knows about one subject, gathered from its items (profiles.ts) — written again from them, never merged. */
|
|
3
|
+
| 'profile';
|
|
4
|
+
export type MemoryScope = 'project' | 'global' | 'person';
|
|
5
|
+
/** `merged`: a text another item absorbed — kept whole, out of recall, so a merge never loses what either side said. */
|
|
6
|
+
export type MemoryStatus = 'active' | 'superseded' | 'archived' | 'merged';
|
|
7
|
+
/** A request as captured: short-term memory. */
|
|
8
|
+
export interface StmEntry {
|
|
9
|
+
uid: string;
|
|
10
|
+
fingerprint: string;
|
|
11
|
+
engine: string;
|
|
12
|
+
session: string;
|
|
13
|
+
turn: number;
|
|
14
|
+
cwd: string | null;
|
|
15
|
+
project: string;
|
|
16
|
+
title: string;
|
|
17
|
+
ts: number;
|
|
18
|
+
synthetic: boolean;
|
|
19
|
+
typed: 'human' | 'unknown';
|
|
20
|
+
request: string;
|
|
21
|
+
reply: string;
|
|
22
|
+
said: string;
|
|
23
|
+
steps: string[];
|
|
24
|
+
files: string[];
|
|
25
|
+
/** Read by the consolidation worker. */
|
|
26
|
+
done: boolean;
|
|
27
|
+
/** The record it is (records.ts): its own id, where it came from, who wrote it, where to open it, and your metadata. */
|
|
28
|
+
record?: {
|
|
29
|
+
id: string;
|
|
30
|
+
source?: string;
|
|
31
|
+
author?: string;
|
|
32
|
+
url?: string;
|
|
33
|
+
metadata?: Record<string, string | number | boolean | null>;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
/** What long-term memory keeps. */
|
|
37
|
+
export interface MemoryItem {
|
|
38
|
+
uid: string;
|
|
39
|
+
kind: MemoryKind;
|
|
40
|
+
scope: MemoryScope;
|
|
41
|
+
project: string;
|
|
42
|
+
engine: string;
|
|
43
|
+
session: string | null;
|
|
44
|
+
cwd: string | null;
|
|
45
|
+
/** Where it came from, for a person: the conversation's title, the note's name. */
|
|
46
|
+
where: string;
|
|
47
|
+
/** Last learned or refreshed. */
|
|
48
|
+
ts: number;
|
|
49
|
+
firstTs: number;
|
|
50
|
+
/** The key: a content title and a ~150-word summary (what is embedded). */
|
|
51
|
+
title: string;
|
|
52
|
+
summary: string;
|
|
53
|
+
body: string;
|
|
54
|
+
files: string[];
|
|
55
|
+
appliesWhen: string;
|
|
56
|
+
/** verified / stated (consolidation keeps only these), curated (a saved note), observed (an episode). */
|
|
57
|
+
confidence: 'verified' | 'stated' | 'curated' | 'observed';
|
|
58
|
+
/** 1–5, from consolidation. */
|
|
59
|
+
importance: number;
|
|
60
|
+
status: MemoryStatus;
|
|
61
|
+
supersededBy?: string;
|
|
62
|
+
/** When it was superseded — kept beside when it was learned (`ts`), so what was true when stays answerable. */
|
|
63
|
+
supersededAt?: number;
|
|
64
|
+
/** For an episode: how its request ended (success, partial, fail, uncertain). */
|
|
65
|
+
outcome?: 'success' | 'partial' | 'fail' | 'uncertain';
|
|
66
|
+
/** For a `merged` record: the item that absorbed it. */
|
|
67
|
+
mergedInto?: string;
|
|
68
|
+
/** The `merged` records this item absorbed: the texts it was made from, whole, in the log. */
|
|
69
|
+
absorbed?: string[];
|
|
70
|
+
/** The requests it was learned from (episode uids), newest last, at most 50. */
|
|
71
|
+
provenance: string[];
|
|
72
|
+
/** How many times consolidation learned it again (a duplicate merged into it). */
|
|
73
|
+
seen: number;
|
|
74
|
+
fingerprint: string;
|
|
75
|
+
/** Requests the person would type when it is needed — searched beside the key (absent before round 2: the worker
|
|
76
|
+
* fills them, see Consolidator.fillCues). */
|
|
77
|
+
cues?: string[];
|
|
78
|
+
/** Where it came from (a guard that keeps only trusted sources): the person's own words, or a result the agent confirmed —
|
|
79
|
+
* with the words that show it. Absent on a desktop, where every source is the person's own machine. */
|
|
80
|
+
origin?: MemoryOrigin;
|
|
81
|
+
/** Items about the same subject that say different things (consolidation and sleep link them; memory_explore walks
|
|
82
|
+
* them), newest last. */
|
|
83
|
+
related?: string[];
|
|
84
|
+
/** What it is about — files, services, commands, named things — as the conversation named them: the concept graph's
|
|
85
|
+
* links (memory_explore walks them; absent before the graph: the worker's backlog fills them). */
|
|
86
|
+
concepts?: string[];
|
|
87
|
+
/** For an observation: when what it tells happened or applies — an absolute date as precise as the conversation made
|
|
88
|
+
* it (YYYY-MM-DD, YYYY-MM or YYYY), resolved from the request's own date; absent when the conversation gave none.
|
|
89
|
+
* `ts` stays when it was said. */
|
|
90
|
+
eventAt?: string;
|
|
91
|
+
/** For an observation of something that lasted (a state, a habit, a job, a stay): when it began and when it ended,
|
|
92
|
+
* as precise as the conversation made them; absent when it gave none. */
|
|
93
|
+
startAt?: string;
|
|
94
|
+
endAt?: string;
|
|
95
|
+
/** For an observation: done (it happened), plan (intended, not yet done), ongoing, or state (how things are). */
|
|
96
|
+
phase?: 'done' | 'plan' | 'ongoing' | 'state';
|
|
97
|
+
/** For an observation: who or what it is about, named as the conversation names them ("the person", "Melanie"). */
|
|
98
|
+
about?: string;
|
|
99
|
+
}
|
|
100
|
+
/** What a kept thing was learned from, and the exact words that show it (a guard checks they are there). */
|
|
101
|
+
export interface MemoryOrigin {
|
|
102
|
+
by: 'user' | 'result';
|
|
103
|
+
evidence: string;
|
|
104
|
+
}
|
|
105
|
+
export declare function fingerprint(...parts: Array<string | readonly string[] | null | undefined>): string;
|
|
106
|
+
/** An append-only log of records by uid: a later record for the same uid replaces the earlier one. What consolidation,
|
|
107
|
+
* sleep and capture read and write, wherever the records are kept. */
|
|
108
|
+
export interface MemoryLog<T extends {
|
|
109
|
+
uid: string;
|
|
110
|
+
}, M> {
|
|
111
|
+
size(): number;
|
|
112
|
+
meta(uid: string): M | undefined;
|
|
113
|
+
entries(): Iterable<[string, M]>;
|
|
114
|
+
load(): Promise<void>;
|
|
115
|
+
get(uid: string): T | null;
|
|
116
|
+
put(recs: T[]): void;
|
|
117
|
+
/** Forget these records: gone from memory, and from the file when it is next compacted. */
|
|
118
|
+
remove(uids: string[]): void;
|
|
119
|
+
/** Every live record, oldest first. */
|
|
120
|
+
all(): Iterable<T>;
|
|
121
|
+
compact(): void;
|
|
122
|
+
/** Resolves once every `put` is kept; rejects with the first write that failed. */
|
|
123
|
+
flush(): Promise<void>;
|
|
124
|
+
}
|
|
125
|
+
/** An append-only JSONL log, read back by byte offset. `meta` is what stays in memory for each record. */
|
|
126
|
+
export declare class JsonlLog<T extends {
|
|
127
|
+
uid: string;
|
|
128
|
+
}, M> implements MemoryLog<T, M> {
|
|
129
|
+
private readonly path;
|
|
130
|
+
private readonly project;
|
|
131
|
+
private readonly live;
|
|
132
|
+
private lines;
|
|
133
|
+
private end;
|
|
134
|
+
constructor(path: string, project: (rec: T) => M);
|
|
135
|
+
size(): number;
|
|
136
|
+
meta(uid: string): M | undefined;
|
|
137
|
+
entries(): Generator<[string, M]>;
|
|
138
|
+
load(): Promise<void>;
|
|
139
|
+
get(uid: string): T | null;
|
|
140
|
+
put(recs: T[]): void;
|
|
141
|
+
remove(uids: string[]): void;
|
|
142
|
+
/** Every live record, in file order. */
|
|
143
|
+
all(): Generator<T>;
|
|
144
|
+
/** A file is written when `put` returns. */
|
|
145
|
+
flush(): Promise<void>;
|
|
146
|
+
/** Rewrite the log without replaced lines once it is twice what is live. */
|
|
147
|
+
compact(): void;
|
|
148
|
+
}
|
|
149
|
+
export type StmMeta = {
|
|
150
|
+
fp: string;
|
|
151
|
+
done: boolean;
|
|
152
|
+
ts: number;
|
|
153
|
+
session: string;
|
|
154
|
+
project: string;
|
|
155
|
+
synthetic: boolean;
|
|
156
|
+
};
|
|
157
|
+
export type LtmMeta = {
|
|
158
|
+
fp: string;
|
|
159
|
+
status: MemoryStatus;
|
|
160
|
+
kind: MemoryKind;
|
|
161
|
+
scope: MemoryScope;
|
|
162
|
+
project: string;
|
|
163
|
+
ts: number;
|
|
164
|
+
importance: number;
|
|
165
|
+
cued: boolean;
|
|
166
|
+
mapped: boolean;
|
|
167
|
+
};
|
|
168
|
+
export declare const stmMetaOf: (r: StmEntry) => StmMeta;
|
|
169
|
+
export declare const ltmMetaOf: (r: MemoryItem) => LtmMeta;
|
|
170
|
+
export declare const stmLog: (path: string) => JsonlLog<StmEntry, StmMeta>;
|
|
171
|
+
export declare const ltmLog: (path: string) => JsonlLog<MemoryItem, LtmMeta>;
|
|
172
|
+
export type StmStore = MemoryLog<StmEntry, StmMeta>;
|
|
173
|
+
export type LtmStore = MemoryLog<MemoryItem, LtmMeta>;
|
|
174
|
+
/** How often each long-term item was shown and opened — what the sleep pass reads as "use". */
|
|
175
|
+
export interface UsageLog {
|
|
176
|
+
of(uid: string): {
|
|
177
|
+
shown: number;
|
|
178
|
+
opened: number;
|
|
179
|
+
lastUsed: number;
|
|
180
|
+
};
|
|
181
|
+
note(uids: string[], what: 'shown' | 'opened', now?: number): void;
|
|
182
|
+
flush(): void | Promise<void>;
|
|
183
|
+
}
|
|
184
|
+
/** How often each long-term item was shown with a turn and opened by an agent. Saved at most once a minute. */
|
|
185
|
+
export declare class UsageBook implements UsageLog {
|
|
186
|
+
private readonly path;
|
|
187
|
+
private data;
|
|
188
|
+
private dirty;
|
|
189
|
+
private timer;
|
|
190
|
+
constructor(path: string);
|
|
191
|
+
of(uid: string): {
|
|
192
|
+
shown: number;
|
|
193
|
+
opened: number;
|
|
194
|
+
lastUsed: number;
|
|
195
|
+
};
|
|
196
|
+
note(uids: string[], what: 'shown' | 'opened', now?: number): void;
|
|
197
|
+
flush(): void;
|
|
198
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a memory holds, on disk, as two append-only logs in its home:
|
|
3
|
+
*
|
|
4
|
+
* - `records.jsonl` — SHORT-TERM memory: every record as it was added (records.ts), kept for good; also the queue the
|
|
5
|
+
* consolidation worker reads (`done`);
|
|
6
|
+
* - `knowledge.jsonl` — LONG-TERM memory: what consolidation kept (consolidate.ts) — typed items, dated observations,
|
|
7
|
+
* kept episodes, profiles — with their keys, status (active / superseded / archived) and provenance.
|
|
8
|
+
*
|
|
9
|
+
* The logs are the truth; Qdrant is derived from them (rebuilt without asking a model, after a Qdrant upgrade or a
|
|
10
|
+
* lost index). In memory: each record's place in its file and a small projection — never the text. A later line for
|
|
11
|
+
* the same uid replaces the earlier one; a log is rewritten without replaced lines once it is twice what is live.
|
|
12
|
+
*
|
|
13
|
+
* `usage.json`: how often each long-term item was shown and opened (the sleep pass's "use", MemRL-style).
|
|
14
|
+
* `recall.jsonl`: what each search showed, for what, and what was opened after (recall-log.ts).
|
|
15
|
+
*
|
|
16
|
+
* The logs are behind `MemoryLog`: these files, or any store you implement (a database table per user). Reads are from what `load()` brought in; `put` writes behind and `flush()`
|
|
17
|
+
* waits for it (a file write is done when `put` returns).
|
|
18
|
+
*/
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { appendFileSync, closeSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { dirname } from 'node:path';
|
|
22
|
+
import { createInterface } from 'node:readline';
|
|
23
|
+
export function fingerprint(...parts) {
|
|
24
|
+
const h = createHash('sha1');
|
|
25
|
+
for (const p of parts)
|
|
26
|
+
h.update(typeof p === 'string' || p == null ? (p ?? '') : p.join('\u0001')).update('\u0000');
|
|
27
|
+
return h.digest('hex').slice(0, 16);
|
|
28
|
+
}
|
|
29
|
+
/** An append-only JSONL log, read back by byte offset. `meta` is what stays in memory for each record. */
|
|
30
|
+
export class JsonlLog {
|
|
31
|
+
path;
|
|
32
|
+
project;
|
|
33
|
+
live = new Map();
|
|
34
|
+
lines = 0;
|
|
35
|
+
end = 0;
|
|
36
|
+
constructor(path, project) {
|
|
37
|
+
this.path = path;
|
|
38
|
+
this.project = project;
|
|
39
|
+
}
|
|
40
|
+
size() { return this.live.size; }
|
|
41
|
+
meta(uid) { return this.live.get(uid)?.meta; }
|
|
42
|
+
*entries() { for (const [uid, s] of this.live)
|
|
43
|
+
yield [uid, s.meta]; }
|
|
44
|
+
async load() {
|
|
45
|
+
this.live.clear();
|
|
46
|
+
this.lines = 0;
|
|
47
|
+
this.end = 0;
|
|
48
|
+
if (!existsSync(this.path))
|
|
49
|
+
return;
|
|
50
|
+
let at = 0;
|
|
51
|
+
for await (const line of createInterface({ input: createReadStream(this.path, { encoding: 'utf8' }), crlfDelay: Infinity })) {
|
|
52
|
+
const len = Buffer.byteLength(line, 'utf8') + 1;
|
|
53
|
+
if (line) {
|
|
54
|
+
try {
|
|
55
|
+
const rec = JSON.parse(line);
|
|
56
|
+
if (rec.removed)
|
|
57
|
+
this.live.delete(rec.uid);
|
|
58
|
+
else
|
|
59
|
+
this.live.set(rec.uid, { at, len, meta: this.project(rec) });
|
|
60
|
+
this.lines++;
|
|
61
|
+
}
|
|
62
|
+
catch { /* a torn last line */ }
|
|
63
|
+
}
|
|
64
|
+
at += len;
|
|
65
|
+
}
|
|
66
|
+
this.end = at;
|
|
67
|
+
}
|
|
68
|
+
get(uid) {
|
|
69
|
+
const s = this.live.get(uid);
|
|
70
|
+
if (!s)
|
|
71
|
+
return null;
|
|
72
|
+
const fd = openSync(this.path, 'r');
|
|
73
|
+
try {
|
|
74
|
+
const buf = Buffer.alloc(s.len);
|
|
75
|
+
readSync(fd, buf, 0, s.len, s.at);
|
|
76
|
+
return JSON.parse(buf.toString('utf8').trimEnd());
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
closeSync(fd);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
put(recs) {
|
|
86
|
+
if (!recs.length)
|
|
87
|
+
return;
|
|
88
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
89
|
+
if (!this.end && existsSync(this.path))
|
|
90
|
+
this.end = statSync(this.path).size;
|
|
91
|
+
const chunks = [];
|
|
92
|
+
let at = this.end;
|
|
93
|
+
for (const rec of recs) {
|
|
94
|
+
const b = Buffer.from(`${JSON.stringify(rec)}\n`, 'utf8');
|
|
95
|
+
this.live.set(rec.uid, { at, len: b.length, meta: this.project(rec) });
|
|
96
|
+
chunks.push(b);
|
|
97
|
+
at += b.length;
|
|
98
|
+
}
|
|
99
|
+
appendFileSync(this.path, Buffer.concat(chunks), { mode: 0o600 });
|
|
100
|
+
this.end = at;
|
|
101
|
+
this.lines += recs.length;
|
|
102
|
+
}
|
|
103
|
+
remove(uids) {
|
|
104
|
+
const gone = uids.filter((u) => this.live.has(u));
|
|
105
|
+
if (!gone.length)
|
|
106
|
+
return;
|
|
107
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
108
|
+
const b = Buffer.from(gone.map((uid) => `${JSON.stringify({ uid, removed: true })}\n`).join(''), 'utf8');
|
|
109
|
+
appendFileSync(this.path, b, { mode: 0o600 });
|
|
110
|
+
if (!this.end)
|
|
111
|
+
this.end = statSync(this.path).size - b.length;
|
|
112
|
+
this.end += b.length;
|
|
113
|
+
this.lines += gone.length;
|
|
114
|
+
for (const uid of gone)
|
|
115
|
+
this.live.delete(uid);
|
|
116
|
+
}
|
|
117
|
+
/** Every live record, in file order. */
|
|
118
|
+
*all() {
|
|
119
|
+
const order = [...this.live.entries()].sort((a, b) => a[1].at - b[1].at);
|
|
120
|
+
for (const [uid] of order) {
|
|
121
|
+
const r = this.get(uid);
|
|
122
|
+
if (r)
|
|
123
|
+
yield r;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** A file is written when `put` returns. */
|
|
127
|
+
async flush() { }
|
|
128
|
+
/** Rewrite the log without replaced lines once it is twice what is live. */
|
|
129
|
+
compact() {
|
|
130
|
+
if (this.lines < 2 * this.live.size + 1000)
|
|
131
|
+
return;
|
|
132
|
+
const recs = [...this.all()];
|
|
133
|
+
const tmp = `${this.path}.compact`;
|
|
134
|
+
writeFileSync(tmp, recs.map((r) => `${JSON.stringify(r)}\n`).join(''), { mode: 0o600 });
|
|
135
|
+
renameSync(tmp, this.path);
|
|
136
|
+
// Offsets again, from the file just written.
|
|
137
|
+
this.live.clear();
|
|
138
|
+
let at = 0;
|
|
139
|
+
for (const r of recs) {
|
|
140
|
+
const len = Buffer.byteLength(`${JSON.stringify(r)}\n`, 'utf8');
|
|
141
|
+
this.live.set(r.uid, { at, len, meta: this.project(r) });
|
|
142
|
+
at += len;
|
|
143
|
+
}
|
|
144
|
+
this.end = at;
|
|
145
|
+
this.lines = recs.length;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export const stmMetaOf = (r) => ({ fp: r.fingerprint, done: r.done, ts: r.ts, session: r.session, project: r.project, synthetic: r.synthetic });
|
|
149
|
+
export const ltmMetaOf = (r) => ({ fp: r.fingerprint, status: r.status, kind: r.kind, scope: r.scope, project: r.project, ts: r.ts, importance: r.importance, cued: !!r.cues?.length, mapped: !!r.concepts?.length });
|
|
150
|
+
export const stmLog = (path) => new JsonlLog(path, stmMetaOf);
|
|
151
|
+
export const ltmLog = (path) => new JsonlLog(path, ltmMetaOf);
|
|
152
|
+
/** How often each long-term item was shown with a turn and opened by an agent. Saved at most once a minute. */
|
|
153
|
+
export class UsageBook {
|
|
154
|
+
path;
|
|
155
|
+
data = {};
|
|
156
|
+
dirty = false;
|
|
157
|
+
timer = null;
|
|
158
|
+
constructor(path) {
|
|
159
|
+
this.path = path;
|
|
160
|
+
try {
|
|
161
|
+
this.data = JSON.parse(readFileSync(path, 'utf8'));
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
this.data = {};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
of(uid) {
|
|
168
|
+
return this.data[uid] ?? { shown: 0, opened: 0, lastUsed: 0 };
|
|
169
|
+
}
|
|
170
|
+
note(uids, what, now = Date.now()) {
|
|
171
|
+
for (const uid of uids) {
|
|
172
|
+
const u = (this.data[uid] ??= { shown: 0, opened: 0, lastUsed: 0 });
|
|
173
|
+
u[what] += 1;
|
|
174
|
+
u.lastUsed = now;
|
|
175
|
+
}
|
|
176
|
+
this.dirty = true;
|
|
177
|
+
this.timer ??= setTimeout(() => { this.timer = null; this.flush(); }, 60_000);
|
|
178
|
+
this.timer.unref?.();
|
|
179
|
+
}
|
|
180
|
+
flush() {
|
|
181
|
+
if (!this.dirty)
|
|
182
|
+
return;
|
|
183
|
+
try {
|
|
184
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
185
|
+
writeFileSync(this.path, JSON.stringify(this.data), { mode: 0o600 });
|
|
186
|
+
this.dirty = false;
|
|
187
|
+
}
|
|
188
|
+
catch { /* read-only: kept in memory */ }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What gets indexed from an item's text, besides its content key: the keyword vector (BM25 as a Qdrant sparse vector —
|
|
3
|
+
* term frequencies here, the IDF on Qdrant's side with `modifier: idf`) and the small-to-big children.
|
|
4
|
+
*
|
|
5
|
+
* Measured on a private history of real work: the keyword channel beside the dense one put a needed
|
|
6
|
+
* item in the top 5 for +5–8 points; ~96-word children beat 192-word ones; adding raw text to the key itself hurt.
|
|
7
|
+
*/
|
|
8
|
+
import type { SparseVector } from './qdrant-client.js';
|
|
9
|
+
/** Lower case, compatibility forms unified, accents dropped from Latin, Greek and Cyrillic letters — "bază" and
|
|
10
|
+
* "baza" are the same word to a person typing either. Marks that make the word elsewhere (Devanagari, Thai) stay. */
|
|
11
|
+
export declare function fold(text: string): string;
|
|
12
|
+
/** Words for the keyword channel, in any language: folded (fold()); identifiers kept whole AND split at `_ . - /`; in
|
|
13
|
+
* scripts without spaces (Chinese, Japanese, Thai…) the segmenter's words. */
|
|
14
|
+
export declare function tokens(text: string): string[];
|
|
15
|
+
/** A text's first `n` words, as written (whitespace flattened). */
|
|
16
|
+
export declare function firstWords(text: string, n: number): string;
|
|
17
|
+
/** 32-bit FNV-1a: a term's index in the sparse vector. */
|
|
18
|
+
export declare function termId(term: string): number;
|
|
19
|
+
/** A document's BM25 term weights (Qdrant applies the IDF). `avgLen`: the typical length of what is indexed. */
|
|
20
|
+
export declare function docSparse(text: string, avgLen?: number): SparseVector;
|
|
21
|
+
/** A query's terms, each once. */
|
|
22
|
+
export declare function querySparse(texts: readonly string[]): SparseVector;
|
|
23
|
+
export declare const CHILD_WORDS = 96;
|
|
24
|
+
export declare const CHILD_STRIDE = 72;
|
|
25
|
+
/** ~96-word windows of a body, a quarter overlapping, all of it — searched, never shown: the item is what comes back.
|
|
26
|
+
* Words as the segmenter finds them, so a body in a script without spaces is windowed too. */
|
|
27
|
+
export declare function children(text: string, words?: number, stride?: number): string[];
|
|
28
|
+
export declare const cut: (s: string, n: number) => string;
|
|
29
|
+
/** One spelling per concept, the graph's key: folded like a word (case, accents, compatibility forms), inner spaces
|
|
30
|
+
* collapsed, stray punctuation off the ends — "Gateway.conf " and "gateway.conf" are one node. */
|
|
31
|
+
export declare function conceptKey(name: string): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const K1 = 1.2;
|
|
2
|
+
const B = 0.75;
|
|
3
|
+
/** Scripts written without spaces between words: there the ICU word segmenter finds the words. */
|
|
4
|
+
const UNSPACED = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Thai}\p{Script=Lao}\p{Script=Khmer}\p{Script=Myanmar}]/u;
|
|
5
|
+
const WORDS = new Intl.Segmenter(undefined, { granularity: 'word' });
|
|
6
|
+
/** Lower case, compatibility forms unified, accents dropped from Latin, Greek and Cyrillic letters — "bază" and
|
|
7
|
+
* "baza" are the same word to a person typing either. Marks that make the word elsewhere (Devanagari, Thai) stay. */
|
|
8
|
+
export function fold(text) {
|
|
9
|
+
return text.normalize('NFKD').replace(/([\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}])\p{M}+/gu, '$1').normalize('NFKC').toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
/** Words for the keyword channel, in any language: folded (fold()); identifiers kept whole AND split at `_ . - /`; in
|
|
12
|
+
* scripts without spaces (Chinese, Japanese, Thai…) the segmenter's words. */
|
|
13
|
+
export function tokens(text) {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const raw of fold(text).split(/[^\p{L}\p{N}\p{M}_.\-/]+/u)) {
|
|
16
|
+
const w = raw.replace(/^[._\-/]+|[._\-/]+$/g, '');
|
|
17
|
+
if (!w)
|
|
18
|
+
continue;
|
|
19
|
+
if (UNSPACED.test(w)) {
|
|
20
|
+
for (const seg of WORDS.segment(w))
|
|
21
|
+
if (seg.isWordLike)
|
|
22
|
+
out.push(seg.segment);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (w.length < 2 || w.length > 60)
|
|
26
|
+
continue;
|
|
27
|
+
out.push(w);
|
|
28
|
+
if (/[_.\-/]/.test(w))
|
|
29
|
+
for (const p of w.split(/[_.\-/]+/))
|
|
30
|
+
if (p.length >= 2 && p !== w)
|
|
31
|
+
out.push(p);
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** Where each word of a text starts and ends — the segmenter's words, so a text without spaces counts right. */
|
|
36
|
+
function wordSpans(text) {
|
|
37
|
+
const at = [];
|
|
38
|
+
for (const seg of WORDS.segment(text))
|
|
39
|
+
if (seg.isWordLike)
|
|
40
|
+
at.push([seg.index, seg.index + seg.segment.length]);
|
|
41
|
+
return at;
|
|
42
|
+
}
|
|
43
|
+
const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
44
|
+
/** A text's first `n` words, as written (whitespace flattened). */
|
|
45
|
+
export function firstWords(text, n) {
|
|
46
|
+
const at = wordSpans(text);
|
|
47
|
+
return flat(at.length > n ? text.slice(0, at[n - 1][1]) : text);
|
|
48
|
+
}
|
|
49
|
+
/** 32-bit FNV-1a: a term's index in the sparse vector. */
|
|
50
|
+
export function termId(term) {
|
|
51
|
+
let h = 0x811c9dc5;
|
|
52
|
+
for (let i = 0; i < term.length; i++) {
|
|
53
|
+
h ^= term.charCodeAt(i);
|
|
54
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
55
|
+
}
|
|
56
|
+
return h >>> 0;
|
|
57
|
+
}
|
|
58
|
+
/** A document's BM25 term weights (Qdrant applies the IDF). `avgLen`: the typical length of what is indexed. */
|
|
59
|
+
export function docSparse(text, avgLen = 220) {
|
|
60
|
+
const ts = tokens(text);
|
|
61
|
+
const tf = new Map();
|
|
62
|
+
for (const t of ts) {
|
|
63
|
+
const id = termId(t);
|
|
64
|
+
tf.set(id, (tf.get(id) ?? 0) + 1);
|
|
65
|
+
}
|
|
66
|
+
const norm = K1 * (1 - B + (B * ts.length) / avgLen);
|
|
67
|
+
const indices = [...tf.keys()].sort((a, b) => a - b);
|
|
68
|
+
return { indices, values: indices.map((i) => { const f = tf.get(i); return (f * (K1 + 1)) / (f + norm); }) };
|
|
69
|
+
}
|
|
70
|
+
/** A query's terms, each once. */
|
|
71
|
+
export function querySparse(texts) {
|
|
72
|
+
const ids = [...new Set(texts.flatMap((t) => tokens(t)).map(termId))].sort((a, b) => a - b);
|
|
73
|
+
return { indices: ids, values: ids.map(() => 1) };
|
|
74
|
+
}
|
|
75
|
+
export const CHILD_WORDS = 96;
|
|
76
|
+
export const CHILD_STRIDE = 72;
|
|
77
|
+
/** ~96-word windows of a body, a quarter overlapping, all of it — searched, never shown: the item is what comes back.
|
|
78
|
+
* Words as the segmenter finds them, so a body in a script without spaces is windowed too. */
|
|
79
|
+
export function children(text, words = CHILD_WORDS, stride = CHILD_STRIDE) {
|
|
80
|
+
const at = wordSpans(text);
|
|
81
|
+
if (!at.length)
|
|
82
|
+
return flat(text) ? [flat(text)] : [];
|
|
83
|
+
if (at.length <= words)
|
|
84
|
+
return [flat(text)];
|
|
85
|
+
const out = [];
|
|
86
|
+
let end = 0;
|
|
87
|
+
for (let i = 0; i < at.length - Math.floor(words / 2); i += stride) {
|
|
88
|
+
end = Math.min(i + words, at.length);
|
|
89
|
+
out.push(flat(text.slice(at[i][0], at[end - 1][1])));
|
|
90
|
+
}
|
|
91
|
+
// The last words, when the stride stopped short of them: a window ending at the text's end.
|
|
92
|
+
if (end < at.length)
|
|
93
|
+
out.push(flat(text.slice(at[Math.max(0, at.length - words)][0], at[at.length - 1][1])));
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
export const cut = (s, n) => (s.length > n ? `${s.slice(0, n)}…` : s);
|
|
97
|
+
/** One spelling per concept, the graph's key: folded like a word (case, accents, compatibility forms), inner spaces
|
|
98
|
+
* collapsed, stray punctuation off the ends — "Gateway.conf " and "gateway.conf" are one node. */
|
|
99
|
+
export function conceptKey(name) {
|
|
100
|
+
return fold(name).replace(/\s+/g, ' ').replace(/^[\s.,:;"'`()[\]]+|[\s.,:;"'`()[\]]+$/g, '');
|
|
101
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Words kept of a memory (64 lost most of the gain). */
|
|
2
|
+
export declare const WORD_LIMIT = 128;
|
|
3
|
+
/** Unit vectors, one a word, flat: `words × dims` numbers. */
|
|
4
|
+
export interface WordVectors {
|
|
5
|
+
words: number;
|
|
6
|
+
dims: number;
|
|
7
|
+
data: Float32Array;
|
|
8
|
+
}
|
|
9
|
+
export type WordEncoder = (texts: string[], kind: 'query' | 'document') => Promise<WordVectors[]>;
|
|
10
|
+
/** A memory's words as signs, one bit a number, a word to a whole number of bytes. */
|
|
11
|
+
export interface PackedWords {
|
|
12
|
+
words: number;
|
|
13
|
+
dims: number;
|
|
14
|
+
bits: Uint8Array;
|
|
15
|
+
}
|
|
16
|
+
export declare function packWords(v: WordVectors, limit?: number): PackedWords;
|
|
17
|
+
/** The sum, over the question's words, of the closest word of the memory. */
|
|
18
|
+
export declare function maxSim(query: WordVectors, memory: PackedWords): number;
|
|
19
|
+
/** The packed words by text, in memory and appended to a file: key, word count, numbers a word, the bits. Derived, like
|
|
20
|
+
* the index: losing it only means encoding again. */
|
|
21
|
+
export declare class WordStore {
|
|
22
|
+
private readonly file?;
|
|
23
|
+
private readonly byKey;
|
|
24
|
+
constructor(file?: string | undefined);
|
|
25
|
+
get size(): number;
|
|
26
|
+
get(text: string): PackedWords | undefined;
|
|
27
|
+
load(): Promise<void>;
|
|
28
|
+
put(entries: Array<[string, PackedWords]>): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
/** A reranker for memory_search (RecallDeps.rerank) that needs no server: what is not stored yet is encoded here, once.
|
|
31
|
+
* Null — the search keeps its own order — when the encoder fails or takes longer than it was given. */
|
|
32
|
+
export declare function wordReranker(encode: WordEncoder, store: WordStore): (query: string, documents: string[], timeoutMs: number) => Promise<number[] | null>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Word by word: memory_search's order with no server. A memory's one vector averages it, so one exact detail in a long
|
|
3
|
+
* memory is drowned out. The embedder has a vector per word before it averages them; a memory scores the sum, over the
|
|
4
|
+
* question's words, of its closest word (MaxSim, as ColBERT does). Over the search's best 30, fused by rank as the
|
|
5
|
+
* reranker is (reranker.ts `rerankedOrder`). No training, no word list, any language.
|
|
6
|
+
*
|
|
7
|
+
* Measured (docs/BENCHMARKS.md): all 2,011 questions, answer shown at 5 50.3 → 53.1 (+80/−24), at 10
|
|
8
|
+
* 57.1 → 58.4 (+67/−40); no refinement of the score beat this plain sum; other languages against English memories gain
|
|
9
|
+
* more (Romanian at 5 43.0 → 48.2). One bit a number keeps nearly all of it, and 128 words are enough where 64 are not.
|
|
10
|
+
*
|
|
11
|
+
* A memory's words are kept small, the question's whole: its cosine with a word stored as signs is the sum of its
|
|
12
|
+
* numbers with those signs, over √dims. Kept by the text's hash, so an unchanged text is never encoded twice.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
16
|
+
import { dirname } from 'node:path';
|
|
17
|
+
/** Words kept of a memory (64 lost most of the gain). */
|
|
18
|
+
export const WORD_LIMIT = 128;
|
|
19
|
+
const KEY_BYTES = 8;
|
|
20
|
+
export function packWords(v, limit = WORD_LIMIT) {
|
|
21
|
+
const words = Math.min(v.words, limit);
|
|
22
|
+
const stride = Math.ceil(v.dims / 8);
|
|
23
|
+
const bits = new Uint8Array(words * stride);
|
|
24
|
+
for (let w = 0; w < words; w++) {
|
|
25
|
+
for (let d = 0; d < v.dims; d++)
|
|
26
|
+
if (v.data[w * v.dims + d] >= 0)
|
|
27
|
+
bits[w * stride + (d >> 3)] |= 1 << (d & 7);
|
|
28
|
+
}
|
|
29
|
+
return { words, dims: v.dims, bits };
|
|
30
|
+
}
|
|
31
|
+
/** The sum, over the question's words, of the closest word of the memory. */
|
|
32
|
+
export function maxSim(query, memory) {
|
|
33
|
+
if (!query.words || !memory.words || query.dims !== memory.dims)
|
|
34
|
+
return Number.NEGATIVE_INFINITY;
|
|
35
|
+
const { dims } = query;
|
|
36
|
+
const stride = Math.ceil(dims / 8);
|
|
37
|
+
const norm = Math.sqrt(dims);
|
|
38
|
+
let total = 0;
|
|
39
|
+
for (let q = 0; q < query.words; q++) {
|
|
40
|
+
const at = q * dims;
|
|
41
|
+
let best = Number.NEGATIVE_INFINITY;
|
|
42
|
+
for (let w = 0; w < memory.words; w++) {
|
|
43
|
+
const row = w * stride;
|
|
44
|
+
let sum = 0;
|
|
45
|
+
for (let d = 0; d < dims; d++) {
|
|
46
|
+
const x = query.data[at + d];
|
|
47
|
+
sum += memory.bits[row + (d >> 3)] & (1 << (d & 7)) ? x : -x;
|
|
48
|
+
}
|
|
49
|
+
if (sum > best)
|
|
50
|
+
best = sum;
|
|
51
|
+
}
|
|
52
|
+
total += best / norm;
|
|
53
|
+
}
|
|
54
|
+
return total;
|
|
55
|
+
}
|
|
56
|
+
const keyOf = (text) => createHash('sha256').update(text).digest('hex').slice(0, KEY_BYTES * 2);
|
|
57
|
+
/** The packed words by text, in memory and appended to a file: key, word count, numbers a word, the bits. Derived, like
|
|
58
|
+
* the index: losing it only means encoding again. */
|
|
59
|
+
export class WordStore {
|
|
60
|
+
file;
|
|
61
|
+
byKey = new Map();
|
|
62
|
+
constructor(file) {
|
|
63
|
+
this.file = file;
|
|
64
|
+
}
|
|
65
|
+
get size() { return this.byKey.size; }
|
|
66
|
+
get(text) { return this.byKey.get(keyOf(text)); }
|
|
67
|
+
async load() {
|
|
68
|
+
if (!this.file)
|
|
69
|
+
return;
|
|
70
|
+
const b = await readFile(this.file).catch(() => null);
|
|
71
|
+
if (!b)
|
|
72
|
+
return;
|
|
73
|
+
for (let at = 0; at + KEY_BYTES + 4 <= b.length;) {
|
|
74
|
+
const key = b.subarray(at, at + KEY_BYTES).toString('hex');
|
|
75
|
+
const words = b.readUInt16LE(at + KEY_BYTES);
|
|
76
|
+
const dims = b.readUInt16LE(at + KEY_BYTES + 2);
|
|
77
|
+
const size = words * Math.ceil(dims / 8);
|
|
78
|
+
const start = at + KEY_BYTES + 4;
|
|
79
|
+
if (start + size > b.length)
|
|
80
|
+
break; // cut short by a crash: what came before is whole
|
|
81
|
+
this.byKey.set(key, { words, dims, bits: new Uint8Array(b.subarray(start, start + size)) });
|
|
82
|
+
at = start + size;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async put(entries) {
|
|
86
|
+
const parts = [];
|
|
87
|
+
for (const [text, p] of entries) {
|
|
88
|
+
const key = keyOf(text);
|
|
89
|
+
this.byKey.set(key, p);
|
|
90
|
+
const head = Buffer.alloc(KEY_BYTES + 4);
|
|
91
|
+
Buffer.from(key, 'hex').copy(head);
|
|
92
|
+
head.writeUInt16LE(p.words, KEY_BYTES);
|
|
93
|
+
head.writeUInt16LE(p.dims, KEY_BYTES + 2);
|
|
94
|
+
parts.push(head, Buffer.from(p.bits));
|
|
95
|
+
}
|
|
96
|
+
if (!this.file || !parts.length)
|
|
97
|
+
return;
|
|
98
|
+
await mkdir(dirname(this.file), { recursive: true });
|
|
99
|
+
await appendFile(this.file, Buffer.concat(parts));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** A reranker for memory_search (RecallDeps.rerank) that needs no server: what is not stored yet is encoded here, once.
|
|
103
|
+
* Null — the search keeps its own order — when the encoder fails or takes longer than it was given. */
|
|
104
|
+
export function wordReranker(encode, store) {
|
|
105
|
+
return async (query, documents, timeoutMs) => {
|
|
106
|
+
let timer;
|
|
107
|
+
const late = new Promise((resolve) => { timer = setTimeout(() => resolve(null), timeoutMs); });
|
|
108
|
+
const work = (async () => {
|
|
109
|
+
const missing = [...new Set(documents.filter((t) => !store.get(t)))];
|
|
110
|
+
if (missing.length) {
|
|
111
|
+
const encoded = await encode(missing, 'document');
|
|
112
|
+
await store.put(missing.map((t, i) => [t, packWords(encoded[i])]));
|
|
113
|
+
}
|
|
114
|
+
const [q] = await encode([query], 'query');
|
|
115
|
+
return documents.map((t) => maxSim(q, store.get(t)));
|
|
116
|
+
})();
|
|
117
|
+
try {
|
|
118
|
+
return await Promise.race([work, late]);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
clearTimeout(timer);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|