@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.
@@ -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];
@@ -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
  ];
@@ -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
- constructor(h: Household);
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.h = h;
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
- const resolver = household ? new TokenResolver(household) : undefined;
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 = household;
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);
@@ -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;
@@ -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 s = (result.page !== undefined ? splitPage(result.page) : null) ?? splitPage(back);
261
- const core = s?.core || source.id === 'loom';
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
- if (pathname.startsWith('/act/')) {
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
- const sourceId = pathname.slice('/act/'.length);
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 (sourceId === 'loom') {
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>;