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
@@ -20,7 +20,7 @@ What a Claude Code or OpenClaude install writes, piece by piece. The **Kind** wo
20
20
  | learn hook | When the agent finishes a reply, and before compaction, distils durable facts from the recent conversation into `memory/`. Off until you set `GENESEED_LLM` to a model command such as `claude -p` | automatic | One model call each time it runs, once enabled | Leave `GENESEED_LLM` unset (the default) |
21
21
  | `skills/` | Written procedures you call by name, such as `brainstorm` or `plan` | on demand | ~4.1k tokens for names and descriptions; a skill's body loads only when called | No per-skill switch; `geneseed uninstall` removes them all |
22
22
  | `agents/` | Specialists the agent can delegate to, such as `reviewer` or `tester` | on demand | ~0.5k tokens | `geneseed uninstall` |
23
- | `memory/` | Durable facts, one per file, with a `MEMORY.md` index the context hook injects | automatic | 0 when fresh; grows as facts accumulate | `geneseed memory list`, then `geneseed memory rm <name>` |
23
+ | `memory/` | Durable facts, one per file, with a `MEMORY.md` index the context hook injects | automatic | 0 when fresh; grows as facts accumulate | `geneseed memory list`, then `geneseed memory rm <name>` (archives; `--purge` deletes) |
24
24
  | `notebook/` | The agent's own working space: plans, scratch notes, and the gate log `gates.jsonl` | automatic | 0 when fresh; its index grows with use | Delete what you do not want; it is git-ignored and yours |
25
25
  | hook shim | `~/.geneseed/bin/geneseed-hook`, the one stable path every hook calls, so moving the Geneseed checkout does not break your hooks | automatic | Nothing on its own | Not removed by uninstall, since every install on the machine shares it. `GENESEED_HOME` moves it |
26
26
 
@@ -24,7 +24,7 @@ What an OpenCode install writes, piece by piece. The **Kind** words are explaine
24
24
  | workflow plugin | Adds a `workflow` tool that runs saved orchestration scripts | on demand | Runs only when called | `geneseed uninstall` |
25
25
  | `skills/` | Written procedures you call by name, such as `brainstorm` or `plan` | on demand | ~4.1k tokens for names and descriptions; a skill's body loads only when called | No per-skill switch; `geneseed uninstall` removes them all |
26
26
  | `agents/` | Specialists the agent can delegate to, such as `reviewer` or `tester` | on demand | ~0.5k tokens | `geneseed uninstall` |
27
- | `memory/` | Durable facts, one per file, with a `MEMORY.md` index the context plugin injects | automatic | 0 when fresh; grows as facts accumulate | `geneseed memory list`, then `geneseed memory rm <name>` |
27
+ | `memory/` | Durable facts, one per file, with a `MEMORY.md` index the context plugin injects | automatic | 0 when fresh; grows as facts accumulate | `geneseed memory list`, then `geneseed memory rm <name>` (archives; `--purge` deletes) |
28
28
  | `notebook/` | The agent's own working space: plans, scratch notes, and the gate log `gates.jsonl` | automatic | 0 when fresh; its index grows with use | Delete what you do not want; it is git-ignored and yours |
29
29
 
30
30
  ## Where the files are
@@ -2,5 +2,5 @@
2
2
  "_comment": "version is a human-readable release label only; the canonical identity of an installed harness is the source fingerprint stamped in .geneseed-version (see `harness version`), not this string.",
3
3
  "theme": "neutral",
4
4
  "product": "Geneseed",
5
- "version": "4.3.0"
5
+ "version": "4.4.0"
6
6
  }
package/js/README.md CHANGED
@@ -181,7 +181,7 @@ Verbs that change an install that already exists, as opposed to rendering one.
181
181
  | `setup.mjs` | The install wizard, and the CLI's only synchronous stdin readers (`ask`/`confirm`/`askChoice`) |
182
182
  | `uninstall.mjs` | Removes or disables an install: manifest-driven reversal, plus stash deactivate/reactivate |
183
183
  | `migrate.mjs` | `geneseed migrate` — all-or-nothing re-emit moving installs onto the npm install shape |
184
- | `memory.mjs` | `geneseed memory list|rm` — CLI-side fact deletion, twin of the web DELETE endpoint |
184
+ | `memory.mjs` | `geneseed memory list|rm` — CLI-side fact removal (archive by default, `--purge` deletes), twin of the web delete endpoint |
185
185
 
