@oharness/engine 0.4.0 → 0.5.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/dist/agent/agent.d.ts +63 -0
- package/dist/agent/agent.js +257 -7
- package/dist/agent/agent.js.map +1 -1
- package/dist/agent/memory-backup.d.ts +38 -0
- package/dist/agent/memory-backup.js +126 -0
- package/dist/agent/memory-backup.js.map +1 -0
- package/dist/agent/memory-consolidate.d.ts +47 -0
- package/dist/agent/memory-consolidate.js +118 -0
- package/dist/agent/memory-consolidate.js.map +1 -0
- package/dist/agent/memory-errors.d.ts +73 -0
- package/dist/agent/memory-errors.js +74 -0
- package/dist/agent/memory-errors.js.map +1 -0
- package/dist/agent/memory-extract.d.ts +36 -0
- package/dist/agent/memory-extract.js +169 -0
- package/dist/agent/memory-extract.js.map +1 -0
- package/dist/agent/memory-limits.d.ts +115 -0
- package/dist/agent/memory-limits.js +109 -0
- package/dist/agent/memory-limits.js.map +1 -0
- package/dist/agent/memory-links.d.ts +21 -0
- package/dist/agent/memory-links.js +45 -0
- package/dist/agent/memory-links.js.map +1 -0
- package/dist/agent/memory-merge.d.ts +34 -0
- package/dist/agent/memory-merge.js +152 -0
- package/dist/agent/memory-merge.js.map +1 -0
- package/dist/agent/memory-provider.d.ts +73 -0
- package/dist/agent/memory-provider.js +2 -0
- package/dist/agent/memory-provider.js.map +1 -0
- package/dist/agent/memory-recall.d.ts +72 -0
- package/dist/agent/memory-recall.js +192 -0
- package/dist/agent/memory-recall.js.map +1 -0
- package/dist/agent/memory-redact.d.ts +15 -0
- package/dist/agent/memory-redact.js +48 -0
- package/dist/agent/memory-redact.js.map +1 -0
- package/dist/agent/memory-review.d.ts +47 -0
- package/dist/agent/memory-review.js +127 -0
- package/dist/agent/memory-review.js.map +1 -0
- package/dist/agent/memory-usage.d.ts +24 -0
- package/dist/agent/memory-usage.js +68 -0
- package/dist/agent/memory-usage.js.map +1 -0
- package/dist/agent/memory.d.ts +137 -14
- package/dist/agent/memory.js +431 -130
- package/dist/agent/memory.js.map +1 -1
- package/dist/agent/permissions.d.ts +10 -2
- package/dist/agent/permissions.js +22 -3
- package/dist/agent/permissions.js.map +1 -1
- package/dist/agent/session.d.ts +8 -1
- package/dist/agent/session.js +9 -0
- package/dist/agent/session.js.map +1 -1
- package/dist/agent/store.d.ts +3 -0
- package/dist/agent/store.js.map +1 -1
- package/dist/agent/titles.js +2 -2
- package/dist/auth/providers/official.d.ts +13 -0
- package/dist/auth/providers/official.js +38 -3
- package/dist/auth/providers/official.js.map +1 -1
- package/dist/config.d.ts +20 -1
- package/dist/config.js +31 -1
- package/dist/config.js.map +1 -1
- package/dist/core/audit.d.ts +19 -0
- package/dist/core/types.d.ts +6 -0
- package/dist/core/types.js +12 -0
- package/dist/core/types.js.map +1 -1
- package/dist/harness.d.ts +7 -1
- package/dist/harness.js +33 -2
- package/dist/harness.js.map +1 -1
- package/dist/providers/builtin.d.ts +20 -0
- package/dist/providers/builtin.js +82 -0
- package/dist/providers/builtin.js.map +1 -1
- package/dist/providers/catalog.js +30 -3
- package/dist/providers/catalog.js.map +1 -1
- package/dist/providers/index.d.ts +2 -2
- package/dist/providers/index.js +1 -1
- package/dist/providers/index.js.map +1 -1
- package/dist/providers/piai.js +17 -8
- package/dist/providers/piai.js.map +1 -1
- package/dist/providers/registry.d.ts +5 -0
- package/dist/providers/registry.js +10 -2
- package/dist/providers/registry.js.map +1 -1
- package/dist/providers/types.d.ts +31 -0
- package/dist/providers/types.js +36 -0
- package/dist/providers/types.js.map +1 -1
- package/dist/runtime-plugins/builtin/memory-coordinator.d.ts +79 -0
- package/dist/runtime-plugins/builtin/memory-coordinator.js +169 -0
- package/dist/runtime-plugins/builtin/memory-coordinator.js.map +1 -0
- package/dist/runtime-plugins/builtin/memory-migrate.d.ts +35 -0
- package/dist/runtime-plugins/builtin/memory-migrate.js +141 -0
- package/dist/runtime-plugins/builtin/memory-migrate.js.map +1 -0
- package/dist/runtime-plugins/builtin/memory.d.ts +144 -5
- package/dist/runtime-plugins/builtin/memory.js +1016 -112
- package/dist/runtime-plugins/builtin/memory.js.map +1 -1
- package/dist/runtime-plugins/builtin/studio.js +21 -4
- package/dist/runtime-plugins/builtin/studio.js.map +1 -1
- package/dist/runtime-plugins/cloud-connector.js +4 -1
- package/dist/runtime-plugins/cloud-connector.js.map +1 -1
- package/dist/runtime-plugins/extensions.d.ts +9 -0
- package/dist/runtime-plugins/extensions.js +15 -0
- package/dist/runtime-plugins/extensions.js.map +1 -1
- package/dist/runtime-plugins/host.js +25 -2
- package/dist/runtime-plugins/host.js.map +1 -1
- package/dist/runtime-plugins/types.d.ts +15 -0
- package/dist/tools/builtin/ask.d.ts +9 -0
- package/dist/tools/builtin/ask.js +71 -0
- package/dist/tools/builtin/ask.js.map +1 -0
- package/dist/tools/builtin/index.d.ts +1 -0
- package/dist/tools/builtin/index.js +3 -0
- package/dist/tools/builtin/index.js.map +1 -1
- package/dist/tools/builtin/memory.d.ts +5 -5
- package/dist/tools/builtin/memory.js +151 -40
- package/dist/tools/builtin/memory.js.map +1 -1
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +1 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/questions.d.ts +89 -0
- package/dist/tools/questions.js +97 -0
- package/dist/tools/questions.js.map +1 -0
- package/dist/tools/types.d.ts +7 -0
- package/dist/tools/types.js.map +1 -1
- package/package.json +2 -2
package/dist/agent/memory.js
CHANGED
|
@@ -1,125 +1,289 @@
|
|
|
1
|
+
import { MemoryError } from "./memory-errors.js";
|
|
1
2
|
import { mkdir, open, readdir, writeFile, rename, rm, lstat } from "node:fs/promises";
|
|
2
3
|
import { constants } from "node:fs";
|
|
3
4
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { shortId } from "../util/id.js";
|
|
6
7
|
import { splitFrontmatter } from "./skills.js";
|
|
7
|
-
|
|
8
|
+
import { DEFAULT_MEMORY_POLICY, MEMORY_SAFETY, isMemoryType } from "./memory-limits.js";
|
|
9
|
+
import { buildLinkGraph } from "./memory-links.js";
|
|
10
|
+
import { rankNotes, recallQuery, renderRecall, selectRecall } from "./memory-recall.js";
|
|
11
|
+
export const validMemoryId = (id) => typeof id === "string" && MEMORY_SAFETY.idPattern.test(id) && !MEMORY_SAFETY.reservedIds.includes(id);
|
|
12
|
+
/**
|
|
13
|
+
* The identity of a note's content, compared across machines.
|
|
14
|
+
*
|
|
15
|
+
* `type` takes part only when a note has one. Every note written before the
|
|
16
|
+
* field existed keeps exactly the hash that its sync baseline and the server's
|
|
17
|
+
* manifest already recorded; including an absent type as `null` would have
|
|
18
|
+
* made every existing note look edited on every machine at once.
|
|
19
|
+
*/
|
|
8
20
|
export function noteHash(note) {
|
|
9
|
-
|
|
21
|
+
if (!note)
|
|
22
|
+
return null;
|
|
23
|
+
const fields = [note.id, note.summary, note.body, note.tags, note.createdAt];
|
|
24
|
+
if (note.type !== undefined)
|
|
25
|
+
fields.push(note.type);
|
|
26
|
+
return createHash("sha256").update(JSON.stringify(fields)).digest("hex");
|
|
10
27
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Tags as a model writes them, made into tags a note can hold.
|
|
30
|
+
*
|
|
31
|
+
* Models pad them (" aesthetics"), prefix them ("#design"), and put two in one
|
|
32
|
+
* string ("design, ux"). Refusing the whole note over that loses a note the
|
|
33
|
+
* user asked for, so each is trimmed, split and de-duplicated, and anything
|
|
34
|
+
* still unusable is dropped.
|
|
35
|
+
*/
|
|
36
|
+
export function normalizeTags(tags) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (const raw of tags) {
|
|
40
|
+
if (typeof raw !== "string")
|
|
41
|
+
continue;
|
|
42
|
+
for (const part of raw.split(/[,,\s]+/)) {
|
|
43
|
+
const tag = part.replace(/^#+/, "");
|
|
44
|
+
if (!MEMORY_SAFETY.tagPattern.test(tag) || seen.has(tag.toLowerCase()))
|
|
45
|
+
continue;
|
|
46
|
+
seen.add(tag.toLowerCase());
|
|
47
|
+
out.push(tag);
|
|
48
|
+
if (out.length >= MEMORY_SAFETY.maxTags)
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
export function memoryNote(entry) {
|
|
55
|
+
const { file: _, ...note } = entry;
|
|
56
|
+
if (note.type === undefined)
|
|
57
|
+
delete note.type;
|
|
58
|
+
return note;
|
|
59
|
+
}
|
|
60
|
+
export function validateNote(note, maxNoteBytes = DEFAULT_MEMORY_POLICY.maxNoteBytes) {
|
|
61
|
+
const s = MEMORY_SAFETY;
|
|
62
|
+
if (!validMemoryId(note.id) || typeof note.summary !== "string" || !note.summary.trim() || note.summary.length > s.maxSummaryChars ||
|
|
63
|
+
/[\r\n]/.test(note.summary) || note.summary !== note.summary.trim() || typeof note.body !== "string" || note.body !== note.body.trim() ||
|
|
64
|
+
Buffer.byteLength(note.body) > maxNoteBytes ||
|
|
65
|
+
!Array.isArray(note.tags) || note.tags.length > s.maxTags || note.tags.some((tag) => typeof tag !== "string" || !s.tagPattern.test(tag)) ||
|
|
66
|
+
!Number.isSafeInteger(note.createdAt) || note.createdAt < 0 ||
|
|
67
|
+
(note.type !== undefined && !isMemoryType(note.type)))
|
|
17
68
|
throw new Error("Invalid memory note");
|
|
18
69
|
}
|
|
19
70
|
const writes = new Map();
|
|
20
|
-
/** How much of the memory index may enter the system prompt. */
|
|
21
|
-
const MAX_PROMPT_CHARS = 4_000;
|
|
22
71
|
export class MemoryStore {
|
|
23
72
|
dir;
|
|
24
|
-
|
|
73
|
+
maxNotes;
|
|
74
|
+
maxNoteBytes;
|
|
75
|
+
scope;
|
|
76
|
+
onWarning;
|
|
77
|
+
files = new Map();
|
|
78
|
+
cached;
|
|
79
|
+
warnedOmitted = 0;
|
|
80
|
+
scoped = new WeakMap();
|
|
81
|
+
listeners = new Set();
|
|
82
|
+
constructor(dir, options = {}) {
|
|
25
83
|
this.dir = dir;
|
|
84
|
+
this.maxNotes = Math.max(1, Math.min(options.maxNotes ?? DEFAULT_MEMORY_POLICY.maxNotes, MEMORY_SAFETY.maxNotesCeiling));
|
|
85
|
+
this.maxNoteBytes = Math.max(1024, Math.min(options.maxNoteBytes ?? DEFAULT_MEMORY_POLICY.maxNoteBytes, MEMORY_SAFETY.maxNoteBytesCeiling));
|
|
86
|
+
this.scope = options.scope ?? "project";
|
|
87
|
+
this.onWarning = options.onWarning;
|
|
26
88
|
}
|
|
27
89
|
get location() {
|
|
28
90
|
return this.dir;
|
|
29
91
|
}
|
|
92
|
+
/** Told after every write this store makes, once the file is in place. */
|
|
93
|
+
onChange(listener) {
|
|
94
|
+
this.listeners.add(listener);
|
|
95
|
+
return () => { this.listeners.delete(listener); };
|
|
96
|
+
}
|
|
97
|
+
emit(id, origin) {
|
|
98
|
+
for (const listener of this.listeners) {
|
|
99
|
+
try {
|
|
100
|
+
listener({ id, origin });
|
|
101
|
+
}
|
|
102
|
+
catch { /* an observer never fails a write */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
30
105
|
async load() {
|
|
31
|
-
|
|
106
|
+
return (await this.snapshot()).entries;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Every note, read from disk only where a file changed.
|
|
110
|
+
*
|
|
111
|
+
* Recall scores notes on every user message, so rereading the directory each
|
|
112
|
+
* time would put thousands of file reads in front of every reply. A file's
|
|
113
|
+
* size and timestamps decide whether its parsed note is reused; the store's
|
|
114
|
+
* own writes drop their file from that cache outright, because two writes
|
|
115
|
+
* inside one timestamp tick with bodies of equal length would otherwise look
|
|
116
|
+
* unchanged.
|
|
117
|
+
*/
|
|
118
|
+
async snapshot() {
|
|
119
|
+
let names;
|
|
32
120
|
try {
|
|
33
121
|
for (const directory of [this.dir, path.dirname(this.dir)]) {
|
|
34
122
|
if ((await lstat(directory)).isSymbolicLink())
|
|
35
123
|
throw new Error("Memory directory cannot be a symlink");
|
|
36
124
|
}
|
|
37
|
-
|
|
125
|
+
names = (await readdir(this.dir)).filter((name) => name.endsWith(".md"));
|
|
38
126
|
}
|
|
39
127
|
catch (error) {
|
|
40
|
-
if (error.code === "ENOENT")
|
|
41
|
-
|
|
128
|
+
if (error.code === "ENOENT") {
|
|
129
|
+
this.files.clear();
|
|
130
|
+
this.cached = undefined;
|
|
131
|
+
return { entries: [], omitted: 0, links: buildLinkGraph([]) };
|
|
132
|
+
}
|
|
42
133
|
throw error;
|
|
43
134
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
135
|
+
const stats = [];
|
|
136
|
+
for (let i = 0; i < names.length; i += MEMORY_SAFETY.statConcurrency) {
|
|
137
|
+
const batch = await Promise.all(names.slice(i, i + MEMORY_SAFETY.statConcurrency).map(async (name) => {
|
|
138
|
+
const file = path.join(this.dir, name);
|
|
139
|
+
let info;
|
|
140
|
+
try {
|
|
141
|
+
info = await lstat(file);
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
// Removed between listing and stat: a concurrent delete, not corruption.
|
|
145
|
+
if (error.code === "ENOENT")
|
|
146
|
+
return undefined;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
if (info.isSymbolicLink() || !info.isFile())
|
|
54
150
|
throw new Error("Invalid memory file");
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
});
|
|
151
|
+
return { name, file, mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, size: info.size };
|
|
152
|
+
}));
|
|
153
|
+
for (const item of batch)
|
|
154
|
+
if (item)
|
|
155
|
+
stats.push(item);
|
|
156
|
+
}
|
|
157
|
+
const signature = stats.map((s) => `${s.name}:${s.mtimeMs}:${s.ctimeMs}:${s.size}`).sort().join("|");
|
|
158
|
+
if (this.cached?.signature === signature)
|
|
159
|
+
return this.cached.snapshot;
|
|
160
|
+
// Past the limit, the notes that stop loading are the ones nobody has
|
|
161
|
+
// touched for longest. Degrading, rather than refusing to load at all:
|
|
162
|
+
// before this, one note too many switched off the index, the tools and
|
|
163
|
+
// sync together.
|
|
164
|
+
stats.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
|
|
165
|
+
const kept = stats.slice(0, this.maxNotes);
|
|
166
|
+
const omitted = stats.length - kept.length;
|
|
167
|
+
if (omitted > 0 && omitted !== this.warnedOmitted) {
|
|
168
|
+
this.warnedOmitted = omitted;
|
|
169
|
+
this.onWarning?.(`Memory holds ${stats.length} notes, over the limit of ${this.maxNotes}; the ${omitted} least recently modified are not loaded.`);
|
|
170
|
+
}
|
|
171
|
+
else if (omitted === 0)
|
|
172
|
+
this.warnedOmitted = 0;
|
|
173
|
+
const nextFiles = new Map();
|
|
174
|
+
const entries = [];
|
|
175
|
+
for (const item of kept) {
|
|
176
|
+
const previous = this.files.get(item.name);
|
|
177
|
+
const entry = previous && previous.mtimeMs === item.mtimeMs && previous.ctimeMs === item.ctimeMs && previous.size === item.size
|
|
178
|
+
? previous.entry
|
|
179
|
+
: await this.readNote(item.file, item.name);
|
|
180
|
+
nextFiles.set(item.name, { mtimeMs: item.mtimeMs, ctimeMs: item.ctimeMs, size: item.size, entry });
|
|
181
|
+
entries.push(entry);
|
|
87
182
|
}
|
|
88
183
|
entries.sort((a, b) => b.createdAt - a.createdAt);
|
|
89
184
|
const seen = new Set();
|
|
185
|
+
const folded = new Set();
|
|
90
186
|
for (const entry of entries) {
|
|
91
|
-
validateNote(entry);
|
|
187
|
+
validateNote(entry, this.maxNoteBytes);
|
|
92
188
|
if (seen.has(entry.id))
|
|
93
189
|
throw new Error("Duplicate memory id");
|
|
190
|
+
// macOS and Windows file systems do not tell `A.md` from `a.md`, so two
|
|
191
|
+
// ids that differ only in case are one file waiting to overwrite the other.
|
|
192
|
+
const lower = entry.id.toLowerCase();
|
|
193
|
+
if (folded.has(lower))
|
|
194
|
+
throw new Error("Memory ids differ only by letter case");
|
|
94
195
|
seen.add(entry.id);
|
|
196
|
+
folded.add(lower);
|
|
95
197
|
}
|
|
96
|
-
|
|
198
|
+
this.files = nextFiles;
|
|
199
|
+
const snapshot = { entries, omitted, links: buildLinkGraph(entries) };
|
|
200
|
+
this.cached = { signature, snapshot };
|
|
201
|
+
return snapshot;
|
|
97
202
|
}
|
|
203
|
+
async readNote(file, name) {
|
|
204
|
+
let raw;
|
|
205
|
+
// O_NOFOLLOW is undefined on Windows, which makes it 0 here: no error and
|
|
206
|
+
// no protection. The lstat above still rejects a link on every platform.
|
|
207
|
+
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
208
|
+
try {
|
|
209
|
+
const stat = await handle.stat();
|
|
210
|
+
const limit = Math.max(256 * 1024, this.maxNoteBytes + MEMORY_SAFETY.fileOverheadBytes);
|
|
211
|
+
if (!stat.isFile() || stat.size > limit)
|
|
212
|
+
throw new Error("Invalid memory file");
|
|
213
|
+
raw = await handle.readFile("utf8");
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await handle.close();
|
|
217
|
+
}
|
|
218
|
+
// New writes use JSON-flow YAML metadata so quotes, colons and tags round-trip.
|
|
219
|
+
// Keep the original scalar frontmatter reader for existing local notes.
|
|
220
|
+
const jsonHeader = /^\uFEFF?---\r?\n(\{[^\n]+\})\r?\n---\r?\n([\s\S]*)$/.exec(raw);
|
|
221
|
+
if (jsonHeader) {
|
|
222
|
+
const metadata = JSON.parse(jsonHeader[1]);
|
|
223
|
+
return {
|
|
224
|
+
id: metadata.id, summary: metadata.summary, tags: metadata.tags,
|
|
225
|
+
...(metadata.type !== undefined ? { type: metadata.type } : {}),
|
|
226
|
+
createdAt: metadata.createdAt, updatedAt: metadata.updatedAt, body: jsonHeader[2].trim(), file,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
const { frontmatter, body } = splitFrontmatter(raw);
|
|
230
|
+
const summary = frontmatter["summary"]?.trim();
|
|
231
|
+
// Without a summary the entry cannot appear in the index, and an entry
|
|
232
|
+
// the model never sees is one it will never ask for.
|
|
233
|
+
if (!summary)
|
|
234
|
+
throw new Error("Memory note has no summary");
|
|
235
|
+
const type = frontmatter["type"]?.trim();
|
|
236
|
+
return {
|
|
237
|
+
id: frontmatter["id"]?.trim() || name.replace(/\.md$/, ""),
|
|
238
|
+
summary,
|
|
239
|
+
body: body.trim(),
|
|
240
|
+
tags: (frontmatter["tags"] ?? "").split(/[,\s]+/).map((tag) => tag.trim()).filter((tag) => tag !== ""),
|
|
241
|
+
...(type ? { type: type } : {}),
|
|
242
|
+
createdAt: Number(frontmatter["createdAt"]) || 0,
|
|
243
|
+
updatedAt: Number(frontmatter["updatedAt"]) || 0,
|
|
244
|
+
file,
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Write a note. With `id`, that note is updated and must exist. Without it,
|
|
249
|
+
* a note whose summary matches (ignoring case) is updated rather than
|
|
250
|
+
* duplicated, and otherwise a new one is created.
|
|
251
|
+
*/
|
|
98
252
|
async remember(input) {
|
|
99
|
-
if (typeof input.summary !== "string" || typeof input.body !== "string" || input.tags !== undefined && !Array.isArray(input.tags))
|
|
253
|
+
if (typeof input.summary !== "string" || typeof input.body !== "string" || (input.tags !== undefined && !Array.isArray(input.tags)) ||
|
|
254
|
+
(input.type !== undefined && !isMemoryType(input.type)) || (input.id !== undefined && !validMemoryId(input.id))) {
|
|
100
255
|
throw new Error("Invalid memory note");
|
|
256
|
+
}
|
|
101
257
|
return this.exclusive(async () => {
|
|
102
258
|
await mkdir(this.dir, { recursive: true });
|
|
103
259
|
const existing = await this.load();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
260
|
+
const summary = input.summary.trim();
|
|
261
|
+
let match;
|
|
262
|
+
if (input.id !== undefined) {
|
|
263
|
+
match = existing.find((entry) => entry.id === input.id);
|
|
264
|
+
if (!match)
|
|
265
|
+
throw new Error(`No memory note with id "${input.id}"`);
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
// Re-learning the same lesson should update the note, not add a
|
|
269
|
+
// duplicate that competes with it in the index.
|
|
270
|
+
match = existing.find((entry) => entry.summary.toLowerCase() === summary.toLowerCase());
|
|
271
|
+
}
|
|
272
|
+
if (!match && existing.length >= this.maxNotes) {
|
|
273
|
+
throw new Error(`Memory limit: ${this.maxNotes} notes. Delete or merge notes before adding more.`);
|
|
274
|
+
}
|
|
275
|
+
const id = match?.id ?? `${slug(summary)}-${shortId(3)}`;
|
|
110
276
|
const createdAt = match?.createdAt || Date.now();
|
|
111
277
|
const file = match?.file ?? path.join(this.dir, `${id}.md`);
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
id,
|
|
117
|
-
summary: input.summary.trim(),
|
|
118
|
-
body: input.body.trim(),
|
|
119
|
-
tags: input.tags ?? [],
|
|
120
|
-
createdAt,
|
|
121
|
-
file,
|
|
278
|
+
const type = input.type ?? match?.type;
|
|
279
|
+
const note = {
|
|
280
|
+
id, summary, body: input.body.trim(), tags: input.tags ? normalizeTags(input.tags) : match?.tags ?? [], createdAt, updatedAt: Date.now(),
|
|
281
|
+
...(type ? { type } : {}),
|
|
122
282
|
};
|
|
283
|
+
validateNote(note, this.maxNoteBytes);
|
|
284
|
+
await this.atomic(file, encodeNote(note));
|
|
285
|
+
this.emit(id, "local");
|
|
286
|
+
return { ...note, file };
|
|
123
287
|
});
|
|
124
288
|
}
|
|
125
289
|
async forget(id) {
|
|
@@ -129,28 +293,38 @@ export class MemoryStore {
|
|
|
129
293
|
if (!match)
|
|
130
294
|
return false;
|
|
131
295
|
await rm(match.file, { force: true });
|
|
296
|
+
this.invalidate(match.file);
|
|
297
|
+
this.emit(id, "local");
|
|
132
298
|
return true;
|
|
133
299
|
});
|
|
134
300
|
}
|
|
135
301
|
/** Compare before applying downloaded data; never overwrite a concurrent edit. */
|
|
136
|
-
async apply(id, note, expected) {
|
|
302
|
+
async apply(id, note, expected, origin = "local") {
|
|
137
303
|
if (!validMemoryId(id) || (note && note.id !== id))
|
|
138
304
|
throw new Error("Invalid memory id");
|
|
139
305
|
if (note)
|
|
140
|
-
validateNote(note);
|
|
306
|
+
validateNote(note, this.maxNoteBytes);
|
|
141
307
|
await this.exclusive(async () => {
|
|
142
308
|
const entries = await this.load(), current = entries.find((entry) => entry.id === id);
|
|
143
|
-
if (note && !current
|
|
144
|
-
|
|
309
|
+
if (note && !current) {
|
|
310
|
+
if (entries.length >= this.maxNotes)
|
|
311
|
+
throw new Error(`Memory limit: ${this.maxNotes} notes`);
|
|
312
|
+
if (entries.some((entry) => entry.id.toLowerCase() === id.toLowerCase()))
|
|
313
|
+
throw new Error("Memory ids differ only by letter case");
|
|
314
|
+
}
|
|
145
315
|
if (noteHash(current) !== expected)
|
|
146
|
-
throw new
|
|
316
|
+
throw new MemoryError("localChanged");
|
|
147
317
|
const file = current?.file ?? path.join(this.dir, id + ".md");
|
|
148
318
|
if (!note) {
|
|
149
|
-
if (current)
|
|
319
|
+
if (current) {
|
|
150
320
|
await rm(file);
|
|
321
|
+
this.invalidate(file);
|
|
322
|
+
this.emit(id, origin);
|
|
323
|
+
}
|
|
151
324
|
return;
|
|
152
325
|
}
|
|
153
326
|
await this.atomic(file, encodeNote(note));
|
|
327
|
+
this.emit(id, origin);
|
|
154
328
|
});
|
|
155
329
|
}
|
|
156
330
|
exclusive(action) {
|
|
@@ -161,6 +335,10 @@ export class MemoryStore {
|
|
|
161
335
|
writes.delete(key); });
|
|
162
336
|
return job;
|
|
163
337
|
}
|
|
338
|
+
invalidate(file) {
|
|
339
|
+
this.cached = undefined;
|
|
340
|
+
this.files.delete(path.basename(file));
|
|
341
|
+
}
|
|
164
342
|
async atomic(file, raw) {
|
|
165
343
|
await mkdir(this.dir, { recursive: true });
|
|
166
344
|
if ((await lstat(this.dir)).isSymbolicLink())
|
|
@@ -168,71 +346,194 @@ export class MemoryStore {
|
|
|
168
346
|
const temp = path.join(this.dir, "." + randomUUID() + ".tmp");
|
|
169
347
|
try {
|
|
170
348
|
await writeFile(temp, raw, { mode: 0o600, flag: "wx" });
|
|
171
|
-
await
|
|
349
|
+
await renameWithRetry(temp, file);
|
|
172
350
|
}
|
|
173
351
|
finally {
|
|
174
352
|
await rm(temp, { force: true });
|
|
353
|
+
this.invalidate(file);
|
|
175
354
|
}
|
|
176
355
|
}
|
|
177
|
-
/**
|
|
356
|
+
/** Notes as recall sees them, with this store's scope. Stable objects while a file is unchanged. */
|
|
357
|
+
scopedNotes(entries) {
|
|
358
|
+
return entries.map((entry) => {
|
|
359
|
+
let note = this.scoped.get(entry);
|
|
360
|
+
if (!note) {
|
|
361
|
+
note = {
|
|
362
|
+
id: entry.id, summary: entry.summary, body: entry.body, tags: entry.tags, createdAt: entry.createdAt,
|
|
363
|
+
...(entry.updatedAt ? { updatedAt: entry.updatedAt } : {}),
|
|
364
|
+
...(entry.type ? { type: entry.type } : {}),
|
|
365
|
+
scope: this.scope, entry,
|
|
366
|
+
};
|
|
367
|
+
this.scoped.set(entry, note);
|
|
368
|
+
}
|
|
369
|
+
return note;
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
/** Notes scored against a query, best first. */
|
|
373
|
+
async rank(query, options = {}) {
|
|
374
|
+
return rankNotes(this.scopedNotes(await this.load()), query, options);
|
|
375
|
+
}
|
|
376
|
+
/** Scored search across summaries, bodies and tags, falling back to plain substring matching. */
|
|
178
377
|
async search(query) {
|
|
378
|
+
const ranked = await this.rank(query);
|
|
379
|
+
if (ranked.length)
|
|
380
|
+
return ranked.map((entry) => entry.note.entry);
|
|
179
381
|
const needle = query.toLowerCase();
|
|
180
382
|
return (await this.load()).filter((entry) => entry.summary.toLowerCase().includes(needle) ||
|
|
181
383
|
entry.body.toLowerCase().includes(needle) ||
|
|
182
384
|
entry.tags.some((tag) => tag.toLowerCase().includes(needle)));
|
|
183
385
|
}
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
386
|
+
/** The index for this store alone. See `memoryIndexSection`. */
|
|
387
|
+
async promptSection(indexChars = DEFAULT_MEMORY_POLICY.indexChars) {
|
|
388
|
+
return memoryIndexSection(this.scopedNotes(await this.load()), indexChars, { showScope: false });
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async function renameWithRetry(from, to) {
|
|
392
|
+
for (let attempt = 0;; attempt++) {
|
|
393
|
+
try {
|
|
394
|
+
await rename(from, to);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
catch (error) {
|
|
398
|
+
// On Windows a rename over a file an editor, indexer or antivirus has
|
|
399
|
+
// open fails for a moment with EPERM, EBUSY or EACCES and then succeeds.
|
|
400
|
+
const code = error.code ?? "";
|
|
401
|
+
if (process.platform !== "win32" || attempt >= 5 || !["EPERM", "EBUSY", "EACCES"].includes(code))
|
|
402
|
+
throw error;
|
|
403
|
+
await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const scopedCache = new WeakMap();
|
|
408
|
+
/**
|
|
409
|
+
* Both scopes loaded as one list, project notes first, with links resolved
|
|
410
|
+
* across the two: a note in either may link to a note in the other.
|
|
411
|
+
*/
|
|
412
|
+
export async function loadScoped(stores) {
|
|
413
|
+
const project = await stores.project.snapshot();
|
|
414
|
+
const user = stores.user ? await stores.user.snapshot() : undefined;
|
|
415
|
+
const cached = scopedCache.get(stores);
|
|
416
|
+
if (cached && cached.project === project && cached.user === user)
|
|
417
|
+
return cached.value;
|
|
418
|
+
const notes = [...stores.project.scopedNotes(project.entries), ...(stores.user && user ? stores.user.scopedNotes(user.entries) : [])];
|
|
419
|
+
const value = { notes, links: buildLinkGraph(notes), omitted: project.omitted + (user?.omitted ?? 0) };
|
|
420
|
+
scopedCache.set(stores, { project, ...(user ? { user } : {}), value });
|
|
421
|
+
return value;
|
|
422
|
+
}
|
|
423
|
+
/** A note by id, project scope first. */
|
|
424
|
+
export function findScoped(notes, id) {
|
|
425
|
+
return notes.find((note) => note.id === id && note.scope === "project") ?? notes.find((note) => note.id === id);
|
|
426
|
+
}
|
|
427
|
+
export function storeFor(stores, scope) {
|
|
428
|
+
if (scope === "user") {
|
|
429
|
+
if (!stores.user)
|
|
430
|
+
throw new Error("User-level memory is unavailable on this machine");
|
|
431
|
+
return stores.user;
|
|
432
|
+
}
|
|
433
|
+
return stores.project;
|
|
434
|
+
}
|
|
435
|
+
export const MEMORY_GUIDANCE = [
|
|
436
|
+
"Record with `remember` when:",
|
|
437
|
+
"- the user corrects you, or confirms an approach that was not obvious;",
|
|
438
|
+
"- the user states a preference about how to work;",
|
|
439
|
+
"- you learn something about the project that the code and git history do not record: a decision and its reason, a constraint, where something external lives.",
|
|
440
|
+
"Do not record what the code or git history already says, what only matters to the current task, or any credential.",
|
|
441
|
+
"Tag each note with the words someone might use instead of the summary's: synonyms, and the term in the other language in use (for example border, 边框, 描边). Recall matches words, not meaning.",
|
|
442
|
+
"Before adding a note, look at the related notes `remember` returns: update an existing note by passing its `id` rather than adding a near-duplicate, and link related notes in the body as [[note-id]].",
|
|
443
|
+
"A note that names a file, function or setting may be out of date. Confirm it still holds before acting on it, and update or `forget` a note that turns out to be wrong.",
|
|
444
|
+
"Scope defaults to `project`, and to `user` only for notes of type `user`. Put a correction or preference in scope `user` only when it plainly holds in every project; one about how this codebase is worked on stays in `project`.",
|
|
445
|
+
].join("\n");
|
|
446
|
+
/**
|
|
447
|
+
* The index that goes into the system prompt: one line per note, summaries only.
|
|
448
|
+
*
|
|
449
|
+
* Same reasoning as skills — putting every note's body in the prompt would
|
|
450
|
+
* grow without bound and crowd out the actual conversation. The model reads a
|
|
451
|
+
* summary, then asks for the body if it matters; notes that match a message
|
|
452
|
+
* are attached to it without being asked for.
|
|
453
|
+
*
|
|
454
|
+
* Most recently touched first, which is not the order the notes are stored in.
|
|
455
|
+
* The budget drops whatever does not fit, so this ordering decides which notes
|
|
456
|
+
* stop being listed once there are more than about fifty. A note written a year
|
|
457
|
+
* ago and corrected on Tuesday is the more useful of the two, and updatedAt is
|
|
458
|
+
* the only thing here that knows that. Nothing is lost, only unlisted: the line
|
|
459
|
+
* at the end says how many, and the tool searches every note.
|
|
460
|
+
*/
|
|
461
|
+
export function memoryIndexSection(notes, indexChars, options) {
|
|
462
|
+
if (!notes.length) {
|
|
463
|
+
// With nothing to list, the full guidance would be paid for on every
|
|
464
|
+
// request to repeat what the tool descriptions say. Enough to know when to write.
|
|
465
|
+
return [
|
|
212
466
|
"## Memory",
|
|
213
467
|
"",
|
|
214
|
-
"
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
468
|
+
"No notes recorded yet. Use `remember` when the user corrects you, states a preference, or tells you something about the project that the code does not show. Never record credentials.",
|
|
469
|
+
].join("\n");
|
|
470
|
+
}
|
|
471
|
+
const lines = [
|
|
472
|
+
"## Memory",
|
|
473
|
+
"",
|
|
474
|
+
"Notes recorded in earlier sessions. These are summaries: read a note with the `memory` tool",
|
|
475
|
+
"before acting on it. Notes that match a user message are also attached to that message",
|
|
476
|
+
"inside <recalled-memory>.",
|
|
477
|
+
"",
|
|
478
|
+
MEMORY_GUIDANCE,
|
|
479
|
+
"",
|
|
480
|
+
];
|
|
481
|
+
const ordered = [...notes].sort((a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt));
|
|
482
|
+
let budget = indexChars;
|
|
483
|
+
let omitted = 0;
|
|
484
|
+
for (const note of ordered) {
|
|
485
|
+
const labels = [options.showScope ? note.scope : undefined, note.type].filter(Boolean);
|
|
486
|
+
const line = `- \`${note.id}\`${labels.length ? ` (${labels.join(" · ")})` : ""} — ${note.summary}`;
|
|
487
|
+
if (line.length > budget) {
|
|
488
|
+
omitted++;
|
|
489
|
+
continue;
|
|
228
490
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
return lines.join("\n");
|
|
491
|
+
lines.push(line);
|
|
492
|
+
budget -= line.length;
|
|
232
493
|
}
|
|
494
|
+
if (omitted > 0)
|
|
495
|
+
lines.push(`- … ${omitted} older notes; search them with the \`memory\` tool.`);
|
|
496
|
+
return lines.join("\n");
|
|
497
|
+
}
|
|
498
|
+
/** Recall across both scopes, rendered for attaching to a user message. */
|
|
499
|
+
export async function recallFromStores(stores, input, policy, usage) {
|
|
500
|
+
if (!policy.enabled || policy.perTurn <= 0)
|
|
501
|
+
return undefined;
|
|
502
|
+
const { notes, links } = await loadScoped(stores);
|
|
503
|
+
if (!notes.length)
|
|
504
|
+
return undefined;
|
|
505
|
+
const byId = new Map();
|
|
506
|
+
for (const note of [...notes].reverse())
|
|
507
|
+
byId.set(note.id, note);
|
|
508
|
+
const ranked = rankNotes(notes, recallQuery(input.prompt, input.context), {
|
|
509
|
+
exclude: input.exclude,
|
|
510
|
+
usage: (id) => (byId.get(id)?.scope === "user" ? usage?.user : usage?.project)?.get(id),
|
|
511
|
+
});
|
|
512
|
+
const chosen = selectRecall(ranked, policy);
|
|
513
|
+
if (!chosen.length)
|
|
514
|
+
return undefined;
|
|
515
|
+
const chosenIds = new Set(chosen.map((entry) => entry.note.id));
|
|
516
|
+
const items = chosen.map(({ note }) => ({
|
|
517
|
+
note,
|
|
518
|
+
related: (links.outgoing.get(note.id) ?? [])
|
|
519
|
+
.filter((id) => !chosenIds.has(id) && !input.exclude.has(id))
|
|
520
|
+
.map((id) => byId.get(id))
|
|
521
|
+
.filter((entry) => entry !== undefined)
|
|
522
|
+
.slice(0, 5)
|
|
523
|
+
.map((entry) => ({ id: entry.id, summary: entry.summary })),
|
|
524
|
+
}));
|
|
525
|
+
usage?.project?.record(chosen.filter((entry) => entry.note.scope === "project").map((entry) => entry.note.id));
|
|
526
|
+
usage?.user?.record(chosen.filter((entry) => entry.note.scope === "user").map((entry) => entry.note.id));
|
|
527
|
+
return {
|
|
528
|
+
text: renderRecall(items, policy.budgetChars),
|
|
529
|
+
items: chosen.map(({ note }) => ({ id: note.id, summary: note.summary, scope: note.scope })),
|
|
530
|
+
};
|
|
233
531
|
}
|
|
234
532
|
function encodeNote(note) {
|
|
235
|
-
const metadata = {
|
|
533
|
+
const metadata = {
|
|
534
|
+
id: note.id, summary: note.summary, ...(note.type ? { type: note.type } : {}), tags: note.tags,
|
|
535
|
+
createdAt: note.createdAt, updatedAt: note.updatedAt ?? Date.now(),
|
|
536
|
+
};
|
|
236
537
|
return "---\n" + JSON.stringify(metadata) + "\n---\n" + note.body + "\n";
|
|
237
538
|
}
|
|
238
539
|
function slug(value) {
|