@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.
Files changed (117) hide show
  1. package/dist/agent/agent.d.ts +63 -0
  2. package/dist/agent/agent.js +257 -7
  3. package/dist/agent/agent.js.map +1 -1
  4. package/dist/agent/memory-backup.d.ts +38 -0
  5. package/dist/agent/memory-backup.js +126 -0
  6. package/dist/agent/memory-backup.js.map +1 -0
  7. package/dist/agent/memory-consolidate.d.ts +47 -0
  8. package/dist/agent/memory-consolidate.js +118 -0
  9. package/dist/agent/memory-consolidate.js.map +1 -0
  10. package/dist/agent/memory-errors.d.ts +73 -0
  11. package/dist/agent/memory-errors.js +74 -0
  12. package/dist/agent/memory-errors.js.map +1 -0
  13. package/dist/agent/memory-extract.d.ts +36 -0
  14. package/dist/agent/memory-extract.js +169 -0
  15. package/dist/agent/memory-extract.js.map +1 -0
  16. package/dist/agent/memory-limits.d.ts +115 -0
  17. package/dist/agent/memory-limits.js +109 -0
  18. package/dist/agent/memory-limits.js.map +1 -0
  19. package/dist/agent/memory-links.d.ts +21 -0
  20. package/dist/agent/memory-links.js +45 -0
  21. package/dist/agent/memory-links.js.map +1 -0
  22. package/dist/agent/memory-merge.d.ts +34 -0
  23. package/dist/agent/memory-merge.js +152 -0
  24. package/dist/agent/memory-merge.js.map +1 -0
  25. package/dist/agent/memory-provider.d.ts +73 -0
  26. package/dist/agent/memory-provider.js +2 -0
  27. package/dist/agent/memory-provider.js.map +1 -0
  28. package/dist/agent/memory-recall.d.ts +72 -0
  29. package/dist/agent/memory-recall.js +192 -0
  30. package/dist/agent/memory-recall.js.map +1 -0
  31. package/dist/agent/memory-redact.d.ts +15 -0
  32. package/dist/agent/memory-redact.js +48 -0
  33. package/dist/agent/memory-redact.js.map +1 -0
  34. package/dist/agent/memory-review.d.ts +47 -0
  35. package/dist/agent/memory-review.js +127 -0
  36. package/dist/agent/memory-review.js.map +1 -0
  37. package/dist/agent/memory-usage.d.ts +24 -0
  38. package/dist/agent/memory-usage.js +68 -0
  39. package/dist/agent/memory-usage.js.map +1 -0
  40. package/dist/agent/memory.d.ts +137 -14
  41. package/dist/agent/memory.js +431 -130
  42. package/dist/agent/memory.js.map +1 -1
  43. package/dist/agent/permissions.d.ts +10 -2
  44. package/dist/agent/permissions.js +22 -3
  45. package/dist/agent/permissions.js.map +1 -1
  46. package/dist/agent/session.d.ts +8 -1
  47. package/dist/agent/session.js +9 -0
  48. package/dist/agent/session.js.map +1 -1
  49. package/dist/agent/store.d.ts +3 -0
  50. package/dist/agent/store.js.map +1 -1
  51. package/dist/agent/titles.js +2 -2
  52. package/dist/auth/providers/official.d.ts +13 -0
  53. package/dist/auth/providers/official.js +38 -3
  54. package/dist/auth/providers/official.js.map +1 -1
  55. package/dist/config.d.ts +20 -1
  56. package/dist/config.js +31 -1
  57. package/dist/config.js.map +1 -1
  58. package/dist/core/audit.d.ts +19 -0
  59. package/dist/core/types.d.ts +6 -0
  60. package/dist/core/types.js +12 -0
  61. package/dist/core/types.js.map +1 -1
  62. package/dist/harness.d.ts +7 -1
  63. package/dist/harness.js +33 -2
  64. package/dist/harness.js.map +1 -1
  65. package/dist/providers/builtin.d.ts +20 -0
  66. package/dist/providers/builtin.js +82 -0
  67. package/dist/providers/builtin.js.map +1 -1
  68. package/dist/providers/catalog.js +30 -3
  69. package/dist/providers/catalog.js.map +1 -1
  70. package/dist/providers/index.d.ts +2 -2
  71. package/dist/providers/index.js +1 -1
  72. package/dist/providers/index.js.map +1 -1
  73. package/dist/providers/piai.js +17 -8
  74. package/dist/providers/piai.js.map +1 -1
  75. package/dist/providers/registry.d.ts +5 -0
  76. package/dist/providers/registry.js +10 -2
  77. package/dist/providers/registry.js.map +1 -1
  78. package/dist/providers/types.d.ts +31 -0
  79. package/dist/providers/types.js +36 -0
  80. package/dist/providers/types.js.map +1 -1
  81. package/dist/runtime-plugins/builtin/memory-coordinator.d.ts +79 -0
  82. package/dist/runtime-plugins/builtin/memory-coordinator.js +169 -0
  83. package/dist/runtime-plugins/builtin/memory-coordinator.js.map +1 -0
  84. package/dist/runtime-plugins/builtin/memory-migrate.d.ts +35 -0
  85. package/dist/runtime-plugins/builtin/memory-migrate.js +141 -0
  86. package/dist/runtime-plugins/builtin/memory-migrate.js.map +1 -0
  87. package/dist/runtime-plugins/builtin/memory.d.ts +144 -5
  88. package/dist/runtime-plugins/builtin/memory.js +1016 -112
  89. package/dist/runtime-plugins/builtin/memory.js.map +1 -1
  90. package/dist/runtime-plugins/builtin/studio.js +21 -4
  91. package/dist/runtime-plugins/builtin/studio.js.map +1 -1
  92. package/dist/runtime-plugins/cloud-connector.js +4 -1
  93. package/dist/runtime-plugins/cloud-connector.js.map +1 -1
  94. package/dist/runtime-plugins/extensions.d.ts +9 -0
  95. package/dist/runtime-plugins/extensions.js +15 -0
  96. package/dist/runtime-plugins/extensions.js.map +1 -1
  97. package/dist/runtime-plugins/host.js +25 -2
  98. package/dist/runtime-plugins/host.js.map +1 -1
  99. package/dist/runtime-plugins/types.d.ts +15 -0
  100. package/dist/tools/builtin/ask.d.ts +9 -0
  101. package/dist/tools/builtin/ask.js +71 -0
  102. package/dist/tools/builtin/ask.js.map +1 -0
  103. package/dist/tools/builtin/index.d.ts +1 -0
  104. package/dist/tools/builtin/index.js +3 -0
  105. package/dist/tools/builtin/index.js.map +1 -1
  106. package/dist/tools/builtin/memory.d.ts +5 -5
  107. package/dist/tools/builtin/memory.js +151 -40
  108. package/dist/tools/builtin/memory.js.map +1 -1
  109. package/dist/tools/index.d.ts +2 -0
  110. package/dist/tools/index.js +1 -0
  111. package/dist/tools/index.js.map +1 -1
  112. package/dist/tools/questions.d.ts +89 -0
  113. package/dist/tools/questions.js +97 -0
  114. package/dist/tools/questions.js.map +1 -0
  115. package/dist/tools/types.d.ts +7 -0
  116. package/dist/tools/types.js.map +1 -1
  117. package/package.json +2 -2
