geneseed 4.3.1 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +4 -4
  3. package/SHIPPED.md +2 -2
  4. package/adapters/opencode/plugins/geneseed-context.js +18 -1
  5. package/adapters/opencode/plugins/geneseed-learn.js +178 -46
  6. package/docs/concepts/agents.md +1 -0
  7. package/docs/concepts/hooks.md +1 -1
  8. package/docs/concepts/skills.md +1 -0
  9. package/docs/reference/plugin-learn.md +2 -2
  10. package/docs/understand/a-day.md +3 -3
  11. package/docs/understand/machine-claude.md +1 -1
  12. package/docs/understand/machine-opencode.md +1 -1
  13. package/harness.config.json +1 -1
  14. package/js/README.md +1 -1
  15. package/js/build/bundle.mjs +2 -0
  16. package/js/build/emit-claude.mjs +2 -0
  17. package/js/build/emit-opencode.mjs +2 -0
  18. package/js/build/stubs.mjs +9 -4
  19. package/js/cli-table.json +32 -2
  20. package/js/hosts/hooks-context.mjs +25 -1
  21. package/js/hosts/hooks-learn.mjs +42 -16
  22. package/js/hosts/memory-files.mjs +283 -60
  23. package/js/inspect/checks-build.mjs +2 -1
  24. package/js/inspect/cost.mjs +1 -1
  25. package/js/inspect/inventory.mjs +1 -1
  26. package/js/maintain/memory.mjs +38 -11
  27. package/js/web/actions.mjs +4 -3
  28. package/package.json +1 -1
  29. package/registry.json +14 -0
  30. package/src/AGENT.md.tmpl +2 -0
  31. package/src/agents/memory-curator.md +71 -0
  32. package/src/memory/README.md +41 -10
  33. package/src/skills/deck.md +56 -0
  34. package/src/skills/frontend-design.md +1 -0
  35. package/src/skills/geneseed.md +1 -1
  36. package/src/skills/rule/consolidate.md +18 -11
  37. package/src/skills/rule.md +9 -6
  38. package/themes/_TEMPLATE.json +2 -0
  39. package/themes/biker.json +2 -0
  40. package/themes/commentator.json +2 -0
  41. package/themes/cyberpunk.json +2 -0
  42. package/themes/gamer.json +2 -0
  43. package/themes/imperial.json +2 -0
  44. package/themes/joker.json +2 -0
  45. package/themes/marvin.json +2 -0
  46. package/themes/mean.json +2 -0
  47. package/themes/military.json +2 -0
  48. package/themes/neutral.json +2 -0
  49. package/themes/pirate.json +2 -0
  50. package/themes/sports.json +2 -0
  51. package/themes/verstappen.json +2 -0
  52. package/themes/wizard.json +2 -0
  53. package/web/dist/assets/{Activity-UiGvYgEl.js → Activity-BZE6REef.js} +1 -1
  54. package/web/dist/assets/{ActivityDetail-COXCSeXn.js → ActivityDetail-DCB8Cr2W.js} +1 -1
  55. package/web/dist/assets/{Dashboard-DkdOUpke.js → Dashboard-B4Drq5wp.js} +2 -2
  56. package/web/dist/assets/{Docs-TtVyc_Dy.js → Docs-D30CiqYy.js} +1 -1
  57. package/web/dist/assets/{GreenhouseView-Bh0xq6Up.js → GreenhouseView-Djt-4oO5.js} +1 -1
  58. package/web/dist/assets/{Installs-J3IoSqk6.js → Installs-B3bw5ckh.js} +1 -1
  59. package/web/dist/assets/{JournalView-BrD33V35.js → JournalView-OWhx9YKg.js} +1 -1
  60. package/web/dist/assets/{Laws-Cjkenizx.js → Laws-B9day2FV.js} +1 -1
  61. package/web/dist/assets/Library-BpIrLH-O.js +1 -0
  62. package/web/dist/assets/{Loops-D15LDhZO.js → Loops-CxH14kyE.js} +1 -1
  63. package/web/dist/assets/{OperatorHudView-B5CcWoJ6.js → OperatorHudView-otOz6Gha.js} +1 -1
  64. package/web/dist/assets/{Personal-BBdGbJn6.js → Personal-abL6089y.js} +1 -1
  65. package/web/dist/assets/{Tabs-s8T501po.js → Tabs-DjWN0i7A.js} +1 -1
  66. package/web/dist/assets/{drawable-DALqyS4l.js → drawable-C2ZsBZCv.js} +1 -1
  67. package/web/dist/assets/{index-BP4foUp0.js → index-CdpLpPQN.js} +2 -2
  68. package/web/dist/index.html +1 -1
  69. package/web/dist/assets/Library-CKyeB096.js +0 -1
