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
package/CHANGELOG.md CHANGED
@@ -8,6 +8,35 @@ 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
+
29
+ ## [4.3.1] — 2026-10-15
30
+
31
+ Patch: a folder excluded from the global harness now stays excluded in its git worktrees.
32
+
33
+ ### Upgrade notes
34
+ - Run `geneseed rebuild-all` once after updating so installed hooks and OpenCode plugins carry the fix.
35
+ - On Claude Code and OpenClaude, a worktree still loads the global `CLAUDE.md` until you commit the `claudeMdExcludes` snippet that `geneseed exclude list` prints into the repo's tracked settings file.
36
+
37
+ ### Fixed
38
+ - **Exclusions cover git worktrees.** A session in a linked worktree of an excluded repo (`git worktree add`, or a tool that keeps worktrees elsewhere on disk) got the whole global harness, because the bypass only matched the working directory's path. The hooks and the three OpenCode plugins now follow the worktree's `.git` file back to the main repository, reading files only. `exclude add` and `exclude list` warn that `settings.local.json` never reaches a worktree and print the snippet to commit; Geneseed writes no tracked file. (#280)
39
+
11
40
  ## [4.3.0] — 2026-10-15
12
41
 
13
42
  Minor: a performance agent, and Deletion Is Deliberate says what counts as recoverable.
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.0**.
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` |
@@ -85,17 +85,41 @@ const norm = (p) => {
85
85
  }
86
86
  return process.platform === "win32" ? s.toLowerCase() : s
87
87
  }
88
+ // A linked git worktree of an excluded repo is excluded too, wherever it sits on disk — twin of
89
+ // js/hosts/gitref.mjs `mainWorktreeOf`: the worktree's `.git` FILE names its gitdir, whose
90
+ // `commondir` names the shared `.git`; that directory's parent is the main worktree. null for
91
+ // a main worktree, a submodule, a bare repo's worktree, or anything malformed.
92
+ async function mainWorktreeOf(dir) {
93
+ try {
94
+ let root = path.resolve(dir)
95
+ for (;;) {
96
+ try { await fs.stat(path.join(root, ".git")); break } catch { /* walk up */ }
97
+ const up = path.dirname(root)
98
+ if (up === root) return null
99
+ root = up
100
+ }
101
+ const dotgit = path.join(root, ".git")
102
+ if (!(await fs.stat(dotgit)).isFile()) return null
103
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(await fs.readFile(dotgit, "utf8"))
104
+ if (!m) return null
105
+ const gitdir = path.resolve(root, m[1])
106
+ const common = path.resolve(gitdir, (await fs.readFile(path.join(gitdir, "commondir"), "utf8")).trim())
107
+ return path.basename(common) === ".git" ? path.dirname(common) : null
108
+ } catch { return null }
109
+ }
88
110
  async function sovereignBypass(cwd) {
89
111
  try {
90
112
  const cfg = path.dirname(PLUGIN_DIR) // plugins/ sits directly in <cfg>
91
113
  const raw = await fs.readFile(path.join(cfg, "excludes.json"), "utf8")
92
114
  const entries = (JSON.parse(raw).excludes) || []
93
115
  const here = norm(cwd || process.cwd())
116
+ const main = await mainWorktreeOf(cwd || process.cwd())
117
+ const heres = main ? [here, norm(main)] : [here]
94
118
  for (const e of entries) {
95
119
  const p = typeof e === "string" ? e : e && e.path
96
120
  if (typeof p !== "string" || !p.trim()) continue
97
121
  const base = norm(p.trim()).replace(/[\\/]+$/, "")
98
- if (here === base || here.startsWith(base + path.sep)) return true
122
+ if (heres.some((h) => h === base || h.startsWith(base + path.sep))) return true
99
123
  }
100
124
  } catch { /* degrade to active */ }
101
125
  return false
@@ -213,11 +237,28 @@ async function sessionFiles(dir) {
213
237
  let text
214
238
  try { text = (await fs.readFile(abs, "utf8")).replace(/\r\n/g, "\n") } catch { continue }
215
239
  if (SEED_SHA256.has(createHash("sha256").update(text).digest("hex"))) continue
216
- 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 })
217
243
  }
218
244
  return found
219
245
  }
220
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
+
221
262
  async function readJson(p) {
222
263
  try { return JSON.parse(await fs.readFile(p, "utf8")) } catch { return null }
223
264
  }
@@ -72,17 +72,41 @@ const norm = (p) => {
72
72
  }
73
73
  return process.platform === "win32" ? s.toLowerCase() : s
74
74
  }
75
+ // A linked git worktree of an excluded repo is excluded too, wherever it sits on disk — twin of
76
+ // js/hosts/gitref.mjs `mainWorktreeOf`: the worktree's `.git` FILE names its gitdir, whose
77
+ // `commondir` names the shared `.git`; that directory's parent is the main worktree. null for
78
+ // a main worktree, a submodule, a bare repo's worktree, or anything malformed.
79
+ async function mainWorktreeOf(dir) {
80
+ try {
81
+ let root = path.resolve(dir)
82
+ for (;;) {
83
+ try { await fs.stat(path.join(root, ".git")); break } catch { /* walk up */ }
84
+ const up = path.dirname(root)
85
+ if (up === root) return null
86
+ root = up
87
+ }
88
+ const dotgit = path.join(root, ".git")
89
+ if (!(await fs.stat(dotgit)).isFile()) return null
90
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(await fs.readFile(dotgit, "utf8"))
91
+ if (!m) return null
92
+ const gitdir = path.resolve(root, m[1])
93
+ const common = path.resolve(gitdir, (await fs.readFile(path.join(gitdir, "commondir"), "utf8")).trim())
94
+ return path.basename(common) === ".git" ? path.dirname(common) : null
95
+ } catch { return null }
96
+ }
75
97
  async function sovereignBypass(cwd) {
76
98
  try {
77
99
  const cfg = path.dirname(PLUGIN_DIR) // plugins/ sits directly in <cfg>
78
100
  const raw = await fs.readFile(path.join(cfg, "excludes.json"), "utf8")
79
101
  const entries = (JSON.parse(raw).excludes) || []
80
102
  const here = norm(cwd || process.cwd())
103
+ const main = await mainWorktreeOf(cwd || process.cwd())
104
+ const heres = main ? [here, norm(main)] : [here]
81
105
  for (const e of entries) {
82
106
  const p = typeof e === "string" ? e : e && e.path
83
107
  if (typeof p !== "string" || !p.trim()) continue
84
108
  const base = norm(p.trim()).replace(/[\\/]+$/, "")
85
- if (here === base || here.startsWith(base + path.sep)) return true
109
+ if (heres.some((h) => h === base || h.startsWith(base + path.sep))) return true
86
110
  }
87
111
  } catch { /* degrade to active */ }
88
112
  return false
@@ -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
  //
@@ -67,17 +67,41 @@ const norm = (p) => {
67
67
  }
68
68
  return process.platform === "win32" ? s.toLowerCase() : s
69
69
  }
70
+ // A linked git worktree of an excluded repo is excluded too, wherever it sits on disk — twin of
71
+ // js/hosts/gitref.mjs `mainWorktreeOf`: the worktree's `.git` FILE names its gitdir, whose
72
+ // `commondir` names the shared `.git`; that directory's parent is the main worktree. null for
73
+ // a main worktree, a submodule, a bare repo's worktree, or anything malformed.
74
+ async function mainWorktreeOf(dir) {
75
+ try {
76
+ let root = path.resolve(dir)
77
+ for (;;) {
78
+ try { await fs.stat(path.join(root, ".git")); break } catch { /* walk up */ }
79
+ const up = path.dirname(root)
80
+ if (up === root) return null
81
+ root = up
82
+ }
83
+ const dotgit = path.join(root, ".git")
84
+ if (!(await fs.stat(dotgit)).isFile()) return null
85
+ const m = /^gitdir:\s*(.+?)\s*$/m.exec(await fs.readFile(dotgit, "utf8"))
86
+ if (!m) return null
87
+ const gitdir = path.resolve(root, m[1])
88
+ const common = path.resolve(gitdir, (await fs.readFile(path.join(gitdir, "commondir"), "utf8")).trim())
89
+ return path.basename(common) === ".git" ? path.dirname(common) : null
90
+ } catch { return null }
91
+ }
70
92
  async function sovereignBypass(cwd) {
71
93
  try {
72
94
  const cfg = path.dirname(PLUGIN_DIR) // plugins/ sits directly in <cfg>
73
95
  const raw = await fs.readFile(path.join(cfg, "excludes.json"), "utf8")
74
96
  const entries = (JSON.parse(raw).excludes) || []
75
97
  const here = norm(cwd || process.cwd())
98
+ const main = await mainWorktreeOf(cwd || process.cwd())
99
+ const heres = main ? [here, norm(main)] : [here]
76
100
  for (const e of entries) {
77
101
  const p = typeof e === "string" ? e : e && e.path
78
102
  if (typeof p !== "string" || !p.trim()) continue
79
103
  const base = norm(p.trim()).replace(/[\\/]+$/, "")
80
- if (here === base || here.startsWith(base + path.sep)) return true
104
+ if (heres.some((h) => h === base || h.startsWith(base + path.sep))) return true
81
105
  }
82
106
  } catch { /* degrade to active */ }
83
107
  return false
@@ -101,10 +125,22 @@ Markdown memory files in this exact format, separated by a line containing only
101
125
  name: <kebab-case-slug>
102
126
  description: <one-line summary>
103
127
  type: user | feedback | project | reference
128
+ topic: <kebab-case-slug; reuse a topic from TOPICS IN USE below when one fits>
104
129
  ---
105
130
  <the fact, stated plainly. For 'feedback' and 'project', add **Why:** and
106
131
  **How to apply:** lines.>
107
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
+
108
144
  DEFAULT TO 'NOTHING'. Writing a memory is the rare exception, not the norm — most
109
145
  sessions should yield zero. Emit AT MOST ONE, and only if it is a GENERAL, reusable
110
146
  principle you would want at the START of an unrelated future task.
@@ -228,29 +264,28 @@ function envModel() {
228
264
  const RESERVED_STEMS = new Set(["memory", "readme"])
229
265
 
230
266
  async function existingSlugs(memDir) {
231
- const skip = RESERVED_STEMS
232
- try {
233
- const entries = await fs.readdir(memDir)
234
- return new Set(
235
- entries
236
- .filter((f) => f.endsWith(".md"))
237
- .map((f) => f.slice(0, -3))
238
- .filter((s) => !skip.has(s.toLowerCase()))
239
- )
240
- } catch {
241
- return new Set()
242
- }
267
+ return new Set((await listMd(memDir)).map((f) => f.slice(0, -3)).filter((s) => !RESERVED_STEMS.has(s.toLowerCase())))
243
268
  }
244
269
 
245
- 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) {
246
275
  const parts = [LEARN_PROMPT_HEAD, ""]
247
- if (existing.size) {
248
- parts.push(
249
- "ALREADY STORED — do NOT emit a memory matching any of these slugs " +
250
- "(skip updates too; only genuinely new facts):"
251
- )
252
- for (const slug of [...existing].sort()) parts.push(`- ${slug}`)
253
- 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(", "), "")
254
289
  }
255
290
  parts.push("NOTES:", notes)
256
291
  return parts.join("\n")
@@ -272,37 +307,158 @@ function parseFrontmatter(chunk) {
272
307
  // js/hosts/hooks.mjs, inlined because this file ships standalone.
273
308
  const MEMORY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/
274
309
 
275
- // Compared lowercase: on a case-insensitive filesystem (Windows, macOS) `User-Prefs`
276
- // 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.
277
342
  async function writeMemories(output, memDir, existing) {
278
- const taken = new Set([...existing].map((s) => s.toLowerCase()))
279
- const written = []
280
- const indexLines = []
343
+ const stored = new Map([...existing].map((s) => [s.toLowerCase(), s]))
344
+ const done = new Set()
345
+ const res = { created: [], updated: [], archived: [] }
281
346
  for (let chunk of output.split(/^---FILE---\s*$/m)) {
282
347
  chunk = chunk.trim()
283
348
  if (!chunk || chunk.toUpperCase() === "NOTHING") continue
284
349
  const fm = parseFrontmatter(chunk)
285
350
  const name = (fm.name || "").trim()
286
351
  const key = name.toLowerCase()
287
- if (!MEMORY_SLUG_RE.test(name) || RESERVED_STEMS.has(key) || taken.has(key)) continue
288
- await fs.writeFile(path.join(memDir, `${name}.md`), chunk.replace(/\n+$/, "") + "\n", "utf8")
289
- existing.add(name)
290
- taken.add(key)
291
- written.push(name)
292
- const desc = (fm.description || "").trim()
293
- 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))
294
442
  }
295
- if (indexLines.length) {
296
- const index = path.join(memDir, "MEMORY.md")
297
- let current = "# Memory Index\n"
298
- try { current = (await fs.readFile(index, "utf8")).replace(/\n+$/, "") + "\n" } catch {}
299
- 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"}`)
300
450
  }
