@jbarket/loomai 0.12.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 +22 -1
- package/README.md +13 -0
- 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
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.13.0] - 2026-10-24
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **The household page** (docs/specs/household.md, phase 4). In a
|
|
20
|
+
household, the page is the owner's view of all of it:
|
|
21
|
+
- **Home:** every agent (what it's doing, from its tape), the tape across
|
|
22
|
+
all of them, and the commons.
|
|
23
|
+
- **Knowledge:** the commons' pages, by domain.
|
|
24
|
+
- **People:** the directory, with every agent's relationship to each
|
|
25
|
+
person, and who changed the shared entry.
|
|
26
|
+
- **`/a/<agent>`:** that agent's view, plus **Setup**: its identity
|
|
27
|
+
(read-only), its tokens (make one, shown once; revoke) and health.
|
|
28
|
+
- **Add an agent:** creates its stack and its first token.
|
|
29
|
+
|
|
30
|
+
A daemon now re-reads household.json as it changes, so a new agent is
|
|
31
|
+
served at once. The old single-agent addresses (`/mind`, `/memory/…`)
|
|
32
|
+
land on the default agent's view. A plain context dir keeps the
|
|
33
|
+
single-agent page.
|
|
34
|
+
|
|
15
35
|
## [0.12.0] - 2026-10-24
|
|
16
36
|
|
|
17
37
|
### Added
|
|
@@ -751,7 +771,8 @@ Initial public release.
|
|
|
751
771
|
stack and all added external-service dependencies or operational
|
|
752
772
|
overhead.
|
|
753
773
|
|
|
754
|
-
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.
|
|
774
|
+
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.13.0...HEAD
|
|
775
|
+
[0.13.0]: https://github.com/sleepunit-agents/loom/compare/v0.12.0...v0.13.0
|
|
755
776
|
[0.12.0]: https://github.com/sleepunit-agents/loom/compare/v0.11.0...v0.12.0
|
|
756
777
|
[0.11.0]: https://github.com/sleepunit-agents/loom/compare/v0.10.0...v0.11.0
|
|
757
778
|
[0.10.0]: https://github.com/sleepunit-agents/loom/compare/v0.9.2...v0.10.0
|
package/README.md
CHANGED
|
@@ -402,6 +402,19 @@ replace, never read back), the chat bridges. Nothing to configure: install a
|
|
|
402
402
|
piece and it shows up, uninstall it and it's gone. loom never names a piece
|
|
403
403
|
([`docs/extensions.md`](docs/extensions.md), "The page").
|
|
404
404
|
|
|
405
|
+
**In a household** ([`docs/specs/household.md`](docs/specs/household.md))
|
|
406
|
+
the page is the owner's view of all of it:
|
|
407
|
+
- **Home** shows every agent (what it's doing, from its tape), the tape
|
|
408
|
+
across all of them, and the commons at a glance.
|
|
409
|
+
- **Knowledge** is the commons' pages.
|
|
410
|
+
- **People** is the directory, and each person's page shows every agent's
|
|
411
|
+
relationship with them.
|
|
412
|
+
- **`/a/<agent>`** is one agent's own view (its tape, Mind and people) plus
|
|
413
|
+
**Setup**: its identity (read-only), its tokens (make one, shown once;
|
|
414
|
+
revoke), and health.
|
|
415
|
+
- **Add an agent** creates its stack and hands over its first token. It's
|
|
416
|
+
served at once, without a restart.
|
|
417
|
+
|
|
405
418
|
**The page needs `LOOM_BEARER_TOKEN`.** A daemon with no token serves none
|
|
406
419
|
(403), the same rule as `/cli`, because any site that DNS-rebinds a name to
|
|
407
420
|
the loopback address could read an open page. Log in once with the token. The
|
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>;
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The owner's view of a household (docs/specs/household.md, "The page").
|
|
3
|
+
*
|
|
4
|
+
* / Home: every agent, what each is doing, the tape
|
|
5
|
+
* across all of them, the commons at a glance
|
|
6
|
+
* /knowledge[/<slug>] the commons' knowledge
|
|
7
|
+
* /people[/<slug>] the directory, each person with every agent's relationship
|
|
8
|
+
* /a/<agent>/… one agent's view (its tape, Mind, its people), plus Setup
|
|
9
|
+
* /new-agent add an agent
|
|
10
|
+
*
|
|
11
|
+
* Only the owner sees these (the page is owner-only), which is why a person's
|
|
12
|
+
* page here may show every agent's relationship: an agent's own tools never
|
|
13
|
+
* can.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import BetterSqlite3 from 'better-sqlite3';
|
|
18
|
+
import { addAgent, AGENT_NAME, agentDir, commonsKnowledgePath, createToken, HouseholdError, loadTokens, parseRole, revokeToken, } from '../household.js';
|
|
19
|
+
import { commonsPeoplePath } from '../backends/people-household.js';
|
|
20
|
+
import { fieldState, PeopleStore } from '../backends/people.js';
|
|
21
|
+
import { resolveSqliteDbPath } from '../config.js';
|
|
22
|
+
import { bootstrap } from '../tools/bootstrap.js';
|
|
23
|
+
import { ago, agentName, uiTimeZone, when } from './core.js';
|
|
24
|
+
import { tapeRows } from './data.js';
|
|
25
|
+
import { validPath } from './vocab.js';
|
|
26
|
+
const HOME_TAPE = 30;
|
|
27
|
+
function agentLink(agent, path = '') {
|
|
28
|
+
return `loom:a/${agent}${path ? `/${path}` : ''}`;
|
|
29
|
+
}
|
|
30
|
+
function count(path, sql, ...args) {
|
|
31
|
+
if (!existsSync(path))
|
|
32
|
+
return 0;
|
|
33
|
+
const db = new BetterSqlite3(path, { readonly: true, fileMustExist: true });
|
|
34
|
+
try {
|
|
35
|
+
return db.prepare(sql).get(...args).n;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
db.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function value(v) {
|
|
45
|
+
return Array.isArray(v) ? v.join(', ') : String(v);
|
|
46
|
+
}
|
|
47
|
+
// ─── Home ────────────────────────────────────────────────────────────────────
|
|
48
|
+
export function householdHome(h, opts = {}) {
|
|
49
|
+
const tz = opts.timeZone ?? uiTimeZone();
|
|
50
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
51
|
+
const agents = h.agents.map((a) => {
|
|
52
|
+
const dir = agentDir(h, a);
|
|
53
|
+
const tape = tapeRows(dir, 24, nowMs);
|
|
54
|
+
return {
|
|
55
|
+
agent: a,
|
|
56
|
+
name: agentName(dir),
|
|
57
|
+
tape,
|
|
58
|
+
memories: count(resolveSqliteDbPath(dir), "SELECT COUNT(*) AS n FROM memories WHERE archived = 0 AND category != 'episode'"),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
const tape = agents
|
|
62
|
+
.flatMap((x) => x.tape.map((e) => ({ ...e, agent: x.agent, name: x.name })))
|
|
63
|
+
.sort((a, b) => (a.created < b.created ? 1 : -1));
|
|
64
|
+
const pages = count(commonsKnowledgePath(h), "SELECT COUNT(*) AS n FROM pages WHERE status = 'active'");
|
|
65
|
+
const people = count(commonsPeoplePath(h), 'SELECT COUNT(*) AS n FROM people WHERE archived_at IS NULL');
|
|
66
|
+
const sections = [
|
|
67
|
+
{
|
|
68
|
+
id: 'agents',
|
|
69
|
+
title: 'Agents',
|
|
70
|
+
blocks: [
|
|
71
|
+
{
|
|
72
|
+
type: 'list',
|
|
73
|
+
items: agents.map((x) => {
|
|
74
|
+
const last = x.tape[0];
|
|
75
|
+
return {
|
|
76
|
+
text: x.name,
|
|
77
|
+
meta: last ? `${ago(last.created, tz, nowMs)} · ${last.where} · ${last.title}` : 'quiet for a day',
|
|
78
|
+
badge: `${x.tape.length} today`,
|
|
79
|
+
page: agentLink(x.agent),
|
|
80
|
+
};
|
|
81
|
+
}),
|
|
82
|
+
},
|
|
83
|
+
{ type: 'link', text: 'Add an agent', page: 'loom:new-agent' },
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'tape',
|
|
88
|
+
title: 'What\'s happening, everywhere',
|
|
89
|
+
blocks: tape.length
|
|
90
|
+
? [{
|
|
91
|
+
type: 'list',
|
|
92
|
+
items: tape.slice(0, HOME_TAPE).map((e) => ({
|
|
93
|
+
text: e.title,
|
|
94
|
+
meta: `${e.name} · ${ago(e.created, tz, nowMs)} · ${e.where}`,
|
|
95
|
+
page: validPath(`a/${e.agent}/memory/${e.ref}`) ? agentLink(e.agent, `memory/${e.ref}`) : undefined,
|
|
96
|
+
})),
|
|
97
|
+
},
|
|
98
|
+
...(tape.length > HOME_TAPE ? [{ type: 'text', text: `and ${tape.length - HOME_TAPE} more today; each agent's page has its whole tape`, tone: 'muted' }] : [])]
|
|
99
|
+
: [{ type: 'text', text: 'Nothing in the last day.', tone: 'muted' }],
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
id: 'commons',
|
|
103
|
+
title: 'The commons',
|
|
104
|
+
side: true,
|
|
105
|
+
blocks: [
|
|
106
|
+
{ type: 'stats', items: [
|
|
107
|
+
{ label: 'knowledge pages', value: pages.toLocaleString('en-US'), page: 'loom:knowledge' },
|
|
108
|
+
{ label: 'people', value: String(people), page: 'loom:people' },
|
|
109
|
+
] },
|
|
110
|
+
{ type: 'text', text: 'Shared by every agent: facts about the world, and who people are and where to reach them.', tone: 'muted' },
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
];
|
|
114
|
+
return {
|
|
115
|
+
tiles: [
|
|
116
|
+
{ label: 'Agents', value: String(h.agents.length), detail: agents.map((x) => x.name).join(', '), page: '' },
|
|
117
|
+
{ label: 'Commons', value: `${pages.toLocaleString('en-US')} pages`, detail: `${people} people`, page: 'knowledge' },
|
|
118
|
+
],
|
|
119
|
+
page: { title: 'Home', sections },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export function knowledgePage(h, query, opts = {}) {
|
|
123
|
+
const tz = opts.timeZone ?? uiTimeZone();
|
|
124
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
125
|
+
const path = commonsKnowledgePath(h);
|
|
126
|
+
if (!existsSync(path))
|
|
127
|
+
return { title: 'Knowledge', intro: 'The commons has no knowledge yet.', sections: [] };
|
|
128
|
+
const db = new BetterSqlite3(path, { readonly: true, fileMustExist: true });
|
|
129
|
+
try {
|
|
130
|
+
const domains = db.prepare("SELECT substr(domain, 1, instr(domain || '/', '/') - 1) AS top, COUNT(*) AS n FROM pages WHERE status = 'active' GROUP BY top ORDER BY n DESC LIMIT 12").all();
|
|
131
|
+
const pick = domains.some((d) => d.top === query.domain) ? query.domain : '';
|
|
132
|
+
const rows = db.prepare(`SELECT slug, title, domain, updated, sourcing FROM pages WHERE status = 'active' ${pick ? "AND (domain = ? OR domain LIKE ? || '/%')" : ''} ORDER BY updated DESC LIMIT 60`).all(...(pick ? [pick, pick] : []));
|
|
133
|
+
const total = count(path, "SELECT COUNT(*) AS n FROM pages WHERE status = 'active'");
|
|
134
|
+
return {
|
|
135
|
+
title: 'Knowledge',
|
|
136
|
+
intro: 'Facts about the world, shared by every agent in the household. Newest first.',
|
|
137
|
+
sections: [
|
|
138
|
+
{ id: 'filter', blocks: [
|
|
139
|
+
{ type: 'filters', param: 'domain', options: [{ value: '', label: 'Everything' }, ...domains.map((d) => ({ value: d.top, label: `${d.top} (${d.n})` }))] },
|
|
140
|
+
{ type: 'text', text: `${total.toLocaleString('en-US')} pages${pick ? `, ${rows.length} shown in ${pick}` : ''}`, tone: 'muted' },
|
|
141
|
+
] },
|
|
142
|
+
{ id: 'pages', blocks: [{
|
|
143
|
+
type: 'list',
|
|
144
|
+
items: rows.map((r) => ({
|
|
145
|
+
text: r.title,
|
|
146
|
+
meta: `${r.domain} · ${ago(r.updated, tz, nowMs)}${r.sourcing ? ` · ${r.sourcing}` : ''}`,
|
|
147
|
+
page: validPath(`knowledge/${r.slug}`) ? `loom:knowledge/${r.slug}` : undefined,
|
|
148
|
+
})),
|
|
149
|
+
}] },
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
db.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
export function knowledgeEntry(h, slug, opts = {}) {
|
|
158
|
+
const tz = opts.timeZone ?? uiTimeZone();
|
|
159
|
+
const path = commonsKnowledgePath(h);
|
|
160
|
+
if (!existsSync(path))
|
|
161
|
+
return { title: 'Not found', sections: [] };
|
|
162
|
+
const db = new BetterSqlite3(path, { readonly: true, fileMustExist: true });
|
|
163
|
+
try {
|
|
164
|
+
const r = db.prepare("SELECT slug, title, domain, body, updated, sourcing, created_by, author, verified_at FROM pages WHERE slug = ? AND status = 'active'").get(slug);
|
|
165
|
+
if (!r)
|
|
166
|
+
return { title: 'Not found', sections: [] };
|
|
167
|
+
const cites = (() => {
|
|
168
|
+
try {
|
|
169
|
+
return db.prepare('SELECT claim, source_kind, source_locator FROM citations c JOIN pages p ON p.id = c.page_id WHERE p.slug = ? LIMIT 40').all(slug);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
})();
|
|
175
|
+
const body = r.body ?? '';
|
|
176
|
+
const chunks = body.match(/[\s\S]{1,1900}/g) ?? [''];
|
|
177
|
+
return {
|
|
178
|
+
title: r.title,
|
|
179
|
+
sections: [
|
|
180
|
+
{ id: 'body', blocks: chunks.slice(0, 18).map((c) => ({ type: 'text', text: c })) },
|
|
181
|
+
...(cites.length ? [{ id: 'citations', title: 'Sources', blocks: [{ type: 'list', items: cites.map((c) => ({ text: c.claim, meta: `${c.source_kind}${c.source_locator ? ` · ${c.source_locator}` : ''}`, ...(c.source_locator && /^https?:\/\//.test(c.source_locator) ? { href: c.source_locator } : {}) })) }] }] : []),
|
|
182
|
+
{ id: 'record', side: true, blocks: [{ type: 'kv', items: [
|
|
183
|
+
{ label: 'domain', value: r.domain },
|
|
184
|
+
{ label: 'sourcing', value: r.sourcing ?? 'unknown' },
|
|
185
|
+
{ label: 'updated', value: when(r.updated, tz) },
|
|
186
|
+
{ label: 'verified', value: when(r.verified_at ?? null, tz) },
|
|
187
|
+
{ label: 'written by', value: r.created_by ?? r.author ?? 'unknown' },
|
|
188
|
+
{ label: 'slug', value: r.slug },
|
|
189
|
+
] }] },
|
|
190
|
+
],
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
db.close();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// ─── People ──────────────────────────────────────────────────────────────────
|
|
198
|
+
function directory(h) {
|
|
199
|
+
const path = commonsPeoplePath(h);
|
|
200
|
+
return existsSync(path) ? new PeopleStore({ dbPath: path, readonly: true }) : null;
|
|
201
|
+
}
|
|
202
|
+
/** Each agent's relationship with a person, for the owner's view. */
|
|
203
|
+
function relationships(h, slug) {
|
|
204
|
+
return h.agents.flatMap((a) => {
|
|
205
|
+
const path = resolveSqliteDbPath(agentDir(h, a));
|
|
206
|
+
if (!existsSync(path))
|
|
207
|
+
return [];
|
|
208
|
+
const st = new PeopleStore({ dbPath: path, readonly: true });
|
|
209
|
+
try {
|
|
210
|
+
const row = st.getRow(slug);
|
|
211
|
+
return row ? [{ agent: a, name: agentName(agentDir(h, a)), row, memories: st.memoriesSince(slug, row.synthesized_at) }] : [];
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
finally {
|
|
217
|
+
st.close();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
export function householdPeople(h) {
|
|
222
|
+
const dir = directory(h);
|
|
223
|
+
if (!dir)
|
|
224
|
+
return { title: 'People', intro: 'Nobody in the directory yet.', sections: [] };
|
|
225
|
+
try {
|
|
226
|
+
const list = dir.list();
|
|
227
|
+
return {
|
|
228
|
+
title: 'People',
|
|
229
|
+
intro: 'One entry per person, shared by every agent: who they are and where to reach them. Each agent keeps its own relationship with them.',
|
|
230
|
+
sections: [{ id: 'everyone', blocks: [{
|
|
231
|
+
type: 'list',
|
|
232
|
+
items: list.map((p) => {
|
|
233
|
+
const rel = relationships(h, p.slug).map((r) => r.name);
|
|
234
|
+
const flag = [p.stale ? `${p.stale} stale` : '', p.unverified ? `${p.unverified} unverified` : ''].filter(Boolean).join(' · ');
|
|
235
|
+
return {
|
|
236
|
+
text: p.name,
|
|
237
|
+
meta: rel.length ? `known to ${rel.join(', ')}` : 'no agent has a relationship yet',
|
|
238
|
+
badge: flag || undefined,
|
|
239
|
+
tone: flag ? 'warn' : undefined,
|
|
240
|
+
page: validPath(`people/${p.slug}`) ? `loom:people/${p.slug}` : undefined,
|
|
241
|
+
};
|
|
242
|
+
}),
|
|
243
|
+
}] }],
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
dir.close();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
export function householdPerson(h, slug, opts = {}) {
|
|
251
|
+
const tz = opts.timeZone ?? uiTimeZone();
|
|
252
|
+
const nowMs = opts.nowMs ?? Date.now();
|
|
253
|
+
const dir = directory(h);
|
|
254
|
+
if (!dir)
|
|
255
|
+
return { title: 'Not found', sections: [] };
|
|
256
|
+
try {
|
|
257
|
+
const d = dir.getRow(slug);
|
|
258
|
+
if (!d)
|
|
259
|
+
return { title: 'Not found', sections: [] };
|
|
260
|
+
const where = Object.entries(d.card.where ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
|
261
|
+
const facts = Object.entries(d.card.how ?? {});
|
|
262
|
+
const history = dir.history(slug).slice(0, 12);
|
|
263
|
+
const sections = [];
|
|
264
|
+
if (where.length) {
|
|
265
|
+
sections.push({ id: 'where', title: 'Where to reach them', blocks: [{
|
|
266
|
+
type: 'table', columns: ['where', 'value', 'from', 'checked'],
|
|
267
|
+
rows: where.map(([k, env]) => {
|
|
268
|
+
const s = fieldState(env, nowMs);
|
|
269
|
+
return [k, value(env.value), env.source ?? '', s.kind === 'ok' ? `ok, ${ago(env.verified_at ?? null, tz, nowMs)}` : s.kind === 'stale' ? `stale ${s.days}d` : 'unverified'];
|
|
270
|
+
}),
|
|
271
|
+
}] });
|
|
272
|
+
}
|
|
273
|
+
for (const r of relationships(h, slug)) {
|
|
274
|
+
const how = Object.entries(r.row.card.how ?? {});
|
|
275
|
+
const blocks = [];
|
|
276
|
+
if (r.row.card.summary)
|
|
277
|
+
blocks.push({ type: 'text', text: r.row.card.summary });
|
|
278
|
+
if (how.length)
|
|
279
|
+
blocks.push({ type: 'kv', items: how.map(([k, v]) => ({ label: k, value: value(v) })) });
|
|
280
|
+
if (r.row.card.may?.length)
|
|
281
|
+
blocks.push({ type: 'list', items: r.row.card.may.map((m) => ({ text: m.text, meta: `said by ${m.source}` })) });
|
|
282
|
+
blocks.push({ type: 'text', text: `${r.memories} memor${r.memories === 1 ? 'y' : 'ies'} newer than this view · updated ${ago(r.row.updated, tz, nowMs)}`, tone: r.memories ? 'warn' : 'muted' });
|
|
283
|
+
if (!r.row.card.summary && !how.length && !r.row.card.may?.length)
|
|
284
|
+
blocks.unshift({ type: 'text', text: 'Knows of them; nothing more yet.', tone: 'muted' });
|
|
285
|
+
blocks.push({ type: 'link', text: `${r.name}'s view`, page: agentLink(r.agent, `people/${slug}`) });
|
|
286
|
+
sections.push({ id: `rel-${r.agent}`, title: `${r.name}`, blocks });
|
|
287
|
+
}
|
|
288
|
+
if (facts.length)
|
|
289
|
+
sections.push({ id: 'facts', title: 'Facts', side: true, blocks: [{ type: 'kv', items: facts.map(([k, v]) => ({ label: k, value: value(v) })) }] });
|
|
290
|
+
if (history.length) {
|
|
291
|
+
sections.push({ id: 'changes', title: 'Changes to the shared entry', side: true, blocks: [{
|
|
292
|
+
type: 'list', items: history.map((r) => ({ text: `${r.actor ?? 'someone'} ${r.op === 'write' ? 'changed it' : r.op}`, meta: ago(r.replaced_at, tz, nowMs) })),
|
|
293
|
+
}] });
|
|
294
|
+
}
|
|
295
|
+
return { title: d.card.name, intro: 'The shared entry, then how each agent sees them.', sections };
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
dir.close();
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
// ─── An agent's Setup ────────────────────────────────────────────────────────
|
|
302
|
+
/**
|
|
303
|
+
* The creed, folded by its own headings: one fold per `#`/`##` section,
|
|
304
|
+
* each split at paragraph breaks to fit a block, never mid-word.
|
|
305
|
+
*/
|
|
306
|
+
function identitySections(md) {
|
|
307
|
+
if (!md.trim())
|
|
308
|
+
return [{ type: 'text', text: '(no IDENTITY.md)', tone: 'muted' }];
|
|
309
|
+
const sections = [];
|
|
310
|
+
for (const line of md.split('\n')) {
|
|
311
|
+
const m = /^#{1,2}\s+(.+)$/.exec(line);
|
|
312
|
+
if (m || sections.length === 0)
|
|
313
|
+
sections.push({ title: m ? m[1].trim() : 'Identity', body: '' });
|
|
314
|
+
if (!m)
|
|
315
|
+
sections[sections.length - 1].body += `${line}\n`;
|
|
316
|
+
}
|
|
317
|
+
return sections.filter((x) => x.body.trim()).slice(0, 16).map((x) => {
|
|
318
|
+
const chunks = [];
|
|
319
|
+
let cur = '';
|
|
320
|
+
for (const para of x.body.trim().split(/\n\s*\n/)) {
|
|
321
|
+
if (cur && cur.length + para.length + 2 > 1900) {
|
|
322
|
+
chunks.push(cur);
|
|
323
|
+
cur = '';
|
|
324
|
+
}
|
|
325
|
+
cur = cur ? `${cur}\n\n${para}` : para.slice(0, 1900);
|
|
326
|
+
}
|
|
327
|
+
if (cur)
|
|
328
|
+
chunks.push(cur);
|
|
329
|
+
return { type: 'details', summary: x.title.slice(0, 200), blocks: chunks.slice(0, 19).map((c) => ({ type: 'text', text: c })) };
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
export function agentSetup(h, agent, opts = {}) {
|
|
333
|
+
const tz = opts.timeZone ?? uiTimeZone();
|
|
334
|
+
const dir = agentDir(h, agent);
|
|
335
|
+
let identity = '';
|
|
336
|
+
try {
|
|
337
|
+
identity = readFileSync(join(dir, 'IDENTITY.md'), 'utf-8');
|
|
338
|
+
}
|
|
339
|
+
catch { /* none */ }
|
|
340
|
+
const harnesses = (() => { try {
|
|
341
|
+
return readdirSync(join(dir, 'harnesses')).filter((f) => f.endsWith('.md')).map((f) => f.slice(0, -3));
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return [];
|
|
345
|
+
} })();
|
|
346
|
+
const tokens = loadTokens(h.home).filter((t) => t.role === `agent:${agent}` || (agent === h.default && t.role === 'owner'));
|
|
347
|
+
const dbSize = (() => { try {
|
|
348
|
+
return statSync(resolveSqliteDbPath(dir)).size;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return 0;
|
|
352
|
+
} })();
|
|
353
|
+
const memories = count(resolveSqliteDbPath(dir), "SELECT COUNT(*) AS n FROM memories WHERE archived = 0 AND category != 'episode'");
|
|
354
|
+
return {
|
|
355
|
+
title: `${agentName(dir)} · Setup`,
|
|
356
|
+
sections: [
|
|
357
|
+
{ id: 'identity', title: 'Identity', blocks: [
|
|
358
|
+
{ type: 'text', text: 'Read-only here: an agent\'s creed is changed by the agent (update_identity) or by hand, never from a web page.', tone: 'muted' },
|
|
359
|
+
...identitySections(identity),
|
|
360
|
+
] },
|
|
361
|
+
{ id: 'tokens', title: 'Tokens', blocks: [
|
|
362
|
+
tokens.length
|
|
363
|
+
? { type: 'details', summary: `${tokens.filter((t) => !t.revoked).length} live, ${tokens.filter((t) => t.revoked).length} revoked`, blocks: tokens.map((t) => (t.revoked
|
|
364
|
+
? { type: 'text', text: `${t.label} (${t.role}) · revoked ${when(t.revoked, tz)}`, tone: 'muted' }
|
|
365
|
+
: { type: 'callout', title: `${t.label} · ${t.role}`, text: `created ${when(t.created, tz)} · ${t.last_used ? `last used ${when(t.last_used, tz)}` : 'never used'}` })) }
|
|
366
|
+
: { type: 'text', text: 'No tokens yet.', tone: 'muted' },
|
|
367
|
+
...tokens.filter((t) => !t.revoked).map((t) => ({ type: 'buttons', items: [{ action: 'revoke-token', label: `Revoke "${t.label}"`, tone: 'danger', fields: { label: t.label } }] })),
|
|
368
|
+
{ type: 'form', action: 'create-token', title: 'New token', submit: 'Create token', hidden: { agent },
|
|
369
|
+
note: 'Shown once, on the next page. Give it to the harness or house that will be this agent; loom keeps only its fingerprint.',
|
|
370
|
+
fields: [{ name: 'label', label: 'Label', kind: 'text', hint: 'Where it goes, e.g. "Art · laptop".', required: true }] },
|
|
371
|
+
] },
|
|
372
|
+
{ id: 'health', side: true, title: 'Health', blocks: [{ type: 'kv', items: [
|
|
373
|
+
{ label: 'memories', value: memories.toLocaleString('en-US') },
|
|
374
|
+
{ label: 'memory store', value: `${(dbSize / 1048576).toFixed(1)} MB` },
|
|
375
|
+
{ label: 'harnesses', value: harnesses.join(', ') || 'none' },
|
|
376
|
+
{ label: 'directory', value: `agents/${agent}` },
|
|
377
|
+
{ label: 'default agent', value: agent === h.default ? 'yes' : 'no' },
|
|
378
|
+
] }] },
|
|
379
|
+
],
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
// ─── Adding an agent ─────────────────────────────────────────────────────────
|
|
383
|
+
export function newAgentPage() {
|
|
384
|
+
return {
|
|
385
|
+
title: 'Add an agent',
|
|
386
|
+
intro: 'A new stack in this household: its own identity, memory and relationships, sharing the commons. You get its first token on the next page.',
|
|
387
|
+
sections: [{ id: 'form', blocks: [{
|
|
388
|
+
type: 'form', action: 'add-agent', submit: 'Create the agent',
|
|
389
|
+
fields: [
|
|
390
|
+
{ name: 'agent', label: 'Short name', kind: 'text', hint: 'Lowercase letters, digits and dashes, e.g. "mark". It names the directory and the token role.', required: true },
|
|
391
|
+
{ name: 'name', label: 'Name', kind: 'text', hint: 'What it\'s called, e.g. "Visual Mark".', required: true },
|
|
392
|
+
{ name: 'purpose', label: 'What it\'s for', kind: 'textarea', required: true },
|
|
393
|
+
{ name: 'voice', label: 'How it talks', kind: 'textarea', hint: 'A line or two. It can change this itself later.', required: true },
|
|
394
|
+
],
|
|
395
|
+
}] }],
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/** The owner's household actions: tokens and agents. */
|
|
399
|
+
export async function householdAct(h, action, fields) {
|
|
400
|
+
try {
|
|
401
|
+
if (action === 'create-token') {
|
|
402
|
+
const agent = fields.agent ?? '';
|
|
403
|
+
const role = agent === '' ? null : parseRole(`agent:${agent}`);
|
|
404
|
+
if (!role)
|
|
405
|
+
return { ok: false, message: 'Name an agent for the token.' };
|
|
406
|
+
const { token } = createToken(h, { role, label: fields.label ?? '' });
|
|
407
|
+
return { ok: true, message: `Token for ${agent}, shown this once: ${token}`, page: `loom:a/${agent}/setup` };
|
|
408
|
+
}
|
|
409
|
+
if (action === 'revoke-token') {
|
|
410
|
+
const t = revokeToken(h, fields.label ?? '');
|
|
411
|
+
const agent = t.role.startsWith('agent:') ? t.role.slice('agent:'.length) : h.default;
|
|
412
|
+
return { ok: true, message: `Revoked "${t.label}". It stops working on its next request.`, page: `loom:a/${agent}/setup` };
|
|
413
|
+
}
|
|
414
|
+
if (action === 'add-agent') {
|
|
415
|
+
const agent = (fields.agent ?? '').trim();
|
|
416
|
+
if (!AGENT_NAME.test(agent))
|
|
417
|
+
return { ok: false, message: 'The short name must be lowercase letters, digits and dashes, starting with a letter.' };
|
|
418
|
+
if (h.agents.includes(agent))
|
|
419
|
+
return { ok: false, message: `${agent} is already in the household.` };
|
|
420
|
+
if (!fields.name?.trim() || !fields.purpose?.trim() || !fields.voice?.trim())
|
|
421
|
+
return { ok: false, message: 'A name, what it\'s for, and how it talks are all needed.' };
|
|
422
|
+
const dir = agentDir(h, agent);
|
|
423
|
+
if (existsSync(dir))
|
|
424
|
+
return { ok: false, message: `agents/${agent} already exists on disk; add it with loom household add.` };
|
|
425
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
426
|
+
await bootstrap(dir, { name: fields.name.trim(), purpose: fields.purpose.trim(), voice: fields.voice.trim(), user: h.owner });
|
|
427
|
+
const next = addAgent(h, agent);
|
|
428
|
+
const { token } = createToken(next, { role: { kind: 'agent', agent }, label: `${fields.name.trim()} · first token` });
|
|
429
|
+
return { ok: true, message: `${fields.name.trim()} is in the household. Its first token, shown this once: ${token}`, page: `loom:a/${agent}/setup` };
|
|
430
|
+
}
|
|
431
|
+
return { ok: false, message: `loom has no action "${action}".` };
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
if (err instanceof HouseholdError)
|
|
435
|
+
return { ok: false, message: err.message };
|
|
436
|
+
return { ok: false, message: err.message };
|
|
437
|
+
}
|
|
438
|
+
}
|
package/dist/ui/render.d.ts
CHANGED
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Ask, Page, Tile } from './vocab.js';
|
|
8
8
|
export declare function esc(s: string): string;
|
|
9
|
-
/**
|
|
9
|
+
/**
|
|
10
|
+
* Whose page this is: loom's own (`loom`) or a piece's. `base` is where its
|
|
11
|
+
* page links resolve; `act` is where its forms post (one agent's view in a
|
|
12
|
+
* household posts to that agent).
|
|
13
|
+
*/
|
|
10
14
|
export interface Source {
|
|
11
15
|
id: string;
|
|
12
16
|
base: string;
|
|
17
|
+
act?: string;
|
|
13
18
|
}
|
|
14
19
|
export declare const CORE: Source;
|
|
15
20
|
export declare const pieceSource: (piece: string) => Source;
|
|
21
|
+
/** One agent's view in a household. */
|
|
22
|
+
export declare const agentSource: (agent: string) => Source;
|
|
16
23
|
export interface RenderContext {
|
|
17
24
|
source: Source;
|
|
18
25
|
/** This page's path within its source, and its query, for filters and for returning after an action. */
|
package/dist/ui/render.js
CHANGED
|
@@ -9,6 +9,8 @@ export function esc(s) {
|
|
|
9
9
|
}
|
|
10
10
|
export const CORE = { id: 'loom', base: '/' };
|
|
11
11
|
export const pieceSource = (piece) => ({ id: piece, base: `/p/${piece}/` });
|
|
12
|
+
/** One agent's view in a household. */
|
|
13
|
+
export const agentSource = (agent) => ({ id: 'loom', base: `/a/${agent}/`, act: `/a/${agent}/act/loom` });
|
|
12
14
|
function qs(query) {
|
|
13
15
|
const p = new URLSearchParams();
|
|
14
16
|
for (const [k, v] of Object.entries(query))
|
|
@@ -40,7 +42,7 @@ function hiddenInputs(fields) {
|
|
|
40
42
|
}
|
|
41
43
|
function actionForm(ctx, action, inner, cls) {
|
|
42
44
|
const back = ctx.path + qs(ctx.query);
|
|
43
|
-
return `<form class="${cls}" method="post" action="
|
|
45
|
+
return `<form class="${cls}" method="post" action="${esc(ctx.source.act ?? `/act/${ctx.source.id}`)}">${hiddenInputs({ _csrf: ctx.csrf, _action: action, _path: back })}${inner}</form>`;
|
|
44
46
|
}
|
|
45
47
|
let fieldSeq = 0;
|
|
46
48
|
function renderField(f) {
|
package/dist/ui/vocab.js
CHANGED
|
@@ -31,7 +31,7 @@ const PATH_CHARS = /^[a-z0-9._~/-]*$/;
|
|
|
31
31
|
class Invalid extends Error {
|
|
32
32
|
}
|
|
33
33
|
/** loom's own pages a link may name with `loom:`. */
|
|
34
|
-
const CORE_PAGES = /^(|mind|people|people\/[a-z0-9][a-z0-9-]*|memory\/.+)$/;
|
|
34
|
+
const CORE_PAGES = /^(|mind|people|people\/[a-z0-9][a-z0-9-]*|memory\/.+|knowledge|knowledge\/.+|new-agent|a\/[a-z][a-z0-9-]{0,39}(|\/mind|\/setup|\/people|\/people\/[a-z0-9][a-z0-9-]*|\/memory\/.+))$/;
|
|
35
35
|
/** A page path: `""` (the main page) or lowercase segments, never `..`, never absolute. */
|
|
36
36
|
export function validPath(p) {
|
|
37
37
|
if (p.length > LIMITS.path || !PATH_CHARS.test(p))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jbarket/loomai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.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",
|