@jbarket/loomai 0.11.0 → 0.13.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 +42 -1
- package/README.md +48 -0
- package/dist/bundle.d.ts +80 -0
- package/dist/bundle.js +377 -0
- package/dist/cli/bundle.d.ts +3 -0
- package/dist/cli/bundle.js +107 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/subcommands.d.ts +1 -1
- package/dist/cli/subcommands.js +1 -1
- package/dist/household.d.ts +10 -2
- package/dist/household.js +28 -2
- package/dist/transport/http-server.js +6 -3
- package/dist/ui/core.d.ts +2 -0
- package/dist/ui/core.js +1 -1
- package/dist/ui/handler.d.ts +6 -0
- package/dist/ui/handler.js +95 -11
- package/dist/ui/household-pages.d.ts +15 -0
- package/dist/ui/household-pages.js +438 -0
- package/dist/ui/render.d.ts +8 -1
- package/dist/ui/render.js +3 -1
- package/dist/ui/vocab.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loom export` and `loom restore` (src/bundle.ts; docs/specs/household.md,
|
|
3
|
+
* "Export and restore"). Local only: neither is a remote verb.
|
|
4
|
+
*/
|
|
5
|
+
import { householdHome } from '../household.js';
|
|
6
|
+
import { BundleError, drillBundle, exportBundle, restoreBundle, sizeOf } from '../bundle.js';
|
|
7
|
+
import { extractGlobalFlags, resolveEnv } from './args.js';
|
|
8
|
+
const EXPORT_USAGE = `Usage: loom export --to <file> --recipient <age1…> [--recipient …]
|
|
9
|
+
|
|
10
|
+
Write the whole household to one file, encrypted to the owner's key (an age
|
|
11
|
+
public key, or an ssh public key). Inside: the household's files, the commons,
|
|
12
|
+
every agent's directory, and each installed piece's own part, which the piece
|
|
13
|
+
encrypts itself (loom-<piece> bundle export). A plain context dir exports as a
|
|
14
|
+
household of one.
|
|
15
|
+
`;
|
|
16
|
+
const RESTORE_USAGE = `Usage: loom restore <file> --identity <key file> [--to <dir>]
|
|
17
|
+
loom restore <file> --identity <key file> --drill
|
|
18
|
+
|
|
19
|
+
Lay a bundle down in an empty LOOM_HOME (--to, else $LOOM_HOME, else
|
|
20
|
+
~/.config/loom), then hand each installed piece its part (loom-<piece> bundle import).
|
|
21
|
+
A piece that isn't installed keeps its part in pieces-pending/.
|
|
22
|
+
|
|
23
|
+
--drill restores into a throwaway directory instead and compares it with the
|
|
24
|
+
live household (agents, identities, memories, every card, knowledge, tokens),
|
|
25
|
+
then removes it. Nothing live is touched.
|
|
26
|
+
|
|
27
|
+
A restore trusts what's in the bundle: age encrypts but doesn't prove who made
|
|
28
|
+
the file, so only restore bundles you made.
|
|
29
|
+
`;
|
|
30
|
+
function flags(args, name) {
|
|
31
|
+
const out = [];
|
|
32
|
+
for (let i = 0; i < args.length; i++)
|
|
33
|
+
if (args[i] === name && args[i + 1])
|
|
34
|
+
out.push(args[++i]);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
export async function runExport(rawArgv, io) {
|
|
38
|
+
const { flags: global, rest: argv } = extractGlobalFlags(rawArgv);
|
|
39
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
40
|
+
io.stdout(EXPORT_USAGE);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
const to = flags(argv, '--to')[0];
|
|
44
|
+
const recipients = flags(argv, '--recipient');
|
|
45
|
+
if (!to || recipients.length === 0) {
|
|
46
|
+
io.stderr(EXPORT_USAGE);
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
const { contextDir } = resolveEnv(global, io.env);
|
|
50
|
+
try {
|
|
51
|
+
const m = await exportBundle({ contextDir, to, recipients, env: io.env });
|
|
52
|
+
const left = m.pieces.filter((p) => !p.included);
|
|
53
|
+
io.stdout(`Bundle written: ${to} (${(sizeOf(to) / 1048576).toFixed(1)} MB), agents ${m.household.agents.join(', ')}.\n`);
|
|
54
|
+
for (const p of m.pieces.filter((x) => x.included))
|
|
55
|
+
io.stdout(` piece ${p.name}: included (${p.bytes} bytes, encrypted by the piece)\n`);
|
|
56
|
+
for (const p of left)
|
|
57
|
+
io.stdout(` piece ${p.name}: not included (${p.reason})\n`);
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err instanceof BundleError) {
|
|
62
|
+
io.stderr(`${err.message}\n`);
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export async function runRestore(rawArgv, io) {
|
|
69
|
+
const { flags: global, rest: argv } = extractGlobalFlags(rawArgv);
|
|
70
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
71
|
+
io.stdout(RESTORE_USAGE);
|
|
72
|
+
return 0;
|
|
73
|
+
}
|
|
74
|
+
const file = argv.find((a, i) => !a.startsWith('--') && (i === 0 || !argv[i - 1].startsWith('--')));
|
|
75
|
+
const identity = flags(argv, '--identity')[0];
|
|
76
|
+
if (!file || !identity) {
|
|
77
|
+
io.stderr(RESTORE_USAGE);
|
|
78
|
+
return 2;
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
if (argv.includes('--drill')) {
|
|
82
|
+
const { contextDir } = resolveEnv(global, io.env);
|
|
83
|
+
const r = await drillBundle({ file, identity, contextDir, env: io.env });
|
|
84
|
+
if (r.ok) {
|
|
85
|
+
io.stdout(`Drill passed: the bundle from ${r.manifest.created} restores to a household that matches the live one.\n`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
88
|
+
io.stdout(`Drill found ${r.differences.length} difference${r.differences.length === 1 ? '' : 's'} (the live household may simply have moved on since ${r.manifest.created}):\n`);
|
|
89
|
+
for (const d of r.differences)
|
|
90
|
+
io.stdout(` ${d.what}: live ${JSON.stringify(d.live)}, restored ${JSON.stringify(d.restored)}\n`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
const home = flags(argv, '--to')[0] ?? householdHome(io.env);
|
|
94
|
+
const r = await restoreBundle({ file, identity, home, env: io.env });
|
|
95
|
+
io.stdout(`Restored the household (${r.manifest.household.agents.join(', ')}, bundle from ${r.manifest.created}) to ${r.home}.\n`);
|
|
96
|
+
for (const p of r.pieces)
|
|
97
|
+
io.stdout(` piece ${p.name}: ${p.restored ? 'restored' : `not restored (${p.reason})`}\n`);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
if (err instanceof BundleError) {
|
|
102
|
+
io.stderr(`${err.message}\n`);
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -34,6 +34,8 @@ Commands:
|
|
|
34
34
|
pieces List installed pieces (loom-<name> on PATH)
|
|
35
35
|
household The household: agents and the default (docs/specs/household.md)
|
|
36
36
|
token Mint, list and revoke the household's tokens
|
|
37
|
+
export Write the whole household to one encrypted bundle
|
|
38
|
+
restore Lay a bundle down in an empty LOOM_HOME, or --drill it
|
|
37
39
|
|
|
38
40
|
Pieces: \`loom <name> ...\` runs an installed extension, loom-<name> on PATH
|
|
39
41
|
(e.g. loom tasks list). Core commands always win. See docs/extensions.md.
|
|
@@ -208,6 +210,14 @@ async function dispatch(sub, rest, io) {
|
|
|
208
210
|
const { runToken } = await import('./household.js');
|
|
209
211
|
return runToken(rest, io);
|
|
210
212
|
}
|
|
213
|
+
case 'export': {
|
|
214
|
+
const { runExport } = await import('./bundle.js');
|
|
215
|
+
return runExport(rest, io);
|
|
216
|
+
}
|
|
217
|
+
case 'restore': {
|
|
218
|
+
const { runRestore } = await import('./bundle.js');
|
|
219
|
+
return runRestore(rest, io);
|
|
220
|
+
}
|
|
211
221
|
default:
|
|
212
222
|
io.stderr(`Subcommand not implemented yet: ${sub}\n`);
|
|
213
223
|
return 2;
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
* (src/cli/index.ts) and the CLI-vs-MCP routing gate (src/index.ts) so
|
|
4
4
|
* the set never drifts between entry points.
|
|
5
5
|
*/
|
|
6
|
-
export declare const SUBCOMMANDS: readonly ['wake', 'recall', 'remember', 'forget', 'update', 'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve', 'inject', 'harness', 'install', 'doctor', 'migrate', 'pieces', 'household', 'token'];
|
|
6
|
+
export declare const SUBCOMMANDS: readonly ['wake', 'recall', 'remember', 'forget', 'update', 'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve', 'inject', 'harness', 'install', 'doctor', 'migrate', 'pieces', 'household', 'token', 'export', 'restore'];
|
|
7
7
|
export type Subcommand = typeof SUBCOMMANDS[number];
|
package/dist/cli/subcommands.js
CHANGED
|
@@ -7,5 +7,5 @@ export const SUBCOMMANDS = [
|
|
|
7
7
|
'wake', 'recall', 'remember', 'forget', 'update',
|
|
8
8
|
'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve',
|
|
9
9
|
'inject', 'harness',
|
|
10
|
-
'install', 'doctor', 'migrate', 'pieces', 'household', 'token',
|
|
10
|
+
'install', 'doctor', 'migrate', 'pieces', 'household', 'token', 'export', 'restore',
|
|
11
11
|
];
|
package/dist/household.d.ts
CHANGED
|
@@ -60,10 +60,12 @@ export declare function revokeToken(h: Household, label: string, now?: Date): To
|
|
|
60
60
|
* time, and records when a token was last used, at most once a minute.
|
|
61
61
|
*/
|
|
62
62
|
export declare class TokenResolver {
|
|
63
|
-
private readonly h;
|
|
64
63
|
private cache;
|
|
65
64
|
private lastTouch;
|
|
66
|
-
|
|
65
|
+
private readonly get;
|
|
66
|
+
/** A household, or a function returning the current one (re-read as household.json changes). */
|
|
67
|
+
constructor(h: Household | (() => Household));
|
|
68
|
+
private get h();
|
|
67
69
|
private current;
|
|
68
70
|
/**
|
|
69
71
|
* The role for a presented token, or null. Revoked tokens and roles naming a
|
|
@@ -115,3 +117,9 @@ export declare function commonsProblem(h: Household): string | null;
|
|
|
115
117
|
* stopped.
|
|
116
118
|
*/
|
|
117
119
|
export declare function moveKnowledgeToCommons(h: Household, agent: string): string | null;
|
|
120
|
+
/**
|
|
121
|
+
* The household at `home`, re-read whenever household.json changes, so an
|
|
122
|
+
* agent added while a daemon runs is served without a restart. Keeps the
|
|
123
|
+
* last good copy if the file is briefly unreadable mid-write.
|
|
124
|
+
*/
|
|
125
|
+
export declare function liveHousehold(initial: Household): () => Household;
|
package/dist/household.js
CHANGED
|
@@ -254,12 +254,14 @@ export function revokeToken(h, label, now = new Date()) {
|
|
|
254
254
|
* time, and records when a token was last used, at most once a minute.
|
|
255
255
|
*/
|
|
256
256
|
export class TokenResolver {
|
|
257
|
-
h;
|
|
258
257
|
cache = null;
|
|
259
258
|
lastTouch = new Map();
|
|
259
|
+
get;
|
|
260
|
+
/** A household, or a function returning the current one (re-read as household.json changes). */
|
|
260
261
|
constructor(h) {
|
|
261
|
-
this.
|
|
262
|
+
this.get = typeof h === 'function' ? h : () => h;
|
|
262
263
|
}
|
|
264
|
+
get h() { return this.get(); }
|
|
263
265
|
current() {
|
|
264
266
|
const path = join(this.h.home, TOKENS_FILE);
|
|
265
267
|
let key;
|
|
@@ -457,3 +459,27 @@ export function moveKnowledgeToCommons(h, agent) {
|
|
|
457
459
|
return to;
|
|
458
460
|
});
|
|
459
461
|
}
|
|
462
|
+
/**
|
|
463
|
+
* The household at `home`, re-read whenever household.json changes, so an
|
|
464
|
+
* agent added while a daemon runs is served without a restart. Keeps the
|
|
465
|
+
* last good copy if the file is briefly unreadable mid-write.
|
|
466
|
+
*/
|
|
467
|
+
export function liveHousehold(initial) {
|
|
468
|
+
let current = initial;
|
|
469
|
+
let key = '';
|
|
470
|
+
return () => {
|
|
471
|
+
try {
|
|
472
|
+
const st = statSync(join(initial.home, HOUSEHOLD_FILE));
|
|
473
|
+
const k = `${st.ino}:${st.size}:${st.mtimeMs}`;
|
|
474
|
+
if (k !== key) {
|
|
475
|
+
const next = loadHousehold(initial.home);
|
|
476
|
+
if (next) {
|
|
477
|
+
current = next;
|
|
478
|
+
key = k;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch { /* keep the last good one */ }
|
|
483
|
+
return current;
|
|
484
|
+
};
|
|
485
|
+
}
|
|
@@ -42,7 +42,7 @@ import { handleCliBody } from './cli-endpoint.js';
|
|
|
42
42
|
import { handleUi, isUiRequest, legacyUiAuth } from '../ui/handler.js';
|
|
43
43
|
import { createKnowledgeOnlyServer } from './knowledge-server.js';
|
|
44
44
|
import { hasUnsplitPeople } from '../backends/people-household.js';
|
|
45
|
-
import { agentDir, assertRealAgentDir, commonsProblem, roleString, TokenResolver } from '../household.js';
|
|
45
|
+
import { agentDir, assertRealAgentDir, commonsProblem, liveHousehold, roleString, TokenResolver } from '../household.js';
|
|
46
46
|
import { createHash, timingSafeEqual as cryptoTimingSafeEqual } from 'node:crypto';
|
|
47
47
|
import { assertSafeBind, checkBearer, checkBearerMulti, checkPayloadSize, DEFAULT_MAX_BODY_BYTES, } from './guards.js';
|
|
48
48
|
/**
|
|
@@ -152,7 +152,9 @@ export async function startHttpServer(opts) {
|
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
-
|
|
155
|
+
// Read fresh on each use: an agent added from the page is served at once.
|
|
156
|
+
const live = household ? liveHousehold(household) : undefined;
|
|
157
|
+
const resolver = live ? new TokenResolver(live) : undefined;
|
|
156
158
|
const ownerDigest = opts.token ? createHash('sha256').update(opts.token).digest() : undefined;
|
|
157
159
|
/** Resolve a household caller: 401 (no/unknown token), 403 (not allowed there) or 404 (no such agent). */
|
|
158
160
|
function callerFor(header, target) {
|
|
@@ -175,7 +177,7 @@ export async function startHttpServer(opts) {
|
|
|
175
177
|
}
|
|
176
178
|
if (!role)
|
|
177
179
|
return { status: 401, error: 'unauthorized: bearer token not recognized' };
|
|
178
|
-
const h =
|
|
180
|
+
const h = live();
|
|
179
181
|
if (role.kind === 'peer') {
|
|
180
182
|
if (target)
|
|
181
183
|
return { status: 403, error: 'forbidden: a peer token reaches the commons only' };
|
|
@@ -317,6 +319,7 @@ export async function startHttpServer(opts) {
|
|
|
317
319
|
contextDir: opts.contextDir,
|
|
318
320
|
auth: uiAuth,
|
|
319
321
|
env: process.env,
|
|
322
|
+
household: live,
|
|
320
323
|
});
|
|
321
324
|
}
|
|
322
325
|
// Household: `/agents/<name>/…` names the agent (the owner's way to
|
package/dist/ui/core.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface CoreOptions {
|
|
|
9
9
|
}
|
|
10
10
|
/** "Sat Oct 24, 12:09 pm" */
|
|
11
11
|
export declare function when(iso: string | null, tz: string): string;
|
|
12
|
+
/** "today 12:09 pm", "yesterday", "Oct 19" */
|
|
13
|
+
export declare function ago(iso: string | null, tz: string, nowMs: number): string;
|
|
12
14
|
export interface Home {
|
|
13
15
|
tile: Tile;
|
|
14
16
|
page: Page;
|
package/dist/ui/core.js
CHANGED
|
@@ -59,7 +59,7 @@ export function when(iso, tz) {
|
|
|
59
59
|
return `${p.weekday} ${p.date}, ${p.time}`;
|
|
60
60
|
}
|
|
61
61
|
/** "today 12:09 pm", "yesterday", "Oct 19" */
|
|
62
|
-
function ago(iso, tz, nowMs) {
|
|
62
|
+
export function ago(iso, tz, nowMs) {
|
|
63
63
|
if (!iso || Number.isNaN(Date.parse(iso)))
|
|
64
64
|
return 'never';
|
|
65
65
|
const p = parts(iso, tz);
|
package/dist/ui/handler.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { type Household } from '../household.js';
|
|
2
3
|
export interface UiOptions {
|
|
3
4
|
contextDir: string;
|
|
4
5
|
/** Who may log in to the page. */
|
|
5
6
|
auth: UiAuth;
|
|
6
7
|
/** Env for piece discovery and piece processes (PATH decides which pieces exist). */
|
|
7
8
|
env: NodeJS.ProcessEnv;
|
|
9
|
+
/**
|
|
10
|
+
* The household, read fresh (an agent added from the page shows up on the
|
|
11
|
+
* next request). Absent for a plain context dir: the single-agent page.
|
|
12
|
+
*/
|
|
13
|
+
household?: () => Household;
|
|
8
14
|
}
|
|
9
15
|
/** Whether a request is for the page (see the header for how `/` is shared with MCP). */
|
|
10
16
|
export declare function isUiRequest(req: IncomingMessage, pathname: string): boolean;
|
package/dist/ui/handler.js
CHANGED
|
@@ -34,8 +34,10 @@
|
|
|
34
34
|
*/
|
|
35
35
|
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
36
36
|
import { agentName, coreAct, corePage, homePage, uiTimeZone } from './core.js';
|
|
37
|
+
import { agentSetup, householdAct, householdHome, householdPeople, householdPerson, knowledgeEntry, knowledgePage, newAgentPage } from './household-pages.js';
|
|
38
|
+
import { agentDir } from '../household.js';
|
|
37
39
|
import { pieceAct, pieceManifests, piecePage } from './pieces.js';
|
|
38
|
-
import { CORE, pieceSource, renderHome, renderLogin, renderPage } from './render.js';
|
|
40
|
+
import { agentSource, CORE, pageHref, pieceSource, renderHome, renderLogin, renderPage } from './render.js';
|
|
39
41
|
import { ID, LIMITS, QUERY_KEY, splitPage, validPath } from './vocab.js';
|
|
40
42
|
const COOKIE = 'loom_ui';
|
|
41
43
|
const LOGIN_MAX_BYTES = 4096;
|
|
@@ -54,7 +56,7 @@ const SECURITY_HEADERS = {
|
|
|
54
56
|
'referrer-policy': 'same-origin',
|
|
55
57
|
'cache-control': 'no-store',
|
|
56
58
|
};
|
|
57
|
-
const PAGE_ROOTS = ['/login', '/logout', '/pieces.json', '/mind', '/people', '/memory', '/p', '/act'];
|
|
59
|
+
const PAGE_ROOTS = ['/login', '/logout', '/pieces.json', '/mind', '/people', '/memory', '/p', '/act', '/knowledge', '/a', '/new-agent'];
|
|
58
60
|
/** Whether a request is for the page (see the header for how `/` is shared with MCP). */
|
|
59
61
|
export function isUiRequest(req, pathname) {
|
|
60
62
|
if (pathname === '/ui' || pathname.startsWith('/ui/'))
|
|
@@ -241,6 +243,23 @@ function chrome(opts, pieces, skipped, current, csrf, flash) {
|
|
|
241
243
|
const tabs = pieces.filter(hasPages)
|
|
242
244
|
.map((p) => ({ label: p.manifest.tab?.label ?? p.piece, href: `/p/${p.piece}`, current: current === `p/${p.piece}`, piece: true }))
|
|
243
245
|
.sort((a, b) => a.label.localeCompare(b.label));
|
|
246
|
+
const h = opts.household?.();
|
|
247
|
+
if (h) {
|
|
248
|
+
return {
|
|
249
|
+
agent: h.owner ? `${h.owner}'s household` : 'household',
|
|
250
|
+
nav: [
|
|
251
|
+
{ label: 'Home', href: '/', current: current === '', piece: false },
|
|
252
|
+
{ label: 'Knowledge', href: '/knowledge', current: current === 'knowledge', piece: false },
|
|
253
|
+
{ label: 'People', href: '/people', current: current === 'people', piece: false },
|
|
254
|
+
...h.agents.map((a) => ({ label: agentName(agentDir(h, a)), href: `/a/${a}`, current: current === `a/${a}`, piece: true })),
|
|
255
|
+
...tabs,
|
|
256
|
+
],
|
|
257
|
+
clock: clock(),
|
|
258
|
+
csrf,
|
|
259
|
+
flash,
|
|
260
|
+
skipped,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
244
263
|
return {
|
|
245
264
|
agent: agentName(opts.contextDir),
|
|
246
265
|
nav: [
|
|
@@ -257,12 +276,8 @@ function chrome(opts, pieces, skipped, current, csrf, flash) {
|
|
|
257
276
|
}
|
|
258
277
|
/** Where to land after an action: the page the piece names, or back where the form was. */
|
|
259
278
|
function landing(source, result, back) {
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
const path = s?.path ?? '';
|
|
263
|
-
const q = s ? new URLSearchParams(s.query).toString() : '';
|
|
264
|
-
const base = core ? `/${path}` : `/p/${source.id}${path ? `/${path}` : ''}`;
|
|
265
|
-
return q ? `${base}?${q}` : base;
|
|
279
|
+
const link = result.page !== undefined && splitPage(result.page) ? result.page : splitPage(back) ? back : '';
|
|
280
|
+
return pageHref(link, source);
|
|
266
281
|
}
|
|
267
282
|
function withFlash(to, id) {
|
|
268
283
|
return `${to}${to.includes('?') ? '&' : '?'}_flash=${id}`;
|
|
@@ -334,12 +349,16 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
334
349
|
return send(res, 401, 'text/plain', 'unauthorized');
|
|
335
350
|
}
|
|
336
351
|
const url = new URL(req.url ?? pathname, 'http://loom.invalid');
|
|
337
|
-
|
|
352
|
+
const hh = opts.household?.();
|
|
353
|
+
const agentAct = hh ? /^\/a\/([a-z][a-z0-9-]{0,39})\/act\/loom$/.exec(pathname) : null;
|
|
354
|
+
if (pathname.startsWith('/act/') || agentAct) {
|
|
338
355
|
if (req.method !== 'POST')
|
|
339
356
|
return send(res, 405, 'text/plain', 'method not allowed');
|
|
340
357
|
if (!sameOrigin(req))
|
|
341
358
|
return send(res, 403, 'text/plain', 'forbidden: cross-site request');
|
|
342
|
-
|
|
359
|
+
if (agentAct && !hh.agents.includes(agentAct[1]))
|
|
360
|
+
return send(res, 404, 'text/plain', 'not found');
|
|
361
|
+
const sourceId = agentAct ? 'loom' : pathname.slice('/act/'.length);
|
|
343
362
|
if (sourceId !== 'loom' && !ID.test(sourceId))
|
|
344
363
|
return send(res, 404, 'text/plain', 'not found');
|
|
345
364
|
const form = await readForm(req, ACT_MAX_BYTES);
|
|
@@ -368,7 +387,17 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
368
387
|
return send(res, 400, 'text/plain', 'too many fields');
|
|
369
388
|
let source;
|
|
370
389
|
let result;
|
|
371
|
-
if (
|
|
390
|
+
if (agentAct) {
|
|
391
|
+
// One agent's own actions (archive, ratify, reject), on its stack.
|
|
392
|
+
source = agentSource(agentAct[1]);
|
|
393
|
+
result = await coreAct(agentDir(hh, agentAct[1]), action, fields);
|
|
394
|
+
}
|
|
395
|
+
else if (sourceId === 'loom' && hh) {
|
|
396
|
+
// The household's: tokens and agents.
|
|
397
|
+
source = CORE;
|
|
398
|
+
result = await householdAct(hh, action, fields);
|
|
399
|
+
}
|
|
400
|
+
else if (sourceId === 'loom') {
|
|
372
401
|
source = CORE;
|
|
373
402
|
result = await coreAct(opts.contextDir, action, fields);
|
|
374
403
|
}
|
|
@@ -403,6 +432,61 @@ export async function handleUi(req, res, pathname, opts) {
|
|
|
403
432
|
const { pieces, skipped } = await pieceManifests(opts.env);
|
|
404
433
|
const skippedNames = skipped.map((s) => s.piece);
|
|
405
434
|
const html = (body, status = 200) => send(res, status, 'text/html; charset=utf-8', body);
|
|
435
|
+
if (hh) {
|
|
436
|
+
const ctxOf = (path, source = CORE) => ({ source, path, query, csrf });
|
|
437
|
+
const send200 = (page, current, ctx) => html(renderPage(page, ctx, chrome(opts, pieces, skippedNames, current, csrf, flash)), page.title === 'Not found' && page.sections.length === 0 ? 404 : 200);
|
|
438
|
+
if (pathname === '/') {
|
|
439
|
+
const home = householdHome(hh);
|
|
440
|
+
const tiles = [
|
|
441
|
+
...home.tiles.map((t) => ({ ...t, source: CORE })),
|
|
442
|
+
...pieces.filter((p) => p.manifest.tile).map((p) => ({ ...p.manifest.tile, source: pieceSource(p.piece) })),
|
|
443
|
+
];
|
|
444
|
+
const asks = pieces.flatMap((p) => p.manifest.asks.map((a) => ({ ...a, source: pieceSource(p.piece) })));
|
|
445
|
+
return html(renderHome(home.page, ctxOf(''), chrome(opts, pieces, skippedNames, '', csrf, flash), tiles, asks));
|
|
446
|
+
}
|
|
447
|
+
if (pathname === '/knowledge')
|
|
448
|
+
return send200(knowledgePage(hh, query), 'knowledge', ctxOf('knowledge'));
|
|
449
|
+
if (pathname.startsWith('/knowledge/')) {
|
|
450
|
+
const slug = decode(pathname.slice('/knowledge/'.length));
|
|
451
|
+
return send200(slug && validPath(slug) ? knowledgeEntry(hh, slug) : notFound, 'knowledge', ctxOf('knowledge'));
|
|
452
|
+
}
|
|
453
|
+
if (pathname === '/people')
|
|
454
|
+
return send200(householdPeople(hh), 'people', ctxOf('people'));
|
|
455
|
+
if (pathname.startsWith('/people/')) {
|
|
456
|
+
const slug = decode(pathname.slice('/people/'.length));
|
|
457
|
+
return send200(slug && /^[a-z0-9][a-z0-9-]*$/.test(slug) ? householdPerson(hh, slug) : notFound, 'people', ctxOf(`people/${slug}`));
|
|
458
|
+
}
|
|
459
|
+
if (pathname === '/new-agent')
|
|
460
|
+
return send200(newAgentPage(), '', ctxOf('new-agent'));
|
|
461
|
+
const a = /^\/a\/([a-z][a-z0-9-]{0,39})(?:\/(.*))?$/.exec(pathname);
|
|
462
|
+
if (a) {
|
|
463
|
+
const agent = a[1];
|
|
464
|
+
if (!hh.agents.includes(agent))
|
|
465
|
+
return send200(notFound, '', ctxOf(''));
|
|
466
|
+
const rest = decode(a[2] ?? '') ?? '\0';
|
|
467
|
+
const dir = agentDir(hh, agent);
|
|
468
|
+
const src = agentSource(agent);
|
|
469
|
+
if (rest === '') {
|
|
470
|
+
const home = homePage(dir, query);
|
|
471
|
+
home.page.sections.unshift({ id: 'agent-nav', side: true, blocks: [{ type: 'list', items: [
|
|
472
|
+
{ text: 'Mind', meta: 'what it wakes up holding, and why', page: 'mind' },
|
|
473
|
+
{ text: 'People', meta: 'as it sees them', page: 'people' },
|
|
474
|
+
{ text: 'Setup', meta: 'identity, tokens, health', page: `loom:a/${agent}/setup` },
|
|
475
|
+
] }] });
|
|
476
|
+
const tiles = [{ ...home.tile, source: src }];
|
|
477
|
+
return html(renderHome(home.page, ctxOf('', src), chrome(opts, pieces, skippedNames, `a/${agent}`, csrf, flash), tiles, []));
|
|
478
|
+
}
|
|
479
|
+
if (rest === 'setup')
|
|
480
|
+
return send200(agentSetup(hh, agent), `a/${agent}`, ctxOf(`a/${agent}/setup`, CORE));
|
|
481
|
+
const page = validPath(rest) ? corePage(dir, rest, query) : null;
|
|
482
|
+
return send200(page ?? notFound, `a/${agent}`, ctxOf(validPath(rest) ? rest : '', src));
|
|
483
|
+
}
|
|
484
|
+
// loom's own single-agent addresses land on the default agent's view.
|
|
485
|
+
if (pathname === '/mind' || pathname.startsWith('/memory/')) {
|
|
486
|
+
res.writeHead(302, { location: `/a/${hh.default}${pathname}${url.search}`, ...SECURITY_HEADERS });
|
|
487
|
+
return void res.end();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
406
490
|
if (pathname === '/') {
|
|
407
491
|
const home = homePage(opts.contextDir, query);
|
|
408
492
|
const tiles = [
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type Household } from '../household.js';
|
|
2
|
+
import { type CoreOptions } from './core.js';
|
|
3
|
+
import { type ActResult, type Page, type Tile } from './vocab.js';
|
|
4
|
+
export declare function householdHome(h: Household, opts?: CoreOptions): {
|
|
5
|
+
tiles: Tile[];
|
|
6
|
+
page: Page;
|
|
7
|
+
};
|
|
8
|
+
export declare function knowledgePage(h: Household, query: Record<string, string>, opts?: CoreOptions): Page;
|
|
9
|
+
export declare function knowledgeEntry(h: Household, slug: string, opts?: CoreOptions): Page;
|
|
10
|
+
export declare function householdPeople(h: Household): Page;
|
|
11
|
+
export declare function householdPerson(h: Household, slug: string, opts?: CoreOptions): Page;
|
|
12
|
+
export declare function agentSetup(h: Household, agent: string, opts?: CoreOptions): Page;
|
|
13
|
+
export declare function newAgentPage(): Page;
|
|
14
|
+
/** The owner's household actions: tokens and agents. */
|
|
15
|
+
export declare function householdAct(h: Household, action: string, fields: Record<string, string>): Promise<ActResult>;
|