@jbarket/loomai 0.5.0 → 0.6.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.
Files changed (94) hide show
  1. package/CHANGELOG.md +114 -1
  2. package/README.md +97 -0
  3. package/dist/backends/dedup.d.ts +23 -0
  4. package/dist/backends/dedup.js +100 -0
  5. package/dist/backends/embedding-runtime.js +38 -9
  6. package/dist/backends/episodes.js +1 -1
  7. package/dist/backends/fastembed.js +30 -4
  8. package/dist/backends/migrations.js +200 -0
  9. package/dist/backends/mmr.d.ts +8 -0
  10. package/dist/backends/mmr.js +18 -4
  11. package/dist/backends/people.d.ts +192 -0
  12. package/dist/backends/people.js +824 -0
  13. package/dist/backends/proposals.js +1 -1
  14. package/dist/backends/recorder-health.d.ts +34 -0
  15. package/dist/backends/recorder-health.js +117 -0
  16. package/dist/backends/salience.d.ts +39 -0
  17. package/dist/backends/salience.js +52 -4
  18. package/dist/backends/scope.d.ts +56 -0
  19. package/dist/backends/scope.js +75 -0
  20. package/dist/backends/sqlite-knowledge.d.ts +24 -4
  21. package/dist/backends/sqlite-knowledge.js +253 -91
  22. package/dist/backends/sqlite-vec.d.ts +32 -1
  23. package/dist/backends/sqlite-vec.js +394 -75
  24. package/dist/backends/types.d.ts +203 -3
  25. package/dist/categories.d.ts +2 -0
  26. package/dist/categories.js +2 -0
  27. package/dist/cli/args.d.ts +16 -1
  28. package/dist/cli/args.js +45 -1
  29. package/dist/cli/index.d.ts +7 -0
  30. package/dist/cli/index.js +57 -3
  31. package/dist/cli/knowledge.js +4 -1
  32. package/dist/cli/memory.js +2 -2
  33. package/dist/cli/person.d.ts +2 -0
  34. package/dist/cli/person.js +116 -0
  35. package/dist/cli/recall.js +2 -1
  36. package/dist/cli/remember.js +10 -0
  37. package/dist/cli/remote.d.ts +36 -0
  38. package/dist/cli/remote.js +173 -0
  39. package/dist/cli/serve.d.ts +7 -0
  40. package/dist/cli/serve.js +94 -5
  41. package/dist/cli/subcommands.d.ts +1 -1
  42. package/dist/cli/subcommands.js +1 -1
  43. package/dist/cli/update.js +30 -11
  44. package/dist/clients.js +7 -4
  45. package/dist/config.d.ts +14 -0
  46. package/dist/config.js +25 -2
  47. package/dist/index.js +22 -7
  48. package/dist/injection/harnesses.js +9 -3
  49. package/dist/install/harnesses.js +7 -1
  50. package/dist/server.d.ts +8 -1
  51. package/dist/server.js +461 -374
  52. package/dist/tools/dossier.js +5 -1
  53. package/dist/tools/eurorack-import.js +3 -0
  54. package/dist/tools/identity.d.ts +2 -0
  55. package/dist/tools/identity.js +35 -2
  56. package/dist/tools/knowledge-history.d.ts +6 -1
  57. package/dist/tools/knowledge-history.js +26 -8
  58. package/dist/tools/knowledge-maintain.d.ts +5 -0
  59. package/dist/tools/knowledge-maintain.js +92 -5
  60. package/dist/tools/knowledge-recall.d.ts +7 -1
  61. package/dist/tools/knowledge-recall.js +53 -3
  62. package/dist/tools/knowledge-verify.d.ts +11 -1
  63. package/dist/tools/knowledge-verify.js +17 -4
  64. package/dist/tools/knowledge-write.d.ts +8 -1
  65. package/dist/tools/knowledge-write.js +27 -9
  66. package/dist/tools/memory-history.d.ts +5 -0
  67. package/dist/tools/memory-history.js +52 -0
  68. package/dist/tools/memory-list.js +2 -1
  69. package/dist/tools/memory-merge.d.ts +2 -0
  70. package/dist/tools/memory-merge.js +40 -0
  71. package/dist/tools/memory-revision-restore.d.ts +5 -0
  72. package/dist/tools/memory-revision-restore.js +33 -0
  73. package/dist/tools/person.d.ts +32 -0
  74. package/dist/tools/person.js +111 -0
  75. package/dist/tools/prune.js +1 -1
  76. package/dist/tools/recall.js +7 -1
  77. package/dist/tools/remember.js +18 -2
  78. package/dist/tools/update.js +5 -1
  79. package/dist/transport/cli-endpoint.d.ts +13 -0
  80. package/dist/transport/cli-endpoint.js +97 -0
  81. package/dist/transport/guards.d.ts +14 -0
  82. package/dist/transport/guards.js +26 -0
  83. package/dist/transport/http-server.d.ts +28 -1
  84. package/dist/transport/http-server.js +156 -18
  85. package/dist/transport/knowledge-server.d.ts +11 -0
  86. package/dist/transport/knowledge-server.js +30 -0
  87. package/dist/transport/knowledge-tools.d.ts +50 -0
  88. package/dist/transport/knowledge-tools.js +290 -0
  89. package/dist/transport/mcp-app-hello.d.ts +1 -1
  90. package/dist/widgets/display-modes.html +62 -56
  91. package/dist/widgets/hello.html +62 -56
  92. package/dist/widgets/proposals.html +62 -56
  93. package/dist/widgets/top-of-mind.html +60 -54
  94. package/package.json +7 -5
