geneseed 4.3.1 → 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 (65) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +2 -2
  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/reference/plugin-learn.md +2 -2
  9. package/docs/understand/a-day.md +3 -3
  10. package/docs/understand/machine-claude.md +1 -1
  11. package/docs/understand/machine-opencode.md +1 -1
  12. package/harness.config.json +1 -1
  13. package/js/README.md +1 -1
  14. package/js/build/bundle.mjs +2 -0
  15. package/js/build/emit-claude.mjs +2 -0
  16. package/js/build/emit-opencode.mjs +2 -0
  17. package/js/build/stubs.mjs +9 -4
  18. package/js/cli-table.json +32 -2
  19. package/js/hosts/hooks-context.mjs +25 -1
  20. package/js/hosts/hooks-learn.mjs +42 -16
  21. package/js/hosts/memory-files.mjs +283 -60
  22. package/js/inspect/checks-build.mjs +2 -1
  23. package/js/inspect/cost.mjs +1 -1
  24. package/js/maintain/memory.mjs +38 -11
  25. package/js/web/actions.mjs +4 -3
  26. package/package.json +1 -1
  27. package/registry.json +7 -0
  28. package/src/AGENT.md.tmpl +1 -0
  29. package/src/agents/memory-curator.md +71 -0
  30. package/src/memory/README.md +41 -10
  31. package/src/skills/geneseed.md +1 -1
  32. package/src/skills/rule/consolidate.md +18 -11
  33. package/src/skills/rule.md +9 -6
  34. package/themes/_TEMPLATE.json +1 -0
  35. package/themes/biker.json +1 -0
  36. package/themes/commentator.json +1 -0
  37. package/themes/cyberpunk.json +1 -0
  38. package/themes/gamer.json +1 -0
  39. package/themes/imperial.json +1 -0
  40. package/themes/joker.json +1 -0
  41. package/themes/marvin.json +1 -0
  42. package/themes/mean.json +1 -0
  43. package/themes/military.json +1 -0
  44. package/themes/neutral.json +1 -0
  45. package/themes/pirate.json +1 -0
  46. package/themes/sports.json +1 -0
  47. package/themes/verstappen.json +1 -0
  48. package/themes/wizard.json +1 -0
  49. package/web/dist/assets/{Activity-UiGvYgEl.js → Activity-BZE6REef.js} +1 -1
  50. package/web/dist/assets/{ActivityDetail-COXCSeXn.js → ActivityDetail-DCB8Cr2W.js} +1 -1
  51. package/web/dist/assets/{Dashboard-DkdOUpke.js → Dashboard-B4Drq5wp.js} +2 -2
  52. package/web/dist/assets/{Docs-TtVyc_Dy.js → Docs-D30CiqYy.js} +1 -1
  53. package/web/dist/assets/{GreenhouseView-Bh0xq6Up.js → GreenhouseView-Djt-4oO5.js} +1 -1
  54. package/web/dist/assets/{Installs-J3IoSqk6.js → Installs-B3bw5ckh.js} +1 -1
  55. package/web/dist/assets/{JournalView-BrD33V35.js → JournalView-OWhx9YKg.js} +1 -1
  56. package/web/dist/assets/{Laws-Cjkenizx.js → Laws-B9day2FV.js} +1 -1
  57. package/web/dist/assets/Library-BpIrLH-O.js +1 -0
  58. package/web/dist/assets/{Loops-D15LDhZO.js → Loops-CxH14kyE.js} +1 -1
  59. package/web/dist/assets/{OperatorHudView-B5CcWoJ6.js → OperatorHudView-otOz6Gha.js} +1 -1
  60. package/web/dist/assets/{Personal-BBdGbJn6.js → Personal-abL6089y.js} +1 -1
  61. package/web/dist/assets/{Tabs-s8T501po.js → Tabs-DjWN0i7A.js} +1 -1
  62. package/web/dist/assets/{drawable-DALqyS4l.js → drawable-C2ZsBZCv.js} +1 -1
  63. package/web/dist/assets/{index-BP4foUp0.js → index-CdpLpPQN.js} +2 -2
  64. package/web/dist/index.html +1 -1
  65. package/web/dist/assets/Library-CKyeB096.js +0 -1
