geneseed 4.3.0 → 4.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +2 -2
  3. package/SHIPPED.md +2 -2
  4. package/adapters/opencode/plugins/geneseed-context.js +43 -2
  5. package/adapters/opencode/plugins/geneseed-guard.js +25 -1
  6. package/adapters/opencode/plugins/geneseed-learn.js +203 -47
  7. package/docs/concepts/agents.md +1 -0
  8. package/docs/concepts/exclusions.md +2 -0
  9. package/docs/concepts/hooks.md +1 -1
  10. package/docs/concepts/host-matrix.md +2 -1
  11. package/docs/reference/opencode-plugins.md +1 -1
  12. package/docs/reference/plugin-learn.md +2 -2
  13. package/docs/understand/a-day.md +3 -3
  14. package/docs/understand/machine-claude.md +1 -1
  15. package/docs/understand/machine-opencode.md +1 -1
  16. package/harness.config.json +1 -1
  17. package/js/README.md +1 -1
  18. package/js/build/bundle.mjs +2 -0
  19. package/js/build/emit-claude.mjs +2 -0
  20. package/js/build/emit-opencode.mjs +2 -0
  21. package/js/build/stubs.mjs +9 -4
  22. package/js/cli-table.json +32 -2
  23. package/js/hosts/gitref.mjs +25 -1
  24. package/js/hosts/hooks-context.mjs +25 -1
  25. package/js/hosts/hooks-learn.mjs +42 -16
  26. package/js/hosts/hosts.mjs +14 -4
  27. package/js/hosts/memory-files.mjs +283 -60
  28. package/js/inspect/checks-build.mjs +2 -1
  29. package/js/inspect/cost.mjs +1 -1
  30. package/js/inspect/excludes.mjs +33 -2
  31. package/js/maintain/memory.mjs +38 -11
  32. package/js/web/actions.mjs +4 -3
  33. package/package.json +1 -1
  34. package/registry.json +7 -0
  35. package/src/AGENT.md.tmpl +1 -0
  36. package/src/agents/memory-curator.md +71 -0
  37. package/src/memory/README.md +41 -10
  38. package/src/skills/geneseed.md +1 -1
  39. package/src/skills/rule/consolidate.md +18 -11
  40. package/src/skills/rule.md +9 -6
  41. package/themes/_TEMPLATE.json +1 -0
  42. package/themes/biker.json +1 -0
  43. package/themes/commentator.json +1 -0
  44. package/themes/cyberpunk.json +1 -0
  45. package/themes/gamer.json +1 -0
  46. package/themes/imperial.json +1 -0
  47. package/themes/joker.json +1 -0
  48. package/themes/marvin.json +1 -0
  49. package/themes/mean.json +1 -0
  50. package/themes/military.json +1 -0
  51. package/themes/neutral.json +1 -0
  52. package/themes/pirate.json +1 -0
  53. package/themes/sports.json +1 -0
  54. package/themes/verstappen.json +1 -0
  55. package/themes/wizard.json +1 -0
  56. package/web/dist/assets/{Activity-DDTRkc4M.js → Activity-BZE6REef.js} +1 -1
  57. package/web/dist/assets/{ActivityDetail-C41RsH-S.js → ActivityDetail-DCB8Cr2W.js} +1 -1
  58. package/web/dist/assets/{Dashboard-B0DJ2oUk.js → Dashboard-B4Drq5wp.js} +2 -2
  59. package/web/dist/assets/{Docs-DBj7-n9F.js → Docs-D30CiqYy.js} +1 -1
  60. package/web/dist/assets/{GreenhouseView-Bz-Q1Xl-.js → GreenhouseView-Djt-4oO5.js} +1 -1
  61. package/web/dist/assets/{Installs-BNlWdJJE.js → Installs-B3bw5ckh.js} +1 -1
  62. package/web/dist/assets/{JournalView-BkwmfkEy.js → JournalView-OWhx9YKg.js} +1 -1
  63. package/web/dist/assets/{Laws-CzvMZCUL.js → Laws-B9day2FV.js} +1 -1
  64. package/web/dist/assets/Library-BpIrLH-O.js +1 -0
  65. package/web/dist/assets/{Loops-s_NsuNaQ.js → Loops-CxH14kyE.js} +1 -1
  66. package/web/dist/assets/Markdown-DrXLM6TA.js +65 -0
  67. package/web/dist/assets/{OperatorHudView-CgeWJnPI.js → OperatorHudView-otOz6Gha.js} +1 -1
  68. package/web/dist/assets/{Personal-DMM6jfE0.js → Personal-abL6089y.js} +1 -1
  69. package/web/dist/assets/{Tabs-CPpcsYDc.js → Tabs-DjWN0i7A.js} +1 -1
  70. package/web/dist/assets/{drawable-D-fIT_ow.js → drawable-C2ZsBZCv.js} +1 -1
  71. package/web/dist/assets/index-CdpLpPQN.js +2 -0
  72. package/web/dist/index.html +1 -1
  73. package/web/dist/assets/Library-BgIpOTHi.js +0 -1
  74. package/web/dist/assets/Markdown-C6fRd4A4.js +0 -67
  75. package/web/dist/assets/index-DXIa0ZG_.js +0 -2