@@ -0,0 +1,824 @@
1
+ /**
2
+ * Person cards — the condensed current state of a human the agent works with
3
+ * (docs/specs/person-cards.md, t-1376).
4
+ *
5
+ * A card is current state: one value per field, overwritten in place. The past
6
+ * lives only in person_revisions (audit and undo, never read at boot). A card
7
+ * is not a knowledge page and not the memories it condenses: the tagged `user`
8
+ * memories stay the evidence, and stay what recall searches.
9
+ *
10
+ * The card is NOT an authorization surface. The agent writes its own cards, so
11
+ * `may` entries are a record of what was said, never a grant, and the
12
+ * harness's gating names (RESERVED_KEYS) cannot appear on a card at all.
13
+ *
14
+ * Storage is the memory database (resolveSqliteDbPath), opened raw like
15
+ * proposals.ts — no embedder. Every mutation is one BEGIN IMMEDIATE
16
+ * transaction (read, merge, validate, snapshot, write, prune), the pattern of
17
+ * SqliteKnowledgeBackend.writePage: loom's one-writer rule is about *who*
18
+ * writes, so the transaction is what serializes concurrent bodies.
19
+ */
20
+ import { existsSync } from 'node:fs';
21
+ import BetterSqlite3 from 'better-sqlite3';
22
+ import { resolveSqliteDbPath } from '../config.js';
23
+ import { pathSegmentError } from '../path-safety.js';
24
+ import { MIGRATIONS } from './migrations.js';
25
+ import { retryWrite } from './retry.js';
26
+ // ─── Limits (the spec's Shape and Rendering sections) ────────────────────────
27
+ export const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
28
+ export const MAX_SLUG = 64;
29
+ const MAX_NAME = 80;
30
+ const MAX_SUMMARY = 1200;
31
+ const FIELD_RE = /^[a-z0-9_]+$/;
32
+ const MAX_FIELD_NAME = 32;
33
+ const MAX_FIELDS = 12;
34
+ const MAX_STRING = 200;
35
+ const MAX_ARRAY = 8;
36
+ const MAX_MAY = 8;
37
+ const DEFAULT_STALE_DAYS = 14;
38
+ export const MAX_RENDER = 2000;
39
+ const MAX_REVISIONS = 10;
40
+ const SKEW_MS = 60_000;
41
+ const DAY_MS = 86_400_000;
42
+ /**
43
+ * The harness's gating names. Fixed here, not configured: a card that carried
44
+ * one would look like a gate, and the agent writes its own cards.
45
+ */
46
+ export const RESERVED_KEYS = ['tier', 'field', 'projects', 'scope', 'trust'];
47
+ const TOP_KEYS = ['slug', 'name', 'summary', 'where', 'how', 'may'];
48
+ const WRITE_KEYS = [...TOP_KEYS, 'merge'];
49
+ const ENVELOPE_KEYS = ['value', 'verified_at', 'source', 'stale_after_days'];
50
+ const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,3})?Z$/;
51
+ /** Every rejection the people surface makes. The message names the path. */
52
+ export class PersonCardError extends Error {
53
+ constructor(message) {
54
+ super(message);
55
+ this.name = 'PersonCardError';
56
+ }
57
+ }
58
+ // ─── Validation ──────────────────────────────────────────────────────────────
59
+ /** Null when `slug` is a valid person slug, else the reason. Never echoes an over-long slug. */
60
+ export function personSlugError(slug) {
61
+ if (typeof slug !== 'string' || slug === '')
62
+ return 'slug is required';
63
+ if (slug.length > MAX_SLUG)
64
+ return `slug is longer than ${MAX_SLUG} chars`;
65
+ const segment = pathSegmentError(slug, 'slug');
66
+ if (segment)
67
+ return segment;
68
+ if (!SLUG_RE.test(slug))
69
+ return `slug "${slug}" must match ${SLUG_RE.source}`;
70
+ return null;
71
+ }
72
+ function fail(message) {
73
+ throw new PersonCardError(message);
74
+ }
75
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
76
+ /** Reject a reserved key anywhere under `node`, naming it. Runs before shape checks. */
77
+ function assertNoReserved(node, path) {
78
+ if (Array.isArray(node)) {
79
+ node.forEach((v, i) => assertNoReserved(v, `${path}[${i}]`));
80
+ return;
81
+ }
82
+ if (!isPlainObject(node))
83
+ return;
84
+ for (const [k, v] of Object.entries(node)) {
85
+ if (RESERVED_KEYS.includes(k)) {
86
+ fail(`${path}.${k}: "${k}" is a reserved key (harness gating names: ${RESERVED_KEYS.join(', ')}). ` +
87
+ 'A card is not an authorization surface; gates stay in the harness.');
88
+ }
89
+ assertNoReserved(v, `${path}.${k}`);
90
+ }
91
+ }
92
+ /** Reject a `null` anywhere under `node`, naming the path. */
93
+ function assertNoNull(node, path) {
94
+ if (node === null)
95
+ fail(`${path}: null is not allowed here`);
96
+ if (Array.isArray(node))
97
+ node.forEach((v, i) => assertNoNull(v, `${path}[${i}]`));
98
+ else if (isPlainObject(node))
99
+ for (const [k, v] of Object.entries(node))
100
+ assertNoNull(v, `${path}.${k}`);
101
+ }
102
+ function checkString(v, path, max) {
103
+ if (typeof v !== 'string')
104
+ fail(`${path}: must be a string`);
105
+ if (v.length > max)
106
+ fail(`${path}: longer than ${max} chars (${v.length})`);
107
+ return v;
108
+ }
109
+ function checkValue(v, path) {
110
+ if (typeof v === 'string') {
111
+ checkString(v, path, MAX_STRING);
112
+ }
113
+ else if (typeof v === 'number') {
114
+ if (!Number.isFinite(v))
115
+ fail(`${path}: must be a finite number`);
116
+ }
117
+ else if (typeof v === 'boolean') {
118
+ // ok
119
+ }
120
+ else if (Array.isArray(v)) {
121
+ if (v.length > MAX_ARRAY)
122
+ fail(`${path}: more than ${MAX_ARRAY} items`);
123
+ v.forEach((item, i) => checkString(item, `${path}[${i}]`, MAX_STRING));
124
+ }
125
+ else {
126
+ fail(`${path}: must be a string, number, boolean, or array of strings`);
127
+ }
128
+ }
129
+ /** Names FIELD_RE admits that would reach Object.prototype through a plain-object assignment. */
130
+ const PROTO_NAMES = ['__proto__', 'constructor', 'prototype'];
131
+ function checkFieldName(name, path) {
132
+ if (!FIELD_RE.test(name) || name.length > MAX_FIELD_NAME) {
133
+ fail(`${path}: field name must match ${FIELD_RE.source} and be at most ${MAX_FIELD_NAME} chars`);
134
+ }
135
+ if (PROTO_NAMES.includes(name))
136
+ fail(`${path}: "${name}" is not allowed as a field name`);
137
+ }
138
+ function checkVerifiedAt(v, path, nowMs) {
139
+ const s = checkString(v, path, MAX_STRING);
140
+ if (!ISO_UTC_RE.test(s) || Number.isNaN(Date.parse(s))) {
141
+ fail(`${path}: must be an ISO-8601 UTC timestamp (e.g. 2026-10-19T14:00:00Z)`);
142
+ }
143
+ if (nowMs !== null && Date.parse(s) > nowMs + SKEW_MS)
144
+ fail(`${path}: is in the future`);
145
+ }
146
+ function checkEnvelope(env, path, nowMs) {
147
+ if (!isPlainObject(env))
148
+ fail(`${path}: must be an object with a "value"`);
149
+ for (const k of Object.keys(env)) {
150
+ if (!ENVELOPE_KEYS.includes(k))
151
+ fail(`${path}.${k}: unknown envelope key (allowed: ${ENVELOPE_KEYS.join(', ')})`);
152
+ }
153
+ if (!('value' in env))
154
+ fail(`${path}.value: is required`);
155
+ checkValue(env.value, `${path}.value`);
156
+ if ('verified_at' in env)
157
+ checkVerifiedAt(env.verified_at, `${path}.verified_at`, nowMs);
158
+ if ('source' in env)
159
+ checkString(env.source, `${path}.source`, MAX_STRING);
160
+ if ('stale_after_days' in env) {
161
+ const d = env.stale_after_days;
162
+ if (typeof d !== 'number' || !Number.isInteger(d) || d < 1 || d > 365) {
163
+ fail(`${path}.stale_after_days: must be an integer 1–365`);
164
+ }
165
+ }
166
+ }
167
+ /**
168
+ * Validate a complete card: keys, types, lengths and the worst-case render
169
+ * cap. `nowMs` adds the time checks (no future verified_at); pass null to skip
170
+ * them, which revision restore does — the snapshot was valid when written.
171
+ */
172
+ export function validateCard(card, nowMs) {
173
+ if (!isPlainObject(card))
174
+ fail('card: must be an object');
175
+ assertNoReserved(card, 'card');
176
+ assertNoNull(card, 'card');
177
+ for (const k of Object.keys(card)) {
178
+ if (!TOP_KEYS.includes(k))
179
+ fail(`${k}: unknown top-level key (allowed: ${TOP_KEYS.join(', ')})`);
180
+ }
181
+ const slugErr = personSlugError(card.slug);
182
+ if (slugErr)
183
+ fail(`slug: ${slugErr}`);
184
+ const name = checkString(card.name, 'name', MAX_NAME);
185
+ if (name.trim() === '')
186
+ fail('name: must not be empty');
187
+ if ('summary' in card) {
188
+ const summary = checkString(card.summary, 'summary', MAX_SUMMARY);
189
+ if (summary.trim() === '')
190
+ fail('summary: must not be empty (a merge: false write without summary removes it)');
191
+ }
192
+ for (const section of ['where', 'how']) {
193
+ const fields = card[section];
194
+ if (!isPlainObject(fields))
195
+ fail(`${section}: must be an object`);
196
+ const names = Object.keys(fields);
197
+ if (names.length > MAX_FIELDS)
198
+ fail(`${section}: more than ${MAX_FIELDS} fields`);
199
+ for (const n of names) {
200
+ checkFieldName(n, `${section}.${n}`);
201
+ if (section === 'where')
202
+ checkEnvelope(fields[n], `where.${n}`, nowMs);
203
+ else
204
+ checkValue(fields[n], `how.${n}`);
205
+ }
206
+ }
207
+ if (!Array.isArray(card.may))
208
+ fail('may: must be an array');
209
+ if (card.may.length > MAX_MAY)
210
+ fail(`may: more than ${MAX_MAY} entries`);
211
+ card.may.forEach((entry, i) => {
212
+ const path = `may[${i}]`;
213
+ if (!isPlainObject(entry))
214
+ fail(`${path}: must be {text, source}`);
215
+ for (const k of Object.keys(entry)) {
216
+ if (k !== 'text' && k !== 'source')
217
+ fail(`${path}.${k}: unknown key (allowed: text, source)`);
218
+ }
219
+ if (!('text' in entry))
220
+ fail(`${path}.text: is required`);
221
+ if (!('source' in entry))
222
+ fail(`${path}.source: is required — a may entry records who said it`);
223
+ checkString(entry.text, `${path}.text`, MAX_STRING);
224
+ checkString(entry.source, `${path}.source`, MAX_STRING);
225
+ });
226
+ const worst = renderCard(card, 0, { worstCase: true }).length;
227
+ if (worst > MAX_RENDER) {
228
+ fail(`card: worst-case render is ${worst} chars, over the ${MAX_RENDER}-char cap (every where marker counted as [STALE 999+d])`);
229
+ }
230
+ }
231
+ /** Where `null` may appear in a write request: only as a field directly under where/how, in a merge write. */
232
+ function validateWriteNulls(input, merge) {
233
+ for (const k of TOP_KEYS) {
234
+ if (k in input && input[k] === null) {
235
+ fail(`${k}: null is not allowed${k === 'where' || k === 'how' || k === 'may' ? ' (use merge: false to clear a section)' : ''}`);
236
+ }
237
+ }
238
+ if (!merge) {
239
+ assertNoNull(input, 'input');
240
+ return;
241
+ }
242
+ for (const section of ['where', 'how']) {
243
+ const fields = input[section];
244
+ if (!isPlainObject(fields))
245
+ continue;
246
+ for (const [n, v] of Object.entries(fields)) {
247
+ if (v !== null)
248
+ assertNoNull(v, `${section}.${n}`);
249
+ }
250
+ }
251
+ if ('may' in input)
252
+ assertNoNull(input.may, 'may');
253
+ for (const k of ['name', 'summary'])
254
+ if (k in input)
255
+ assertNoNull(input[k], k);
256
+ }
257
+ /** Stale when now − verified_at > stale_after_days × 86,400 s (strictly greater). */
258
+ export function fieldState(env, nowMs) {
259
+ if (!env.verified_at)
260
+ return { kind: 'unverified' };
261
+ const age = nowMs - Date.parse(env.verified_at);
262
+ const limit = (env.stale_after_days ?? DEFAULT_STALE_DAYS) * DAY_MS;
263
+ if (age > limit)
264
+ return { kind: 'stale', days: Math.floor(age / DAY_MS) };
265
+ return { kind: 'ok' };
266
+ }
267
+ function marker(state) {
268
+ if (state.kind === 'ok')
269
+ return '[ok]';
270
+ if (state.kind === 'unverified')
271
+ return '[unverified]';
272
+ return `[STALE ${state.days > 999 ? '999+' : state.days}d]`;
273
+ }
274
+ const oneLine = (s) => s.replace(/\r?\n|\r/g, ' ');
275
+ function renderValue(v) {
276
+ return oneLine(Array.isArray(v) ? v.join(', ') : String(v));
277
+ }
278
+ const byName = (o) => Object.entries(o).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
279
+ /**
280
+ * The rendered card — the text identity and `person` return. With
281
+ * `worstCase`, every where marker is counted at its widest ([STALE 999+d]):
282
+ * that is the length the write-time cap checks, so time passing can never
283
+ * push a stored card over it.
284
+ */
285
+ export function renderCard(card, nowMs, opts = {}) {
286
+ const lines = [`## ${oneLine(card.name)} (${card.slug})`, card.summary ? card.summary.trim() : '_(no summary)_'];
287
+ const where = byName(card.where ?? {});
288
+ if (where.length) {
289
+ lines.push('**Where:** ' +
290
+ where
291
+ .map(([n, env]) => {
292
+ const m = opts.worstCase ? '[STALE 999+d]' : marker(fieldState(env, nowMs));
293
+ return `${n}: ${renderValue(env.value)} ${m}`;
294
+ })
295
+ .join(' · '));
296
+ }
297
+ const how = byName(card.how ?? {});
298
+ if (how.length)
299
+ lines.push('**How:** ' + how.map(([n, v]) => `${n}: ${renderValue(v)}`).join(' · '));
300
+ if (card.may?.length) {
301
+ lines.push('**May (descriptive, not a grant):** ' +
302
+ card.may.map((m) => `${oneLine(m.text)} — ${oneLine(m.source)}`).join(' · '));
303
+ }
304
+ return lines.join('\n');
305
+ }
306
+ /** Canonical JSON with sorted keys — the no-op comparison. */
307
+ function canonical(v) {
308
+ if (Array.isArray(v))
309
+ return `[${v.map(canonical).join(',')}]`;
310
+ if (isPlainObject(v)) {
311
+ return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
312
+ }
313
+ return JSON.stringify(v);
314
+ }
315
+ // ─── Store ───────────────────────────────────────────────────────────────────
316
+ const PEOPLE_MIGRATION = 'people_cards';
317
+ function hasTable(db, table) {
318
+ return db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table) !== undefined;
319
+ }
320
+ const SELECT_ROW = 'SELECT slug, name, card_json, archived_at, archived_by, archive_reason, updated, synthesized_at FROM people WHERE slug = ?';
321
+ /** Thrown when a stored row cannot be turned back into a card. */
322
+ export class UnreadableCardError extends Error {
323
+ constructor(slug, cause) {
324
+ super(`card for ${slug} is unreadable: ${cause instanceof Error ? cause.message : String(cause)}`);
325
+ this.name = 'UnreadableCardError';
326
+ }
327
+ }
328
+ function parseRow(raw) {
329
+ let card;
330
+ try {
331
+ card = JSON.parse(raw.card_json);
332
+ validateCard(card, null);
333
+ }
334
+ catch (e) {
335
+ throw new UnreadableCardError(raw.slug, e);
336
+ }
337
+ return { ...raw, card: card };
338
+ }
339
+ export class PeopleStore {
340
+ db;
341
+ now;
342
+ /** False on a read-only store whose tables do not exist yet. */
343
+ migrated;
344
+ constructor(config) {
345
+ this.now = config.now ?? Date.now;
346
+ this.db = new BetterSqlite3(config.dbPath, config.readonly ? { readonly: true } : {});
347
+ this.db.pragma(`busy_timeout = ${config.busyTimeoutMs ?? 5000}`);
348
+ if (!config.readonly) {
349
+ this.db.pragma('journal_mode = WAL');
350
+ // Only this feature's migration: the people tables do not depend on the
351
+ // memories schema, and a raw store must not half-run the others.
352
+ const m = MIGRATIONS.find((x) => x.id === PEOPLE_MIGRATION);
353
+ if (m.pending(this.db))
354
+ m.run(this.db);
355
+ }
356
+ this.migrated = hasTable(this.db, 'people') && hasTable(this.db, 'person_revisions');
357
+ }
358
+ close() {
359
+ this.db.close();
360
+ }
361
+ nowIso() {
362
+ return new Date(this.now()).toISOString();
363
+ }
364
+ readRow(slug) {
365
+ return this.db.prepare(SELECT_ROW).get(slug);
366
+ }
367
+ snapshot(prev, op, at) {
368
+ const snap = { ...prev, card: JSON.parse(prev.card_json) };
369
+ delete snap.card_json;
370
+ 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);
373
+ this.db
374
+ .prepare(`DELETE FROM person_revisions WHERE slug = ? AND id NOT IN
375
+ (SELECT id FROM person_revisions WHERE slug = ? ORDER BY id DESC LIMIT ?)`)
376
+ .run(prev.slug, prev.slug, MAX_REVISIONS);
377
+ return id;
378
+ }
379
+ store(row) {
380
+ this.db
381
+ .prepare(`INSERT INTO people (slug, name, card_json, archived_at, archived_by, archive_reason, updated, synthesized_at)
382
+ VALUES (@slug, @name, @card_json, @archived_at, @archived_by, @archive_reason, @updated, @synthesized_at)
383
+ ON CONFLICT(slug) DO UPDATE SET name = excluded.name, card_json = excluded.card_json,
384
+ archived_at = excluded.archived_at, archived_by = excluded.archived_by,
385
+ archive_reason = excluded.archive_reason, updated = excluded.updated,
386
+ synthesized_at = excluded.synthesized_at`)
387
+ .run(row);
388
+ }
389
+ /** One BEGIN IMMEDIATE transaction around `fn`, retried as a whole on SQLITE_BUSY. */
390
+ mutate(fn) {
391
+ if (!this.migrated)
392
+ fail('people store is not migrated');
393
+ return retryWrite(() => this.db.transaction(fn).immediate());
394
+ }
395
+ result(row, unchanged, revisionId) {
396
+ return {
397
+ card: JSON.parse(row.card_json),
398
+ unchanged,
399
+ revision_id: revisionId,
400
+ archived: row.archived_at !== null,
401
+ updated: row.updated,
402
+ synthesized_at: row.synthesized_at,
403
+ };
404
+ }
405
+ requireActive(slug, verb) {
406
+ const row = this.readRow(slug);
407
+ if (!row)
408
+ fail(`no card for ${slug}`);
409
+ if (row.archived_at !== null)
410
+ fail(`card for ${slug} is archived; person_restore it before ${verb}`);
411
+ return row;
412
+ }
413
+ write(input) {
414
+ if (!isPlainObject(input))
415
+ fail('input: must be an object');
416
+ const raw = input;
417
+ for (const k of Object.keys(raw)) {
418
+ if (!WRITE_KEYS.includes(k))
419
+ fail(`${k}: unknown top-level key (allowed: ${WRITE_KEYS.join(', ')})`);
420
+ }
421
+ const slugErr = personSlugError(input.slug);
422
+ if (slugErr)
423
+ fail(`slug: ${slugErr}`);
424
+ const merge = input.merge ?? true;
425
+ if (typeof merge !== 'boolean')
426
+ fail('merge: must be a boolean');
427
+ assertNoReserved({ where: raw.where, how: raw.how, may: raw.may }, 'input');
428
+ validateWriteNulls(raw, merge);
429
+ for (const section of ['where', 'how']) {
430
+ if (!(section in raw) || raw[section] === undefined)
431
+ continue;
432
+ if (!isPlainObject(raw[section]))
433
+ fail(`${section}: must be an object`);
434
+ // Names are checked before any null-delete applies: an invalid request
435
+ // rejects even when it would change nothing (PR143 review).
436
+ for (const n of Object.keys(raw[section]))
437
+ checkFieldName(n, `${section}.${n}`);
438
+ }
439
+ const passedSummary = 'summary' in raw && raw.summary !== undefined;
440
+ return this.mutate(() => {
441
+ const nowMs = this.now();
442
+ const at = new Date(nowMs).toISOString();
443
+ const existing = this.readRow(input.slug);
444
+ if (existing?.archived_at)
445
+ fail(`card for ${input.slug} is archived; person_restore it before writing`);
446
+ const current = existing ? (parseRow(existing).card) : null;
447
+ let next;
448
+ if (!merge || !current) {
449
+ next = { slug: input.slug, name: input.name, where: input.where ?? {}, how: input.how ?? {}, may: input.may ?? [] };
450
+ if (passedSummary)
451
+ next.summary = input.summary;
452
+ // A merge write that creates: drop the null deletes, there is nothing to remove.
453
+ for (const section of ['where', 'how']) {
454
+ next[section] = Object.fromEntries(Object.entries(next[section]).filter(([, v]) => v !== null));
455
+ }
456
+ }
457
+ else {
458
+ next = structuredClone(current);
459
+ if (input.name !== undefined)
460
+ next.name = input.name;
461
+ if (passedSummary)
462
+ next.summary = input.summary;
463
+ for (const section of ['where', 'how']) {
464
+ const passed = input[section];
465
+ if (!passed)
466
+ continue;
467
+ const merged = { ...next[section] };
468
+ for (const [n, v] of Object.entries(passed)) {
469
+ if (v === null)
470
+ delete merged[n];
471
+ else
472
+ merged[n] = v;
473
+ }
474
+ next[section] = merged;
475
+ }
476
+ if (input.may !== undefined)
477
+ next.may = input.may;
478
+ }
479
+ if (next.name === undefined)
480
+ fail(`name: is required ${current ? 'on a merge: false write' : 'to create a card'}`);
481
+ validateCard(next, nowMs);
482
+ if (current && !passedSummary && canonical(next) === canonical(current)) {
483
+ return this.result(existing, true, null);
484
+ }
485
+ const revisionId = existing ? this.snapshot(existing, 'write', at) : null;
486
+ const synthesizedAt = passedSummary ? at : !merge ? null : (existing?.synthesized_at ?? null);
487
+ const row = {
488
+ slug: input.slug,
489
+ name: next.name,
490
+ card_json: JSON.stringify(next),
491
+ archived_at: null,
492
+ archived_by: null,
493
+ archive_reason: null,
494
+ updated: at,
495
+ synthesized_at: synthesizedAt,
496
+ };
497
+ this.store(row);
498
+ return this.result(row, false, revisionId);
499
+ });
500
+ }
501
+ verify(input) {
502
+ const slugErr = personSlugError(input.slug);
503
+ if (slugErr)
504
+ fail(`slug: ${slugErr}`);
505
+ if (!Array.isArray(input.fields) || input.fields.length === 0)
506
+ fail('fields: list at least one where field');
507
+ if (input.source !== undefined)
508
+ checkString(input.source, 'source', MAX_STRING);
509
+ return this.mutate(() => {
510
+ const nowMs = this.now();
511
+ const at = new Date(nowMs).toISOString();
512
+ const verifiedAt = input.verified_at ?? at;
513
+ checkVerifiedAt(verifiedAt, 'verified_at', nowMs);
514
+ const existing = this.requireActive(input.slug, 'verifying');
515
+ const card = parseRow(existing).card;
516
+ for (const f of input.fields) {
517
+ // Own properties only: `in` would accept an inherited name like
518
+ // __proto__ and stamp Object.prototype (PR143 review).
519
+ if (typeof f !== 'string' || !Object.hasOwn(card.where, f)) {
520
+ fail(`fields: "${String(f)}" is not a where field on ${input.slug}`);
521
+ }
522
+ }
523
+ const next = structuredClone(card);
524
+ for (const f of input.fields) {
525
+ next.where[f].verified_at = verifiedAt;
526
+ if (input.source !== undefined)
527
+ next.where[f].source = input.source;
528
+ }
529
+ validateCard(next, nowMs);
530
+ if (canonical(next) === canonical(card))
531
+ return this.result(existing, true, null);
532
+ const revisionId = this.snapshot(existing, 'verify', at);
533
+ const row = { ...existing, card_json: JSON.stringify(next), updated: at };
534
+ this.store(row);
535
+ return this.result(row, false, revisionId);
536
+ });
537
+ }
538
+ archive(input) {
539
+ const slugErr = personSlugError(input.slug);
540
+ if (slugErr)
541
+ fail(`slug: ${slugErr}`);
542
+ const reason = checkString(input.reason, 'reason', MAX_STRING);
543
+ if (reason.trim() === '')
544
+ fail('reason: is required');
545
+ return this.mutate(() => {
546
+ const at = this.nowIso();
547
+ const existing = this.readRow(input.slug);
548
+ if (!existing)
549
+ fail(`no card for ${input.slug}`);
550
+ if (existing.archived_at !== null)
551
+ return this.result(existing, true, null);
552
+ const revisionId = this.snapshot(existing, 'archive', at);
553
+ const row = { ...existing, archived_at: at, archived_by: input.actor, archive_reason: reason, updated: at };
554
+ this.store(row);
555
+ return this.result(row, false, revisionId);
556
+ });
557
+ }
558
+ restore(input) {
559
+ const slugErr = personSlugError(input.slug);
560
+ if (slugErr)
561
+ fail(`slug: ${slugErr}`);
562
+ return this.mutate(() => {
563
+ const at = this.nowIso();
564
+ const existing = this.readRow(input.slug);
565
+ if (!existing)
566
+ fail(`no card for ${input.slug}`);
567
+ if (existing.archived_at === null)
568
+ return this.result(existing, true, null);
569
+ const revisionId = this.snapshot(existing, 'restore', at);
570
+ const row = { ...existing, archived_at: null, archived_by: null, archive_reason: null, updated: at };
571
+ this.store(row);
572
+ return this.result(row, false, revisionId);
573
+ });
574
+ }
575
+ revisionRestore(input) {
576
+ const slugErr = personSlugError(input.slug);
577
+ if (slugErr)
578
+ fail(`slug: ${slugErr}`);
579
+ return this.mutate(() => {
580
+ const at = this.nowIso();
581
+ const existing = this.requireActive(input.slug, 'restoring a revision');
582
+ const rev = this.db
583
+ .prepare('SELECT slug, snapshot_json FROM person_revisions WHERE id = ?')
584
+ .get(input.revision_id);
585
+ if (!rev)
586
+ fail(`revision ${input.revision_id} not found`);
587
+ if (rev.slug !== input.slug)
588
+ fail(`revision ${input.revision_id} belongs to another card, not ${input.slug}`);
589
+ const snap = JSON.parse(rev.snapshot_json);
590
+ // Structural only: time checks are not re-applied to a snapshot that was valid when written.
591
+ validateCard(snap.card, null);
592
+ if (snap.card.slug !== input.slug)
593
+ fail(`revision ${input.revision_id} holds a card for another slug`);
594
+ const cardJson = JSON.stringify(snap.card);
595
+ const synthesizedAt = snap.synthesized_at ?? null;
596
+ if (cardJson === existing.card_json && synthesizedAt === existing.synthesized_at) {
597
+ return this.result(existing, true, null);
598
+ }
599
+ const revisionId = this.snapshot(existing, 'revision_restore', at);
600
+ const row = { ...existing, name: snap.card.name, card_json: cardJson, synthesized_at: synthesizedAt, updated: at };
601
+ this.store(row);
602
+ return this.result(row, false, revisionId);
603
+ });
604
+ }
605
+ /** The stored row, or null. Throws UnreadableCardError for a row that does not parse or validate. */
606
+ getRow(slug) {
607
+ if (!this.migrated)
608
+ return null;
609
+ const raw = this.readRow(slug);
610
+ return raw ? parseRow(raw) : null;
611
+ }
612
+ read(slug, includeArchived = false) {
613
+ const slugErr = personSlugError(slug);
614
+ if (slugErr)
615
+ fail(`slug: ${slugErr}`);
616
+ const row = this.getRow(slug);
617
+ if (!row)
618
+ return { found: false };
619
+ const archived = row.archived_at !== null;
620
+ if (archived && !includeArchived)
621
+ return { found: true, archived: true };
622
+ return {
623
+ found: true,
624
+ rendered: renderCard(row.card, this.now()) + (archived ? `\n_(archived ${row.archived_at} by ${row.archived_by}: ${row.archive_reason})_` : ''),
625
+ card: row.card,
626
+ archived,
627
+ updated: row.updated,
628
+ synthesized_at: row.synthesized_at,
629
+ };
630
+ }
631
+ list(includeArchived = false) {
632
+ if (!this.migrated)
633
+ return [];
634
+ const raws = this.db
635
+ .prepare(`SELECT slug, name, card_json, archived_at, archived_by, archive_reason, updated, synthesized_at FROM people
636
+ ${includeArchived ? '' : 'WHERE archived_at IS NULL'} ORDER BY slug`)
637
+ .all();
638
+ const nowMs = this.now();
639
+ const nowIso = new Date(nowMs).toISOString();
640
+ const hasMemories = hasTable(this.db, 'memories');
641
+ const count = hasMemories
642
+ ? this.db.prepare(`SELECT COUNT(*) AS n FROM memories
643
+ WHERE category = 'user' AND archived = 0
644
+ AND json_extract(metadata, '$.person') = ?
645
+ AND (expires_at IS NULL OR expires_at > ?)
646
+ AND (? IS NULL OR created > ?)`)
647
+ : null;
648
+ return raws.map((raw) => {
649
+ let stale = 0;
650
+ let unverified = 0;
651
+ try {
652
+ for (const env of Object.values(parseRow(raw).card.where)) {
653
+ const s = fieldState(env, nowMs);
654
+ if (s.kind === 'stale')
655
+ stale++;
656
+ if (s.kind === 'unverified')
657
+ unverified++;
658
+ }
659
+ }
660
+ catch {
661
+ // An unreadable card still lists; its field counts read zero.
662
+ }
663
+ const n = count
664
+ ? count.get(raw.slug, nowIso, raw.synthesized_at, raw.synthesized_at).n
665
+ : 0;
666
+ return {
667
+ slug: raw.slug,
668
+ name: raw.name,
669
+ archived: raw.archived_at !== null,
670
+ updated: raw.updated,
671
+ synthesized_at: raw.synthesized_at,
672
+ stale,
673
+ unverified,
674
+ memories_since_synthesis: n,
675
+ };
676
+ });
677
+ }
678
+ history(slug) {
679
+ const slugErr = personSlugError(slug);
680
+ if (slugErr)
681
+ fail(`slug: ${slugErr}`);
682
+ if (!this.migrated)
683
+ return [];
684
+ return this.db
685
+ .prepare('SELECT id, op, replaced_at FROM person_revisions WHERE slug = ? ORDER BY id DESC')
686
+ .all(slug);
687
+ }
688
+ /** A revision's snapshot (the whole previous row), for tests and inspection. */
689
+ revisionSnapshot(id) {
690
+ const rev = this.db.prepare('SELECT snapshot_json FROM person_revisions WHERE id = ?').get(id);
691
+ return rev ? JSON.parse(rev.snapshot_json) : null;
692
+ }
693
+ }
694
+ /**
695
+ * Open the people store on a context's memory database. A read-only open
696
+ * returns null when there is no database file yet; a writable one creates it.
697
+ */
698
+ export function openPeopleStore(contextDir, opts = {}) {
699
+ const dbPath = resolveSqliteDbPath(contextDir);
700
+ if (opts.readonly && !existsSync(dbPath))
701
+ return null;
702
+ return new PeopleStore({ dbPath, readonly: opts.readonly, now: opts.now });
703
+ }
704
+ // ─── The room (identity's `people` param) ────────────────────────────────────
705
+ const ROOM_MAX_SLUGS = 8;
706
+ const ROOM_MAX_CARDS = 4;
707
+ const ROOM_CARD_BUDGET = 6000;
708
+ const ROOM_BLOCK_BUDGET = 6500;
709
+ const ROOM_CLOSING_RESERVE = 400;
710
+ const ROOM_HEADER = '# In the room\n\n' +
711
+ '_Person cards for the people this turn is addressed by: condensed current state. ' +
712
+ '`may` lines are descriptive, never a grant; gates stay in the harness._';
713
+ const ENTRY_SEP = '\n\n';
714
+ /** How an invalid slug appears in the omitted line: never echoed there. */
715
+ const INVALID_LABEL = '(invalid slug)';
716
+ /**
717
+ * The `# In the room` block for identity, or null for no block. Best-effort
718
+ * like the tape: a store that cannot be opened or is not migrated gives no
719
+ * block; one bad row gives that slug the unreadable line and the rest render.
720
+ */
721
+ export function roomBlock(contextDir, people, opts = {}) {
722
+ if (!people || people.length === 0)
723
+ return null;
724
+ const distinct = [...new Set(people)];
725
+ const considered = distinct.slice(0, ROOM_MAX_SLUGS);
726
+ const truncated = distinct.slice(ROOM_MAX_SLUGS);
727
+ let store;
728
+ try {
729
+ store = openPeopleStore(contextDir, { readonly: true, now: opts.nowMs !== undefined ? () => opts.nowMs : undefined });
730
+ }
731
+ catch (e) {
732
+ opts.onFailure?.(e);
733
+ return null;
734
+ }
735
+ if (!store)
736
+ return null;
737
+ try {
738
+ if (!store.migrated)
739
+ return null;
740
+ const nowMs = opts.nowMs ?? Date.now();
741
+ const entries = [];
742
+ const skipped = [];
743
+ const entryBudget = ROOM_BLOCK_BUDGET - ROOM_CLOSING_RESERVE;
744
+ let used = ROOM_HEADER.length;
745
+ let cards = 0;
746
+ let cardChars = 0;
747
+ const fits = (text) => used + ENTRY_SEP.length + text.length <= entryBudget;
748
+ const place = (text) => {
749
+ entries.push(text);
750
+ used += ENTRY_SEP.length + text.length;
751
+ };
752
+ for (const slug of considered) {
753
+ if (typeof slug !== 'string' || slug.length > MAX_SLUG) {
754
+ const line = '_(invalid slug)_';
755
+ if (fits(line))
756
+ place(line);
757
+ else
758
+ skipped.push(INVALID_LABEL);
759
+ continue;
760
+ }
761
+ if (personSlugError(slug)) {
762
+ const line = `_(invalid slug: ${slug})_`;
763
+ if (fits(line))
764
+ place(line);
765
+ else
766
+ skipped.push(INVALID_LABEL);
767
+ continue;
768
+ }
769
+ let line = null;
770
+ let card = null;
771
+ try {
772
+ const row = store.getRow(slug);
773
+ if (!row)
774
+ line = `_(no card for ${slug})_`;
775
+ else if (row.archived_at !== null)
776
+ line = `_(card for ${slug} is archived)_`;
777
+ else
778
+ card = renderCard(row.card, nowMs);
779
+ }
780
+ catch (e) {
781
+ console.error(`[loom:identity] person card ${slug} unreadable:`, e);
782
+ opts.onFailure?.(e);
783
+ line = `_(card for ${slug} is unreadable)_`;
784
+ }
785
+ if (line !== null) {
786
+ if (fits(line))
787
+ place(line);
788
+ else
789
+ skipped.push(slug);
790
+ continue;
791
+ }
792
+ if (cards >= ROOM_MAX_CARDS || cardChars + card.length > ROOM_CARD_BUDGET || !fits(card)) {
793
+ skipped.push(slug);
794
+ continue;
795
+ }
796
+ place(card);
797
+ cards++;
798
+ cardChars += card.length;
799
+ }
800
+ const omitted = [...skipped, ...truncated.map((s) => (personSlugError(s) ? INVALID_LABEL : s))];
801
+ let block = [ROOM_HEADER, ...entries].join(ENTRY_SEP);
802
+ if (omitted.length)
803
+ block += ENTRY_SEP + omittedLine(omitted);
804
+ return block;
805
+ }
806
+ finally {
807
+ store.close();
808
+ }
809
+ }
810
+ /** `_(N cards omitted for budget: a, b, …)_`, listing slugs until the next would overflow the reserve. */
811
+ function omittedLine(omitted) {
812
+ const head = `_(${omitted.length} card${omitted.length === 1 ? '' : 's'} omitted for budget: `;
813
+ const max = ROOM_CLOSING_RESERVE - ENTRY_SEP.length;
814
+ const listed = [];
815
+ for (const s of omitted) {
816
+ const candidate = head + [...listed, s].join(', ');
817
+ const tailRoom = listed.length + 1 < omitted.length ? ', …)_'.length : ')_'.length;
818
+ if (candidate.length + tailRoom > max)
819
+ break;
820
+ listed.push(s);
821
+ }
822
+ const ellipsis = listed.length < omitted.length ? (listed.length ? ', …' : '…') : '';
823
+ return `${head}${listed.join(', ')}${ellipsis})_`;
824
+ }