@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 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.13.0...HEAD
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, '-cf', plain, '.'], 'tar');
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
- run(tar, ['-C', dir, '--no-same-owner', '-xf', plain], 'tar');
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`);
@@ -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);
@@ -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 `peer:<name>`. */
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
- return r.kind === 'owner' ? 'owner' : r.kind === 'agent' ? `agent:${r.agent}` : `peer:${r.name}`;
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
- /** Resolve a household caller: 401 (no/unknown token), 403 (not allowed there) or 404 (no such agent). */
160
- function callerFor(header, target) {
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;
@@ -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
- let running = 0;
60
- const waiting = [];
61
- async function slot(f) {
62
- if (running >= MAX_RUNNING)
63
- await new Promise((r) => waiting.push(r));
64
- running++;
65
- try {
66
- return await f();
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
- finally {
69
- running--;
70
- waiting.shift()?.();
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
- function ask(path, args, env, opts = {}) {
74
- return slot(() => run(path, args, env, opts));
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.13.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",