geneseed 4.3.1 → 4.4.1
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 +28 -0
- package/README.md +4 -4
- package/SHIPPED.md +2 -2
- package/adapters/opencode/plugins/geneseed-context.js +18 -1
- package/adapters/opencode/plugins/geneseed-learn.js +178 -46
- package/docs/concepts/agents.md +1 -0
- package/docs/concepts/hooks.md +1 -1
- package/docs/concepts/skills.md +1 -0
- 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/hooks-context.mjs +25 -1
- package/js/hosts/hooks-learn.mjs +42 -16
- 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/inventory.mjs +1 -1
- package/js/maintain/memory.mjs +38 -11
- package/js/web/actions.mjs +4 -3
- package/package.json +1 -1
- package/registry.json +14 -0
- package/src/AGENT.md.tmpl +2 -0
- package/src/agents/memory-curator.md +71 -0
- package/src/memory/README.md +41 -10
- package/src/skills/deck.md +56 -0
- package/src/skills/frontend-design.md +1 -0
- 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 +2 -0
- package/themes/biker.json +2 -0
- package/themes/commentator.json +2 -0
- package/themes/cyberpunk.json +2 -0
- package/themes/gamer.json +2 -0
- package/themes/imperial.json +2 -0
- package/themes/joker.json +2 -0
- package/themes/marvin.json +2 -0
- package/themes/mean.json +2 -0
- package/themes/military.json +2 -0
- package/themes/neutral.json +2 -0
- package/themes/pirate.json +2 -0
- package/themes/sports.json +2 -0
- package/themes/verstappen.json +2 -0
- package/themes/wizard.json +2 -0
- package/web/dist/assets/{Activity-UiGvYgEl.js → Activity-BZE6REef.js} +1 -1
- package/web/dist/assets/{ActivityDetail-COXCSeXn.js → ActivityDetail-DCB8Cr2W.js} +1 -1
- package/web/dist/assets/{Dashboard-DkdOUpke.js → Dashboard-B4Drq5wp.js} +2 -2
- package/web/dist/assets/{Docs-TtVyc_Dy.js → Docs-D30CiqYy.js} +1 -1
- package/web/dist/assets/{GreenhouseView-Bh0xq6Up.js → GreenhouseView-Djt-4oO5.js} +1 -1
- package/web/dist/assets/{Installs-J3IoSqk6.js → Installs-B3bw5ckh.js} +1 -1
- package/web/dist/assets/{JournalView-BrD33V35.js → JournalView-OWhx9YKg.js} +1 -1
- package/web/dist/assets/{Laws-Cjkenizx.js → Laws-B9day2FV.js} +1 -1
- package/web/dist/assets/Library-BpIrLH-O.js +1 -0
- package/web/dist/assets/{Loops-D15LDhZO.js → Loops-CxH14kyE.js} +1 -1
- package/web/dist/assets/{OperatorHudView-B5CcWoJ6.js → OperatorHudView-otOz6Gha.js} +1 -1
- package/web/dist/assets/{Personal-BBdGbJn6.js → Personal-abL6089y.js} +1 -1
- package/web/dist/assets/{Tabs-s8T501po.js → Tabs-DjWN0i7A.js} +1 -1
- package/web/dist/assets/{drawable-DALqyS4l.js → drawable-C2ZsBZCv.js} +1 -1
- package/web/dist/assets/{index-BP4foUp0.js → index-CdpLpPQN.js} +2 -2
- package/web/dist/index.html +1 -1
- package/web/dist/assets/Library-CKyeB096.js +0 -1
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": []
|
|
@@ -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
|
-
|
|
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
|
}
|
package/js/hosts/hooks-learn.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `learn` — the Stop/SubagentStop/PreCompact verb: distil notes or a transcript into
|
|
3
|
-
*
|
|
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
|
-
|
|
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 (
|
|
169
|
-
parts.push('ALREADY STORED \u2014
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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,
|
|
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
|
|
345
|
-
if (
|
|
346
|
-
err(`[learn] wrote ${
|
|
347
|
-
+ `${memDir}: ${
|
|
348
|
-
}
|
|
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 {
|
|
@@ -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_219, lean: 39_088 };
|
|
32
32
|
|
|
33
33
|
const decode = (p) => readText(p).split('\r\n').join('\n');
|
|
34
34
|
|
package/js/inspect/inventory.mjs
CHANGED
|
@@ -94,7 +94,7 @@ export const SKILL_CLASS = {
|
|
|
94
94
|
loop: 'design', 'brick-forge': 'design',
|
|
95
95
|
'codebase-design': 'design', wayfinder: 'design',
|
|
96
96
|
develop: 'build', refactor: 'build', debug: 'build', dependencies: 'build',
|
|
97
|
-
'frontend-design': 'build', prototype: 'build',
|
|
97
|
+
'frontend-design': 'build', prototype: 'build', deck: 'build',
|
|
98
98
|
'forge-mcp': 'build', bruno: 'build', worktree: 'build',
|
|
99
99
|
'geneseed-code-review': 'review', 'fresh-eyes': 'review', 'security-audit': 'review',
|
|
100
100
|
'roast-me': 'review', 'review-response': 'review', ponytail: 'review',
|