geneseed 3.1.3 → 3.2.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 +70 -0
- package/README.md +2 -2
- package/SHIPPED.md +3 -3
- package/bin/build-driver.mjs +127 -79
- package/bin/geneseed-cli.mjs +42 -84
- package/bin/geneseed-hook.mjs +5 -2
- package/harness.config.json +1 -1
- package/js/README.md +1 -10
- package/js/build/emit-claude.mjs +4 -5
- package/js/build/emit-common.mjs +63 -114
- package/js/build/emit-opencode.mjs +74 -66
- package/js/build/generate.mjs +4 -6
- package/js/build/render.mjs +13 -13
- package/js/build/source.mjs +7 -6
- package/js/build/stubs.mjs +25 -33
- package/js/build/themes.mjs +1 -1
- package/js/build/version.mjs +1 -3
- package/js/hosts/hooks.mjs +3 -3
- package/js/hosts/hosts.mjs +2 -4
- package/js/hosts/installs.mjs +35 -28
- package/js/hosts/link.mjs +1 -1
- package/js/hosts/native.mjs +37 -19
- package/js/hosts/opencode.mjs +39 -28
- package/js/hosts/settings.mjs +33 -110
- package/js/inspect/checks-authoring.mjs +5 -4
- package/js/inspect/checks-build.mjs +1 -1
- package/js/inspect/diff.mjs +1 -3
- package/js/inspect/doctor.mjs +59 -44
- package/js/inspect/excludes.mjs +4 -10
- package/js/inspect/inventory.mjs +37 -55
- package/js/inspect/registry.mjs +4 -7
- package/js/inspect/scan.mjs +8 -15
- package/js/inspect/status.mjs +1 -1
- package/js/lib/fs.mjs +34 -0
- package/js/lib/json.mjs +28 -1
- package/js/lib/paths.mjs +0 -10
- package/js/lib/udiff.mjs +11 -16
- package/js/maintain/memory.mjs +2 -4
- package/js/maintain/migrate.mjs +3 -3
- package/js/maintain/uninstall.mjs +90 -78
- package/js/maintain/update.mjs +2 -2
- package/js/ui/anim.mjs +1 -1
- package/js/ui/menu.mjs +1 -1
- package/js/ui/tui.mjs +3 -20
- package/js/web/actions.mjs +230 -217
- package/js/web/activity.mjs +71 -89
- package/js/web/api.mjs +133 -224
- package/js/web/daemon.mjs +92 -110
- package/js/web/docs.mjs +66 -111
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +78 -97
- package/js/web/jobs.mjs +124 -165
- package/js/web/routes.mjs +73 -152
- package/js/web/server.mjs +47 -82
- package/package.json +1 -1
- package/src/agents/explorer.md +2 -1
- package/src/doctrines/process.md +4 -2
- package/src/laws/universal.md +55 -43
- package/src/skills/clarify.md +1 -1
- package/src/skills/forge-mcp.md +2 -1
- package/src/skills/fresh-eyes.md +1 -1
- package/src/skills/ingest.md +2 -1
- package/src/skills/parallel-agents.md +1 -1
- package/src/skills/ponytail.md +2 -2
- package/src/skills/research.md +4 -2
- 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-B49fE_ug.js → Activity-BTCG2gMl.js} +1 -1
- package/web/dist/assets/{ActivityDetail-abt7hzD2.js → ActivityDetail-julsmPDB.js} +1 -1
- package/web/dist/assets/CatalogRow-DqS4e5XM.js +1 -0
- package/web/dist/assets/Diff-CIrGbiqm.js +5 -0
- package/web/dist/assets/Doctor-CpI53oHY.js +1 -0
- package/web/dist/assets/{Graph-DvYMtlvl.js → Graph-CS4tA7YS.js} +1 -1
- package/web/dist/assets/Harness-vRYs11kX.js +1 -0
- package/web/dist/assets/Laws-BjbHJrJt.js +3 -0
- package/web/dist/assets/Library-CjC7BAWN.js +1 -0
- package/web/dist/assets/{marked.esm-JKvyS0Pp.js → Markdown-kgpg9EQM.js} +15 -15
- package/web/dist/assets/Profile-CHr-PPf6.js +1 -0
- package/web/dist/assets/{Rules-CfsV1EVy.js → Rules-CsC6hfqk.js} +1 -1
- package/web/dist/assets/Skills-BvatJGfe.js +1 -0
- package/web/dist/assets/{StatusBadge-C9bZOEZk.js → StatusBadge-gRrak0nO.js} +1 -1
- package/web/dist/assets/{activity-Cc0n2jwx.js → activity-B0-FXZCj.js} +1 -1
- package/web/dist/assets/index-BnRWQbZb.js +12 -0
- package/web/dist/assets/index-Bx3I7cRW.js +2 -0
- package/web/dist/assets/index-D0f9tJlA.css +1 -0
- package/web/dist/assets/index-DJOhOwwR.js +1 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +42 -71
- package/web/dist/assets/Diff-D3_bVkih.js +0 -5
- package/web/dist/assets/Doctor-hXp7srsx.js +0 -1
- package/web/dist/assets/Harness-BIWei7N5.js +0 -1
- package/web/dist/assets/Laws-CxmnRdwt.js +0 -3
- package/web/dist/assets/Library-MjbOkHIW.js +0 -1
- package/web/dist/assets/Markdown-Dx_w0faR.js +0 -1
- package/web/dist/assets/Profile-BRFvv2gr.js +0 -1
- package/web/dist/assets/Skills-DArA6IHS.js +0 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +0 -1
- package/web/dist/assets/index-DC3-GlCY.js +0 -1
- package/web/dist/assets/index-DP035wPa.js +0 -12
- package/web/dist/assets/index-dHi-7M-F.js +0 -2
package/js/web/activity.mjs
CHANGED
|
@@ -7,78 +7,64 @@
|
|
|
7
7
|
* directory self-cleans), and returns what is left. Writer and reader share only that
|
|
8
8
|
* directory: no RPC, crash-isolated, and cross-tool by construction.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* and giving this row the 409 treatment is mutation M22.
|
|
10
|
+
* `/api/activity` answers a GET through `apiActivity` and a POST through `apiActivityToggle`
|
|
11
|
+
* at the bottom of this file. It is the one POST route whose result is sent at 200 whatever
|
|
12
|
+
* `ok` says, which is why `POST_ROUTES` in `js/web/routes.mjs` carries a 409 COLUMN rather
|
|
13
|
+
* than a rule — and giving this row the 409 treatment is mutation M22.
|
|
15
14
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
20
|
-
* 0)` is the twin here; it is a core `process` API and not `child_process`, so the ban that
|
|
21
|
-
* P4a's source check enforces is untouched. libuv's `uv_kill` opens the process for query
|
|
22
|
-
* and answers `ESRCH` when the pid is free and `EPERM` when it cannot tell, which is
|
|
23
|
-
* exactly the reference's "no such process" / "err toward alive" pair.
|
|
15
|
+
* `process.kill(pid, 0)` PROBES LIVENESS WITHOUT A SPAWN — it is a core `process` API, not
|
|
16
|
+
* `child_process`, so it stays clear of the spawn ban. libuv's `uv_kill` opens the process
|
|
17
|
+
* for query and answers `ESRCH` when the pid is free and `EPERM` when it cannot tell —
|
|
18
|
+
* "no such process" vs "err toward alive".
|
|
24
19
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* inconclusive answer errs toward alive.
|
|
20
|
+
* `process.kill` alone cannot tell a truly-dead pid from one that EXITED but whose handle is
|
|
21
|
+
* still held: it reports the latter alive too. The staleness backstop (below) is what prunes
|
|
22
|
+
* it, thirty minutes later — reproducing an exit-code read would need a native binding, not
|
|
23
|
+
* worth it for a best-effort probe that already errs toward "alive" on any inconclusive
|
|
24
|
+
* answer.
|
|
31
25
|
*
|
|
32
|
-
* `parseJson
|
|
33
|
-
*
|
|
34
|
-
* `
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* `entry.
|
|
38
|
-
*
|
|
26
|
+
* `parseJson`, NOT `JSON.parse` — NOT a style choice. These files are written by a third
|
|
27
|
+
* party and carry `cost` as a float. `JSON.parse` collapses `1.0` to the bare integer `1`,
|
|
28
|
+
* which `bareInts` then renders back as `1`, losing the distinction the source file made;
|
|
29
|
+
* `parseJson`'s wrapper is what survives the round trip.
|
|
30
|
+
*
|
|
31
|
+
* Its companion is `isTruthy`: a naive `entry.cost || 0` fallback is TRUE for a wrapped zero
|
|
32
|
+
* (the wrapper is an object, never falsy on its own), which would echo back the wrapper's
|
|
33
|
+
* `0.0` instead of collapsing to the fallback `0`. `isTruthy` is what makes a wrapped zero
|
|
34
|
+
* read as falsy, so a `"cost": 0.0` entry correctly comes back `0`.
|
|
39
35
|
*/
|
|
40
|
-
import { mkdirSync, readdirSync,
|
|
36
|
+
import { mkdirSync, readdirSync, unlinkSync } from 'node:fs';
|
|
41
37
|
import path from 'node:path';
|
|
42
38
|
|
|
43
|
-
import { readText, writeText } from '../lib/fs.mjs';
|
|
39
|
+
import { readText, writeText, isDir, isFile } from '../lib/fs.mjs';
|
|
44
40
|
import { parseJson, isTruthy } from '../lib/json.mjs';
|
|
45
41
|
import { normcase } from '../lib/paths.mjs';
|
|
46
42
|
import { parseIntStrict, stripWhitespace } from '../lib/text.mjs';
|
|
47
|
-
import { NotFound } from './api.mjs';
|
|
43
|
+
import { NotFound, stemOf } from './api.mjs';
|
|
48
44
|
|
|
49
45
|
/**
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* (process-still-alive) session ages out.
|
|
46
|
+
* A session whose writer is gone, or that has not been touched in this long, is pruned.
|
|
47
|
+
* pid-liveness is the real signal; the staleness backstop covers a reused pid and a
|
|
48
|
+
* platform where the probe cannot tell. Generous on purpose: streaming bumps `updated_at`
|
|
49
|
+
* constantly, so only a genuinely abandoned (process-still-alive) session ages out.
|
|
55
50
|
*/
|
|
56
51
|
export const ACTIVITY_STALE_SECONDS = 1800;
|
|
57
52
|
|
|
58
|
-
const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return false; } };
|
|
59
|
-
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
60
|
-
|
|
61
|
-
/** `time.time()` — unix seconds as a float. */
|
|
62
53
|
const nowSeconds = () => Date.now() / 1000;
|
|
63
54
|
|
|
64
55
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
56
|
+
* `<target>/activity`. `state.target` is ALREADY the OpenCode config dir, so this must not
|
|
57
|
+
* append `opencode/` again; that double-join is the classic seam bug.
|
|
67
58
|
*/
|
|
68
59
|
const activityDir = (state) => path.join(state.target, 'activity');
|
|
69
60
|
|
|
70
|
-
/**
|
|
61
|
+
/** The runtime on/off flag — the same path the plugin resolves. */
|
|
71
62
|
const activityFlag = (state) => path.join(state.target, '.geneseed-activity');
|
|
72
63
|
|
|
73
64
|
/**
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
* The one input this cannot mirror is an undecodable flag file: Python's
|
|
78
|
-
* `read_text(encoding="utf-8")` raises a `UnicodeDecodeError`, which is a `ValueError` and
|
|
79
|
-
* not caught, where Node substitutes U+FFFD and reads on. No cell seeds one — it would take
|
|
80
|
-
* a binary file where a four-letter word belongs — and the reference's answer there is a
|
|
81
|
-
* 500, which is not a behaviour worth reproducing.
|
|
65
|
+
* On unless the flag file explicitly says off. An absent (or unreadable) file is the
|
|
66
|
+
* default every user who has never toggled is in. An undecodable flag file is not treated
|
|
67
|
+
* specially: Node substitutes U+FFFD and reads on rather than raising.
|
|
82
68
|
*/
|
|
83
69
|
export function activityEnabled(state) {
|
|
84
70
|
let raw;
|
|
@@ -91,14 +77,14 @@ export function activityEnabled(state) {
|
|
|
91
77
|
}
|
|
92
78
|
|
|
93
79
|
/**
|
|
94
|
-
*
|
|
95
|
-
* pid", so both come back null here.
|
|
80
|
+
* Coerces a raw pid value to an integer, or `null` for anything unusable.
|
|
96
81
|
*
|
|
97
|
-
* The value came out of `parseJson`, so a JSON number
|
|
98
|
-
* cannot import by name
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
82
|
+
* The value came out of `parseJson`, so a JSON number may arrive wrapped in `JsonNumber`,
|
|
83
|
+
* which this module cannot import by name (only `parseJson`/`isTruthy`/etc are exported). It
|
|
84
|
+
* is recognised by the only thing the wrapper promises: a numeric `valueOf`. A plain
|
|
85
|
+
* object's `valueOf` returns itself (rejected by the `typeof n !== 'number'` check below)
|
|
86
|
+
* and an array is excluded outright. Also handles a decimal string, a boolean (`true` → 1),
|
|
87
|
+
* and a float (truncated) — whatever a hand-edited snapshot can produce.
|
|
102
88
|
*/
|
|
103
89
|
function toIntOr(raw) {
|
|
104
90
|
if (raw === true) return 1;
|
|
@@ -110,7 +96,7 @@ function toIntOr(raw) {
|
|
|
110
96
|
return Math.trunc(n);
|
|
111
97
|
}
|
|
112
98
|
|
|
113
|
-
/**
|
|
99
|
+
/** Best-effort liveness for the writer process. See this file's header. */
|
|
114
100
|
export function pidAlive(raw) {
|
|
115
101
|
const pid = toIntOr(raw);
|
|
116
102
|
if (pid === null || pid <= 0) return false;
|
|
@@ -118,17 +104,16 @@ export function pidAlive(raw) {
|
|
|
118
104
|
process.kill(pid, 0);
|
|
119
105
|
return true;
|
|
120
106
|
} catch (e) {
|
|
121
|
-
//
|
|
122
|
-
//
|
|
107
|
+
// ESRCH → dead. Anything else (`EPERM`, and whatever a locked-down host answers) →
|
|
108
|
+
// alive, and the staleness backstop decides.
|
|
123
109
|
return !(e && e.code === 'ESRCH');
|
|
124
110
|
}
|
|
125
111
|
}
|
|
126
112
|
|
|
127
113
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* comparison over every response gates.
|
|
114
|
+
* One session snapshot normalised into the stable shape the UI consumes. v1 writers omit
|
|
115
|
+
* the enrichment keys, so those default to null/0 and the UI hides what is absent. The KEY
|
|
116
|
+
* ORDER here is deliberate and stable — do not reshuffle it.
|
|
132
117
|
*/
|
|
133
118
|
function normalizeEntry(entry, stem) {
|
|
134
119
|
const g = (k) => (Object.hasOwn(entry, k) ? entry[k] : null);
|
|
@@ -152,7 +137,7 @@ function normalizeEntry(entry, stem) {
|
|
|
152
137
|
};
|
|
153
138
|
}
|
|
154
139
|
|
|
155
|
-
/** `
|
|
140
|
+
/** `null` on a missing, garbage or non-dict file. Never throws. */
|
|
156
141
|
function readEntry(p) {
|
|
157
142
|
let entry;
|
|
158
143
|
try {
|
|
@@ -163,7 +148,7 @@ function readEntry(p) {
|
|
|
163
148
|
return (entry !== null && typeof entry === 'object' && !Array.isArray(entry)) ? entry : null;
|
|
164
149
|
}
|
|
165
150
|
|
|
166
|
-
/**
|
|
151
|
+
/** The writer's pid is alive AND the snapshot is not stale. */
|
|
167
152
|
function isLive(entry, now) {
|
|
168
153
|
const updated = isTruthy(Object.hasOwn(entry, 'updated_at') ? entry.updated_at : null)
|
|
169
154
|
? entry.updated_at : 0;
|
|
@@ -171,13 +156,10 @@ function isLive(entry, now) {
|
|
|
171
156
|
Object.hasOwn(entry, 'pid') ? entry.pid : null);
|
|
172
157
|
}
|
|
173
158
|
|
|
174
|
-
/** `Path.stem` — the name without its LAST suffix, so `s-live.detail.json` keeps the dot. */
|
|
175
|
-
const stemOf = (name) => name.slice(0, name.length - path.extname(name).length);
|
|
176
|
-
|
|
177
159
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
160
|
+
* Every live session, dead and stale ones pruned AND their files removed. Never throws: a
|
|
161
|
+
* missing dir is `[]`, a garbage file is skipped (and kept — only a snapshot that PARSED and
|
|
162
|
+
* failed the liveness test is unlinked), newest first.
|
|
181
163
|
*/
|
|
182
164
|
export function activityEntries(state) {
|
|
183
165
|
const d = activityDir(state);
|
|
@@ -190,8 +172,9 @@ export function activityEntries(state) {
|
|
|
190
172
|
} catch {
|
|
191
173
|
return [];
|
|
192
174
|
}
|
|
193
|
-
//
|
|
194
|
-
//
|
|
175
|
+
// Case-INSENSITIVE for the `.json` suffix (matches Windows filesystem behaviour); the
|
|
176
|
+
// `.detail.json` check below is case-SENSITIVE on both platforms. Two different rules,
|
|
177
|
+
// deliberately.
|
|
195
178
|
for (const name of names.filter((n) => normcase(n).endsWith('.json')).sort()) {
|
|
196
179
|
if (name.endsWith('.detail.json')) continue;
|
|
197
180
|
const p = path.join(d, name);
|
|
@@ -201,34 +184,34 @@ export function activityEntries(state) {
|
|
|
201
184
|
try {
|
|
202
185
|
unlinkSync(p); // crashed / abandoned writer
|
|
203
186
|
unlinkSync(path.join(d, `${stemOf(name)}.detail.json`)); // its detail file too
|
|
204
|
-
} catch { /*
|
|
187
|
+
} catch { /* both unlinks are best-effort; a lingering file next scan is harmless */ }
|
|
205
188
|
continue;
|
|
206
189
|
}
|
|
207
190
|
out.push(normalizeEntry(entry, stemOf(name)));
|
|
208
191
|
}
|
|
209
|
-
//
|
|
210
|
-
//
|
|
192
|
+
// STABLE sort matters when `updated_at` ties: a `b - a` comparator over ES2019's
|
|
193
|
+
// guaranteed-stable `Array.sort` preserves the original (directory-listing) order for
|
|
194
|
+
// equal keys.
|
|
211
195
|
out.sort((a, b) => Number(b.updated_at) - Number(a.updated_at));
|
|
212
196
|
return out;
|
|
213
197
|
}
|
|
214
198
|
|
|
215
|
-
/** `_web_activity.api_activity` (GET). */
|
|
216
199
|
export function apiActivity(state) {
|
|
217
200
|
const enabled = activityEnabled(state);
|
|
218
201
|
return { enabled, activity: enabled ? activityEntries(state) : [] };
|
|
219
202
|
}
|
|
220
203
|
|
|
221
204
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
205
|
+
* One session's snapshot plus its step timeline and its UNCAPPED files/todos. 404 if the
|
|
206
|
+
* session is absent or has aged out; a missing or garbage detail file degrades to an empty
|
|
207
|
+
* timeline rather than a 500.
|
|
225
208
|
*/
|
|
226
209
|
export function apiActivityDetail(state, sid) {
|
|
227
210
|
const d = activityDir(state);
|
|
228
211
|
// Resolve by the writer's safe-name scheme, which is also what keeps the read inside the
|
|
229
212
|
// directory: every separator, `..` and drive colon becomes `_`, so there is no join to
|
|
230
|
-
// steer. The `u` flag makes an astral character ONE replacement
|
|
231
|
-
//
|
|
213
|
+
// steer. The `u` flag makes an astral character ONE replacement rather than one per
|
|
214
|
+
// surrogate half.
|
|
232
215
|
const stem = sid.replace(/[^A-Za-z0-9_.-]/gu, '_');
|
|
233
216
|
const snap = path.join(d, `${stem}.json`);
|
|
234
217
|
const entry = isFile(snap) ? readEntry(snap) : null;
|
|
@@ -244,8 +227,6 @@ export function apiActivityDetail(state, sid) {
|
|
|
244
227
|
}
|
|
245
228
|
const fg = (k) => (Object.hasOwn(full, k) ? full[k] : null);
|
|
246
229
|
// The detail file carries the uncapped lists; fall back to the snapshot's capped ones.
|
|
247
|
-
// Assigning over an existing key keeps its POSITION in both languages, which is what lets
|
|
248
|
-
// the response stay byte-comparable.
|
|
249
230
|
session.files = isTruthy(fg('files')) ? fg('files') : session.files;
|
|
250
231
|
session.todos = isTruthy(fg('todos')) ? fg('todos') : session.todos;
|
|
251
232
|
// The compact transcript: an ordered list of {role, text, t} turns. Empty falls back to
|
|
@@ -260,16 +241,17 @@ export function apiActivityDetail(state, sid) {
|
|
|
260
241
|
}
|
|
261
242
|
|
|
262
243
|
/**
|
|
263
|
-
*
|
|
244
|
+
* Flip the runtime on/off flag.
|
|
264
245
|
*
|
|
265
246
|
* The OpenCode plugin reads this file on every event, so the change takes effect without
|
|
266
247
|
* restarting anything; writing `off` also makes the plugin clear its snapshots on its next
|
|
267
248
|
* event, while the reader gates the surface immediately.
|
|
268
249
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
250
|
+
* DEFAULTS TO ON: an empty or missing `enabled` field in the body enables. Do not read this
|
|
251
|
+
* as `body.enabled === true` — that would flip an absent field to "disable" instead.
|
|
252
|
+
*
|
|
253
|
+
* This is the one POST route whose result is sent at 200 whatever `ok` says — see this
|
|
254
|
+
* file's header, and `POST_ROUTES_CONVENTION` in `js/web/routes.mjs`.
|
|
273
255
|
*/
|
|
274
256
|
export function apiActivityToggle(state, body) {
|
|
275
257
|
const raw = (body && Object.hasOwn(body, 'enabled')) ? body.enabled : true;
|