@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,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",
|