@jbarket/loomai 0.13.0 → 0.14.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 +32 -1
- package/dist/bundle.js +16 -6
- package/dist/cli/household.js +4 -3
- package/dist/household.d.ts +10 -1
- package/dist/household.js +9 -1
- package/dist/transport/http-server.js +85 -2
- package/dist/ui/pieces.d.ts +29 -0
- package/dist/ui/pieces.js +71 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.14.0] - 2026-10-25
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **The piece API** (docs/extensions.md, "The API"). In a household,
|
|
20
|
+
`POST /api/<piece>/<path>` runs `loom-<piece> api --json` with
|
|
21
|
+
`{caller, path, body}` and passes the piece's `{status, body}` back. It's for
|
|
22
|
+
callers that aren't a browser: a box calling home to the runtime piece, a
|
|
23
|
+
body asking for a house. POST, JSON and no Origin, like `/cli`. It has its
|
|
24
|
+
own pool (four running, eight waiting, then 503), so a flood can't starve
|
|
25
|
+
the page.
|
|
26
|
+
- **Piece credentials:** a token role `piece:<piece>/<subject>`
|
|
27
|
+
(`loom token create --piece box/thor`). It reaches only its own piece's
|
|
28
|
+
API, as its subject: no MCP, no `/cli`, no page, no other piece.
|
|
29
|
+
|
|
30
|
+
### Security
|
|
31
|
+
|
|
32
|
+
- The household daemon's caller resolution now handles every role kind by
|
|
33
|
+
name, and refuses anything that isn't one of them. Before this, a role
|
|
34
|
+
kind it didn't know would have been served as the owner.
|
|
35
|
+
|
|
36
|
+
## [0.13.1] - 2026-10-24
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- **Bundles are gzipped** before they're encrypted, which makes a real
|
|
41
|
+
household about a third of the size (40 MB down to about 13 MB). `loom restore`
|
|
42
|
+
still reads the uncompressed bundles that 0.12 and 0.13 wrote.
|
|
43
|
+
|
|
15
44
|
## [0.13.0] - 2026-10-24
|
|
16
45
|
|
|
17
46
|
### Added
|
|
@@ -771,7 +800,9 @@ Initial public release.
|
|
|
771
800
|
stack and all added external-service dependencies or operational
|
|
772
801
|
overhead.
|
|
773
802
|
|
|
774
|
-
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.
|
|
803
|
+
[Unreleased]: https://github.com/sleepunit-agents/loom/compare/v0.14.0...HEAD
|
|
804
|
+
[0.14.0]: https://github.com/sleepunit-agents/loom/compare/v0.13.1...v0.14.0
|
|
805
|
+
[0.13.1]: https://github.com/sleepunit-agents/loom/compare/v0.13.0...v0.13.1
|
|
775
806
|
[0.13.0]: https://github.com/sleepunit-agents/loom/compare/v0.12.0...v0.13.0
|
|
776
807
|
[0.12.0]: https://github.com/sleepunit-agents/loom/compare/v0.11.0...v0.12.0
|
|
777
808
|
[0.11.0]: https://github.com/sleepunit-agents/loom/compare/v0.10.0...v0.11.0
|
package/dist/bundle.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* loom restore <file> --identity <key> --drill restore into a throwaway dir and
|
|
9
9
|
* compare with the live household
|
|
10
10
|
*
|
|
11
|
-
* Inside (a tar, then age-encrypted): manifest.json, household.json, the token
|
|
11
|
+
* Inside (a gzipped tar, then age-encrypted): manifest.json, household.json, the token
|
|
12
12
|
* files, the commons, every agent's directory (SQLite through the backup API,
|
|
13
13
|
* never a raw copy of a live file), and each installed piece's own part, which
|
|
14
14
|
* the piece encrypts itself (`loom-<piece> bundle export`): core never sees inside
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { spawn, spawnSync } from 'node:child_process';
|
|
21
21
|
import { createHash } from 'node:crypto';
|
|
22
|
-
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs';
|
|
22
|
+
import { chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readlinkSync, readSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync, } from 'node:fs';
|
|
23
23
|
import { tmpdir } from 'node:os';
|
|
24
24
|
import { basename, delimiter, join, resolve } from 'node:path';
|
|
25
25
|
import BetterSqlite3 from 'better-sqlite3';
|
|
@@ -198,9 +198,9 @@ export async function exportBundle(opts) {
|
|
|
198
198
|
household: { agents: h.agents, default: h.default, ...(h.owner ? { owner: h.owner } : {}) }, pieces,
|
|
199
199
|
};
|
|
200
200
|
writeFileSync(join(stage, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 });
|
|
201
|
-
// tar, then encrypt; only the encrypted file leaves the temp dir.
|
|
202
|
-
const plain = join(work, 'household.tar');
|
|
203
|
-
run(tar, ['-C', stage, '-
|
|
201
|
+
// tar and gzip, then encrypt; only the encrypted file leaves the temp dir.
|
|
202
|
+
const plain = join(work, 'household.tar.gz');
|
|
203
|
+
run(tar, ['-C', stage, '-czf', plain, '.'], 'tar');
|
|
204
204
|
const out = resolve(opts.to);
|
|
205
205
|
const tmpOut = `${out}.${process.pid}.tmp`;
|
|
206
206
|
run(age, ['-e', ...opts.recipients.flatMap((r) => ['-r', r]), '-o', tmpOut, plain], 'age');
|
|
@@ -228,7 +228,17 @@ function unpack(fileIn, identityIn, dir, env) {
|
|
|
228
228
|
run(age, ['-d', '-i', identity, '-o', plain, file], 'age (decrypt: is this the right key?)');
|
|
229
229
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
230
230
|
// Members are ours (we wrote them), but never let one land outside dir.
|
|
231
|
-
|
|
231
|
+
// Bundles from 0.12 and 0.13 are a plain tar; later ones are gzipped.
|
|
232
|
+
const head = Buffer.alloc(2);
|
|
233
|
+
const fd = openSync(plain, 'r');
|
|
234
|
+
try {
|
|
235
|
+
readSync(fd, head, 0, 2, 0);
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
closeSync(fd);
|
|
239
|
+
}
|
|
240
|
+
const gz = head[0] === 0x1f && head[1] === 0x8b;
|
|
241
|
+
run(tar, ['-C', dir, '--no-same-owner', gz ? '-xzf' : '-xf', plain], 'tar');
|
|
232
242
|
const m = JSON.parse(readFileSync(join(dir, 'manifest.json'), 'utf-8'));
|
|
233
243
|
if (m.v !== 1)
|
|
234
244
|
throw new BundleError(`this bundle is format v${String(m.v)}; this loom reads v1`);
|
package/dist/cli/household.js
CHANGED
|
@@ -21,13 +21,14 @@ $LOOM_HOME defaults to ~/.config/loom.
|
|
|
21
21
|
`;
|
|
22
22
|
const TOKEN_USAGE = `Usage: loom token <command>
|
|
23
23
|
|
|
24
|
-
create (--agent <name> | --owner | --peer <name>) --label <text>
|
|
24
|
+
create (--agent <name> | --owner | --peer <name> | --piece <piece>/<subject>) --label <text>
|
|
25
25
|
Mint a token. It's printed once; only its sha256 is kept
|
|
26
26
|
list Every token: label, role, created, last used, revoked
|
|
27
27
|
revoke <label> Revoke the live token with that label (takes effect on the next request)
|
|
28
28
|
|
|
29
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
|
|
30
|
+
the page; peer:<name> reaches commons knowledge only; piece:<piece>/<subject> is
|
|
31
|
+
a piece's own credential (e.g. a box's), reaching only POST /api/<piece>/.
|
|
31
32
|
`;
|
|
32
33
|
function flag(args, name) {
|
|
33
34
|
const i = args.indexOf(name);
|
|
@@ -107,7 +108,7 @@ export async function runToken(argv, io) {
|
|
|
107
108
|
if (!h)
|
|
108
109
|
return 1;
|
|
109
110
|
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 picked = [flag(rest, '--agent') && `agent:${flag(rest, '--agent')}`, rest.includes('--owner') && 'owner', flag(rest, '--peer') && `peer:${flag(rest, '--peer')}`, flag(rest, '--piece') && `piece:${flag(rest, '--piece')}`].filter(Boolean);
|
|
111
112
|
const role = picked.length === 1 ? parseRole(picked[0]) : null;
|
|
112
113
|
if (!label || !role) {
|
|
113
114
|
io.stderr(TOKEN_USAGE);
|
package/dist/household.d.ts
CHANGED
|
@@ -15,10 +15,19 @@ export type Role = {
|
|
|
15
15
|
} | {
|
|
16
16
|
kind: 'peer';
|
|
17
17
|
name: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A piece's credential (docs/extensions.md, "The API"): reaches only
|
|
21
|
+
* POST /api/<piece>/…, as `subject`. No MCP, no /cli, no page, no store.
|
|
22
|
+
*/
|
|
23
|
+
| {
|
|
24
|
+
kind: 'piece';
|
|
25
|
+
piece: string;
|
|
26
|
+
subject: string;
|
|
18
27
|
};
|
|
19
28
|
export interface TokenRecord {
|
|
20
29
|
label: string;
|
|
21
|
-
/** `agent:<name>`, `owner` or `
|
|
30
|
+
/** `agent:<name>`, `owner`, `peer:<name>` or `piece:<piece>/<subject>`. */
|
|
22
31
|
role: string;
|
|
23
32
|
/** sha256 of the token, hex. The token itself is never stored. */
|
|
24
33
|
sha256: string;
|
package/dist/household.js
CHANGED
|
@@ -86,13 +86,21 @@ export function householdOf(contextDir) {
|
|
|
86
86
|
export function parseRole(s) {
|
|
87
87
|
if (s === 'owner')
|
|
88
88
|
return { kind: 'owner' };
|
|
89
|
+
const p = /^piece:([a-z][a-z0-9-]{0,39})\/([a-z0-9][a-z0-9._-]{0,63})$/.exec(s);
|
|
90
|
+
if (p)
|
|
91
|
+
return p[1] === 'loom' ? null : { kind: 'piece', piece: p[1], subject: p[2] };
|
|
89
92
|
const m = /^(agent|peer):([a-z][a-z0-9-]{0,39})$/.exec(s);
|
|
90
93
|
if (!m)
|
|
91
94
|
return null;
|
|
92
95
|
return m[1] === 'agent' ? { kind: 'agent', agent: m[2] } : { kind: 'peer', name: m[2] };
|
|
93
96
|
}
|
|
94
97
|
export function roleString(r) {
|
|
95
|
-
|
|
98
|
+
switch (r.kind) {
|
|
99
|
+
case 'owner': return 'owner';
|
|
100
|
+
case 'agent': return `agent:${r.agent}`;
|
|
101
|
+
case 'peer': return `peer:${r.name}`;
|
|
102
|
+
case 'piece': return `piece:${r.piece}/${r.subject}`;
|
|
103
|
+
}
|
|
96
104
|
}
|
|
97
105
|
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
|
98
106
|
function writePrivate(path, body) {
|
|
@@ -40,6 +40,8 @@ import { createLoomServer } from '../server.js';
|
|
|
40
40
|
import { createBackend } from '../backends/index.js';
|
|
41
41
|
import { handleCliBody } from './cli-endpoint.js';
|
|
42
42
|
import { handleUi, isUiRequest, legacyUiAuth } from '../ui/handler.js';
|
|
43
|
+
import { pieceApi } from '../ui/pieces.js';
|
|
44
|
+
import { findPiece } from '../cli/pieces.js';
|
|
43
45
|
import { createKnowledgeOnlyServer } from './knowledge-server.js';
|
|
44
46
|
import { hasUnsplitPeople } from '../backends/people-household.js';
|
|
45
47
|
import { agentDir, assertRealAgentDir, commonsProblem, liveHousehold, roleString, TokenResolver } from '../household.js';
|
|
@@ -156,8 +158,8 @@ export async function startHttpServer(opts) {
|
|
|
156
158
|
const live = household ? liveHousehold(household) : undefined;
|
|
157
159
|
const resolver = live ? new TokenResolver(live) : undefined;
|
|
158
160
|
const ownerDigest = opts.token ? createHash('sha256').update(opts.token).digest() : undefined;
|
|
159
|
-
/**
|
|
160
|
-
function
|
|
161
|
+
/** The role a presented bearer has: 401 (none/unknown) or 503 (tokens.json unreadable). */
|
|
162
|
+
function roleFor(header) {
|
|
161
163
|
const presented = (header ?? '').replace(/^Bearer\s+/i, '').trim();
|
|
162
164
|
if (!presented)
|
|
163
165
|
return { status: 401, error: 'unauthorized: missing bearer token' };
|
|
@@ -177,6 +179,16 @@ export async function startHttpServer(opts) {
|
|
|
177
179
|
}
|
|
178
180
|
if (!role)
|
|
179
181
|
return { status: 401, error: 'unauthorized: bearer token not recognized' };
|
|
182
|
+
return role;
|
|
183
|
+
}
|
|
184
|
+
/** Resolve a household caller: 401 (no/unknown token), 403 (not allowed there) or 404 (no such agent). */
|
|
185
|
+
function callerFor(header, target) {
|
|
186
|
+
const role = roleFor(header);
|
|
187
|
+
if ('status' in role)
|
|
188
|
+
return role;
|
|
189
|
+
// Every kind is handled by name: an unlisted one must never fall through to the owner's branch.
|
|
190
|
+
if (role.kind === 'piece')
|
|
191
|
+
return { status: 403, error: `forbidden: a piece credential reaches only /api/${role.piece}/` };
|
|
180
192
|
const h = live();
|
|
181
193
|
if (role.kind === 'peer') {
|
|
182
194
|
if (target)
|
|
@@ -188,6 +200,8 @@ export async function startHttpServer(opts) {
|
|
|
188
200
|
return { status: 403, error: `forbidden: this token is ${role.agent}'s` };
|
|
189
201
|
return { role, agent: role.agent, contextDir: agentDir(h, role.agent), key: `${roleString(role)}>${role.agent}` };
|
|
190
202
|
}
|
|
203
|
+
if (role.kind !== 'owner')
|
|
204
|
+
return { status: 403, error: 'forbidden' };
|
|
191
205
|
const agent = target ?? h.default;
|
|
192
206
|
if (!h.agents.includes(agent))
|
|
193
207
|
return { status: 404, error: `not-found: no agent named ${agent}` };
|
|
@@ -306,6 +320,66 @@ export async function startHttpServer(opts) {
|
|
|
306
320
|
await server.connect(transport);
|
|
307
321
|
return transport;
|
|
308
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* POST /api/<piece>/<path>. Like /cli: JSON only, no Origin (a browser
|
|
325
|
+
* always sends one, and this route grants no CORS), within the body cap.
|
|
326
|
+
* An owner or agent token calls as itself; a piece credential only its own
|
|
327
|
+
* piece, as its subject; a peer never.
|
|
328
|
+
*/
|
|
329
|
+
async function handlePieceApi(req, res, piece, subpath) {
|
|
330
|
+
if (req.method !== 'POST')
|
|
331
|
+
return sendError(res, 405, `method-not-allowed: POST /api/${piece}/`);
|
|
332
|
+
const role = roleFor(req.headers['authorization']);
|
|
333
|
+
if ('status' in role)
|
|
334
|
+
return sendError(res, role.status, role.error);
|
|
335
|
+
let caller;
|
|
336
|
+
switch (role.kind) {
|
|
337
|
+
case 'owner':
|
|
338
|
+
caller = { kind: 'owner' };
|
|
339
|
+
break;
|
|
340
|
+
case 'agent':
|
|
341
|
+
caller = { kind: 'agent', agent: role.agent };
|
|
342
|
+
break;
|
|
343
|
+
case 'piece':
|
|
344
|
+
if (role.piece !== piece)
|
|
345
|
+
return sendError(res, 403, `forbidden: this credential is ${role.piece}'s`);
|
|
346
|
+
caller = { kind: 'piece', subject: role.subject };
|
|
347
|
+
break;
|
|
348
|
+
case 'peer': return sendError(res, 403, 'forbidden: a peer token reaches the commons only');
|
|
349
|
+
}
|
|
350
|
+
if (req.headers['origin'] !== undefined)
|
|
351
|
+
return sendError(res, 403, 'forbidden: /api refuses browser requests');
|
|
352
|
+
if (!/^application\/json\b/i.test(req.headers['content-type'] ?? '')) {
|
|
353
|
+
return sendError(res, 415, 'unsupported-media-type: POST /api takes application/json');
|
|
354
|
+
}
|
|
355
|
+
const declared = Number(req.headers['content-length'] ?? 0);
|
|
356
|
+
if (Number.isFinite(declared) && declared > 0 && !checkPayloadSize(declared, maxBytes).ok) {
|
|
357
|
+
return sendError(res, 413, `oversized: declared ${declared} bytes exceeds cap ${maxBytes}`);
|
|
358
|
+
}
|
|
359
|
+
const raw = await collectBody(req, maxBytes);
|
|
360
|
+
if (raw === OVERSIZED)
|
|
361
|
+
return sendError(res, 413, `oversized: body exceeds cap ${maxBytes}`);
|
|
362
|
+
let body;
|
|
363
|
+
try {
|
|
364
|
+
body = raw === '' ? {} : JSON.parse(raw);
|
|
365
|
+
}
|
|
366
|
+
catch (e) {
|
|
367
|
+
return sendError(res, 400, `bad-input: malformed JSON (${e.message})`);
|
|
368
|
+
}
|
|
369
|
+
// "loom" is core's own name; a piece can't take it.
|
|
370
|
+
const path = piece === 'loom' ? null : findPiece(piece, process.env);
|
|
371
|
+
if (!path)
|
|
372
|
+
return sendError(res, 404, `not-found: no piece named ${piece}`);
|
|
373
|
+
// A caller that hangs up while queued gives its place back.
|
|
374
|
+
const gone = new AbortController();
|
|
375
|
+
res.on('close', () => { if (!res.writableEnded)
|
|
376
|
+
gone.abort(); });
|
|
377
|
+
const out = await pieceApi(path, { caller, path: subpath, body }, process.env, gone.signal);
|
|
378
|
+
if (gone.signal.aborted)
|
|
379
|
+
return;
|
|
380
|
+
res.writeHead(out.status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
381
|
+
res.end(JSON.stringify(out.body));
|
|
382
|
+
}
|
|
309
383
|
const http = createServer((req, res) => {
|
|
310
384
|
void (async () => {
|
|
311
385
|
// loom's web page (src/ui), at the root. Its own auth (a session from
|
|
@@ -322,6 +396,15 @@ export async function startHttpServer(opts) {
|
|
|
322
396
|
household: live,
|
|
323
397
|
});
|
|
324
398
|
}
|
|
399
|
+
// POST /api/<piece>/…: a piece's API (docs/extensions.md, "The API"),
|
|
400
|
+
// for callers that aren't a browser: a box calling home, a body asking
|
|
401
|
+
// for a house. Household only, bearer only, JSON only.
|
|
402
|
+
if (household && (fullPath === '/api' || fullPath.startsWith('/api/'))) {
|
|
403
|
+
const api = /^\/api\/([a-z][a-z0-9-]{0,39})((?:\/[A-Za-z0-9._~-]+)*)\/?$/.exec(fullPath);
|
|
404
|
+
if (!api || api[2].split('/').some((s) => s === '.' || s === '..'))
|
|
405
|
+
return sendError(res, 404, 'not-found: /api/<piece>/<path>');
|
|
406
|
+
return handlePieceApi(req, res, api[1], api[2].replace(/^\//, ''));
|
|
407
|
+
}
|
|
325
408
|
// Household: `/agents/<name>/…` names the agent (the owner's way to
|
|
326
409
|
// reach a non-default one; an agent's own token may use its own name).
|
|
327
410
|
const scoped = household ? /^\/agents\/([a-z][a-z0-9-]{0,39})(\/.*)?$/.exec(fullPath) : null;
|
package/dist/ui/pieces.d.ts
CHANGED
|
@@ -22,6 +22,9 @@ export declare function pieceEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
|
|
|
22
22
|
* when a piece actually runs.
|
|
23
23
|
*/
|
|
24
24
|
export declare const MAX_RUNNING = 6;
|
|
25
|
+
/** /api: at most four at once, eight more waiting. */
|
|
26
|
+
export declare const MAX_API_RUNNING = 4;
|
|
27
|
+
export declare const MAX_API_WAITING = 8;
|
|
25
28
|
/** Every installed piece's manifest, plus the ones that had nothing to show and why. */
|
|
26
29
|
export declare function pieceManifests(env: NodeJS.ProcessEnv, nowMs?: number): Promise<{
|
|
27
30
|
pieces: PieceInfo[];
|
|
@@ -35,3 +38,29 @@ export declare function pieceAct(info: PieceInfo, request: {
|
|
|
35
38
|
path: string;
|
|
36
39
|
fields: Record<string, string>;
|
|
37
40
|
}, env: NodeJS.ProcessEnv): Promise<ActResult>;
|
|
41
|
+
/** A piece's API answer is its own JSON; these bound how long one call may take. */
|
|
42
|
+
export declare const API_TIMEOUT_MS = 15000;
|
|
43
|
+
/** Who is calling a piece's API, as core resolved the bearer. */
|
|
44
|
+
export type ApiCaller = {
|
|
45
|
+
kind: 'owner';
|
|
46
|
+
} | {
|
|
47
|
+
kind: 'agent';
|
|
48
|
+
agent: string;
|
|
49
|
+
} | {
|
|
50
|
+
kind: 'piece';
|
|
51
|
+
subject: string;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Call a piece's API (docs/extensions.md, "The API"): `loom-<piece> api --json`
|
|
55
|
+
* with {caller, path, body} on stdin. The piece answers {status?, body}; a
|
|
56
|
+
* status outside 200-499, or anything that isn't that shape, is the piece's
|
|
57
|
+
* failure, which the caller sees as a 502.
|
|
58
|
+
*/
|
|
59
|
+
export declare function pieceApi(path: string, request: {
|
|
60
|
+
caller: ApiCaller;
|
|
61
|
+
path: string;
|
|
62
|
+
body: unknown;
|
|
63
|
+
}, env: NodeJS.ProcessEnv, gone?: AbortSignal): Promise<{
|
|
64
|
+
status: number;
|
|
65
|
+
body: unknown;
|
|
66
|
+
}>;
|
package/dist/ui/pieces.js
CHANGED
|
@@ -56,22 +56,56 @@ function killGroup(pid) {
|
|
|
56
56
|
* when a piece actually runs.
|
|
57
57
|
*/
|
|
58
58
|
export const MAX_RUNNING = 6;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
/**
|
|
60
|
+
* A pool of piece processes. The page's and the API's are separate, so callers
|
|
61
|
+
* of /api (a box, which may be compromised) can never starve the owner's page.
|
|
62
|
+
* The API's queue is bounded too: past it, a call is refused, not parked.
|
|
63
|
+
*/
|
|
64
|
+
class Pool {
|
|
65
|
+
max;
|
|
66
|
+
maxWaiting;
|
|
67
|
+
running = 0;
|
|
68
|
+
waiting = [];
|
|
69
|
+
constructor(max, maxWaiting = Infinity) {
|
|
70
|
+
this.max = max;
|
|
71
|
+
this.maxWaiting = maxWaiting;
|
|
67
72
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
73
|
+
/** null when the queue is full, or when `gone` fired before a slot came up. */
|
|
74
|
+
async run(f, gone) {
|
|
75
|
+
if (this.running >= this.max) {
|
|
76
|
+
if (this.waiting.length >= this.maxWaiting)
|
|
77
|
+
return null;
|
|
78
|
+
const got = await new Promise((resolve) => {
|
|
79
|
+
const go = () => { gone?.removeEventListener('abort', drop); resolve(true); };
|
|
80
|
+
const drop = () => { const i = this.waiting.indexOf(go); if (i >= 0)
|
|
81
|
+
this.waiting.splice(i, 1); resolve(false); };
|
|
82
|
+
this.waiting.push(go);
|
|
83
|
+
gone?.addEventListener('abort', drop, { once: true });
|
|
84
|
+
});
|
|
85
|
+
if (!got)
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
if (gone?.aborted) {
|
|
89
|
+
this.waiting.shift()?.();
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
this.running++;
|
|
93
|
+
try {
|
|
94
|
+
return await f();
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
this.running--;
|
|
98
|
+
this.waiting.shift()?.();
|
|
99
|
+
}
|
|
71
100
|
}
|
|
72
101
|
}
|
|
73
|
-
|
|
74
|
-
|
|
102
|
+
const pagePool = new Pool(MAX_RUNNING);
|
|
103
|
+
/** /api: at most four at once, eight more waiting. */
|
|
104
|
+
export const MAX_API_RUNNING = 4;
|
|
105
|
+
export const MAX_API_WAITING = 8;
|
|
106
|
+
const apiPool = new Pool(MAX_API_RUNNING, MAX_API_WAITING);
|
|
107
|
+
async function ask(path, args, env, opts = {}) {
|
|
108
|
+
return (await pagePool.run(() => run(path, args, env, opts)));
|
|
75
109
|
}
|
|
76
110
|
function run(path, args, env, opts) {
|
|
77
111
|
return new Promise((resolve) => {
|
|
@@ -189,3 +223,27 @@ export async function pieceAct(info, request, env) {
|
|
|
189
223
|
const result = parsed === undefined ? 'not JSON' : parseActResult(parsed);
|
|
190
224
|
return typeof result === 'string' ? { ok: false, message: `${info.piece} sent an answer loom couldn't read (${result}).` } : result;
|
|
191
225
|
}
|
|
226
|
+
/** A piece's API answer is its own JSON; these bound how long one call may take. */
|
|
227
|
+
export const API_TIMEOUT_MS = 15_000;
|
|
228
|
+
/**
|
|
229
|
+
* Call a piece's API (docs/extensions.md, "The API"): `loom-<piece> api --json`
|
|
230
|
+
* with {caller, path, body} on stdin. The piece answers {status?, body}; a
|
|
231
|
+
* status outside 200-499, or anything that isn't that shape, is the piece's
|
|
232
|
+
* failure, which the caller sees as a 502.
|
|
233
|
+
*/
|
|
234
|
+
export async function pieceApi(path, request, env, gone) {
|
|
235
|
+
const r = await apiPool.run(() => run(path, ['api', '--json'], env, { stdin: JSON.stringify(request), timeoutMs: API_TIMEOUT_MS }), gone);
|
|
236
|
+
if (r === null)
|
|
237
|
+
return { status: 503, body: { error: 'busy: too many piece API calls at once; try again shortly' } };
|
|
238
|
+
if (!r.ok)
|
|
239
|
+
return { status: 502, body: { error: `the piece didn't answer (${r.reason})` } };
|
|
240
|
+
const parsed = json(r.stdout);
|
|
241
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || !('body' in parsed)) {
|
|
242
|
+
return { status: 502, body: { error: 'the piece answered with something other than {status?, body}' } };
|
|
243
|
+
}
|
|
244
|
+
const status = parsed.status === undefined ? 200 : parsed.status;
|
|
245
|
+
if (typeof status !== 'number' || !Number.isInteger(status) || status < 200 || status > 499) {
|
|
246
|
+
return { status: 502, body: { error: 'the piece answered with a status outside 200-499' } };
|
|
247
|
+
}
|
|
248
|
+
return { status, body: parsed.body };
|
|
249
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jbarket/loomai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|