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
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
* SPLIT FROM `learn` FOR THE CLI's SAKE. `learn` spawns the model CLI, and the web console
|
|
6
6
|
* (`js/web/api.mjs`, `js/web/actions.mjs`), `geneseed memory` (`js/maintain/memory.mjs`) and the
|
|
7
7
|
* loop catalogue (`js/loop/catalog.mjs`) imported the hook module only for `frontmatter` and
|
|
8
|
-
* `memoryDropIndex` — which dragged
|
|
8
|
+
* `memoryDropIndex` — which dragged Node's child-process module into `bin/geneseed-cli.mjs`'s
|
|
9
|
+
* closure (the generator now imports this file too, and its spawn ban greps for that literal)
|
|
9
10
|
* and made the hook module a row in `tests/unit/hook_cli.test.mjs`'s spawn allow-list for a
|
|
10
11
|
* spawn the CLI never makes. Everything here is fs-only; `hooks-learn.mjs` is the one spawner.
|
|
11
12
|
*/
|
|
12
|
-
import { existsSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, renameSync, rmdirSync, unlinkSync } from 'node:fs';
|
|
13
14
|
import path from 'node:path';
|
|
14
15
|
import { readText, writeText } from '../lib/fs.mjs';
|
|
15
16
|
import { normcase } from '../lib/paths.mjs';
|
|
@@ -24,6 +25,25 @@ const FILE_SEP_RE = /^---FILE---[^\S\n]*$/m;
|
|
|
24
25
|
const MEMORY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
|
|
25
26
|
const MAX_AGENT_BULLETS = 100;
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The seed MEMORY.md body. `js/build/stubs.mjs` seeds it from here, and an emptied store is
|
|
30
|
+
* written back to exactly these bytes, so the context hook's seed hash keeps skipping it.
|
|
31
|
+
*/
|
|
32
|
+
export const MEMORY_SEED = '# Memory Index\n';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* THE INDEX HAS TWO LEVELS. MEMORY.md is a map of topics, injected every session. Each
|
|
36
|
+
* `topics/<topic>.md` lists the facts under that topic, and the agent opens it on demand.
|
|
37
|
+
* A flat one-line-per-fact index overran the context hook's session budget at about 200
|
|
38
|
+
* facts and lost the newest ones, which is why it was split.
|
|
39
|
+
*/
|
|
40
|
+
const TOPICS = 'topics';
|
|
41
|
+
const MAP_HINT = 'Each topic file lists its facts; open the one the task needs.';
|
|
42
|
+
// A fact pointer: `- [title](slug.md)` in a legacy flat index, `- [title](../slug.md)` in a
|
|
43
|
+
// topic file. The slug may not hold a `/`, so a map line (`topics/x.md`) is never a fact.
|
|
44
|
+
const FACT_LINE_RE = /^- \[([^\]]*)\]\((?:\.\.\/)?([^/)]+)\.md\)(.*)$/;
|
|
45
|
+
const MAP_LINE_RE = /^- \[[^\]]*\]\(topics\/[^)]+\.md\)/;
|
|
46
|
+
|
|
27
47
|
/** `append_agent_lesson` — one dated bullet, capped at the newest MAX_AGENT_BULLETS. */
|
|
28
48
|
export function appendAgentLesson(memDir, agent, lesson) {
|
|
29
49
|
const d = path.join(memDir, 'agents');
|
|
@@ -77,45 +97,247 @@ function globMd(dir) {
|
|
|
77
97
|
/** Stems that name the store's own files, never a fact — `MEMORY` is the index itself. */
|
|
78
98
|
const RESERVED_STEMS = new Set(['memory', 'readme']);
|
|
79
99
|
|
|
80
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* `_existing_slugs` — slugs already stored. learn never CREATES one of these; it may only
|
|
102
|
+
* update or archive it by name (`writeMemories`).
|
|
103
|
+
*/
|
|
81
104
|
export function existingSlugs(memDir) {
|
|
82
105
|
return new Set(globMd(memDir).map(stem).filter((s) => !RESERVED_STEMS.has(s.toLowerCase())));
|
|
83
106
|
}
|
|
84
107
|
|
|
108
|
+
/** Lines of a text file, or null when it cannot be read. */
|
|
109
|
+
function readLines(p) {
|
|
110
|
+
try { return splitLines(readText(p)); } catch { return null; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Write `text` only when the file does not already hold it. A second run of a rebuild then
|
|
115
|
+
* touches nothing, which keeps `golden --idempotent` green and leaves the user's mtimes alone.
|
|
116
|
+
*/
|
|
117
|
+
function writeIfChanged(p, text) {
|
|
118
|
+
const old = readLines(p);
|
|
119
|
+
if (old && `${old.join('\n')}\n` === text) return;
|
|
120
|
+
writeText(p, text);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A topic name as a file stem: `[a-z0-9-]`, any other run becomes `-`, empty becomes `misc`. */
|
|
124
|
+
function topicSlug(raw) {
|
|
125
|
+
return raw.toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'misc';
|
|
126
|
+
}
|
|
127
|
+
|
|
85
128
|
/**
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
129
|
+
* The topic a fact files under: `topic:`, else `type:`, else `misc`.
|
|
130
|
+
*
|
|
131
|
+
* NESTED `type:` IS ACCEPTED ON PURPOSE. The flat frontmatter parser trims keys, so Claude
|
|
132
|
+
* Code's own memory shape, with `type:` indented under `metadata:`, reads as `type`.
|
|
133
|
+
*/
|
|
134
|
+
const topicOf = (fm) => topicSlug(fmGet(fm, 'topic') || fmGet(fm, 'type'));
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Every fact on disk, in the index's sort order: slug -> { desc, topic }. Top level only, so
|
|
138
|
+
* `archive/` and `agents/` are never facts. Exported for learn's prompt, which lists them.
|
|
139
|
+
*/
|
|
140
|
+
export function readFacts(memDir) {
|
|
141
|
+
const facts = new Map();
|
|
142
|
+
for (const name of sortPaths(globMd(memDir))) {
|
|
143
|
+
const s = stem(name);
|
|
144
|
+
if (RESERVED_STEMS.has(s.toLowerCase())) continue;
|
|
145
|
+
try {
|
|
146
|
+
const [fm] = frontmatter(readText(path.join(memDir, name)));
|
|
147
|
+
facts.set(s, { desc: fmGet(fm, 'description').trim(), topic: topicOf(fm) });
|
|
148
|
+
} catch { continue; }
|
|
149
|
+
}
|
|
150
|
+
return facts;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Every fact pointer the index holds now, from a flat MEMORY.md and from the topic files:
|
|
155
|
+
* slug -> { title, rest }. `rest` is everything after the link, hook text included.
|
|
156
|
+
*/
|
|
157
|
+
function indexedFacts(memDir) {
|
|
158
|
+
const found = new Map();
|
|
159
|
+
const tdir = path.join(memDir, TOPICS);
|
|
160
|
+
const files = [path.join(memDir, 'MEMORY.md'), ...globMd(tdir).map((f) => path.join(tdir, f))];
|
|
161
|
+
for (const f of files) {
|
|
162
|
+
for (const line of readLines(f) ?? []) {
|
|
163
|
+
const m = FACT_LINE_RE.exec(line.trim());
|
|
164
|
+
if (m) found.set(m[2], { title: m[1], rest: m[3] });
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return found;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Rewrite MEMORY.md as the map: one line per topic file with its fact count.
|
|
172
|
+
*
|
|
173
|
+
* LINES THE MAP DID NOT GENERATE ARE CARRIED, so a note the user wrote into the index
|
|
174
|
+
* survives. `dropFlat` also drops legacy flat fact pointers; a rebuild passes it because it
|
|
175
|
+
* has just moved them into topic files. With no topic and nothing carried, the file goes
|
|
176
|
+
* back to the seed.
|
|
177
|
+
*/
|
|
178
|
+
function writeMap(memDir, dropFlat) {
|
|
179
|
+
const idx = path.join(memDir, 'MEMORY.md');
|
|
180
|
+
const carried = (readLines(idx) ?? []).filter((l) => {
|
|
181
|
+
const t = l.trim();
|
|
182
|
+
return t && t !== MEMORY_SEED.trim() && t !== MAP_HINT && !MAP_LINE_RE.test(t)
|
|
183
|
+
&& !(dropFlat && FACT_LINE_RE.test(t));
|
|
184
|
+
});
|
|
185
|
+
const tdir = path.join(memDir, TOPICS);
|
|
186
|
+
const topics = globMd(tdir).map(stem).sort().map((t) => {
|
|
187
|
+
const n = (readLines(path.join(tdir, `${t}.md`)) ?? [])
|
|
188
|
+
.filter((l) => FACT_LINE_RE.test(l.trim())).length;
|
|
189
|
+
return `- [${t}](${TOPICS}/${t}.md) — ${n} fact${n === 1 ? '' : 's'}`;
|
|
190
|
+
});
|
|
191
|
+
const body = [...(topics.length ? ['', MAP_HINT, '', ...topics] : []),
|
|
192
|
+
...(carried.length ? ['', ...carried] : [])];
|
|
193
|
+
writeIfChanged(idx, body.length ? `${MEMORY_SEED}${body.join('\n')}\n` : MEMORY_SEED);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Remove the topics dir when nothing is left in it. */
|
|
197
|
+
function pruneTopicsDir(memDir) {
|
|
198
|
+
try { rmdirSync(path.join(memDir, TOPICS)); } catch { /* not empty, or absent */ }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Rebuild both index levels from the fact files on disk.
|
|
203
|
+
*
|
|
204
|
+
* A fact the index already points at keeps its existing line, title and hook included,
|
|
205
|
+
* unless `refresh` is set. That makes a migration or a learn run lossless. `consolidate`
|
|
206
|
+
* passes `refresh`, because it exists to re-read the descriptions; learn passes the slugs
|
|
207
|
+
* it just UPDATED as `fresh`, because their old line describes a fact that is gone. Topic
|
|
208
|
+
* files no topic uses any more are deleted: they are generated, never facts.
|
|
209
|
+
*/
|
|
210
|
+
function rebuildIndex(memDir, refresh, fresh = new Set()) {
|
|
211
|
+
const facts = readFacts(memDir);
|
|
212
|
+
const old = indexedFacts(memDir);
|
|
213
|
+
const byTopic = new Map();
|
|
214
|
+
for (const [slug, { desc, topic }] of facts) {
|
|
215
|
+
const o = refresh || fresh.has(slug) ? null : old.get(slug);
|
|
216
|
+
const line = o ? `- [${o.title}](../${slug}.md)${o.rest}`
|
|
217
|
+
: `- [${slug}](../${slug}.md)${desc ? ` — ${desc}` : ''}`;
|
|
218
|
+
if (!byTopic.has(topic)) byTopic.set(topic, []);
|
|
219
|
+
byTopic.get(topic).push(line);
|
|
220
|
+
}
|
|
221
|
+
const tdir = path.join(memDir, TOPICS);
|
|
222
|
+
for (const f of globMd(tdir)) {
|
|
223
|
+
if (!byTopic.has(stem(f))) unlinkSync(path.join(tdir, f));
|
|
224
|
+
}
|
|
225
|
+
if (byTopic.size) mkdirSync(tdir, { recursive: true });
|
|
226
|
+
for (const [topic, lines] of byTopic) {
|
|
227
|
+
writeIfChanged(path.join(tdir, `${topic}.md`), `# ${topic}\n\n${lines.join('\n')}\n`);
|
|
228
|
+
}
|
|
229
|
+
if (!byTopic.size) pruneTopicsDir(memDir);
|
|
230
|
+
writeMap(memDir, true);
|
|
231
|
+
return { facts, old };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Move a flat MEMORY.md to the two-level index, once, at emit time.
|
|
236
|
+
*
|
|
237
|
+
* Acts only when MEMORY.md still holds a flat fact pointer and at least one fact exists, so
|
|
238
|
+
* a seed-only store and an already-migrated store are left alone. It rewrites MEMORY.md and
|
|
239
|
+
* `topics/*.md` only, never a fact file, and it keeps each pointer's own title and hook.
|
|
240
|
+
*/
|
|
241
|
+
export function migrateMemoryIndex(memDir) {
|
|
242
|
+
const lines = readLines(path.join(memDir, 'MEMORY.md'));
|
|
243
|
+
if (!lines?.some((l) => FACT_LINE_RE.test(l.trim())) || !existingSlugs(memDir).size) return false;
|
|
244
|
+
rebuildIndex(memDir, false);
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The chunk without its frontmatter `action:` line, which is an instruction, not the fact. */
|
|
249
|
+
function stripAction(chunk) {
|
|
250
|
+
const lines = chunk.split('\n');
|
|
251
|
+
const close = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
|
|
252
|
+
if (lines[0].trim() !== '---' || close < 0) return chunk;
|
|
253
|
+
return lines.filter((l, i) => i === 0 || i >= close || !/^\s*action\s*:/.test(l)).join('\n');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Whether the stored fact carries a `force:`. Such a fact is never updated or archived by
|
|
258
|
+
* learn: the memory README's Bridge rule has it revised inline with its supersession
|
|
259
|
+
* recorded, a `constraint` only by the user, and the model sees no fact's body or `force:`.
|
|
260
|
+
*/
|
|
261
|
+
function hasForce(memDir, slug) {
|
|
262
|
+
try { return !!fmGet(frontmatter(readText(path.join(memDir, `${slug}.md`)))[0], 'force'); } catch {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Move `<slug>.md` into `archive/`, never over a file already there: a taken name gets
|
|
269
|
+
* `-2`, `-3`, ... A counter rather than a date, so a second archive the same day cannot
|
|
270
|
+
* collide. RENAME, never unlink: an archived fact leaves the index but stays on disk.
|
|
271
|
+
* Returns the path it landed at. The index is the caller's: `geneseed memory rm` drops the
|
|
272
|
+
* line (`memoryDropIndex`), learn rebuilds both levels.
|
|
273
|
+
*/
|
|
274
|
+
export function archiveFact(memDir, slug) {
|
|
275
|
+
const adir = path.join(memDir, 'archive');
|
|
276
|
+
mkdirSync(adir, { recursive: true });
|
|
277
|
+
let dest = path.join(adir, `${slug}.md`);
|
|
278
|
+
for (let n = 2; existsSync(dest); n += 1) dest = path.join(adir, `${slug}-${n}.md`);
|
|
279
|
+
renameSync(path.join(memDir, `${slug}.md`), dest);
|
|
280
|
+
return dest;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* `_write_memories` — split the model output into files and apply each, then rebuild the
|
|
285
|
+
* two-level index from disk with the same builder `consolidate` uses. A rebuild rather than
|
|
286
|
+
* an append, so a legacy flat index is migrated on the way and a topic count can never
|
|
287
|
+
* drift from its file. `existing` is MUTATED to match the store afterwards.
|
|
288
|
+
*
|
|
289
|
+
* A chunk's frontmatter `action:` says what to do with its `name:`:
|
|
290
|
+
* - none (or `create`): write a NEW fact; a slug already stored is skipped;
|
|
291
|
+
* - `update`: archive the stored fact (`archiveFact`), then write this text, which the
|
|
292
|
+
* model has merged, in its place. The model sees only the description, so the old
|
|
293
|
+
* body, links and extra frontmatter survive in `archive/` and every update is reversible;
|
|
294
|
+
* - `archive`: move the stored fact to `archive/` (`archiveFact`).
|
|
295
|
+
* An update or archive naming no stored fact, or one carrying a `force:` (`hasForce`), is
|
|
296
|
+
* skipped, as is any other action, and each slug is acted on once per reply.
|
|
89
297
|
*
|
|
90
298
|
* A reserved stem is refused (`name: MEMORY` would overwrite the index), and names compare
|
|
91
299
|
* LOWERCASE: on Windows and macOS `User-Prefs.md` and `user-prefs.md` are one file, so a
|
|
92
|
-
* case variant of a stored slug would overwrite it.
|
|
300
|
+
* case variant of a stored slug would overwrite it. An update or archive therefore acts on
|
|
301
|
+
* the STORED spelling, whatever case the model used.
|
|
302
|
+
*
|
|
303
|
+
* Returns `{ created, updated, archived }`, each a list of slugs.
|
|
93
304
|
*/
|
|
94
305
|
export function writeMemories(modelOutput, memDir, existing) {
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
const
|
|
306
|
+
const stored = new Map([...existing].map((s) => [s.toLowerCase(), s]));
|
|
307
|
+
const done = new Set();
|
|
308
|
+
const res = { created: [], updated: [], archived: [] };
|
|
98
309
|
for (let chunk of modelOutput.split(FILE_SEP_RE)) {
|
|
99
310
|
chunk = chunk.trim();
|
|
100
311
|
if (!chunk || chunk.toUpperCase() === 'NOTHING') continue;
|
|
101
312
|
const [fm] = frontmatter(chunk);
|
|
102
313
|
const name = fmGet(fm, 'name').trim();
|
|
103
314
|
const key = name.toLowerCase();
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
315
|
+
const action = fmGet(fm, 'action').trim().toLowerCase() || 'create';
|
|
316
|
+
if (!MEMORY_SLUG_RE.test(name) || RESERVED_STEMS.has(key) || done.has(key)) continue;
|
|
317
|
+
const slug = stored.get(key);
|
|
318
|
+
if (slug && action !== 'create' && hasForce(memDir, slug)) continue;
|
|
319
|
+
const body = `${stripAction(chunk).replace(/\n+$/, '')}\n`;
|
|
320
|
+
if (action === 'create' && !slug) {
|
|
321
|
+
writeText(path.join(memDir, `${name}.md`), body);
|
|
322
|
+
existing.add(name);
|
|
323
|
+
stored.set(key, name);
|
|
324
|
+
res.created.push(name);
|
|
325
|
+
} else if (action === 'update' && slug) {
|
|
326
|
+
archiveFact(memDir, slug); // the old version first: the model never saw its body
|
|
327
|
+
writeText(path.join(memDir, `${slug}.md`), body);
|
|
328
|
+
res.updated.push(slug);
|
|
329
|
+
} else if (action === 'archive' && slug) {
|
|
330
|
+
archiveFact(memDir, slug);
|
|
331
|
+
existing.delete(slug);
|
|
332
|
+
stored.delete(key);
|
|
333
|
+
res.archived.push(slug);
|
|
334
|
+
} else continue;
|
|
335
|
+
done.add(key);
|
|
111
336
|
}
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
const current = existsSync(index) ? `${readText(index).replace(/\n+$/, '')}\n`
|
|
115
|
-
: '# Memory Index\n';
|
|
116
|
-
writeText(index, `${current}${indexLines.join('\n')}\n`);
|
|
337
|
+
if (res.created.length || res.updated.length || res.archived.length) {
|
|
338
|
+
rebuildIndex(memDir, false, new Set(res.updated));
|
|
117
339
|
}
|
|
118
|
-
return
|
|
340
|
+
return res;
|
|
119
341
|
}
|
|
120
342
|
|
|
121
343
|
/**
|
|
@@ -134,57 +356,58 @@ export function writeMemories(modelOutput, memDir, existing) {
|
|
|
134
356
|
* so a hand-written index in a different shape still loses its entry. And the file is only
|
|
135
357
|
* REWRITTEN when something actually changed, which keeps a delete of an unindexed fact from
|
|
136
358
|
* touching the user's mtime.
|
|
359
|
+
*
|
|
360
|
+
* BOTH LEVELS: the `(<name>.md)` line of a legacy flat MEMORY.md, and the `(../<name>.md)`
|
|
361
|
+
* line of whichever topic file holds it. A topic file left with no fact is deleted, and the
|
|
362
|
+
* map's counts are refreshed only when a topic file changed. Matching the two exact forms
|
|
363
|
+
* keeps `b` from taking `ab.md` with it.
|
|
137
364
|
*/
|
|
138
365
|
export function memoryDropIndex(memDir, name) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
366
|
+
dropLines(path.join(memDir, 'MEMORY.md'), `(${name}.md)`);
|
|
367
|
+
const tdir = path.join(memDir, TOPICS);
|
|
368
|
+
let touched = false;
|
|
369
|
+
for (const f of globMd(tdir)) {
|
|
370
|
+
const p = path.join(tdir, f);
|
|
371
|
+
const left = dropLines(p, `(../${name}.md)`);
|
|
372
|
+
if (!left) continue;
|
|
373
|
+
touched = true;
|
|
374
|
+
if (!left.some((l) => FACT_LINE_RE.test(l.trim()))) {
|
|
375
|
+
try { unlinkSync(p); } catch { /* as below */ }
|
|
376
|
+
}
|
|
145
377
|
}
|
|
146
|
-
|
|
147
|
-
|
|
378
|
+
if (!touched) return;
|
|
379
|
+
pruneTopicsDir(memDir);
|
|
380
|
+
try { writeMap(memDir, false); } catch { /* the reference swallows OSError here too */ }
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Drop every line holding `needle`; the kept lines when something went, else null. */
|
|
384
|
+
function dropLines(p, needle) {
|
|
385
|
+
const lines = readLines(p);
|
|
386
|
+
if (!lines) return null; // the reference's `except OSError: return`
|
|
387
|
+
const keep = lines.filter((ln) => !ln.includes(needle));
|
|
388
|
+
if (keep.length === lines.length) return null;
|
|
148
389
|
try {
|
|
149
|
-
writeText(
|
|
390
|
+
writeText(p, `${keep.join('\n')}\n`);
|
|
150
391
|
} catch { /* the reference swallows OSError here too */ }
|
|
392
|
+
return keep;
|
|
151
393
|
}
|
|
152
394
|
|
|
153
395
|
/**
|
|
154
|
-
* `consolidate_memory` — rebuild
|
|
155
|
-
* orphans, prune dead lines,
|
|
156
|
-
*
|
|
396
|
+
* `consolidate_memory` — rebuild both index levels from the fact files actually on disk:
|
|
397
|
+
* index orphans, prune dead lines, refresh each line from its fact's description, report
|
|
398
|
+
* duplicate descriptions. It REPORTS them and never merges: merging is a judgement, made by
|
|
399
|
+
* the model at learn time (`writeMemories`'s `update`) or by the user, never by this rebuild. `added` and
|
|
400
|
+
* `pruned` compare against the pointers of BOTH levels, so a migration counts as neither.
|
|
157
401
|
*/
|
|
158
402
|
export function consolidateMemory(memDir) {
|
|
159
|
-
const
|
|
160
|
-
const facts = new Map();
|
|
161
|
-
for (const name of sortPaths(globMd(memDir))) {
|
|
162
|
-
const s = stem(name);
|
|
163
|
-
if (skip.has(s.toLowerCase())) continue;
|
|
164
|
-
try {
|
|
165
|
-
facts.set(s, fmGet(frontmatter(readText(path.join(memDir, name)))[0], 'description').trim());
|
|
166
|
-
} catch { continue; }
|
|
167
|
-
}
|
|
168
|
-
const index = path.join(memDir, 'MEMORY.md');
|
|
169
|
-
const oldSlugs = new Set();
|
|
170
|
-
if (existsSync(index)) {
|
|
171
|
-
for (const line of splitLines(readText(index))) {
|
|
172
|
-
const m = /^- \[[^\]]*\]\(([^)]+)\.md\)/.exec(line.trim());
|
|
173
|
-
if (m) oldSlugs.add(m[1]);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
const lines = ['# Memory Index', ''];
|
|
177
|
-
for (const [slug, desc] of facts) {
|
|
178
|
-
lines.push(`- [${slug}](${slug}.md)${desc ? ` \u2014 ${desc}` : ''}`);
|
|
179
|
-
}
|
|
180
|
-
writeText(index, `${lines.join('\n')}\n`);
|
|
403
|
+
const { facts, old } = rebuildIndex(memDir, true);
|
|
181
404
|
const seen = new Map();
|
|
182
405
|
const dups = [];
|
|
183
|
-
for (const [slug, desc] of facts) {
|
|
406
|
+
for (const [slug, { desc }] of facts) {
|
|
184
407
|
if (desc && seen.has(desc)) dups.push([seen.get(desc), slug]);
|
|
185
408
|
if (!seen.has(desc)) seen.set(desc, slug);
|
|
186
409
|
}
|
|
187
|
-
const added = [...facts.keys()].filter((s) => !
|
|
188
|
-
const pruned = [...
|
|
410
|
+
const added = [...facts.keys()].filter((s) => !old.has(s)).sort();
|
|
411
|
+
const pruned = [...old.keys()].filter((s) => !facts.has(s)).sort();
|
|
189
412
|
return { added, pruned, duplicates: dups };
|
|
190
413
|
}
|
|
@@ -196,7 +196,8 @@ export function colorThemeProblems() {
|
|
|
196
196
|
* rebuild able to clear it.
|
|
197
197
|
*
|
|
198
198
|
* Host-state files (context.json, MEMORY.md, the markers) are created once and never
|
|
199
|
-
* rendered
|
|
199
|
+
* rendered (MEMORY.md's one later write is `migrateMemoryIndex` moving a flat index to
|
|
200
|
+
* topics), so they are not in the render set and are correctly ignored. Notebook files
|
|
200
201
|
* except its `.gitignore` are seed-once and agent-owned after the first build, so they are
|
|
201
202
|
* compared only for EXISTENCE.
|
|
202
203
|
*/
|
package/js/inspect/cost.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { isFile, readText } from '../lib/fs.mjs';
|
|
|
28
28
|
* console draws it on the cost chart, so it lives here, once, where both read it. Raise it by
|
|
29
29
|
* exactly the measured overrun and say why in that test's docblock, which keeps its history.
|
|
30
30
|
*/
|
|
31
|
-
export const CARRIER_CEILING = { full:
|
|
31
|
+
export const CARRIER_CEILING = { full: 62_107, lean: 38_976 };
|
|
32
32
|
|
|
33
33
|
const decode = (p) => readText(p).split('\r\n').join('\n');
|
|
34
34
|
|
package/js/inspect/excludes.mjs
CHANGED
|
@@ -32,6 +32,28 @@ import { normcase, toPlatformPath } from '../lib/paths.mjs';
|
|
|
32
32
|
const localSettings = (repo, host) =>
|
|
33
33
|
path.join(repo, HOSTS.find((h) => h.host === host).projectMarker, 'settings.local.json');
|
|
34
34
|
|
|
35
|
+
/** The `claudeMdExcludes` entry naming a Claude-family global install's CLAUDE.md, as wired. */
|
|
36
|
+
const claudeMdEntry = (cfg) => resolvePath(path.join(cfg, 'CLAUDE.md')).split(path.sep).join('/');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The worktree caveat, or null when it does not apply: a Claude-family install and an
|
|
40
|
+
* excluded folder that is a git repository (a `.git` there — no repo, no worktrees).
|
|
41
|
+
*
|
|
42
|
+
* WHY A WARNING AND NOT A WRITE: `settings.local.json` is gitignored, so a `git worktree add`
|
|
43
|
+
* of the excluded repo never carries it and the global CLAUDE.md loads there again (the hooks
|
|
44
|
+
* do stand down — `sovereignBypass` follows the worktree to its main repo). The one durable
|
|
45
|
+
* fix is the repo's TRACKED `settings.json`, and the user decided (2026-10) Geneseed never
|
|
46
|
+
* writes a tracked file in a repo it was told to leave alone — so it prints the snippet.
|
|
47
|
+
*/
|
|
48
|
+
function worktreeNote(host, cfg, repo) {
|
|
49
|
+
if (host !== 'claude' && host !== 'openclaude') return null;
|
|
50
|
+
if (!existsSync(path.join(repo, '.git'))) return null;
|
|
51
|
+
const marker = HOSTS.find((h) => h.host === host).projectMarker;
|
|
52
|
+
return `${host}: git worktrees of this repo do not get the gitignored `
|
|
53
|
+
+ `${marker}/settings.local.json and still load the global CLAUDE.md — to stop that, commit `
|
|
54
|
+
+ `to the repo's ${marker}/settings.json: "claudeMdExcludes": ["${claudeMdEntry(cfg)}"]`;
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
/** `_global_installs` — every host whose GLOBAL config dir carries a Geneseed manifest. */
|
|
36
58
|
function globalInstalls() {
|
|
37
59
|
const out = [];
|
|
@@ -112,8 +134,7 @@ export function excludeAdd(target) {
|
|
|
112
134
|
const priorWired = (isDict(prior) ? get(prior, 'wired') : null) || {};
|
|
113
135
|
const wired = {};
|
|
114
136
|
if ((host === 'claude' || host === 'openclaude') && isDir(repo)) {
|
|
115
|
-
const
|
|
116
|
-
const added = wireClaudeExcludes(localSettings(repo, host), [entry]);
|
|
137
|
+
const added = wireClaudeExcludes(localSettings(repo, host), [claudeMdEntry(cfg)]);
|
|
117
138
|
if (added.length) {
|
|
118
139
|
wired.claude_md_excludes = added;
|
|
119
140
|
} else if (get(priorWired, 'claude_md_excludes')) {
|
|
@@ -126,6 +147,8 @@ export function excludeAdd(target) {
|
|
|
126
147
|
// own project-bypasses-global wiring, or a bailed write) — do NOT fabricate ownership,
|
|
127
148
|
// or a later excludeRemove would strip wiring we never created.
|
|
128
149
|
if (!added.length) messages.push(`${host}: claudeMdExcludes already present (kept)`);
|
|
150
|
+
const note = worktreeNote(host, cfg, repo);
|
|
151
|
+
if (note) messages.push(note);
|
|
129
152
|
}
|
|
130
153
|
if (host === 'bob' && isDir(repo)) {
|
|
131
154
|
const stub = path.join(repo, '.bob', 'rules', 'geneseed.md');
|
|
@@ -249,6 +272,14 @@ export function cmdExclude(args) {
|
|
|
249
272
|
const flag = missing.length
|
|
250
273
|
? ` (MISSING from: ${missing.join(', ')} — re-run \`geneseed exclude add\`)` : '';
|
|
251
274
|
printOut(` ${rec.path} [${rec.hosts.slice().sort().join(', ')}]${flag}\n`);
|
|
275
|
+
for (const { host, cfg } of snap.installs) {
|
|
276
|
+
if (!rec.hosts.includes(host)) continue;
|
|
277
|
+
let note = null;
|
|
278
|
+
try {
|
|
279
|
+
note = worktreeNote(host, cfg, toPlatformPath(rec.path));
|
|
280
|
+
} catch { /* a broken resolver: the listing itself must still print */ }
|
|
281
|
+
if (note) printOut(` note: ${note}\n`);
|
|
282
|
+
}
|
|
252
283
|
}
|
|
253
284
|
return 0;
|
|
254
285
|
}
|
package/js/maintain/memory.mjs
CHANGED
|
@@ -25,11 +25,16 @@
|
|
|
25
25
|
*
|
|
26
26
|
* `memoryDropIndex` is the hook module's, not a copy: writing the fact and unwriting its
|
|
27
27
|
* index line are two halves of one format, and the half that writes lives there.
|
|
28
|
+
*
|
|
29
|
+
* `rm` ARCHIVES BY DEFAULT. A removed fact moves into `archive/` (`archiveFact`, the helper
|
|
30
|
+
* learn uses), so a slip of the keyboard or a click in the console is reversible; only
|
|
31
|
+
* `--purge` unlinks. `--archived` points `list` and `rm` at `archive/` itself, and a file
|
|
32
|
+
* there can only be purged — it has nowhere further to go.
|
|
28
33
|
*/
|
|
29
34
|
import { readdirSync, unlinkSync } from 'node:fs';
|
|
30
35
|
import path from 'node:path';
|
|
31
36
|
|
|
32
|
-
import { memoryDropIndex } from '../hosts/memory-files.mjs';
|
|
37
|
+
import { archiveFact, memoryDropIndex } from '../hosts/memory-files.mjs';
|
|
33
38
|
import { resolveMemoryDir } from '../hosts/hosts.mjs';
|
|
34
39
|
import { printOut, printErr, isFile } from '../lib/fs.mjs';
|
|
35
40
|
|
|
@@ -58,13 +63,28 @@ export function memoryFacts(dir) {
|
|
|
58
63
|
.map((n) => n.slice(0, -3)).filter(isFactName).sort();
|
|
59
64
|
}
|
|
60
65
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Take a fact out of the store and its line out of the index. Archived into `archive/` unless
|
|
68
|
+
* `purge`, which unlinks it. Returns where it went (the archive path, or '' when purged), or
|
|
69
|
+
* null when `name` is not a fact here.
|
|
70
|
+
*/
|
|
71
|
+
export function memoryDelete(dir, name, purge = false) {
|
|
72
|
+
if (!isFactName(name)) return null;
|
|
64
73
|
const p = path.join(dir, `${name}.md`);
|
|
74
|
+
if (!isFile(p)) return null;
|
|
75
|
+
let dest = '';
|
|
76
|
+
if (purge) unlinkSync(p);
|
|
77
|
+
else dest = archiveFact(dir, name);
|
|
78
|
+
memoryDropIndex(dir, name);
|
|
79
|
+
return dest;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Unlink `archive/<name>.md`. True when a file went. The archive has no index to update. */
|
|
83
|
+
export function memoryPurgeArchived(dir, name) {
|
|
84
|
+
if (!isFactName(name)) return false;
|
|
85
|
+
const p = path.join(dir, 'archive', `${name}.md`);
|
|
65
86
|
if (!isFile(p)) return false;
|
|
66
87
|
unlinkSync(p);
|
|
67
|
-
memoryDropIndex(dir, name);
|
|
68
88
|
return true;
|
|
69
89
|
}
|
|
70
90
|
|
|
@@ -78,13 +98,14 @@ export function cmdMemory(args) {
|
|
|
78
98
|
+ 'or run this from a directory with a memory/ store.\n');
|
|
79
99
|
return 1;
|
|
80
100
|
}
|
|
101
|
+
const where = args.archived ? path.join(dir, 'archive') : dir;
|
|
81
102
|
if (args.action === 'list') {
|
|
82
|
-
const facts = memoryFacts(
|
|
103
|
+
const facts = memoryFacts(where);
|
|
83
104
|
if (!facts.length) {
|
|
84
|
-
printOut(`[memory] ${
|
|
105
|
+
printOut(`[memory] ${where}: no facts.\n`);
|
|
85
106
|
return 0;
|
|
86
107
|
}
|
|
87
|
-
printOut(`[memory] ${
|
|
108
|
+
printOut(`[memory] ${where}\n`);
|
|
88
109
|
for (const name of facts) printOut(` ${name}\n`);
|
|
89
110
|
return 0;
|
|
90
111
|
}
|
|
@@ -92,14 +113,20 @@ export function cmdMemory(args) {
|
|
|
92
113
|
printErr('[memory] rm needs the name of a fact.\n');
|
|
93
114
|
return 2;
|
|
94
115
|
}
|
|
95
|
-
if (!
|
|
116
|
+
if (args.archived && !args.purge) {
|
|
117
|
+
printErr('[memory] an archived fact can only be purged: add --purge to delete it.\n');
|
|
118
|
+
return 2;
|
|
119
|
+
}
|
|
120
|
+
const done = args.archived ? (memoryPurgeArchived(dir, args.name) ? '' : null)
|
|
121
|
+
: memoryDelete(dir, args.name, Boolean(args.purge));
|
|
122
|
+
if (done === null) {
|
|
96
123
|
// ONE MESSAGE FOR BOTH REFUSALS, which is the endpoint's shape too — it raises the same
|
|
97
124
|
// `NotFound(name)` for a reserved name, a name with a separator and a fact that is not
|
|
98
125
|
// there. Telling a caller that `MEMORY` exists but is protected is a distinction this
|
|
99
126
|
// verb has no reason to draw.
|
|
100
|
-
printErr(`[memory] no such fact '${args.name}' in ${
|
|
127
|
+
printErr(`[memory] no such fact '${args.name}' in ${where}.\n`);
|
|
101
128
|
return 1;
|
|
102
129
|
}
|
|
103
|
-
printOut(`[memory] deleted ${args.name}.\n`);
|
|
130
|
+
printOut(done ? `[memory] archived ${args.name} -> ${done}\n` : `[memory] deleted ${args.name}.\n`);
|
|
104
131
|
return 0;
|
|
105
132
|
}
|
package/js/web/actions.mjs
CHANGED
|
@@ -276,7 +276,8 @@ export function apiPickFolder(done) {
|
|
|
276
276
|
// ---- memory ------------------------------------------------------------------------------
|
|
277
277
|
|
|
278
278
|
/**
|
|
279
|
-
* One fact file, and its line in the MEMORY.md index.
|
|
279
|
+
* One fact file, and its line in the MEMORY.md index. The file is ARCHIVED into
|
|
280
|
+
* `memory/archive/`, never unlinked: the console has no purge, `geneseed memory rm --purge` does.
|
|
280
281
|
*
|
|
281
282
|
* `name` is a BARE SLUG: a path separator, or one of the two reserved names, is refused, so
|
|
282
283
|
* this can only ever remove a fact inside the resolved memory dir. MEMORY.md is the index the
|
|
@@ -290,9 +291,9 @@ export function apiMemoryDelete(state, name) {
|
|
|
290
291
|
// `memoryDelete` is the `geneseed memory rm` engine, and its reserved-name check is
|
|
291
292
|
// CASE-FOLDED: this endpoint's own compared `MEMORY` exactly, so on Windows and macOS `memory`
|
|
292
293
|
// resolved to MEMORY.md, passed, and deleted the index the agent reads at session start.
|
|
293
|
-
if (
|
|
294
|
+
if (memoryDelete(d, name) === null) throw new NotFound(name);
|
|
294
295
|
state.refresh();
|
|
295
|
-
return {
|
|
296
|
+
return { archived: name };
|
|
296
297
|
}
|
|
297
298
|
|
|
298
299
|
// ---- sovereign-repo exclusions -------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "Every field here is argued and gated in tests/unit/package_manifest.test.mjs. `files`, `bin` and `engines` each have a test that fails when reality drifts from this file; `version` mirrors harness.config.json and is gated too. Read that docblock before editing this one.",
|
|
3
3
|
"name": "geneseed",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.4.0",
|
|
5
5
|
"description": "A portable, theme-able harness you implant once and use everywhere to grow a disciplined AI coding agent.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"ai",
|
package/registry.json
CHANGED
|
@@ -57,6 +57,13 @@
|
|
|
57
57
|
"added": "2026-06-08",
|
|
58
58
|
"last_verified": ""
|
|
59
59
|
},
|
|
60
|
+
"agents/memory-curator": {
|
|
61
|
+
"status": "experimental",
|
|
62
|
+
"version": "1.0.0",
|
|
63
|
+
"owner": "Guillaume Lemer",
|
|
64
|
+
"added": "2026-10-16",
|
|
65
|
+
"last_verified": ""
|
|
66
|
+
},
|
|
60
67
|
"agents/operator": {
|
|
61
68
|
"status": "approved",
|
|
62
69
|
"version": "1.0.0",
|
package/src/AGENT.md.tmpl
CHANGED
|
@@ -166,6 +166,7 @@ Active packs: {{DOCTRINES_LIST}}{{EXCLUDED_RULES_LINE}}
|
|
|
166
166
|
| [performance]({{DIR_AGENTS}}/performance.md) | something is reported slow or heavy, or a diff touches a hot path — measured, never guessed |
|
|
167
167
|
| [explorer]({{DIR_AGENTS}}/explorer.md) | you must sweep many files for an answer but only want the conclusion |
|
|
168
168
|
| [researcher]({{DIR_AGENTS}}/researcher.md) | a question needs the open web — fetched, cross-checked, and returned as sourced findings, not pages |
|
|
169
|
+
| [memory-curator]({{DIR_AGENTS}}/memory-curator.md) | the {{MEMORY}} store needs bulk curation |
|
|
169
170
|
| [advocate]({{DIR_AGENTS}}/advocate.md) | a council debate needs the strongest case **for** the motion argued in isolation |
|
|
170
171
|
| [skeptic]({{DIR_AGENTS}}/skeptic.md) | a council debate needs a devil's advocate to attack the motion's failure modes |
|
|
171
172
|
| [pragmatist]({{DIR_AGENTS}}/pragmatist.md) | a council debate needs the cost, effort, and complexity weighed against the payoff |
|