geneseed 3.13.0 → 3.14.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 +39 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-learn.js +10 -1
- package/bin/geneseed-cli.mjs +10 -14
- package/bin/geneseed-hook.mjs +22 -15
- package/docs/extending.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +19 -10
- package/js/build/args.mjs +374 -0
- package/js/build/driver.mjs +137 -553
- package/js/build/emit-claude.mjs +2 -4
- package/js/build/generate.mjs +6 -3
- package/js/build/render.mjs +5 -9
- package/js/build/source.mjs +3 -11
- package/js/hosts/hooks-context.mjs +431 -0
- package/js/hosts/hooks-learn.mjs +275 -0
- package/js/hosts/hooks-prims.mjs +108 -0
- package/js/hosts/hooks.mjs +50 -977
- package/js/hosts/hosts.mjs +12 -4
- package/js/hosts/installs.mjs +5 -6
- package/js/hosts/mcp.mjs +12 -48
- package/js/hosts/memory-files.mjs +181 -0
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +373 -0
- package/js/inspect/checks-authoring.mjs +21 -828
- package/js/inspect/checks-build.mjs +4 -4
- package/js/inspect/checks-constitution.mjs +493 -0
- package/js/inspect/checks-counts.mjs +242 -0
- package/js/inspect/checks-repo.mjs +17 -8
- package/js/inspect/diff.mjs +4 -1
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +1 -1
- package/js/inspect/validate.mjs +48 -35
- package/js/loop/catalog.mjs +5 -3
- package/js/loop/cli.mjs +26 -12
- package/js/loop/graph.mjs +1 -1
- package/js/loop/registry.mjs +10 -15
- package/js/loop/score.mjs +1 -1
- package/js/loop/state.mjs +35 -11
- package/js/maintain/memory.mjs +1 -1
- package/js/maintain/migrate.mjs +18 -30
- package/js/maintain/uninstall.mjs +47 -88
- package/js/ui/cli.mjs +12 -12
- package/js/web/actions.mjs +29 -19
- package/js/web/api.mjs +21 -21
- package/js/web/docs.mjs +29 -72
- package/package.json +1 -1
- package/web/dist/assets/Activity-CjRqZnr-.js +1 -0
- package/web/dist/assets/ActivityDetail-OQTxKBj2.js +1 -0
- package/web/dist/assets/Dashboard-J-xCe_Gk.js +2 -0
- package/web/dist/assets/Docs-CvOZ8akL.js +5 -0
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-6KDjX8da.js +2 -0
- package/web/dist/assets/Installs-MWuhNjSm.js +2 -0
- package/web/dist/assets/JournalView-C8MrOz2c.js +1 -0
- package/web/dist/assets/Laws-Du0-Zyp2.js +3 -0
- package/web/dist/assets/Library-DOkO0TtB.js +1 -0
- package/web/dist/assets/Loops-Dv_aLByJ.js +2 -0
- package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
- package/web/dist/assets/OperatorHudView-BPP1dtLz.js +2 -0
- package/web/dist/assets/Personal-26VPYkK0.js +1 -0
- package/web/dist/assets/Tabs-BzKuFskD.js +1 -0
- package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
- package/web/dist/assets/{drawable-C_x5xncq.js → drawable-zVVDbsyw.js} +1 -1
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-KHt3wE_K.js +2 -0
- package/web/dist/assets/{react-Bbzhmq4v.js → react-B9Z_tXRV.js} +1 -1
- package/web/dist/assets/sections-GalMS0Zv.js +1 -0
- package/web/dist/assets/usePoll-DD8VjtSg.js +1 -0
- package/web/dist/index.html +4 -5
- package/web/src/pages/Laws.jsx +0 -1
- package/web/dist/assets/Activity-Ca2Aruu7.js +0 -1
- package/web/dist/assets/ActivityDetail-Bo7v4Z_r.js +0 -1
- package/web/dist/assets/Dashboard-BuMq46aT.js +0 -2
- package/web/dist/assets/Docs-BzccCllG.js +0 -5
- package/web/dist/assets/FilterInput-CBcJBQPI.js +0 -1
- package/web/dist/assets/GreenhouseView-BCIKcnGG.js +0 -2
- package/web/dist/assets/Installs-DMFJvq7s.js +0 -2
- package/web/dist/assets/JournalView-o1rmd1_1.js +0 -1
- package/web/dist/assets/Laws-BANxq_I_.js +0 -3
- package/web/dist/assets/Library-DW4x0fbh.js +0 -1
- package/web/dist/assets/Loops-DEBibbBP.js +0 -2
- package/web/dist/assets/OperatorHudView-CLD0fYaA.js +0 -2
- package/web/dist/assets/Personal-6uMJvyBG.js +0 -1
- package/web/dist/assets/Tabs-BubnyZhF.js +0 -1
- package/web/dist/assets/activity-CObqyxkL.js +0 -1
- package/web/dist/assets/index-CMPgezFm.js +0 -2
- package/web/dist/assets/index-DMsT4Aa6.css +0 -1
- package/web/dist/assets/rolldown-runtime-hePW80VL.js +0 -1
- package/web/dist/assets/sections-Dg23Hel5.js +0 -1
- package/web/dist/assets/usePoll-BOqeunzF.js +0 -1
package/js/hosts/hosts.mjs
CHANGED
|
@@ -228,14 +228,22 @@ export function openclaudeConfigDir() {
|
|
|
228
228
|
* block`). It is a column and not a literal in the message for the reason every other column
|
|
229
229
|
* here is one — Bob answers `AGENTS.md` while Claude answers `CLAUDE.md`, and
|
|
230
230
|
* a host added later must not need the message edited to stay true.
|
|
231
|
+
*
|
|
232
|
+
* `family` joined in 2026-10: `'claude'` for every host emitted through the Claude-shaped
|
|
233
|
+
* engine — one manifest shape, one `settings.json` hook wiring, one strict-JSON `mcpServers`
|
|
234
|
+
* config, one reversal. The list had been spelled out by hand in ten places, one of which
|
|
235
|
+
* ignored the named constant right above it; a fifth host is now one row here.
|
|
231
236
|
*/
|
|
232
237
|
export const HOSTS = [
|
|
233
|
-
{ host: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md' },
|
|
234
|
-
{ host: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md' },
|
|
235
|
-
{ host: 'bob', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md' },
|
|
236
|
-
{ host: 'openclaude', configDir: openclaudeConfigDir, projectMarker: '.openclaude', agentFile: 'CLAUDE.md' },
|
|
238
|
+
{ host: 'opencode', family: 'opencode', configDir: opencodeConfigDir, projectMarker: '.opencode', agentFile: 'AGENT.md' },
|
|
239
|
+
{ host: 'claude', family: 'claude', configDir: claudeConfigDir, projectMarker: '.claude', agentFile: 'CLAUDE.md' },
|
|
240
|
+
{ host: 'bob', family: 'claude', configDir: bobConfigDir, projectMarker: '.bob', agentFile: 'AGENTS.md' },
|
|
241
|
+
{ host: 'openclaude', family: 'claude', configDir: openclaudeConfigDir, projectMarker: '.openclaude', agentFile: 'CLAUDE.md' },
|
|
237
242
|
];
|
|
238
243
|
|
|
244
|
+
/** The Claude-STYLE hosts (`family: 'claude'`), in `HOSTS` order. Test with `.includes(host)`. */
|
|
245
|
+
export const CLAUDE_STYLE = HOSTS.filter((h) => h.family === 'claude').map((h) => h.host);
|
|
246
|
+
|
|
239
247
|
/**
|
|
240
248
|
* `_build_global.host_catalogs_natively` — does `host` list every skill and agent to the
|
|
241
249
|
* model by itself?
|
package/js/hosts/installs.mjs
CHANGED
|
@@ -36,10 +36,10 @@ import { existsSync, readdirSync } from 'node:fs';
|
|
|
36
36
|
import path from 'node:path';
|
|
37
37
|
|
|
38
38
|
import { CONFIG, PACK_ORDER, THEMES, discoverNames } from '../build/source.mjs';
|
|
39
|
-
import { GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
|
|
39
|
+
import { CLAUDE_STYLE, GLOBAL_MANIFEST, HOSTS, resolvePath } from './hosts.mjs';
|
|
40
40
|
import { registryRoots } from '../inspect/registry.mjs';
|
|
41
41
|
import { printErr, readText, isFile, isDir } from '../lib/fs.mjs';
|
|
42
|
-
import { formatRepr } from '../lib/json.mjs';
|
|
42
|
+
import { formatRepr, isDict } from '../lib/json.mjs';
|
|
43
43
|
import { comparePaths } from '../lib/paths.mjs';
|
|
44
44
|
|
|
45
45
|
/**
|
|
@@ -394,7 +394,7 @@ export function excludedRulesOfDir(d) {
|
|
|
394
394
|
/** `_harness_mcp._claude_read_manifest`. */
|
|
395
395
|
export function claudeReadManifest(cfgDir) {
|
|
396
396
|
const data = readJsonMaybe(path.join(cfgDir, GLOBAL_MANIFEST));
|
|
397
|
-
return
|
|
397
|
+
return isDict(data) ? data : {};
|
|
398
398
|
}
|
|
399
399
|
|
|
400
400
|
/**
|
|
@@ -403,8 +403,7 @@ export function claudeReadManifest(cfgDir) {
|
|
|
403
403
|
* Claude-style reversal.
|
|
404
404
|
*/
|
|
405
405
|
export function manifestIsClaude(cfgDir) {
|
|
406
|
-
|
|
407
|
-
return typeof mg === 'object' && mg !== null && !Array.isArray(mg);
|
|
406
|
+
return isDict(claudeReadManifest(cfgDir).managed);
|
|
408
407
|
}
|
|
409
408
|
|
|
410
409
|
/** `_harness_mcp._EMIT_HOST_SCOPE` — a marker's emit name fixes (host, scope). */
|
|
@@ -454,7 +453,7 @@ export function installKind(root) {
|
|
|
454
453
|
|
|
455
454
|
/** `_harness_mcp._install_state` — 'active' | 'disabled' | 'absent'. */
|
|
456
455
|
export function installState(root, host = 'opencode', scope = 'global') {
|
|
457
|
-
if (
|
|
456
|
+
if (CLAUDE_STYLE.includes(host)) return claudeState(root, scope, host);
|
|
458
457
|
if (isDir(path.join(root, DISABLED_STASH))) return 'disabled';
|
|
459
458
|
return installKind(root) !== null ? 'active' : 'absent';
|
|
460
459
|
}
|
package/js/hosts/mcp.mjs
CHANGED
|
@@ -11,12 +11,6 @@
|
|
|
11
11
|
* `_mcp_install_targets`, `_mcp_known_names` and `_mcp_meta` are 168 lines of Python, all
|
|
12
12
|
* new. The endpoints on top of them are the small half.
|
|
13
13
|
*
|
|
14
|
-
* AND `settings.mjs`'s ATOMIC WRITE IS NOT THIS ONE. `atomicWriteJson` is
|
|
15
|
-
* `_build_settings._atomic_write_json`; `_mcp_save` is a different function with a different
|
|
16
|
-
* temp suffix (`.tmp`, not `.geneseed-tmp`) that also creates the parent directory. Reusing
|
|
17
|
-
* the first would be right until the moment a write fails, which is the moment the temp
|
|
18
|
-
* file's name becomes the only evidence of what happened.
|
|
19
|
-
*
|
|
20
14
|
* THE ROUND TRIP IS THE HAZARD, and it is why every read here goes through `parseJson`.
|
|
21
15
|
* `_mcp_save` rewrites the WHOLE config, not the servers map — and for a Claude global
|
|
22
16
|
* install that config is `~/.claude.json`, which holds projects and history far beyond MCP
|
|
@@ -28,23 +22,23 @@
|
|
|
28
22
|
* the wrapper rather than testing `typeof v === 'object'`. Python's `isinstance(x, dict)` is
|
|
29
23
|
* false for a float; the JS twin has to be false for its wrapper.
|
|
30
24
|
*/
|
|
31
|
-
import { existsSync
|
|
25
|
+
import { existsSync } from 'node:fs';
|
|
32
26
|
import os from 'node:os';
|
|
33
27
|
import path from 'node:path';
|
|
34
28
|
|
|
35
|
-
import { bobConfigDir, resolvePath } from './hosts.mjs';
|
|
29
|
+
import { CLAUDE_STYLE, bobConfigDir, resolvePath } from './hosts.mjs';
|
|
36
30
|
import { installState, installTargets } from './installs.mjs';
|
|
37
|
-
import { printOut, printErr
|
|
38
|
-
import { isDict
|
|
31
|
+
import { printOut, printErr } from '../lib/fs.mjs';
|
|
32
|
+
import { isDict } from '../lib/json.mjs';
|
|
39
33
|
import { padEndToWidth } from '../lib/text.mjs';
|
|
40
|
-
import {
|
|
34
|
+
import { atomicWriteJson, loadJsonObject, opencodeTarget } from './settings.mjs';
|
|
41
35
|
|
|
42
36
|
|
|
43
37
|
/** `dict.get(key, default)`. */
|
|
44
38
|
const dget = (obj, key, dflt) => (isDict(obj) && Object.hasOwn(obj, key) ? obj[key] : dflt);
|
|
45
39
|
|
|
46
40
|
/** The hosts whose MCP config is strict JSON under `mcpServers`, with no `enabled` flag. */
|
|
47
|
-
const FLAGLESS =
|
|
41
|
+
const FLAGLESS = CLAUDE_STYLE;
|
|
48
42
|
|
|
49
43
|
/**
|
|
50
44
|
* `_harness_mcp._MCP_PRESETS` — the ready-to-wire servers the MCP screen can toggle.
|
|
@@ -175,49 +169,19 @@ export function mcpSetEnabled(config, name, enabled, host = 'opencode') {
|
|
|
175
169
|
* version of this note said otherwise, and `generate.test.mjs` pins the string-aware case.)
|
|
176
170
|
*/
|
|
177
171
|
export function mcpLoad(p, host = 'opencode') {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
try {
|
|
181
|
-
text = readText(p);
|
|
182
|
-
} catch {
|
|
183
|
-
return {};
|
|
184
|
-
}
|
|
185
|
-
if (FLAGLESS.includes(host)) {
|
|
186
|
-
let data;
|
|
187
|
-
try {
|
|
188
|
-
data = parseJson(text);
|
|
189
|
-
} catch {
|
|
190
|
-
return {};
|
|
191
|
-
}
|
|
192
|
-
return isDict(data) ? data : {};
|
|
193
|
-
}
|
|
194
|
-
const [data] = readJsonc(text);
|
|
195
|
-
return isDict(data) ? data : {};
|
|
172
|
+
const { state, data } = loadJsonObject(p, { strict: FLAGLESS.includes(host) });
|
|
173
|
+
return state === 'ok' ? data : {};
|
|
196
174
|
}
|
|
197
175
|
|
|
198
176
|
/** `_mcp_commented` — an existing `.jsonc` that carries comments, which must not be rewritten. */
|
|
199
177
|
export function mcpCommented(p) {
|
|
200
|
-
if (path.extname(p) !== '.jsonc'
|
|
201
|
-
|
|
202
|
-
const [, had] = readJsonc(readText(p));
|
|
203
|
-
return had;
|
|
204
|
-
} catch {
|
|
205
|
-
return false;
|
|
206
|
-
}
|
|
178
|
+
if (path.extname(p) !== '.jsonc') return false;
|
|
179
|
+
return loadJsonObject(p).hadComments;
|
|
207
180
|
}
|
|
208
181
|
|
|
209
|
-
/**
|
|
210
|
-
* `_mcp_save` — pretty JSON, written ATOMICALLY through a sibling temp file.
|
|
211
|
-
*
|
|
212
|
-
* Not `atomicWriteJson`: that is `_build_settings._atomic_write_json`, whose temp file is
|
|
213
|
-
* named `.geneseed-tmp` and which does not create the parent directory. Two Python
|
|
214
|
-
* functions, two twins.
|
|
215
|
-
*/
|
|
182
|
+
/** `_mcp_save` — pretty JSON, written ATOMICALLY. The one writer, under its MCP name. */
|
|
216
183
|
export function mcpSave(p, config) {
|
|
217
|
-
|
|
218
|
-
const tmp = `${p}.tmp`;
|
|
219
|
-
writeText(tmp, `${jsonDumpsIndent(config)}\n`);
|
|
220
|
-
renameSync(tmp, p);
|
|
184
|
+
atomicWriteJson(p, config);
|
|
221
185
|
}
|
|
222
186
|
|
|
223
187
|
/**
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The memory store's file format — fact files with frontmatter, the `MEMORY.md` index, the
|
|
3
|
+
* per-agent lesson files — read and written without spawning anything.
|
|
4
|
+
*
|
|
5
|
+
* SPLIT FROM `learn` FOR THE CLI's SAKE. `learn` spawns the model CLI, and the web console
|
|
6
|
+
* (`js/web/api.mjs`, `js/web/actions.mjs`), `geneseed memory` (`js/maintain/memory.mjs`) and the
|
|
7
|
+
* loop catalogue (`js/loop/catalog.mjs`) imported the hook module only for `frontmatter` and
|
|
8
|
+
* `memoryDropIndex` — which dragged `node:child_process` into `bin/geneseed-cli.mjs`'s closure
|
|
9
|
+
* and made the hook module a row in `tests/unit/hook_cli.test.mjs`'s spawn allow-list for a
|
|
10
|
+
* spawn the CLI never makes. Everything here is fs-only; `hooks-learn.mjs` is the one spawner.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { readText, writeText } from '../lib/fs.mjs';
|
|
15
|
+
import { normcase } from '../lib/paths.mjs';
|
|
16
|
+
import { isFile, listDir, sortPaths, splitLines, splitWords } from './hooks-prims.mjs';
|
|
17
|
+
|
|
18
|
+
const FRONTMATTER_RE = /^\s*---\s*\n([\s\S]*?)\n---\s*\n?([\s\S]*)$/;
|
|
19
|
+
const FILE_SEP_RE = /^---FILE---[^\S\n]*$/m;
|
|
20
|
+
// A memory `name:` becomes a FILENAME and the model writes it, so it is a plain slug or
|
|
21
|
+
// nothing — no separator, no `..`, no drive. Looser than `hooks-learn.mjs`'s AGENT_NAME_RE on
|
|
22
|
+
// purpose: a memory slug is a phrase (case and length vary), and a refused one is a fact
|
|
23
|
+
// silently lost.
|
|
24
|
+
const MEMORY_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}$/;
|
|
25
|
+
const MAX_AGENT_BULLETS = 100;
|
|
26
|
+
|
|
27
|
+
/** `append_agent_lesson` — one dated bullet, capped at the newest MAX_AGENT_BULLETS. */
|
|
28
|
+
export function appendAgentLesson(memDir, agent, lesson) {
|
|
29
|
+
const d = path.join(memDir, 'agents');
|
|
30
|
+
mkdirSync(d, { recursive: true });
|
|
31
|
+
const f = path.join(d, `${agent}.md`);
|
|
32
|
+
let bullets = [];
|
|
33
|
+
if (existsSync(f)) bullets = splitLines(readText(f)).filter((l) => l.startsWith('- '));
|
|
34
|
+
const now = new Date();
|
|
35
|
+
const day = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-`
|
|
36
|
+
+ `${String(now.getDate()).padStart(2, '0')}`;
|
|
37
|
+
bullets.push(`- ${day}: ${splitWords(lesson).join(' ')}`);
|
|
38
|
+
bullets = bullets.slice(-MAX_AGENT_BULLETS);
|
|
39
|
+
writeText(f, `# ${agent} \u2014 lessons\n${bullets.join('\n')}\n`);
|
|
40
|
+
return f;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `_frontmatter` — leading YAML-ish frontmatter as a flat map, plus the body.
|
|
45
|
+
*
|
|
46
|
+
* Exported since P6b, whose `memoryItems` reads `name` and `description` off a fact file
|
|
47
|
+
* exactly as `_web_catalog._memory_items` does. That makes the web its SECOND owner, which
|
|
48
|
+
* this port treats as a coverage event rather than a tidiness one — the fields it produces
|
|
49
|
+
* are unreachable from a P6b cell (only the COUNT is consumed) and P6c's catalog cells are
|
|
50
|
+
* what gate them.
|
|
51
|
+
*/
|
|
52
|
+
export function frontmatter(md) {
|
|
53
|
+
const m = FRONTMATTER_RE.exec(md);
|
|
54
|
+
if (!m) return [new Map(), md];
|
|
55
|
+
const fm = new Map();
|
|
56
|
+
for (const line of splitLines(m[1])) {
|
|
57
|
+
const at = line.indexOf(':');
|
|
58
|
+
if (at >= 0) {
|
|
59
|
+
// `str.strip('"')` strips the character from BOTH ends, however many there are.
|
|
60
|
+
fm.set(line.slice(0, at).trim(), line.slice(at + 1).trim().replace(/^"+|"+$/g, ''));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [fm, m[2]];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const fmGet = (fm, key) => (fm.has(key) ? fm.get(key) : '');
|
|
67
|
+
|
|
68
|
+
/** `Path.stem` for a filename. */
|
|
69
|
+
const stem = (name) => path.basename(name, path.extname(name));
|
|
70
|
+
|
|
71
|
+
/** `mem_dir.glob("*.md")` — case-INSENSITIVE on Windows, as pathlib's glob is. */
|
|
72
|
+
function globMd(dir) {
|
|
73
|
+
return listDir(dir).filter((n) => normcase(n).endsWith('.md')
|
|
74
|
+
&& isFile(path.join(dir, n)));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `_existing_slugs` — slugs already stored, so learn never re-emits a known fact. */
|
|
78
|
+
export function existingSlugs(memDir) {
|
|
79
|
+
const skip = new Set(['memory', 'readme']);
|
|
80
|
+
return new Set(globMd(memDir).map(stem).filter((s) => !skip.has(s.toLowerCase())));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* `_write_memories` — split the model output into files, write each NEW one, and append a
|
|
85
|
+
* pointer line to MEMORY.md. `existing` is MUTATED, so a reply carrying the same slug
|
|
86
|
+
* twice writes it once.
|
|
87
|
+
*/
|
|
88
|
+
export function writeMemories(modelOutput, memDir, existing) {
|
|
89
|
+
const written = [];
|
|
90
|
+
const indexLines = [];
|
|
91
|
+
for (let chunk of modelOutput.split(FILE_SEP_RE)) {
|
|
92
|
+
chunk = chunk.trim();
|
|
93
|
+
if (!chunk || chunk.toUpperCase() === 'NOTHING') continue;
|
|
94
|
+
const [fm] = frontmatter(chunk);
|
|
95
|
+
const name = fmGet(fm, 'name').trim();
|
|
96
|
+
if (!MEMORY_SLUG_RE.test(name) || existing.has(name)) continue;
|
|
97
|
+
writeText(path.join(memDir, `${name}.md`), `${chunk.replace(/\n+$/, '')}\n`);
|
|
98
|
+
existing.add(name);
|
|
99
|
+
written.push(name);
|
|
100
|
+
const desc = fmGet(fm, 'description').trim();
|
|
101
|
+
indexLines.push(`- [${name}](${name}.md)${desc ? ` \u2014 ${desc}` : ''}`);
|
|
102
|
+
}
|
|
103
|
+
if (indexLines.length) {
|
|
104
|
+
const index = path.join(memDir, 'MEMORY.md');
|
|
105
|
+
const current = existsSync(index) ? `${readText(index).replace(/\n+$/, '')}\n`
|
|
106
|
+
: '# Memory Index\n';
|
|
107
|
+
writeText(index, `${current}${indexLines.join('\n')}\n`);
|
|
108
|
+
}
|
|
109
|
+
return written;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `_harness_tui_views._memory_drop_index` — remove the index line(s) naming `<name>.md`.
|
|
114
|
+
*
|
|
115
|
+
* ITS PYTHON HOME IS THE TUI, and it is here because this is where its SIBLINGS' twins are:
|
|
116
|
+
* `appendAgentLesson` writes the index lines this deletes and `consolidateMemory` rebuilds
|
|
117
|
+
* them, so the three rules about MEMORY.md's format live together rather than one of them
|
|
118
|
+
* living beside a web endpoint. P7 ports the TUI's memory view onto this same function.
|
|
119
|
+
*
|
|
120
|
+
* The P6f plan asked whether P5's `learn` had already brought it across. It had not —
|
|
121
|
+
* nothing in `js/` referenced it — which is the third time a plan's "already ported" column
|
|
122
|
+
* was answered by reading rather than by trusting.
|
|
123
|
+
*
|
|
124
|
+
* SUBSTRING, NOT PARSE, exactly as the reference is: any line containing `(<name>.md)` goes,
|
|
125
|
+
* so a hand-written index in a different shape still loses its entry. And the file is only
|
|
126
|
+
* REWRITTEN when something actually changed, which keeps a delete of an unindexed fact from
|
|
127
|
+
* touching the user's mtime.
|
|
128
|
+
*/
|
|
129
|
+
export function memoryDropIndex(memDir, name) {
|
|
130
|
+
const idx = path.join(memDir, 'MEMORY.md');
|
|
131
|
+
let lines;
|
|
132
|
+
try {
|
|
133
|
+
lines = splitLines(readText(idx));
|
|
134
|
+
} catch {
|
|
135
|
+
return; // the reference's `except OSError: return`
|
|
136
|
+
}
|
|
137
|
+
const keep = lines.filter((ln) => !ln.includes(`(${name}.md)`));
|
|
138
|
+
if (keep.length === lines.length) return;
|
|
139
|
+
try {
|
|
140
|
+
writeText(idx, `${keep.join('\n')}\n`);
|
|
141
|
+
} catch { /* the reference swallows OSError here too */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `consolidate_memory` — rebuild MEMORY.md from the fact files actually on disk: index
|
|
146
|
+
* orphans, prune dead lines, report duplicate descriptions (never auto-merge — nuance is
|
|
147
|
+
* the user's to keep).
|
|
148
|
+
*/
|
|
149
|
+
export function consolidateMemory(memDir) {
|
|
150
|
+
const skip = new Set(['memory', 'readme']);
|
|
151
|
+
const facts = new Map();
|
|
152
|
+
for (const name of sortPaths(globMd(memDir))) {
|
|
153
|
+
const s = stem(name);
|
|
154
|
+
if (skip.has(s.toLowerCase())) continue;
|
|
155
|
+
try {
|
|
156
|
+
facts.set(s, fmGet(frontmatter(readText(path.join(memDir, name)))[0], 'description').trim());
|
|
157
|
+
} catch { continue; }
|
|
158
|
+
}
|
|
159
|
+
const index = path.join(memDir, 'MEMORY.md');
|
|
160
|
+
const oldSlugs = new Set();
|
|
161
|
+
if (existsSync(index)) {
|
|
162
|
+
for (const line of splitLines(readText(index))) {
|
|
163
|
+
const m = /^- \[[^\]]*\]\(([^)]+)\.md\)/.exec(line.trim());
|
|
164
|
+
if (m) oldSlugs.add(m[1]);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const lines = ['# Memory Index', ''];
|
|
168
|
+
for (const [slug, desc] of facts) {
|
|
169
|
+
lines.push(`- [${slug}](${slug}.md)${desc ? ` \u2014 ${desc}` : ''}`);
|
|
170
|
+
}
|
|
171
|
+
writeText(index, `${lines.join('\n')}\n`);
|
|
172
|
+
const seen = new Map();
|
|
173
|
+
const dups = [];
|
|
174
|
+
for (const [slug, desc] of facts) {
|
|
175
|
+
if (desc && seen.has(desc)) dups.push([seen.get(desc), slug]);
|
|
176
|
+
if (!seen.has(desc)) seen.set(desc, slug);
|
|
177
|
+
}
|
|
178
|
+
const added = [...facts.keys()].filter((s) => !oldSlugs.has(s)).sort();
|
|
179
|
+
const pruned = [...oldSlugs].filter((s) => !facts.has(s)).sort();
|
|
180
|
+
return { added, pruned, duplicates: dups };
|
|
181
|
+
}
|