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
package/js/web/api.mjs
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The web console's
|
|
3
|
-
* `
|
|
4
|
-
* all eleven read from.
|
|
2
|
+
* The web console's dashboard and settings reads — `overview`, `recent`, `themes`, `setup`,
|
|
3
|
+
* `doctor`, `diff`, `installs`, `excludes` and the two `loops` reads.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* WHAT IS NOT HERE, AND WHERE IT WENT: the Library read model (catalog sections, items, wiki,
|
|
6
|
+
* memory, `NotFound`) is `catalog.mjs`; the `WebState` every endpoint reads from is `state.mjs`;
|
|
7
|
+
* the fingerprint-guarded rules/profile editors are `user-files.mjs`; and the GET tables that
|
|
8
|
+
* bind paths to these functions are `routes.mjs`. This file imports none of its route siblings,
|
|
9
|
+
* which is what keeps the old `api ↔ actions` and `api ↔ activity` cycles from coming back.
|
|
11
10
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
11
|
+
import { statSync } from 'node:fs';
|
|
14
12
|
import path from 'node:path';
|
|
15
13
|
|
|
16
14
|
import { CONFIG, ROOT, THEMES, discoverNames } from '../build/source.mjs';
|
|
@@ -18,661 +16,31 @@ import { sourceReleaseVersion } from '../hosts/opencode.mjs';
|
|
|
18
16
|
import { diffCollect } from '../inspect/diff.mjs';
|
|
19
17
|
import { doctorCollect } from '../inspect/doctor.mjs';
|
|
20
18
|
import { excludesSnapshot } from '../inspect/excludes.mjs';
|
|
21
|
-
import { CLAUDE_STYLE,
|
|
19
|
+
import { CLAUDE_STYLE, HOSTS, resolvePath } from '../hosts/hosts.mjs';
|
|
22
20
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
|
|
21
|
+
footprintOfDir, installState, installTargets, modeOfDir, trustOfDir,
|
|
22
|
+
postureOfDir, readJsonMaybe, themeOfDir,
|
|
26
23
|
} from '../hosts/installs.mjs';
|
|
27
|
-
import {
|
|
28
|
-
import { DEFAULT_PRESET, PRESETS } from '../loop/score.mjs';
|
|
24
|
+
import { PRESETS } from '../loop/score.mjs';
|
|
29
25
|
import { loadCatalog, catalogProblems } from '../loop/catalog.mjs';
|
|
30
26
|
import { activeLoops } from '../loop/registry.mjs';
|
|
31
|
-
import {
|
|
32
|
-
SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
|
|
33
|
-
} from '../inspect/inventory.mjs';
|
|
34
|
-
import { firstBlockquote } from '../hosts/native.mjs';
|
|
35
27
|
import { EMIT_OPTIONS, themeOptions } from '../maintain/setup.mjs';
|
|
36
28
|
import { accentFor, statusData } from '../inspect/status.mjs';
|
|
37
|
-
import {
|
|
38
|
-
import { comparePaths, normcase
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
import {
|
|
43
|
-
import { apiMcp, apiRules } from './actions.mjs';
|
|
44
|
-
|
|
45
|
-
/** A requested catalog section or item that does not exist. */
|
|
46
|
-
export class NotFound extends Error {}
|
|
47
|
-
|
|
48
|
-
/** Two resolved paths, compared case-folded on Windows. */
|
|
49
|
-
const samePath = (a, b) => normcase(resolvePath(a)) === normcase(resolvePath(b));
|
|
50
|
-
|
|
51
|
-
/** `??` would also swallow an explicit `null` the file declared; this does not. */
|
|
52
|
-
const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
|
|
53
|
-
|
|
54
|
-
/** SHA-256 hex, first 16 chars — `""` for an empty file. */
|
|
55
|
-
export function fingerprint(text) {
|
|
56
|
-
return text ? createHash('sha256').update(text, 'utf-8').digest('hex').slice(0, 16) : '';
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* `YYYY-MM-DD HH:MM`, LOCAL time — `toISOString` would be UTC, which reads wrong in every
|
|
61
|
-
* timezone but one for a user-facing timestamp.
|
|
62
|
-
*/
|
|
63
|
-
export function stampMinute(ms) {
|
|
64
|
-
const d = new Date(ms);
|
|
65
|
-
const p2 = (n) => String(n).padStart(2, '0');
|
|
66
|
-
return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} `
|
|
67
|
-
+ `${p2(d.getHours())}:${p2(d.getMinutes())}`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// ---- WebState ---------------------------------------------------------------------------
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* The resolved view of the deployed harness a request reads from.
|
|
74
|
-
*
|
|
75
|
-
* A factory returning a plain object rather than a class, because the two cached
|
|
76
|
-
* properties are the only behaviour and a getter pair expresses them without ceremony.
|
|
77
|
-
* THE CACHING IS BEHAVIOUR, NOT AN OPTIMISATION: `doctorCollect` renders a theme per call
|
|
78
|
-
* and the dashboard GETs `/api/overview` on every navigation, so an uncached `doctor`
|
|
79
|
-
* would put a full build on the request path. `refresh()` is what a mutation calls to
|
|
80
|
-
* drop both.
|
|
81
|
-
*/
|
|
82
|
-
export function webState(theme = null, target = null) {
|
|
83
|
-
const st = {
|
|
84
|
-
// NOT resolved: `target` is printed by three endpoints, so resolving it here would
|
|
85
|
-
// change what they answer.
|
|
86
|
-
target: target || opencodeConfigDir(),
|
|
87
|
-
root: null,
|
|
88
|
-
theme: null,
|
|
89
|
-
emit: null,
|
|
90
|
-
footprint: null,
|
|
91
|
-
posture: null,
|
|
92
|
-
mode: null,
|
|
93
|
-
trust: null,
|
|
94
|
-
_inv: null,
|
|
95
|
-
_doctor: null,
|
|
96
|
-
};
|
|
97
|
-
// The INSTALL ROOT (== build --out). For globals it IS the data dir; a claude/bob
|
|
98
|
-
// PROJECT install keeps its data under <repo>/.claude while the markers land at <repo>/.
|
|
99
|
-
st.root = st.target;
|
|
100
|
-
st.theme = theme || themeOfDir(st.target) || 'neutral';
|
|
101
|
-
st.emit = installedDefaults().emit || 'opencode-global';
|
|
102
|
-
// The install's sigils, each defaulted when its marker is absent (footprint to 'full').
|
|
103
|
-
// Read from the ROOT, where every emit writes them.
|
|
104
|
-
const readMarkers = () => {
|
|
105
|
-
st.footprint = footprintOfDir(st.root);
|
|
106
|
-
st.posture = postureOfDir(st.root) || 'peer';
|
|
107
|
-
st.mode = modeOfDir(st.root) || 'direct';
|
|
108
|
-
st.trust = trustOfDir(st.root) || DEFAULT_PRESET;
|
|
109
|
-
};
|
|
110
|
-
readMarkers();
|
|
111
|
-
|
|
112
|
-
Object.defineProperty(st, 'inventory', {
|
|
113
|
-
get() {
|
|
114
|
-
if (st._inv === null) st._inv = inventoryFor(st);
|
|
115
|
-
return st._inv;
|
|
116
|
-
},
|
|
117
|
-
});
|
|
118
|
-
Object.defineProperty(st, 'doctor', {
|
|
119
|
-
get() {
|
|
120
|
-
if (st._doctor === null) {
|
|
121
|
-
const [, problems] = doctorCollect({ theme: st.theme });
|
|
122
|
-
st.stampDoctor(problems);
|
|
123
|
-
}
|
|
124
|
-
return st._doctor;
|
|
125
|
-
},
|
|
126
|
-
});
|
|
127
|
-
st.stampDoctor = (problems) => {
|
|
128
|
-
st._doctor = { ok: !problems.length, problems, checked_at: stampMinute(Date.now()) };
|
|
129
|
-
};
|
|
130
|
-
/**
|
|
131
|
-
* The CURRENT install's mode, from its `.geneseed-emit` marker: the ROOT first (where
|
|
132
|
-
* every emit writes it), then the data dir.
|
|
133
|
-
*
|
|
134
|
-
* `refresh()` below MUST call this: without it, `emit` goes stale after any write that
|
|
135
|
-
* re-themes or re-points the install, since the constructor only reads it once via
|
|
136
|
-
* `installedDefaults()`.
|
|
137
|
-
*/
|
|
138
|
-
st.detectEmit = () => {
|
|
139
|
-
for (const d of [st.root, st.target]) {
|
|
140
|
-
try {
|
|
141
|
-
const em = path.join(d, '.geneseed-emit');
|
|
142
|
-
if (isFile(em)) {
|
|
143
|
-
const v = stripWhitespace(readText(em));
|
|
144
|
-
if (v) return v;
|
|
145
|
-
}
|
|
146
|
-
} catch { /* unreadable marker: try the next candidate */ }
|
|
147
|
-
}
|
|
148
|
-
return existsSync(path.join(st.root, 'CLAUDE.md')) ? 'claude-global' : 'opencode-global';
|
|
149
|
-
};
|
|
150
|
-
/**
|
|
151
|
-
* Re-point the console at another detected install's data dir.
|
|
152
|
-
*
|
|
153
|
-
* `root` is the install ROOT the markers and sigils live at. It defaults to `target` and
|
|
154
|
-
* differs only for claude/bob/openclaude PROJECT installs, where the data sits under
|
|
155
|
-
* `<repo>/.claude|.bob|.openclaude` while `.geneseed-emit`/`-theme`/`-footprint` land at
|
|
156
|
-
* `<repo>/` — reading them from the data dir mis-detects the install as opencode/neutral,
|
|
157
|
-
* and a Diff or a Restore would then overwrite it in the wrong dialect.
|
|
158
|
-
*/
|
|
159
|
-
st.selectView = (target, root = null) => {
|
|
160
|
-
st.target = target;
|
|
161
|
-
st.root = root || target;
|
|
162
|
-
st.theme = themeOfDir(st.root) || themeOfDir(st.target) || 'neutral';
|
|
163
|
-
st.emit = st.detectEmit();
|
|
164
|
-
readMarkers();
|
|
165
|
-
st._inv = null;
|
|
166
|
-
st._doctor = null;
|
|
167
|
-
};
|
|
168
|
-
st.refresh = () => {
|
|
169
|
-
st._inv = null;
|
|
170
|
-
st._doctor = null;
|
|
171
|
-
st.theme = themeOfDir(st.root) || themeOfDir(st.target) || st.theme;
|
|
172
|
-
st.emit = st.detectEmit() || st.emit;
|
|
173
|
-
readMarkers();
|
|
174
|
-
};
|
|
175
|
-
return st;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export function deployed(state) {
|
|
179
|
-
return existsSync(path.join(state.target, GLOBAL_MANIFEST));
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* A deployed spec's one-line purpose.
|
|
184
|
-
*
|
|
185
|
-
* The `> blockquote` convention every rendered skill and agent carries, then the
|
|
186
|
-
* frontmatter `description`, then the first prose paragraph. The fallbacks are for the
|
|
187
|
-
* VENDORED skill folders, which ride in verbatim with no blockquote and would otherwise
|
|
188
|
-
* show a blank Purpose cell.
|
|
189
|
-
*/
|
|
190
|
-
export function specDesc(fm, body) {
|
|
191
|
-
const bq = firstBlockquote(body);
|
|
192
|
-
if (bq) return bq;
|
|
193
|
-
const desc = String(fm.has('description') ? fm.get('description') : '').trim();
|
|
194
|
-
if (desc) return desc.split(/\s+/).filter(Boolean).join(' ');
|
|
195
|
-
for (const para of body.split('\n\n')) {
|
|
196
|
-
const s = para.split(/\s+/).filter(Boolean).join(' ');
|
|
197
|
-
if (s && !(s.startsWith('#') || s.startsWith('---') || s.startsWith('<!--'))) return s;
|
|
198
|
-
}
|
|
199
|
-
return '';
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Agent/skill specs read straight off a DEPLOYED harness dir.
|
|
204
|
-
*
|
|
205
|
-
* Agents are flat `<root>/<name>.md` (skipping `_*` templates); skills use OpenCode's
|
|
206
|
-
* folder layout `<root>/<name>/SKILL.md`. Matched case-INSENSITIVE on Windows via
|
|
207
|
-
* `normcase`, not a bare `endsWith`. The frontmatter is stripped because it is host
|
|
208
|
-
* plumbing rather than prose, which is what makes a deployed entry the same shape as a
|
|
209
|
-
* source-rendered one and every consumer indifferent to the origin.
|
|
210
|
-
*/
|
|
211
|
-
function specEntries(root, nested) {
|
|
212
|
-
const out = [];
|
|
213
|
-
if (!isDir(root)) return out;
|
|
214
|
-
let names;
|
|
215
|
-
try { names = readdirSync(root); } catch { return out; }
|
|
216
|
-
const files = nested
|
|
217
|
-
? names.sort(comparePaths).filter((n) => isDir(path.join(root, n)))
|
|
218
|
-
.map((n) => path.join(root, n, 'SKILL.md'))
|
|
219
|
-
: names.filter((n) => normcase(n).endsWith('.md') && !n.startsWith('_'))
|
|
220
|
-
.sort(comparePaths).map((n) => path.join(root, n));
|
|
221
|
-
for (const p of files) {
|
|
222
|
-
if (!isFile(p)) continue;
|
|
223
|
-
const [fm, body] = frontmatter(readMaybe(p) ?? '');
|
|
224
|
-
const name = nested ? path.basename(path.dirname(p)) : path.basename(p, '.md');
|
|
225
|
-
out.push({ name, desc: specDesc(fm, body), body, source: resolvePath(p) });
|
|
226
|
-
}
|
|
227
|
-
out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
228
|
-
return out;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/**
|
|
232
|
-
* The agents and skills actually installed at `state.target`, not a fresh render of `src/`.
|
|
233
|
-
*
|
|
234
|
-
* All three constitutional tiers still come from the render: once deployed they live inside
|
|
235
|
-
* AGENT.md rather than as separate files, so the deployed arm replaces the two ENTITY rosters
|
|
236
|
-
* and none of the constitution. A deployed spec file carries neither a category nor a
|
|
237
|
-
* lifecycle status, so each is tagged BY NAME from the same two sources the source render
|
|
238
|
-
* uses — and an entity the registry does not know reads "personal" for both, which is the
|
|
239
|
-
* honest answer rather than a borrowed one: filing it under the `build` fallback would claim
|
|
240
|
-
* a Geneseed taxonomy slot it was never given.
|
|
241
|
-
*
|
|
242
|
-
* ⚠ THE PACK SELECTION IS THE ONE THING THE RENDER CANNOT KNOW. `renderAll` emits all four
|
|
243
|
-
* pack files whatever `cfg.doctrines` says — measured: `makeCfg({doctrines:['craft']})` yields
|
|
244
|
-
* the same 105 items — because the filtering happens when AGENT.md's §2 is assembled, not in
|
|
245
|
-
* the walk. So the catalogue is always whole, and which packs this INSTALL built in comes from
|
|
246
|
-
* its own carrier via `doctrinesForBuild`, which resolves "no marker" to every pack.
|
|
247
|
-
*/
|
|
248
|
-
function deployedInventory(state) {
|
|
249
|
-
const render = tuiInventory(state.theme, doctrinesForBuild(state.target),
|
|
250
|
-
excludedRulesOfDir(state.target));
|
|
251
|
-
const registry = loadRegistry();
|
|
252
|
-
const skills = specEntries(path.join(state.target, 'skills'), true);
|
|
253
|
-
for (const e of skills) {
|
|
254
|
-
e.status = entityStatus(registry, `skills/${e.name}`);
|
|
255
|
-
e.klass = e.status === 'personal' ? 'personal'
|
|
256
|
-
: (Object.hasOwn(SKILL_CLASS, e.name) ? SKILL_CLASS[e.name] : 'build');
|
|
257
|
-
}
|
|
258
|
-
const agents = specEntries(path.join(state.target, 'agents'), false);
|
|
259
|
-
for (const e of agents) e.status = entityStatus(registry, `agents/${e.name}`);
|
|
260
|
-
return { agents,
|
|
261
|
-
skills,
|
|
262
|
-
laws: render.laws,
|
|
263
|
-
ontology: render.ontology,
|
|
264
|
-
doctrines: render.doctrines,
|
|
265
|
-
theme: state.theme };
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** The deployed record when there is one, else the source render. */
|
|
269
|
-
function inventoryFor(state) {
|
|
270
|
-
return deployed(state) ? deployedInventory(state) : tuiInventory(state.theme);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// ---- the catalog stores overview counts ---------------------------------------------------
|
|
274
|
-
|
|
275
|
-
/** Always `<target>/memory` — never the CWD-scanning resolver. */
|
|
276
|
-
const memoryDir = (state) => path.join(state.target, 'memory');
|
|
277
|
-
const notebookDir = (state) => path.join(state.target, 'notebook');
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Matched case-insensitively on Windows.
|
|
281
|
-
*
|
|
282
|
-
* `comparePaths` and NOT a bare `.sort()`: on Windows, filenames sort case-insensitively, so
|
|
283
|
-
* `MEMORY.md` sorts under `m` and lands after `a-fact.md`; JS's default comparator is UTF-16
|
|
284
|
-
* code units, so `M` (0x4D) sorts before `a` (0x61) and the whole catalog comes back in a
|
|
285
|
-
* different order. The `normcase` in the filter beside it only fixes matching, not
|
|
286
|
-
* ordering — this is the other half.
|
|
287
|
-
*/
|
|
288
|
-
function globMd(dir) {
|
|
289
|
-
if (!isDir(dir)) return [];
|
|
290
|
-
let names;
|
|
291
|
-
try { names = readdirSync(dir); } catch { return []; }
|
|
292
|
-
return names.filter((n) => normcase(n).endsWith('.md') && isFile(path.join(dir, n)))
|
|
293
|
-
.sort(comparePaths);
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/** Filename without its extension — single owner, imported by `js/web/activity.mjs` too. */
|
|
297
|
-
export const stemOf = (name) => name.slice(0, name.length - path.extname(name).length);
|
|
298
|
-
|
|
299
|
-
export function memoryItems(state) {
|
|
300
|
-
const d = memoryDir(state);
|
|
301
|
-
if (!isDir(d)) return [];
|
|
302
|
-
return globMd(d).map((n) => {
|
|
303
|
-
const p = path.join(d, n);
|
|
304
|
-
const [fm] = frontmatter(readMaybe(p) ?? '');
|
|
305
|
-
const stem = stemOf(n);
|
|
306
|
-
return {
|
|
307
|
-
name: stem,
|
|
308
|
-
title: fm.has('name') ? fm.get('name') : stem,
|
|
309
|
-
desc: fm.has('description') ? fm.get('description') : '',
|
|
310
|
-
source: resolvePath(p),
|
|
311
|
-
};
|
|
312
|
-
});
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
function notebookItems(state) {
|
|
316
|
-
const d = notebookDir(state);
|
|
317
|
-
if (!isDir(d)) return [];
|
|
318
|
-
return globMd(d).map((n) => ({
|
|
319
|
-
name: stemOf(n), title: stemOf(n), desc: '', source: resolvePath(path.join(d, n)),
|
|
320
|
-
}));
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** The two setup manifests, in listing order. */
|
|
324
|
-
const CONFIG_META = {
|
|
325
|
-
'context.json': ['Project context', 'what the agent loads for this project'],
|
|
326
|
-
'wiki.jsonc': ['Wiki manifest', 'your machine-wide knowledge base(s)'],
|
|
327
|
-
};
|
|
328
|
-
|
|
329
|
-
function configItems(state) {
|
|
330
|
-
const out = [];
|
|
331
|
-
for (const fname of ['context.json', 'wiki.jsonc']) {
|
|
332
|
-
const p = path.join(state.target, fname);
|
|
333
|
-
if (!isFile(p)) continue;
|
|
334
|
-
const [title, desc] = CONFIG_META[fname] ?? [fname, ''];
|
|
335
|
-
out.push({ name: fname, title, desc, type: 'config', kind: 'manifest',
|
|
336
|
-
source: resolvePath(p) });
|
|
337
|
-
}
|
|
338
|
-
return out;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const WIKI_FILE_CAP = 5000;
|
|
342
|
-
|
|
343
|
-
/**
|
|
344
|
-
* `resolvePath` for a path that came out of a HAND-MAINTAINED manifest — `null`, never a throw.
|
|
345
|
-
*
|
|
346
|
-
* The three wiki sites below, and `apiDeployCmd` in `js/web/actions.mjs`, are the
|
|
347
|
-
* request-path callers of `expanduser`, and it REFUSES a `~user` form by printing and throwing
|
|
348
|
-
* (`js/hosts/hosts.mjs`'s docblock). None of them may let that escape: `js/web/handler.mjs`
|
|
349
|
-
* wraps the whole GET in a blanket `catch` → a JSON 500, so an unguarded refusal would turn
|
|
350
|
-
* ONE bad line in a file the user hand-edits into a dead Knowledge section. That is the same
|
|
351
|
-
* argument `sovereignBypass` makes in `js/hosts/hosts.mjs` — contain the refusal per entry
|
|
352
|
-
* and take the site's OWN degrade — and each of the three already has one: `[]`, `continue`,
|
|
353
|
-
* `continue`-into-404.
|
|
354
|
-
*
|
|
355
|
-
* `withDiscardableStderr` because `expanduser` prints its refusal at the RAISE SITE, and
|
|
356
|
-
* this caller wants that swallowed along with the exception rather than logged.
|
|
357
|
-
*
|
|
358
|
-
* DELIBERATE PRODUCT DECISION: a `~otherUser` path is refused rather than resolved, even
|
|
359
|
-
* where the account happens to exist — reading another account's files through a
|
|
360
|
-
* hand-edited wiki manifest is not a feature.
|
|
361
|
-
*/
|
|
362
|
-
function wikiPath(p) {
|
|
363
|
-
try {
|
|
364
|
-
return withDiscardableStderr(() => resolvePath(p));
|
|
365
|
-
} catch {
|
|
366
|
-
return null;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* `$GENESEED_WIKI` first, else `wiki.jsonc` beside the deployed bundle, read with the
|
|
372
|
-
* harness's generic JSONC loader.
|
|
373
|
-
*
|
|
374
|
-
* `resolvePath`, not merely `expanduser`: harmless here because `p` is consumed by `isFile`
|
|
375
|
-
* and `mcpLoad` and never reaches a response body, and both of those follow symlinks and
|
|
376
|
-
* resolve a relative path against the same cwd anyway.
|
|
377
|
-
*/
|
|
378
|
-
function wikiManifest(state) {
|
|
379
|
-
const cand = process.env.GENESEED_WIKI;
|
|
380
|
-
const p = cand ? wikiPath(cand) : path.join(state.target, 'wiki.jsonc');
|
|
381
|
-
if (!p || !isFile(p)) return [];
|
|
382
|
-
const cfg = mcpLoad(p);
|
|
383
|
-
const wikis = cfg.wikis;
|
|
384
|
-
return Array.isArray(wikis) ? wikis : [];
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* The COMMENT-TOLERANT dict loader, `{}` for a file that is missing, unreadable,
|
|
389
|
-
* unparseable, or not an object.
|
|
390
|
-
*
|
|
391
|
-
* Comment-tolerant is the whole point, and getting this wrong is silent: the two files it
|
|
392
|
-
* reads are `wiki.jsonc` and `context.json`, hand-maintained, so a `//` line is expected.
|
|
393
|
-
* Plain `JSON.parse` would throw on it and this loader would then answer `{}` — the wiki
|
|
394
|
-
* section listing nothing and the config item's `manifest` coming back empty, with no
|
|
395
|
-
* error surfaced anywhere.
|
|
396
|
-
*/
|
|
397
|
-
function mcpLoad(p) {
|
|
398
|
-
if (!isFile(p)) return {};
|
|
399
|
-
let text;
|
|
400
|
-
try { text = readText(p); } catch { return {}; }
|
|
401
|
-
const [data] = readJsonc(text);
|
|
402
|
-
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/**
|
|
406
|
-
* Every `.md` under `dir`, recursively.
|
|
407
|
-
*
|
|
408
|
-
* `recursive: true` replaces a hand-rolled walk; its one caller re-sorts the result with
|
|
409
|
-
* `comparePaths` before using it, so the order this returns is never observed.
|
|
410
|
-
*/
|
|
411
|
-
function rglobMd(dir) {
|
|
412
|
-
let entries;
|
|
413
|
-
try { entries = readdirSync(dir, { recursive: true, withFileTypes: true }); } catch { return []; }
|
|
414
|
-
return entries
|
|
415
|
-
.filter((e) => e.isFile() && normcase(e.name).endsWith('.md'))
|
|
416
|
-
.map((e) => path.join(e.parentPath, e.name));
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
export function wikiItems(state) {
|
|
420
|
-
const items = [];
|
|
421
|
-
const seen = new Set();
|
|
422
|
-
for (const w of wikiManifest(state)) {
|
|
423
|
-
if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
|
|
424
|
-
const wname = String(dget(w, 'name', null) || 'wiki');
|
|
425
|
-
// PER ENTRY, and the loop continues — one unusable `path` in the manifest must not
|
|
426
|
-
// blank every OTHER vault in it.
|
|
427
|
-
const root = wikiPath(String(dget(w, 'path', null) || ''));
|
|
428
|
-
if (!root || !isDir(root)) continue;
|
|
429
|
-
// A hand-edited `entries` that is not a list (`{}`, a string) is a malformed vault, not a
|
|
430
|
-
// crash: `/api/status` counts these, and one bad manifest must not fail the dashboard.
|
|
431
|
-
const rawEntries = dget(w, 'entries', null);
|
|
432
|
-
const entries = (Array.isArray(rawEntries) ? rawEntries : [])
|
|
433
|
-
.filter((e) => e && typeof e === 'object' && !Array.isArray(e));
|
|
434
|
-
const excludes = entries.filter((e) => dget(e, 'load', null) === 'exclude')
|
|
435
|
-
.map((e) => String(dget(e, 'path', null) || '').replace(/^\/+|\/+$/g, '').replace(/\\/g, '/'));
|
|
436
|
-
const excluded = (rel) => excludes.some((x) => x && (rel === x || rel.startsWith(`${x}/`)));
|
|
437
|
-
|
|
438
|
-
for (const e of entries) {
|
|
439
|
-
if (dget(e, 'load', null) === 'exclude') continue;
|
|
440
|
-
const rel = String(dget(e, 'path', null) || '').replace(/^\/+|\/+$/g, '').replace(/\\/g, '/');
|
|
441
|
-
const desc = String(dget(e, 'description', null) || '');
|
|
442
|
-
const fp = path.join(root, rel);
|
|
443
|
-
let mds;
|
|
444
|
-
if (isFile(fp) && path.extname(fp) === '.md') mds = [fp];
|
|
445
|
-
// Capped and sorted via `comparePaths`, for the reason `globMd` carries.
|
|
446
|
-
else if (isDir(fp)) mds = rglobMd(fp).sort(comparePaths).slice(0, WIKI_FILE_CAP);
|
|
447
|
-
else continue;
|
|
448
|
-
for (const md of mds) {
|
|
449
|
-
const r = path.relative(root, md).split(path.sep).join('/');
|
|
450
|
-
const key = `${wname}:${r}`;
|
|
451
|
-
if (seen.has(key) || excluded(r)) continue;
|
|
452
|
-
seen.add(key);
|
|
453
|
-
items.push({ name: key, title: path.basename(md, '.md'),
|
|
454
|
-
desc: mds.length === 1 ? desc : r, type: 'wiki', kind: 'page', group: wname,
|
|
455
|
-
source: resolvePath(md) });
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
return items;
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/** One page by `<wiki>:<relpath>`, never outside the vault. */
|
|
463
|
-
function apiWikiItem(state, name) {
|
|
464
|
-
const at = name.indexOf(':');
|
|
465
|
-
const wname = at < 0 ? name : name.slice(0, at);
|
|
466
|
-
const rel = (at < 0 ? '' : name.slice(at + 1))
|
|
467
|
-
.replace(/^\/+|\/+$/g, '').replace(/\\/g, '/');
|
|
468
|
-
for (const w of wikiManifest(state)) {
|
|
469
|
-
if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
|
|
470
|
-
if (String(dget(w, 'name', null) || 'wiki') !== wname) continue;
|
|
471
|
-
// An unresolvable or refused root falls through to the `NotFound` below, the same as
|
|
472
|
-
// one that simply does not exist.
|
|
473
|
-
const root = wikiPath(String(dget(w, 'path', null) || ''));
|
|
474
|
-
if (!root) continue;
|
|
475
|
-
const p = resolvePath(path.join(root, rel));
|
|
476
|
-
if (rel && path.extname(p) === '.md' && within(p, root) && isFile(p)) {
|
|
477
|
-
const body = readMaybe(p) ?? '';
|
|
478
|
-
return { type: 'wiki', name, title: path.basename(p, '.md'), desc: '',
|
|
479
|
-
body, links: resolveLinks(state, body), source: p };
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
throw new NotFound(name);
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Re-exported from `js/lib/paths.mjs`, where the lib split put it.
|
|
487
|
-
*
|
|
488
|
-
* ⚠ A SECOND, INDEPENDENT COPY lives in `js/inspect/scan.mjs`, written for the doctor's tree
|
|
489
|
-
* walks. A change to the containment rule has to land on both, and nothing compares them.
|
|
490
|
-
*
|
|
491
|
-
* Re-exported here (rather than imported directly by callers) so `js/web/actions.mjs`'s
|
|
492
|
-
* import path stays unchanged.
|
|
493
|
-
*/
|
|
494
|
-
export { within };
|
|
495
|
-
|
|
496
|
-
/**
|
|
497
|
-
* Catalog names are flat basenames.
|
|
498
|
-
*
|
|
499
|
-
* A separator, a `..` or a drive colon in the URL segment is someone steering the join
|
|
500
|
-
* outside the catalog dir. A GET carries no token, so before this check the endpoint was
|
|
501
|
-
* an arbitrary-file read; it raises rather than resolving.
|
|
502
|
-
*/
|
|
503
|
-
function flatName(name) {
|
|
504
|
-
if (!name || name.includes('/') || name.includes('\\') || name.includes('..')
|
|
505
|
-
|| name.includes(':')) {
|
|
506
|
-
throw new NotFound(name);
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
|
|
511
|
-
|
|
512
|
-
/**
|
|
513
|
-
* `[[name]]` matched against known agent and skill names.
|
|
514
|
-
*
|
|
515
|
-
* AGENTS FIRST, SKILLS SECOND, into ONE map — so a name that is both resolves as a SKILL.
|
|
516
|
-
* This ordering is deliberate and must not be swapped.
|
|
517
|
-
*/
|
|
518
|
-
export function resolveLinks(state, body) {
|
|
519
|
-
const inv = state.inventory;
|
|
520
|
-
const known = new Map();
|
|
521
|
-
for (const e of inv.agents) known.set(e.name, 'agent');
|
|
522
|
-
for (const e of inv.skills) known.set(e.name, 'skill');
|
|
523
|
-
const links = [];
|
|
524
|
-
const seen = new Set();
|
|
525
|
-
for (const m of body.matchAll(WIKILINK_RE)) {
|
|
526
|
-
// `stripWhitespace`, not `trim()` — the two whitespace classes differ, and both call
|
|
527
|
-
// sites on this regex must answer with the same rule.
|
|
528
|
-
const label = stripWhitespace(m[1]);
|
|
529
|
-
if (known.has(label) && !seen.has(label)) {
|
|
530
|
-
seen.add(label);
|
|
531
|
-
links.push({ label, type: known.get(label), name: label });
|
|
532
|
-
}
|
|
533
|
-
}
|
|
534
|
-
return links;
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* A setup file parsed into the shape the detail pane renders as cards, or `null` so the
|
|
539
|
-
* caller falls back to the raw body.
|
|
540
|
-
*/
|
|
541
|
-
function configManifest(name, p) {
|
|
542
|
-
const cfg = mcpLoad(p);
|
|
543
|
-
if (name === 'wiki.jsonc') {
|
|
544
|
-
const wikis = cfg.wikis;
|
|
545
|
-
return { kind: 'wiki', wikis: Array.isArray(wikis) ? wikis : [] };
|
|
546
|
-
}
|
|
547
|
-
if (name === 'context.json') {
|
|
548
|
-
const ctx = cfg.context;
|
|
549
|
-
return { kind: 'context', context: Array.isArray(ctx) ? ctx : [] };
|
|
550
|
-
}
|
|
551
|
-
return null;
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
/**
|
|
555
|
-
* A closed list; anything else is a 404.
|
|
556
|
-
*
|
|
557
|
-
* ⚠ `laws` STAYS ONE SECTION FOR ALL THREE TIERS, and that is a decision rather than an
|
|
558
|
-
* oversight. The console has ONE Constitution entry, not three, so a reader keeps the "read it
|
|
559
|
-
* top to bottom" property; the id stays `laws` because `tests/helpers/cli_golden.mjs`
|
|
560
|
-
* hard-requires `web/src/pages/Laws.jsx` and because a route rename buys nothing and costs a
|
|
561
|
-
* redirect. The tier lives on the ITEM (`tier: 'ontology' | 'invariant' | 'doctrine'`), which
|
|
562
|
-
* is where a consumer can act on it.
|
|
563
|
-
*/
|
|
564
|
-
export const SECTIONS = ['agents', 'skills', 'laws', 'memory', 'notebook', 'wiki', 'config'];
|
|
29
|
+
import { isFile } from '../lib/fs.mjs';
|
|
30
|
+
import { comparePaths, normcase } from '../lib/paths.mjs';
|
|
31
|
+
import {
|
|
32
|
+
configItems, deployed, dget, memoryItems, notebookItems, wikiItems,
|
|
33
|
+
} from './catalog.mjs';
|
|
34
|
+
import { stampMinute } from './state.mjs';
|
|
565
35
|
|
|
566
36
|
/**
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
* FLAT AND NOT NESTED, because every other section is `{section, items}` and a second shape
|
|
570
|
-
* for one section is a second client path for one roster. The pack's grouping metadata rides
|
|
571
|
-
* on each doctrine row (`pack`, `packTitle`, `packDesc`, `active`), so a consumer groups by
|
|
572
|
-
* `pack` and has everything a header needs without a second fetch.
|
|
573
|
-
*
|
|
574
|
-
* Names are ADDRESSES and the three shapes cannot collide: `ont:<slug>` carries a colon no
|
|
575
|
-
* numeral has, an invariant is `[IVXLCDM]+`, a doctrine rule is `<pack>.<n>`. `apiItem`
|
|
576
|
-
* resolves all three off the same list.
|
|
37
|
+
* `js/web/docs.mjs` imports these three from HERE, and that file is outside this split — so
|
|
38
|
+
* they are re-exported rather than moved out from under it. New code imports `catalog.mjs`.
|
|
577
39
|
*/
|
|
578
|
-
|
|
579
|
-
return [
|
|
580
|
-
...(inv.ontology ?? []).map((e) => ({ name: `ont:${e.id}`, title: e.title, desc: '',
|
|
581
|
-
tier: 'ontology' })),
|
|
582
|
-
...inv.laws.map((e) => ({ name: e.num, title: `Rule ${e.num} — ${e.title}`, desc: '',
|
|
583
|
-
klass: e.klass ?? 'craft', tier: 'invariant' })),
|
|
584
|
-
...(inv.doctrines ?? []).flatMap((p) => p.rules.map((r) => ({
|
|
585
|
-
name: `${r.pack}.${r.n}`,
|
|
586
|
-
title: `Doctrine ${r.pack} ${r.n} — ${r.title}`,
|
|
587
|
-
desc: '',
|
|
588
|
-
klass: r.klass ?? p.pack,
|
|
589
|
-
tier: 'doctrine',
|
|
590
|
-
pack: p.pack,
|
|
591
|
-
packTitle: p.title,
|
|
592
|
-
packDesc: p.desc,
|
|
593
|
-
// ⚠ EVERY RULE IS LISTED WHETHER OR NOT IT IS BUILT IN, and `active` is the RULE's own
|
|
594
|
-
// — a pack can be on with one rule excluded. A row that vanished from the payload would
|
|
595
|
-
// be indistinguishable from one that never shipped, and the console could not then
|
|
596
|
-
// offer the switch that turns it back on.
|
|
597
|
-
active: r.active !== false,
|
|
598
|
-
packActive: p.active,
|
|
599
|
-
}))),
|
|
600
|
-
];
|
|
601
|
-
}
|
|
40
|
+
export { NotFound, deployed, resolveLinks } from './catalog.mjs';
|
|
602
41
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
const inv = state.inventory;
|
|
606
|
-
let items;
|
|
607
|
-
if (section === 'agents') {
|
|
608
|
-
items = inv.agents.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
|
|
609
|
-
source: e.source ?? null, status: e.status ?? 'unknown' }));
|
|
610
|
-
} else if (section === 'skills') {
|
|
611
|
-
items = inv.skills.map((e) => ({ name: e.name, title: e.name, desc: e.desc,
|
|
612
|
-
source: e.source ?? null, klass: e.klass ?? 'build', status: e.status ?? 'unknown' }));
|
|
613
|
-
} else if (section === 'laws') {
|
|
614
|
-
items = constitutionItems(inv);
|
|
615
|
-
} else if (section === 'memory') {
|
|
616
|
-
items = memoryItems(state);
|
|
617
|
-
} else if (section === 'notebook') {
|
|
618
|
-
items = notebookItems(state);
|
|
619
|
-
} else if (section === 'wiki') {
|
|
620
|
-
items = wikiItems(state);
|
|
621
|
-
} else {
|
|
622
|
-
items = configItems(state);
|
|
623
|
-
}
|
|
624
|
-
return { section, items };
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
export function apiItem(state, type, name) {
|
|
628
|
-
const inv = state.inventory;
|
|
629
|
-
if (type === 'agent' || type === 'skill') {
|
|
630
|
-
const e = (type === 'agent' ? inv.agents : inv.skills).find((x) => x.name === name);
|
|
631
|
-
if (!e) throw new NotFound(name);
|
|
632
|
-
const out = { type, name, title: name, desc: e.desc, body: e.body,
|
|
633
|
-
links: resolveLinks(state, e.body), source: e.source ?? null };
|
|
634
|
-
if (type === 'skill') out.klass = e.klass ?? 'build';
|
|
635
|
-
out.status = e.status ?? 'unknown';
|
|
636
|
-
return out;
|
|
637
|
-
}
|
|
638
|
-
if (type === 'law') {
|
|
639
|
-
// ONE ARM FOR THREE TIERS, resolved off the same list the catalogue publishes — so a name
|
|
640
|
-
// that appears in `/api/catalog/laws` always opens, and one that does not always 404s.
|
|
641
|
-
// Building the list twice is what would let the two drift.
|
|
642
|
-
const e = constitutionItems(inv).find((x) => x.name === name);
|
|
643
|
-
if (!e) throw new NotFound(name);
|
|
644
|
-
const body = e.tier === 'ontology'
|
|
645
|
-
? (inv.ontology.find((s) => `ont:${s.id}` === name)?.body ?? '')
|
|
646
|
-
: (e.tier === 'invariant'
|
|
647
|
-
? (inv.laws.find((x) => x.num === name)?.body ?? '')
|
|
648
|
-
: (inv.doctrines.flatMap((p) => p.rules)
|
|
649
|
-
.find((r) => `${r.pack}.${r.n}` === name)?.body ?? ''));
|
|
650
|
-
// `links: []` — a constitution body is not link-resolved. It cites its siblings by
|
|
651
|
-
// ADDRESS (`Rule IV`, `Doctrine ops 1`, `Ontology: Telos`), not by path, and there is no
|
|
652
|
-
// file behind those to resolve to.
|
|
653
|
-
return { ...e, type, name, body, links: [] };
|
|
654
|
-
}
|
|
655
|
-
if (type === 'memory' || type === 'notebook') {
|
|
656
|
-
flatName(name);
|
|
657
|
-
const d = type === 'notebook' ? notebookDir(state) : memoryDir(state);
|
|
658
|
-
const p = path.join(d, `${name}.md`);
|
|
659
|
-
if (!isFile(p)) throw new NotFound(name);
|
|
660
|
-
const body = readMaybe(p) ?? '';
|
|
661
|
-
return { type, name, title: name, desc: '', body,
|
|
662
|
-
links: resolveLinks(state, body), source: resolvePath(p) };
|
|
663
|
-
}
|
|
664
|
-
if (type === 'wiki') return apiWikiItem(state, name);
|
|
665
|
-
if (type === 'config') {
|
|
666
|
-
flatName(name);
|
|
667
|
-
const p = path.join(state.target, name);
|
|
668
|
-
if (!isFile(p)) throw new NotFound(name);
|
|
669
|
-
const raw = readMaybe(p) ?? '';
|
|
670
|
-
const [title, desc] = CONFIG_META[name] ?? [name, ''];
|
|
671
|
-
return { type, name, title, desc, manifest: configManifest(name, p),
|
|
672
|
-
body: `\`\`\`json\n${raw}\n\`\`\``, links: [], source: resolvePath(p) };
|
|
673
|
-
}
|
|
674
|
-
throw new NotFound(type);
|
|
675
|
-
}
|
|
42
|
+
/** Two resolved paths, compared case-folded on Windows. */
|
|
43
|
+
const samePath = (a, b) => normcase(resolvePath(a)) === normcase(resolvePath(b));
|
|
676
44
|
|
|
677
45
|
// ---- the eight endpoints -------------------------------------------------------------
|
|
678
46
|
|
|
@@ -717,10 +85,18 @@ export function apiDoctor(state) {
|
|
|
717
85
|
checked_at: state.doctor.checked_at };
|
|
718
86
|
}
|
|
719
87
|
|
|
88
|
+
/**
|
|
89
|
+
* `deployed` and `diffable` are TWO FACTS, and they used to be one field. `diffCollect` answers
|
|
90
|
+
* `files: null` for every PROJECT install (it only knows how to re-render a global emit), so
|
|
91
|
+
* `deployed: files !== null` told the Diff page a perfectly real project install was "No deployed
|
|
92
|
+
* harness". `deployed` is the manifest's answer, the same one every other endpoint gives;
|
|
93
|
+
* `diffable` is whether a drift check exists for this install at all.
|
|
94
|
+
*/
|
|
720
95
|
export function apiDiff(state) {
|
|
721
96
|
const { target, theme, files } = diffCollect({
|
|
722
97
|
target: state.target, theme: state.theme, emit: state.emit });
|
|
723
|
-
return { deployed: files !== null, target, theme,
|
|
98
|
+
return { deployed: deployed(state), diffable: files !== null, target, theme,
|
|
99
|
+
files: files || [] };
|
|
724
100
|
}
|
|
725
101
|
|
|
726
102
|
/** The union across every global install, verbatim. */
|
|
@@ -747,7 +123,7 @@ export function apiInstalls(state) {
|
|
|
747
123
|
footprint: footprintOfDir(root),
|
|
748
124
|
posture: postureOfDir(root),
|
|
749
125
|
mode: modeOfDir(root),
|
|
750
|
-
trust: trustOfDir(root),
|
|
126
|
+
trust: trustOfDir(root, host),
|
|
751
127
|
selected: samePath(viewCfg(host, scope, root), state.target),
|
|
752
128
|
});
|
|
753
129
|
}
|
|
@@ -755,20 +131,6 @@ export function apiInstalls(state) {
|
|
|
755
131
|
modes: discoverNames('modes', 'direct'), trusts: Object.keys(PRESETS) };
|
|
756
132
|
}
|
|
757
133
|
|
|
758
|
-
/** Beside the deployed AGENT.md. */
|
|
759
|
-
export const PROFILE_FILE = 'PROFILE.md';
|
|
760
|
-
const profilePath = (state) => path.join(state.target, PROFILE_FILE);
|
|
761
|
-
|
|
762
|
-
function apiProfile(state) {
|
|
763
|
-
const p = profilePath(state);
|
|
764
|
-
if (!isFile(p)) return { exists: false, path: p, text: '', fingerprint: '' };
|
|
765
|
-
const text = readMaybe(p) ?? '';
|
|
766
|
-
// `seeded`: the file is still the template the build wrote once and never touched since.
|
|
767
|
-
// `readMaybe` normalises line endings, so a CRLF copy (the emit writes the platform's, and
|
|
768
|
-
// an editor may re-save either way) still compares equal to the LF literal.
|
|
769
|
-
const seeded = text === PROFILE_STUB;
|
|
770
|
-
return { exists: true, path: p, text, fingerprint: fingerprint(text), seeded };
|
|
771
|
-
}
|
|
772
134
|
|
|
773
135
|
/** The dashboard aggregate. */
|
|
774
136
|
export function apiOverview(state) {
|
|
@@ -986,49 +348,3 @@ function releaseLabel() {
|
|
|
986
348
|
return v && v !== '0.0.0' ? v : null;
|
|
987
349
|
}
|
|
988
350
|
|
|
989
|
-
/**
|
|
990
|
-
* The prefix routes — path in, response out.
|
|
991
|
-
*
|
|
992
|
-
* `percentDecode` and not `decodeURIComponent`: the JS builtin throws a `URIError` on a `%`
|
|
993
|
-
* that is not an escape, where this shell instead answers a 404 naming the literal text.
|
|
994
|
-
*/
|
|
995
|
-
export const PREFIX_ROUTES = [
|
|
996
|
-
['/api/catalog/', (state, p) => apiCatalog(state, p.split('/').pop())],
|
|
997
|
-
// /api/item/<type>/<name> — TYPE has no slash, the NAME keeps its slashes so a wiki
|
|
998
|
-
// page's relpath survives. A missing name is a 404 here rather than a 500 two frames down.
|
|
999
|
-
['/api/item/', (state, p) => {
|
|
1000
|
-
const m = /^\/api\/item\/([^/]+)\/(.+)$/.exec(p);
|
|
1001
|
-
if (!m) throw new NotFound(p);
|
|
1002
|
-
return apiItem(state, m[1], percentDecode(m[2]));
|
|
1003
|
-
}],
|
|
1004
|
-
// The sid is passed through unquoted — `apiActivityDetail`'s safe-name scheme is what
|
|
1005
|
-
// makes the lookup safe, rather than a check out here.
|
|
1006
|
-
['/api/activity/', (state, p) => apiActivityDetail(
|
|
1007
|
-
state, percentDecode(p.slice('/api/activity/'.length)))],
|
|
1008
|
-
];
|
|
1009
|
-
|
|
1010
|
-
/**
|
|
1011
|
-
* A literal path to `api_X(state)`, table-keyed rather than a chain of `if`s — the reason
|
|
1012
|
-
* it is a table is `tests/unit/web_server.test.mjs`'s cross-check, which requires this
|
|
1013
|
-
* table plus `PREFIX_ROUTES` and `GET_INLINE` to equal a surface list written out in the
|
|
1014
|
-
* test, so a route cannot appear or vanish unenumerated.
|
|
1015
|
-
*/
|
|
1016
|
-
export const STATE_ROUTES = {
|
|
1017
|
-
'/api/overview': apiOverview,
|
|
1018
|
-
'/api/recent': apiRecent,
|
|
1019
|
-
'/api/themes': apiThemes,
|
|
1020
|
-
'/api/setup': apiSetup,
|
|
1021
|
-
'/api/doctor': apiDoctor,
|
|
1022
|
-
'/api/installs': apiInstalls,
|
|
1023
|
-
'/api/excludes': apiExcludes,
|
|
1024
|
-
'/api/profile': apiProfile,
|
|
1025
|
-
'/api/diff': apiDiff,
|
|
1026
|
-
'/api/activity': apiActivity,
|
|
1027
|
-
// `/api/rules` must exist as a PAIR with its POST: `apiRulesMutate` splices `parseRules`'
|
|
1028
|
-
// line indices, and the fingerprint every mutation must send back is what this GET
|
|
1029
|
-
// answers — a daemon whose POST worked and whose GET 501'd would have no way to obtain one.
|
|
1030
|
-
'/api/rules': apiRules,
|
|
1031
|
-
'/api/mcp': apiMcp,
|
|
1032
|
-
'/api/loops': apiLoops,
|
|
1033
|
-
'/api/loops/active': apiLoopsActive,
|
|
1034
|
-
};
|