geneseed 3.14.0 → 3.16.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 +66 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +45 -11
- package/adapters/opencode/plugins/geneseed-guard.js +16 -4
- package/adapters/opencode/plugins/geneseed-learn.js +37 -11
- 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 +22 -20
- package/bin/geneseed-hook.mjs +6 -3
- package/docs/concepts/exclusions.md +21 -9
- package/docs/guides/install-scripted.md +1 -1
- package/docs/guides/project-context.md +1 -1
- package/docs/token-footprint.md +10 -7
- package/harness.config.json +1 -1
- package/js/README.md +9 -5
- package/js/build/args.mjs +38 -6
- package/js/build/bundle.mjs +10 -10
- package/js/build/driver.mjs +16 -14
- package/js/build/emit-claude.mjs +3 -2
- package/js/build/emit-common.mjs +2 -14
- package/js/build/emit-opencode.mjs +12 -8
- package/js/build/generate.mjs +26 -16
- package/js/build/render.mjs +34 -4
- package/js/build/source.mjs +43 -7
- package/js/build/stubs.mjs +6 -5
- package/js/build/version.mjs +21 -2
- package/js/cli-table.json +15 -0
- package/js/hosts/hooks-context.mjs +85 -34
- package/js/hosts/hooks-prims.mjs +2 -2
- package/js/hosts/hosts.mjs +24 -26
- package/js/hosts/installs.mjs +58 -16
- package/js/hosts/link.mjs +75 -26
- package/js/hosts/memory-files.mjs +13 -4
- package/js/hosts/native.mjs +106 -48
- package/js/hosts/opencode.mjs +46 -36
- package/js/hosts/shim.mjs +4 -2
- package/js/inspect/diff.mjs +9 -7
- package/js/inspect/excludes.mjs +10 -5
- package/js/inspect/status.mjs +20 -22
- package/js/inspect/validate.mjs +5 -3
- 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/maintain/memory.mjs +5 -6
- package/js/maintain/migrate.mjs +4 -3
- package/js/maintain/setup.mjs +20 -135
- package/js/maintain/uninstall.mjs +10 -9
- package/js/maintain/update.mjs +115 -34
- package/js/ui/anim.mjs +8 -12
- package/js/ui/cli.mjs +29 -35
- package/js/ui/menu.mjs +7 -13
- package/js/ui/tui.mjs +14 -123
- package/js/web/actions.mjs +87 -389
- package/js/web/activity.mjs +1 -1
- package/js/web/api.mjs +33 -717
- package/js/web/catalog.mjs +527 -0
- package/js/web/daemon.mjs +21 -3
- 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/src/AGENT.md.tmpl +1 -1
- package/web/dist/assets/{Activity-CjRqZnr-.js → Activity-kpWxd70F.js} +1 -1
- package/web/dist/assets/{ActivityDetail-OQTxKBj2.js → ActivityDetail-Ccr942VL.js} +1 -1
- package/web/dist/assets/Dashboard-D1lE7jcg.js +2 -0
- package/web/dist/assets/{Docs-CvOZ8akL.js → Docs-BHyaggFy.js} +3 -3
- package/web/dist/assets/{GreenhouseView-6KDjX8da.js → GreenhouseView-Sc_UDMOE.js} +1 -1
- package/web/dist/assets/Installs-Gw-pJ3Zi.js +2 -0
- package/web/dist/assets/JournalView-_gxpsxXi.js +1 -0
- package/web/dist/assets/{Laws-Du0-Zyp2.js → Laws-S9fq1r9T.js} +1 -1
- package/web/dist/assets/{Library-DOkO0TtB.js → Library-MBxadRzn.js} +1 -1
- package/web/dist/assets/{Loops-Dv_aLByJ.js → Loops-Da_JhlzM.js} +2 -2
- package/web/dist/assets/{OperatorHudView-BPP1dtLz.js → OperatorHudView--TkvDybH.js} +1 -1
- package/web/dist/assets/{Personal-26VPYkK0.js → Personal-CTXKHVbV.js} +1 -1
- package/web/dist/assets/Tabs-B0cdf8k6.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-ujKG1ZNf.js +2 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/Dashboard-J-xCe_Gk.js +0 -2
- package/web/dist/assets/Installs-MWuhNjSm.js +0 -2
- package/web/dist/assets/JournalView-C8MrOz2c.js +0 -1
- package/web/dist/assets/Tabs-BzKuFskD.js +0 -1
- package/web/dist/assets/api-D_ixdyEY.js +0 -1
- package/web/dist/assets/drawable-zVVDbsyw.js +0 -1
- package/web/dist/assets/index-KHt3wE_K.js +0 -2
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Library's READ MODEL — what the console lists and opens: the agent/skill inventory (read
|
|
3
|
+
* off the deployed install, else rendered from source), the constitution's three tiers, and the
|
|
4
|
+
* hand-written stores beside the install (memory, notebook, wiki, the two setup manifests).
|
|
5
|
+
* `apiCatalog` and `apiItem` are its two endpoints; `routes.mjs` binds them to paths.
|
|
6
|
+
*
|
|
7
|
+
* `NotFound` lives here because this is where it is raised most, and because `state.mjs` imports
|
|
8
|
+
* this file (for the inventory it caches) and nothing here imports `state.mjs` back.
|
|
9
|
+
*
|
|
10
|
+
* `flatName`'s traversal refusal and `CONFIG_META`'s allowlist are the two reasons a GET — which
|
|
11
|
+
* carries no token — cannot read an arbitrary file through `/api/item/`.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { GLOBAL_MANIFEST, resolvePath } from '../hosts/hosts.mjs';
|
|
17
|
+
import { doctrinesForBuild, excludedRulesOfDir, readMaybe } from '../hosts/installs.mjs';
|
|
18
|
+
import { frontmatter } from '../hosts/memory-files.mjs';
|
|
19
|
+
import {
|
|
20
|
+
SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
|
|
21
|
+
} from '../inspect/inventory.mjs';
|
|
22
|
+
import { firstBlockquote } from '../hosts/native.mjs';
|
|
23
|
+
import { readText, withDiscardableStderr, isDir, isFile } from '../lib/fs.mjs';
|
|
24
|
+
import { comparePaths, normcase, within } from '../lib/paths.mjs';
|
|
25
|
+
import { stripWhitespace } from '../lib/text.mjs';
|
|
26
|
+
import { readJsonc } from '../hosts/settings.mjs';
|
|
27
|
+
|
|
28
|
+
/** A requested catalog section or item that does not exist. */
|
|
29
|
+
export class NotFound extends Error {}
|
|
30
|
+
|
|
31
|
+
/** `??` would also swallow an explicit `null` the file declared; this does not. */
|
|
32
|
+
export const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
|
|
33
|
+
|
|
34
|
+
export function deployed(state) {
|
|
35
|
+
return existsSync(path.join(state.target, GLOBAL_MANIFEST));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A deployed spec's one-line purpose.
|
|
40
|
+
*
|
|
41
|
+
* The `> blockquote` convention every rendered skill and agent carries, then the
|
|
42
|
+
* frontmatter `description`, then the first prose paragraph. The fallbacks are for the
|
|
43
|
+
* VENDORED skill folders, which ride in verbatim with no blockquote and would otherwise
|
|
44
|
+
* show a blank Purpose cell.
|
|
45
|
+
*/
|
|
46
|
+
export function specDesc(fm, body) {
|
|
47
|
+
const bq = firstBlockquote(body);
|
|
48
|
+
if (bq) return bq;
|
|
49
|
+
const desc = String(fm.has('description') ? fm.get('description') : '').trim();
|
|
50
|
+
if (desc) return desc.split(/\s+/).filter(Boolean).join(' ');
|
|
51
|
+
for (const para of body.split('\n\n')) {
|
|
52
|
+
const s = para.split(/\s+/).filter(Boolean).join(' ');
|
|
53
|
+
if (s && !(s.startsWith('#') || s.startsWith('---') || s.startsWith('<!--'))) return s;
|
|
54
|
+
}
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Agent/skill specs read straight off a DEPLOYED harness dir.
|
|
60
|
+
*
|
|
61
|
+
* Agents are flat `<root>/<name>.md` (skipping `_*` templates); skills use OpenCode's
|
|
62
|
+
* folder layout `<root>/<name>/SKILL.md`. Matched case-INSENSITIVE on Windows via
|
|
63
|
+
* `normcase`, not a bare `endsWith`. The frontmatter is stripped because it is host
|
|
64
|
+
* plumbing rather than prose, which is what makes a deployed entry the same shape as a
|
|
65
|
+
* source-rendered one and every consumer indifferent to the origin.
|
|
66
|
+
*/
|
|
67
|
+
function specEntries(root, nested) {
|
|
68
|
+
const out = [];
|
|
69
|
+
if (!isDir(root)) return out;
|
|
70
|
+
let names;
|
|
71
|
+
try { names = readdirSync(root); } catch { return out; }
|
|
72
|
+
const files = nested
|
|
73
|
+
? names.sort(comparePaths).filter((n) => isDir(path.join(root, n)))
|
|
74
|
+
.map((n) => path.join(root, n, 'SKILL.md'))
|
|
75
|
+
: names.filter((n) => normcase(n).endsWith('.md') && !n.startsWith('_'))
|
|
76
|
+
.sort(comparePaths).map((n) => path.join(root, n));
|
|
77
|
+
for (const p of files) {
|
|
78
|
+
if (!isFile(p)) continue;
|
|
79
|
+
const [fm, body] = frontmatter(readMaybe(p) ?? '');
|
|
80
|
+
const name = nested ? path.basename(path.dirname(p)) : path.basename(p, '.md');
|
|
81
|
+
out.push({ name, desc: specDesc(fm, body), body, source: resolvePath(p) });
|
|
82
|
+
}
|
|
83
|
+
out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The agents and skills actually installed at `state.target`, not a fresh render of `src/`.
|
|
89
|
+
*
|
|
90
|
+
* All three constitutional tiers still come from the render: once deployed they live inside
|
|
91
|
+
* AGENT.md rather than as separate files, so the deployed arm replaces the two ENTITY rosters
|
|
92
|
+
* and none of the constitution. A deployed spec file carries neither a category nor a
|
|
93
|
+
* lifecycle status, so each is tagged BY NAME from the same two sources the source render
|
|
94
|
+
* uses — and an entity the registry does not know reads "personal" for both, which is the
|
|
95
|
+
* honest answer rather than a borrowed one: filing it under the `build` fallback would claim
|
|
96
|
+
* a Geneseed taxonomy slot it was never given.
|
|
97
|
+
*
|
|
98
|
+
* ⚠ THE PACK SELECTION IS THE ONE THING THE RENDER CANNOT KNOW. `renderAll` emits all four
|
|
99
|
+
* pack files whatever `cfg.doctrines` says — measured: `makeCfg({doctrines:['craft']})` yields
|
|
100
|
+
* the same 105 items — because the filtering happens when AGENT.md's §2 is assembled, not in
|
|
101
|
+
* the walk. So the catalogue is always whole, and which packs this INSTALL built in comes from
|
|
102
|
+
* its own carrier via `doctrinesForBuild`, which resolves "no marker" to every pack.
|
|
103
|
+
*/
|
|
104
|
+
function deployedInventory(state) {
|
|
105
|
+
const render = tuiInventory(state.theme, doctrinesForBuild(state.target),
|
|
106
|
+
excludedRulesOfDir(state.target));
|
|
107
|
+
const registry = loadRegistry();
|
|
108
|
+
const skills = specEntries(path.join(state.target, 'skills'), true);
|
|
109
|
+
for (const e of skills) {
|
|
110
|
+
e.status = entityStatus(registry, `skills/${e.name}`);
|
|
111
|
+
e.klass = e.status === 'personal' ? 'personal'
|
|
112
|
+
: (Object.hasOwn(SKILL_CLASS, e.name) ? SKILL_CLASS[e.name] : 'build');
|
|
113
|
+
}
|
|
114
|
+
const agents = specEntries(path.join(state.target, 'agents'), false);
|
|
115
|
+
for (const e of agents) e.status = entityStatus(registry, `agents/${e.name}`);
|
|
116
|
+
return { agents,
|
|
117
|
+
skills,
|
|
118
|
+
laws: render.laws,
|
|
119
|
+
ontology: render.ontology,
|
|
120
|
+
doctrines: render.doctrines,
|
|
121
|
+
theme: state.theme };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The deployed record when there is one, else the source render. */
|
|
125
|
+
export function inventoryFor(state) {
|
|
126
|
+
return deployed(state) ? deployedInventory(state) : tuiInventory(state.theme);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- the catalog stores overview counts ---------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Always `<target>/memory` — never the CWD-scanning resolver. The ONE copy: `actions.mjs` and
|
|
133
|
+
* `user-files.mjs` delete and promote facts in the same directory this lists.
|
|
134
|
+
*/
|
|
135
|
+
export const memoryDir = (state) => path.join(state.target, 'memory');
|
|
136
|
+
const notebookDir = (state) => path.join(state.target, 'notebook');
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Matched case-insensitively on Windows.
|
|
140
|
+
*
|
|
141
|
+
* `comparePaths` and NOT a bare `.sort()`: on Windows, filenames sort case-insensitively, so
|
|
142
|
+
* `MEMORY.md` sorts under `m` and lands after `a-fact.md`; JS's default comparator is UTF-16
|
|
143
|
+
* code units, so `M` (0x4D) sorts before `a` (0x61) and the whole catalog comes back in a
|
|
144
|
+
* different order. The `normcase` in the filter beside it only fixes matching, not
|
|
145
|
+
* ordering — this is the other half.
|
|
146
|
+
*/
|
|
147
|
+
function globMd(dir) {
|
|
148
|
+
if (!isDir(dir)) return [];
|
|
149
|
+
let names;
|
|
150
|
+
try { names = readdirSync(dir); } catch { return []; }
|
|
151
|
+
return names.filter((n) => normcase(n).endsWith('.md') && isFile(path.join(dir, n)))
|
|
152
|
+
.sort(comparePaths);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Filename without its extension — single owner, imported by `js/web/activity.mjs` too. */
|
|
156
|
+
export const stemOf = (name) => name.slice(0, name.length - path.extname(name).length);
|
|
157
|
+
|
|
158
|
+
export function memoryItems(state) {
|
|
159
|
+
const d = memoryDir(state);
|
|
160
|
+
if (!isDir(d)) return [];
|
|
161
|
+
return globMd(d).map((n) => {
|
|
162
|
+
const p = path.join(d, n);
|
|
163
|
+
const [fm] = frontmatter(readMaybe(p) ?? '');
|
|
164
|
+
const stem = stemOf(n);
|
|
165
|
+
return {
|
|
166
|
+
name: stem,
|
|
167
|
+
title: fm.has('name') ? fm.get('name') : stem,
|
|
168
|
+
desc: fm.has('description') ? fm.get('description') : '',
|
|
169
|
+
source: resolvePath(p),
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function notebookItems(state) {
|
|
175
|
+
const d = notebookDir(state);
|
|
176
|
+
if (!isDir(d)) return [];
|
|
177
|
+
return globMd(d).map((n) => ({
|
|
178
|
+
name: stemOf(n), title: stemOf(n), desc: '', source: resolvePath(path.join(d, n)),
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The two setup manifests, in listing order. */
|
|
183
|
+
const CONFIG_META = {
|
|
184
|
+
'context.json': ['Project context', 'what the agent loads for this project'],
|
|
185
|
+
'wiki.jsonc': ['Wiki manifest', 'your machine-wide knowledge base(s)'],
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export function configItems(state) {
|
|
189
|
+
const out = [];
|
|
190
|
+
for (const fname of ['context.json', 'wiki.jsonc']) {
|
|
191
|
+
const p = path.join(state.target, fname);
|
|
192
|
+
if (!isFile(p)) continue;
|
|
193
|
+
const [title, desc] = CONFIG_META[fname] ?? [fname, ''];
|
|
194
|
+
out.push({ name: fname, title, desc, type: 'config', kind: 'manifest',
|
|
195
|
+
source: resolvePath(p) });
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const WIKI_FILE_CAP = 5000;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `resolvePath` for a path that came out of a HAND-MAINTAINED manifest — `null`, never a throw.
|
|
204
|
+
*
|
|
205
|
+
* The three wiki sites below, and `apiDeployCmd` in `js/web/actions.mjs`, are the
|
|
206
|
+
* request-path callers of `expanduser`, and it REFUSES a `~user` form by printing and throwing
|
|
207
|
+
* (`js/hosts/hosts.mjs`'s docblock). None of them may let that escape: `js/web/handler.mjs`
|
|
208
|
+
* wraps the whole GET in a blanket `catch` → a JSON 500, so an unguarded refusal would turn
|
|
209
|
+
* ONE bad line in a file the user hand-edits into a dead Knowledge section. That is the same
|
|
210
|
+
* argument `sovereignBypass` makes in `js/hosts/hosts.mjs` — contain the refusal per entry
|
|
211
|
+
* and take the site's OWN degrade — and each of the three already has one: `[]`, `continue`,
|
|
212
|
+
* `continue`-into-404.
|
|
213
|
+
*
|
|
214
|
+
* `withDiscardableStderr` because `expanduser` prints its refusal at the RAISE SITE, and
|
|
215
|
+
* this caller wants that swallowed along with the exception rather than logged.
|
|
216
|
+
*
|
|
217
|
+
* DELIBERATE PRODUCT DECISION: a `~otherUser` path is refused rather than resolved, even
|
|
218
|
+
* where the account happens to exist — reading another account's files through a
|
|
219
|
+
* hand-edited wiki manifest is not a feature.
|
|
220
|
+
*/
|
|
221
|
+
function wikiPath(p) {
|
|
222
|
+
try {
|
|
223
|
+
return withDiscardableStderr(() => resolvePath(p));
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* `$GENESEED_WIKI` first, else `wiki.jsonc` beside the deployed bundle, read with the
|
|
231
|
+
* harness's generic JSONC loader.
|
|
232
|
+
*
|
|
233
|
+
* `resolvePath`, not merely `expanduser`: harmless here because `p` is consumed by `isFile`
|
|
234
|
+
* and `mcpLoad` and never reaches a response body, and both of those follow symlinks and
|
|
235
|
+
* resolve a relative path against the same cwd anyway.
|
|
236
|
+
*/
|
|
237
|
+
function wikiManifest(state) {
|
|
238
|
+
const cand = process.env.GENESEED_WIKI;
|
|
239
|
+
const p = cand ? wikiPath(cand) : path.join(state.target, 'wiki.jsonc');
|
|
240
|
+
if (!p || !isFile(p)) return [];
|
|
241
|
+
const cfg = mcpLoad(p);
|
|
242
|
+
const wikis = cfg.wikis;
|
|
243
|
+
return Array.isArray(wikis) ? wikis : [];
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The COMMENT-TOLERANT dict loader, `{}` for a file that is missing, unreadable,
|
|
248
|
+
* unparseable, or not an object.
|
|
249
|
+
*
|
|
250
|
+
* Comment-tolerant is the whole point, and getting this wrong is silent: the two files it
|
|
251
|
+
* reads are `wiki.jsonc` and `context.json`, hand-maintained, so a `//` line is expected.
|
|
252
|
+
* Plain `JSON.parse` would throw on it and this loader would then answer `{}` — the wiki
|
|
253
|
+
* section listing nothing and the config item's `manifest` coming back empty, with no
|
|
254
|
+
* error surfaced anywhere.
|
|
255
|
+
*/
|
|
256
|
+
function mcpLoad(p) {
|
|
257
|
+
if (!isFile(p)) return {};
|
|
258
|
+
let text;
|
|
259
|
+
try { text = readText(p); } catch { return {}; }
|
|
260
|
+
const [data] = readJsonc(text);
|
|
261
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Every `.md` under `dir`, recursively.
|
|
266
|
+
*
|
|
267
|
+
* `recursive: true` replaces a hand-rolled walk; its one caller re-sorts the result with
|
|
268
|
+
* `comparePaths` before using it, so the order this returns is never observed.
|
|
269
|
+
*/
|
|
270
|
+
function rglobMd(dir) {
|
|
271
|
+
let entries;
|
|
272
|
+
try { entries = readdirSync(dir, { recursive: true, withFileTypes: true }); } catch { return []; }
|
|
273
|
+
return entries
|
|
274
|
+
.filter((e) => e.isFile() && normcase(e.name).endsWith('.md'))
|
|
275
|
+
.map((e) => path.join(e.parentPath, e.name));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function wikiItems(state) {
|
|
279
|
+
const items = [];
|
|
280
|
+
const seen = new Set();
|
|
281
|
+
for (const w of wikiManifest(state)) {
|
|
282
|
+
if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
|
|
283
|
+
const wname = String(dget(w, 'name', null) || 'wiki');
|
|
284
|
+
// PER ENTRY, and the loop continues — one unusable `path` in the manifest must not
|
|
285
|
+
// blank every OTHER vault in it.
|
|
286
|
+
const root = wikiPath(String(dget(w, 'path', null) || ''));
|
|
287
|
+
if (!root || !isDir(root)) continue;
|
|
288
|
+
// A hand-edited `entries` that is not a list (`{}`, a string) is a malformed vault, not a
|
|
289
|
+
// crash: `/api/status` counts these, and one bad manifest must not fail the dashboard.
|
|
290
|
+
const rawEntries = dget(w, 'entries', null);
|
|
291
|
+
const entries = (Array.isArray(rawEntries) ? rawEntries : [])
|
|
292
|
+
.filter((e) => e && typeof e === 'object' && !Array.isArray(e));
|
|
293
|
+
const excludes = entries.filter((e) => dget(e, 'load', null) === 'exclude')
|
|
294
|
+
.map((e) => String(dget(e, 'path', null) || '').replace(/^\/+|\/+$/g, '').replace(/\\/g, '/'));
|
|
295
|
+
const excluded = (rel) => excludes.some((x) => x && (rel === x || rel.startsWith(`${x}/`)));
|
|
296
|
+
|
|
297
|
+
for (const e of entries) {
|
|
298
|
+
if (dget(e, 'load', null) === 'exclude') continue;
|
|
299
|
+
const rel = String(dget(e, 'path', null) || '').replace(/^\/+|\/+$/g, '').replace(/\\/g, '/');
|
|
300
|
+
const desc = String(dget(e, 'description', null) || '');
|
|
301
|
+
const fp = path.join(root, rel);
|
|
302
|
+
let mds;
|
|
303
|
+
if (isFile(fp) && path.extname(fp) === '.md') mds = [fp];
|
|
304
|
+
// Capped and sorted via `comparePaths`, for the reason `globMd` carries.
|
|
305
|
+
else if (isDir(fp)) mds = rglobMd(fp).sort(comparePaths).slice(0, WIKI_FILE_CAP);
|
|
306
|
+
else continue;
|
|
307
|
+
for (const md of mds) {
|
|
308
|
+
const r = path.relative(root, md).split(path.sep).join('/');
|
|
309
|
+
const key = `${wname}:${r}`;
|
|
310
|
+
if (seen.has(key) || excluded(r)) continue;
|
|
311
|
+
seen.add(key);
|
|
312
|
+
items.push({ name: key, title: path.basename(md, '.md'),
|
|
313
|
+
desc: mds.length === 1 ? desc : r, type: 'wiki', kind: 'page', group: wname,
|
|
314
|
+
source: resolvePath(md) });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return items;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** One page by `<wiki>:<relpath>`, never outside the vault. */
|
|
322
|
+
function apiWikiItem(state, name) {
|
|
323
|
+
const at = name.indexOf(':');
|
|
324
|
+
const wname = at < 0 ? name : name.slice(0, at);
|
|
325
|
+
const rel = (at < 0 ? '' : name.slice(at + 1))
|
|
326
|
+
.replace(/^\/+|\/+$/g, '').replace(/\\/g, '/');
|
|
327
|
+
for (const w of wikiManifest(state)) {
|
|
328
|
+
if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
|
|
329
|
+
if (String(dget(w, 'name', null) || 'wiki') !== wname) continue;
|
|
330
|
+
// An unresolvable or refused root falls through to the `NotFound` below, the same as
|
|
331
|
+
// one that simply does not exist.
|
|
332
|
+
const root = wikiPath(String(dget(w, 'path', null) || ''));
|
|
333
|
+
if (!root) continue;
|
|
334
|
+
const p = resolvePath(path.join(root, rel));
|
|
335
|
+
if (rel && path.extname(p) === '.md' && within(p, root) && isFile(p)) {
|
|
336
|
+
const body = readMaybe(p) ?? '';
|
|
337
|
+
return { type: 'wiki', name, title: path.basename(p, '.md'), desc: '',
|
|
338
|
+
body, links: resolveLinks(state, body), source: p };
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
throw new NotFound(name);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Catalog names are flat basenames.
|
|
347
|
+
*
|
|
348
|
+
* A separator, a `..` or a drive colon in the URL segment is someone steering the join
|
|
349
|
+
* outside the catalog dir. A GET carries no token, so before this check the endpoint was
|
|
350
|
+
* an arbitrary-file read; it raises rather than resolving.
|
|
351
|
+
*/
|
|
352
|
+
function flatName(name) {
|
|
353
|
+
if (!name || name.includes('/') || name.includes('\\') || name.includes('..')
|
|
354
|
+
|| name.includes(':')) {
|
|
355
|
+
throw new NotFound(name);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* `[[name]]` matched against known agent and skill names.
|
|
363
|
+
*
|
|
364
|
+
* AGENTS FIRST, SKILLS SECOND, into ONE map — so a name that is both resolves as a SKILL.
|
|
365
|
+
* This ordering is deliberate and must not be swapped.
|
|
366
|
+
*/
|
|
367
|
+
export function resolveLinks(state, body) {
|
|
368
|
+
const inv = state.inventory;
|
|
369
|
+
const known = new Map();
|
|
370
|
+
for (const e of inv.agents) known.set(e.name, 'agent');
|
|
371
|
+
for (const e of inv.skills) known.set(e.name, 'skill');
|
|
372
|
+
const links = [];
|
|
373
|
+
const seen = new Set();
|
|
374
|
+
for (const m of body.matchAll(WIKILINK_RE)) {
|
|
375
|
+
// `stripWhitespace`, not `trim()` — the two whitespace classes differ, and both call
|
|
376
|
+
// sites on this regex must answer with the same rule.
|
|
377
|
+
const label = stripWhitespace(m[1]);
|
|
378
|
+
if (known.has(label) && !seen.has(label)) {
|
|
379
|
+
seen.add(label);
|
|
380
|
+
links.push({ label, type: known.get(label), name: label });
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return links;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A setup file parsed into the shape the detail pane renders as cards, or `null` so the
|
|
388
|
+
* caller falls back to the raw body.
|
|
389
|
+
*/
|
|
390
|
+
function configManifest(name, p) {
|
|
391
|
+
const cfg = mcpLoad(p);
|
|
392
|
+
if (name === 'wiki.jsonc') {
|
|
393
|
+
const wikis = cfg.wikis;
|
|
394
|
+
return { kind: 'wiki', wikis: Array.isArray(wikis) ? wikis : [] };
|
|
395
|
+
}
|
|
396
|
+
if (name === 'context.json') {
|
|
397
|
+
const ctx = cfg.context;
|
|
398
|
+
return { kind: 'context', context: Array.isArray(ctx) ? ctx : [] };
|
|
399
|
+
}
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* A closed list; anything else is a 404.
|
|
405
|
+
*
|
|
406
|
+
* ⚠ `laws` STAYS ONE SECTION FOR ALL THREE TIERS, and that is a decision rather than an
|
|
407
|
+
* oversight. The console has ONE Constitution entry, not three, so a reader keeps the "read it
|
|
408
|
+
* top to bottom" property; the id stays `laws` because `tests/helpers/cli_golden.mjs`
|
|
409
|
+
* hard-requires `web/src/pages/Laws.jsx` and because a route rename buys nothing and costs a
|
|
410
|
+
* redirect. The tier lives on the ITEM (`tier: 'ontology' | 'invariant' | 'doctrine'`), which
|
|
411
|
+
* is where a consumer can act on it.
|
|
412
|
+
*/
|
|
413
|
+
export const SECTIONS = ['agents', 'skills', 'laws', 'memory', 'notebook', 'wiki', 'config'];
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* The `laws` section's items — the whole constitution, in constitutional order, one flat list.
|
|
417
|
+
*
|
|
418
|
+
* FLAT AND NOT NESTED, because every other section is `{section, items}` and a second shape
|
|
419
|
+
* for one section is a second client path for one roster. The pack's grouping metadata rides
|
|
420
|
+
* on each doctrine row (`pack`, `packTitle`, `packDesc`, `active`), so a consumer groups by
|
|
421
|
+
* `pack` and has everything a header needs without a second fetch.
|
|
422
|
+
*
|
|
423
|
+
* Names are ADDRESSES and the three shapes cannot collide: `ont:<slug>` carries a colon no
|
|
424
|
+
* numeral has, an invariant is `[IVXLCDM]+`, a doctrine rule is `<pack>.<n>`. `apiItem`
|
|
425
|
+
* resolves all three off the same list.
|
|
426
|
+
*/
|
|
427
|
+
function constitutionItems(inv) {
|
|
428
|
+
return [
|
|
429
|
+
...(inv.ontology ?? []).map((e) => ({ name: `ont:${e.id}`, title: e.title, desc: '',
|
|
430
|
+
tier: 'ontology' })),
|
|
431
|
+
...inv.laws.map((e) => ({ name: e.num, title: `Rule ${e.num} — ${e.title}`, desc: '',
|
|
432
|
+
klass: e.klass ?? 'craft', tier: 'invariant' })),
|
|
433
|
+
...(inv.doctrines ?? []).flatMap((p) => p.rules.map((r) => ({
|
|
434
|
+
name: `${r.pack}.${r.n}`,
|
|
435
|
+
title: `Doctrine ${r.pack} ${r.n} — ${r.title}`,
|
|
436
|
+
desc: '',
|
|
437
|
+
klass: r.klass ?? p.pack,
|
|
438
|
+
tier: 'doctrine',
|
|
439
|
+
pack: p.pack,
|
|
440
|
+
packTitle: p.title,
|
|
441
|
+
packDesc: p.desc,
|
|
442
|
+
// ⚠ EVERY RULE IS LISTED WHETHER OR NOT IT IS BUILT IN, and `active` is the RULE's own
|
|
443
|
+
// — a pack can be on with one rule excluded. A row that vanished from the payload would
|
|
444
|
+
// be indistinguishable from one that never shipped, and the console could not then
|
|
445
|
+
// offer the switch that turns it back on.
|
|
446
|
+
active: r.active !== false,
|
|
447
|
+
packActive: p.active,
|
|
448
|
+
}))),
|
|
449
|
+
];
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function apiCatalog(state, section) {
|
|
453
|
+
if (!SECTIONS.includes(section)) throw new NotFound(section);
|
|
454
|
+
const inv = state.inventory;
|
|
455
|
+
let items;
|
|
456
|
+
if (section === 'agents') {
|
|
457
|
+
items = inv.agents.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
|
|
458
|
+
source: e.source ?? null, status: e.status ?? 'unknown' }));
|
|
459
|
+
} else if (section === 'skills') {
|
|
460
|
+
items = inv.skills.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
|
|
461
|
+
source: e.source ?? null, klass: e.klass ?? 'build', status: e.status ?? 'unknown' }));
|
|
462
|
+
} else if (section === 'laws') {
|
|
463
|
+
items = constitutionItems(inv);
|
|
464
|
+
} else if (section === 'memory') {
|
|
465
|
+
items = memoryItems(state);
|
|
466
|
+
} else if (section === 'notebook') {
|
|
467
|
+
items = notebookItems(state);
|
|
468
|
+
} else if (section === 'wiki') {
|
|
469
|
+
items = wikiItems(state);
|
|
470
|
+
} else {
|
|
471
|
+
items = configItems(state);
|
|
472
|
+
}
|
|
473
|
+
return { section, items };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
export function apiItem(state, type, name) {
|
|
477
|
+
const inv = state.inventory;
|
|
478
|
+
if (type === 'agent' || type === 'skill') {
|
|
479
|
+
const e = (type === 'agent' ? inv.agents : inv.skills).find((x) => x.name === name);
|
|
480
|
+
if (!e) throw new NotFound(name);
|
|
481
|
+
const out = { type, name, title: name, desc: e.desc, body: e.body,
|
|
482
|
+
links: resolveLinks(state, e.body), source: e.source ?? null };
|
|
483
|
+
if (type === 'skill') out.klass = e.klass ?? 'build';
|
|
484
|
+
out.status = e.status ?? 'unknown';
|
|
485
|
+
return out;
|
|
486
|
+
}
|
|
487
|
+
if (type === 'law') {
|
|
488
|
+
// ONE ARM FOR THREE TIERS, resolved off the same list the catalogue publishes — so a name
|
|
489
|
+
// that appears in `/api/catalog/laws` always opens, and one that does not always 404s.
|
|
490
|
+
// Building the list twice is what would let the two drift.
|
|
491
|
+
const e = constitutionItems(inv).find((x) => x.name === name);
|
|
492
|
+
if (!e) throw new NotFound(name);
|
|
493
|
+
const body = e.tier === 'ontology'
|
|
494
|
+
? (inv.ontology.find((s) => `ont:${s.id}` === name)?.body ?? '')
|
|
495
|
+
: (e.tier === 'invariant'
|
|
496
|
+
? (inv.laws.find((x) => x.num === name)?.body ?? '')
|
|
497
|
+
: (inv.doctrines.flatMap((p) => p.rules)
|
|
498
|
+
.find((r) => `${r.pack}.${r.n}` === name)?.body ?? ''));
|
|
499
|
+
// `links: []` — a constitution body is not link-resolved. It cites its siblings by
|
|
500
|
+
// ADDRESS (`Rule IV`, `Doctrine ops 1`, `Ontology: Telos`), not by path, and there is no
|
|
501
|
+
// file behind those to resolve to.
|
|
502
|
+
return { ...e, type, name, body, links: [] };
|
|
503
|
+
}
|
|
504
|
+
if (type === 'memory' || type === 'notebook') {
|
|
505
|
+
flatName(name);
|
|
506
|
+
const d = type === 'notebook' ? notebookDir(state) : memoryDir(state);
|
|
507
|
+
const p = path.join(d, `${name}.md`);
|
|
508
|
+
if (!isFile(p)) throw new NotFound(name);
|
|
509
|
+
const body = readMaybe(p) ?? '';
|
|
510
|
+
return { type, name, title: name, desc: '', body,
|
|
511
|
+
links: resolveLinks(state, body), source: resolvePath(p) };
|
|
512
|
+
}
|
|
513
|
+
if (type === 'wiki') return apiWikiItem(state, name);
|
|
514
|
+
if (type === 'config') {
|
|
515
|
+
// THE ALLOWLIST, NOT `flatName`: a flat name still reached every top-level file of the
|
|
516
|
+
// install — `.geneseed-web.json` (this daemon's own CSRF token) and `settings.json` among
|
|
517
|
+
// them — over a GET that carries no token. Only the manifests the catalogue lists can open.
|
|
518
|
+
if (!Object.hasOwn(CONFIG_META, name)) throw new NotFound(name);
|
|
519
|
+
const p = path.join(state.target, name);
|
|
520
|
+
if (!isFile(p)) throw new NotFound(name);
|
|
521
|
+
const raw = readMaybe(p) ?? '';
|
|
522
|
+
const [title, desc] = CONFIG_META[name];
|
|
523
|
+
return { type, name, title, desc, manifest: configManifest(name, p),
|
|
524
|
+
body: `\`\`\`json\n${raw}\n\`\`\``, links: [], source: resolvePath(p) };
|
|
525
|
+
}
|
|
526
|
+
throw new NotFound(type);
|
|
527
|
+
}
|
package/js/web/daemon.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { printOut, writeText } from '../lib/fs.mjs';
|
|
13
13
|
import { isTruthy, jsonDumpsCompact } from '../lib/json.mjs';
|
|
14
|
-
import { webState } from './
|
|
14
|
+
import { webState } from './state.mjs';
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
17
|
import { chmodSync, mkdirSync, openSync, readFileSync, unlinkSync } from 'node:fs';
|
|
@@ -190,11 +190,18 @@ export function restartArgs(theme) {
|
|
|
190
190
|
|
|
191
191
|
/**
|
|
192
192
|
* A detached `web restart`, so the new server survives the exit of the very process asking
|
|
193
|
-
* for it. Called by POST `/api/restart
|
|
193
|
+
* for it. Called by POST `/api/restart` and after a successful update job.
|
|
194
|
+
*
|
|
195
|
+
* ONLY FROM THE DAEMON THE RECORD NAMES — `false`, and nothing spawned, otherwise. A plain
|
|
196
|
+
* `geneseed web` serves in the foreground and writes no record, so `web restart` found nothing
|
|
197
|
+
* to stop, started a fresh daemon on 4747 while this process still held it, lost the bind,
|
|
198
|
+
* fell back to a random port, and left a background console nobody had asked for.
|
|
194
199
|
*/
|
|
195
200
|
export function requestRestart(theme) {
|
|
196
201
|
const target = webState(theme).target;
|
|
202
|
+
if (readDaemon(target)?.pid !== process.pid) return false;
|
|
197
203
|
spawnDetached(restartArgs(theme), join(target, '.geneseed-web.log'));
|
|
204
|
+
return true;
|
|
198
205
|
}
|
|
199
206
|
|
|
200
207
|
/**
|
|
@@ -315,6 +322,14 @@ export async function stopDaemon(theme = null, port = null) {
|
|
|
315
322
|
printOut(`[web] stopped (pid ${none(st.pid)}).\n`);
|
|
316
323
|
return 0;
|
|
317
324
|
}
|
|
325
|
+
// A FAILED SHUTDOWN IS NOT A DEAD SERVER. The POST times out at 3 s, a synchronous handler
|
|
326
|
+
// (a doctor run) can hold the event loop longer, and a console with a job running answers
|
|
327
|
+
// 409 — deleting the record then left a live daemon nothing could find again.
|
|
328
|
+
if (await probe(st.url)) {
|
|
329
|
+
printOut(`[web] the server on ${st.url} did not stop (pid ${none(st.pid)}) — it may be `
|
|
330
|
+
+ 'busy with a job; try again once it finishes.\n');
|
|
331
|
+
return 1;
|
|
332
|
+
}
|
|
318
333
|
clearDaemon(target);
|
|
319
334
|
printOut('[web] no live server (cleared a stale record).\n');
|
|
320
335
|
return 0;
|
|
@@ -378,7 +393,10 @@ export async function restartDaemon(theme = null, port = null, openBrowser = tru
|
|
|
378
393
|
// A live daemon means a tab is already open on this (preserved) port and will
|
|
379
394
|
// reconnect on its own — no need to open a fresh browser window here.
|
|
380
395
|
open = false;
|
|
381
|
-
|
|
396
|
+
// A daemon that refused to stop is still on the port: starting another would find it live
|
|
397
|
+
// and report "already running" over a restart that never happened.
|
|
398
|
+
const stopped = await stopDaemon(theme);
|
|
399
|
+
if (stopped !== 0) return stopped;
|
|
382
400
|
for (let i = 0; i < 50; i += 1) {
|
|
383
401
|
|
|
384
402
|
if (!await probe(`http://127.0.0.1:${usePort}`, 0.2)) break;
|