memtrails 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +56 -0
- package/README.md +25 -9
- package/dist/core/consolidate.d.ts +24 -9
- package/dist/core/consolidate.js +101 -49
- package/dist/core/episodes.d.ts +1 -1
- package/dist/core/episodes.js +2 -8
- package/dist/core/extract.d.ts +3 -0
- package/dist/core/extract.js +12 -5
- package/dist/core/gather.d.ts +3 -0
- package/dist/core/gather.js +16 -10
- package/dist/core/model.d.ts +9 -4
- package/dist/core/model.js +39 -40
- package/dist/core/profiles.d.ts +1 -1
- package/dist/core/profiles.js +12 -9
- package/dist/core/qdrant-binary.d.ts +52 -5
- package/dist/core/qdrant-binary.js +197 -48
- package/dist/core/qdrant-client.d.ts +6 -21
- package/dist/core/qdrant-client.js +36 -100
- package/dist/core/qdrant-process.d.ts +30 -2
- package/dist/core/qdrant-process.js +217 -27
- package/dist/core/rebuild.d.ts +5 -1
- package/dist/core/rebuild.js +23 -5
- package/dist/core/recall-log.d.ts +3 -0
- package/dist/core/recall-log.js +6 -1
- package/dist/core/recall.js +19 -8
- package/dist/core/redact.d.ts +8 -0
- package/dist/core/redact.js +48 -9
- package/dist/core/sleep.d.ts +4 -3
- package/dist/core/sleep.js +27 -16
- package/dist/core/store.d.ts +7 -1
- package/dist/core/store.js +101 -33
- package/dist/core/text.d.ts +6 -2
- package/dist/core/text.js +19 -6
- package/dist/core/words.js +6 -2
- package/dist/core/writer.d.ts +2 -1
- package/dist/core/writer.js +9 -3
- package/dist/importers/files.js +96 -15
- package/dist/importers/git.js +3 -1
- package/dist/importers/jsonl.d.ts +6 -0
- package/dist/importers/jsonl.js +10 -5
- package/dist/importers/lines.d.ts +13 -0
- package/dist/importers/lines.js +70 -0
- package/dist/importers/mbox.js +32 -12
- package/dist/importers/slack.js +5 -2
- package/dist/importers/text.js +85 -14
- package/dist/llm.d.ts +10 -8
- package/dist/llm.js +53 -7
- package/dist/memory.d.ts +7 -2
- package/dist/memory.js +284 -57
- package/dist/records.js +6 -3
- package/dist/tools/agent-tools.d.ts +2 -5
- package/dist/tools/agent-tools.js +37 -21
- package/package.json +1 -1
package/dist/core/redact.js
CHANGED
|
@@ -2,41 +2,80 @@
|
|
|
2
2
|
* Secrets are scrubbed from what memory sends to a language model and from what consolidation keeps: provider keys,
|
|
3
3
|
* tokens, credentials in URLs, private keys, e-mail local parts, JWTs, `key=value` secrets; home directories become `~`;
|
|
4
4
|
* a long high-entropy run last. The records you add are kept as you wrote them.
|
|
5
|
+
*
|
|
6
|
+
* The input can be anything a record held, so every pattern is bounded: no rule may take more than linear time on a
|
|
7
|
+
* hostile string (a long run of what almost matches).
|
|
5
8
|
*/
|
|
9
|
+
/**
|
|
10
|
+
* A name that says its value is a secret: `password`, `GITHUB_TOKEN`, `AWS_SECRET_ACCESS_KEY`, `PGPASSWORD`,
|
|
11
|
+
* `client_secret`, `AccountKey`. The secret word must end the name (`token_count`, `max_tokens` are not secrets), and
|
|
12
|
+
* any prefix is allowed (`_` is a word character, so `\btoken` alone misses `GITHUB_TOKEN`).
|
|
13
|
+
*/
|
|
14
|
+
const SECRET_NAME = String.raw `[A-Za-z0-9_.-]{0,64}?(?:api[_-]?key|apikey|(?:access|secret|private|account|signing|encryption|master|client)[_-]?key|token|secret|password|passwd|passphrase|authorization)`;
|
|
15
|
+
/** `name=value`, `name: value`, `"name": "value"`, `Authorization: token X` (the scheme word is kept, its credential not). */
|
|
16
|
+
const KEY_VALUE = new RegExp(String.raw `\b(${SECRET_NAME})(["']?[ \t]{0,16}[=:][ \t]{0,16})(?:(["'])((?:[^"'\\\n]|\\.){1,512})|((?:(?:Bearer|Basic|Digest|token|Bot)[ \t]{1,16})?)([^\s&"';]+))`, 'gi');
|
|
6
17
|
const SECRET_PATTERNS = [
|
|
7
18
|
[/\bsk-[A-Za-z0-9_-]{16,}/g, '<redacted:sk>'],
|
|
8
19
|
[/\bsk_(?:live|test)_[A-Za-z0-9]{16,}/g, '<redacted:stripe>'],
|
|
9
20
|
[/\bpk_(?:live|test)_[A-Za-z0-9]{16,}/g, '<redacted:stripe-pub>'],
|
|
10
21
|
[/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '<redacted:github>'],
|
|
11
22
|
[/\bgh[porsu]_[A-Za-z0-9]{20,}/g, '<redacted:github>'],
|
|
23
|
+
[/\bglpat-[A-Za-z0-9_-]{20,}/g, '<redacted:gitlab>'],
|
|
24
|
+
[/\bhf_[A-Za-z0-9]{30,}/g, '<redacted:huggingface>'],
|
|
25
|
+
[/\bnpm_[A-Za-z0-9]{36,}/g, '<redacted:npm>'],
|
|
12
26
|
[/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '<redacted:slack>'],
|
|
13
|
-
[/\
|
|
27
|
+
[/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, '<redacted:aws>'],
|
|
14
28
|
[/\bAIza[A-Za-z0-9_-]{20,}/g, '<redacted:google>'],
|
|
15
|
-
[/\
|
|
29
|
+
[/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g, '<redacted:sendgrid>'],
|
|
30
|
+
// A Telegram bot token: the bot's number, then 35 characters.
|
|
31
|
+
[/\b\d{8,10}:[A-Za-z0-9_-]{35}(?![A-Za-z0-9_-])/g, '<redacted:telegram>'],
|
|
32
|
+
// `scheme://user:password@host`: the password runs to the LAST `@` before the host, so `p@ss` and `pa/ss` go whole.
|
|
33
|
+
[/\b([a-z][a-z0-9+.-]{0,31}:\/\/)([^\s/:@]{0,256}):([^\s]{1,256})@(?=[^\s@/?#]{0,253}(?:[/?#\s]|$))/gi, '$1$2:<redacted>@'],
|
|
16
34
|
[/https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9/]+/g, 'https://hooks.slack.com/services/<redacted>'],
|
|
17
35
|
[/https:\/\/(?:canary\.|ptb\.)?discord(?:app)?\.com\/api\/webhooks\/[0-9]+\/[A-Za-z0-9_-]+/g, '<redacted:discord-webhook>'],
|
|
18
|
-
|
|
19
|
-
[/\b[A-Za-z0-9._%+-]
|
|
20
|
-
|
|
21
|
-
[/\
|
|
22
|
-
[
|
|
23
|
-
[
|
|
36
|
+
// Local part and domain bounded as RFC 5321 bounds them: unbounded, a long run of `a.a.a…` without an `@` is quadratic.
|
|
37
|
+
[/\b[A-Za-z0-9._%+-]{1,64}@([A-Za-z0-9.-]{1,253}\.[A-Za-z]{2,63})\b/g, '<redacted:email>@$1'],
|
|
38
|
+
// Bounded: every `eyJ` in one long run is a start that scans it again. A longer JWT is caught as entropy below.
|
|
39
|
+
[/\beyJ[A-Za-z0-9_-]{8,1024}\.[A-Za-z0-9_-]{8,8192}\.[A-Za-z0-9_-]{8,}/g, '<redacted:jwt>'],
|
|
40
|
+
[/\b(Bearer|Basic)\s{1,16}[A-Za-z0-9._~+/=-]{12,}/gi, '$1 <redacted>'],
|
|
41
|
+
[/([?&](?:token|key|api_key|apikey|secret|password|access_token|sig)=)[^&\s"']+/gi, '$1<redacted>'],
|
|
24
42
|
];
|
|
25
43
|
const HOME_DIR_PATTERNS = [
|
|
26
44
|
[/\/Users\/[^/\s"')\]]+/g, '~'],
|
|
27
45
|
[/\/home\/[^/\s"')\]]+/g, '~'],
|
|
28
46
|
[/[A-Za-z]:\\Users\\[^\\\s"')\]]+/gi, '~'],
|
|
29
47
|
];
|
|
30
|
-
/** Conservative on purpose (48+): digests and minified names are shorter. */
|
|
48
|
+
/** Conservative on purpose (48+): digests and minified names are shorter, and a git sha (40 hex) stays. */
|
|
31
49
|
const HIGH_ENTROPY = /\b[A-Za-z0-9_-]{48,}\b/g;
|
|
50
|
+
const PEM_BEGIN = /-----BEGIN [A-Z ]{0,40}PRIVATE KEY-----/g;
|
|
51
|
+
const PEM_END = /-----END [A-Z ]{0,40}PRIVATE KEY-----/g;
|
|
52
|
+
/** Every PEM private key; one left unterminated is cut to the end. A scan, not `BEGIN[\s\S]*?END`: many BEGINs without an END made that quadratic. */
|
|
53
|
+
function dropPrivateKeys(s) {
|
|
54
|
+
if (!s.includes('PRIVATE KEY-----'))
|
|
55
|
+
return s;
|
|
56
|
+
let out = '';
|
|
57
|
+
let from = 0;
|
|
58
|
+
PEM_BEGIN.lastIndex = 0;
|
|
59
|
+
for (let m = PEM_BEGIN.exec(s); m; m = PEM_BEGIN.exec(s)) {
|
|
60
|
+
PEM_END.lastIndex = m.index + m[0].length;
|
|
61
|
+
const end = PEM_END.exec(s);
|
|
62
|
+
out += `${s.slice(from, m.index)}<redacted:private-key>`;
|
|
63
|
+
if (!end)
|
|
64
|
+
return out;
|
|
65
|
+
from = PEM_BEGIN.lastIndex = end.index + end[0].length;
|
|
66
|
+
}
|
|
67
|
+
return out + s.slice(from);
|
|
68
|
+
}
|
|
32
69
|
/** Scrub one string. Never throws; returns `''` for a non-string. */
|
|
33
70
|
export function redactString(input) {
|
|
34
71
|
if (typeof input !== 'string' || input.length === 0)
|
|
35
72
|
return '';
|
|
36
73
|
let out = input;
|
|
37
74
|
try {
|
|
75
|
+
out = dropPrivateKeys(out);
|
|
38
76
|
for (const [re, replacement] of SECRET_PATTERNS)
|
|
39
77
|
out = out.replace(re, replacement);
|
|
78
|
+
out = out.replace(KEY_VALUE, (_w, name, sep, quote, _q, scheme) => quote ? `${name}${sep}${quote}<redacted>` : `${name}${sep}${scheme ?? ''}<redacted>`);
|
|
40
79
|
for (const [re, replacement] of HOME_DIR_PATTERNS)
|
|
41
80
|
out = out.replace(re, replacement);
|
|
42
81
|
out = out.replace(HIGH_ENTROPY, '<redacted:entropy>');
|
package/dist/core/sleep.d.ts
CHANGED
|
@@ -19,11 +19,9 @@ export interface SleepDeps extends ConsolidateDeps {
|
|
|
19
19
|
last: number;
|
|
20
20
|
} & SleepReport): Promise<void>;
|
|
21
21
|
};
|
|
22
|
-
/** A backfill's night: fresh items looked at this many at once (1), relate asks in flight (RELATE_IN_FLIGHT)
|
|
23
|
-
* pairs a night asks about (PAIRS_PER_SLEEP). */
|
|
22
|
+
/** A backfill's night: fresh items looked at this many at once (1), and relate asks in flight (RELATE_IN_FLIGHT). */
|
|
24
23
|
scanAtOnce?: number;
|
|
25
24
|
relateInFlight?: number;
|
|
26
|
-
pairsPerSleep?: number;
|
|
27
25
|
/** Answers applied at once (1, in order). */
|
|
28
26
|
applyAtOnce?: number;
|
|
29
27
|
}
|
|
@@ -35,6 +33,9 @@ export interface SleepReport {
|
|
|
35
33
|
archived: number;
|
|
36
34
|
profiled: number;
|
|
37
35
|
unprofiled: number;
|
|
36
|
+
/** Set when the language model did not answer (none configured, unreachable, busy): why. What it was to read waits for
|
|
37
|
+
* the next night. */
|
|
38
|
+
paused?: string;
|
|
38
39
|
}
|
|
39
40
|
export declare class Sleep {
|
|
40
41
|
private readonly d;
|
package/dist/core/sleep.js
CHANGED
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
18
|
import { dirname } from 'node:path';
|
|
19
|
-
import { Consolidator, RELATED, relateBatches, SAME } from './consolidate.js';
|
|
19
|
+
import { Consolidator, learnedAt, RELATED, relateBatches, SAME } from './consolidate.js';
|
|
20
20
|
import { docText } from './embedder.js';
|
|
21
|
+
import { allOrAbort } from '../llm.js';
|
|
21
22
|
import { pointId } from './qdrant-client.js';
|
|
22
23
|
import { writeProfiles } from './profiles.js';
|
|
23
24
|
import { writeLtm } from './writer.js';
|
|
@@ -61,11 +62,13 @@ export class Sleep {
|
|
|
61
62
|
// Observations are add-only (consolidate.ts observe): two events are never one, and an older one stays, dated. A
|
|
62
63
|
// profile is written again from its items (4), never related to anything.
|
|
63
64
|
const knowledge = (m) => m.status === 'active' && m.kind !== 'episode' && m.kind !== 'note' && m.kind !== 'observation' && m.kind !== 'profile';
|
|
64
|
-
|
|
65
|
+
// Learned since: by when it was written, not by its source's time — history imported after a sleep is new to memory.
|
|
66
|
+
const fresh = [...this.d.ltm.entries()].filter(([, m]) => knowledge(m) && learnedAt(m) >= since)
|
|
67
|
+
.sort((a, b) => learnedAt(a[1]) - learnedAt(b[1])).map(([uid]) => uid);
|
|
68
|
+
/** `at`: when the fresh item of the pair was learned — where the next night starts when this pair is not asked. */
|
|
65
69
|
const pairs = [];
|
|
66
70
|
const seen = new Set();
|
|
67
71
|
let resumeAt = null;
|
|
68
|
-
const cap = this.d.pairsPerSleep ?? PAIRS_PER_SLEEP;
|
|
69
72
|
const look = async (it) => {
|
|
70
73
|
// The key vector the index already holds; embedded again only when it is not there.
|
|
71
74
|
const held = (await this.d.client.retrieve([pointId(it.uid, 0)], true))[0]?.vector;
|
|
@@ -84,8 +87,8 @@ export class Sleep {
|
|
|
84
87
|
const group = fresh.slice(i, i + atOnce).map((uid) => this.d.ltm.get(uid)).filter((it) => !!it && it.status === 'active');
|
|
85
88
|
if (!group.length)
|
|
86
89
|
continue;
|
|
87
|
-
if (pairs.length >=
|
|
88
|
-
resumeAt = group[0]
|
|
90
|
+
if (pairs.length >= PAIRS_PER_SLEEP) {
|
|
91
|
+
resumeAt = learnedAt(group[0]);
|
|
89
92
|
break;
|
|
90
93
|
}
|
|
91
94
|
r.looked += group.length;
|
|
@@ -98,7 +101,8 @@ export class Sleep {
|
|
|
98
101
|
continue;
|
|
99
102
|
seen.add(key);
|
|
100
103
|
// The newer of the two is NEW, the older KNOWN: a contradiction retires the older.
|
|
101
|
-
|
|
104
|
+
const at = learnedAt(it);
|
|
105
|
+
pairs.push(it.ts >= known.ts ? { it, known, score: n.score, at } : { it: known, known: it, score: n.score, at });
|
|
102
106
|
}
|
|
103
107
|
}
|
|
104
108
|
}
|
|
@@ -112,13 +116,15 @@ export class Sleep {
|
|
|
112
116
|
const inFlight = Math.max(1, this.d.relateInFlight ?? RELATE_IN_FLIGHT);
|
|
113
117
|
let next = 0;
|
|
114
118
|
let unanswered = false;
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
// A lane that throws stops the others, their asks too (allOrAbort).
|
|
120
|
+
await allOrAbort(Array.from({ length: Math.min(inFlight, batches.length) }, () => async (signal) => {
|
|
121
|
+
while (!unanswered && next < batches.length && !this.d.aborted() && !signal.aborted) {
|
|
117
122
|
const b = batches[next++];
|
|
118
|
-
const got = await worker.relate(b);
|
|
119
|
-
if (!got) {
|
|
123
|
+
const got = await worker.relate(b, signal);
|
|
124
|
+
if (!(got instanceof Map)) {
|
|
120
125
|
unanswered = true;
|
|
121
|
-
|
|
126
|
+
r.paused ??= got.paused;
|
|
127
|
+
resumeAt = Math.min(resumeAt ?? Infinity, ...b.map((p) => p.at));
|
|
122
128
|
return;
|
|
123
129
|
}
|
|
124
130
|
b.forEach((p, n) => { const d = got.get(`p${n + 1}`); if (d)
|
|
@@ -150,8 +156,9 @@ export class Sleep {
|
|
|
150
156
|
r.merged++;
|
|
151
157
|
});
|
|
152
158
|
let at = 0;
|
|
153
|
-
|
|
154
|
-
|
|
159
|
+
// An answer that fails to apply stops the other lanes at their next pair.
|
|
160
|
+
await allOrAbort(Array.from({ length: Math.max(1, this.d.applyAtOnce ?? 1) }, () => async (signal) => {
|
|
161
|
+
while (at < pairs.length && !this.d.aborted() && !signal.aborted)
|
|
155
162
|
await apply(pairs[at++]);
|
|
156
163
|
}));
|
|
157
164
|
if (this.d.aborted())
|
|
@@ -162,10 +169,12 @@ export class Sleep {
|
|
|
162
169
|
if (m.status !== 'active' || m.kind === 'note' || !HALF_LIFE[m.kind])
|
|
163
170
|
continue;
|
|
164
171
|
const use = this.d.usage.of(uid);
|
|
165
|
-
|
|
172
|
+
// Unused for half a year since it was learned, not since its source was written: imported history is not archived
|
|
173
|
+
// the night it arrives.
|
|
174
|
+
if (priority(m, use, now) >= ARCHIVE_BELOW || now - Math.max(m.ts, learnedAt(m), use.lastUsed) < ARCHIVE_UNUSED_MS)
|
|
166
175
|
continue;
|
|
167
176
|
const it = this.d.ltm.get(uid);
|
|
168
|
-
if (it)
|
|
177
|
+
if (it && now - learnedAt(it) >= ARCHIVE_UNUSED_MS)
|
|
169
178
|
retire.push({ ...it, status: 'archived' });
|
|
170
179
|
}
|
|
171
180
|
for (let i = 0; i < retire.length && !this.d.aborted(); i += 50) {
|
|
@@ -180,8 +189,10 @@ export class Sleep {
|
|
|
180
189
|
const p = await writeProfiles(this.d);
|
|
181
190
|
r.profiled = p.profiled;
|
|
182
191
|
r.unprofiled = p.unprofiled;
|
|
183
|
-
if (p.paused)
|
|
192
|
+
if (p.paused) {
|
|
193
|
+
r.paused ??= p.paused;
|
|
184
194
|
this.d.log(`memory sleep: profiles wait for the next night (${p.paused})`);
|
|
195
|
+
}
|
|
185
196
|
this.d.ltm.compact();
|
|
186
197
|
// A night that stopped early (the pair cap, the fast model away) starts the next one where it stopped.
|
|
187
198
|
if (this.d.sleepState) {
|
package/dist/core/store.d.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface MemoryItem {
|
|
|
47
47
|
/** Last learned or refreshed. */
|
|
48
48
|
ts: number;
|
|
49
49
|
firstTs: number;
|
|
50
|
+
/** When it was written to long-term memory (`ts` is its source's time); absent in older logs. */
|
|
51
|
+
learnedAt?: number;
|
|
50
52
|
/** The key: a content title and a ~150-word summary (what is embedded). */
|
|
51
53
|
title: string;
|
|
52
54
|
summary: string;
|
|
@@ -137,13 +139,16 @@ export declare class JsonlLog<T extends {
|
|
|
137
139
|
entries(): Generator<[string, M]>;
|
|
138
140
|
load(): Promise<void>;
|
|
139
141
|
get(uid: string): T | null;
|
|
142
|
+
private read;
|
|
140
143
|
put(recs: T[]): void;
|
|
141
144
|
remove(uids: string[]): void;
|
|
142
145
|
/** Every live record, in file order. */
|
|
143
146
|
all(): Generator<T>;
|
|
144
147
|
/** A file is written when `put` returns. */
|
|
145
148
|
flush(): Promise<void>;
|
|
146
|
-
/** Rewrite the log without replaced lines once it is twice what is live.
|
|
149
|
+
/** Rewrite the log without replaced lines once it is twice what is live. Record by record into a file beside it, made
|
|
150
|
+
* durable and then renamed over it: a log of any size, and a crash leaves the old one whole. A record that cannot be
|
|
151
|
+
* read is never dropped by a rewrite: it throws, and the log stays as it is. */
|
|
147
152
|
compact(): void;
|
|
148
153
|
}
|
|
149
154
|
export type StmMeta = {
|
|
@@ -164,6 +169,7 @@ export type LtmMeta = {
|
|
|
164
169
|
importance: number;
|
|
165
170
|
cued: boolean;
|
|
166
171
|
mapped: boolean;
|
|
172
|
+
learnedAt?: number;
|
|
167
173
|
};
|
|
168
174
|
export declare const stmMetaOf: (r: StmEntry) => StmMeta;
|
|
169
175
|
export declare const ltmMetaOf: (r: MemoryItem) => LtmMeta;
|
package/dist/core/store.js
CHANGED
|
@@ -17,15 +17,16 @@
|
|
|
17
17
|
* waits for it (a file write is done when `put` returns).
|
|
18
18
|
*/
|
|
19
19
|
import { createHash } from 'node:crypto';
|
|
20
|
-
import { appendFileSync, closeSync, createReadStream, existsSync, mkdirSync, openSync, readFileSync, readSync, renameSync, statSync, writeFileSync } from 'node:fs';
|
|
20
|
+
import { appendFileSync, closeSync, createReadStream, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, truncateSync, writeFileSync, writeSync } from 'node:fs';
|
|
21
21
|
import { dirname } from 'node:path';
|
|
22
|
-
import { createInterface } from 'node:readline';
|
|
23
22
|
export function fingerprint(...parts) {
|
|
24
23
|
const h = createHash('sha1');
|
|
25
24
|
for (const p of parts)
|
|
26
25
|
h.update(typeof p === 'string' || p == null ? (p ?? '') : p.join('\u0001')).update('\u0000');
|
|
27
26
|
return h.digest('hex').slice(0, 16);
|
|
28
27
|
}
|
|
28
|
+
/** What a compaction holds before it writes. */
|
|
29
|
+
const WRITE_BYTES = 1 << 20;
|
|
29
30
|
/** An append-only JSONL log, read back by byte offset. `meta` is what stays in memory for each record. */
|
|
30
31
|
export class JsonlLog {
|
|
31
32
|
path;
|
|
@@ -47,23 +48,51 @@ export class JsonlLog {
|
|
|
47
48
|
this.end = 0;
|
|
48
49
|
if (!existsSync(this.path))
|
|
49
50
|
return;
|
|
51
|
+
// By bytes, not decoded lines: a torn multi-byte character must not shift every offset after it.
|
|
50
52
|
let at = 0;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (line)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
this.
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
let pending = [];
|
|
54
|
+
const take = (line, len) => {
|
|
55
|
+
if (!line.length)
|
|
56
|
+
return true;
|
|
57
|
+
try {
|
|
58
|
+
const rec = JSON.parse(line.toString('utf8'));
|
|
59
|
+
if (rec.removed)
|
|
60
|
+
this.live.delete(rec.uid);
|
|
61
|
+
else
|
|
62
|
+
this.live.set(rec.uid, { at, len, meta: this.project(rec) });
|
|
63
|
+
this.lines++;
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return false;
|
|
63
68
|
}
|
|
64
|
-
|
|
69
|
+
};
|
|
70
|
+
for await (const chunk of createReadStream(this.path)) {
|
|
71
|
+
let from = 0;
|
|
72
|
+
for (let nl = chunk.indexOf(10); nl >= 0; nl = chunk.indexOf(10, from)) {
|
|
73
|
+
const line = Buffer.concat([...pending, chunk.subarray(from, nl)]);
|
|
74
|
+
pending = [];
|
|
75
|
+
take(line, line.length + 1);
|
|
76
|
+
at += line.length + 1;
|
|
77
|
+
from = nl + 1;
|
|
78
|
+
}
|
|
79
|
+
if (from < chunk.length)
|
|
80
|
+
pending.push(chunk.subarray(from));
|
|
65
81
|
}
|
|
66
|
-
|
|
82
|
+
// A crash cut the last line short of its '\n': whole but for it, it is ended; torn, it goes. Either way what is
|
|
83
|
+
// added next starts on a line of its own, at the offset the index gives it.
|
|
84
|
+
const tail = Buffer.concat(pending);
|
|
85
|
+
if (tail.length) {
|
|
86
|
+
try {
|
|
87
|
+
// Its length without the '\n' it is given: readable whether or not that write lands.
|
|
88
|
+
if (take(tail, tail.length))
|
|
89
|
+
appendFileSync(this.path, '\n');
|
|
90
|
+
else
|
|
91
|
+
truncateSync(this.path, at);
|
|
92
|
+
}
|
|
93
|
+
catch { /* read-only: nothing is added after it either */ }
|
|
94
|
+
}
|
|
95
|
+
this.end = statSync(this.path).size;
|
|
67
96
|
}
|
|
68
97
|
get(uid) {
|
|
69
98
|
const s = this.live.get(uid);
|
|
@@ -71,9 +100,7 @@ export class JsonlLog {
|
|
|
71
100
|
return null;
|
|
72
101
|
const fd = openSync(this.path, 'r');
|
|
73
102
|
try {
|
|
74
|
-
|
|
75
|
-
readSync(fd, buf, 0, s.len, s.at);
|
|
76
|
-
return JSON.parse(buf.toString('utf8').trimEnd());
|
|
103
|
+
return this.read(fd, s);
|
|
77
104
|
}
|
|
78
105
|
catch {
|
|
79
106
|
return null;
|
|
@@ -82,6 +109,11 @@ export class JsonlLog {
|
|
|
82
109
|
closeSync(fd);
|
|
83
110
|
}
|
|
84
111
|
}
|
|
112
|
+
read(fd, s) {
|
|
113
|
+
const buf = Buffer.alloc(s.len);
|
|
114
|
+
readSync(fd, buf, 0, s.len, s.at);
|
|
115
|
+
return JSON.parse(buf.toString('utf8').trimEnd());
|
|
116
|
+
}
|
|
85
117
|
put(recs) {
|
|
86
118
|
if (!recs.length)
|
|
87
119
|
return;
|
|
@@ -125,28 +157,64 @@ export class JsonlLog {
|
|
|
125
157
|
}
|
|
126
158
|
/** A file is written when `put` returns. */
|
|
127
159
|
async flush() { }
|
|
128
|
-
/** Rewrite the log without replaced lines once it is twice what is live.
|
|
160
|
+
/** Rewrite the log without replaced lines once it is twice what is live. Record by record into a file beside it, made
|
|
161
|
+
* durable and then renamed over it: a log of any size, and a crash leaves the old one whole. A record that cannot be
|
|
162
|
+
* read is never dropped by a rewrite: it throws, and the log stays as it is. */
|
|
129
163
|
compact() {
|
|
130
164
|
if (this.lines < 2 * this.live.size + 1000)
|
|
131
165
|
return;
|
|
132
|
-
const
|
|
166
|
+
const order = [...this.live.entries()].sort((a, b) => a[1].at - b[1].at);
|
|
133
167
|
const tmp = `${this.path}.compact`;
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
168
|
+
const next = new Map();
|
|
169
|
+
const src = openSync(this.path, 'r');
|
|
170
|
+
let out = null;
|
|
171
|
+
try {
|
|
172
|
+
out = openSync(tmp, 'w', 0o600);
|
|
173
|
+
let at = 0;
|
|
174
|
+
let parts = [];
|
|
175
|
+
let size = 0;
|
|
176
|
+
const write = () => { writeSync(out, Buffer.concat(parts)); parts = []; size = 0; };
|
|
177
|
+
for (const [uid, s] of order) {
|
|
178
|
+
let rec = null;
|
|
179
|
+
try {
|
|
180
|
+
rec = this.read(src, s);
|
|
181
|
+
}
|
|
182
|
+
catch { /* below */ }
|
|
183
|
+
if (rec?.uid !== uid)
|
|
184
|
+
throw new Error(`${this.path}: record ${uid} cannot be read at ${s.at}; not compacted`);
|
|
185
|
+
const b = Buffer.from(`${JSON.stringify(rec)}\n`, 'utf8');
|
|
186
|
+
next.set(uid, { at, len: b.length, meta: s.meta });
|
|
187
|
+
parts.push(b);
|
|
188
|
+
size += b.length;
|
|
189
|
+
at += b.length;
|
|
190
|
+
if (size >= WRITE_BYTES)
|
|
191
|
+
write();
|
|
192
|
+
}
|
|
193
|
+
if (size)
|
|
194
|
+
write();
|
|
195
|
+
fsyncSync(out);
|
|
196
|
+
closeSync(out);
|
|
197
|
+
out = null;
|
|
198
|
+
renameSync(tmp, this.path);
|
|
199
|
+
this.live.clear();
|
|
200
|
+
for (const [uid, s] of next)
|
|
201
|
+
this.live.set(uid, s);
|
|
202
|
+
this.end = at;
|
|
203
|
+
this.lines = next.size;
|
|
204
|
+
}
|
|
205
|
+
catch (e) {
|
|
206
|
+
if (out !== null)
|
|
207
|
+
closeSync(out);
|
|
208
|
+
rmSync(tmp, { force: true });
|
|
209
|
+
throw e;
|
|
210
|
+
}
|
|
211
|
+
finally {
|
|
212
|
+
closeSync(src);
|
|
143
213
|
}
|
|
144
|
-
this.end = at;
|
|
145
|
-
this.lines = recs.length;
|
|
146
214
|
}
|
|
147
215
|
}
|
|
148
216
|
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 });
|
|
217
|
+
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, learnedAt: r.learnedAt });
|
|
150
218
|
export const stmLog = (path) => new JsonlLog(path, stmMetaOf);
|
|
151
219
|
export const ltmLog = (path) => new JsonlLog(path, ltmMetaOf);
|
|
152
220
|
/** How often each long-term item was shown with a turn and opened by an agent. Saved at most once a minute. */
|
package/dist/core/text.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export declare function fold(text: string): string;
|
|
|
12
12
|
/** Words for the keyword channel, in any language: folded (fold()); identifiers kept whole AND split at `_ . - /`; in
|
|
13
13
|
* scripts without spaces (Chinese, Japanese, Thai…) the segmenter's words. */
|
|
14
14
|
export declare function tokens(text: string): string[];
|
|
15
|
+
/** Whitespace runs as one space, the ends trimmed. */
|
|
16
|
+
export declare const flat: (s: string) => string;
|
|
15
17
|
/** A text's first `n` words, as written (whitespace flattened). */
|
|
16
18
|
export declare function firstWords(text: string, n: number): string;
|
|
17
19
|
/** 32-bit FNV-1a: a term's index in the sparse vector. */
|
|
@@ -25,7 +27,9 @@ export declare const CHILD_STRIDE = 72;
|
|
|
25
27
|
/** ~96-word windows of a body, a quarter overlapping, all of it — searched, never shown: the item is what comes back.
|
|
26
28
|
* Words as the segmenter finds them, so a body in a script without spaces is windowed too. */
|
|
27
29
|
export declare function children(text: string, words?: number, stride?: number): string[];
|
|
28
|
-
|
|
30
|
+
/** The first `n` UTF-16 units and an ellipsis — never ending on half a surrogate pair (an emoji cut in two is not valid
|
|
31
|
+
* UTF-8, and Qdrant refuses the whole write over it). */
|
|
32
|
+
export declare function cut(s: string, n: number): string;
|
|
29
33
|
/** 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 — "
|
|
34
|
+
* collapsed, stray punctuation off the ends — "Proxy.conf " and "proxy.conf" are one node. */
|
|
31
35
|
export declare function conceptKey(name: string): string;
|
package/dist/core/text.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
const K1 = 1.2;
|
|
2
2
|
const B = 0.75;
|
|
3
|
+
/** Longer than this is not a word a person would type (a hash, a base64 blob). */
|
|
4
|
+
const MAX_TOKEN = 60;
|
|
3
5
|
/** Scripts written without spaces between words: there the ICU word segmenter finds the words. */
|
|
4
6
|
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
7
|
const WORDS = new Intl.Segmenter(undefined, { granularity: 'word' });
|
|
@@ -22,12 +24,14 @@ export function tokens(text) {
|
|
|
22
24
|
out.push(seg.segment);
|
|
23
25
|
continue;
|
|
24
26
|
}
|
|
25
|
-
if (w.length < 2
|
|
27
|
+
if (w.length < 2)
|
|
26
28
|
continue;
|
|
27
|
-
|
|
29
|
+
// The length cap is per word: a path too long to be one keyword still gives its parts.
|
|
30
|
+
if (w.length <= MAX_TOKEN)
|
|
31
|
+
out.push(w);
|
|
28
32
|
if (/[_.\-/]/.test(w))
|
|
29
33
|
for (const p of w.split(/[_.\-/]+/))
|
|
30
|
-
if (p.length >= 2 && p !== w)
|
|
34
|
+
if (p.length >= 2 && p.length <= MAX_TOKEN && p !== w)
|
|
31
35
|
out.push(p);
|
|
32
36
|
}
|
|
33
37
|
return out;
|
|
@@ -40,7 +44,8 @@ function wordSpans(text) {
|
|
|
40
44
|
at.push([seg.index, seg.index + seg.segment.length]);
|
|
41
45
|
return at;
|
|
42
46
|
}
|
|
43
|
-
|
|
47
|
+
/** Whitespace runs as one space, the ends trimmed. */
|
|
48
|
+
export const flat = (s) => s.replace(/\s+/g, ' ').trim();
|
|
44
49
|
/** A text's first `n` words, as written (whitespace flattened). */
|
|
45
50
|
export function firstWords(text, n) {
|
|
46
51
|
const at = wordSpans(text);
|
|
@@ -93,9 +98,17 @@ export function children(text, words = CHILD_WORDS, stride = CHILD_STRIDE) {
|
|
|
93
98
|
out.push(flat(text.slice(at[Math.max(0, at.length - words)][0], at[at.length - 1][1])));
|
|
94
99
|
return out;
|
|
95
100
|
}
|
|
96
|
-
|
|
101
|
+
/** The first `n` UTF-16 units and an ellipsis — never ending on half a surrogate pair (an emoji cut in two is not valid
|
|
102
|
+
* UTF-8, and Qdrant refuses the whole write over it). */
|
|
103
|
+
export function cut(s, n) {
|
|
104
|
+
if (s.length <= n)
|
|
105
|
+
return s;
|
|
106
|
+
const high = n > 0 && s.charCodeAt(n - 1) >= 0xd800 && s.charCodeAt(n - 1) <= 0xdbff;
|
|
107
|
+
const end = high ? n - 1 : n;
|
|
108
|
+
return `${s.slice(0, end)}…`;
|
|
109
|
+
}
|
|
97
110
|
/** 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 — "
|
|
111
|
+
* collapsed, stray punctuation off the ends — "Proxy.conf " and "proxy.conf" are one node. */
|
|
99
112
|
export function conceptKey(name) {
|
|
100
113
|
return fold(name).replace(/\s+/g, ' ').replace(/^[\s.,:;"'`()[\]]+|[\s.,:;"'`()[\]]+$/g, '');
|
|
101
114
|
}
|
package/dist/core/words.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* numbers with those signs, over √dims. Kept by the text's hash, so an unchanged text is never encoded twice.
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from 'node:crypto';
|
|
15
|
-
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
15
|
+
import { appendFile, mkdir, readFile, truncate } from 'node:fs/promises';
|
|
16
16
|
import { dirname } from 'node:path';
|
|
17
17
|
/** Words kept of a memory (64 lost most of the gain). */
|
|
18
18
|
export const WORD_LIMIT = 128;
|
|
@@ -70,7 +70,8 @@ export class WordStore {
|
|
|
70
70
|
const b = await readFile(this.file).catch(() => null);
|
|
71
71
|
if (!b)
|
|
72
72
|
return;
|
|
73
|
-
|
|
73
|
+
let at = 0;
|
|
74
|
+
while (at + KEY_BYTES + 4 <= b.length) {
|
|
74
75
|
const key = b.subarray(at, at + KEY_BYTES).toString('hex');
|
|
75
76
|
const words = b.readUInt16LE(at + KEY_BYTES);
|
|
76
77
|
const dims = b.readUInt16LE(at + KEY_BYTES + 2);
|
|
@@ -81,6 +82,9 @@ export class WordStore {
|
|
|
81
82
|
this.byKey.set(key, { words, dims, bits: new Uint8Array(b.subarray(start, start + size)) });
|
|
82
83
|
at = start + size;
|
|
83
84
|
}
|
|
85
|
+
// The cut-off entry goes, or every entry appended after it would be read as part of it and never load again.
|
|
86
|
+
if (at < b.length)
|
|
87
|
+
await truncate(this.file, at).catch(() => { });
|
|
84
88
|
}
|
|
85
89
|
async put(entries) {
|
|
86
90
|
const parts = [];
|
package/dist/core/writer.d.ts
CHANGED
|
@@ -18,7 +18,8 @@ export declare function writeStm(d: WriterDeps, entries: StmEntry[]): Promise<vo
|
|
|
18
18
|
* either there are, so the two never meet. */
|
|
19
19
|
export declare const cuePoint: (k: number) => number;
|
|
20
20
|
/** The time a date stands for, as the first and last millisecond: a day, a month or a year (an observation's
|
|
21
|
-
* `eventAt`, or a date an agent searches by) — null for anything else
|
|
21
|
+
* `eventAt`, or a date an agent searches by) — null for anything else, a month or day that does not exist too
|
|
22
|
+
* (2024-02-30 is not 1 March). */
|
|
22
23
|
export declare function dateSpan(d: string | undefined): {
|
|
23
24
|
from: number;
|
|
24
25
|
to: number;
|
package/dist/core/writer.js
CHANGED
|
@@ -36,11 +36,12 @@ export async function writeStm(d, entries) {
|
|
|
36
36
|
const points = [];
|
|
37
37
|
let at = 0;
|
|
38
38
|
for (const { e, body, kids } of plan) {
|
|
39
|
-
|
|
39
|
+
// The record's kind (`engine`) on every point: a kinds filter must find a record by any of its chunks.
|
|
40
|
+
const common = { item: stmItem(e.uid), tier: 'stm', status: 'active', kind: 'episode', engine: e.engine, scope: 'project', project: e.project, session: e.session, ts: e.ts, synthetic: e.synthetic };
|
|
40
41
|
points.push({
|
|
41
42
|
id: pointId(stmItem(e.uid), 0),
|
|
42
43
|
vector: { dense: Array.from(vecs[at]), bm25: docSparse(`${stmTitle(e)}\n${body}`) },
|
|
43
|
-
payload: { ...common, part: 'key',
|
|
44
|
+
payload: { ...common, part: 'key', cwd: e.cwd ?? '', where: e.title, title: stmTitle(e), summary: rawKey(e), body, files: e.files, importance: 0, ...(e.record ? { record: e.record } : {}) },
|
|
44
45
|
});
|
|
45
46
|
kids.forEach((_, k) => points.push({ id: pointId(stmItem(e.uid), k + 1), vector: { dense: Array.from(vecs[at + 1 + k]) }, payload: { ...common, part: 'child' } }));
|
|
46
47
|
at += 1 + kids.length;
|
|
@@ -53,12 +54,17 @@ export async function writeStm(d, entries) {
|
|
|
53
54
|
* either there are, so the two never meet. */
|
|
54
55
|
export const cuePoint = (k) => -(k + 1);
|
|
55
56
|
/** The time a date stands for, as the first and last millisecond: a day, a month or a year (an observation's
|
|
56
|
-
* `eventAt`, or a date an agent searches by) — null for anything else
|
|
57
|
+
* `eventAt`, or a date an agent searches by) — null for anything else, a month or day that does not exist too
|
|
58
|
+
* (2024-02-30 is not 1 March). */
|
|
57
59
|
export function dateSpan(d) {
|
|
58
60
|
const m = /^(\d{4})(?:-(\d{2})(?:-(\d{2}))?)?$/.exec(d ?? '');
|
|
59
61
|
if (!m)
|
|
60
62
|
return null;
|
|
61
63
|
const [y, mo, dd] = [Number(m[1]), m[2] ? Number(m[2]) - 1 : null, m[3] ? Number(m[3]) : null];
|
|
64
|
+
if (mo !== null && (mo < 0 || mo > 11))
|
|
65
|
+
return null;
|
|
66
|
+
if (mo !== null && dd !== null && (dd < 1 || new Date(Date.UTC(y, mo, dd)).getUTCMonth() !== mo))
|
|
67
|
+
return null;
|
|
62
68
|
if (mo === null)
|
|
63
69
|
return { from: Date.UTC(y, 0, 1), to: Date.UTC(y + 1, 0, 1) - 1 };
|
|
64
70
|
if (dd === null)
|