@@ -5,11 +5,12 @@
5
5
  * SPLIT FROM `learn` FOR THE CLI's SAKE. `learn` spawns the model CLI, and the web console
6
6
  * (`js/web/api.mjs`, `js/web/actions.mjs`), `geneseed memory` (`js/maintain/memory.mjs`) and the
7
7
  * loop catalogue (`js/loop/catalog.mjs`) imported the hook module only for `frontmatter` and
8
- * `memoryDropIndex` — which dragged `node:child_process` into `bin/geneseed-cli.mjs`'s closure
8
+ * `memoryDropIndex` — which dragged Node's child-process module into `bin/geneseed-cli.mjs`'s
9
+ * closure (the generator now imports this file too, and its spawn ban greps for that literal)
9
10
  * and made the hook module a row in `tests/unit/hook_cli.test.mjs`'s spawn allow-list for a
10
11
  * spawn the CLI never makes. Everything here is fs-only; `hooks-learn.mjs` is the one spawner.
11
12
  */
12
- import { existsSync, mkdirSync } from 'node:fs';
13
+ import { existsSync, mkdirSync, renameSync, rmdirSync, unlinkSync } from 'node:fs';
13
14
  import path from 'node:path';
14
15
  import { readText, writeText } from '../lib/fs.mjs';
15
16
  import { normcase } from '../lib/paths.mjs';
@@ -24,6 +25,25 @@ const FILE_SEP_RE = /^---FILE---[^\S\n]*$/m;
24
25
  const MEMORY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
25
26
  const MAX_AGENT_BULLETS = 100;
26
27
 
28
+ /**
29
+ * The seed MEMORY.md body. `js/build/stubs.mjs` seeds it from here, and an emptied store is
30
+ * written back to exactly these bytes, so the context hook's seed hash keeps skipping it.
31
+ */
32
+ export const MEMORY_SEED = '# Memory Index\n';
33
+
34
+ /**
35
+ * THE INDEX HAS TWO LEVELS. MEMORY.md is a map of topics, injected every session. Each
36
+ * `topics/<topic>.md` lists the facts under that topic, and the agent opens it on demand.
37
+ * A flat one-line-per-fact index overran the context hook's session budget at about 200
38
+ * facts and lost the newest ones, which is why it was split.
39
+ */
40
+ const TOPICS = 'topics';
41
+ const MAP_HINT = 'Each topic file lists its facts; open the one the task needs.';
42
+ // A fact pointer: `- [title](slug.md)` in a legacy flat index, `- [title](../slug.md)` in a
43
+ // topic file. The slug may not hold a `/`, so a map line (`topics/x.md`) is never a fact.
44
+ const FACT_LINE_RE = /^- \[([^\]]*)\]\((?:\.\.\/)?([^/)]+)\.md\)(.*)$/;
45
+ const MAP_LINE_RE = /^- \[[^\]]*\]\(topics\/[^)]+\.md\)/;
46
+
27
47
  /** `append_agent_lesson` — one dated bullet, capped at the newest MAX_AGENT_BULLETS. */
