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.
- package/CHANGELOG.md +29 -0
- package/README.md +2 -2
- package/SHIPPED.md +2 -2
- package/adapters/opencode/plugins/geneseed-context.js +43 -2
- package/adapters/opencode/plugins/geneseed-guard.js +25 -1
- package/adapters/opencode/plugins/geneseed-learn.js +203 -47
- package/docs/concepts/agents.md +1 -0
- package/docs/concepts/exclusions.md +2 -0
- package/docs/concepts/hooks.md +1 -1
- package/docs/concepts/host-matrix.md +2 -1
- package/docs/reference/opencode-plugins.md +1 -1
- package/docs/reference/plugin-learn.md +2 -2
- package/docs/understand/a-day.md +3 -3
- package/docs/understand/machine-claude.md +1 -1
- package/docs/understand/machine-opencode.md +1 -1
- package/harness.config.json +1 -1
- package/js/README.md +1 -1
- package/js/build/bundle.mjs +2 -0
- package/js/build/emit-claude.mjs +2 -0
- package/js/build/emit-opencode.mjs +2 -0
- package/js/build/stubs.mjs +9 -4
- package/js/cli-table.json +32 -2
- package/js/hosts/gitref.mjs +25 -1
- package/js/hosts/hooks-context.mjs +25 -1
- package/js/hosts/hooks-learn.mjs +42 -16
- package/js/hosts/hosts.mjs +14 -4
- package/js/hosts/memory-files.mjs +283 -60
- package/js/inspect/checks-build.mjs +2 -1
- package/js/inspect/cost.mjs +1 -1
- package/js/inspect/excludes.mjs +33 -2
- package/js/maintain/memory.mjs +38 -11
- package/js/web/actions.mjs +4 -3
- package/package.json +1 -1
- package/registry.json +7 -0
- package/src/AGENT.md.tmpl +1 -0
- package/src/agents/memory-curator.md +71 -0
- package/src/memory/README.md +41 -10
- package/src/skills/geneseed.md +1 -1
- package/src/skills/rule/consolidate.md +18 -11
- package/src/skills/rule.md +9 -6
- package/themes/_TEMPLATE.json +1 -0
- package/themes/biker.json +1 -0
- package/themes/commentator.json +1 -0
- package/themes/cyberpunk.json +1 -0
- package/themes/gamer.json +1 -0
- package/themes/imperial.json +1 -0
- package/themes/joker.json +1 -0
- package/themes/marvin.json +1 -0
- package/themes/mean.json +1 -0
- package/themes/military.json +1 -0
- package/themes/neutral.json +1 -0
- package/themes/pirate.json +1 -0
- package/themes/sports.json +1 -0
- package/themes/verstappen.json +1 -0
- package/themes/wizard.json +1 -0
- package/web/dist/assets/{Activity-DDTRkc4M.js → Activity-BZE6REef.js} +1 -1
- package/web/dist/assets/{ActivityDetail-C41RsH-S.js → ActivityDetail-DCB8Cr2W.js} +1 -1
- package/web/dist/assets/{Dashboard-B0DJ2oUk.js → Dashboard-B4Drq5wp.js} +2 -2
- package/web/dist/assets/{Docs-DBj7-n9F.js → Docs-D30CiqYy.js} +1 -1
- package/web/dist/assets/{GreenhouseView-Bz-Q1Xl-.js → GreenhouseView-Djt-4oO5.js} +1 -1
- package/web/dist/assets/{Installs-BNlWdJJE.js → Installs-B3bw5ckh.js} +1 -1
- package/web/dist/assets/{JournalView-BkwmfkEy.js → JournalView-OWhx9YKg.js} +1 -1
- package/web/dist/assets/{Laws-CzvMZCUL.js → Laws-B9day2FV.js} +1 -1
- package/web/dist/assets/Library-BpIrLH-O.js +1 -0
- package/web/dist/assets/{Loops-s_NsuNaQ.js → Loops-CxH14kyE.js} +1 -1
- package/web/dist/assets/Markdown-DrXLM6TA.js +65 -0
- package/web/dist/assets/{OperatorHudView-CgeWJnPI.js → OperatorHudView-otOz6Gha.js} +1 -1
- package/web/dist/assets/{Personal-DMM6jfE0.js → Personal-abL6089y.js} +1 -1
- package/web/dist/assets/{Tabs-CPpcsYDc.js → Tabs-DjWN0i7A.js} +1 -1
- package/web/dist/assets/{drawable-D-fIT_ow.js → drawable-C2ZsBZCv.js} +1 -1
- package/web/dist/assets/index-CdpLpPQN.js +2 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/Library-BgIpOTHi.js +0 -1
- package/web/dist/assets/Markdown-C6fRd4A4.js +0 -67
- 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
|
[](package.json)
|
|
13
13
|
[](themes/)
|
|
14
14
|
[](src/skills/)
|
|
15
|
-
[](src/agents/)
|
|
16
16
|
[](src/doctrines/)
|
|
17
17
|
[](adapters/opencode/plugins/)
|
|
18
18
|
[](#-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** (
|
|
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.
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
|
17
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
248
|
-
parts.push(
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
)
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
//
|
|
276
|
-
|
|
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
|
|
279
|
-
const
|
|
280
|
-
const
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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 (
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
316
|
-
|
|
317
|
-
console.error(`[geneseed-learn]
|
|
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,
|
|
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,
|
|
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
|
})
|
package/docs/concepts/agents.md
CHANGED
|
@@ -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.
|
package/docs/concepts/hooks.md
CHANGED
|
@@ -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
|
|
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
|
|
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):
|
|
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;
|
package/docs/understand/a-day.md
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
|