@jbarket/loomai 0.9.2 → 0.11.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 +69 -1
- package/README.md +29 -0
- package/dist/backends/migrations.js +9 -0
- package/dist/backends/people-household.d.ts +77 -0
- package/dist/backends/people-household.js +305 -0
- package/dist/backends/people.d.ts +37 -1
- package/dist/backends/people.js +42 -7
- package/dist/cli/args.js +2 -0
- package/dist/cli/household.d.ts +3 -0
- package/dist/cli/household.js +159 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/serve.js +9 -2
- package/dist/cli/subcommands.d.ts +1 -1
- package/dist/cli/subcommands.js +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.js +16 -1
- package/dist/household.d.ts +117 -0
- package/dist/household.js +459 -0
- package/dist/injection/writer.js +5 -2
- package/dist/path-safety.d.ts +1 -1
- package/dist/path-safety.js +5 -3
- package/dist/server.d.ts +7 -0
- package/dist/server.js +6 -4
- package/dist/tools/harness.d.ts +17 -0
- package/dist/tools/harness.js +45 -2
- package/dist/tools/person.d.ts +1 -0
- package/dist/tools/person.js +19 -6
- package/dist/transport/http-server.d.ts +7 -0
- package/dist/transport/http-server.js +156 -20
- package/dist/ui/handler.d.ts +15 -2
- package/dist/ui/handler.js +22 -10
- package/package.json +1 -1
package/dist/backends/people.js
CHANGED
|
@@ -313,7 +313,7 @@ function canonical(v) {
|
|
|
313
313
|
return JSON.stringify(v);
|
|
314
314
|
}
|
|
315
315
|
// ─── Store ───────────────────────────────────────────────────────────────────
|
|
316
|
-
const
|
|
316
|
+
const PEOPLE_MIGRATIONS = ['people_cards', 'person_revisions_actor'];
|
|
317
317
|
function hasTable(db, table) {
|
|
318
318
|
return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !== undefined;
|
|
319
319
|
}
|
|
@@ -339,19 +339,23 @@ function parseRow(raw) {
|
|
|
339
339
|
export class PeopleStore {
|
|
340
340
|
db;
|
|
341
341
|
now;
|
|
342
|
+
actor;
|
|
342
343
|
/** False on a read-only store whose tables do not exist yet. */
|
|
343
344
|
migrated;
|
|
344
345
|
constructor(config) {
|
|
345
346
|
this.now = config.now ?? Date.now;
|
|
347
|
+
this.actor = config.actor ?? null;
|
|
346
348
|
this.db = new BetterSqlite3(config.dbPath, config.readonly ? { readonly: true } : {});
|
|
347
349
|
this.db.pragma(`busy_timeout = ${config.busyTimeoutMs ?? 5000}`);
|
|
348
350
|
if (!config.readonly) {
|
|
349
351
|
this.db.pragma('journal_mode = WAL');
|
|
350
352
|
// Only this feature's migration: the people tables do not depend on the
|
|
351
353
|
// memories schema, and a raw store must not half-run the others.
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
m.
|
|
354
|
+
for (const id of PEOPLE_MIGRATIONS) {
|
|
355
|
+
const m = MIGRATIONS.find((x) => x.id === id);
|
|
356
|
+
if (m.pending(this.db))
|
|
357
|
+
m.run(this.db);
|
|
358
|
+
}
|
|
355
359
|
}
|
|
356
360
|
this.migrated = hasTable(this.db, 'people') && hasTable(this.db, 'person_revisions');
|
|
357
361
|
}
|
|
@@ -368,8 +372,8 @@ export class PeopleStore {
|
|
|
368
372
|
const snap = { ...prev, card: JSON.parse(prev.card_json) };
|
|
369
373
|
delete snap.card_json;
|
|
370
374
|
const id = Number(this.db
|
|
371
|
-
.prepare('INSERT INTO person_revisions (slug, snapshot_json, op, replaced_at) VALUES (?, ?, ?, ?)')
|
|
372
|
-
.run(prev.slug, JSON.stringify(snap), op, at).lastInsertRowid);
|
|
375
|
+
.prepare('INSERT INTO person_revisions (slug, snapshot_json, op, replaced_at, actor) VALUES (?, ?, ?, ?, ?)')
|
|
376
|
+
.run(prev.slug, JSON.stringify(snap), op, at, this.actor).lastInsertRowid);
|
|
373
377
|
this.db
|
|
374
378
|
.prepare(`DELETE FROM person_revisions WHERE slug = ? AND id NOT IN
|
|
375
379
|
(SELECT id FROM person_revisions WHERE slug = ? ORDER BY id DESC LIMIT ?)`)
|
|
@@ -681,10 +685,41 @@ export class PeopleStore {
|
|
|
681
685
|
fail(`slug: ${slugErr}`);
|
|
682
686
|
if (!this.migrated)
|
|
683
687
|
return [];
|
|
688
|
+
const hasActor = this.db.pragma('table_info(person_revisions)').some((c) => c.name === 'actor');
|
|
684
689
|
return this.db
|
|
685
|
-
.prepare(
|
|
690
|
+
.prepare(`SELECT id, op, replaced_at${hasActor ? ', actor' : ''} FROM person_revisions WHERE slug = ? ORDER BY id DESC`)
|
|
686
691
|
.all(slug);
|
|
687
692
|
}
|
|
693
|
+
/**
|
|
694
|
+
* Put a card in place as is, keeping the row's timestamps and archive state
|
|
695
|
+
* (a migration, not an edit). Recorded as a 'split' revision when a row is
|
|
696
|
+
* replaced.
|
|
697
|
+
*/
|
|
698
|
+
replaceCard(slug, card, keep) {
|
|
699
|
+
validateCard(card, null);
|
|
700
|
+
this.mutate(() => {
|
|
701
|
+
const existing = this.readRow(slug);
|
|
702
|
+
if (existing)
|
|
703
|
+
this.snapshot(existing, 'split', this.nowIso());
|
|
704
|
+
this.store({ slug, name: card.name, card_json: JSON.stringify(card), ...keep });
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
/** The raw stored rows (timestamps and archive state included), for a migration. */
|
|
708
|
+
rows() {
|
|
709
|
+
if (!this.migrated)
|
|
710
|
+
return [];
|
|
711
|
+
return this.db.prepare('SELECT slug, name, card_json, archived_at, archived_by, archive_reason, updated, synthesized_at FROM people ORDER BY slug').all()
|
|
712
|
+
.map((r) => ({ slug: r.slug, card: JSON.parse(r.card_json), updated: r.updated, synthesized_at: r.synthesized_at, archived_at: r.archived_at, archived_by: r.archived_by, archive_reason: r.archive_reason }));
|
|
713
|
+
}
|
|
714
|
+
/** This database's `user` memories about a person, newer than `since` (all of them when null). */
|
|
715
|
+
memoriesSince(slug, since) {
|
|
716
|
+
if (!hasTable(this.db, 'memories'))
|
|
717
|
+
return 0;
|
|
718
|
+
const nowIso = new Date(this.now()).toISOString();
|
|
719
|
+
return this.db.prepare(`SELECT COUNT(*) AS n FROM memories
|
|
720
|
+
WHERE category = 'user' AND archived = 0 AND json_extract(metadata, '$.person') = ?
|
|
721
|
+
AND (expires_at IS NULL OR expires_at > ?) AND (? IS NULL OR created > ?)`).get(slug, nowIso, since, since).n;
|
|
722
|
+
}
|
|
688
723
|
/** A revision's snapshot (the whole previous row), for tests and inspection. */
|
|
689
724
|
revisionSnapshot(id) {
|
|
690
725
|
const rev = this.db.prepare('SELECT snapshot_json FROM person_revisions WHERE id = ?').get(id);
|
package/dist/cli/args.js
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { resolve } from 'node:path';
|
|
7
7
|
import { resolveDefaultContextPath } from '../config.js';
|
|
8
|
+
import { contextFromHome } from '../household.js';
|
|
8
9
|
export function resolveEnv(flags, processEnv) {
|
|
9
10
|
const contextDir = flags.contextDir ??
|
|
10
11
|
processEnv.LOOM_CONTEXT_DIR ??
|
|
12
|
+
contextFromHome(processEnv) ??
|
|
11
13
|
resolveDefaultContextPath(processEnv.HOME);
|
|
12
14
|
return {
|
|
13
15
|
contextDir: resolve(contextDir),
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `loom household` and `loom token` (docs/specs/household.md). Local only:
|
|
3
|
+
* neither is a remote verb, so they never run through /cli. They read and
|
|
4
|
+
* write $LOOM_HOME (default ~/.config/loom) directly.
|
|
5
|
+
*/
|
|
6
|
+
import { addAgent, agentDir, moveKnowledgeToCommons, createToken, householdHome, HouseholdError, initHousehold, loadHousehold, loadTokens, parseRole, revokeToken, } from '../household.js';
|
|
7
|
+
import { splitPeopleIntoCommons } from '../backends/people-household.js';
|
|
8
|
+
const HOUSEHOLD_USAGE = `Usage: loom household <command>
|
|
9
|
+
|
|
10
|
+
show The household at $LOOM_HOME: agents, the default, the owner
|
|
11
|
+
init <agent> [--owner <name>]
|
|
12
|
+
Make $LOOM_HOME/<agent> (a context dir) the first agent of a new
|
|
13
|
+
household: it moves to agents/<agent>, and a symlink stays behind
|
|
14
|
+
add <agent> Add an agent whose directory already exists under agents/
|
|
15
|
+
commons Move the default agent's knowledge.db to commons/knowledge.db and
|
|
16
|
+
split every agent's person cards (handles and facts to the commons
|
|
17
|
+
directory, the rest stays as its relationships). Once, with
|
|
18
|
+
every loom daemon on this household stopped
|
|
19
|
+
|
|
20
|
+
$LOOM_HOME defaults to ~/.config/loom.
|
|
21
|
+
`;
|
|
22
|
+
const TOKEN_USAGE = `Usage: loom token <command>
|
|
23
|
+
|
|
24
|
+
create (--agent <name> | --owner | --peer <name>) --label <text>
|
|
25
|
+
Mint a token. It's printed once; only its sha256 is kept
|
|
26
|
+
list Every token: label, role, created, last used, revoked
|
|
27
|
+
revoke <label> Revoke the live token with that label (takes effect on the next request)
|
|
28
|
+
|
|
29
|
+
Roles: agent:<name> is MCP and the CLI as that agent; owner sees everything and
|
|
30
|
+
the page; peer:<name> reaches commons knowledge only.
|
|
31
|
+
`;
|
|
32
|
+
function flag(args, name) {
|
|
33
|
+
const i = args.indexOf(name);
|
|
34
|
+
return i === -1 ? undefined : args[i + 1];
|
|
35
|
+
}
|
|
36
|
+
function need(io) {
|
|
37
|
+
const home = householdHome(io.env);
|
|
38
|
+
const h = loadHousehold(home);
|
|
39
|
+
if (!h)
|
|
40
|
+
io.stderr(`No household at ${home} (no household.json). Make one with: loom household init <agent>\n`);
|
|
41
|
+
return h;
|
|
42
|
+
}
|
|
43
|
+
export async function runHousehold(argv, io) {
|
|
44
|
+
const [sub, ...rest] = argv;
|
|
45
|
+
try {
|
|
46
|
+
if (sub === 'show') {
|
|
47
|
+
const h = need(io);
|
|
48
|
+
if (!h)
|
|
49
|
+
return 1;
|
|
50
|
+
io.stdout(`home ${h.home}\nagents ${h.agents.join(', ')}\ndefault ${h.default}\n${h.owner ? `owner ${h.owner}\n` : ''}`);
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
53
|
+
if (sub === 'init') {
|
|
54
|
+
const agent = rest[0];
|
|
55
|
+
if (!agent || agent.startsWith('-')) {
|
|
56
|
+
io.stderr(HOUSEHOLD_USAGE);
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
const h = initHousehold(householdHome(io.env), agent, { owner: flag(rest, '--owner') });
|
|
60
|
+
const cards = splitPeopleIntoCommons(h, agent, agentDir(h, agent));
|
|
61
|
+
io.stdout(`Household at ${h.home}: ${agent} moved to agents/${agent}; ${h.home}/${agent} is now a symlink to it. ${cards} person card${cards === 1 ? '' : 's'} split into the commons directory.\n`);
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
if (sub === 'commons') {
|
|
65
|
+
const h = need(io);
|
|
66
|
+
if (!h)
|
|
67
|
+
return 1;
|
|
68
|
+
const moved = moveKnowledgeToCommons(h, h.default);
|
|
69
|
+
io.stdout(moved ? `Knowledge moved to ${moved}; every agent shares it now.\n` : `agents/${h.default} has no knowledge.db to move.\n`);
|
|
70
|
+
// Every agent's cards, so the daemon's "haven't been split" refusal can
|
|
71
|
+
// always be cleared here.
|
|
72
|
+
for (const a of h.agents) {
|
|
73
|
+
const cards = splitPeopleIntoCommons(h, a, agentDir(h, a));
|
|
74
|
+
io.stdout(`${a}: ${cards} person card${cards === 1 ? '' : 's'} split (handles and facts in the commons directory, the rest as ${a}'s relationships).\n`);
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
if (sub === 'add') {
|
|
79
|
+
const h = need(io);
|
|
80
|
+
if (!h)
|
|
81
|
+
return 1;
|
|
82
|
+
const agent = rest[0];
|
|
83
|
+
if (!agent) {
|
|
84
|
+
io.stderr(HOUSEHOLD_USAGE);
|
|
85
|
+
return 2;
|
|
86
|
+
}
|
|
87
|
+
const next = addAgent(h, agent);
|
|
88
|
+
io.stdout(`Agents: ${next.agents.join(', ')}\n`);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
io.stderr(HOUSEHOLD_USAGE);
|
|
92
|
+
return sub === undefined || sub === '--help' || sub === '-h' ? 0 : 2;
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
if (err instanceof HouseholdError) {
|
|
96
|
+
io.stderr(`${err.message}\n`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function runToken(argv, io) {
|
|
103
|
+
const [sub, ...rest] = argv;
|
|
104
|
+
try {
|
|
105
|
+
if (sub === 'create') {
|
|
106
|
+
const h = need(io);
|
|
107
|
+
if (!h)
|
|
108
|
+
return 1;
|
|
109
|
+
const label = flag(rest, '--label');
|
|
110
|
+
const picked = [flag(rest, '--agent') && `agent:${flag(rest, '--agent')}`, rest.includes('--owner') && 'owner', flag(rest, '--peer') && `peer:${flag(rest, '--peer')}`].filter(Boolean);
|
|
111
|
+
const role = picked.length === 1 ? parseRole(picked[0]) : null;
|
|
112
|
+
if (!label || !role) {
|
|
113
|
+
io.stderr(TOKEN_USAGE);
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
116
|
+
const { token, record } = createToken(h, { role, label });
|
|
117
|
+
// The token goes to stdout alone, so it can be piped straight into a file.
|
|
118
|
+
io.stderr(`Created ${record.role} token "${record.label}". It won't be shown again.\n`);
|
|
119
|
+
io.stdout(`${token}\n`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
if (sub === 'list') {
|
|
123
|
+
const h = need(io);
|
|
124
|
+
if (!h)
|
|
125
|
+
return 1;
|
|
126
|
+
const tokens = loadTokens(h.home);
|
|
127
|
+
if (!tokens.length) {
|
|
128
|
+
io.stdout('No tokens yet.\n');
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
for (const t of tokens) {
|
|
132
|
+
io.stdout(`${t.revoked ? 'revoked' : 'live '} ${t.role.padEnd(16)} ${t.label} (created ${t.created.slice(0, 10)}${t.last_used ? `, last used ${t.last_used.slice(0, 16).replace('T', ' ')}` : ', never used'}${t.revoked ? `, revoked ${t.revoked.slice(0, 10)}` : ''})\n`);
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
if (sub === 'revoke') {
|
|
137
|
+
const h = need(io);
|
|
138
|
+
if (!h)
|
|
139
|
+
return 1;
|
|
140
|
+
const label = rest.join(' ').trim();
|
|
141
|
+
if (!label) {
|
|
142
|
+
io.stderr(TOKEN_USAGE);
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
|
145
|
+
const t = revokeToken(h, label);
|
|
146
|
+
io.stdout(`Revoked ${t.role} token "${t.label}".\n`);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
io.stderr(TOKEN_USAGE);
|
|
150
|
+
return sub === undefined || sub === '--help' || sub === '-h' ? 0 : 2;
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
if (err instanceof HouseholdError) {
|
|
154
|
+
io.stderr(`${err.message}\n`);
|
|
155
|
+
return 1;
|
|
156
|
+
}
|
|
157
|
+
throw err;
|
|
158
|
+
}
|
|
159
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -32,6 +32,8 @@ Commands:
|
|
|
32
32
|
doctor Probe the loom environment (read-only)
|
|
33
33
|
migrate Apply pending schema migrations to memories.db
|
|
34
34
|
pieces List installed pieces (loom-<name> on PATH)
|
|
35
|
+
household The household: agents and the default (docs/specs/household.md)
|
|
36
|
+
token Mint, list and revoke the household's tokens
|
|
35
37
|
|
|
36
38
|
Pieces: \`loom <name> ...\` runs an installed extension, loom-<name> on PATH
|
|
37
39
|
(e.g. loom tasks list). Core commands always win. See docs/extensions.md.
|
|
@@ -198,6 +200,14 @@ async function dispatch(sub, rest, io) {
|
|
|
198
200
|
const { runPiecesCommand } = await import('./pieces.js');
|
|
199
201
|
return runPiecesCommand(rest, io);
|
|
200
202
|
}
|
|
203
|
+
case 'household': {
|
|
204
|
+
const { runHousehold } = await import('./household.js');
|
|
205
|
+
return runHousehold(rest, io);
|
|
206
|
+
}
|
|
207
|
+
case 'token': {
|
|
208
|
+
const { runToken } = await import('./household.js');
|
|
209
|
+
return runToken(rest, io);
|
|
210
|
+
}
|
|
201
211
|
default:
|
|
202
212
|
io.stderr(`Subcommand not implemented yet: ${sub}\n`);
|
|
203
213
|
return 2;
|
package/dist/cli/serve.js
CHANGED
|
@@ -17,6 +17,7 @@ import { parseArgs } from 'node:util';
|
|
|
17
17
|
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
|
|
18
18
|
import { createLoomServer } from '../server.js';
|
|
19
19
|
import { createKnowledgeOnlyServer } from '../transport/knowledge-server.js';
|
|
20
|
+
import { householdOf } from '../household.js';
|
|
20
21
|
import { startHttpServer } from '../transport/http-server.js';
|
|
21
22
|
import { resolveContextDir } from '../config.js';
|
|
22
23
|
const USAGE = `Usage: loom serve [options]
|
|
@@ -140,9 +141,15 @@ export async function run(argv, io) {
|
|
|
140
141
|
const host = values.host ?? process.env.LOOM_HTTP_HOST ?? '127.0.0.1';
|
|
141
142
|
const port = Number(values.port ?? process.env.LOOM_HTTP_PORT ?? 8787);
|
|
142
143
|
const token = process.env.LOOM_BEARER_TOKEN || undefined;
|
|
143
|
-
|
|
144
|
+
// A context dir at <home>/agents/<name> next to a household.json serves
|
|
145
|
+
// the whole household (docs/specs/household.md); anything else is a
|
|
146
|
+
// household of one, exactly as before.
|
|
147
|
+
const found = householdOf(contextDir);
|
|
148
|
+
const handle = await startHttpServer({ contextDir, host, port, token, gapsDir, household: found?.household });
|
|
144
149
|
io.stderr(`loom: HTTP MCP daemon on http://${handle.host}:${handle.port} ` +
|
|
145
|
-
|
|
150
|
+
(found
|
|
151
|
+
? `(household of ${found.household.agents.join(', ')}; tokens in ${found.household.home}/tokens.json)\n`
|
|
152
|
+
: `(${token ? 'bearer-gated' : 'open — network is the boundary'})\n`));
|
|
146
153
|
// Hold open until the process is signalled.
|
|
147
154
|
await new Promise(() => { });
|
|
148
155
|
return 0;
|
|
@@ -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'];
|
|
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'];
|
|
7
7
|
export type Subcommand = typeof SUBCOMMANDS[number];
|
package/dist/cli/subcommands.js
CHANGED
|
@@ -7,5 +7,5 @@ export const SUBCOMMANDS = [
|
|
|
7
7
|
'wake', 'recall', 'remember', 'forget', 'update',
|
|
8
8
|
'memory', 'knowledge', 'person', 'update-identity', 'bootstrap', 'serve',
|
|
9
9
|
'inject', 'harness',
|
|
10
|
-
'install', 'doctor', 'migrate', 'pieces',
|
|
10
|
+
'install', 'doctor', 'migrate', 'pieces', 'household', 'token',
|
|
11
11
|
];
|
package/dist/config.d.ts
CHANGED
|
@@ -24,6 +24,11 @@ export declare function assertContextBootable(contextDir: string, defaultPath?:
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function resolveRepoRoot(): string;
|
|
26
26
|
export declare function resolveSqliteDbPath(contextDir: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Knowledge is facts about the world. In a household it's the commons, shared
|
|
29
|
+
* by every agent (docs/specs/household.md); otherwise it lives in the
|
|
30
|
+
* agent's own context dir, as it always has.
|
|
31
|
+
*/
|
|
27
32
|
export declare function resolveKnowledgeDbPath(contextDir: string): string;
|
|
28
33
|
/**
|
|
29
34
|
* Resolve the acting identity's name for actor attribution (knowledge
|
package/dist/config.js
CHANGED
|
@@ -18,6 +18,7 @@ import { resolve, dirname, basename } from 'node:path';
|
|
|
18
18
|
import { homedir } from 'node:os';
|
|
19
19
|
import { fileURLToPath } from 'node:url';
|
|
20
20
|
import { existsSync, readFileSync, writeFileSync, statSync, realpathSync } from 'node:fs';
|
|
21
|
+
import { commonsKnowledgePath, contextFromHome, householdOf } from './household.js';
|
|
21
22
|
/**
|
|
22
23
|
* The single canonical helper for the default context path. Every call
|
|
23
24
|
* site that needs `~/.config/loom/default` must go through this —
|
|
@@ -34,6 +35,10 @@ export function resolveContextDir() {
|
|
|
34
35
|
if (argIdx !== -1 && process.argv[argIdx + 1]) {
|
|
35
36
|
return resolve(process.argv[argIdx + 1]);
|
|
36
37
|
}
|
|
38
|
+
// LOOM_HOME with a household.json: the agent LOOM_AGENT names, or its default.
|
|
39
|
+
const fromHome = contextFromHome();
|
|
40
|
+
if (fromHome)
|
|
41
|
+
return fromHome;
|
|
37
42
|
return resolveDefaultContextPath();
|
|
38
43
|
}
|
|
39
44
|
/**
|
|
@@ -67,8 +72,18 @@ export function resolveRepoRoot() {
|
|
|
67
72
|
export function resolveSqliteDbPath(contextDir) {
|
|
68
73
|
return process.env.LOOM_SQLITE_DB_PATH ?? resolve(contextDir, 'memories.db');
|
|
69
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Knowledge is facts about the world. In a household it's the commons, shared
|
|
77
|
+
* by every agent (docs/specs/household.md); otherwise it lives in the
|
|
78
|
+
* agent's own context dir, as it always has.
|
|
79
|
+
*/
|
|
70
80
|
export function resolveKnowledgeDbPath(contextDir) {
|
|
71
|
-
|
|
81
|
+
if (process.env.LOOM_KNOWLEDGE_DB_PATH)
|
|
82
|
+
return process.env.LOOM_KNOWLEDGE_DB_PATH;
|
|
83
|
+
const hh = householdOf(contextDir);
|
|
84
|
+
if (hh)
|
|
85
|
+
return commonsKnowledgePath(hh.household);
|
|
86
|
+
return resolve(contextDir, 'knowledge.db');
|
|
72
87
|
}
|
|
73
88
|
/**
|
|
74
89
|
* Resolve the acting identity's name for actor attribution (knowledge
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export declare const AGENT_NAME: RegExp;
|
|
2
|
+
export interface Household {
|
|
3
|
+
/** $LOOM_HOME: where household.json lives. */
|
|
4
|
+
home: string;
|
|
5
|
+
agents: string[];
|
|
6
|
+
/** The agent the owner's token acts as when it names none. */
|
|
7
|
+
default: string;
|
|
8
|
+
owner?: string;
|
|
9
|
+
}
|
|
10
|
+
export type Role = {
|
|
11
|
+
kind: 'agent';
|
|
12
|
+
agent: string;
|
|
13
|
+
} | {
|
|
14
|
+
kind: 'owner';
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'peer';
|
|
17
|
+
name: string;
|
|
18
|
+
};
|
|
19
|
+
export interface TokenRecord {
|
|
20
|
+
label: string;
|
|
21
|
+
/** `agent:<name>`, `owner` or `peer:<name>`. */
|
|
22
|
+
role: string;
|
|
23
|
+
/** sha256 of the token, hex. The token itself is never stored. */
|
|
24
|
+
sha256: string;
|
|
25
|
+
created: string;
|
|
26
|
+
last_used?: string;
|
|
27
|
+
revoked?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare class HouseholdError extends Error {
|
|
30
|
+
}
|
|
31
|
+
/** $LOOM_HOME, else ~/.config/loom. */
|
|
32
|
+
export declare function householdHome(env?: NodeJS.ProcessEnv): string;
|
|
33
|
+
export declare function agentDir(h: Household, agent: string): string;
|
|
34
|
+
/** The household at `home`, or null when there's none. Throws on a malformed file. */
|
|
35
|
+
export declare function loadHousehold(home: string): Household | null;
|
|
36
|
+
/**
|
|
37
|
+
* The household a context dir belongs to: one whose `agents/<name>` is this
|
|
38
|
+
* dir (after resolving symlinks, so an old `~/.config/loom/art` link still
|
|
39
|
+
* finds it). Null for a plain context dir.
|
|
40
|
+
*/
|
|
41
|
+
export declare function householdOf(contextDir: string): {
|
|
42
|
+
household: Household;
|
|
43
|
+
agent: string;
|
|
44
|
+
} | null;
|
|
45
|
+
export declare function parseRole(s: string): Role | null;
|
|
46
|
+
export declare function roleString(r: Role): string;
|
|
47
|
+
export declare function loadTokens(home: string): TokenRecord[];
|
|
48
|
+
/** Mint a token. Returns it once; only its sha256 is kept. */
|
|
49
|
+
export declare function createToken(h: Household, input: {
|
|
50
|
+
role: Role;
|
|
51
|
+
label: string;
|
|
52
|
+
}, now?: Date): {
|
|
53
|
+
token: string;
|
|
54
|
+
record: TokenRecord;
|
|
55
|
+
};
|
|
56
|
+
export declare function revokeToken(h: Household, label: string, now?: Date): TokenRecord;
|
|
57
|
+
/**
|
|
58
|
+
* Resolves presented tokens to roles. Reads tokens.json when it changes (so
|
|
59
|
+
* a revoke takes effect on the next request), compares digests in constant
|
|
60
|
+
* time, and records when a token was last used, at most once a minute.
|
|
61
|
+
*/
|
|
62
|
+
export declare class TokenResolver {
|
|
63
|
+
private readonly h;
|
|
64
|
+
private cache;
|
|
65
|
+
private lastTouch;
|
|
66
|
+
constructor(h: Household);
|
|
67
|
+
private current;
|
|
68
|
+
/**
|
|
69
|
+
* The role for a presented token, or null. Revoked tokens and roles naming a
|
|
70
|
+
* missing agent resolve to null. Throws HouseholdError when tokens.json is
|
|
71
|
+
* unreadable or untrusted: the caller fails closed.
|
|
72
|
+
*/
|
|
73
|
+
resolve(presented: string): Role | null;
|
|
74
|
+
/** Whether a live owner token has this sha256 (the page's sessions check this, so revoking ends them). */
|
|
75
|
+
ownerDigestLive(digestHex: string): boolean;
|
|
76
|
+
private touch;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Turn a plain context dir into the first agent of a new household: move
|
|
80
|
+
* `<home>/<name>` to `<home>/agents/<name>`, leave a symlink at the old path
|
|
81
|
+
* so anything that still names it keeps working, and write household.json.
|
|
82
|
+
*/
|
|
83
|
+
export declare function initHousehold(home: string, agent: string, opts?: {
|
|
84
|
+
owner?: string;
|
|
85
|
+
}): Household;
|
|
86
|
+
/**
|
|
87
|
+
* An agent's directory must be a real directory, not a symlink: `agents/mark
|
|
88
|
+
* -> art` would make two agents share one stack.
|
|
89
|
+
*/
|
|
90
|
+
export declare function assertRealAgentDir(h: Household, agent: string): void;
|
|
91
|
+
/** Add an agent to a household (its directory must already exist under agents/). */
|
|
92
|
+
export declare function addAgent(h: Household, agent: string): Household;
|
|
93
|
+
/**
|
|
94
|
+
* The context dir a household names when nothing else does: with LOOM_HOME
|
|
95
|
+
* set and a household.json there, `agents/<LOOM_AGENT or the default>`.
|
|
96
|
+
* Undefined otherwise. Only an explicit LOOM_HOME is consulted here, so a
|
|
97
|
+
* stray household.json never changes where an unconfigured process looks.
|
|
98
|
+
* (A daemon is different: one whose context dir already is
|
|
99
|
+
* `<home>/agents/<name>` serves that household; see householdOf.)
|
|
100
|
+
*/
|
|
101
|
+
export declare function contextFromHome(env?: NodeJS.ProcessEnv): string | undefined;
|
|
102
|
+
export declare function commonsDir(h: Household): string;
|
|
103
|
+
/** The household's shared knowledge store. */
|
|
104
|
+
export declare function commonsKnowledgePath(h: Household): string;
|
|
105
|
+
/**
|
|
106
|
+
* Why this household can't serve yet, or null. An agent's own knowledge.db
|
|
107
|
+
* beside the commons would quietly fork the knowledge base, so both "only the
|
|
108
|
+
* agent's" (not moved yet) and "both" (needs a merge) are refusals.
|
|
109
|
+
*/
|
|
110
|
+
export declare function commonsProblem(h: Household): string | null;
|
|
111
|
+
/**
|
|
112
|
+
* Move one agent's knowledge.db to the commons: checkpoint its WAL into the
|
|
113
|
+
* main file, then rename it (and any sidecars). Refuses when the commons
|
|
114
|
+
* already has a knowledge store. Run with every daemon on this household
|
|
115
|
+
* stopped.
|
|
116
|
+
*/
|
|
117
|
+
export declare function moveKnowledgeToCommons(h: Household, agent: string): string | null;
|