package/js/cli-table.json CHANGED
@@ -619,7 +619,7 @@
619
619
  ],
620
620
  "dest": "consolidate",
621
621
  "metavar": null,
622
- "help": "rebuild MEMORY.md from the fact files on disk: re-index orphans, prune dead lines, report duplicate descriptions",
622
+ "help": "rebuild the memory index (MEMORY.md and topics/) from the fact files on disk: re-index orphans, prune dead lines, report duplicate descriptions",
623
623
  "choices": null,
624
624
  "default": false,
625
625
  "required": false,
@@ -909,7 +909,7 @@
909
909
  },
910
910
  {
911
911
  "name": "memory",
912
- "help": "manage the memory store: list the facts the agent reads, or remove one by name",
912
+ "help": "manage the memory store: list the facts the agent reads, or remove one by name (archived into memory/archive/; --purge deletes)",
913
913
  "description": "",
914
914
  "positionals": [
915
915
  {
@@ -957,6 +957,36 @@
957
957
  "is_flag": false,
958
958
  "type": null,
959
959
  "hidden": false
960
+ },
961
+ {
962
+ "names": [
963
+ "--archived"
964
+ ],
965
+ "dest": "archived",
966
+ "metavar": null,
967
+ "help": "act on memory/archive/ instead: list the archived facts, or (with --purge) delete one",
968
+ "choices": null,
969
+ "default": false,
970
+ "required": false,
971
+ "nargs": "0",
972
+ "is_flag": true,
973
+ "type": null,
974
+ "hidden": false
975
+ },
976
+ {
977
+ "names": [
978
+ "--purge"
979
+ ],
980
+ "dest": "purge",
981
+ "metavar": null,
982
+ "help": "rm deletes the fact for good instead of archiving it (cannot be undone)",
983
+ "choices": null,
984
+ "default": false,
985
+ "required": false,
986
+ "nargs": "0",
987
+ "is_flag": true,
988
+ "type": null,
989
+ "hidden": false
960
990
  }
961
991
  ],
962
992
  "mutex": []
@@ -146,6 +146,28 @@ const SEED_SHA256 = new Set([
146
146
  ]);
147
147
  export { SESSION_FILES, SEED_SHA256 };
148
148
 
149
+ // A topic line of the memory map (`js/hosts/memory-files.mjs` writes it) and its fact count.
150
+ const MAP_COUNT_RE = /^- \[([^\]]*)\]\(topics\/[^)]+\.md\) — (\d+) facts?$/;
151
+ // Past this many facts a topic file stops being a quick read, and the curator splits it.
152
+ const CURATE_TOPIC_MAX = 40;
153
+ const CURATE_NUDGE = '[memory] A topic holds over 40 facts, or facts sit in misc: offer the user '
154
+ + 'to dispatch the memory-curator agent to merge, re-topic and archive this store.';
155
+ export { CURATE_TOPIC_MAX, CURATE_NUDGE };
156
+
157
+ /**
158
+ * The one-line curation nudge for a memory map, or ''. Read off the map's own counts, so it
159
+ * costs no extra file read on the hook path. Both triggers are ones a curation pass clears; a
160
+ * store that is merely LARGE is not one, since a curated large store would be nagged forever.
161
+ * The OpenCode context plugin carries the same rule (parity gated in tests/unit/claude.test.mjs).
162
+ */
163
+ export function memoryNudge(map) {
164
+ for (const line of map.split('\n')) {
165
+ const m = MAP_COUNT_RE.exec(line.trim());
166
+ if (m && (m[1] === 'misc' || Number(m[2]) > CURATE_TOPIC_MAX)) return CURATE_NUDGE;
167
+ }
168
+ return '';
169
+ }
170
+
149
171
  /**
150
172
  * The session files present under `hookRoot` and changed from their seed, as `{ rel, abs, text }`
151
173
  * (text CRLF-folded, trailing newlines trimmed). `$GENESEED_WIKI` overrides the wiki declaration,
@@ -164,7 +186,9 @@ export function sessionFiles(hookRoot) {
164
186
  let text;
165
187
  try { text = readFileSync(abs, 'utf8').replace(/\r\n/g, '\n'); } catch { continue; }
166
188
  if (SEED_SHA256.has(createHash('sha256').update(text).digest('hex'))) continue;
167
- found.push({ rel, abs, text: text.replace(/\n+$/, '') });
189
+ text = text.replace(/\n+$/, '');
190
+ const nudge = rel.endsWith('MEMORY.md') ? memoryNudge(text) : '';
191
+ found.push({ rel, abs, text: nudge ? `${text}\n${nudge}` : text });
168
192
  }
169
193
  return found;
170
194
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * `learn` — the Stop/SubagentStop/PreCompact verb: distil notes or a transcript into deduped
3
- * memory entries through the model CLI `$GENESEED_LLM` names. THE ONE SPAWN in the hook entry's
4
- * closure; the store's file format it writes through lives in `./memory-files.mjs`, which does
2
+ * `learn` — the Stop/SubagentStop/PreCompact verb: distil notes or a transcript into memory
3
+ * entries (a new fact, or an update or archive of a stored one) through the model CLI
4
+ * `$GENESEED_LLM` names. THE ONE SPAWN in the hook entry's closure; the store's file format it writes through lives in `./memory-files.mjs`, which does
5
5
  * not spawn and is what the CLI imports. See `js/hosts/hooks.mjs`'s header for the contract.
6
6
  */