28
48
  export function appendAgentLesson(memDir, agent, lesson) {
29
49
  const d = path.join(memDir, 'agents');
@@ -77,45 +97,247 @@ function globMd(dir) {
77
97
  /** Stems that name the store's own files, never a fact — `MEMORY` is the index itself. */
78
98
  const RESERVED_STEMS = new Set(['memory', 'readme']);
79
99
 
80
- /** `_existing_slugs` — slugs already stored, so learn never re-emits a known fact. */
100
+ /**
101
+ * `_existing_slugs` — slugs already stored. learn never CREATES one of these; it may only
102
+ * update or archive it by name (`writeMemories`).
103
+ */
81
104
  export function existingSlugs(memDir) {
82
105
  return new Set(globMd(memDir).map(stem).filter((s) => !RESERVED_STEMS.has(s.toLowerCase())));
83
106
  }
84
107
 
108
+ /** Lines of a text file, or null when it cannot be read. */
109
+ function readLines(p) {
110
+ try { return splitLines(readText(p)); } catch { return null; }
111
+ }
112
+
113
+ /**
114
+ * Write `text` only when the file does not already hold it. A second run of a rebuild then
115
+ * touches nothing, which keeps `golden --idempotent` green and leaves the user's mtimes alone.
116
+ */
117
+ function writeIfChanged(p, text) {
118
+ const old = readLines(p);
119
+ if (old && `${old.join('\n')}\n` === text) return;
120
+ writeText(p, text);
121
+ }
122
+
123
+ /** A topic name as a file stem: `[a-z0-9-]`, any other run becomes `-`, empty becomes `misc`. */
124
+ function topicSlug(raw) {
125
+ return raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'misc';
126
+ }
127
+
85
128
  /**
86
- * `_write_memories` — split the model output into files, write each NEW one, and append a
87
- * pointer line to MEMORY.md. `existing` is MUTATED, so a reply carrying the same slug
88
- * twice writes it once.
129
+ * The topic a fact files under: `topic:`, else `type:`, else `misc`.
130
+ *
131
+ * NESTED `type:` IS ACCEPTED ON PURPOSE. The flat frontmatter parser trims keys, so Claude
132
+ * Code's own memory shape, with `type:` indented under `metadata:`, reads as `type`.
133
+ */
134
+ const topicOf = (fm) => topicSlug(fmGet(fm, 'topic') || fmGet(fm, 'type'));
135
+
136
+ /**
137
+ * Every fact on disk, in the index's sort order: slug -> { desc, topic }. Top level only, so
138
+ * `archive/` and `agents/` are never facts. Exported for learn's prompt, which lists them.
139
+ */
140
+ export function readFacts(memDir) {
141
+ const facts = new Map();
142
+ for (const name of sortPaths(globMd(memDir))) {
143
+ const s = stem(name);
144
+ if (RESERVED_STEMS.has(s.toLowerCase())) continue;
145
+ try {
146
+ const [fm] = frontmatter(readText(path.join(memDir, name)));
147
+ facts.set(s, { desc: fmGet(fm, 'description').trim(), topic: topicOf(fm) });
148
+ } catch { continue; }
149
+ }
150
+ return facts;
151
+ }
152
+
153
+ /**
154
+ * Every fact pointer the index holds now, from a flat MEMORY.md and from the topic files:
155
+ * slug -> { title, rest }. `rest` is everything after the link, hook text included.
156
+ */
157
+ function indexedFacts(memDir) {
158
+ const found = new Map();
159
+ const tdir = path.join(memDir, TOPICS);
160
+ const files = [path.join(memDir, 'MEMORY.md'), ...globMd(tdir).map((f) => path.join(tdir, f))];
161
+ for (const f of files) {
162
+ for (const line of readLines(f) ?? []) {
163
+ const m = FACT_LINE_RE.exec(line.trim());
164
+ if (m) found.set(m[2], { title: m[1], rest: m[3] });
165
+ }
166
+ }
167
+ return found;
168
+ }
169
+
170
+ /**
171
+ * Rewrite MEMORY.md as the map: one line per topic file with its fact count.
172
+ *
173
+ * LINES THE MAP DID NOT GENERATE ARE CARRIED, so a note the user wrote into the index
174
+ * survives. `dropFlat` also drops legacy flat fact pointers; a rebuild passes it because it
175
+ * has just moved them into topic files. With no topic and nothing carried, the file goes
176
+ * back to the seed.
177
+ */
178
+ function writeMap(memDir, dropFlat) {
179
+ const idx = path.join(memDir, 'MEMORY.md');
180
+ const carried = (readLines(idx) ?? []).filter((l) => {
181
+ const t = l.trim();
182
+ return t && t !== MEMORY_SEED.trim() && t !== MAP_HINT && !MAP_LINE_RE.test(t)
183
+ && !(dropFlat && FACT_LINE_RE.test(t));
184
+ });
185
+ const tdir = path.join(memDir, TOPICS);
186
+ const topics = globMd(tdir).map(stem).sort().map((t) => {
187
+ const n = (readLines(path.join(tdir, `${t}.md`)) ?? [])
188
+ .filter((l) => FACT_LINE_RE.test(l.trim())).length;
189
+ return `- [${t}](${TOPICS}/${t}.md) — ${n} fact${n === 1 ? '' : 's'}`;
190
+ });
191
+ const body = [...(topics.length ? ['', MAP_HINT, '', ...topics] : []),
192
+ ...(carried.length ? ['', ...carried] : [])];
193
+ writeIfChanged(idx, body.length ? `${MEMORY_SEED}${body.join('\n')}\n` : MEMORY_SEED);
194
+ }
195
+
196
+ /** Remove the topics dir when nothing is left in it. */
197
+ function pruneTopicsDir(memDir) {
198
+ try { rmdirSync(path.join(memDir, TOPICS)); } catch { /* not empty, or absent */ }
199
+ }
200
+
201
+ /**
202
+ * Rebuild both index levels from the fact files on disk.
203
+ *
204
+ * A fact the index already points at keeps its existing line, title and hook included,
205
+ * unless `refresh` is set. That makes a migration or a learn run lossless. `consolidate`
206
+ * passes `refresh`, because it exists to re-read the descriptions; learn passes the slugs
207
+ * it just UPDATED as `fresh`, because their old line describes a fact that is gone. Topic
208
+ * files no topic uses any more are deleted: they are generated, never facts.
209
+ */
210
+ function rebuildIndex(memDir, refresh, fresh = new Set()) {
211
+ const facts = readFacts(memDir);
212
+ const old = indexedFacts(memDir);
213
+ const byTopic = new Map();
214
+ for (const [slug, { desc, topic }] of facts) {
215
+ const o = refresh || fresh.has(slug) ? null : old.get(slug);
216
+ const line = o ? `- [${o.title}](../${slug}.md)${o.rest}`
217
+ : `- [${slug}](../${slug}.md)${desc ? ` — ${desc}` : ''}`;
218
+ if (!byTopic.has(topic)) byTopic.set(topic, []);
219
+ byTopic.get(topic).push(line);
220
+ }
221
+ const tdir = path.join(memDir, TOPICS);
222
+ for (const f of globMd(tdir)) {
223
+ if (!byTopic.has(stem(f))) unlinkSync(path.join(tdir, f));
224
+ }
225
+ if (byTopic.size) mkdirSync(tdir, { recursive: true });
226
+ for (const [topic, lines] of byTopic) {
227
+ writeIfChanged(path.join(tdir, `${topic}.md`), `# ${topic}\n\n${lines.join('\n')}\n`);
228
+ }
229
+ if (!byTopic.size) pruneTopicsDir(memDir);
230
+ writeMap(memDir, true);
231
+ return { facts, old };
232
+ }
233
+
234
+ /**
235
+ * Move a flat MEMORY.md to the two-level index, once, at emit time.
236
+ *
237
+ * Acts only when MEMORY.md still holds a flat fact pointer and at least one fact exists, so
238
+ * a seed-only store and an already-migrated store are left alone. It rewrites MEMORY.md and
239
+ * `topics/*.md` only, never a fact file, and it keeps each pointer's own title and hook.
240
+ */
241
+ export function migrateMemoryIndex(memDir) {
242
+ const lines = readLines(path.join(memDir, 'MEMORY.md'));
243
+ if (!lines?.some((l) => FACT_LINE_RE.test(l.trim())) || !existingSlugs(memDir).size) return false;
244
+ rebuildIndex(memDir, false);
245
+ return true;
246
+ }
247
+
248
+ /** The chunk without its frontmatter `action:` line, which is an instruction, not the fact. */
249
+ function stripAction(chunk) {
250
+ const lines = chunk.split('\n');
251
+ const close = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
252
+ if (lines[0].trim() !== '---' || close < 0) return chunk;
253
+ return lines.filter((l, i) => i === 0 || i >= close || !/^\s*action\s*:/.test(l)).join('\n');
254
+ }
255
+
256
+ /**
257
+ * Whether the stored fact carries a `force:`. Such a fact is never updated or archived by
258
+ * learn: the memory README's Bridge rule has it revised inline with its supersession
259
+ * recorded, a `constraint` only by the user, and the model sees no fact's body or `force:`.
260
+ */
261
+ function hasForce(memDir, slug) {
262
+ try { return !!fmGet(frontmatter(readText(path.join(memDir, `${slug}.md`)))[0], 'force'); } catch {
263
+ return false;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Move `<slug>.md` into `archive/`, never over a file already there: a taken name gets
269
+ * `-2`, `-3`, ... A counter rather than a date, so a second archive the same day cannot
270
+ * collide. RENAME, never unlink: an archived fact leaves the index but stays on disk.
271
+ * Returns the path it landed at. The index is the caller's: `geneseed memory rm` drops the
272
+ * line (`memoryDropIndex`), learn rebuilds both levels.
273
+ */
274
+ export function archiveFact(memDir, slug) {
275
+ const adir = path.join(memDir, 'archive');
276
+ mkdirSync(adir, { recursive: true });
277
+ let dest = path.join(adir, `${slug}.md`);
278
+ for (let n = 2; existsSync(dest); n += 1) dest = path.join(adir, `${slug}-${n}.md`);
279
+ renameSync(path.join(memDir, `${slug}.md`), dest);
280
+ return dest;
281
+ }
282
+
283
+ /**
284
+ * `_write_memories` — split the model output into files and apply each, then rebuild the
285
+ * two-level index from disk with the same builder `consolidate` uses. A rebuild rather than
286
+ * an append, so a legacy flat index is migrated on the way and a topic count can never
287
+ * drift from its file. `existing` is MUTATED to match the store afterwards.
288
+ *
289
+ * A chunk's frontmatter `action:` says what to do with its `name:`:
290
+ * - none (or `create`): write a NEW fact; a slug already stored is skipped;
291
+ * - `update`: archive the stored fact (`archiveFact`), then write this text, which the
292
+ * model has merged, in its place. The model sees only the description, so the old
293
+ * body, links and extra frontmatter survive in `archive/` and every update is reversible;
294
+ * - `archive`: move the stored fact to `archive/` (`archiveFact`).
295
+ * An update or archive naming no stored fact, or one carrying a `force:` (`hasForce`), is
296
+ * skipped, as is any other action, and each slug is acted on once per reply.
89
297
  *
90
298
  * A reserved stem is refused (`name: MEMORY` would overwrite the index), and names compare
91
299
  * LOWERCASE: on Windows and macOS `User-Prefs.md` and `user-prefs.md` are one file, so a
92
- * case variant of a stored slug would overwrite it.
300
+ * case variant of a stored slug would overwrite it. An update or archive therefore acts on
301
+ * the STORED spelling, whatever case the model used.
302
+ *
303
+ * Returns `{ created, updated, archived }`, each a list of slugs.
93
304
  */
94
305
  export function writeMemories(modelOutput, memDir, existing) {
95
- const taken = new Set([...existing].map((s) => s.toLowerCase()));
96
- const written = [];
97
- const indexLines = [];
306
+ const stored = new Map([...existing].map((s) => [s.toLowerCase(), s]));
307
+ const done = new Set();
308
+ const res = { created: [], updated: [], archived: [] };
98
309
  for (let chunk of modelOutput.split(FILE_SEP_RE)) {
99
310
  chunk = chunk.trim();
100
311
  if (!chunk || chunk.toUpperCase() === 'NOTHING') continue;
101
312
  const [fm] = frontmatter(chunk);
102
313
  const name = fmGet(fm, 'name').trim();
103
314
  const key = name.toLowerCase();
104
- if (!MEMORY_SLUG_RE.test(name) || RESERVED_STEMS.has(key) || taken.has(key)) continue;
105
- writeText(path.join(memDir, `${name}.md`), `${chunk.replace(/\n+$/, '')}\n`);
106
- existing.add(name);
107
- taken.add(key);
108
- written.push(name);
109
- const desc = fmGet(fm, 'description').trim();
110
- indexLines.push(`- [${name}](${name}.md)${desc ? ` \u2014 ${desc}` : ''}`);
315
+ const action = fmGet(fm, 'action').trim().toLowerCase() || 'create';
316
+ if (!MEMORY_SLUG_RE.test(name) || RESERVED_STEMS.has(key) || done.has(key)) continue;
317
+ const slug = stored.get(key);
318
+ if (slug && action !== 'create' && hasForce(memDir, slug)) continue;
319
+ const body = `${stripAction(chunk).replace(/\n+$/, '')}\n`;
320
+ if (action === 'create' && !slug) {
321
+ writeText(path.join(memDir, `${name}.md`), body);
322
+ existing.add(name);
323
+ stored.set(key, name);
324
+ res.created.push(name);
325
+ } else if (action === 'update' && slug) {
326
+ archiveFact(memDir, slug); // the old version first: the model never saw its body
327
+ writeText(path.join(memDir, `${slug}.md`), body);
328
+ res.updated.push(slug);
329
+ } else if (action === 'archive' && slug) {
330
+ archiveFact(memDir, slug);
331
+ existing.delete(slug);
332
+ stored.delete(key);
333
+ res.archived.push(slug);
334
+ } else continue;
335
+ done.add(key);
111
336
  }
112
- if (indexLines.length) {
113
- const index = path.join(memDir, 'MEMORY.md');
114
- const current = existsSync(index) ? `${readText(index).replace(/\n+$/, '')}\n`
115
- : '# Memory Index\n';
116
- writeText(index, `${current}${indexLines.join('\n')}\n`);
337
+ if (res.created.length || res.updated.length || res.archived.length) {
338
+ rebuildIndex(memDir, false, new Set(res.updated));
117
339
  }
118
- return written;
340
+ return res;
119
341
  }
120
342
 
121
343
  /**
@@ -134,57 +356,58 @@ export function writeMemories(modelOutput, memDir, existing) {
134
356
  * so a hand-written index in a different shape still loses its entry. And the file is only
135
357
  * REWRITTEN when something actually changed, which keeps a delete of an unindexed fact from
136
358
  * touching the user's mtime.
359
+ *
360
+ * BOTH LEVELS: the `(<name>.md)` line of a legacy flat MEMORY.md, and the `(../<name>.md)`
361
+ * line of whichever topic file holds it. A topic file left with no fact is deleted, and the
362
+ * map's counts are refreshed only when a topic file changed. Matching the two exact forms
363
+ * keeps `b` from taking `ab.md` with it.
137
364
  */
138
365
  export function memoryDropIndex(memDir, name) {
139
- const idx = path.join(memDir, 'MEMORY.md');
140
- let lines;
141
- try {
142
- lines = splitLines(readText(idx));
143
- } catch {
144
- return; // the reference's `except OSError: return`
366
+ dropLines(path.join(memDir, 'MEMORY.md'), `(${name}.md)`);
367
+ const tdir = path.join(memDir, TOPICS);
368
+ let touched = false;
369
+ for (const f of globMd(tdir)) {
370
+ const p = path.join(tdir, f);
371
+ const left = dropLines(p, `(../${name}.md)`);
372
+ if (!left) continue;
373
+ touched = true;
374
+ if (!left.some((l) => FACT_LINE_RE.test(l.trim()))) {
375
+ try { unlinkSync(p); } catch { /* as below */ }
376
+ }
145
377
  }
146
- const keep = lines.filter((ln) => !ln.includes(`(${name}.md)`));
147
- if (keep.length === lines.length) return;
378
+ if (!touched) return;
379
+ pruneTopicsDir(memDir);
380
+ try { writeMap(memDir, false); } catch { /* the reference swallows OSError here too */ }
381
+ }
382
+
383
+ /** Drop every line holding `needle`; the kept lines when something went, else null. */
384
+ function dropLines(p, needle) {
385
+ const lines = readLines(p);
386
+ if (!lines) return null; // the reference's `except OSError: return`
387
+ const keep = lines.filter((ln) => !ln.includes(needle));
388
+ if (keep.length === lines.length) return null;
148
389
  try {
149
- writeText(idx, `${keep.join('\n')}\n`);
390
+ writeText(p, `${keep.join('\n')}\n`);
150
391
  } catch { /* the reference swallows OSError here too */ }
392
+ return keep;
151
393
  }
152
394
 
153
395
  /**
154
- * `consolidate_memory` — rebuild MEMORY.md from the fact files actually on disk: index
155
- * orphans, prune dead lines, report duplicate descriptions (never auto-merge — nuance is
156
- * the user's to keep).
396
+ * `consolidate_memory` — rebuild both index levels from the fact files actually on disk:
397
+ * index orphans, prune dead lines, refresh each line from its fact's description, report
398
+ * duplicate descriptions. It REPORTS them and never merges: merging is a judgement, made by
399
+ * the model at learn time (`writeMemories`'s `update`) or by the user, never by this rebuild. `added` and
400
+ * `pruned` compare against the pointers of BOTH levels, so a migration counts as neither.
157
401
  */
158
402
  export function consolidateMemory(memDir) {
159
- const skip = RESERVED_STEMS;
160
- const facts = new Map();
161
- for (const name of sortPaths(globMd(memDir))) {
162
- const s = stem(name);
163
- if (skip.has(s.toLowerCase())) continue;
164
- try {
165
- facts.set(s, fmGet(frontmatter(readText(path.join(memDir, name)))[0], 'description').trim());
166
- } catch { continue; }
167
- }
168
- const index = path.join(memDir, 'MEMORY.md');
169
- const oldSlugs = new Set();
170
- if (existsSync(index)) {
171
- for (const line of splitLines(readText(index))) {
172
- const m = /^- \[[^\]]*\]\(([^)]+)\.md\)/.exec(line.trim());
173
- if (m) oldSlugs.add(m[1]);
174
- }
175
- }
176
- const lines = ['# Memory Index', ''];
177
- for (const [slug, desc] of facts) {
178
- lines.push(`- [${slug}](${slug}.md)${desc ? ` \u2014 ${desc}` : ''}`);
179
- }
180
- writeText(index, `${lines.join('\n')}\n`);
403
+ const { facts, old } = rebuildIndex(memDir, true);
181
404
  const seen = new Map();
182
405
  const dups = [];
183
- for (const [slug, desc] of facts) {
406
+ for (const [slug, { desc }] of facts) {
184
407
  if (desc && seen.has(desc)) dups.push([seen.get(desc), slug]);
185
408
  if (!seen.has(desc)) seen.set(desc, slug);
186
409
  }
187
- const added = [...facts.keys()].filter((s) => !oldSlugs.has(s)).sort();
188
- const pruned = [...oldSlugs].filter((s) => !facts.has(s)).sort();
410
+ const added = [...facts.keys()].filter((s) => !old.has(s)).sort();
411
+ const pruned = [...old.keys()].filter((s) => !facts.has(s)).sort();
189
412
  return { added, pruned, duplicates: dups };
190
413
  }
@@ -196,7 +196,8 @@ export function colorThemeProblems() {
196
196
  * rebuild able to clear it.
197
197
  *
198
198
  * Host-state files (context.json, MEMORY.md, the markers) are created once and never
199
- * rendered, so they are not in the render set and are correctly ignored. Notebook files
199
+ * rendered (MEMORY.md's one later write is `migrateMemoryIndex` moving a flat index to
200
+ * topics), so they are not in the render set and are correctly ignored. Notebook files
200
201
  * except its `.gitignore` are seed-once and agent-owned after the first build, so they are
201
202
  * compared only for EXISTENCE.
202
203
  */
@@ -28,7 +28,7 @@ import { isFile, readText } from '../lib/fs.mjs';
28
28
  * console draws it on the cost chart, so it lives here, once, where both read it. Raise it by
29
29
  * exactly the measured overrun and say why in that test's docblock, which keeps its history.
30
30
  */
31
- export const CARRIER_CEILING = { full: 62_076, lean: 38_890 };
31
+ export const CARRIER_CEILING = { full: 62_107, lean: 38_976 };
32
32
 
33
33
  const decode = (p) => readText(p).split('\r\n').join('\n');
34
34
 
@@ -32,6 +32,28 @@ import { normcase, toPlatformPath } from '../lib/paths.mjs';
32
32
  const localSettings = (repo, host) =>
33
33
  path.join(repo, HOSTS.find((h) => h.host === host).projectMarker, 'settings.local.json');
34
34
 
35
+ /** The `claudeMdExcludes` entry naming a Claude-family global install's CLAUDE.md, as wired. */
36
+ const claudeMdEntry = (cfg) => resolvePath(path.join(cfg, 'CLAUDE.md')).split(path.sep).join('/');
37
+
38
+ /**
39
+ * The worktree caveat, or null when it does not apply: a Claude-family install and an
40
+ * excluded folder that is a git repository (a `.git` there — no repo, no worktrees).
41
+ *
42
+ * WHY A WARNING AND NOT A WRITE: `settings.local.json` is gitignored, so a `git worktree add`
43
+ * of the excluded repo never carries it and the global CLAUDE.md loads there again (the hooks
44
+ * do stand down — `sovereignBypass` follows the worktree to its main repo). The one durable
45
+ * fix is the repo's TRACKED `settings.json`, and the user decided (2026-10) Geneseed never
46
+ * writes a tracked file in a repo it was told to leave alone — so it prints the snippet.
47
+ */
48
+ function worktreeNote(host, cfg, repo) {
49
+ if (host !== 'claude' && host !== 'openclaude') return null;
50
+ if (!existsSync(path.join(repo, '.git'))) return null;
51
+ const marker = HOSTS.find((h) => h.host === host).projectMarker;
52
+ return `${host}: git worktrees of this repo do not get the gitignored `
53
+ + `${marker}/settings.local.json and still load the global CLAUDE.md — to stop that, commit `
54
+ + `to the repo's ${marker}/settings.json: "claudeMdExcludes": ["${claudeMdEntry(cfg)}"]`;
55
+ }
56
+
35
57
  /** `_global_installs` — every host whose GLOBAL config dir carries a Geneseed manifest. */
36
58
  function globalInstalls() {
37
59
  const out = [];
@@ -112,8 +134,7 @@ export function excludeAdd(target) {
112
134
  const priorWired = (isDict(prior) ? get(prior, 'wired') : null) || {};
113
135
  const wired = {};
114
136
  if ((host === 'claude' || host === 'openclaude') && isDir(repo)) {
115
- const entry = resolvePath(path.join(cfg, 'CLAUDE.md')).split(path.sep).join('/');
116
- const added = wireClaudeExcludes(localSettings(repo, host), [entry]);
137
+ const added = wireClaudeExcludes(localSettings(repo, host), [claudeMdEntry(cfg)]);
117
138
  if (added.length) {
118
139
  wired.claude_md_excludes = added;
119
140
  } else if (get(priorWired, 'claude_md_excludes')) {
@@ -126,6 +147,8 @@ export function excludeAdd(target) {
126
147
  // own project-bypasses-global wiring, or a bailed write) — do NOT fabricate ownership,
127
148
  // or a later excludeRemove would strip wiring we never created.
128
149
  if (!added.length) messages.push(`${host}: claudeMdExcludes already present (kept)`);
150
+ const note = worktreeNote(host, cfg, repo);
151
+ if (note) messages.push(note);
129
152
  }
130
153
  if (host === 'bob' && isDir(repo)) {
131
154
  const stub = path.join(repo, '.bob', 'rules', 'geneseed.md');
@@ -249,6 +272,14 @@ export function cmdExclude(args) {
249
272
  const flag = missing.length
250
273
  ? ` (MISSING from: ${missing.join(', ')} — re-run \`geneseed exclude add\`)` : '';
251
274
  printOut(` ${rec.path} [${rec.hosts.slice().sort().join(', ')}]${flag}\n`);
275
+ for (const { host, cfg } of snap.installs) {
276
+ if (!rec.hosts.includes(host)) continue;
277
+ let note = null;
278
+ try {
279
+ note = worktreeNote(host, cfg, toPlatformPath(rec.path));
280
+ } catch { /* a broken resolver: the listing itself must still print */ }
281
+ if (note) printOut(` note: ${note}\n`);
282
+ }
252
283
  }
253
284
  return 0;
254
285
  }
@@ -25,11 +25,16 @@
25
25
  *
26
26
  * `memoryDropIndex` is the hook module's, not a copy: writing the fact and unwriting its
27
27
  * index line are two halves of one format, and the half that writes lives there.
28
+ *
29
+ * `rm` ARCHIVES BY DEFAULT. A removed fact moves into `archive/` (`archiveFact`, the helper
30
+ * learn uses), so a slip of the keyboard or a click in the console is reversible; only
31
+ * `--purge` unlinks. `--archived` points `list` and `rm` at `archive/` itself, and a file
32
+ * there can only be purged — it has nowhere further to go.
28
33
  */
29
34
  import { readdirSync, unlinkSync } from 'node:fs';
30
35
  import path from 'node:path';
31
36
 
32
- import { memoryDropIndex } from '../hosts/memory-files.mjs';
37
+ import { archiveFact, memoryDropIndex } from '../hosts/memory-files.mjs';
33
38
  import { resolveMemoryDir } from '../hosts/hosts.mjs';
34
39
  import { printOut, printErr, isFile } from '../lib/fs.mjs';
35
40
 
@@ -58,13 +63,28 @@ export function memoryFacts(dir) {
58
63
  .map((n) => n.slice(0, -3)).filter(isFactName).sort();
59
64
  }
60
65
 
61
- /** `api_memory_delete`, minus the HTTP: the file, then its line in the index. */
62
- export function memoryDelete(dir, name) {
63
- if (!isFactName(name)) return false;
66
+ /**
67
+ * Take a fact out of the store and its line out of the index. Archived into `archive/` unless
68
+ * `purge`, which unlinks it. Returns where it went (the archive path, or '' when purged), or
69
+ * null when `name` is not a fact here.
70
+ */
71
+ export function memoryDelete(dir, name, purge = false) {
72
+ if (!isFactName(name)) return null;
64
73
  const p = path.join(dir, `${name}.md`);
74
+ if (!isFile(p)) return null;
75
+ let dest = '';
76
+ if (purge) unlinkSync(p);
77
+ else dest = archiveFact(dir, name);
78
+ memoryDropIndex(dir, name);
79
+ return dest;
80
+ }
81
+
82
+ /** Unlink `archive/<name>.md`. True when a file went. The archive has no index to update. */
83
+ export function memoryPurgeArchived(dir, name) {
84
+ if (!isFactName(name)) return false;
85
+ const p = path.join(dir, 'archive', `${name}.md`);
65
86
  if (!isFile(p)) return false;
66
87
  unlinkSync(p);
67
- memoryDropIndex(dir, name);
68
88
  return true;
69
89
  }
70
90
 
@@ -78,13 +98,14 @@ export function cmdMemory(args) {
78
98
  + 'or run this from a directory with a memory/ store.\n');
79
99
  return 1;
80
100
  }
101
+ const where = args.archived ? path.join(dir, 'archive') : dir;
81
102
  if (args.action === 'list') {
82
- const facts = memoryFacts(dir);
103
+ const facts = memoryFacts(where);
83
104
  if (!facts.length) {
84
- printOut(`[memory] ${dir}: no facts.\n`);
105
+ printOut(`[memory] ${where}: no facts.\n`);
85
106
  return 0;
86
107
  }
87
- printOut(`[memory] ${dir}\n`);
108
+ printOut(`[memory] ${where}\n`);
88
109
  for (const name of facts) printOut(` ${name}\n`);
89
110
  return 0;
90
111
  }
@@ -92,14 +113,20 @@ export function cmdMemory(args) {
92
113
  printErr('[memory] rm needs the name of a fact.\n');
93
114
  return 2;
94
115
  }
95
- if (!memoryDelete(dir, args.name)) {
116
+ if (args.archived && !args.purge) {
117
+ printErr('[memory] an archived fact can only be purged: add --purge to delete it.\n');
118
+ return 2;
119
+ }
120
+ const done = args.archived ? (memoryPurgeArchived(dir, args.name) ? '' : null)
121
+ : memoryDelete(dir, args.name, Boolean(args.purge));
122
+ if (done === null) {
96
123
  // ONE MESSAGE FOR BOTH REFUSALS, which is the endpoint's shape too — it raises the same
97
124
  // `NotFound(name)` for a reserved name, a name with a separator and a fact that is not
98
125
  // there. Telling a caller that `MEMORY` exists but is protected is a distinction this
99
126
  // verb has no reason to draw.
100
- printErr(`[memory] no such fact '${args.name}' in ${dir}.\n`);
127
+ printErr(`[memory] no such fact '${args.name}' in ${where}.\n`);
101
128
  return 1;
102
129
  }
103
- printOut(`[memory] deleted ${args.name}.\n`);
130
+ printOut(done ? `[memory] archived ${args.name} -> ${done}\n` : `[memory] deleted ${args.name}.\n`);
104
131
  return 0;
105
132
  }
@@ -276,7 +276,8 @@ export function apiPickFolder(done) {
276
276
  // ---- memory ------------------------------------------------------------------------------
277
277
 
278
278
  /**
279
- * One fact file, and its line in the MEMORY.md index.
279
+ * One fact file, and its line in the MEMORY.md index. The file is ARCHIVED into
280
+ * `memory/archive/`, never unlinked: the console has no purge, `geneseed memory rm --purge` does.
280
281
  *
281
282
  * `name` is a BARE SLUG: a path separator, or one of the two reserved names, is refused, so
282
283
  * this can only ever remove a fact inside the resolved memory dir. MEMORY.md is the index the
@@ -290,9 +291,9 @@ export function apiMemoryDelete(state, name) {
290
291
  // `memoryDelete` is the `geneseed memory rm` engine, and its reserved-name check is
291
292
  // CASE-FOLDED: this endpoint's own compared `MEMORY` exactly, so on Windows and macOS `memory`
292
293
  // resolved to MEMORY.md, passed, and deleted the index the agent reads at session start.
293
- if (!memoryDelete(d, name)) throw new NotFound(name);
294
+ if (memoryDelete(d, name) === null) throw new NotFound(name);
294
295
  state.refresh();
295
- return { deleted: name };
296
+ return { archived: name };
296
297
  }
297
298
 
298
299
  // ---- sovereign-repo exclusions -------------------------------------------------------------
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
3
3
  "name": "geneseed",
4
- "version": "4.3.0",
4
+ "version": "4.4.0",
5
5
  "description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
6
6
  "keywords": [
7
7
  "ai",
package/registry.json CHANGED
@@ -57,6 +57,13 @@
57
57
  "added": "2026-06-08",
58
58
  "last_verified": ""
59
59
  },
60
+ "agents/memory-curator": {
61
+ "status": "experimental",
62
+ "version": "1.0.0",
63
+ "owner": "Guillaume Lemer",
64
+ "added": "2026-10-16",
65
+ "last_verified": ""
66
+ },
60
67
  "agents/operator": {
61
68
  "status": "approved",
62
69
  "version": "1.0.0",
package/src/AGENT.md.tmpl CHANGED
@@ -166,6 +166,7 @@ Active packs: {{DOCTRINES_LIST}}{{EXCLUDED_RULES_LINE}}
166
166
  | [performance]({{DIR_AGENTS}}/performance.md) | something is reported slow or heavy, or a diff touches a hot path — measured, never guessed |
167
167
  | [explorer]({{DIR_AGENTS}}/explorer.md) | you must sweep many files for an answer but only want the conclusion |
168
168
  | [researcher]({{DIR_AGENTS}}/researcher.md) | a question needs the open web — fetched, cross-checked, and returned as sourced findings, not pages |
169
+ | [memory-curator]({{DIR_AGENTS}}/memory-curator.md) | the {{MEMORY}} store needs bulk curation |
169
170
  | [advocate]({{DIR_AGENTS}}/advocate.md) | a council debate needs the strongest case **for** the motion argued in isolation |
170
171
  | [skeptic]({{DIR_AGENTS}}/skeptic.md) | a council debate needs a devil's advocate to attack the motion's failure modes |
171
172
  | [pragmatist]({{DIR_AGENTS}}/pragmatist.md) | a council debate needs the cost, effort, and complexity weighed against the payoff |