package/CHANGELOG.md CHANGED
@@ -8,6 +8,24 @@ label. For the capability ↔ spec map, see [SHIPPED.md](SHIPPED.md).
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [4.4.0] — 2026-10-16
12
+
13
+ Minor: the memory store scales — a two-level index, merge and archive at creation, and a memory-curator agent.
14
+
15
+ ### Upgrade notes
16
+ - Run `geneseed rebuild-all` once after updating. It migrates every existing `memory/MEMORY.md` to the two-level index: only the index files are rewritten, never a fact file.
17
+ - A large or untidy store (a topic over 40 facts, or a `misc` topic) now gets a one-line session nudge to dispatch the memory-curator agent; it merges and archives topic by topic and deletes nothing without your approval.
18
+ - `geneseed memory rm` now archives into `memory/archive/`; pass `--purge` for the old deletion.
19
+
20
+ ### Added
21
+ - **memory-curator agent.** Merges duplicate facts (archiving the merged-away versions), assigns missing topics, archives stale facts, and proposes deletion of archived ones; never touches a `force:` fact without a ruling. (#284)
22
+ - **`geneseed memory list --archived`, `rm --purge`, `rm --archived --purge`.** (#284)
23
+
24
+ ### Changed
25
+ - **Two-level memory index.** `MEMORY.md` is now a small map of topics; each `memory/topics/<topic>.md` lists its facts. A fact's optional `topic:` picks its group, else its `type:`, else `misc`. A flat index of 200+ facts overran the context hook's budget and the newest facts were cut. (#282)
26
+ - **learn merges and archives.** The distiller sees the stored facts with their descriptions and the topics in use; it can update an existing fact (the old version is archived first) or archive a stale one, and tags new facts with a topic. Nothing is ever deleted; facts carrying `force:` are left alone. (#283)
27
+ - **Memory deletion archives by default**, in the CLI and the web console. (#284)
28
+
11
29
  ## [4.3.1] — 2026-10-15
12
30
 
13
31
  Patch: a folder excluded from the global harness now stays excluded in its git worktrees.
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  [![zero dependencies](https://img.shields.io/badge/deps-zero-success)](package.json)
13
13
  [![Themes](https://img.shields.io/badge/themes-14-9cf)](themes/)
14
14
  [![Skills](https://img.shields.io/badge/skills-46-blueviolet)](src/skills/)
15
- [![Agents](https://img.shields.io/badge/agents-19-orange)](src/agents/)
15
+ [![Agents](https://img.shields.io/badge/agents-20-orange)](src/agents/)
16
16
  [![Laws and doctrines](https://img.shields.io/badge/laws-9%20%2B%2029%20doctrines-critical)](src/doctrines/)
17
17
  [![Plugins](https://img.shields.io/badge/plugins-7-teal)](adapters/opencode/plugins/)
18
18
  [![OpenCode · Claude Code · Bob · OpenClaude · AGENT.md](https://img.shields.io/badge/works%20with-OpenCode%20·%20Claude%20Code%20·%20Bob%20·%20OpenClaude%20·%20AGENT.md-1f6feb)](#-supported-harnesses)
@@ -178,7 +178,7 @@ The harness ships as a small set of layers, and the web console's rail is the sa
178
178
  | **🧭 Ethos** (`ontology/`) | the mind the rules govern, in four prose sections — **Telos** (the Pact: three ranked laws — protect the user, serve their intent, keep your own honesty), **Evidence** (every claim graded by how it was obtained), **Decisions** (classify and tier by reversibility, show the real forks), **Conduct** (answer what was asked, once). Always in force, never toggleable |
179
179
  | **🛡️ Rules** (`laws/`) | 9 universal laws — always in force, never toggleable: sealed-secrets, one-intent-one-act, verify-before-asserting, deletion-is-deliberate, surface-failures, data-not-orders, least-privilege, cure-the-cause, echo-the-intent. Each rule has a permanent id; its number is only its position, and the harness cites rules by name |
180
180
  | **📐 Doctrines** (`doctrines/`) | practice packs, chosen per install at build time: **craft** (how code is written), **rigor** (how work is proven), **ops** (how the machine is operated), **process** (how a task is run), **comms** (how answers are presented). A doctrine rule may tighten a Rule, never repeal one, and the user's own `user-rules.md` outranks it. Pick with `geneseed-build --doctrines craft,rigor` (or `none`), a single rule with `--exclude-rules "process 7"`, in the setup wizard, or through the switches on the console's Constitution page; all five pack files ship on disk either way, so a citation into a pack you left out still resolves |
181
- | **🤖 Agents** (19) | capability specialists: `reviewer`, `tester`, `architect`, `docs`, `security`, `performance`, `explorer`, `researcher`, `developer` — plus a debate **council** the `council` skill convenes: `advocate`, `skeptic`, `pragmatist`, `steward`, `visionary`, `user-advocate`, `framer`, `empiricist`, `operator`, `historian` |
181
+ | **🤖 Agents** (20) | capability specialists: `reviewer`, `tester`, `architect`, `docs`, `security`, `performance`, `explorer`, `researcher`, `developer`, `memory-curator` — plus a debate **council** the `council` skill convenes: `advocate`, `skeptic`, `pragmatist`, `steward`, `visionary`, `user-advocate`, `framer`, `empiricist`, `operator`, `historian` |
182
182
  | **🛠 Skills** (46) | repeatable workflows: brainstorm · plan · **codebase-design** · **wayfinder** · **develop** · **worktree** · debug · **prototype** · refactor · **ponytail** · **forge-mcp** · **bruno** · geneseed-code-review · **fresh-eyes** · **security-audit** · **dependencies** · **review-response** · commit · **ship** · **release** · **git-history** · document-project · **frontend-design** · **prose** · **ingest** · **research** · **teach** · **quiz** · **learn-mode** · handoff · roast-me · **council** · parallel-agents · **workflow** · **wiki** · **geneseed** · **rule** · **profile** · **skill-forge** · **pipeline** · **loop** · **brick-forge** · **daydream** · **react-view-transitions** · **token-report** · **explain-changes** |
183
183
  | **🔌 Plugins** (OpenCode) | `geneseed-context` injects project docs *and your machine wiki* every session (and across compaction); `geneseed-learn` distils memory at session end; `geneseed-guard` enforces the safety Laws and protected wiki folders at the tool boundary; `geneseed-workflow` registers the `workflow` tool that runs saved orchestration scripts; `geneseed-notify` sends a native OS notification when a long run finishes; `geneseed-ponytail` holds a minimal-code mode (`/ponytail lite\|full\|ultra\|off`), opt-in, injecting the laziest-that-works ruleset every turn so it doesn't drift; `geneseed-activity` streams what each session is doing to the web console's Activity view |
184
184
  | **🧠 Memory** (`memory/`) | one-fact-per-file durable knowledge, indexed by `MEMORY.md` (git-ignored, personal) |
package/SHIPPED.md CHANGED
@@ -20,7 +20,7 @@ historical references, not files distributed with the repo.
20
20
  > **On version numbers.** [`harness.config.json`](harness.config.json)'s `version` is a
21
21
  > human-readable label only. The canonical identity of an installed harness is the
22
22
  > **source fingerprint** stamped in `.geneseed-version` (see `geneseed version`), not the
23
- > string. Current label: **4.3.1**.
23
+ > string. Current label: **4.4.0**.
24
24
 
25
25
  ## Capabilities
26
26
 
@@ -29,7 +29,7 @@ Every row below is present in the tree today; "Spec" names the design record.
29
29
  | Capability | What it is | Spec |
30
30
  | --- | --- | --- |
31
31
  | **Themed generator** | One neutral `src/` → 14 themed bundles via `geneseed-build` (zero dependencies); structure stays theme-independent | [DESIGN.md](DESIGN.md) |
32
- | **Laws / Agents / Skills / Memory / Notebook** | 9 laws, 19 agents, 46 skills, the memory convention, and the agent's sovereign notebook | `2026-06-11-notebook-sovereign-space` · `2026-06-10-notebook-agent-own-space` |
32
+ | **Laws / Agents / Skills / Memory / Notebook** | 9 laws, 20 agents, 46 skills, the memory convention, and the agent's sovereign notebook | `2026-06-11-notebook-sovereign-space` · `2026-06-10-notebook-agent-own-space` |
33
33
  | **Three-tier constitution** | The governance surface splits into an always-on **Ontology** (Telos, Evidence, Decisions, Conduct — the Pact absorbed into Telos), the nine always-on **Invariants**, and five toggleable **Doctrine** packs (craft, rigor, ops, process, comms) addressed `<pack> <n>`. Every rule carries a permanent id; its number is its position, computed at render time, and a citation renders as the rule's principle name. Packs are chosen at build time with `--doctrines` or the setup wizard; the whole catalogue ships on disk either way, so a citation into an inactive pack still resolves. Precedence, in order: Ontology + Invariants, then the user's `user-rules.md`, then the active Doctrines, then `PROFILE.md`. `doctor`'s `constitutionProblems` gates rule ids (declared, unique, never a retired one), the per-rule Principle column, every `{{LAW:<id>}}` / `{{DOCTRINE:<id>}}` citation in `src/`, and the rule that an always-on tier may never cite a toggleable one | `2026-08-19-three-tier-constitution-design` |
34
34
  | **`doctor`** | One check: unresolved tokens, dead/non-hermetic links, theme-key parity, authoring gates, rendered-bundle drift | `2026-06-07-theme-aware-doctor-diff` |
35
35
  | **`diff` + improvements export** | Deployed-vs-source drift report; export an `improvements.md` back-port | `2026-06-12-improvements-export` |
@@ -237,11 +237,28 @@ async function sessionFiles(dir) {
237
237
  let text
238
238
  try { text = (await fs.readFile(abs, "utf8")).replace(/\r\n/g, "\n") } catch { continue }
239
239
  if (SEED_SHA256.has(createHash("sha256").update(text).digest("hex"))) continue
240
- found.push({ rel, abs, text: text.replace(/\n+$/, "") })
240
+ text = text.replace(/\n+$/, "")
241
+ const nudge = rel.endsWith("MEMORY.md") ? memoryNudge(text) : ""
242
+ found.push({ rel, abs, text: nudge ? `${text}\n${nudge}` : text })
241
243
  }
242
244
  return found
243
245
  }
244
246
 
247
+ // The memory map's curation nudge: same rule, threshold and text as memoryNudge in
248
+ // js/hosts/hooks-context.mjs (tests/unit/claude.test.mjs gates the copy). Read off the map's
249
+ // own counts; a large but curated store is never nagged.
250
+ const MAP_COUNT_RE = /^- \[([^\]]*)\]\(topics\/[^)]+\.md\) — (\d+) facts?$/
251
+ const CURATE_TOPIC_MAX = 40
252
+ const CURATE_NUDGE = "[memory] A topic holds over 40 facts, or facts sit in misc: offer the user "
253
+ + "to dispatch the memory-curator agent to merge, re-topic and archive this store."
254
+ function memoryNudge(map) {
255
+ for (const line of map.split("\n")) {
256
+ const m = MAP_COUNT_RE.exec(line.trim())
257
+ if (m && (m[1] === "misc" || Number(m[2]) > CURATE_TOPIC_MAX)) return CURATE_NUDGE
258
+ }
259
+ return ""
260
+ }
261
+
245
262
  async function readJson(p) {
246
263
  try { return JSON.parse(await fs.readFile(p, "utf8")) } catch { return null }
247
264
  }
@@ -13,8 +13,8 @@
13
13
  // on the first idle would therefore mine just turn 1 and miss the rest. So each idle
14
14
  // RE-ARMS a debounce timer (GENESEED_LEARN_DEBOUNCE_MS, default 60s) and the distil
15
15
  // runs once the session has been quiet for that long — i.e. at the real end. A
16
- // continued session simply re-arms and re-distils; slug dedup keeps writes
17
- // idempotent. (Trade-off: if OpenCode is killed inside the window the final distil
16
+ // continued session simply re-arms and re-distils; a stored slug is never created
17
+ // twice, though a re-distil may update or archive it. (Trade-off: if OpenCode is killed inside the window the final distil
18
18
  // is lost — still strictly better than capturing only turn 1, and far cheaper than
19
19
  // re-distilling every turn.)
20
20
  //
@@ -125,10 +125,22 @@ Markdown memory files in this exact format, separated by a line containing only
125
125
  name: <kebab-case-slug>
126
126
  description: <one-line summary>
127
127
  type: user | feedback | project | reference
128
+ topic: <kebab-case-slug; reuse a topic from TOPICS IN USE below when one fits>
128
129
  ---
129
130
  <the fact, stated plainly. For 'feedback' and 'project', add **Why:** and
130
131
  **How to apply:** lines.>
131
132
 
133
+ MERGE BEFORE YOU CREATE. The facts already stored are listed below as
134
+ 'slug — description'. Never create a fact under one of those slugs. Instead:
135
+ - If your memory refines, extends or corrects a stored fact, add the line
136
+ 'action: update' to its frontmatter and use that fact's own name. Your text
137
+ REPLACES the stored file, so write the merged fact: keep everything the old
138
+ one says that is still true, then add or correct what the notes show.
139
+ - If the notes show a stored fact is now false or obsolete, output only
140
+ frontmatter holding its 'name:' and the line 'action: archive'. It is moved
141
+ out of the index, never deleted.
142
+ - With no 'action:' line, the memory is a NEW fact under a NEW slug.
143
+
132
144
  DEFAULT TO 'NOTHING'. Writing a memory is the rare exception, not the norm — most
133
145
  sessions should yield zero. Emit AT MOST ONE, and only if it is a GENERAL, reusable
134
146
  principle you would want at the START of an unrelated future task.
@@ -252,29 +264,28 @@ function envModel() {
252
264
  const RESERVED_STEMS = new Set(["memory", "readme"])
253
265
 
254
266
  async function existingSlugs(memDir) {
255
- const skip = RESERVED_STEMS
256
- try {
257
- const entries = await fs.readdir(memDir)
258
- return new Set(
259
- entries
260
- .filter((f) => f.endsWith(".md"))
261
- .map((f) => f.slice(0, -3))
262
- .filter((s) => !skip.has(s.toLowerCase()))
263
- )
264
- } catch {
265
- return new Set()
266
- }
267
+ return new Set((await listMd(memDir)).map((f) => f.slice(0, -3)).filter((s) => !RESERVED_STEMS.has(s.toLowerCase())))
267
268
  }
268
269
 
269
- function buildPrompt(notes, existing) {
270
+ // The stored facts' lines are capped at MAX_STORED_CHARS, a twin of buildLearnPrompt in
271
+ // js/hosts/hooks-learn.mjs: a store of hundreds of facts would otherwise outgrow the notes.
272
+ // A fact past the cap is only unlisted — writeMemories still refuses to create its slug.
273
+ const MAX_STORED_CHARS = 8000
274
+ function buildPrompt(notes, facts) {
270
275
  const parts = [LEARN_PROMPT_HEAD, ""]
271
- if (existing.size) {
272
- parts.push(
273
- "ALREADY STORED — do NOT emit a memory matching any of these slugs " +
274
- "(skip updates too; only genuinely new facts):"
275
- )
276
- for (const slug of [...existing].sort()) parts.push(`- ${slug}`)
277
- parts.push("")
276
+ if (facts.size) {
277
+ parts.push("ALREADY STORED — 'slug — description', to update or archive, never to create:")
278
+ let used = 0
279
+ let shown = 0
280
+ for (const [slug, { desc }] of facts) {
281
+ const line = desc ? `- ${slug} — ${desc}` : `- ${slug}`
282
+ if (used + line.length > MAX_STORED_CHARS) break
283
+ parts.push(line)
284
+ used += line.length + 1
285
+ shown += 1
286
+ }
287
+ if (shown < facts.size) parts.push(`(${facts.size - shown} more stored facts not shown)`)
288
+ parts.push("", "TOPICS IN USE:", [...new Set([...facts.values()].map((f) => f.topic))].sort().join(", "), "")
278
289
  }
279
290
  parts.push("NOTES:", notes)
280
291
  return parts.join("\n")
@@ -296,37 +307,158 @@ function parseFrontmatter(chunk) {
296
307
  // js/hosts/hooks.mjs, inlined because this file ships standalone.
297
308
  const MEMORY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/
298
309
 
299
- // Compared lowercase: on a case-insensitive filesystem (Windows, macOS) `User-Prefs`
300
- // and `user-prefs` are the same file, so a case variant would overwrite a stored fact.
310
+ // The chunk without its frontmatter `action:` line, which is an instruction, not the fact.
311
+ function stripAction(chunk) {
312
+ const lines = chunk.split("\n")
313
+ const close = lines.findIndex((l, i) => i > 0 && l.trim() === "---")
314
+ if (lines[0].trim() !== "---" || close < 0) return chunk
315
+ return lines.filter((l, i) => i === 0 || i >= close || !/^\s*action\s*:/.test(l)).join("\n")
316
+ }
317
+
318
+ // A fact with a `force:` is never updated or archived by learn (the memory README's Bridge
319
+ // rule: revised inline with its supersession recorded, a constraint only by the user).
320
+ async function hasForce(memDir, slug) {
321
+ try {
322
+ return !!parseFrontmatter((await fs.readFile(path.join(memDir, `${slug}.md`), "utf8")).replace(/\r\n/g, "\n")).force
323
+ } catch { return false }
324
+ }
325
+
326
+ // Move <slug>.md into archive/, never over a file already there (-2, -3, ... instead), and
327
+ // never unlink: an archived fact leaves the index but stays on disk.
328
+ async function archiveFact(memDir, slug) {
329
+ const adir = path.join(memDir, "archive")
330
+ await fs.mkdir(adir, { recursive: true })
331
+ let dest = path.join(adir, `${slug}.md`)
332
+ for (let n = 2; await fs.stat(dest).then(() => true, () => false); n += 1) dest = path.join(adir, `${slug}-${n}.md`)
333
+ await fs.rename(path.join(memDir, `${slug}.md`), dest)
334
+ }
335
+
336
+ // The twin of writeMemories in js/hosts/memory-files.mjs: no `action:` creates a NEW slug,
337
+ // `action: update` archives the stored fact and writes the merged text in its place, `action: archive` moves it
338
+ // to archive/. An update or archive of no stored fact, or of one with a `force:`, is skipped,
339
+ // as is any other action, and each slug is acted on once. Compared lowercase: on a case-insensitive filesystem (Windows,
340
+ // macOS) `User-Prefs` and `user-prefs` are the same file, so a case variant acts on the
341
+ // stored spelling and never creates a second fact.
301
342
  async function writeMemories(output, memDir, existing) {
302
- const taken = new Set([...existing].map((s) => s.toLowerCase()))
303
- const written = []
304
- const indexLines = []
343
+ const stored = new Map([...existing].map((s) => [s.toLowerCase(), s]))
344
+ const done = new Set()
345
+ const res = { created: [], updated: [], archived: [] }
305
346
  for (let chunk of output.split(/^---FILE---\s*$/m)) {
306
347
  chunk = chunk.trim()
307
348
  if (!chunk || chunk.toUpperCase() === "NOTHING") continue
308
349
  const fm = parseFrontmatter(chunk)
309
350
  const name = (fm.name || "").trim()
310
351
  const key = name.toLowerCase()
311
- if (!MEMORY_SLUG_RE.test(name) || RESERVED_STEMS.has(key) || taken.has(key)) continue
312
- await fs.writeFile(path.join(memDir, `${name}.md`), chunk.replace(/\n+$/, "") + "\n", "utf8")
313
- existing.add(name)
314
- taken.add(key)
315
- written.push(name)
316
- const desc = (fm.description || "").trim()
317
- indexLines.push(`- [${name}](${name}.md)` + (desc ? ` — ${desc}` : ""))
352
+ const action = (fm.action || "").trim().toLowerCase() || "create"
353
+ if (!MEMORY_SLUG_RE.test(name) || RESERVED_STEMS.has(key) || done.has(key)) continue
354
+ const slug = stored.get(key)
355
+ if (slug && action !== "create" && (await hasForce(memDir, slug))) continue
356
+ const body = stripAction(chunk).replace(/\n+$/, "") + "\n"
357
+ if (action === "create" && !slug) {
358
+ await fs.writeFile(path.join(memDir, `${name}.md`), body, "utf8")
359
+ existing.add(name)
360
+ stored.set(key, name)
361
+ res.created.push(name)
362
+ } else if (action === "update" && slug) {
363
+ await archiveFact(memDir, slug) // the old version first: the model never saw its body
364
+ await fs.writeFile(path.join(memDir, `${slug}.md`), body, "utf8")
365
+ res.updated.push(slug)
366
+ } else if (action === "archive" && slug) {
367
+ await archiveFact(memDir, slug)
368
+ existing.delete(slug)
369
+ stored.delete(key)
370
+ res.archived.push(slug)
371
+ } else continue
372
+ done.add(key)
373
+ }
374
+ if (res.created.length || res.updated.length || res.archived.length) await rebuildIndex(memDir, new Set(res.updated))
375
+ return res
376
+ }
377
+
378
+ // The two-level index, a twin of rebuildIndex in js/hosts/memory-files.mjs (this file ships
379
+ // standalone): MEMORY.md is a map of topics, each topics/<topic>.md lists its facts. A fact
380
+ // already indexed keeps its line (title and hook), a new one gets its description, a topic
381
+ // file no fact uses is deleted, and map lines the build did not generate are carried.
382
+ const MEMORY_SEED = "# Memory Index\n"
383
+ const MAP_HINT = "Each topic file lists its facts; open the one the task needs."
384
+ const FACT_LINE_RE = /^- \[([^\]]*)\]\((?:\.\.\/)?([^/)]+)\.md\)(.*)$/
385
+ const MAP_LINE_RE = /^- \[[^\]]*\]\(topics\/[^)]+\.md\)/
386
+
387
+ const readLines = async (p) => {
388
+ try { return (await fs.readFile(p, "utf8")).replace(/\r\n?/g, "\n").replace(/\n$/, "").split("\n") } catch { return null }
389
+ }
390
+ // The core's sortPaths/globMd rule (js/lib/paths.mjs normcase): names fold case on Windows, so
391
+ // the fact order inside a topic file, and which files count as .md, match the harness's.
392
+ const fold = (s) => (process.platform === "win32" ? s.toLowerCase() : s)
393
+ const listMd = async (dir) => {
394
+ try {
395
+ return (await fs.readdir(dir)).filter((f) => fold(f).endsWith(".md"))
396
+ .sort((a, b) => (fold(a) < fold(b) ? -1 : fold(a) > fold(b) ? 1 : 0))
397
+ } catch { return [] }
398
+ }
399
+ async function writeIfChanged(p, text) {
400
+ const old = await readLines(p)
401
+ if (old && old.join("\n") + "\n" === text) return
402
+ await fs.writeFile(p, text, "utf8")
403
+ }
404
+ // `topic:`, else `type:` (a `type:` nested under `metadata:` reads the same: keys are trimmed).
405
+ function topicOf(fm) {
406
+ const raw = (fm.topic || fm.type || "").trim()
407
+ return raw.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "misc"
408
+ }
409
+
410
+ // Every top-level fact (never archive/ or agents/), in index order: slug -> { desc, topic }.
411
+ async function readFacts(memDir) {
412
+ const facts = new Map()
413
+ for (const f of await listMd(memDir)) {
414
+ const slug = f.slice(0, -3)
415
+ if (RESERVED_STEMS.has(slug.toLowerCase())) continue
416
+ let fm
417
+ try { fm = parseFrontmatter((await fs.readFile(path.join(memDir, f), "utf8")).replace(/\r\n/g, "\n")) } catch { continue }
418
+ facts.set(slug, { desc: (fm.description || "").trim(), topic: topicOf(fm) })
419
+ }
420
+ return facts
421
+ }
422
+
423
+ // `fresh`: slugs just updated, whose old index line describes a fact that is gone.
424
+ async function rebuildIndex(memDir, fresh = new Set()) {
425
+ const tdir = path.join(memDir, "topics")
426
+ const old = new Map()
427
+ for (const f of [path.join(memDir, "MEMORY.md"), ...(await listMd(tdir)).map((t) => path.join(tdir, t))]) {
428
+ for (const line of (await readLines(f)) ?? []) {
429
+ const m = FACT_LINE_RE.exec(line.trim())
430
+ if (m) old.set(m[2], { title: m[1], rest: m[3] })
431
+ }
432
+ }
433
+ const byTopic = new Map()
434
+ for (const [slug, { desc, topic }] of await readFacts(memDir)) {
435
+ const o = fresh.has(slug) ? null : old.get(slug)
436
+ const line = o ? `- [${o.title}](../${slug}.md)${o.rest}` : `- [${slug}](../${slug}.md)` + (desc ? ` — ${desc}` : "")
437
+ if (!byTopic.has(topic)) byTopic.set(topic, [])
438
+ byTopic.get(topic).push(line)
439
+ }
440
+ for (const f of await listMd(tdir)) {
441
+ if (!byTopic.has(f.slice(0, -3))) await fs.unlink(path.join(tdir, f))
318
442
  }
319
- if (indexLines.length) {
320
- const index = path.join(memDir, "MEMORY.md")
321
- let current = "# Memory Index\n"
322
- try { current = (await fs.readFile(index, "utf8")).replace(/\n+$/, "") + "\n" } catch {}
323
- await fs.writeFile(index, current + indexLines.join("\n") + "\n", "utf8")
443
+ if (byTopic.size) await fs.mkdir(tdir, { recursive: true })
444
+ else await fs.rmdir(tdir).catch(() => {})
445
+ const mapLines = []
446
+ for (const topic of [...byTopic.keys()].sort()) {
447
+ const lines = byTopic.get(topic)
448
+ await writeIfChanged(path.join(tdir, `${topic}.md`), `# ${topic}\n\n${lines.join("\n")}\n`)
449
+ mapLines.push(`- [${topic}](topics/${topic}.md) — ${lines.length} fact${lines.length === 1 ? "" : "s"}`)
324
450
  }
325
- return written
451
+ const index = path.join(memDir, "MEMORY.md")
452
+ const carried = ((await readLines(index)) ?? []).filter((l) => {
453
+ const t = l.trim()
454
+ return t && t !== MEMORY_SEED.trim() && t !== MAP_HINT && !MAP_LINE_RE.test(t) && !FACT_LINE_RE.test(t)
455
+ })
456
+ const body = [...(mapLines.length ? ["", MAP_HINT, "", ...mapLines] : []), ...(carried.length ? ["", ...carried] : [])]
457
+ await writeIfChanged(index, body.length ? MEMORY_SEED + body.join("\n") + "\n" : MEMORY_SEED)
326
458
  }
327
459
 
328
460
  // The distil's reply into the store: a child session's one agent lesson, else memory
329
- // files plus their MEMORY.md lines. Shared by the V1 and V2 runs.
461
+ // files plus their two-level index. Shared by the V1 and V2 runs.
330
462
  async function storeDistil(output, { isChild, memDir, agentName, existing }) {
331
463
  if (!output.trim() || output.trim().toUpperCase() === "NOTHING") return
332
464
  if (isChild) {
@@ -336,9 +468,9 @@ async function storeDistil(output, { isChild, memDir, agentName, existing }) {
336
468
  console.error(`[geneseed-learn] agent lesson -> ${f}`)
337
469
  return
338
470
  }
339
- const written = await writeMemories(output, memDir, existing)
340
- if (written.length) {
341
- console.error(`[geneseed-learn] wrote ${written.length} memory file(s): ${written.join(", ")}`)
471
+ const res = await writeMemories(output, memDir, existing)
472
+ for (const [verb, slugs] of [["wrote", res.created], ["updated", res.updated], ["archived", res.archived]]) {
473
+ if (slugs.length) console.error(`[geneseed-learn] ${verb} ${slugs.length} memory file(s): ${slugs.join(", ")}`)
342
474
  }
343
475
  }
344
476
 
@@ -395,7 +527,7 @@ export const GeneseedLearn = async (ctx) => {
395
527
  const existing = await existingSlugs(memDir)
396
528
  const prompt = isChild
397
529
  ? [AGENT_LESSON_PROMPT, "", "NOTES:", notes].join("\n")
398
- : buildPrompt(notes, existing)
530
+ : buildPrompt(notes, await readFacts(memDir))
399
531
 
400
532
  // Distil in a throwaway session with the session's own model, then drop it.
401
533
  const session = await client.session.create({ body: { title: "geneseed-learn (auto)" } })
@@ -529,7 +661,7 @@ async function setupV2(ctx) {
529
661
  const existing = await existingSlugs(memDir)
530
662
  const prompt = isChild
531
663
  ? [AGENT_LESSON_PROMPT, "", "NOTES:", notes].join("\n")
532
- : buildPrompt(notes, existing)
664
+ : buildPrompt(notes, await readFacts(memDir))
533
665
  const reply = await ctx.generate.text({
534
666
  prompt, model: { providerID: model.providerID, id: model.id, ...(model.variant ? { variant: model.variant } : {}) },
535
667
  })
@@ -26,6 +26,7 @@ An **agent** is a specialist the main session can hand a piece of work to. Each
26
26
  | `security` | audits changes that touch the security surface |
27
27
  | `performance` | measures where time and memory go, names the bottleneck with numbers, proposes the fix |
28
28
  | `docs` | writes and updates user-facing documentation after code lands |
29
+ | `memory-curator` | tidies a large memory store: merges duplicates, assigns topics, archives stale facts, proposes deletions for your approval |
29
30
 
30
31
  ### The council
31
32
 
@@ -39,7 +39,7 @@ Geneseed writes its hooks into the host's settings file. Every hook calls one pr
39
39
  - A write to a check the project protects gets a prompt citing *External Gate* (on Bob, it is blocked). A project opts in with `.geneseed/protected-checks.txt` at its git root: one repo-relative file or folder per line, `#` for comments — the checks and tests the agent is judged by. A check the agent can edit is not a check. The list protects itself. Only file-write tools are seen: a shell `sed -i` or redirect is not, so run the same checks in CI as the boundary that holds.
40
40
  - Ordinary edits pass untouched.
41
41
 
42
- **learn.** Takes the tail of the transcript and asks a model to distil what is worth keeping into one-fact-per-file entries under `memory/`, skipping duplicates. It needs `GENESEED_LLM` set to a model command, for example:
42
+ **learn.** Takes the tail of the transcript and asks a model to distil what is worth keeping into one-fact-per-file entries under `memory/`. It is shown the facts already stored, so it updates one with the merged text rather than writing a twin (the old version is kept in `memory/archive/`), and moves one the session proved obsolete into `memory/archive/`. It needs `GENESEED_LLM` set to a model command, for example:
43
43
 
44
44
  ```bash
45
45
  export GENESEED_LLM="claude -p"
@@ -8,11 +8,11 @@ description: "Distils durable facts into memory/ once a session goes quiet."
8
8
  ---
9
9
  One of the [OpenCode plugins](opencode-plugins.md). Its switches are also listed in [Context and memory variables](env-context.md).
10
10
 
11
- **What it does.** Distils durable memories from the conversation into the install's `memory/` directory and keeps `MEMORY.md` up to date, de-duplicating against what is already stored. It is the OpenCode counterpart of the Claude Code `Stop` hook, but needs no API key and no model CLI: it distils with the **same model the session used**, through your OpenCode provider config. See [Memory](../concepts/memory.md).
11
+ **What it does.** Distils durable memories from the conversation into the install's `memory/` directory and keeps its two-level index (`MEMORY.md` and `topics/`) up to date. It never writes a twin of a stored fact: it updates that fact with the merged text instead (keeping the old version in `memory/archive/`), and moves one the session proved obsolete into `memory/archive/`. It is the OpenCode counterpart of the Claude Code `Stop` hook, but needs no API key and no model CLI: it distils with the **same model the session used**, through your OpenCode provider config. See [Memory](../concepts/memory.md).
12
12
 
13
13
  **When it runs.** After the session goes quiet. OpenCode signals "idle" after every turn, so each idle re-arms a timer and the distil runs once nothing has happened for `GENESEED_LEARN_DEBOUNCE_MS` (default 60 s). Trivial sessions are skipped.
14
14
 
15
- **What you see.** On stderr: `[geneseed-learn] wrote N memory file(s): …`, or `skipped: <reason>`. Total silence means it did not load.
15
+ **What you see.** On stderr: `[geneseed-learn] wrote N memory file(s): …` (or `updated` / `archived`), or `skipped: <reason>`. Total silence means it did not load.
16
16
 
17
17
  **Where it writes** — the first that resolves:
18
18
  1. `GENESEED_MEMORY` — an explicit memory dir;
@@ -66,13 +66,13 @@ type: project
66
66
  The integration tests start Postgres in a container; run `docker info` before the suite.
67
67
  ```
68
68
 
69
- and one line is added to `memory/MEMORY.md`:
69
+ and one line is added to its topic file, `memory/topics/project.md` (the topic is the fact's `topic:`, else its `type:`):
70
70
 
71
71
  ```markdown
72
- - [integration-tests-need-docker](integration-tests-need-docker.md) — the integration suite needs a running Docker daemon
72
+ - [integration-tests-need-docker](../integration-tests-need-docker.md) — the integration suite needs a running Docker daemon
73
73
  ```
74
74
 
75
- Next session, that index is in front of the agent before your first message. Memory stays on your machine. `geneseed memory list` shows every fact and `geneseed memory rm <name>` removes one.
75
+ `memory/MEMORY.md` stays a short map, one line per topic with its fact count. Next session, that map is in front of the agent before your first message, and the agent opens the topic file a task needs. If a later session refines that fact, learn updates the same file instead of adding a twin, keeping the old version in `memory/archive/`; if it shows the fact is obsolete, the file moves to `memory/archive/` and leaves the index. Memory stays on your machine. `geneseed memory list` shows every fact and `geneseed memory rm <name>` moves one to `memory/archive/` (`--purge` deletes it for good). When a topic grows past 40 facts or facts land in `misc`, the session says so in one line, and the `memory-curator` agent tidies the store in bulk.
76
76
 
77
77
  ---
78
78
 
@@ -20,7 +20,7 @@ What a Claude Code or OpenClaude install writes, piece by piece. The **Kind** wo
20
20
  | learn hook | When the agent finishes a reply, and before compaction, distils durable facts from the recent conversation into `memory/`. Off until you set `GENESEED_LLM` to a model command such as `claude -p` | automatic | One model call each time it runs, once enabled | Leave `GENESEED_LLM` unset (the default) |
21
21
  | `skills/` | Written procedures you call by name, such as `brainstorm` or `plan` | on demand | ~4.1k tokens for names and descriptions; a skill's body loads only when called | No per-skill switch; `geneseed uninstall` removes them all |
22
22
  | `agents/` | Specialists the agent can delegate to, such as `reviewer` or `tester` | on demand | ~0.5k tokens | `geneseed uninstall` |
23
- | `memory/` | Durable facts, one per file, with a `MEMORY.md` index the context hook injects | automatic | 0 when fresh; grows as facts accumulate | `geneseed memory list`, then `geneseed memory rm <name>` |
23
+ | `memory/` | Durable facts, one per file, with a `MEMORY.md` index the context hook injects | automatic | 0 when fresh; grows as facts accumulate | `geneseed memory list`, then `geneseed memory rm <name>` (archives; `--purge` deletes) |
24
24
  | `notebook/` | The agent's own working space: plans, scratch notes, and the gate log `gates.jsonl` | automatic | 0 when fresh; its index grows with use | Delete what you do not want; it is git-ignored and yours |
25
25
  | hook shim | `~/.geneseed/bin/geneseed-hook`, the one stable path every hook calls, so moving the Geneseed checkout does not break your hooks | automatic | Nothing on its own | Not removed by uninstall, since every install on the machine shares it. `GENESEED_HOME` moves it |
26
26
 
@@ -24,7 +24,7 @@ What an OpenCode install writes, piece by piece. The **Kind** words are explaine
24
24
  | workflow plugin | Adds a `workflow` tool that runs saved orchestration scripts | on demand | Runs only when called | `geneseed uninstall` |
25
25
  | `skills/` | Written procedures you call by name, such as `brainstorm` or `plan` | on demand | ~4.1k tokens for names and descriptions; a skill's body loads only when called | No per-skill switch; `geneseed uninstall` removes them all |
26
26
  | `agents/` | Specialists the agent can delegate to, such as `reviewer` or `tester` | on demand | ~0.5k tokens | `geneseed uninstall` |
27
- | `memory/` | Durable facts, one per file, with a `MEMORY.md` index the context plugin injects | automatic | 0 when fresh; grows as facts accumulate | `geneseed memory list`, then `geneseed memory rm <name>` |
27
+ | `memory/` | Durable facts, one per file, with a `MEMORY.md` index the context plugin injects | automatic | 0 when fresh; grows as facts accumulate | `geneseed memory list`, then `geneseed memory rm <name>` (archives; `--purge` deletes) |
28
28
  | `notebook/` | The agent's own working space: plans, scratch notes, and the gate log `gates.jsonl` | automatic | 0 when fresh; its index grows with use | Delete what you do not want; it is git-ignored and yours |
29
29
 
30
30
  ## Where the files are
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "4.3.1"
5
+ "version": "4.4.0"
6
6
  }
package/js/README.md CHANGED
@@ -181,7 +181,7 @@ Verbs that change an install that already exists, as opposed to rendering one.
181
181
  | `setup.mjs` | The install wizard, and the CLI's only synchronous stdin readers (`ask`/`confirm`/`askChoice`) |
182
182
  | `uninstall.mjs` | Removes or disables an install: manifest-driven reversal, plus stash deactivate/reactivate |
183
183
  | `migrate.mjs` | `geneseed migrate` — all-or-nothing re-emit moving installs onto the npm install shape |
184
- | `memory.mjs` | `geneseed memory list|rm` — CLI-side fact deletion, twin of the web DELETE endpoint |
184
+ | `memory.mjs` | `geneseed memory list|rm` — CLI-side fact removal (archive by default, `--purge` deletes), twin of the web delete endpoint |
185
185
 
186
186
  **Before editing:** `update.mjs` is the one module that MUST spawn rather than import — the code on
187
187
  disk is replaced mid-run, so an in-process call would validate the *old* source and then render it.
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import path from 'node:path';
14
14
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
15
+ import { migrateMemoryIndex } from '../hosts/memory-files.mjs';
15
16
  import { copyFile, isDir, isFile, readText, writeText } from '../lib/fs.mjs';
16
17
  import { jsonDumpsIndent, parseJson, formatReprAscii, isTruthy } from '../lib/json.mjs';
17
18
  import { OWNED_SRC_DIRS, SRC_DIRS_MARKER, TMPL_SPEC_RE } from './emit-common.mjs';
@@ -223,6 +224,7 @@ export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog =
223
224
  ensureProfileStub(out);
224
225
  ensureBundleGitignore(out);
225
226
  ensureMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
227
+ migrateMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
226
228
  ensureNotebookIndex(path.join(out, theme[SRC_DIR_TOKENS.notebook] ?? 'notebook'));
227
229
  process.stdout.write(`[geneseed] built theme '${themeName}' -> ${out} (${items.length} files)\n`);
228
230
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import path from 'node:path';
9
9
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
10
+ import { migrateMemoryIndex } from '../hosts/memory-files.mjs';
10
11
  import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
11
12
  import { ensureAgentOverridesStub, writeAliasCommands } from '../hosts/opencode.mjs';
12
13
  import {
@@ -146,6 +147,7 @@ export function emitClaudeRender(cfg, job) {
146
147
 
147
148
  const memStatus = globalMemory(cfgDir, items, out, cfg.src);
148
149
  ensureMemoryIndex(path.join(cfgDir, 'memory'));
150
+ migrateMemoryIndex(path.join(cfgDir, 'memory'));
149
151
  const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
150
152
  ensureNotebookIndex(path.join(cfgDir, 'notebook'));
151
153
  // context.json too: the root names it as the project's own manifest, and until 2026-10 this
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import path from 'node:path';
10
10
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
11
+ import { migrateMemoryIndex } from '../hosts/memory-files.mjs';
11
12
  import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
12
13
  import {
13
14
  copyPlugins, copyWorkflows, ensureAgentOverridesStub, writeColorThemes, writeCommandLayer,
@@ -233,6 +234,7 @@ export function emitOpencodeGlobalRender(cfg, job) {
233
234
 
234
235
  const memStatus = globalMemory(cfgDir, items, out, cfg.src);
235
236
  ensureMemoryIndex(path.join(cfgDir, 'memory'));
237
+ migrateMemoryIndex(path.join(cfgDir, 'memory'));
236
238
  const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
237
239
  ensureNotebookIndex(path.join(cfgDir, 'notebook'));
238
240
  ensureWikiStub(cfgDir);
@@ -4,14 +4,18 @@
4
4
  *
5
5
  * Every stub is a literal here and an `ensure*` beside it, and the pairing is the contract:
6
6
  * the writer checks first and returns without writing when the file exists, because these are
7
- * the USER's files from the moment they exist. A stub body is bytes a recorded corpus compares
8
- * — changing one is a product change, not a tidy-up.
7
+ * the USER's files from the moment they exist. One exception: an emit also calls
8
+ * `migrateMemoryIndex` on the memory store, which rewrites a FLAT MEMORY.md (and its
9
+ * `topics/` files) into the two-level index once, losslessly, because a flat index overruns
10
+ * the session-start budget. Fact files are never touched. A stub body is bytes a recorded
11
+ * corpus compares — changing one is a product change, not a tidy-up.
9
12
  */
10
13
  import path from 'node:path';
11
14
  import { writeText } from '../lib/fs.mjs';
12
15
  import { isDir } from '../lib/fs.mjs';
13
16
  import { jsonDumpsIndent } from '../lib/json.mjs';
14
17
  import { EXCLUDES_FILE } from '../hosts/hosts.mjs';
18
+ import { MEMORY_SEED } from '../hosts/memory-files.mjs';
15
19
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
16
20
 
17
21
  // ---------------------------------------------------------------------------
@@ -202,7 +206,7 @@ agent-overrides.json
202
206
  `;
203
207
 
204
208
  // ---------------------------------------------------------------------------
205
- // Seeded stubs — write-once, never overwrite
209
+ // Seeded stubs — write-once, never overwrite (MEMORY.md: see the header's one exception)
206
210
  // ---------------------------------------------------------------------------
207
211
 
208
212
  /**
@@ -276,7 +280,8 @@ export const ensureBundleGitignore = stubWriter('.gitignore', BUNDLE_GITIGNORE);
276
280
  * right after the store dir is created, but never invent the dir themselves). */
277
281
  const storeIndexWriter = (file, header) => (dir) => { if (isDir(dir)) seed(path.join(dir, file), header); };
278
282
 
279
- const MEMORY_INDEX = '# Memory Index\n';
283
+ // Owned by `js/hosts/memory-files.mjs`, which writes an emptied store back to these bytes.
284
+ const MEMORY_INDEX = MEMORY_SEED;
280
285
  const NOTEBOOK_INDEX = '# Notebook Index\n';
281
286
 
282
287
  /** `_build_render.ensure_memory_index` — only inside an EXISTING store dir. */