7
7
  import path from 'node:path';
@@ -14,7 +14,7 @@ import {
14
14
  } from './hosts.mjs';
15
15
  import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
16
16
  import {
17
- existingSlugs, writeMemories, consolidateMemory, appendAgentLesson,
17
+ existingSlugs, readFacts, writeMemories, consolidateMemory, appendAgentLesson,
18
18
  } from './memory-files.mjs';
19
19
 
20
20
  const ROOT = path.resolve(import.meta.dirname, '../..');
@@ -163,13 +163,33 @@ export function readNotes(raw, meta = hookMeta(raw)) {
163
163
  return flat;
164
164
  }
165
165
 
166
- function buildLearnPrompt(notes, existing) {
166
+ /**
167
+ * The prompt: the plugin's head, the stored facts as `slug \u2014 description` (so the model can
168
+ * merge into one or archive it), the topics in use, then the notes.
169
+ *
170
+ * THE FACT LINES ARE CAPPED at MAX_STORED_CHARS. A store of hundreds of facts would otherwise
171
+ * outgrow the notes, and the prompt rides on the model CLI's command line, which Windows caps
172
+ * at 32 767 characters (16 000 of notes + the head + this stays under it). A fact past the
173
+ * cap is only unlisted: `writeMemories` still refuses to create its slug. The plugin's
174
+ * `buildPrompt` is the twin.
175
+ */
176
+ const MAX_STORED_CHARS = 8000;
177
+ export function buildLearnPrompt(notes, facts) {
167
178
  const parts = [learnPromptHead(), ''];
168
- if (existing.size) {
169
- parts.push('ALREADY STORED \u2014 do NOT emit a memory matching any of these '
170
- + 'slugs (skip updates too; only genuinely new facts):');
171
- parts.push(...[...existing].sort().map((slug) => `- ${slug}`));
172
- parts.push('');
179
+ if (facts.size) {
180
+ parts.push('ALREADY STORED \u2014 \'slug \u2014 description\', to update or archive, never to create:');
181
+ let used = 0;
182
+ let shown = 0;
183
+ for (const [slug, { desc }] of facts) {
184
+ const line = desc ? `- ${slug} \u2014 ${desc}` : `- ${slug}`;
185
+ if (used + line.length > MAX_STORED_CHARS) break;
186
+ parts.push(line);
187
+ used += line.length + 1;
188
+ shown += 1;
189
+ }
190
+ if (shown < facts.size) parts.push(`(${facts.size - shown} more stored facts not shown)`);
191
+ parts.push('', 'TOPICS IN USE:',
192
+ [...new Set([...facts.values()].map((f) => f.topic))].sort().join(', '), '');
173
193
  }
174
194
  parts.push('NOTES:', notes);
175
195
  return parts.join('\n');
@@ -329,7 +349,7 @@ export function cmdLearn(args) {
329
349
 
330
350
  const memDir = resolveMemoryDir(args.memory);
331
351
  const existing = memDir ? existingSlugs(memDir) : new Set();
332
- const prompt = buildLearnPrompt(notes, existing);
352
+ const prompt = buildLearnPrompt(notes, memDir ? readFacts(memDir) : new Map());
333
353
 
334
354
  const llm = process.env.GENESEED_LLM;
335
355
  if (!llm) {
@@ -341,11 +361,17 @@ export function cmdLearn(args) {
341
361
  const proc = runLlm(llm, prompt);
342
362
  const output = proc.stdout;
343
363
  if (memDir && output.trim() && output.trim().toUpperCase() !== 'NOTHING') {
344
- const written = writeMemories(output, memDir, existing);
345
- if (written.length) {
346
- err(`[learn] wrote ${written.length} memory file(s) to `
347
- + `${memDir}: ${written.join(', ')}\n`);
348
- } else {
364
+ const { created, updated, archived } = writeMemories(output, memDir, existing);
365
+ if (created.length) {
366
+ err(`[learn] wrote ${created.length} memory file(s) to `
367
+ + `${memDir}: ${created.join(', ')}\n`);
368
+ }
369
+ if (updated.length) err(`[learn] updated ${updated.length} memory file(s): ${updated.join(', ')}\n`);
370
+ if (archived.length) {
371
+ err(`[learn] archived ${archived.length} memory file(s) to `
372
+ + `${path.join(memDir, 'archive')}: ${archived.join(', ')}\n`);
373
+ }
374
+ if (!created.length && !updated.length && !archived.length) {
349
375
  err('[learn] nothing new to store (all duplicates).\n');
350
376
  }
351
377
  } else {
@@ -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_219, lean: 39_088 };
32
32
 
33
33
  const decode = (p) => readText(p).split('\r\n').join('\n');
34
34
 
@@ -94,7 +94,7 @@ export const SKILL_CLASS = {
94
94
  loop: 'design', 'brick-forge': 'design',
95
95
  'codebase-design': 'design', wayfinder: 'design',
96
96
  develop: 'build', refactor: 'build', debug: 'build', dependencies: 'build',
97
- 'frontend-design': 'build', prototype: 'build',
97
+ 'frontend-design': 'build', prototype: 'build', deck: 'build',
98
98
  'forge-mcp': 'build', bruno: 'build', worktree: 'build',
99
99
  'geneseed-code-review': 'review', 'fresh-eyes': 'review', 'security-audit': 'review',
100
100
  'roast-me': 'review', 'review-response': 'review', ponytail: 'review',