@jbarket/loomai 0.7.0 → 0.8.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 +21 -1
- package/README.md +20 -0
- package/dist/transport/http-server.js +14 -0
- package/dist/ui/core.d.ts +9 -0
- package/dist/ui/core.js +140 -0
- package/dist/ui/handler.d.ts +11 -0
- package/dist/ui/handler.js +159 -0
- package/dist/ui/piece-sections.d.ts +20 -0
- package/dist/ui/piece-sections.js +112 -0
- package/dist/ui/render.d.ts +16 -0
- package/dist/ui/render.js +70 -0
- package/dist/ui/sections.d.ts +51 -0
- package/dist/ui/sections.js +121 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.8.0] - 2026-10-24
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **loom's page: `/ui` on the HTTP daemon.** A read-only web page with loom's
|
|
20
|
+
own sections: top of mind, the last 24h of episodes, proposals waiting, and
|
|
21
|
+
people. It also gets a group for each installed piece that answers
|
|
22
|
+
`loom-<piece> ui --json` in a small section vocabulary (stats, list, table,
|
|
23
|
+
text, link). Pieces send data, never markup: everything is escaped, the page
|
|
24
|
+
has no script, and a CSP forbids script and framing. Invalid answers are
|
|
25
|
+
dropped whole, and a slow or missing piece is left out.
|
|
26
|
+
- **Login:** the bearer token, once. The page requires `LOOM_BEARER_TOKEN` (no
|
|
27
|
+
token gives 403, like `/cli`). The cookie is keyed per daemon process, isn't
|
|
28
|
+
the token, and lasts 30 days or until a restart. Pieces never see loom's
|
|
29
|
+
tokens; on a timeout their whole process group is killed; concurrent loads
|
|
30
|
+
share one run per piece.
|
|
31
|
+
- **`/ui/sections.json`:** the same data, for scripts.
|
|
32
|
+
- **Scope:** full scope only.
|
|
33
|
+
|
|
15
34
|
## [0.7.0] - 2026-10-24
|
|
16
35
|
|
|
17
36
|
### Added
|
|
@@ -588,7 +607,8 @@ Initial public release.
|
|
|
588
607
|
stack and all added external-service dependencies or operational
|
|
589
608
|
overhead.
|
|
590
609
|
|
|
591
|
-
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.
|
|
610
|
+
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.8.0...HEAD
|
|
611
|
+
[0.8.0]: https://github.com/sleepunit-agents/loom/compare/v0.7.0...v0.8.0
|
|
592
612
|
[0.7.0]: https://github.com/sleepunit-agents/loom/compare/v0.6.0...v0.7.0
|
|
593
613
|
[0.6.0]: https://github.com/sleepunit-agents/loom/compare/v0.5.0...v0.6.0
|
|
594
614
|
[0.5.0]: https://github.com/sleepunit-agents/loom/compare/v0.4.1...v0.5.0
|
package/README.md
CHANGED
|
@@ -383,6 +383,26 @@ header, or a body that isn't `application/json`). The knowledge-only daemon
|
|
|
383
383
|
has no `/cli`. Every other command (`doctor`, `inject`, `serve`, …) stays
|
|
384
384
|
local, and `--url` on one of them is an error.
|
|
385
385
|
|
|
386
|
+
### loom's page — `/ui`
|
|
387
|
+
|
|
388
|
+
The full-scope daemon serves a read-only web page at `/ui`. It shows top of
|
|
389
|
+
mind, the last 24h of episodes from every body, proposals waiting for
|
|
390
|
+
ratification, and person cards with their freshness. Below that, it shows a
|
|
391
|
+
group for each installed **piece** that answers `ui --json`: a chat bridge's
|
|
392
|
+
live conversations, the task queue, and so on. Nothing to configure: install
|
|
393
|
+
a piece and it shows up ([`docs/extensions.md`](docs/extensions.md), "UI
|
|
394
|
+
sections").
|
|
395
|
+
|
|
396
|
+
**The page needs `LOOM_BEARER_TOKEN`.** A daemon with no token serves none
|
|
397
|
+
(403), the same rule as `/cli`, because any site that DNS-rebinds a name to
|
|
398
|
+
the loopback address could read an open page. Log in once with the token. The
|
|
399
|
+
browser keeps a cookie under a key the daemon made at start: it's not the
|
|
400
|
+
token, it can't be replayed against `/mcp` or `/cli`, and it can't be used to
|
|
401
|
+
guess the token. It lasts 30 days, or until the daemon restarts. Scripts can
|
|
402
|
+
send the bearer header to `/ui/sections.json` for the same data. The page has
|
|
403
|
+
no script and is served under a CSP that forbids script and framing. Pieces
|
|
404
|
+
never receive loom's bearer tokens.
|
|
405
|
+
|
|
386
406
|
### Behind a reverse proxy
|
|
387
407
|
|
|
388
408
|
Terminate TLS at a reverse proxy in front of the daemon and point the
|
|
@@ -32,6 +32,7 @@ import { EmptyResultSchema } from '@modelcontextprotocol/core';
|
|
|
32
32
|
import { createLoomServer } from '../server.js';
|
|
33
33
|
import { createBackend } from '../backends/index.js';
|
|
34
34
|
import { handleCliBody } from './cli-endpoint.js';
|
|
35
|
+
import { handleUi, isUiPath } from '../ui/handler.js';
|
|
35
36
|
import { assertSafeBind, checkBearer, checkBearerMulti, checkPayloadSize, DEFAULT_MAX_BODY_BYTES, } from './guards.js';
|
|
36
37
|
/**
|
|
37
38
|
* SSE keep-alive: the SDK opens the standalone GET stream but sends no
|
|
@@ -196,6 +197,19 @@ export async function startHttpServer(opts) {
|
|
|
196
197
|
}
|
|
197
198
|
const http = createServer((req, res) => {
|
|
198
199
|
void (async () => {
|
|
200
|
+
// /ui: loom's web page (src/ui). Its own auth (a cookie from the bearer,
|
|
201
|
+
// or the bearer header), so it comes before the bearer gate. Full scope
|
|
202
|
+
// only: a scoped server has no memory to show.
|
|
203
|
+
const uiPath = new URL(req.url ?? '/', 'http://localhost').pathname;
|
|
204
|
+
if (isUiPath(uiPath)) {
|
|
205
|
+
if (opts.createServer)
|
|
206
|
+
return sendError(res, 404, 'not-found: this server has no UI');
|
|
207
|
+
return handleUi(req, res, uiPath, {
|
|
208
|
+
contextDir: opts.contextDir,
|
|
209
|
+
secrets: opts.token ? [opts.token] : Object.values(opts.tokens ?? {}),
|
|
210
|
+
env: process.env,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
199
213
|
// Auth gate (ac-lt-auth-gate): reject unauthenticated when a token (or
|
|
200
214
|
// token map) is configured. Multi-token mode additionally resolves
|
|
201
215
|
// WHICH identity made this call — the bearer itself is the identity
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Section } from './sections.js';
|
|
2
|
+
/** loom's zone (LOOM_TIMEZONE, then TZ), and one that Intl accepts: a bad value never breaks the page. */
|
|
3
|
+
export declare function uiTimeZone(): string;
|
|
4
|
+
/** The agent's name: IDENTITY.md's first heading, else the context dir's name. */
|
|
5
|
+
export declare function agentName(contextDir: string): string;
|
|
6
|
+
export declare function coreSections(contextDir: string, opts?: {
|
|
7
|
+
timeZone?: string;
|
|
8
|
+
nowMs?: number;
|
|
9
|
+
}): Section[];
|
package/dist/ui/core.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loom's own sections for the UI: what only loom knows. Read-only views over
|
|
3
|
+
* the stores (top of mind, the episode tape, pending proposals, people),
|
|
4
|
+
* assembled from the same functions the identity payload and the MCP-App
|
|
5
|
+
* widgets use. Nothing here writes.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { digestData } from '../backends/salience.js';
|
|
10
|
+
import { episodeRows, tapeClock } from '../backends/episodes.js';
|
|
11
|
+
import { listProposals } from '../backends/proposals.js';
|
|
12
|
+
import { listPeople } from '../tools/person.js';
|
|
13
|
+
const TOP_OF_MIND = 12;
|
|
14
|
+
const TAPE_HOURS = 24;
|
|
15
|
+
const TAPE_SHOWN = 20;
|
|
16
|
+
function whereOf(metadata, project) {
|
|
17
|
+
try {
|
|
18
|
+
const meta = JSON.parse(metadata || '{}');
|
|
19
|
+
if (typeof meta.where === 'string' && meta.where.trim())
|
|
20
|
+
return meta.where.trim();
|
|
21
|
+
}
|
|
22
|
+
catch { /* fall through */ }
|
|
23
|
+
return project?.trim() || '?';
|
|
24
|
+
}
|
|
25
|
+
/** loom's zone (LOOM_TIMEZONE, then TZ), and one that Intl accepts: a bad value never breaks the page. */
|
|
26
|
+
export function uiTimeZone() {
|
|
27
|
+
for (const z of [process.env.LOOM_TIMEZONE, process.env.TZ, 'America/Chicago']) {
|
|
28
|
+
if (!z)
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
new Intl.DateTimeFormat('en-US', { timeZone: z });
|
|
32
|
+
return z;
|
|
33
|
+
}
|
|
34
|
+
catch { /* next */ }
|
|
35
|
+
}
|
|
36
|
+
return 'UTC';
|
|
37
|
+
}
|
|
38
|
+
function tierTone(tier) {
|
|
39
|
+
return tier === 'cold' ? 'muted' : undefined;
|
|
40
|
+
}
|
|
41
|
+
/** The agent's name: IDENTITY.md's first heading, else the context dir's name. */
|
|
42
|
+
export function agentName(contextDir) {
|
|
43
|
+
try {
|
|
44
|
+
const line = readFileSync(join(contextDir, 'IDENTITY.md'), 'utf-8').split('\n').find((l) => /^#\s+\S/.test(l));
|
|
45
|
+
if (line)
|
|
46
|
+
return line.replace(/^#\s+/, '').trim();
|
|
47
|
+
}
|
|
48
|
+
catch { /* no identity file */ }
|
|
49
|
+
return contextDir.split('/').filter(Boolean).pop() ?? 'loom';
|
|
50
|
+
}
|
|
51
|
+
export function coreSections(contextDir, opts = {}) {
|
|
52
|
+
const timeZone = opts.timeZone ?? uiTimeZone();
|
|
53
|
+
const digest = digestData(contextDir, TOP_OF_MIND);
|
|
54
|
+
const episodes = episodeRows(contextDir, { hours: TAPE_HOURS, nowMs: opts.nowMs });
|
|
55
|
+
const pending = listProposals(contextDir).filter((p) => p.status === 'pending');
|
|
56
|
+
const people = listPeople(contextDir);
|
|
57
|
+
const sections = [
|
|
58
|
+
{
|
|
59
|
+
id: 'overview',
|
|
60
|
+
title: 'Overview',
|
|
61
|
+
blocks: [{
|
|
62
|
+
type: 'stats',
|
|
63
|
+
items: [
|
|
64
|
+
{ label: 'memories', value: String(digest?.total ?? 0) },
|
|
65
|
+
{ label: `episodes, last ${TAPE_HOURS}h`, value: String(episodes.length) },
|
|
66
|
+
{ label: 'proposals pending', value: String(pending.length), tone: pending.length ? 'warn' : undefined },
|
|
67
|
+
{ label: 'people', value: String(people.length) },
|
|
68
|
+
],
|
|
69
|
+
}],
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'tape',
|
|
73
|
+
title: `Last ${TAPE_HOURS}h`,
|
|
74
|
+
blocks: episodes.length
|
|
75
|
+
? [
|
|
76
|
+
{
|
|
77
|
+
type: 'list',
|
|
78
|
+
items: [...episodes].reverse().slice(0, TAPE_SHOWN).map((e) => ({
|
|
79
|
+
text: e.title,
|
|
80
|
+
meta: `${tapeClock(e.created, timeZone)} · ${whereOf(e.metadata, e.project)}`,
|
|
81
|
+
})),
|
|
82
|
+
},
|
|
83
|
+
...(episodes.length > TAPE_SHOWN
|
|
84
|
+
? [{ type: 'text', text: `and ${episodes.length - TAPE_SHOWN} earlier` }]
|
|
85
|
+
: []),
|
|
86
|
+
]
|
|
87
|
+
: [{ type: 'text', text: 'No episodes in the last day.' }],
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
id: 'top-of-mind',
|
|
91
|
+
title: 'Top of mind',
|
|
92
|
+
blocks: digest && digest.atoms.length
|
|
93
|
+
? [{
|
|
94
|
+
type: 'list',
|
|
95
|
+
items: digest.atoms.map((a) => ({ text: a.title, meta: `${a.category} · ${a.tier}`, tone: tierTone(a.tier) })),
|
|
96
|
+
}]
|
|
97
|
+
: [{ type: 'text', text: 'Nothing hot yet.' }],
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
if (pending.length) {
|
|
101
|
+
sections.push({
|
|
102
|
+
id: 'proposals',
|
|
103
|
+
title: 'Proposals waiting',
|
|
104
|
+
blocks: [{
|
|
105
|
+
type: 'list',
|
|
106
|
+
items: pending.map((p) => ({
|
|
107
|
+
text: p.title ?? '(untitled)',
|
|
108
|
+
meta: `#${p.id} · ${p.category ?? '?'}${p.source ? ` · ${p.source}` : ''}`,
|
|
109
|
+
tone: 'warn',
|
|
110
|
+
})),
|
|
111
|
+
}],
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (people.length) {
|
|
115
|
+
// Only the cards that need a look: stale or unverified fields, or memories
|
|
116
|
+
// written since the summary was last synthesized.
|
|
117
|
+
const attention = people.filter((p) => p.stale || p.unverified || p.memories_since_synthesis);
|
|
118
|
+
sections.push({
|
|
119
|
+
id: 'people',
|
|
120
|
+
title: 'People',
|
|
121
|
+
blocks: [
|
|
122
|
+
{
|
|
123
|
+
type: 'stats',
|
|
124
|
+
items: [
|
|
125
|
+
{ label: 'cards', value: String(people.length) },
|
|
126
|
+
{ label: 'need a look', value: String(attention.length), tone: attention.length ? 'warn' : 'ok' },
|
|
127
|
+
],
|
|
128
|
+
},
|
|
129
|
+
attention.length
|
|
130
|
+
? {
|
|
131
|
+
type: 'table',
|
|
132
|
+
columns: ['name', 'stale', 'unverified', 'new memories'],
|
|
133
|
+
rows: attention.map((p) => [p.name, String(p.stale), String(p.unverified), String(p.memories_since_synthesis)]),
|
|
134
|
+
}
|
|
135
|
+
: { type: 'text', text: 'Every card is fresh.' },
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return sections;
|
|
140
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
export interface UiOptions {
|
|
3
|
+
contextDir: string;
|
|
4
|
+
/** Configured bearer secrets (the single token, or the token map's values). Empty = open. */
|
|
5
|
+
secrets: string[];
|
|
6
|
+
/** Env for piece discovery and piece processes (PATH decides which pieces exist). */
|
|
7
|
+
env: NodeJS.ProcessEnv;
|
|
8
|
+
}
|
|
9
|
+
export declare function isUiPath(pathname: string): boolean;
|
|
10
|
+
/** Handle a /ui request. The caller has already matched isUiPath. */
|
|
11
|
+
export declare function handleUi(req: IncomingMessage, res: ServerResponse, pathname: string, opts: UiOptions): Promise<void>;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loom's web UI on the HTTP daemon (full scope only): GET /ui, the page;
|
|
3
|
+
* GET /ui/sections.json, the same sections as data; /ui/login and /ui/logout.
|
|
4
|
+
*
|
|
5
|
+
* Auth follows the daemon's bearer token, and the UI needs one: a daemon with
|
|
6
|
+
* no token serves no /ui (like /cli), because an open page would be readable
|
|
7
|
+
* by any site that DNS-rebinds a name to the loopback address. A browser logs
|
|
8
|
+
* in once by pasting the token. The cookie is `<issued-at>.<mac>`, a MAC under
|
|
9
|
+
* a key this process made at start, over the issued-at time and a digest of
|
|
10
|
+
* the token. It isn't the token and isn't derivable from it, so it can't be
|
|
11
|
+
* replayed as a bearer or used to guess the token offline. It lasts 30 days,
|
|
12
|
+
* or until the daemon restarts (log in again). Scripts can send the bearer
|
|
13
|
+
* header instead. The page is read-only, has no script, and is served under a
|
|
14
|
+
* CSP that forbids script and framing.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
17
|
+
import { checkBearer } from '../transport/guards.js';
|
|
18
|
+
import { agentName, coreSections, uiTimeZone } from './core.js';
|
|
19
|
+
import { pieceSections } from './piece-sections.js';
|
|
20
|
+
import { renderLogin, renderPage } from './render.js';
|
|
21
|
+
const COOKIE = 'loom_ui';
|
|
22
|
+
const LOGIN_MAX_BYTES = 4096;
|
|
23
|
+
const COOKIE_MAX_AGE_S = 30 * 24 * 3600;
|
|
24
|
+
/** This process's cookie key. Never written anywhere: a restart means logging in again. */
|
|
25
|
+
const COOKIE_KEY = randomBytes(32);
|
|
26
|
+
const SECURITY_HEADERS = {
|
|
27
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
|
|
28
|
+
'x-content-type-options': 'nosniff',
|
|
29
|
+
'referrer-policy': 'no-referrer',
|
|
30
|
+
'cache-control': 'no-store',
|
|
31
|
+
};
|
|
32
|
+
export function isUiPath(pathname) {
|
|
33
|
+
return pathname === '/ui' || pathname.startsWith('/ui/');
|
|
34
|
+
}
|
|
35
|
+
const digest = (s) => createHash('sha256').update(s).digest();
|
|
36
|
+
function mac(issuedAt, secret) {
|
|
37
|
+
return createHmac('sha256', COOKIE_KEY).update(`loom-ui-v2|${issuedAt}|`).update(digest(secret)).digest('hex');
|
|
38
|
+
}
|
|
39
|
+
function cookieValue(secret, issuedAt = Math.floor(Date.now() / 1000)) {
|
|
40
|
+
return `${issuedAt}.${mac(issuedAt, secret)}`;
|
|
41
|
+
}
|
|
42
|
+
/** Equal-length digests, so neither the content nor the length of a guess leaks. */
|
|
43
|
+
function safeEq(a, b) {
|
|
44
|
+
return timingSafeEqual(digest(a), digest(b));
|
|
45
|
+
}
|
|
46
|
+
function validCookie(value, secrets, nowS = Math.floor(Date.now() / 1000)) {
|
|
47
|
+
const m = /^(\d{1,12})\.([0-9a-f]{64})$/.exec(value);
|
|
48
|
+
if (!m)
|
|
49
|
+
return false;
|
|
50
|
+
const issuedAt = Number(m[1]);
|
|
51
|
+
if (issuedAt > nowS + 60 || nowS - issuedAt > COOKIE_MAX_AGE_S)
|
|
52
|
+
return false;
|
|
53
|
+
return secrets.some((s) => safeEq(m[2], mac(issuedAt, s)));
|
|
54
|
+
}
|
|
55
|
+
function readCookie(req, name) {
|
|
56
|
+
for (const part of (req.headers.cookie ?? '').split(';')) {
|
|
57
|
+
const [k, ...v] = part.trim().split('=');
|
|
58
|
+
if (k === name)
|
|
59
|
+
return v.join('=');
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
function authorized(req, opts) {
|
|
64
|
+
if (opts.secrets.length === 0)
|
|
65
|
+
return false;
|
|
66
|
+
const c = readCookie(req, COOKIE);
|
|
67
|
+
if (c && validCookie(c, opts.secrets))
|
|
68
|
+
return true;
|
|
69
|
+
const header = req.headers['authorization'];
|
|
70
|
+
return header !== undefined && opts.secrets.some((s) => checkBearer(s, header).ok);
|
|
71
|
+
}
|
|
72
|
+
function send(res, status, type, body, extra = {}) {
|
|
73
|
+
res.writeHead(status, { 'content-type': type, ...SECURITY_HEADERS, ...extra });
|
|
74
|
+
res.end(body);
|
|
75
|
+
}
|
|
76
|
+
function redirect(res, to, extra = {}) {
|
|
77
|
+
res.writeHead(303, { location: to, ...SECURITY_HEADERS, ...extra });
|
|
78
|
+
res.end();
|
|
79
|
+
}
|
|
80
|
+
function readForm(req) {
|
|
81
|
+
return new Promise((resolve) => {
|
|
82
|
+
let body = '';
|
|
83
|
+
let done = false;
|
|
84
|
+
const finish = (v) => { if (!done) {
|
|
85
|
+
done = true;
|
|
86
|
+
resolve(v);
|
|
87
|
+
} };
|
|
88
|
+
req.on('data', (d) => {
|
|
89
|
+
if (done)
|
|
90
|
+
return;
|
|
91
|
+
body += d.toString('utf-8');
|
|
92
|
+
if (body.length > LOGIN_MAX_BYTES) {
|
|
93
|
+
finish(null);
|
|
94
|
+
req.destroy();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
req.on('end', () => finish(new URLSearchParams(body)));
|
|
98
|
+
req.on('error', () => finish(null));
|
|
99
|
+
req.on('close', () => finish(null));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
async function sections(opts) {
|
|
103
|
+
const core = coreSections(opts.contextDir);
|
|
104
|
+
const { contributed, skipped } = await pieceSections(opts.env);
|
|
105
|
+
return { core, pieces: contributed, skipped };
|
|
106
|
+
}
|
|
107
|
+
/** Handle a /ui request. The caller has already matched isUiPath. */
|
|
108
|
+
export async function handleUi(req, res, pathname, opts) {
|
|
109
|
+
// No token, no page: an open /ui would be readable by any site that
|
|
110
|
+
// DNS-rebinds a name to the loopback address (the same rule as /cli).
|
|
111
|
+
if (opts.secrets.length === 0) {
|
|
112
|
+
return send(res, 403, 'text/plain', 'forbidden: loom\'s page requires LOOM_BEARER_TOKEN');
|
|
113
|
+
}
|
|
114
|
+
const secure = req.headers['x-forwarded-proto'] === 'https';
|
|
115
|
+
if (pathname === '/ui/login') {
|
|
116
|
+
// Unauthenticated: the login page names no agent.
|
|
117
|
+
if (req.method === 'GET')
|
|
118
|
+
return send(res, 200, 'text/html; charset=utf-8', renderLogin());
|
|
119
|
+
if (req.method !== 'POST')
|
|
120
|
+
return send(res, 405, 'text/plain', 'method not allowed');
|
|
121
|
+
const form = await readForm(req);
|
|
122
|
+
if (!form)
|
|
123
|
+
return send(res, 413, 'text/plain', 'too large');
|
|
124
|
+
const token = form.get('token')?.trim() ?? '';
|
|
125
|
+
const match = opts.secrets.find((s) => safeEq(token, s));
|
|
126
|
+
if (!match)
|
|
127
|
+
return send(res, 401, 'text/html; charset=utf-8', renderLogin('That token didn’t match.'));
|
|
128
|
+
return redirect(res, '/ui', {
|
|
129
|
+
'set-cookie': `${COOKIE}=${cookieValue(match)}; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}${secure ? '; Secure' : ''}`,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (pathname === '/ui/logout') {
|
|
133
|
+
if (req.method !== 'POST')
|
|
134
|
+
return send(res, 405, 'text/plain', 'method not allowed');
|
|
135
|
+
return redirect(res, '/ui/login', { 'set-cookie': `${COOKIE}=; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=0` });
|
|
136
|
+
}
|
|
137
|
+
const agent = agentName(opts.contextDir);
|
|
138
|
+
if (req.method !== 'GET')
|
|
139
|
+
return send(res, 405, 'text/plain', 'method not allowed');
|
|
140
|
+
if (pathname === '/ui/sections.json') {
|
|
141
|
+
if (!authorized(req, opts))
|
|
142
|
+
return send(res, 401, 'application/json', JSON.stringify({ error: 'unauthorized' }));
|
|
143
|
+
const s = await sections(opts);
|
|
144
|
+
return send(res, 200, 'application/json', JSON.stringify({ agent, ...s }));
|
|
145
|
+
}
|
|
146
|
+
if (pathname === '/ui' || pathname === '/ui/') {
|
|
147
|
+
if (!authorized(req, opts))
|
|
148
|
+
return redirect(res, '/ui/login');
|
|
149
|
+
const s = await sections(opts);
|
|
150
|
+
return send(res, 200, 'text/html; charset=utf-8', renderPage({
|
|
151
|
+
agent,
|
|
152
|
+
core: s.core,
|
|
153
|
+
pieces: s.pieces,
|
|
154
|
+
skipped: s.skipped,
|
|
155
|
+
generatedAt: new Date().toLocaleTimeString('en-US', { timeZone: uiTimeZone(), hour: '2-digit', minute: '2-digit' }),
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
return send(res, 404, 'text/plain', 'not found');
|
|
159
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type Section } from './sections.js';
|
|
2
|
+
export declare const PIECE_TIMEOUT_MS = 3000;
|
|
3
|
+
export declare const PIECE_MAX_BYTES: number;
|
|
4
|
+
/** Longer than the page's 30s refresh, so an open tab doesn't re-run every piece each time. */
|
|
5
|
+
export declare const CACHE_MS = 45000;
|
|
6
|
+
export interface PieceSections {
|
|
7
|
+
piece: string;
|
|
8
|
+
sections: Section[];
|
|
9
|
+
}
|
|
10
|
+
/** Why a piece contributed nothing, for the page footer (never shown as an error). */
|
|
11
|
+
export interface PieceSkip {
|
|
12
|
+
piece: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
export declare function clearPieceCache(): void;
|
|
16
|
+
export declare function pieceEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
17
|
+
export declare function pieceSections(env: NodeJS.ProcessEnv, nowMs?: number): Promise<{
|
|
18
|
+
contributed: PieceSections[];
|
|
19
|
+
skipped: PieceSkip[];
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sections from installed pieces: each `loom-<piece>` on PATH is asked
|
|
3
|
+
* `ui --json`. A piece that answers with valid sections shows up on loom's
|
|
4
|
+
* page with no configuration; one that doesn't (no `ui` verb, a timeout, bad
|
|
5
|
+
* JSON) simply isn't there. Composes without requiring (docs/extensions.md).
|
|
6
|
+
*
|
|
7
|
+
* Bounded on every axis: one run per piece per cache window however many
|
|
8
|
+
* pages load at once (the in-flight promise is what's cached), 3s and 512KB
|
|
9
|
+
* per answer, and on a timeout the piece's whole process group is killed, not
|
|
10
|
+
* just its first process. A piece gets the daemon's env minus loom's own
|
|
11
|
+
* bearer tokens.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn } from 'node:child_process';
|
|
14
|
+
import { listPieces } from '../cli/pieces.js';
|
|
15
|
+
import { parseSections } from './sections.js';
|
|
16
|
+
export const PIECE_TIMEOUT_MS = 3_000;
|
|
17
|
+
export const PIECE_MAX_BYTES = 512 * 1024;
|
|
18
|
+
/** Longer than the page's 30s refresh, so an open tab doesn't re-run every piece each time. */
|
|
19
|
+
export const CACHE_MS = 45_000;
|
|
20
|
+
/** After the piece exits, how long to wait for its stdout to close (a background helper may hold it). */
|
|
21
|
+
const DRAIN_MS = 200;
|
|
22
|
+
/** Env loom never hands to a piece. */
|
|
23
|
+
const WITHHELD = ['LOOM_BEARER_TOKEN', 'LOOM_KNOWLEDGE_BEARER_TOKENS'];
|
|
24
|
+
const cache = new Map();
|
|
25
|
+
export function clearPieceCache() { cache.clear(); }
|
|
26
|
+
export function pieceEnv(env) {
|
|
27
|
+
const out = { ...env };
|
|
28
|
+
for (const k of WITHHELD)
|
|
29
|
+
delete out[k];
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
function killGroup(pid) {
|
|
33
|
+
if (pid === undefined)
|
|
34
|
+
return;
|
|
35
|
+
try {
|
|
36
|
+
process.kill(-pid, 'SIGKILL');
|
|
37
|
+
}
|
|
38
|
+
catch { /* already gone */ }
|
|
39
|
+
}
|
|
40
|
+
function ask(path, env) {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
// Its own process group, so a timeout takes the piece's children with it.
|
|
43
|
+
const child = spawn(path, ['ui', '--json'], { env: pieceEnv(env), stdio: ['ignore', 'pipe', 'ignore'], detached: true });
|
|
44
|
+
let out = '';
|
|
45
|
+
let bytes = 0;
|
|
46
|
+
let settled = false;
|
|
47
|
+
let drain;
|
|
48
|
+
const finish = (r) => {
|
|
49
|
+
if (settled)
|
|
50
|
+
return;
|
|
51
|
+
settled = true;
|
|
52
|
+
clearTimeout(timer);
|
|
53
|
+
if (drain)
|
|
54
|
+
clearTimeout(drain);
|
|
55
|
+
child.stdout?.destroy();
|
|
56
|
+
resolve(r);
|
|
57
|
+
};
|
|
58
|
+
const timer = setTimeout(() => { killGroup(child.pid); finish({ ok: false, reason: 'timed out' }); }, PIECE_TIMEOUT_MS);
|
|
59
|
+
child.stdout?.on('data', (d) => {
|
|
60
|
+
bytes += d.length;
|
|
61
|
+
if (bytes > PIECE_MAX_BYTES) {
|
|
62
|
+
killGroup(child.pid);
|
|
63
|
+
finish({ ok: false, reason: 'answer too large' });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
out += d.toString('utf-8');
|
|
67
|
+
});
|
|
68
|
+
child.on('error', (e) => finish({ ok: false, reason: e.message }));
|
|
69
|
+
// Resolve on the piece's own exit (plus a short drain), not on 'close':
|
|
70
|
+
// 'close' waits for every holder of the pipe, and a helper the piece left
|
|
71
|
+
// running would hold it open past the timeout.
|
|
72
|
+
child.on('exit', (code) => {
|
|
73
|
+
const done = () => finish(code === 0 ? { ok: true, stdout: out } : { ok: false, reason: 'no ui' });
|
|
74
|
+
if (child.stdout?.readableEnded)
|
|
75
|
+
return done();
|
|
76
|
+
child.stdout?.once('end', done);
|
|
77
|
+
drain = setTimeout(done, DRAIN_MS);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
async function run(name, path, env) {
|
|
82
|
+
const r = await ask(path, env);
|
|
83
|
+
if (!r.ok)
|
|
84
|
+
return { piece: name, reason: r.reason };
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(r.stdout);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { piece: name, reason: 'invalid: not JSON' };
|
|
91
|
+
}
|
|
92
|
+
const sections = parseSections(parsed);
|
|
93
|
+
return typeof sections === 'string' ? { piece: name, reason: `invalid: ${sections}` } : { piece: name, sections };
|
|
94
|
+
}
|
|
95
|
+
function sectionsFor(name, path, env, nowMs) {
|
|
96
|
+
const hit = cache.get(path);
|
|
97
|
+
if (hit && nowMs - hit.at < CACHE_MS)
|
|
98
|
+
return hit.result;
|
|
99
|
+
// Cache the promise itself: concurrent page loads share one run.
|
|
100
|
+
const result = run(name, path, env);
|
|
101
|
+
cache.set(path, { at: nowMs, result });
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
export async function pieceSections(env, nowMs = Date.now()) {
|
|
105
|
+
// "loom" is the core group's name on the page; a piece can't take it.
|
|
106
|
+
const pieces = listPieces(env).filter((p) => p.name !== 'loom');
|
|
107
|
+
const results = await Promise.all(pieces.map((p) => sectionsFor(p.name, p.path, env, nowMs)));
|
|
108
|
+
return {
|
|
109
|
+
contributed: results.filter((r) => 'sections' in r && r.sections.length > 0),
|
|
110
|
+
skipped: results.filter((r) => 'reason' in r),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side HTML for loom's page. Every string that reaches the page goes
|
|
3
|
+
* through esc(): pieces send data in the section vocabulary, never markup.
|
|
4
|
+
* No client script; the page refreshes itself.
|
|
5
|
+
*/
|
|
6
|
+
import type { Section } from './sections.js';
|
|
7
|
+
import type { PieceSections, PieceSkip } from './piece-sections.js';
|
|
8
|
+
export declare function esc(s: string): string;
|
|
9
|
+
export declare function renderPage(opts: {
|
|
10
|
+
agent: string;
|
|
11
|
+
core: Section[];
|
|
12
|
+
pieces: PieceSections[];
|
|
13
|
+
skipped: PieceSkip[];
|
|
14
|
+
generatedAt: string;
|
|
15
|
+
}): string;
|
|
16
|
+
export declare function renderLogin(error?: string): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export function esc(s) {
|
|
2
|
+
return s
|
|
3
|
+
.replace(/&/g, '&')
|
|
4
|
+
.replace(/</g, '<')
|
|
5
|
+
.replace(/>/g, '>')
|
|
6
|
+
.replace(/"/g, '"')
|
|
7
|
+
.replace(/'/g, ''');
|
|
8
|
+
}
|
|
9
|
+
const toneClass = (t) => (t ? ` tone-${t}` : '');
|
|
10
|
+
function renderBlock(b) {
|
|
11
|
+
switch (b.type) {
|
|
12
|
+
case 'stats':
|
|
13
|
+
return `<dl class="stats">${b.items
|
|
14
|
+
.map((i) => `<div class="stat${toneClass(i.tone)}"><dt>${esc(i.label)}</dt><dd>${esc(i.value)}</dd></div>`)
|
|
15
|
+
.join('')}</dl>`;
|
|
16
|
+
case 'list':
|
|
17
|
+
return `<ul class="list">${b.items
|
|
18
|
+
.map((i) => `<li class="${toneClass(i.tone).trim()}"><span class="text">${esc(i.text)}</span>${i.meta ? `<span class="meta">${esc(i.meta)}</span>` : ''}</li>`)
|
|
19
|
+
.join('')}</ul>`;
|
|
20
|
+
case 'table':
|
|
21
|
+
return `<div class="table-wrap"><table><thead><tr>${b.columns.map((c) => `<th>${esc(c)}</th>`).join('')}</tr></thead><tbody>${b.rows
|
|
22
|
+
.map((r) => `<tr>${r.map((c) => `<td>${esc(c)}</td>`).join('')}</tr>`)
|
|
23
|
+
.join('')}</tbody></table></div>`;
|
|
24
|
+
case 'text':
|
|
25
|
+
return `<p class="text-block">${esc(b.text)}</p>`;
|
|
26
|
+
case 'link':
|
|
27
|
+
return `<p><a href="${esc(b.href)}" rel="noopener noreferrer">${esc(b.text)}</a></p>`;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Ids are namespaced (g- groups, s-<source>--<id> sections) so no piece's
|
|
31
|
+
// anchors can land on another's or on loom's.
|
|
32
|
+
function renderSection(s, source) {
|
|
33
|
+
return `<section class="card" id="s-${esc(source)}--${esc(s.id)}"><h2>${esc(s.title)}</h2>${s.blocks.map(renderBlock).join('')}</section>`;
|
|
34
|
+
}
|
|
35
|
+
const STYLE = `
|
|
36
|
+
:root{--bg:#f7f7f5;--card:#fff;--fg:#1d1d1b;--muted:#6b6b66;--line:#e4e4df;--accent:#3b5bdb;--ok:#2b8a3e;--warn:#b26a00;--bad:#c92a2a;color-scheme:light}
|
|
37
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#141413;--card:#1e1e1c;--fg:#ececea;--muted:#9a9a94;--line:#2e2e2b;--accent:#8ca2ff;--ok:#69db7c;--warn:#ffc078;--bad:#ff8787;color-scheme:dark}}
|
|
38
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
39
|
+
header{padding:14px 16px 0;max-width:1100px;margin:0 auto}header h1{margin:0;font-size:18px;font-weight:600}header .sub{color:var(--muted);font-size:13px}
|
|
40
|
+
nav{display:flex;gap:4px;overflow-x:auto;padding:10px 16px;max-width:1100px;margin:0 auto;border-bottom:1px solid var(--line)}
|
|
41
|
+
nav a{color:var(--muted);text-decoration:none;padding:6px 10px;border-radius:8px;white-space:nowrap;font-size:14px}nav a:hover{background:var(--card);color:var(--fg)}
|
|
42
|
+
main{max-width:1100px;margin:0 auto;padding:16px}.group{margin-bottom:24px}.group>h2.source{font-size:13px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:0 0 8px}
|
|
43
|
+
.grid{display:grid;gap:12px;align-items:start;grid-template-columns:repeat(auto-fill,minmax(min(100%,340px),1fr))}
|
|
44
|
+
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;min-width:0}.card h2{margin:0 0 10px;font-size:15px}
|
|
45
|
+
.stats{display:flex;flex-wrap:wrap;gap:16px;margin:0}.stat dt{color:var(--muted);font-size:12px}.stat dd{margin:0;font-size:22px;font-weight:600;font-variant-numeric:tabular-nums}
|
|
46
|
+
.list{list-style:none;margin:0;padding:0}.list li{padding:6px 0;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:2px}.list li:first-child{border-top:0}
|
|
47
|
+
.list .meta{color:var(--muted);font-size:12px}.text-block{margin:0;color:var(--muted)}
|
|
48
|
+
.table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:13px}th,td{text-align:left;padding:5px 6px;border-top:1px solid var(--line)}th{color:var(--muted);font-weight:500;border-top:0}
|
|
49
|
+
.tone-ok dd,.tone-ok .text{color:var(--ok)}.tone-warn dd,.tone-warn .text{color:var(--warn)}.tone-bad dd,.tone-bad .text{color:var(--bad)}.tone-muted dd,.tone-muted .text{color:var(--muted)}
|
|
50
|
+
a{color:var(--accent)}footer{max-width:1100px;margin:0 auto;padding:0 16px 24px;color:var(--muted);font-size:12px}
|
|
51
|
+
form.login{max-width:360px;margin:48px auto;padding:16px}form.login input{width:100%;padding:10px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--fg);font:inherit}
|
|
52
|
+
form.inline{display:inline}form.inline button{background:none;border:0;padding:0;color:var(--accent);font:inherit;cursor:pointer;text-decoration:underline}
|
|
53
|
+
form.login button{margin-top:10px;padding:9px 14px;border:0;border-radius:8px;background:var(--accent);color:#fff;font:inherit}.err{color:var(--bad)}
|
|
54
|
+
`;
|
|
55
|
+
function shell(title, body, refresh) {
|
|
56
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${refresh ? '<meta http-equiv="refresh" content="30">' : ''}<title>${esc(title)}</title><style>${STYLE}</style></head><body>${body}</body></html>`;
|
|
57
|
+
}
|
|
58
|
+
export function renderPage(opts) {
|
|
59
|
+
const groups = [{ source: 'loom', sections: opts.core }, ...opts.pieces.map((p) => ({ source: p.piece, sections: p.sections }))];
|
|
60
|
+
const nav = groups.map((g) => `<a href="#g-${esc(g.source)}">${esc(g.source)}</a>`).join('');
|
|
61
|
+
const main = groups
|
|
62
|
+
.map((g) => `<div class="group" id="g-${esc(g.source)}"><h2 class="source">${esc(g.source)}</h2><div class="grid">${g.sections.map((s) => renderSection(s, g.source)).join('')}</div></div>`)
|
|
63
|
+
.join('');
|
|
64
|
+
const quiet = opts.skipped.length ? ` · pieces with no page: ${opts.skipped.map((s) => esc(s.piece)).join(', ')}` : '';
|
|
65
|
+
return shell(`${opts.agent} · loom`, `<header><h1>${esc(opts.agent)}</h1><div class="sub">loom</div></header><nav>${nav}</nav><main>${main}</main>` +
|
|
66
|
+
`<footer>Updated ${esc(opts.generatedAt)} · refreshes every 30s${quiet} · <form class="inline" method="post" action="/ui/logout"><button type="submit">log out</button></form></footer>`, true);
|
|
67
|
+
}
|
|
68
|
+
export function renderLogin(error) {
|
|
69
|
+
return shell('loom', `<form class="login" method="post" action="/ui/login"><h1>loom</h1><p class="sub">Paste the bearer token once. This browser keeps a cookie (until the daemon restarts, or 30 days).</p>${error ? `<p class="err">${esc(error)}</p>` : ''}<input type="password" name="token" autocomplete="current-password" autofocus aria-label="token"><button type="submit">Open</button></form>`, false);
|
|
70
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UI section vocabulary (docs/extensions.md, "UI sections").
|
|
3
|
+
*
|
|
4
|
+
* loom's page is built from sections, both its own and the installed pieces'.
|
|
5
|
+
* A piece contributes by answering `loom-<piece> ui --json` with
|
|
6
|
+
* `{ "sections": [...] }` in this vocabulary. Pieces send data, never markup:
|
|
7
|
+
* loom renders every block itself and escapes every string, so a piece can't
|
|
8
|
+
* put HTML or script on loom's page. Anything that doesn't validate is dropped
|
|
9
|
+
* whole (a piece that sends garbage just isn't shown), never half-rendered.
|
|
10
|
+
*/
|
|
11
|
+
export type Tone = 'ok' | 'warn' | 'bad' | 'muted';
|
|
12
|
+
export type Block = {
|
|
13
|
+
type: 'stats';
|
|
14
|
+
items: Array<{
|
|
15
|
+
label: string;
|
|
16
|
+
value: string;
|
|
17
|
+
tone?: Tone;
|
|
18
|
+
}>;
|
|
19
|
+
} | {
|
|
20
|
+
type: 'list';
|
|
21
|
+
items: Array<{
|
|
22
|
+
text: string;
|
|
23
|
+
meta?: string;
|
|
24
|
+
tone?: Tone;
|
|
25
|
+
}>;
|
|
26
|
+
} | {
|
|
27
|
+
type: 'table';
|
|
28
|
+
columns: string[];
|
|
29
|
+
rows: string[][];
|
|
30
|
+
} | {
|
|
31
|
+
type: 'text';
|
|
32
|
+
text: string;
|
|
33
|
+
} | {
|
|
34
|
+
type: 'link';
|
|
35
|
+
text: string;
|
|
36
|
+
href: string;
|
|
37
|
+
};
|
|
38
|
+
export interface Section {
|
|
39
|
+
id: string;
|
|
40
|
+
title: string;
|
|
41
|
+
blocks: Block[];
|
|
42
|
+
}
|
|
43
|
+
export declare const LIMITS: {
|
|
44
|
+
readonly sections: 10;
|
|
45
|
+
readonly blocks: 20;
|
|
46
|
+
readonly items: 200;
|
|
47
|
+
readonly columns: 12;
|
|
48
|
+
readonly string: 2000;
|
|
49
|
+
};
|
|
50
|
+
/** Validate a piece's `ui --json` answer. Returns the sections, or why it was refused. */
|
|
51
|
+
export declare function parseSections(value: unknown): Section[] | string;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The UI section vocabulary (docs/extensions.md, "UI sections").
|
|
3
|
+
*
|
|
4
|
+
* loom's page is built from sections, both its own and the installed pieces'.
|
|
5
|
+
* A piece contributes by answering `loom-<piece> ui --json` with
|
|
6
|
+
* `{ "sections": [...] }` in this vocabulary. Pieces send data, never markup:
|
|
7
|
+
* loom renders every block itself and escapes every string, so a piece can't
|
|
8
|
+
* put HTML or script on loom's page. Anything that doesn't validate is dropped
|
|
9
|
+
* whole (a piece that sends garbage just isn't shown), never half-rendered.
|
|
10
|
+
*/
|
|
11
|
+
export const LIMITS = {
|
|
12
|
+
sections: 10,
|
|
13
|
+
blocks: 20,
|
|
14
|
+
items: 200,
|
|
15
|
+
columns: 12,
|
|
16
|
+
string: 2000,
|
|
17
|
+
};
|
|
18
|
+
const TONES = new Set(['ok', 'warn', 'bad', 'muted']);
|
|
19
|
+
const ID = /^[a-z][a-z0-9-]{0,39}$/;
|
|
20
|
+
class Invalid extends Error {
|
|
21
|
+
}
|
|
22
|
+
function str(v, what) {
|
|
23
|
+
if (typeof v === 'number' && Number.isFinite(v))
|
|
24
|
+
return String(v);
|
|
25
|
+
if (typeof v !== 'string')
|
|
26
|
+
throw new Invalid(`${what} must be a string`);
|
|
27
|
+
if (v.length > LIMITS.string)
|
|
28
|
+
throw new Invalid(`${what} is longer than ${LIMITS.string}`);
|
|
29
|
+
return v;
|
|
30
|
+
}
|
|
31
|
+
function optStr(v, what) {
|
|
32
|
+
return v === undefined ? undefined : str(v, what);
|
|
33
|
+
}
|
|
34
|
+
function tone(v) {
|
|
35
|
+
if (v === undefined)
|
|
36
|
+
return undefined;
|
|
37
|
+
if (typeof v !== 'string' || !TONES.has(v))
|
|
38
|
+
throw new Invalid(`tone must be one of ${[...TONES].join(', ')}`);
|
|
39
|
+
return v;
|
|
40
|
+
}
|
|
41
|
+
function arr(v, what, max) {
|
|
42
|
+
if (!Array.isArray(v))
|
|
43
|
+
throw new Invalid(`${what} must be an array`);
|
|
44
|
+
if (v.length > max)
|
|
45
|
+
throw new Invalid(`${what} has more than ${max} entries`);
|
|
46
|
+
return v;
|
|
47
|
+
}
|
|
48
|
+
function obj(v, what) {
|
|
49
|
+
if (typeof v !== 'object' || v === null || Array.isArray(v))
|
|
50
|
+
throw new Invalid(`${what} must be an object`);
|
|
51
|
+
return v;
|
|
52
|
+
}
|
|
53
|
+
function block(v) {
|
|
54
|
+
const b = obj(v, 'block');
|
|
55
|
+
switch (b.type) {
|
|
56
|
+
case 'stats':
|
|
57
|
+
return {
|
|
58
|
+
type: 'stats',
|
|
59
|
+
items: arr(b.items, 'stats.items', LIMITS.items).map((i) => {
|
|
60
|
+
const o = obj(i, 'stat');
|
|
61
|
+
return { label: str(o.label, 'stat.label'), value: str(o.value, 'stat.value'), tone: tone(o.tone) };
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
case 'list':
|
|
65
|
+
return {
|
|
66
|
+
type: 'list',
|
|
67
|
+
items: arr(b.items, 'list.items', LIMITS.items).map((i) => {
|
|
68
|
+
const o = obj(i, 'list item');
|
|
69
|
+
return { text: str(o.text, 'list.text'), meta: optStr(o.meta, 'list.meta'), tone: tone(o.tone) };
|
|
70
|
+
}),
|
|
71
|
+
};
|
|
72
|
+
case 'table': {
|
|
73
|
+
const columns = arr(b.columns, 'table.columns', LIMITS.columns).map((c) => str(c, 'table column'));
|
|
74
|
+
const rows = arr(b.rows, 'table.rows', LIMITS.items).map((r) => {
|
|
75
|
+
const row = arr(r, 'table row', columns.length).map((c) => str(c, 'table cell'));
|
|
76
|
+
return row;
|
|
77
|
+
});
|
|
78
|
+
return { type: 'table', columns, rows };
|
|
79
|
+
}
|
|
80
|
+
case 'text':
|
|
81
|
+
return { type: 'text', text: str(b.text, 'text') };
|
|
82
|
+
case 'link': {
|
|
83
|
+
const href = str(b.href, 'link.href');
|
|
84
|
+
// Only http(s): no javascript:, data:, or relative tricks.
|
|
85
|
+
let url;
|
|
86
|
+
try {
|
|
87
|
+
url = new URL(href);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
throw new Invalid('link.href must be an absolute URL');
|
|
91
|
+
}
|
|
92
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
93
|
+
throw new Invalid('link.href must be http(s)');
|
|
94
|
+
return { type: 'link', text: str(b.text, 'link.text'), href: url.href };
|
|
95
|
+
}
|
|
96
|
+
default:
|
|
97
|
+
throw new Invalid(`unknown block type ${JSON.stringify(b.type)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Validate a piece's `ui --json` answer. Returns the sections, or why it was refused. */
|
|
101
|
+
export function parseSections(value) {
|
|
102
|
+
try {
|
|
103
|
+
const top = obj(value, 'answer');
|
|
104
|
+
return arr(top.sections, 'sections', LIMITS.sections).map((s) => {
|
|
105
|
+
const o = obj(s, 'section');
|
|
106
|
+
const id = str(o.id, 'section.id');
|
|
107
|
+
if (!ID.test(id))
|
|
108
|
+
throw new Invalid(`section.id ${JSON.stringify(id)} must be lowercase letters, digits and dashes`);
|
|
109
|
+
return {
|
|
110
|
+
id,
|
|
111
|
+
title: str(o.title, 'section.title'),
|
|
112
|
+
blocks: arr(o.blocks, 'section.blocks', LIMITS.blocks).map(block),
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (err instanceof Invalid)
|
|
118
|
+
return err.message;
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jbarket/loomai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "MCP server for persistent AI agent identity and memory. Single-file sqlite-vec + local ONNX embeddings; zero external services.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|