@@ -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
- export const validMemoryId = (id) => typeof id === "string" && /^[a-zA-Z0-9_-]{1,128}$/.test(id) && !["__proto__", "constructor", "prototype"].includes(id);
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
- return note ? createHash("sha256").update(JSON.stringify([note.id, note.summary, note.body, note.tags, note.createdAt])).digest("hex") : null;
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
- export function memoryNote(entry) { const { file: _, ...note } = entry; return note; }
12
- export function validateNote(note) {
13
- if (!validMemoryId(note.id) || typeof note.summary !== "string" || !note.summary.trim() || note.summary.length > 500 ||
14
- /[\r\n]/.test(note.summary) || note.summary !== note.summary.trim() || typeof note.body !== "string" || note.body !== note.body.trim() || Buffer.byteLength(note.body) > 128 * 1024 ||
15
- !Array.isArray(note.tags) || note.tags.length > 30 || note.tags.some((tag) => typeof tag !== "string" || !/^[^\s,\r\n]{1,64}$/.test(tag)) ||
16
- !Number.isSafeInteger(note.createdAt) || note.createdAt < 0)
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
- constructor(dir) {
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
- let files;
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
- files = (await readdir(this.dir)).filter((name) => name.endsWith(".md"));
125
+ names = (await readdir(this.dir)).filter((name) => name.endsWith(".md"));
38
126
  }
39
127
  catch (error) {
40
- if (error.code === "ENOENT")
41
- return [];
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
- if (files.length > 500)
45
- throw new Error("Memory limit: 500 notes per project");
46
- const entries = [];
47
- for (const name of files) {
48
- const file = path.join(this.dir, name);
49
- let raw;
50
- const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
51
- try {
52
- const stat = await handle.stat();
53
- if (!stat.isFile() || stat.size > 256 * 1024)
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
- raw = await handle.readFile("utf8");
56
- }
57
- finally {
58
- await handle.close();
59
- }
60
- // New writes use JSON-flow YAML metadata so quotes, colons and tags round-trip.
61
- // Keep the original scalar frontmatter reader for existing local notes.
62
- const jsonHeader = /^\uFEFF?---\r?\n(\{[^\n]+\})\r?\n---\r?\n([\s\S]*)$/.exec(raw);
63
- if (jsonHeader) {
64
- const metadata = JSON.parse(jsonHeader[1]);
65
- entries.push({ id: metadata.id, summary: metadata.summary, tags: metadata.tags,
66
- createdAt: metadata.createdAt, updatedAt: metadata.updatedAt, body: jsonHeader[2].trim(), file });
67
- continue;
68
- }
69
- const { frontmatter, body } = splitFrontmatter(raw);
70
- const summary = frontmatter["summary"]?.trim();
71
- // Without a summary the entry cannot appear in the index, and an entry
72
- // the model never sees is one it will never ask for.
73
- if (!summary)
74
- throw new Error("Memory note has no summary");
75
- entries.push({
76
- id: frontmatter["id"]?.trim() || name.replace(/\.md$/, ""),
77
- summary,
78
- body: body.trim(),
79
- tags: (frontmatter["tags"] ?? "")
80
- .split(/[,\s]+/)
81
- .map((tag) => tag.trim())
82
- .filter((tag) => tag !== ""),
83
- createdAt: Number(frontmatter["createdAt"]) || 0,
84
- updatedAt: Number(frontmatter["updatedAt"]) || 0,
85
- file,
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
- return entries;
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
- // Re-learning the same lesson should update the note, not add a duplicate
105
- // that competes with it in the index.
106
- const match = existing.find((entry) => entry.summary.toLowerCase() === input.summary.trim().toLowerCase());
107
- const id = match?.id ?? `${slug(input.summary)}-${shortId(3)}`;
108
- if (!match && existing.length >= 500)
109
- throw new Error("Memory limit: 500 notes per project");
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 note = { id, summary: input.summary.trim(), body: input.body.trim(), tags: input.tags ?? [], createdAt, updatedAt: Date.now() };
113
- validateNote(note);
114
- await this.atomic(file, encodeNote(note));
115
- return {
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 && entries.length >= 500)
144
- throw new Error("Memory limit: 500 notes per project");
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 Error("本地记忆已改变,请重新同步。");
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 rename(temp, file);
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
- /** Full-text search across summaries, bodies and tags. */
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
- * The index that goes into the system prompt: summaries only.
186
- *
187
- * Same reasoning as skills — putting every note's body in the prompt would
188
- * grow without bound and crowd out the actual conversation. The model reads
189
- * a summary, then asks for the body if it matters.
190
- */
191
- async promptSection() {
192
- const loaded = await this.load();
193
- if (loaded.length === 0)
194
- return undefined;
195
- /*
196
- * Most recently touched first, which is not the order the notes are stored
197
- * in.
198
- *
199
- * The budget below drops whatever does not fit, so this line decides which
200
- * notes stop being visible once there are more than about fifty. Ordered by
201
- * creation — the order load() returns, and the right one for a list someone
202
- * is reading — the first to fall out is the oldest note, whether or not it
203
- * is the one being re-learned every week. A note written a year ago and
204
- * corrected on Tuesday is the more useful of the two, and updatedAt is the
205
- * only thing here that knows that.
206
- *
207
- * Nothing is lost, only unlisted: the line at the end says how many, and
208
- * the tool searches every note whether or not it is in the index.
209
- */
210
- const entries = [...loaded].sort((a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt));
211
- const lines = [
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
- "Lessons recorded in earlier sessions. Read the full note with the `memory` tool",
215
- "before acting on one — these are summaries, not the whole story.",
216
- "",
217
- ];
218
- let budget = MAX_PROMPT_CHARS;
219
- let omitted = 0;
220
- for (const entry of entries) {
221
- const line = `- \`${entry.id}\` — ${entry.summary}`;
222
- if (line.length > budget) {
223
- omitted++;
224
- continue;
225
- }
226
- lines.push(line);
227
- budget -= line.length;
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
- if (omitted > 0)
230
- lines.push(`- … ${omitted} older notes; search them with the \`memory\` tool.`);
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 = { id: note.id, summary: note.summary, tags: note.tags, createdAt: note.createdAt, updatedAt: note.updatedAt ?? Date.now() };
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) {