186
186
  **Before editing:** `update.mjs` is the one module that MUST spawn rather than import — the code on
187
187
  disk is replaced mid-run, so an in-process call would validate the *old* source and then render it.
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import path from 'node:path';
14
14
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
15
+ import { migrateMemoryIndex } from '../hosts/memory-files.mjs';
15
16
  import { copyFile, isDir, isFile, readText, writeText } from '../lib/fs.mjs';
16
17
  import { jsonDumpsIndent, parseJson, formatReprAscii, isTruthy } from '../lib/json.mjs';
17
18
  import { OWNED_SRC_DIRS, SRC_DIRS_MARKER, TMPL_SPEC_RE } from './emit-common.mjs';
@@ -223,6 +224,7 @@ export function build(cfg, themeName, out, { footprint = 'full', nativeCatalog =
223
224
  ensureProfileStub(out);
224
225
  ensureBundleGitignore(out);
225
226
  ensureMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
227
+ migrateMemoryIndex(path.join(out, theme[SRC_DIR_TOKENS.memory] ?? 'memory'));
226
228
  ensureNotebookIndex(path.join(out, theme[SRC_DIR_TOKENS.notebook] ?? 'notebook'));
227
229
  process.stdout.write(`[geneseed] built theme '${themeName}' -> ${out} (${items.length} files)\n`);
228
230
  }
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import path from 'node:path';
9
9
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
10
+ import { migrateMemoryIndex } from '../hosts/memory-files.mjs';
10
11
  import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
11
12
  import { ensureAgentOverridesStub, writeAliasCommands } from '../hosts/opencode.mjs';
12
13
  import {
@@ -146,6 +147,7 @@ export function emitClaudeRender(cfg, job) {
146
147
 
147
148
  const memStatus = globalMemory(cfgDir, items, out, cfg.src);
148
149
  ensureMemoryIndex(path.join(cfgDir, 'memory'));
150
+ migrateMemoryIndex(path.join(cfgDir, 'memory'));
149
151
  const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
150
152
  ensureNotebookIndex(path.join(cfgDir, 'notebook'));
151
153
  // context.json too: the root names it as the project's own manifest, and until 2026-10 this
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import path from 'node:path';
10
10
  import { VERSION_MARKER } from '../hosts/hosts.mjs';
11
+ import { migrateMemoryIndex } from '../hosts/memory-files.mjs';
11
12
  import { claimer, loadAgentOverrides, writeNativeLayer } from '../hosts/native.mjs';
12
13
  import {
13
14
  copyPlugins, copyWorkflows, ensureAgentOverridesStub, writeColorThemes, writeCommandLayer,
@@ -233,6 +234,7 @@ export function emitOpencodeGlobalRender(cfg, job) {
233
234
 
234
235
  const memStatus = globalMemory(cfgDir, items, out, cfg.src);
235
236
  ensureMemoryIndex(path.join(cfgDir, 'memory'));
237
+ migrateMemoryIndex(path.join(cfgDir, 'memory'));
236
238
  const nbStatus = globalNotebook(cfgDir, items, out, cfg.src);
237
239
  ensureNotebookIndex(path.join(cfgDir, 'notebook'));
238
240
  ensureWikiStub(cfgDir);
@@ -4,14 +4,18 @@
4
4
  *
5
5
  * Every stub is a literal here and an `ensure*` beside it, and the pairing is the contract:
6
6
  * the writer checks first and returns without writing when the file exists, because these are
7
- * the USER's files from the moment they exist. A stub body is bytes a recorded corpus compares
8
- * — changing one is a product change, not a tidy-up.
7
+ * the USER's files from the moment they exist. One exception: an emit also calls
8
+ * `migrateMemoryIndex` on the memory store, which rewrites a FLAT MEMORY.md (and its
9
+ * `topics/` files) into the two-level index once, losslessly, because a flat index overruns
10
+ * the session-start budget. Fact files are never touched. A stub body is bytes a recorded
11
+ * corpus compares — changing one is a product change, not a tidy-up.
9
12
  */
10
13
  import path from 'node:path';
11
14
  import { writeText } from '../lib/fs.mjs';
12
15
  import { isDir } from '../lib/fs.mjs';
13
16
  import { jsonDumpsIndent } from '../lib/json.mjs';
14
17
  import { EXCLUDES_FILE } from '../hosts/hosts.mjs';
18
+ import { MEMORY_SEED } from '../hosts/memory-files.mjs';
15
19
  import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
16
20
 
17
21
  // ---------------------------------------------------------------------------
@@ -202,7 +206,7 @@ agent-overrides.json
202
206
  `;
203
207
 
204
208
  // ---------------------------------------------------------------------------
205
- // Seeded stubs — write-once, never overwrite
209
+ // Seeded stubs — write-once, never overwrite (MEMORY.md: see the header's one exception)
206
210
  // ---------------------------------------------------------------------------
207
211
 
208
212
  /**
@@ -276,7 +280,8 @@ export const ensureBundleGitignore = stubWriter('.gitignore', BUNDLE_GITIGNORE);
276
280
  * right after the store dir is created, but never invent the dir themselves). */
277
281
  const storeIndexWriter = (file, header) => (dir) => { if (isDir(dir)) seed(path.join(dir, file), header); };
278
282
 
279
- const MEMORY_INDEX = '# Memory Index\n';
283
+ // Owned by `js/hosts/memory-files.mjs`, which writes an emptied store back to these bytes.
284
+ const MEMORY_INDEX = MEMORY_SEED;
280
285
  const NOTEBOOK_INDEX = '# Notebook Index\n';
281
286
 
282
287
  /** `_build_render.ensure_memory_index` — only inside an EXISTING store dir. */
package/js/cli-table.json CHANGED
@@ -619,7 +619,7 @@
619
619
  ],
620
620
  "dest": "consolidate",
621
621
  "metavar": null,
622
- "help": "rebuild MEMORY.md from the fact files on disk: re-index orphans, prune dead lines, report duplicate descriptions",
622
+ "help": "rebuild the memory index (MEMORY.md and topics/) from the fact files on disk: re-index orphans, prune dead lines, report duplicate descriptions",
623
623
  "choices": null,
624
624
  "default": false,
625
625
  "required": false,
@@ -909,7 +909,7 @@
909
909
  },
910
910
  {
911
911
  "name": "memory",
912
- "help": "manage the memory store: list the facts the agent reads, or remove one by name",
912
+ "help": "manage the memory store: list the facts the agent reads, or remove one by name (archived into memory/archive/; --purge deletes)",
913
913
  "description": "",
914
914
  "positionals": [
915
915
  {
@@ -957,6 +957,36 @@
957
957
  "is_flag": false,
958
958
  "type": null,
959
959
  "hidden": false
960
+ },
961
+ {
962
+ "names": [
963
+ "--archived"
964
+ ],
965
+ "dest": "archived",
966
+ "metavar": null,
967
+ "help": "act on memory/archive/ instead: list the archived facts, or (with --purge) delete one",
968
+ "choices": null,
969
+ "default": false,
970
+ "required": false,
971
+ "nargs": "0",
972
+ "is_flag": true,
973
+ "type": null,
974
+ "hidden": false
975
+ },
976
+ {
977
+ "names": [
978
+ "--purge"
979
+ ],
980
+ "dest": "purge",
981
+ "metavar": null,
982
+ "help": "rm deletes the fact for good instead of archiving it (cannot be undone)",
983
+ "choices": null,
984
+ "default": false,
985
+ "required": false,
986
+ "nargs": "0",
987
+ "is_flag": true,
988
+ "type": null,
989
+ "hidden": false
960
990
  }
961
991
  ],
962
992
  "mutex": []
@@ -6,7 +6,7 @@
6
6
  * launched-loop evidence reader were duplicated in `js/hosts/hooks.mjs` (the hook's
7
7
  * `loopExempt`) and `js/loop/cli.mjs` (the CLI's commit-message writer) — two copies of the
8
8
  * same worktree-following walk that could silently drift apart. Both now import this one.
9
- * `js/hosts/hooks.mjs` is NOT a safe shared home for it: it pulls in `node:child_process`
9
+ * `js/hosts/hooks.mjs` is NOT a safe shared home for it: it pulls in `child_process`
10
10
  * (`spawnSync`, for the destructive-act guard's own concerns) and the hook-dispatch
11
11
  * machinery (`jsonDumpsCompact`, the ledger), none of which `js/loop/cli.mjs` — under a hard
12
12
  * `child_process` ban like every other closure that feeds `bin/geneseed-cli.mjs` — may ever
@@ -54,6 +54,30 @@ export function gitDirOf(root) {
54
54
  return dotgit;
55
55
  }
56
56
 
57
+ /**
58
+ * The MAIN worktree's root when `cwd` stands in a LINKED worktree (`git worktree add`), else
59
+ * null — a main worktree, a submodule (its gitdir has no `commondir`), a bare repository's
60
+ * worktree, no repo, or a malformed link. Never throws.
61
+ *
62
+ * WHY: a linked worktree can live anywhere on disk (Orca puts them under its own workspaces
63
+ * folder), so a path-prefix test against the repo the user EXCLUDED never matches it, and the
64
+ * global harness woke up inside a sovereign repo's worktree. The worktree's `.git` FILE names
65
+ * `<common>/.git/worktrees/<n>`, whose `commondir` (relative to it, usually `../..`) names the
66
+ * shared `.git`; that directory's parent is the main worktree. Read off disk, never by
67
+ * spawning git: `sovereignBypass` runs on every hook call and feeds the CLI's `child_process` ban.
68
+ */
69
+ export function mainWorktreeOf(cwd) {
70
+ try {
71
+ const root = gitRootOf(cwd);
72
+ if (!root || !statSync(path.join(root, '.git')).isFile()) return null;
73
+ const gitdir = gitDirOf(root);
74
+ const common = path.resolve(gitdir, readFileSync(path.join(gitdir, 'commondir'), 'utf8').trim());
75
+ return path.basename(common) === '.git' ? path.dirname(common) : null;
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+
57
81
  /**
58
82
  * The branch checked out in `cwd`, read from .git/HEAD without spawning git. null on
59
83
  * anything unexpected (detached HEAD, no repo, a malformed worktree link, unreadable) — the
@@ -146,6 +146,28 @@ const SEED_SHA256 = new Set([
146
146
  ]);
147
147
  export { SESSION_FILES, SEED_SHA256 };
148
148
 
149
+ // A topic line of the memory map (`js/hosts/memory-files.mjs` writes it) and its fact count.
150
+ const MAP_COUNT_RE = /^- \[([^\]]*)\]\(topics\/[^)]+\.md\) — (\d+) facts?$/;
151
+ // Past this many facts a topic file stops being a quick read, and the curator splits it.
152
+ const CURATE_TOPIC_MAX = 40;
153
+ const CURATE_NUDGE = '[memory] A topic holds over 40 facts, or facts sit in misc: offer the user '
154
+ + 'to dispatch the memory-curator agent to merge, re-topic and archive this store.';
155
+ export { CURATE_TOPIC_MAX, CURATE_NUDGE };
156
+
157
+ /**
158
+ * The one-line curation nudge for a memory map, or ''. Read off the map's own counts, so it
159
+ * costs no extra file read on the hook path. Both triggers are ones a curation pass clears; a
160
+ * store that is merely LARGE is not one, since a curated large store would be nagged forever.
161
+ * The OpenCode context plugin carries the same rule (parity gated in tests/unit/claude.test.mjs).
162
+ */
163
+ export function memoryNudge(map) {
164
+ for (const line of map.split('\n')) {
165
+ const m = MAP_COUNT_RE.exec(line.trim());
166
+ if (m && (m[1] === 'misc' || Number(m[2]) > CURATE_TOPIC_MAX)) return CURATE_NUDGE;
167
+ }
168
+ return '';
169
+ }
170
+
149
171
  /**
150
172
  * The session files present under `hookRoot` and changed from their seed, as `{ rel, abs, text }`
151
173
  * (text CRLF-folded, trailing newlines trimmed). `$GENESEED_WIKI` overrides the wiki declaration,
@@ -164,7 +186,9 @@ export function sessionFiles(hookRoot) {
164
186
  let text;
165
187
  try { text = readFileSync(abs, 'utf8').replace(/\r\n/g, '\n'); } catch { continue; }
166
188
  if (SEED_SHA256.has(createHash('sha256').update(text).digest('hex'))) continue;
167
- found.push({ rel, abs, text: text.replace(/\n+$/, '') });
189
+ text = text.replace(/\n+$/, '');
190
+ const nudge = rel.endsWith('MEMORY.md') ? memoryNudge(text) : '';
191
+ found.push({ rel, abs, text: nudge ? `${text}\n${nudge}` : text });
168
192
  }
169
193
  return found;
170
194
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * `learn` — the Stop/SubagentStop/PreCompact verb: distil notes or a transcript into deduped
3
- * memory entries through the model CLI `$GENESEED_LLM` names. THE ONE SPAWN in the hook entry's
4
- * closure; the store's file format it writes through lives in `./memory-files.mjs`, which does
2
+ * `learn` — the Stop/SubagentStop/PreCompact verb: distil notes or a transcript into memory
3
+ * entries (a new fact, or an update or archive of a stored one) through the model CLI
4
+ * `$GENESEED_LLM` names. THE ONE SPAWN in the hook entry's closure; the store's file format it writes through lives in `./memory-files.mjs`, which does
5
5
  * not spawn and is what the CLI imports. See `js/hosts/hooks.mjs`'s header for the contract.
6
6
  */
7
7
  import path from 'node:path';
@@ -14,7 +14,7 @@ import {
14
14
  } from './hosts.mjs';
15
15
  import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
16
16
  import {
17
- existingSlugs, writeMemories, consolidateMemory, appendAgentLesson,
17
+ existingSlugs, readFacts, writeMemories, consolidateMemory, appendAgentLesson,
18
18
  } from './memory-files.mjs';
19
19
 
20
20
  const ROOT = path.resolve(import.meta.dirname, '../..');
@@ -163,13 +163,33 @@ export function readNotes(raw, meta = hookMeta(raw)) {
163
163
  return flat;
164
164
  }
165
165
 
166
- function buildLearnPrompt(notes, existing) {
166
+ /**
167
+ * The prompt: the plugin's head, the stored facts as `slug \u2014 description` (so the model can
168
+ * merge into one or archive it), the topics in use, then the notes.
169
+ *
170
+ * THE FACT LINES ARE CAPPED at MAX_STORED_CHARS. A store of hundreds of facts would otherwise
171
+ * outgrow the notes, and the prompt rides on the model CLI's command line, which Windows caps
172
+ * at 32 767 characters (16 000 of notes + the head + this stays under it). A fact past the
173
+ * cap is only unlisted: `writeMemories` still refuses to create its slug. The plugin's
174
+ * `buildPrompt` is the twin.
175
+ */
176
+ const MAX_STORED_CHARS = 8000;
177
+ export function buildLearnPrompt(notes, facts) {
167
178
  const parts = [learnPromptHead(), ''];
168
- if (existing.size) {
169
- parts.push('ALREADY STORED \u2014 do NOT emit a memory matching any of these '
170
- + 'slugs (skip updates too; only genuinely new facts):');
171
- parts.push(...[...existing].sort().map((slug) => `- ${slug}`));
172
- parts.push('');
179
+ if (facts.size) {
180
+ parts.push('ALREADY STORED \u2014 \'slug \u2014 description\', to update or archive, never to create:');
181
+ let used = 0;
182
+ let shown = 0;
183
+ for (const [slug, { desc }] of facts) {
184
+ const line = desc ? `- ${slug} \u2014 ${desc}` : `- ${slug}`;
185
+ if (used + line.length > MAX_STORED_CHARS) break;
186
+ parts.push(line);
187
+ used += line.length + 1;
188
+ shown += 1;
189
+ }
190
+ if (shown < facts.size) parts.push(`(${facts.size - shown} more stored facts not shown)`);
191
+ parts.push('', 'TOPICS IN USE:',
192
+ [...new Set([...facts.values()].map((f) => f.topic))].sort().join(', '), '');
173
193
  }
174
194
  parts.push('NOTES:', notes);
175
195
  return parts.join('\n');
@@ -329,7 +349,7 @@ export function cmdLearn(args) {
329
349
 
330
350
  const memDir = resolveMemoryDir(args.memory);
331
351
  const existing = memDir ? existingSlugs(memDir) : new Set();
332
- const prompt = buildLearnPrompt(notes, existing);
352
+ const prompt = buildLearnPrompt(notes, memDir ? readFacts(memDir) : new Map());
333
353
 
334
354
  const llm = process.env.GENESEED_LLM;
335
355
  if (!llm) {
@@ -341,11 +361,17 @@ export function cmdLearn(args) {
341
361
  const proc = runLlm(llm, prompt);
342
362
  const output = proc.stdout;
343
363
  if (memDir && output.trim() && output.trim().toUpperCase() !== 'NOTHING') {
344
- const written = writeMemories(output, memDir, existing);
345
- if (written.length) {
346
- err(`[learn] wrote ${written.length} memory file(s) to `
347
- + `${memDir}: ${written.join(', ')}\n`);
348
- } else {
364
+ const { created, updated, archived } = writeMemories(output, memDir, existing);
365
+ if (created.length) {
366
+ err(`[learn] wrote ${created.length} memory file(s) to `
367
+ + `${memDir}: ${created.join(', ')}\n`);
368
+ }
369
+ if (updated.length) err(`[learn] updated ${updated.length} memory file(s): ${updated.join(', ')}\n`);
370
+ if (archived.length) {
371
+ err(`[learn] archived ${archived.length} memory file(s) to `
372
+ + `${path.join(memDir, 'archive')}: ${archived.join(', ')}\n`);
373
+ }
374
+ if (!created.length && !updated.length && !archived.length) {
349
375
  err('[learn] nothing new to store (all duplicates).\n');
350
376
  }
351
377
  } else {
@@ -19,6 +19,7 @@ import path from 'node:path';
19
19
  import os from 'node:os';
20
20
  import { printErr, withDiscardableStderr, isDir, isFile, readText } from '../lib/fs.mjs';
21
21
  import { toPlatformPath, normcase } from '../lib/paths.mjs';
22
+ import { mainWorktreeOf } from './gitref.mjs';
22
23
 
23
24
  /** `_build_global.GLOBAL_MANIFEST` — the file whose presence means "a global install". */
24
25
  export const GLOBAL_MANIFEST = '.geneseed-manifest.json';
@@ -39,6 +40,11 @@ export const GATE_LEDGER = path.join('notebook', 'gates.jsonl');
39
40
  * under the transitive `child_process` ban that `hooks.mjs`'s `learn` verb breaks). A
40
41
  * second copy in `js/inspect/` was the alternative, and a copy is what let the `~user`
41
42
  * refusal diverge between the two CLIs once already (see `hooks.mjs`'s header).
43
+ *
44
+ * A LINKED GIT WORKTREE OF AN EXCLUDED REPO IS EXCLUDED TOO, wherever it sits on disk: the
45
+ * cwd matches, or the main worktree `mainWorktreeOf` reads off the `.git` file does. An
46
+ * exclusion names a repository the user keeps sovereign, not a folder; a worktree elsewhere
47
+ * (Orca's workspaces) otherwise woke the full global harness inside it.
42
48
  */
43
49
  export function sovereignBypass(root) {
44
50
  if (!root) return false;
@@ -49,12 +55,16 @@ export function sovereignBypass(root) {
49
55
  } catch {
50
56
  return false;
51
57
  }
52
- let cwd;
58
+ let heres;
53
59
  try {
54
- cwd = normcase(resolvePath(process.cwd()));
60
+ heres = [normcase(resolvePath(process.cwd()))];
55
61
  } catch {
56
62
  return false;
57
63
  }
64
+ try {
65
+ const main = mainWorktreeOf(process.cwd());
66
+ if (main) heres.push(normcase(resolvePath(main)));
67
+ } catch { /* an unresolvable main worktree degrades to the cwd test alone */ }
58
68
  for (const entry of entries) {
59
69
  const raw = (entry && typeof entry === 'object' ? entry.path : entry) || '';
60
70
  if (typeof raw !== 'string' || !raw.trim()) continue;
@@ -67,7 +77,7 @@ export function sovereignBypass(root) {
67
77
  // and this caller catches: the reference prints nothing here, so replaying the
68
78
  // message would be a stderr divergence on every hook call for one bad entry.
69
79
  //
70
- // `resolvePath`, the same resolution as `cwd` above: symlinks and junctions are followed on
80
+ // `resolvePath`, the same resolution as `heres` above: symlinks and junctions are followed on
71
81
  // BOTH sides, so an entry spelled through a link matches the real directory the session
72
82
  // stands in (and the OpenCode plugins' `norm` applies the identical rule).
73
83
  base = withDiscardableStderr(
@@ -79,7 +89,7 @@ export function sovereignBypass(root) {
79
89
  // Skipping errs toward the gate staying ACTIVE, which is the safe direction.
80
90
  continue;
81
91
  }
82
- if (cwd === base || cwd.startsWith(base + path.sep)) return true;
92
+ if (heres.some((h) => h === base || h.startsWith(base + path.sep))) return true;
83
93
  }
84
94
  return false;
85
95
  }