geneseed 3.13.0 → 3.15.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 +93 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +23 -9
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +45 -10
- package/adapters/opencode/plugins/geneseed-notify.js +37 -19
- package/adapters/opencode/plugins/geneseed-workflow.js +3 -1
- package/adapters/opencode/workflows/_runtime.js +16 -3
- package/bin/geneseed-cli.mjs +32 -34
- package/bin/geneseed-hook.mjs +28 -18
- package/docs/extending.md +5 -5
- package/harness.config.json +1 -1
- package/js/README.md +28 -15
- package/js/build/args.mjs +375 -0
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +145 -560
- package/js/build/emit-claude.mjs +5 -6
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +21 -14
- package/js/build/render.mjs +6 -10
- package/js/build/source.mjs +9 -17
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/hosts/hooks-context.mjs +432 -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 +31 -25
- package/js/hosts/installs.mjs +38 -20
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/mcp.mjs +12 -48
- package/js/hosts/memory-files.mjs +190 -0
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +44 -36
- package/js/hosts/settings.mjs +135 -414
- package/js/hosts/shim.mjs +375 -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 +9 -7
- package/js/inspect/doctor.mjs +2 -2
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/inventory.mjs +4 -4
- package/js/inspect/scan.mjs +1 -1
- package/js/inspect/status.mjs +19 -22
- package/js/inspect/validate.mjs +50 -37
- package/js/lib/fs.mjs +12 -11
- package/js/lib/json.mjs +4 -4
- package/js/lib/paths.mjs +27 -33
- package/js/lib/proc.mjs +3 -3
- package/js/lib/prompt.mjs +132 -0
- package/js/lib/text.mjs +18 -4
- package/js/lib/udiff.mjs +3 -2
- 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 +6 -7
- package/js/maintain/migrate.mjs +22 -33
- package/js/maintain/setup.mjs +9 -131
- package/js/maintain/uninstall.mjs +57 -97
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +37 -43
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +109 -403
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +41 -725
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- package/js/web/docs.mjs +29 -72
- package/js/web/handler.mjs +51 -27
- package/js/web/jobs.mjs +24 -2
- package/js/web/routes.mjs +61 -6
- package/js/web/server.mjs +4 -4
- package/js/web/state.mjs +138 -0
- package/js/web/user-files.mjs +339 -0
- package/package.json +1 -1
- package/web/dist/assets/Activity-kpWxd70F.js +1 -0
- package/web/dist/assets/ActivityDetail-Ccr942VL.js +1 -0
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/Docs-BHyaggFy.js +5 -0
- package/web/dist/assets/FilterInput-CVyqmYWM.js +1 -0
- package/web/dist/assets/GreenhouseView-Sc_UDMOE.js +2 -0
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/Laws-S9fq1r9T.js +3 -0
- package/web/dist/assets/Library-MBxadRzn.js +1 -0
- package/web/dist/assets/Loops-Da_JhlzM.js +2 -0
- package/web/dist/assets/{Markdown-CdTX0kLe.js → Markdown-X2a3qvOh.js} +11 -11
- package/web/dist/assets/OperatorHudView--TkvDybH.js +2 -0
- package/web/dist/assets/Personal-CTXKHVbV.js +1 -0
- package/web/dist/assets/Tabs-B0cdf8k6.js +1 -0
- package/web/dist/assets/activity-Dr7ZMBI3.js +1 -0
- package/web/dist/assets/api-CxgfYu18.js +1 -0
- package/web/dist/assets/drawable-BbWQxe82.js +1 -0
- package/web/dist/assets/index-Ctp-4poP.css +1 -0
- package/web/dist/assets/index-ujKG1ZNf.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 +5 -6
- 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/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-C_x5xncq.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
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `learn` — the Stop/SubagentStop/PreCompact verb: distil notes or a transcript into deduped
|
|
3
|
+
* memory entries through the model CLI `$GENESEED_LLM` names. THE ONE SPAWN in the hook entry's
|
|
4
|
+
* closure; the store's file format it writes through lives in `./memory-files.mjs`, which does
|
|
5
|
+
* not spawn and is what the CLI imports. See `js/hosts/hooks.mjs`'s header for the contract.
|
|
6
|
+
*/
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { readText, printOut as out, printErr as err } from '../lib/fs.mjs';
|
|
10
|
+
import { toPlatformPath } from '../lib/paths.mjs';
|
|
11
|
+
import { NO_WINDOW } from '../lib/proc.mjs';
|
|
12
|
+
import { resolveMemoryDir, sovereignBypass } from './hosts.mjs';
|
|
13
|
+
import { readStdin, splitLines, splitWords } from './hooks-prims.mjs';
|
|
14
|
+
import {
|
|
15
|
+
existingSlugs, writeMemories, consolidateMemory, appendAgentLesson,
|
|
16
|
+
} from './memory-files.mjs';
|
|
17
|
+
|
|
18
|
+
const ROOT = path.resolve(import.meta.dirname, '../..');
|
|
19
|
+
|
|
20
|
+
const AGENT_NAME_RE = /^[a-z][a-z0-9-]{1,40}$/;
|
|
21
|
+
const MAX_NOTES_CHARS = 16000;
|
|
22
|
+
// Host payloads name the finished subagent inconsistently; try each.
|
|
23
|
+
const SUBAGENT_NAME_FIELDS = ['agent_name', 'agent_type', 'subagent_type', 'agent',
|
|
24
|
+
'agent_id'];
|
|
25
|
+
|
|
26
|
+
const PLUGIN_LEARN = path.join(ROOT, 'adapters', 'opencode', 'plugins', 'geneseed-learn.js');
|
|
27
|
+
|
|
28
|
+
/** `s[-n:]` in CODE POINTS. JS slices UTF-16 units and can cut a surrogate pair in half. */
|
|
29
|
+
function tail(s, n) {
|
|
30
|
+
const cps = Array.from(s);
|
|
31
|
+
return cps.length <= n ? s : cps.slice(-n).join('');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `_load_learn_prompt_head` / `_load_agent_lesson_prompt`.
|
|
36
|
+
*
|
|
37
|
+
* The single source of truth for both prompts is the OpenCode plugin — the artifact that
|
|
38
|
+
* ships to the primary runtime — and both CLIs extract the template literal from its
|
|
39
|
+
* SOURCE rather than keeping a copy. Reading it means this port cannot drift from the
|
|
40
|
+
* plugin either; a copied constant here would be a third place to edit.
|
|
41
|
+
*
|
|
42
|
+
* READ ON FIRST USE, ONCE. These were module-level constants, so every process that loaded
|
|
43
|
+
* this code paid two reads of the 17 KB plugin and two regex scans before doing anything — and
|
|
44
|
+
* until the verbs were split, that was every GATE call. A `--consolidate` run needs neither.
|
|
45
|
+
*/
|
|
46
|
+
let pluginSource = null;
|
|
47
|
+
function pluginLiteral(name, fallback) {
|
|
48
|
+
if (pluginSource === null) {
|
|
49
|
+
try {
|
|
50
|
+
pluginSource = readText(PLUGIN_LEARN);
|
|
51
|
+
} catch {
|
|
52
|
+
pluginSource = ''; // an unreadable plugin must never crash a Stop hook
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const m = new RegExp(`const ${name} = \`([\\s\\S]*?)\``).exec(pluginSource);
|
|
56
|
+
return m ? m[1] : fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const learnPromptHead = () => pluginLiteral('LEARN_PROMPT_HEAD',
|
|
60
|
+
'Distil at most one durable, reusable memory from the notes below. '
|
|
61
|
+
+ 'When in doubt, output exactly: NOTHING.');
|
|
62
|
+
const agentLessonPrompt = () => pluginLiteral('AGENT_LESSON_PROMPT',
|
|
63
|
+
'Output AT MOST ONE line: a durable lesson about how this agent should '
|
|
64
|
+
+ 'operate on a future dispatch. Else output exactly: NOTHING.');
|
|
65
|
+
|
|
66
|
+
/** `resolve_agent_name` — a safe agent slug from a raw field value, or null. */
|
|
67
|
+
export function resolveAgentName(raw) {
|
|
68
|
+
const name = typeof raw === 'string' ? raw.trim().toLowerCase() : null;
|
|
69
|
+
return name && AGENT_NAME_RE.test(name) ? name : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The lifecycle-hook payload if stdin is one, else `{}`. */
|
|
73
|
+
function hookMeta(raw) {
|
|
74
|
+
const s = raw.trim();
|
|
75
|
+
if (s.slice(0, 1) !== '{') return {};
|
|
76
|
+
try { return JSON.parse(s); } catch { return {}; }
|
|
77
|
+
}
|
|
78
|
+
/** Flatten a message `content` field (string, or a list of blocks) to text. */
|
|
79
|
+
function contentText(content) {
|
|
80
|
+
if (typeof content === 'string') return content;
|
|
81
|
+
if (Array.isArray(content)) {
|
|
82
|
+
const parts = [];
|
|
83
|
+
for (const block of content) {
|
|
84
|
+
if (typeof block === 'string') parts.push(block);
|
|
85
|
+
else if (block && typeof block === 'object' && block.type === 'text') {
|
|
86
|
+
parts.push(block.text === undefined ? '' : block.text);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return parts.join('\n');
|
|
90
|
+
}
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Render a Claude-Code-style JSONL transcript into `role: text` notes. */
|
|
95
|
+
function flattenTranscript(p) {
|
|
96
|
+
let raw;
|
|
97
|
+
try {
|
|
98
|
+
// `errors="replace"`: Node's utf8 decoder already substitutes U+FFFD.
|
|
99
|
+
raw = readText(p);
|
|
100
|
+
} catch {
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
const acc = [];
|
|
104
|
+
for (let line of splitLines(raw)) {
|
|
105
|
+
line = line.trim();
|
|
106
|
+
if (!line) continue;
|
|
107
|
+
let obj;
|
|
108
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
109
|
+
const msg = obj.message || {};
|
|
110
|
+
const role = msg.role || obj.role || obj.type;
|
|
111
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
112
|
+
const text = contentText(msg.content === undefined ? obj.content : msg.content).trim();
|
|
113
|
+
if (text) acc.push(`${role}: ${text}`);
|
|
114
|
+
}
|
|
115
|
+
return acc.join('\n\n');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* `_read_notes` — a lifecycle-hook payload with a `transcript_path` is read and flattened
|
|
120
|
+
* (this is what makes wiring `learn` to a Stop hook work with no redirection); anything
|
|
121
|
+
* else is used as-is. `meta` is that payload as `hookMeta` parsed it — passed in by
|
|
122
|
+
* `cmdLearn`, which needs it again, so the raw text is parsed once.
|
|
123
|
+
*
|
|
124
|
+
* EXPORTED FOR THE UNIT TIER, and the rule this follows is written down once here for it and
|
|
125
|
+
* for `./memory-files.mjs`'s `existingSlugs` and `writeMemories`.
|
|
126
|
+
*
|
|
127
|
+
* `docs/specs`'s P3 rule is: drive a property through the PUBLIC entry by default, and export a
|
|
128
|
+
* decision only when the property is invisible through that face. The `learn` verb's WIRING is
|
|
129
|
+
* already gated — 78 cells in the CLI matrix drive it end to end — so what a unit test would add
|
|
130
|
+
* there is nothing. What no cell can see is these three functions' behaviour on inputs a cell
|
|
131
|
+
* cannot produce: a model that answers NOTHING, a duplicate slug, a `README.md` beside the
|
|
132
|
+
* store, a transcript payload that is not JSON. Those are decisions over text, and the corpus
|
|
133
|
+
* reaches none of them.
|
|
134
|
+
*/
|
|
135
|
+
export function readNotes(raw, meta = hookMeta(raw)) {
|
|
136
|
+
if (!raw.trim()) return '';
|
|
137
|
+
const tp = meta.transcript_path;
|
|
138
|
+
return tp ? flattenTranscript(tp) : raw;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function buildLearnPrompt(notes, existing) {
|
|
142
|
+
const parts = [learnPromptHead(), ''];
|
|
143
|
+
if (existing.size) {
|
|
144
|
+
parts.push('ALREADY STORED \u2014 do NOT emit a memory matching any of these '
|
|
145
|
+
+ 'slugs (skip updates too; only genuinely new facts):');
|
|
146
|
+
parts.push(...[...existing].sort().map((slug) => `- ${slug}`));
|
|
147
|
+
parts.push('');
|
|
148
|
+
}
|
|
149
|
+
parts.push('NOTES:', notes);
|
|
150
|
+
return parts.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* The model CLI, run as `$GENESEED_LLM`'s words plus the prompt.
|
|
155
|
+
*
|
|
156
|
+
* `encoding="utf-8"` on the Python side and `'utf8'` here, never the console code page: a
|
|
157
|
+
* distilled memory carrying an accent or an em dash is WRITTEN to the store, so a
|
|
158
|
+
* mis-decode outlives the process. `maxBuffer` is lifted off Node's 1 MB default, which
|
|
159
|
+
* would truncate a long reply into a silently-half-parsed set of memory files.
|
|
160
|
+
*/
|
|
161
|
+
function runLlm(llm, prompt) {
|
|
162
|
+
const argv = splitWords(llm);
|
|
163
|
+
// `_harness_learn.py:85` runs this through the wrapped `run(..., capture_output=True)`,
|
|
164
|
+
// which folds in `CREATE_NO_WINDOW`; a model CLI invoked from the windowless daemon
|
|
165
|
+
// otherwise pops a console for as long as it takes to answer.
|
|
166
|
+
const proc = spawnSync(argv[0], [...argv.slice(1), prompt],
|
|
167
|
+
{ encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, ...NO_WINDOW });
|
|
168
|
+
// Universal newlines, because Python reads the pipe in TEXT mode: a model CLI that is
|
|
169
|
+
// itself a Python script emits CRLF on Windows, `subprocess` folds it to `\n`, and
|
|
170
|
+
// `write_text` expands it again on the way into the memory file. Node hands back the raw
|
|
171
|
+
// bytes, so without this the stored fact is written with `\r\r\n` on every line.
|
|
172
|
+
const universal = (s) => (s === null || s === undefined ? ''
|
|
173
|
+
: String(s).replaceAll('\r\n', '\n').replaceAll('\r', '\n'));
|
|
174
|
+
return {
|
|
175
|
+
stdout: universal(proc.stdout),
|
|
176
|
+
stderr: proc.error ? `[learn] could not run ${argv[0]}: ${proc.error.message}\n`
|
|
177
|
+
: universal(proc.stderr),
|
|
178
|
+
// A spawn that never started is Python's FileNotFoundError, which propagates there;
|
|
179
|
+
// here it surfaces as a non-zero code with the reason on stderr rather than a stack.
|
|
180
|
+
returncode: proc.error ? 1 : (proc.status === null ? 1 : proc.status),
|
|
181
|
+
error: proc.error,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `_learn_agent_lesson` — the SubagentStop path: at most one per-agent lesson into
|
|
187
|
+
* `memory/agents/<name>.md`. Skips silently when the host does not name the subagent:
|
|
188
|
+
* parity of mechanism, degrading to a no-op, never a crash.
|
|
189
|
+
*/
|
|
190
|
+
function learnAgentLesson(meta, notes, args) {
|
|
191
|
+
let agent = null;
|
|
192
|
+
for (const field of SUBAGENT_NAME_FIELDS) {
|
|
193
|
+
agent = resolveAgentName(meta[field]);
|
|
194
|
+
if (agent) break;
|
|
195
|
+
}
|
|
196
|
+
if (!agent) return 0;
|
|
197
|
+
const memDir = resolveMemoryDir(args.memory);
|
|
198
|
+
if (!memDir) return 0;
|
|
199
|
+
const prompt = [agentLessonPrompt(), '', 'NOTES:', notes].join('\n');
|
|
200
|
+
const llm = process.env.GENESEED_LLM;
|
|
201
|
+
if (!llm) {
|
|
202
|
+
err('[learn] $GENESEED_LLM unset \u2014 printing agent-lesson prompt.\n\n');
|
|
203
|
+
out(`${prompt}\n`);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
const proc = runLlm(llm, prompt);
|
|
207
|
+
const text = proc.stdout.trim();
|
|
208
|
+
if (text && text.toUpperCase() !== 'NOTHING') {
|
|
209
|
+
const lesson = splitLines(text)[0].replace(/^[-*]+/, '').trim();
|
|
210
|
+
const n = Array.from(lesson).length; // code points, as `len()` counts them
|
|
211
|
+
if (n >= 10 && n <= 300) {
|
|
212
|
+
err(`[learn] agent lesson -> ${appendAgentLesson(memDir, agent, lesson)}\n`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (proc.returncode !== 0 && proc.stderr) err(proc.stderr);
|
|
216
|
+
return proc.returncode;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function cmdLearn(args) {
|
|
220
|
+
if (args.consolidate) {
|
|
221
|
+
const memDir = resolveMemoryDir(args.memory);
|
|
222
|
+
if (!memDir) {
|
|
223
|
+
err('[learn] no memory store found \u2014 nothing to consolidate.\n');
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
const report = consolidateMemory(memDir);
|
|
227
|
+
err(`[learn] consolidated ${memDir}: +${report.added.length} indexed, `
|
|
228
|
+
+ `-${report.pruned.length} pruned, `
|
|
229
|
+
+ `${report.duplicates.length} duplicate description(s)\n`);
|
|
230
|
+
for (const [a, b] of report.duplicates) err(` duplicate: ${a} <-> ${b}\n`);
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// The Stop/SubagentStop hook always passes `--memory <cfg>/memory`; inside an excluded
|
|
235
|
+
// folder the global install must not learn.
|
|
236
|
+
if (args.memory && sovereignBypass(path.dirname(toPlatformPath(args.memory)))) return 0;
|
|
237
|
+
|
|
238
|
+
const raw = args.file ? readText(args.file) : readStdin();
|
|
239
|
+
const meta = hookMeta(raw);
|
|
240
|
+
let notes = readNotes(raw, meta);
|
|
241
|
+
if (!notes.trim()) {
|
|
242
|
+
err('[learn] no notes or transcript content \u2014 nothing to distil.\n');
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
notes = tail(notes, MAX_NOTES_CHARS); // keep the tail: most recent, most durable
|
|
246
|
+
|
|
247
|
+
if (meta.hook_event_name === 'SubagentStop') return learnAgentLesson(meta, notes, args);
|
|
248
|
+
|
|
249
|
+
const memDir = resolveMemoryDir(args.memory);
|
|
250
|
+
const existing = memDir ? existingSlugs(memDir) : new Set();
|
|
251
|
+
const prompt = buildLearnPrompt(notes, existing);
|
|
252
|
+
|
|
253
|
+
const llm = process.env.GENESEED_LLM;
|
|
254
|
+
if (!llm) {
|
|
255
|
+
err('[learn] $GENESEED_LLM unset \u2014 printing prompt instead.\n\n');
|
|
256
|
+
out(`${prompt}\n`);
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const proc = runLlm(llm, prompt);
|
|
261
|
+
const output = proc.stdout;
|
|
262
|
+
if (memDir && output.trim() && output.trim().toUpperCase() !== 'NOTHING') {
|
|
263
|
+
const written = writeMemories(output, memDir, existing);
|
|
264
|
+
if (written.length) {
|
|
265
|
+
err(`[learn] wrote ${written.length} memory file(s) to `
|
|
266
|
+
+ `${memDir}: ${written.join(', ')}\n`);
|
|
267
|
+
} else {
|
|
268
|
+
err('[learn] nothing new to store (all duplicates).\n');
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
out(output);
|
|
272
|
+
}
|
|
273
|
+
if (proc.returncode !== 0 && proc.stderr) err(proc.stderr);
|
|
274
|
+
return proc.returncode;
|
|
275
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The primitives the hook verbs share — `js/hosts/hooks.mjs` (the gates), `hooks-context.mjs`,
|
|
3
|
+
* `hooks-learn.mjs` and `memory-files.mjs`.
|
|
4
|
+
*
|
|
5
|
+
* Its own module because the verbs no longer share one: each loads only the code its verb runs
|
|
6
|
+
* (`bin/geneseed-hook.mjs` imports per verb), and a gate is paid on every tool call. What lives
|
|
7
|
+
* here is what at least two of them need, and nothing in it may spawn or reach the generator —
|
|
8
|
+
* `memory-files.mjs` puts this module in the CLI's closure too.
|
|
9
|
+
*/
|
|
10
|
+
import { statSync, readFileSync, readdirSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { comparePaths } from '../lib/paths.mjs';
|
|
13
|
+
|
|
14
|
+
// ---- Python path and text primitives -------------------------------------------------
|
|
15
|
+
// Small, and each one is here because a JS-idiomatic equivalent differs observably.
|
|
16
|
+
//
|
|
17
|
+
// THE RULE, CORRECTED IN P5d. It used to read "duplicating four helpers is cheaper than
|
|
18
|
+
// coupling a hook to the emitter", with `readText`/`writeText`/`normcase` as the stated
|
|
19
|
+
// exceptions. That is the wrong line, and `pyStrPath` is what proved it: it was a second
|
|
20
|
+
// implementation of `str(Path(p))` under a different NAME from `js/lib/fs.mjs`'s
|
|
21
|
+
// `toPlatformPath`, and the two disagreed. P5c found that `path.normalize` collapses `a/../b`
|
|
22
|
+
// where `PurePath` keeps it, fixed `toPlatformPath`, and gated it with a 25-path corpus — and
|
|
23
|
+
// none of that reached the hook, because a corpus finds what it is pointed at and nothing
|
|
24
|
+
// named `pyStrPath` was. The consequence was live: `sovereignBypass` compares an
|
|
25
|
+
// `excludes.json` entry against cwd, so a hand-edited `..` entry made the NODE hook stand
|
|
26
|
+
// down for a repo Python still gates. (Since the Python is gone, exclude entries resolve `..`
|
|
27
|
+
// and symlinks on every host — `context/sovereign-bypass-matches-a-dotdot-entry` is the cell.)
|
|
28
|
+
//
|
|
29
|
+
// So the line is not "duplicate rather than couple" — it is "one owner for anything that
|
|
30
|
+
// reproduces a language primitive, wherever it is used". `fs.mjs` carries no
|
|
31
|
+
// `child_process` and costs nothing at hook latency. What stays local is what is genuinely
|
|
32
|
+
// only the hook's (`fnmatch`, the transcript readers, `asOsError`'s errno table).
|
|
33
|
+
//
|
|
34
|
+
// AND `expanduser` WAS THE SECOND VIOLATION, closed here. The hook carried its own, which
|
|
35
|
+
// returned a `~user` path UNCHANGED while `js/hosts/hosts.mjs`'s single owner REFUSES the form —
|
|
36
|
+
// an adjudicated product decision (`TheTildeUserFormIsADeliberateDivergence`) that the hook
|
|
37
|
+
// was quietly exempt from because it had a private copy. It now imports the owner. The
|
|
38
|
+
// refusal throws, so `sovereignBypass` and `cmdContext` contain it per entry / per
|
|
39
|
+
// call. For the GATES that containment is not enough, and `guardGate` (`./hooks.mjs`) is the
|
|
40
|
+
// contract: these verbs exit 0 and signal on stdout, so "the gate blew up" and "the gate
|
|
41
|
+
// found nothing" would be the SAME observation to Claude — a crash would be a silent allow.
|
|
42
|
+
// A gate that cannot evaluate a call therefore asks, and says why. `resolvePath` comes from
|
|
43
|
+
// `js/hosts/hosts.mjs`, which every verb already imports — the hook had kept a private copy.
|
|
44
|
+
|
|
45
|
+
// NOT `js/lib/fs.mjs`'s `isFile`/`isDir` (the owner everywhere else), which every verb already
|
|
46
|
+
// loads: those RETHROW anything but a definite "not there" (EACCES above all), and a hook must
|
|
47
|
+
// answer every probe — an unreadable path is simply not a file to inject or a store to guard.
|
|
48
|
+
export const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
49
|
+
export const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
50
|
+
|
|
51
|
+
/** `(p, *p.parents)` — the path and every ancestor, nearest first. */
|
|
52
|
+
export function selfAndParents(p) {
|
|
53
|
+
const out = [p];
|
|
54
|
+
let cur = p;
|
|
55
|
+
for (;;) {
|
|
56
|
+
const up = path.dirname(cur);
|
|
57
|
+
if (up === cur) return out;
|
|
58
|
+
out.push(up);
|
|
59
|
+
cur = up;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `str.splitlines()`.
|
|
65
|
+
*
|
|
66
|
+
* `split('\n')` is not it: Python breaks on eleven boundaries, and a transcript or a
|
|
67
|
+
* memory file carrying a form feed or U+2028 would be split by one implementation and not
|
|
68
|
+
* the other. The trailing empty is dropped the way Python drops it (`"a\n"` is one line,
|
|
69
|
+
* `""` is none).
|
|
70
|
+
*/
|
|
71
|
+
export function splitLines(text) {
|
|
72
|
+
if (!text) return [];
|
|
73
|
+
const parts = text.split(/\r\n|[\n\r\v\f\x1c\x1d\x1e\x85\u2028\u2029]/);
|
|
74
|
+
if (parts.length && parts[parts.length - 1] === '') parts.pop();
|
|
75
|
+
return parts;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** `str.split()` with no argument — runs of whitespace, ends stripped, no empties. */
|
|
79
|
+
export function splitWords(s) {
|
|
80
|
+
return s.split(/\s+/).filter(Boolean);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** stdin, decoded as UTF-8 with Python's universal newlines. Absent stdin reads empty. */
|
|
84
|
+
export function readStdin() {
|
|
85
|
+
try {
|
|
86
|
+
return readFileSync(0, 'utf8').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
|
87
|
+
} catch {
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The two output funnels, with Python's newline translation reproduced, are `printOut`/`printErr`
|
|
93
|
+
// in `js/lib/fs.mjs` — they moved there in P5c, beside `writeText`, which is the same rule for
|
|
94
|
+
// FILES, when `js/inspect/excludes.mjs` became the second caller; see that docblock for why the
|
|
95
|
+
// translation exists and which gate can see it. Each verb imports them as `out`/`err`.
|
|
96
|
+
|
|
97
|
+
/** `Path.glob`/`rglob` order: `sorted()` over paths, which is case-folded on Windows. */
|
|
98
|
+
export function sortPaths(list) {
|
|
99
|
+
return list.slice().sort(comparePaths);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function listDir(dir) {
|
|
103
|
+
try {
|
|
104
|
+
return readdirSync(dir);
|
|
105
|
+
} catch {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
}
|