301
- 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)
302
458
  }
303
459
 
304
460
  // The distil's reply into the store: a child session's one agent lesson, else memory
305
- // 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.
306
462
  async function storeDistil(output, { isChild, memDir, agentName, existing }) {
307
463
  if (!output.trim() || output.trim().toUpperCase() === "NOTHING") return
308
464
  if (isChild) {
@@ -312,9 +468,9 @@ async function storeDistil(output, { isChild, memDir, agentName, existing }) {
312
468
  console.error(`[geneseed-learn] agent lesson -> ${f}`)
313
469
  return
314
470
  }
315
- const written = await writeMemories(output, memDir, existing)
316
- if (written.length) {
317
- 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(", ")}`)
318
474
  }
319
475
  }
320
476
 
@@ -371,7 +527,7 @@ export const GeneseedLearn = async (ctx) => {
371
527
  const existing = await existingSlugs(memDir)
372
528
  const prompt = isChild
373
529
  ? [AGENT_LESSON_PROMPT, "", "NOTES:", notes].join("\n")
374
- : buildPrompt(notes, existing)
530
+ : buildPrompt(notes, await readFacts(memDir))
375
531
 
376
532
  // Distil in a throwaway session with the session's own model, then drop it.
377
533
  const session = await client.session.create({ body: { title: "geneseed-learn (auto)" } })
@@ -505,7 +661,7 @@ async function setupV2(ctx) {
505
661
  const existing = await existingSlugs(memDir)
506
662
  const prompt = isChild
507
663
  ? [AGENT_LESSON_PROMPT, "", "NOTES:", notes].join("\n")
508
- : buildPrompt(notes, existing)
664
+ : buildPrompt(notes, await readFacts(memDir))
509
665
  const reply = await ctx.generate.text({
510
666
  prompt, model: { providerID: model.providerID, id: model.id, ...(model.variant ? { variant: model.variant } : {}) },
511
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
 
@@ -42,6 +42,8 @@ What "dormant" means per host:
42
42
 
43
43
  `geneseed exclude remove` undoes all of it.
44
44
 
45
+ **Git worktrees.** A linked worktree of an excluded repo (`git worktree add`) is excluded too, wherever it sits on disk: the hooks and plugins follow its `.git` file back to the main repository. The global `CLAUDE.md` is the exception on Claude Code and OpenClaude: `settings.local.json` is gitignored, so a worktree never gets it. Geneseed writes no tracked file in a repo you told it to leave alone, so `exclude add` and `exclude list` print the snippet to commit yourself in the repo's tracked `.claude/settings.json` (`.openclaude/` for OpenClaude): `"claudeMdExcludes": ["<path to the global CLAUDE.md>"]`.
46
+
45
47
  **Limits.** Globally installed skills and agents stay *listed* by the host — no host offers a per-repo switch for them. And a global install created *after* you added exclusions starts with an empty list: run `geneseed exclude add` again (`geneseed exclude list` flags installs whose lists differ).
46
48
 
47
49
  The web console has the same controls in the **Excluded folders** card on the Harness page, shown once a global install exists.
@@ -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"
@@ -19,7 +19,7 @@ Every [host](hosts.md) gets the same content. This table is what each one lets G
19
19
  | Consent before every commit and push | 🔒 permission ask | 🪝 ask | 🪝 warning¹ | 🪝 ask² |
20
20
  | Rule-or-memory write needs your call | ⚙️ speed bump³ | 🪝 ask | 🪝 warning¹ | 🪝 ask² |
21
21
  | User-only skills hidden from the model | 🔒 `permission.skill` deny | ✅ `disable-model-invocation` | ✅ `/`-command, no skill | ✅ `disable-model-invocation` |
22
- | Sovereign-repo exclusions | ⚙️ plugins stand down | ✅ `claudeMdExcludes` | ✅ rules shadow stub | ✅ `claudeMdExcludes` |
22
+ | Sovereign-repo exclusions⁵ | ⚙️ plugins stand down | ✅ `claudeMdExcludes` | ✅ rules shadow stub | ✅ `claudeMdExcludes` |
23
23
  | MCP server wiring | ✅ `mcp` | ✅ `mcpServers` | ✅ `mcp.json` | ✅ `mcpServers` |
24
24
  | Colour themes · LSP · workflow runner · primary agent · `/`-commands | ✅ | ➖ | ➖ | ➖ |
25
25
 
@@ -29,6 +29,7 @@ Every [host](hosts.md) gets the same content. This table is what each one lets G
29
29
  ² **OpenClaude** is a Claude Code fork and gets Claude's hooks and verdicts verbatim, in `~/.openclaude/settings.json` (global) or `.openclaude/settings.local.json` (per repo). **Unverified live** for the same reason.
30
30
  ³ OpenCode's plugin hook can only allow or throw — there is no "ask the user" — so this check stops the first such write once rather than prompting.
31
31
  ⁴ The checks differ in shape: OpenCode's guard plugin blocks writes *to* private-key and credential files by path; the Claude-shaped hook looks at the *content* being written for credential-shaped strings (cloud keys, tokens, private-key blocks) and asks. A `.env` file is exempt on the hook side — that is where a secret is allowed to live.
32
+ ⁵ A linked git worktree of an excluded repo is excluded too: the hooks and plugins follow its `.git` file back to the main repository. `claudeMdExcludes` is the exception — it lives in the gitignored `settings.local.json`, which a worktree never gets — so `geneseed exclude add` and `exclude list` print the snippet to commit yourself. See [Exclusions](exclusions.md).
32
33
 
33
34
  ## The one conditional row
34
35
 
@@ -35,4 +35,4 @@ If a plugin seems silent on OpenCode 2, check that `~/.config/opencode/plugins/`
35
35
 
36
36
  ## Excluded folders
37
37
 
38
- `geneseed-context`, `geneseed-guard` and `geneseed-learn` go dormant inside a folder listed in the global install's `excludes.json` (managed by `geneseed exclude`). See [Exclusions](../concepts/exclusions.md).
38
+ `geneseed-context`, `geneseed-guard` and `geneseed-learn` go dormant inside a folder listed in the global install's `excludes.json` (managed by `geneseed exclude`), and inside any linked git worktree of such a folder. See [Exclusions](../concepts/exclusions.md).
@@ -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