geneseed 3.1.1 → 3.1.3
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 +123 -1
- package/DESIGN.md +15 -15
- package/README.md +1 -1
- package/SETUP.md +1 -1
- package/SHIPPED.md +1 -1
- package/bin/{geneseed.mjs → build-driver.mjs} +39 -42
- package/bin/geneseed-cli.mjs +33 -32
- package/bin/geneseed-hook.mjs +7 -7
- package/docs/design-history.md +40 -40
- package/docs/extending.md +111 -83
- package/docs/web/exclusions.md +1 -1
- package/docs/web/footprint.md +1 -1
- package/docs/web/foreman-mode.md +2 -2
- package/docs/web/themes-author.md +1 -1
- package/docs/web-ui.md +41 -29
- package/harness.config.json +1 -1
- package/js/README.md +203 -0
- package/js/build/bundle.mjs +231 -0
- package/js/{catalog.mjs → build/catalog.mjs} +7 -7
- package/js/build/emit-claude.mjs +287 -0
- package/js/build/emit-common.mjs +255 -0
- package/js/build/emit-opencode.mjs +214 -0
- package/js/{generate.mjs → build/generate.mjs} +27 -28
- package/js/{render.mjs → build/render.mjs} +13 -9
- package/js/{checkout.mjs → build/source.mjs} +9 -9
- package/js/build/stubs.mjs +256 -0
- package/js/{themes.mjs → build/themes.mjs} +24 -24
- package/js/build/version.mjs +155 -0
- package/js/{hooks.mjs → hosts/hooks.mjs} +47 -44
- package/js/{hosts.mjs → hosts/hosts.mjs} +30 -28
- package/js/{installs.mjs → hosts/installs.mjs} +18 -16
- package/js/{link.mjs → hosts/link.mjs} +29 -28
- package/js/{mcp.mjs → hosts/mcp.mjs} +10 -9
- package/js/{native.mjs → hosts/native.mjs} +24 -22
- package/js/{opencode.mjs → hosts/opencode.mjs} +12 -7
- package/js/{settings.mjs → hosts/settings.mjs} +63 -55
- package/js/inspect/checks-authoring.mjs +761 -0
- package/js/inspect/checks-build.mjs +231 -0
- package/js/inspect/checks-repo.mjs +308 -0
- package/js/{diff.mjs → inspect/diff.mjs} +34 -31
- package/js/inspect/doctor.mjs +286 -0
- package/js/{excludes.mjs → inspect/excludes.mjs} +30 -33
- package/js/{inventory.mjs → inspect/inventory.mjs} +20 -17
- package/js/{registry.mjs → inspect/registry.mjs} +12 -10
- package/js/inspect/scan.mjs +125 -0
- package/js/{status.mjs → inspect/status.mjs} +27 -25
- package/js/inspect/validate.mjs +199 -0
- package/js/lib/fs.mjs +37 -759
- package/js/lib/json.mjs +344 -0
- package/js/lib/paths.mjs +242 -0
- package/js/lib/proc.mjs +3 -3
- package/js/lib/text.mjs +181 -0
- package/js/lib/udiff.mjs +1 -1
- package/js/{memory.mjs → maintain/memory.mjs} +10 -10
- package/js/{migrate.mjs → maintain/migrate.mjs} +28 -26
- package/js/{setup.mjs → maintain/setup.mjs} +34 -32
- package/js/{uninstall.mjs → maintain/uninstall.mjs} +55 -56
- package/js/{update.mjs → maintain/update.mjs} +51 -51
- package/js/{anim.mjs → ui/anim.mjs} +30 -28
- package/js/{cli.mjs → ui/cli.mjs} +10 -10
- package/js/{menu.mjs → ui/menu.mjs} +10 -9
- package/js/{tui.mjs → ui/tui.mjs} +36 -34
- package/js/web/actions.mjs +84 -85
- package/js/web/activity.mjs +22 -21
- package/js/web/api.mjs +154 -51
- package/js/web/daemon.mjs +319 -0
- package/js/web/docs.mjs +24 -21
- package/js/web/graph.mjs +0 -0
- package/js/web/handler.mjs +442 -0
- package/js/web/jobs.mjs +21 -19
- package/js/web/routes.mjs +245 -0
- package/js/web/server.mjs +56 -987
- package/package.json +11 -3
- package/src/doctrines/README.md +1 -1
- package/themes/opencode/README.md +1 -1
- package/web/dist/assets/{Activity-D_sYBlW-.js → Activity-B49fE_ug.js} +1 -1
- package/web/dist/assets/{ActivityDetail-BomKcMhy.js → ActivityDetail-abt7hzD2.js} +1 -1
- package/web/dist/assets/{Diff-BSvi1WUg.js → Diff-D3_bVkih.js} +1 -1
- package/web/dist/assets/{Doctor-Cz1s-y1n.js → Doctor-hXp7srsx.js} +1 -1
- package/web/dist/assets/{Graph-CuntzFJ0.js → Graph-DvYMtlvl.js} +1 -1
- package/web/dist/assets/Harness-BIWei7N5.js +1 -0
- package/web/dist/assets/Laws-CxmnRdwt.js +3 -0
- package/web/dist/assets/{Library-DXhTN9ju.js → Library-MjbOkHIW.js} +1 -1
- package/web/dist/assets/{Markdown-BoiZcdUl.js → Markdown-Dx_w0faR.js} +1 -1
- package/web/dist/assets/{Profile-DfCHlZ03.js → Profile-BRFvv2gr.js} +1 -1
- package/web/dist/assets/{Rules-eND7hJv9.js → Rules-CfsV1EVy.js} +1 -1
- package/web/dist/assets/{Skills-CDl9opZO.js → Skills-DArA6IHS.js} +1 -1
- package/web/dist/assets/{StatusBadge-DSDtcArX.js → StatusBadge-C9bZOEZk.js} +1 -1
- package/web/dist/assets/{activity-Dmb0FTAx.js → activity-Cc0n2jwx.js} +1 -1
- package/web/dist/assets/index-Bx3oxZ1o.css +1 -0
- package/web/dist/assets/index-DC3-GlCY.js +1 -0
- package/web/dist/assets/index-DP035wPa.js +12 -0
- package/web/dist/assets/{index-Dg58ScBe.js → index-dHi-7M-F.js} +1 -1
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +5 -19
- package/docs/declined.md +0 -200
- package/docs/limits.md +0 -329
- package/js/doctor.mjs +0 -1750
- package/js/emit.mjs +0 -1411
- package/web/dist/assets/About-DyK7yRy7.js +0 -1
- package/web/dist/assets/Harnesses-DgRyOviS.js +0 -1
- package/web/dist/assets/Laws-CWk3Mpri.js +0 -3
- package/web/dist/assets/Themes-CoAWuIeM.js +0 -2
- package/web/dist/assets/index-B-mXquBZ.css +0 -1
- package/web/dist/assets/index-CH_Z725C.js +0 -12
- package/web/dist/assets/index-jrY4m9jP.js +0 -1
package/js/web/api.mjs
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `apiOverview` compute anything of their own, and most of what `apiOverview` computes is
|
|
15
15
|
* counting.
|
|
16
16
|
*
|
|
17
|
-
* THE ONE PARAMETER: `doctorCollect({groups})`. `js/doctor.mjs` carried a `ran(check,
|
|
17
|
+
* THE ONE PARAMETER: `doctorCollect({groups})`. `js/inspect/doctor.mjs` carried a `ran(check,
|
|
18
18
|
* label, probs)` that returned its third argument and threw the first two away, with a
|
|
19
19
|
* docblock saying the label stays "because it is the one place each check is NAMED, and
|
|
20
20
|
* what P6's accumulator will key on". This is that phase. `on_progress=` — the TUI's — is
|
|
@@ -26,15 +26,15 @@
|
|
|
26
26
|
* `state.inventory` and the badges and taxonomy classes belonged to a phase that had not
|
|
27
27
|
* arrived. `api_catalog` consumes exactly those fields, so this is the phase that would
|
|
28
28
|
* have made the counting half and the reading half two classifiers. It did not:
|
|
29
|
-
* `tuiInventory` in `js/inventory.mjs` is the one walk, `js/status.mjs`'s `inventoryCounts`
|
|
29
|
+
* `tuiInventory` in `js/inspect/inventory.mjs` is the one walk, `js/inspect/status.mjs`'s `inventoryCounts`
|
|
30
30
|
* is three `length`s off it, and `specEntries` grew out of `specNames` by adding the READ
|
|
31
31
|
* rather than by being written beside it.
|
|
32
32
|
*
|
|
33
33
|
* THE TAXONOMY WAS ALREADY PORTED, which the measurement found and the handoff did not.
|
|
34
|
-
* `js/status.mjs` said flatly that "the ~111 LOC of TUI taxonomy" was P7's; `LAW_CLASS`,
|
|
34
|
+
* `js/inspect/status.mjs` said flatly that "the ~111 LOC of TUI taxonomy" was P7's; `LAW_CLASS`,
|
|
35
35
|
* `SKILL_CLASS`, `LAW_CLASSES` and `ENTITY_STATUSES` had in fact crossed in P5g inside
|
|
36
|
-
* `js/
|
|
37
|
-
* four to `js/inventory.mjs` — where Python keeps them — rather than copying them, and
|
|
36
|
+
* `js/inspect/checks-authoring.mjs`, because doctor's authoring gates are what validate them. P6c moved the
|
|
37
|
+
* four to `js/inspect/inventory.mjs` — where Python keeps them — rather than copying them, and
|
|
38
38
|
* doctor imports them back. Only `parse_laws`, `load_registry` and `entity_status` were
|
|
39
39
|
* genuinely new.
|
|
40
40
|
*
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
* carries the key, which is why its replay harness erases it before comparing.
|
|
47
47
|
*
|
|
48
48
|
* P6c's ROUTES ARE THE FIRST THAT PARSE A PATH, which brings three things no earlier
|
|
49
|
-
* endpoint could reach: `
|
|
49
|
+
* endpoint could reach: `percentDecode` (NOT `decodeURIComponent`, which throws where the
|
|
50
50
|
* reference leaves a stray `%` alone), the `NotFound` → 404 convention, and `flatName`'s
|
|
51
51
|
* traversal refusal — a GET carries no token, so before that check the item route was an
|
|
52
52
|
* arbitrary-file read.
|
|
@@ -55,27 +55,28 @@ import { createHash } from 'node:crypto';
|
|
|
55
55
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
56
56
|
import path from 'node:path';
|
|
57
57
|
|
|
58
|
-
import { ROOT, THEMES, discoverNames } from '../
|
|
59
|
-
import {
|
|
60
|
-
import {
|
|
61
|
-
import {
|
|
62
|
-
import {
|
|
58
|
+
import { CONFIG, ROOT, THEMES, discoverNames } from '../build/source.mjs';
|
|
59
|
+
import { sourceReleaseVersion } from '../hosts/opencode.mjs';
|
|
60
|
+
import { diffCollect } from '../inspect/diff.mjs';
|
|
61
|
+
import { doctorCollect } from '../inspect/doctor.mjs';
|
|
62
|
+
import { excludesSnapshot } from '../inspect/excludes.mjs';
|
|
63
|
+
import { GLOBAL_MANIFEST, HOSTS, opencodeConfigDir, resolvePath } from '../hosts/hosts.mjs';
|
|
63
64
|
import {
|
|
64
65
|
doctrinesForBuild, excludedRulesOfDir, footprintOfDir, installState, installTargets,
|
|
65
66
|
installedDefaults, modeOfDir,
|
|
66
67
|
postureOfDir, readJsonMaybe, readMaybe, themeOfDir,
|
|
67
|
-
} from '../installs.mjs';
|
|
68
|
-
import { frontmatter } from '../hooks.mjs';
|
|
68
|
+
} from '../hosts/installs.mjs';
|
|
69
|
+
import { frontmatter } from '../hosts/hooks.mjs';
|
|
69
70
|
import {
|
|
70
71
|
SKILL_CLASS, entityStatus, loadRegistry, tuiInventory,
|
|
71
|
-
} from '../inventory.mjs';
|
|
72
|
-
import { firstBlockquote } from '../native.mjs';
|
|
73
|
-
import { EMIT_OPTIONS, themeOptions } from '../setup.mjs';
|
|
74
|
-
import { accentFor, statusData } from '../status.mjs';
|
|
75
|
-
import {
|
|
76
|
-
|
|
77
|
-
} from '../lib/
|
|
78
|
-
import { readJsonc } from '../settings.mjs';
|
|
72
|
+
} from '../inspect/inventory.mjs';
|
|
73
|
+
import { firstBlockquote } from '../hosts/native.mjs';
|
|
74
|
+
import { EMIT_OPTIONS, themeOptions } from '../maintain/setup.mjs';
|
|
75
|
+
import { accentFor, statusData } from '../inspect/status.mjs';
|
|
76
|
+
import { readText, withDiscardableStderr } from '../lib/fs.mjs';
|
|
77
|
+
import { comparePaths, normcase, within } from '../lib/paths.mjs';
|
|
78
|
+
import { stripWhitespace, percentDecode } from '../lib/text.mjs';
|
|
79
|
+
import { readJsonc } from '../hosts/settings.mjs';
|
|
79
80
|
import { apiActivity, apiActivityDetail } from './activity.mjs';
|
|
80
81
|
import { apiMcp, apiRules } from './actions.mjs';
|
|
81
82
|
import { apiGraph } from './graph.mjs';
|
|
@@ -87,7 +88,7 @@ const isDir = (p) => { try { return statSync(p).isDirectory(); } catch { return
|
|
|
87
88
|
const isFile = (p) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
88
89
|
|
|
89
90
|
/** Two resolved paths, compared as `PurePath.__eq__` does — case-folded on Windows. */
|
|
90
|
-
const samePath = (a, b) => normcase(
|
|
91
|
+
const samePath = (a, b) => normcase(resolvePath(a)) === normcase(resolvePath(b));
|
|
91
92
|
|
|
92
93
|
/** `dict.get(key, default)` — `??` would also swallow an explicit null the file declared. */
|
|
93
94
|
const dget = (obj, key, dflt) => (Object.hasOwn(obj, key) ? obj[key] : dflt);
|
|
@@ -177,7 +178,7 @@ export function webState(theme = null, target = null) {
|
|
|
177
178
|
try {
|
|
178
179
|
const em = path.join(d, '.geneseed-emit');
|
|
179
180
|
if (isFile(em)) {
|
|
180
|
-
const v =
|
|
181
|
+
const v = stripWhitespace(readText(em));
|
|
181
182
|
if (v) return v;
|
|
182
183
|
}
|
|
183
184
|
} catch { /* as the Python's `except OSError: pass` */ }
|
|
@@ -264,7 +265,7 @@ export function specEntries(root, nested) {
|
|
|
264
265
|
if (!isFile(p)) continue;
|
|
265
266
|
const [fm, body] = frontmatter(readMaybe(p) ?? '');
|
|
266
267
|
const name = nested ? path.basename(path.dirname(p)) : path.basename(p, '.md');
|
|
267
|
-
out.push({ name, desc: specDesc(fm, body), body, source:
|
|
268
|
+
out.push({ name, desc: specDesc(fm, body), body, source: resolvePath(p) });
|
|
268
269
|
}
|
|
269
270
|
out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
270
271
|
return out;
|
|
@@ -353,7 +354,7 @@ export function memoryItems(state) {
|
|
|
353
354
|
name: stem,
|
|
354
355
|
title: fm.has('name') ? fm.get('name') : stem,
|
|
355
356
|
desc: fm.has('description') ? fm.get('description') : '',
|
|
356
|
-
source:
|
|
357
|
+
source: resolvePath(p),
|
|
357
358
|
};
|
|
358
359
|
});
|
|
359
360
|
}
|
|
@@ -363,7 +364,7 @@ export function notebookItems(state) {
|
|
|
363
364
|
const d = notebookDir(state);
|
|
364
365
|
if (!isDir(d)) return [];
|
|
365
366
|
return globMd(d).map((n) => ({
|
|
366
|
-
name: stemOf(n), title: stemOf(n), desc: '', source:
|
|
367
|
+
name: stemOf(n), title: stemOf(n), desc: '', source: resolvePath(path.join(d, n)),
|
|
367
368
|
}));
|
|
368
369
|
}
|
|
369
370
|
|
|
@@ -381,7 +382,7 @@ export function configItems(state) {
|
|
|
381
382
|
if (!isFile(p)) continue;
|
|
382
383
|
const [title, desc] = CONFIG_META[fname] ?? [fname, ''];
|
|
383
384
|
out.push({ name: fname, title, desc, type: 'config', kind: 'manifest',
|
|
384
|
-
source:
|
|
385
|
+
source: resolvePath(p) });
|
|
385
386
|
}
|
|
386
387
|
return out;
|
|
387
388
|
}
|
|
@@ -389,13 +390,14 @@ export function configItems(state) {
|
|
|
389
390
|
export const WIKI_FILE_CAP = 5000;
|
|
390
391
|
|
|
391
392
|
/**
|
|
392
|
-
* `
|
|
393
|
+
* `resolvePath` for a path that came out of a HAND-MAINTAINED manifest — `null`, never a throw.
|
|
393
394
|
*
|
|
394
|
-
* The three wiki sites below
|
|
395
|
-
* REFUSES a `~user` form by printing and throwing
|
|
396
|
-
* may let that escape: `js/web/
|
|
395
|
+
* The three wiki sites below, and `apiDeployCmd` in `js/web/actions.mjs`, are the
|
|
396
|
+
* request-path callers of `expanduser`, and it REFUSES a `~user` form by printing and throwing
|
|
397
|
+
* (`js/hosts/hosts.mjs`'s docblock). None of them may let that escape: `js/web/handler.mjs`
|
|
398
|
+
* wraps the whole GET in a blanket `catch` → a JSON
|
|
397
399
|
* 500, so an unguarded refusal would turn ONE bad line in a file the user hand-edits into a
|
|
398
|
-
* dead Knowledge section. That is the same argument `sovereignBypass` makes in `js/hooks.mjs`
|
|
400
|
+
* dead Knowledge section. That is the same argument `sovereignBypass` makes in `js/hosts/hooks.mjs`
|
|
399
401
|
* — contain the refusal per entry and take the site's OWN degrade — and each of the three
|
|
400
402
|
* already has one: `[]`, `continue`, `continue`-into-404. The reference reaches those same
|
|
401
403
|
* three degrades for a `~user` path, from the other direction: `ntpath.expanduser` expands
|
|
@@ -413,7 +415,7 @@ export const WIKI_FILE_CAP = 5000;
|
|
|
413
415
|
*/
|
|
414
416
|
function wikiPath(p) {
|
|
415
417
|
try {
|
|
416
|
-
return withDiscardableStderr(() =>
|
|
418
|
+
return withDiscardableStderr(() => resolvePath(p));
|
|
417
419
|
} catch {
|
|
418
420
|
return null;
|
|
419
421
|
}
|
|
@@ -423,7 +425,7 @@ function wikiPath(p) {
|
|
|
423
425
|
* `_web_catalog._wiki_manifest` — `$GENESEED_WIKI` first, else `wiki.jsonc` beside the
|
|
424
426
|
* deployed bundle, read with the harness's generic JSONC loader.
|
|
425
427
|
*
|
|
426
|
-
* `
|
|
428
|
+
* `resolvePath` where the reference only `expanduser()`s. NOT OBSERVABLE, so it stays: `p` is
|
|
427
429
|
* consumed by `isFile` and `mcpLoad` and never reaches a response body, and both of those
|
|
428
430
|
* follow symlinks and resolve a relative path against the same cwd anyway.
|
|
429
431
|
*/
|
|
@@ -476,7 +478,7 @@ export function wikiItems(state) {
|
|
|
476
478
|
if (!w || typeof w !== 'object' || Array.isArray(w)) continue;
|
|
477
479
|
const wname = String(dget(w, 'name', null) || 'wiki');
|
|
478
480
|
// PER ENTRY, and the loop continues — one unusable `path` in the manifest must not blank
|
|
479
|
-
// every OTHER vault in it. `
|
|
481
|
+
// every OTHER vault in it. `resolvePath` where the reference only `expanduser()`s is again
|
|
480
482
|
// unobservable: `root` is only ever joined against or used as the base of a `relative()`
|
|
481
483
|
// whose other operand was built FROM it, and `source` is `.resolve()`d on both sides.
|
|
482
484
|
const root = wikiPath(String(dget(w, 'path', null) || ''));
|
|
@@ -504,7 +506,7 @@ export function wikiItems(state) {
|
|
|
504
506
|
seen.add(key);
|
|
505
507
|
items.push({ name: key, title: path.basename(md, '.md'),
|
|
506
508
|
desc: mds.length === 1 ? desc : r, type: 'wiki', kind: 'page', group: wname,
|
|
507
|
-
source:
|
|
509
|
+
source: resolvePath(md) });
|
|
508
510
|
}
|
|
509
511
|
}
|
|
510
512
|
}
|
|
@@ -525,7 +527,7 @@ export function apiWikiItem(state, name) {
|
|
|
525
527
|
// where the reference lands when the expanded root turns out not to exist.
|
|
526
528
|
const root = wikiPath(String(dget(w, 'path', null) || ''));
|
|
527
529
|
if (!root) continue;
|
|
528
|
-
const p =
|
|
530
|
+
const p = resolvePath(path.join(root, rel));
|
|
529
531
|
if (rel && path.extname(p) === '.md' && within(p, root) && isFile(p)) {
|
|
530
532
|
const body = readMaybe(p) ?? '';
|
|
531
533
|
return { type: 'wiki', name, title: path.basename(p, '.md'), desc: '',
|
|
@@ -536,10 +538,13 @@ export function apiWikiItem(state, name) {
|
|
|
536
538
|
}
|
|
537
539
|
|
|
538
540
|
/**
|
|
539
|
-
* `harness._within` — re-exported from `js/lib/
|
|
541
|
+
* `harness._within` — re-exported from `js/lib/paths.mjs`, where the lib split put it.
|
|
542
|
+
*
|
|
543
|
+
* ⚠ A SECOND, INDEPENDENT COPY lives in `js/inspect/scan.mjs`, written for the doctor's tree
|
|
544
|
+
* walks. A change to the containment rule has to land on both, and nothing compares them.
|
|
540
545
|
*
|
|
541
546
|
* It lived here from P6b (the restore's containment check) until `_install_move_list` needed
|
|
542
|
-
* the same predicate from `js/uninstall.mjs`, which must not import the web tree. The
|
|
547
|
+
* the same predicate from `js/maintain/uninstall.mjs`, which must not import the web tree. The
|
|
543
548
|
* re-export keeps `js/web/actions.mjs`'s import path unchanged.
|
|
544
549
|
*/
|
|
545
550
|
export { within };
|
|
@@ -577,9 +582,9 @@ export function resolveLinks(state, body) {
|
|
|
577
582
|
const links = [];
|
|
578
583
|
const seen = new Set();
|
|
579
584
|
for (const m of body.matchAll(WIKILINK_RE)) {
|
|
580
|
-
// `
|
|
585
|
+
// `stripWhitespace`, not `trim()` — P6d measured the two whitespace classes apart and P6e
|
|
581
586
|
// gave this regex a second reader, so the two call sites answer with one rule.
|
|
582
|
-
const label =
|
|
587
|
+
const label = stripWhitespace(m[1]);
|
|
583
588
|
if (known.has(label) && !seen.has(label)) {
|
|
584
589
|
seen.add(label);
|
|
585
590
|
links.push({ label, type: known.get(label), name: label });
|
|
@@ -715,7 +720,7 @@ export function apiItem(state, type, name) {
|
|
|
715
720
|
if (!isFile(p)) throw new NotFound(name);
|
|
716
721
|
const body = readMaybe(p) ?? '';
|
|
717
722
|
return { type, name, title: name, desc: '', body,
|
|
718
|
-
links: resolveLinks(state, body), source:
|
|
723
|
+
links: resolveLinks(state, body), source: resolvePath(p) };
|
|
719
724
|
}
|
|
720
725
|
if (type === 'wiki') return apiWikiItem(state, name);
|
|
721
726
|
if (type === 'config') {
|
|
@@ -725,7 +730,7 @@ export function apiItem(state, type, name) {
|
|
|
725
730
|
const raw = readMaybe(p) ?? '';
|
|
726
731
|
const [title, desc] = CONFIG_META[name] ?? [name, ''];
|
|
727
732
|
return { type, name, title, desc, manifest: configManifest(name, p),
|
|
728
|
-
body: `\`\`\`json\n${raw}\n\`\`\``, links: [], source:
|
|
733
|
+
body: `\`\`\`json\n${raw}\n\`\`\``, links: [], source: resolvePath(p) };
|
|
729
734
|
}
|
|
730
735
|
throw new NotFound(type);
|
|
731
736
|
}
|
|
@@ -837,8 +842,18 @@ export function apiOverview(state) {
|
|
|
837
842
|
}
|
|
838
843
|
}
|
|
839
844
|
let buildTime = null;
|
|
845
|
+
// The SAME mtime twice, on purpose. `build_time` is the stamp a person reads; `build_epoch`
|
|
846
|
+
// is the number a relative label ("20h") is computed from. The console used to have only the
|
|
847
|
+
// first and had to re-parse it, and `Date.parse` on a space-separated local stamp is
|
|
848
|
+
// engine-dependent — a plausible-but-wrong "2h" is the exact failure a second field costs
|
|
849
|
+
// nothing to prevent.
|
|
850
|
+
let buildEpoch = null;
|
|
840
851
|
const agentMd = path.join(state.target, 'AGENT.md');
|
|
841
|
-
if (isFile(agentMd))
|
|
852
|
+
if (isFile(agentMd)) {
|
|
853
|
+
const ms = statSync(agentMd).mtimeMs;
|
|
854
|
+
buildTime = stampMinute(ms);
|
|
855
|
+
buildEpoch = Math.floor(ms / 1000);
|
|
856
|
+
}
|
|
842
857
|
// Which detected install the current view points at, so the dashboard's footprint hero
|
|
843
858
|
// can re-emit exactly it. Mirrors `viewCfg`'s rule, spelled out as the reference spells
|
|
844
859
|
// it out — the two are separate code in the Python too.
|
|
@@ -898,22 +913,107 @@ export function apiOverview(state) {
|
|
|
898
913
|
doctor: state.doctor,
|
|
899
914
|
diff,
|
|
900
915
|
build_time: buildTime,
|
|
916
|
+
build_epoch: buildEpoch,
|
|
917
|
+
// The RELEASE LABEL OF THE SOURCE THIS CONSOLE IS SERVED FROM, not of the deployed install —
|
|
918
|
+
// the two differ whenever something has changed since the last emit, which is exactly when
|
|
919
|
+
// knowing which one you are looking at matters. `.geneseed-version` carries the install's own
|
|
920
|
+
// label and the fingerprint comparison already reports the drift; this answers the plainer
|
|
921
|
+
// question the topbar asks, "what is running".
|
|
922
|
+
//
|
|
923
|
+
// Degrades to null rather than to a wrong number: `sourceReleaseVersion` answers '0.0.0' for
|
|
924
|
+
// an unreadable config, and a console confidently displaying 0.0.0 is worse than one that
|
|
925
|
+
// shows nothing.
|
|
926
|
+
version: releaseLabel(),
|
|
901
927
|
};
|
|
902
928
|
}
|
|
903
929
|
|
|
930
|
+
/**
|
|
931
|
+
* How many files `apiRecent` will `stat` before it gives up on a section.
|
|
932
|
+
*
|
|
933
|
+
* The wiki is the only section with no ceiling — `WIKI_FILE_CAP` lets one vault contribute
|
|
934
|
+
* 5000 pages — and "the newest N of the first 1200 I looked at" is not the newest N. So a
|
|
935
|
+
* section over the cap is DROPPED and NAMED in `skipped`, rather than answered from a
|
|
936
|
+
* truncated pool: the card can then say which shelves it read, which is a fact, where a
|
|
937
|
+
* silently-biased list would be a wrong one nobody could catch.
|
|
938
|
+
*/
|
|
939
|
+
const RECENT_STAT_CAP = 1200;
|
|
940
|
+
|
|
941
|
+
/** How many entries `/api/recent` returns — the card shows a handful, not a feed. */
|
|
942
|
+
const RECENT_LIMIT = 8;
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* THE NEWEST FILE-BACKED ENTRIES ACROSS THE HARNESS — the "freshly grown" card's whole payload.
|
|
946
|
+
*
|
|
947
|
+
* A GET BEYOND THE REFERENCE (`GET_BEYOND_REF` in `routes.mjs`), and the first one. It exists
|
|
948
|
+
* because NOTHING else in this API carries a per-entry date: every catalog row is
|
|
949
|
+
* `{name, title, desc, source}` and the only two timestamps on the whole surface are
|
|
950
|
+
* `overview.build_time` and `doctor.checked_at`. A client cannot stat a path, so "what changed
|
|
951
|
+
* lately" was unanswerable without a server that looks.
|
|
952
|
+
*
|
|
953
|
+
* ONLY THE SECTIONS THE USER WRITES BY HAND. Memory, notebook, wiki and config each name a
|
|
954
|
+
* file someone actually authored, at a moment that means something.
|
|
955
|
+
*
|
|
956
|
+
* ⚠ SKILLS AND AGENTS ARE EXCLUDED, AND THEY WERE IN HERE FIRST — a live check is what took
|
|
957
|
+
* them out. Their `source` is the EMITTED artefact (`~/.config/opencode/skills/<x>/SKILL.md`),
|
|
958
|
+
* written by the last build, so on a real install all 67 of them carry ONE mtime and it is
|
|
959
|
+
* `overview.build_epoch` to the second. Sorted newest-first they filled the whole answer with
|
|
960
|
+
* the alphabetical head of the last rebuild — `advocate, architect, brainstorm, clarify…` —
|
|
961
|
+
* and pushed out every genuinely recent memory and note. Dating a rendered file dates the
|
|
962
|
+
* BUILD, which the overview already states once and correctly. (Reading the repo's `src/`
|
|
963
|
+
* instead would not save it: a fresh clone stamps every file with the checkout.)
|
|
964
|
+
*
|
|
965
|
+
* The constitution is out for the same family of reason: its tiers are entries inside shared
|
|
966
|
+
* law files, so a per-rule mtime would be the file's, repeated — one wrong date on nine rows.
|
|
967
|
+
*/
|
|
968
|
+
export function apiRecent(state) {
|
|
969
|
+
const sections = [
|
|
970
|
+
['memory', 'memory', () => memoryItems(state)],
|
|
971
|
+
['notebook', 'notebook', () => notebookItems(state)],
|
|
972
|
+
['wiki', 'wiki', () => wikiItems(state)],
|
|
973
|
+
['config', 'config', () => configItems(state)],
|
|
974
|
+
];
|
|
975
|
+
const items = [];
|
|
976
|
+
const skipped = [];
|
|
977
|
+
for (const [section, type, load] of sections) {
|
|
978
|
+
let rows;
|
|
979
|
+
try { rows = load() ?? []; } catch { skipped.push(section); continue; }
|
|
980
|
+
if (rows.length > RECENT_STAT_CAP) { skipped.push(section); continue; }
|
|
981
|
+
for (const r of rows) {
|
|
982
|
+
if (!r.source) continue;
|
|
983
|
+
let mtime;
|
|
984
|
+
// A row whose file has been moved or deleted since the inventory was rendered is not an
|
|
985
|
+
// error — it is simply not among the newest anything. Skipping beats a null date that
|
|
986
|
+
// would sort somewhere.
|
|
987
|
+
try { mtime = Math.floor(statSync(r.source).mtimeMs / 1000); } catch { continue; }
|
|
988
|
+
items.push({ section, type, name: r.name, title: r.title || r.name, mtime });
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
// Newest first, ties broken by name so the order is stable between two requests a second
|
|
992
|
+
// apart — several files written by one rebuild share a second exactly.
|
|
993
|
+
items.sort((a, b) => (b.mtime - a.mtime) || comparePaths(a.name, b.name));
|
|
994
|
+
return { items: items.slice(0, RECENT_LIMIT), skipped, limit: RECENT_LIMIT };
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/** The source's release label, or null when it cannot be read. */
|
|
998
|
+
function releaseLabel() {
|
|
999
|
+
const v = sourceReleaseVersion({ config: CONFIG });
|
|
1000
|
+
return v && v !== '0.0.0' ? v : null;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
904
1003
|
/**
|
|
905
1004
|
* `_web_server.Handler.STATE_ROUTES`, the ported half.
|
|
906
1005
|
*
|
|
907
1006
|
* A literal path to `api_X(state)`, exactly as the reference's table is — and the reason
|
|
908
|
-
* it is a table on this side too is `tests/
|
|
909
|
-
*
|
|
910
|
-
*
|
|
911
|
-
*
|
|
1007
|
+
* it is a table on this side too is `tests/unit/web_server.test.mjs`'s cross-check, which
|
|
1008
|
+
* requires this table plus `PREFIX_ROUTES`, `PORTED_INLINE` and `NOT_PORTED` to equal
|
|
1009
|
+
* `REF_GET` — a FROZEN LITERAL of what the reference daemon answered, since the source it
|
|
1010
|
+
* was once read from with `ast` is deleted. On the second instance of anything, the gate
|
|
1011
|
+
* becomes a table cross-checked against a declaration.
|
|
912
1012
|
*/
|
|
913
1013
|
/**
|
|
914
1014
|
* `_web_server.do_GET`'s prefix routes, the ported ones — path in, response out.
|
|
915
1015
|
*
|
|
916
|
-
* `
|
|
1016
|
+
* `percentDecode` and not `decodeURIComponent`: the JS builtin throws a `URIError` on a `%`
|
|
917
1017
|
* that is not an escape, which the shell would answer as a 500 where the reference answers
|
|
918
1018
|
* a 404 naming the literal text.
|
|
919
1019
|
*/
|
|
@@ -925,13 +1025,13 @@ export const PREFIX_ROUTES = [
|
|
|
925
1025
|
['/api/item/', (state, p) => {
|
|
926
1026
|
const parts = splitN(p, '/', 4);
|
|
927
1027
|
if (parts.length < 5 || !parts[4]) throw new NotFound(p);
|
|
928
|
-
return apiItem(state, parts[3],
|
|
1028
|
+
return apiItem(state, parts[3], percentDecode(parts[4]));
|
|
929
1029
|
}],
|
|
930
1030
|
// P6e. The reference's own `path[len("/api/activity/"):]`, unquoted — the sid keeps
|
|
931
1031
|
// whatever it carries and `api_activity_detail`'s safe-name scheme is what makes the
|
|
932
1032
|
// lookup safe, rather than a check out here.
|
|
933
1033
|
['/api/activity/', (state, p) => apiActivityDetail(
|
|
934
|
-
state,
|
|
1034
|
+
state, percentDecode(p.slice('/api/activity/'.length)))],
|
|
935
1035
|
];
|
|
936
1036
|
|
|
937
1037
|
/** `str.split(sep, maxsplit)` — at most `n` splits, the remainder kept whole. */
|
|
@@ -950,6 +1050,9 @@ function splitN(s, sep, n) {
|
|
|
950
1050
|
|
|
951
1051
|
export const STATE_ROUTES = {
|
|
952
1052
|
'/api/overview': apiOverview,
|
|
1053
|
+
// Beyond the reference — declared in `GET_BEYOND_REF`, which is what keeps the partition
|
|
1054
|
+
// gate's set equality honest instead of forging `REF_GET`.
|
|
1055
|
+
'/api/recent': apiRecent,
|
|
953
1056
|
'/api/themes': apiThemes,
|
|
954
1057
|
'/api/setup': apiSetup,
|
|
955
1058
|
'/api/doctor': apiDoctor,
|