synomem 0.1.0 → 0.2.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 (73) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/CHANGELOG.md +37 -1
  3. package/README.md +28 -15
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +311 -70
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts +96 -17
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +333 -76
  10. package/dist/client.js.map +1 -1
  11. package/dist/config.d.ts +2 -2
  12. package/dist/config.js +8 -8
  13. package/dist/import.d.ts +207 -13
  14. package/dist/import.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/mcp/index.d.ts.map +1 -1
  20. package/dist/mcp/index.js +156 -31
  21. package/dist/mcp/index.js.map +1 -1
  22. package/dist/mcp-server.js +54 -8
  23. package/dist/mcp-server.js.map +1 -1
  24. package/dist/ports/repository.d.ts +18 -1
  25. package/dist/ports/repository.d.ts.map +1 -1
  26. package/dist/projections.d.ts +18 -1
  27. package/dist/projections.d.ts.map +1 -1
  28. package/dist/projections.js +137 -44
  29. package/dist/projections.js.map +1 -1
  30. package/dist/remote.d.ts +57 -9
  31. package/dist/remote.d.ts.map +1 -1
  32. package/dist/remote.js +43 -2
  33. package/dist/remote.js.map +1 -1
  34. package/dist/schemas.d.ts +302 -21
  35. package/dist/schemas.d.ts.map +1 -1
  36. package/dist/schemas.js +142 -26
  37. package/dist/schemas.js.map +1 -1
  38. package/dist/service.d.ts +57 -10
  39. package/dist/service.d.ts.map +1 -1
  40. package/dist/skill-install.d.ts +8 -2
  41. package/dist/skill-install.d.ts.map +1 -1
  42. package/dist/skill-install.js +6 -11
  43. package/dist/skill-install.js.map +1 -1
  44. package/dist/storage.d.ts +41 -1
  45. package/dist/storage.d.ts.map +1 -1
  46. package/dist/storage.js +254 -36
  47. package/dist/storage.js.map +1 -1
  48. package/dist/types.d.ts +213 -38
  49. package/dist/types.d.ts.map +1 -1
  50. package/docs/cli.md +53 -20
  51. package/docs/examples.md +4 -4
  52. package/docs/mcp.md +12 -4
  53. package/docs/skill.md +10 -7
  54. package/docs/storage-format.md +4 -4
  55. package/openapi/synomem-v1.yaml +24 -24
  56. package/package.json +1 -1
  57. package/skills/synomem/SKILL.md +24 -8
  58. package/skills/synomem/agents/openai.yaml +1 -1
  59. package/skills/synomem/references/examples.md +1 -1
  60. package/src/cli.ts +572 -173
  61. package/src/client.ts +391 -79
  62. package/src/config.ts +8 -8
  63. package/src/index.ts +11 -1
  64. package/src/mcp/index.ts +189 -30
  65. package/src/mcp-server.ts +58 -8
  66. package/src/ports/repository.ts +16 -0
  67. package/src/projections.ts +139 -44
  68. package/src/remote.ts +118 -8
  69. package/src/schemas.ts +147 -26
  70. package/src/service.ts +59 -7
  71. package/src/skill-install.ts +14 -17
  72. package/src/storage.ts +326 -34
  73. package/src/types.ts +228 -39
package/src/storage.ts CHANGED
@@ -22,13 +22,16 @@ import {
22
22
  ensureDirectory,
23
23
  readJsonFile,
24
24
  } from './fs-utils.js';
25
+ import { dueInstant } from './projections.js';
25
26
  import { actorSchema, eventSchema, profileSchema } from './schemas.js';
26
27
  import type {
27
28
  ActorIdentity,
28
29
  SynomemConfig,
29
30
  SynomemConfigOverrides,
30
31
  AgentProfile,
32
+ AgentRuntimeBinding,
31
33
  ChangePage,
34
+ JsonValue,
32
35
  KudosChange,
33
36
  SynomemEvent,
34
37
  ItemChange,
@@ -186,6 +189,54 @@ CREATE INDEX kudos_current_actor ON kudos_current(actor_kind, actor_id, given_se
186
189
  CREATE INDEX kudos_current_status ON kudos_current(status, revocation_status, given_sequence DESC);
187
190
  `;
188
191
 
192
+ /**
193
+ * v4 records a task's or todo's deadline on the item index.
194
+ *
195
+ * The plan asks for "accepted Tasks past their due date" as a bounded query.
196
+ * Answering that from the event log means replaying every task on every call,
197
+ * so the deadline is projected alongside the rest of the summary. It is stored
198
+ * as an ISO instant: a date-only due date resolves to the end of that day, so
199
+ * "overdue" means the day has actually passed rather than merely started.
200
+ */
201
+ const migrationV4 = `
202
+ ALTER TABLE items_current ADD COLUMN due_at TEXT;
203
+ CREATE INDEX items_current_due ON items_current(kind, status, due_at);
204
+ `;
205
+
206
+ /**
207
+ * v5 makes alias lookup case-insensitive and unambiguous.
208
+ *
209
+ * The plan requires that `mycroft`, `Mycroft` and `Mike` may all resolve to one
210
+ * canonical agent, while a name two agents both claim must return candidates
211
+ * rather than guessing. A normalized column with a unique index enforces the
212
+ * second half at write time: the collision is refused when the alias is added,
213
+ * not discovered later by whoever happens to look it up first.
214
+ *
215
+ * Runtime bindings arrive here too. An agent is not one harness forever —
216
+ * Mycroft may run Hermes on two machines, or move between harnesses — so the
217
+ * binding is its own row keyed by agent, installation, runtime and profile
218
+ * rather than a field on the agent.
219
+ */
220
+ const migrationV5 = `
221
+ ALTER TABLE aliases ADD COLUMN normalized_alias TEXT;
222
+ UPDATE aliases SET normalized_alias = lower(alias);
223
+ CREATE UNIQUE INDEX aliases_normalized ON aliases(normalized_alias);
224
+
225
+ CREATE TABLE agent_runtime_bindings (
226
+ id TEXT PRIMARY KEY,
227
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
228
+ installation_id TEXT,
229
+ runtime TEXT NOT NULL,
230
+ profile TEXT,
231
+ capabilities_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(capabilities_json)),
232
+ bound_at TEXT NOT NULL,
233
+ last_seen_at TEXT
234
+ ) STRICT;
235
+ CREATE UNIQUE INDEX agent_runtime_bindings_unique
236
+ ON agent_runtime_bindings(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''));
237
+ CREATE INDEX agent_runtime_bindings_agent ON agent_runtime_bindings(agent_id);
238
+ `;
239
+
189
240
  const migrationV3 = `
190
241
  DROP TRIGGER IF EXISTS events_append_only_update;
191
242
  DROP TRIGGER IF EXISTS events_append_only_delete;
@@ -303,10 +354,18 @@ function itemSummaryFromRow(row: ItemRow): ItemSummary {
303
354
  };
304
355
  }
305
356
 
357
+ /**
358
+ * Resolves a due value to a comparable instant.
359
+ *
360
+ * A date-only deadline resolves to the END of that day, so "overdue" means the
361
+ * day has passed rather than merely begun. Treating 2026-09-15 as midnight
362
+ * would report a task due today as already late.
363
+ */
306
364
  function eventKind(event: SynomemEvent): RecordKind | undefined {
307
365
  if (event.type.startsWith('kudos.')) return 'kudos';
308
366
  if (event.type.startsWith('memo.')) return 'memo';
309
367
  if (event.type.startsWith('note.')) return 'note';
368
+ if (event.type.startsWith('task.')) return 'task';
310
369
  if (event.type.startsWith('todo.')) return 'todo';
311
370
  return undefined;
312
371
  }
@@ -440,7 +499,7 @@ export class SynomemStorage implements SynomemRepository {
440
499
  const version = Number(
441
500
  (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
442
501
  );
443
- if (version > 3) {
502
+ if (version > 5) {
444
503
  throw new SynomemError(
445
504
  'UNSUPPORTED_SCHEMA',
446
505
  `Database schema version ${version} is newer than this package supports.`,
@@ -474,29 +533,56 @@ export class SynomemStorage implements SynomemRepository {
474
533
  if (afterV2 === 2) {
475
534
  this.transactionSync(() => {
476
535
  db.exec(migrationV3);
477
- this.rebuildItemsCurrentIndex();
536
+ // The item index is left empty here and populated by v4, which adds the
537
+ // due-date column the projection writes. Rebuilding before that column
538
+ // exists fails on the first task.
478
539
  db.prepare(
479
540
  'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
480
541
  ).run(3, new Date().toISOString());
481
542
  db.exec('PRAGMA user_version = 3');
482
543
  });
483
544
  }
545
+ const afterV3 = Number(
546
+ (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
547
+ );
548
+ if (afterV3 === 3) {
549
+ this.transactionSync(() => {
550
+ db.exec(migrationV4);
551
+ this.rebuildItemsCurrentIndex();
552
+ db.prepare(
553
+ 'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
554
+ ).run(4, new Date().toISOString());
555
+ db.exec('PRAGMA user_version = 4');
556
+ });
557
+ }
558
+ const afterV4 = Number(
559
+ (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
560
+ );
561
+ if (afterV4 === 4) {
562
+ this.transactionSync(() => {
563
+ db.exec(migrationV5);
564
+ db.prepare(
565
+ 'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
566
+ ).run(5, new Date().toISOString());
567
+ db.exec('PRAGMA user_version = 5');
568
+ });
569
+ }
484
570
  }
485
571
 
486
572
  private assertSchemaSupported(): void {
487
573
  const version = Number(
488
574
  (this.db().prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
489
575
  );
490
- if (version !== 3) {
491
- if (version === 1 || version === 2) {
576
+ if (version !== 5) {
577
+ if (version >= 1 && version <= 4) {
492
578
  throw new SynomemError(
493
579
  'UNSUPPORTED_SCHEMA',
494
- 'Database schema version 1 requires migration. Open this home once with readOnly: false, then retry the read-only client.',
580
+ `Database schema version ${version} requires migration. Open this home once with readOnly: false, then retry the read-only client.`,
495
581
  );
496
582
  }
497
583
  throw new SynomemError(
498
584
  'UNSUPPORTED_SCHEMA',
499
- `Expected database schema version 3; found ${version}.`,
585
+ `Expected database schema version 5; found ${version}.`,
500
586
  );
501
587
  }
502
588
  }
@@ -673,6 +759,7 @@ export class SynomemStorage implements SynomemRepository {
673
759
  ownerDisplayName?: string;
674
760
  assigneeAgentId?: string;
675
761
  assigneeDisplayName?: string;
762
+ dueAt?: string;
676
763
  }): void => {
677
764
  this.db()
678
765
  .prepare(
@@ -680,8 +767,8 @@ export class SynomemStorage implements SynomemRepository {
680
767
  item_id, kind, created_sequence, updated_sequence, created_at, updated_at,
681
768
  actor_kind, actor_id, actor_display_name, title, tags_json, visibility, status,
682
769
  recipient_agent_id, recipient_display_name, owner_agent_id, owner_display_name,
683
- assignee_agent_id, assignee_display_name
684
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
770
+ assignee_agent_id, assignee_display_name, due_at
771
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
685
772
  )
686
773
  .run(
687
774
  event.aggregateId,
@@ -703,6 +790,7 @@ export class SynomemStorage implements SynomemRepository {
703
790
  values.ownerDisplayName ?? null,
704
791
  values.assigneeAgentId ?? null,
705
792
  values.assigneeDisplayName ?? null,
793
+ values.dueAt ?? null,
706
794
  );
707
795
  };
708
796
  const updateStatus = (status: string): void => {
@@ -762,35 +850,69 @@ export class SynomemStorage implements SynomemRepository {
762
850
  event.aggregateId,
763
851
  );
764
852
  } else if (event.type === 'note.archived') updateStatus('archived');
765
- else if (event.type === 'todo.created') {
853
+ else if (event.type === 'task.created') {
766
854
  insert({
767
- kind: 'todo',
855
+ kind: 'task',
768
856
  title: event.title,
769
857
  tags: event.tags,
770
858
  visibility: event.visibility,
771
859
  status: event.requiresAcceptance ? 'assigned' : 'open',
772
860
  assigneeAgentId: event.assigneeAgentId,
773
861
  assigneeDisplayName: event.assigneeDisplayName,
862
+ ...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
774
863
  });
775
- } else if (event.type === 'todo.updated') {
864
+ } else if (event.type === 'task.updated') {
776
865
  this.db()
777
866
  .prepare(
778
- `UPDATE items_current SET title = ?, tags_json = ?, visibility = ?,
867
+ `UPDATE items_current SET title = ?, tags_json = ?, visibility = ?, due_at = ?,
779
868
  updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
780
869
  )
781
870
  .run(
782
871
  event.title,
783
872
  JSON.stringify(event.tags ?? []),
784
873
  event.visibility,
874
+ dueInstant(event.due) ?? null,
875
+ sequence,
876
+ event.createdAt,
877
+ event.aggregateId,
878
+ );
879
+ } else if (event.type === 'task.accepted') updateStatus('open');
880
+ else if (event.type === 'task.rejected') updateStatus('rejected');
881
+ else if (event.type === 'task.completed') updateStatus('completed');
882
+ else if (event.type === 'task.reopened') updateStatus('open');
883
+ else if (event.type === 'task.canceled') updateStatus('canceled');
884
+ else if (event.type === 'todo.created') {
885
+ // A Todo's owner IS its author, and it is always private. Recording the
886
+ // owner explicitly rather than inferring it from the actor keeps the
887
+ // owner-scoped read filters uniform across notes and todos.
888
+ insert({
889
+ kind: 'todo',
890
+ title: event.title,
891
+ tags: event.tags,
892
+ visibility: 'private',
893
+ status: 'open',
894
+ ownerAgentId: event.actor.id,
895
+ ...(event.actor.displayName ? { ownerDisplayName: event.actor.displayName } : {}),
896
+ ...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
897
+ });
898
+ } else if (event.type === 'todo.updated') {
899
+ this.db()
900
+ .prepare(
901
+ `UPDATE items_current SET title = ?, tags_json = ?, due_at = ?,
902
+ updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
903
+ )
904
+ .run(
905
+ event.title,
906
+ JSON.stringify(event.tags ?? []),
907
+ dueInstant(event.due) ?? null,
785
908
  sequence,
786
909
  event.createdAt,
787
910
  event.aggregateId,
788
911
  );
789
- } else if (event.type === 'todo.accepted') updateStatus('open');
790
- else if (event.type === 'todo.rejected') updateStatus('rejected');
791
- else if (event.type === 'todo.completed') updateStatus('completed');
912
+ } else if (event.type === 'todo.completed') updateStatus('completed');
792
913
  else if (event.type === 'todo.reopened') updateStatus('open');
793
914
  else if (event.type === 'todo.canceled') updateStatus('canceled');
915
+ else if (event.type === 'todo.archived') updateStatus('archived');
794
916
  }
795
917
 
796
918
  rebuildItemsCurrentIndex(): void {
@@ -1037,11 +1159,48 @@ export class SynomemStorage implements SynomemRepository {
1037
1159
  if (input.pending) {
1038
1160
  add(`((kind = 'kudos' AND status = 'unacknowledged') OR
1039
1161
  (kind = 'memo' AND status = 'unread') OR
1040
- (kind = 'todo' AND status IN ('assigned', 'open')))`);
1162
+ (kind = 'task' AND status IN ('assigned', 'open')))`);
1041
1163
  }
1042
1164
  if (input.visibility) add('visibility = ?', input.visibility);
1043
1165
  if (input.from) add('created_at >= ?', input.from);
1044
1166
  if (input.to) add('created_at <= ?', input.to);
1167
+
1168
+ // Unanswered discovery: items still waiting for somebody to respond. Kept
1169
+ // distinct from `pending`, which also counts accepted-and-in-progress work.
1170
+ if (input.awaitingResponse) {
1171
+ add(`((kind = 'kudos' AND status = 'unacknowledged') OR
1172
+ (kind = 'memo' AND status = 'unread') OR
1173
+ (kind = 'task' AND status = 'assigned'))`);
1174
+ }
1175
+ if (input.awaitingSince) add('created_at <= ?', input.awaitingSince);
1176
+
1177
+ // Overdue discovery: a deadline that has passed, on work still open. A
1178
+ // completed or canceled item is not overdue, however late it was.
1179
+ if (input.overdueAsOf) {
1180
+ add(
1181
+ `(due_at IS NOT NULL AND due_at < ? AND status IN ('assigned', 'open'))`,
1182
+ input.overdueAsOf,
1183
+ );
1184
+ }
1185
+
1186
+ /*
1187
+ * Private todos are owner-only for EVERY viewer, including a human.
1188
+ *
1189
+ * The visibility rules below exempt human actors, on the reasoning that a
1190
+ * person operating a local home is its operator. That does not extend to
1191
+ * todos: the plan is explicit that a todo is visible only to its owner
1192
+ * except through an explicitly authorized administrative capability, and
1193
+ * this release has no such capability. Without this clause a human actor
1194
+ * would see every agent's private reminders in an item list.
1195
+ */
1196
+ add(
1197
+ `(kind != 'todo' OR (owner_agent_id = ? AND ? = 'agent') OR (actor_id = ? AND actor_kind = ?))`,
1198
+ viewer.id,
1199
+ viewer.kind,
1200
+ viewer.id,
1201
+ viewer.kind,
1202
+ );
1203
+
1045
1204
  if (viewer.kind !== 'human') {
1046
1205
  if (viewer.kind === 'agent') {
1047
1206
  add(
@@ -1259,7 +1418,7 @@ export class SynomemStorage implements SynomemRepository {
1259
1418
  this.db()
1260
1419
  .prepare(
1261
1420
  `SELECT COUNT(*) AS count FROM events WHERE type IN
1262
- ('kudos.given', 'memo.sent', 'note.created', 'todo.created')`,
1421
+ ('kudos.given', 'memo.sent', 'note.created', 'task.created')`,
1263
1422
  )
1264
1423
  .get() as { count: number }
1265
1424
  ).count,
@@ -1412,13 +1571,19 @@ export class SynomemStorage implements SynomemRepository {
1412
1571
  'note.created',
1413
1572
  'note.revised',
1414
1573
  'note.archived',
1574
+ 'task.created',
1575
+ 'task.updated',
1576
+ 'task.completed',
1577
+ 'task.reopened',
1578
+ 'task.accepted',
1579
+ 'task.rejected',
1580
+ 'task.canceled',
1415
1581
  'todo.created',
1416
1582
  'todo.updated',
1417
1583
  'todo.completed',
1418
1584
  'todo.reopened',
1419
- 'todo.accepted',
1420
- 'todo.rejected',
1421
1585
  'todo.canceled',
1586
+ 'todo.archived',
1422
1587
  ]);
1423
1588
  if (
1424
1589
  (typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 1) ||
@@ -1436,7 +1601,7 @@ export class SynomemStorage implements SynomemRepository {
1436
1601
  legacy.kudosId ??
1437
1602
  legacy.memoId ??
1438
1603
  legacy.noteId ??
1439
- legacy.todoId ??
1604
+ legacy.taskId ??
1440
1605
  (typeof legacy.agentId === 'string' ? legacy.agentId : undefined) ??
1441
1606
  (typeof legacy.agent === 'object' && legacy.agent !== null
1442
1607
  ? (legacy.agent as { id?: unknown }).id
@@ -1480,9 +1645,7 @@ export class SynomemStorage implements SynomemRepository {
1480
1645
  parsed.createdAt,
1481
1646
  parsed.createdAt,
1482
1647
  );
1483
- for (const alias of parsed.aliases ?? []) {
1484
- this.db().prepare('INSERT INTO aliases(alias, agent_id) VALUES (?, ?)').run(alias, parsed.id);
1485
- }
1648
+ this.insertAliases(parsed.id, parsed.aliases ?? []);
1486
1649
  }
1487
1650
 
1488
1651
  updateAgent(profile: AgentProfile, updatedAt: string): void {
@@ -1491,22 +1654,151 @@ export class SynomemStorage implements SynomemRepository {
1491
1654
  .prepare('UPDATE agents SET display_name = ?, profile_json = ?, updated_at = ? WHERE id = ?')
1492
1655
  .run(parsed.displayName, JSON.stringify(parsed), updatedAt, parsed.id);
1493
1656
  this.db().prepare('DELETE FROM aliases WHERE agent_id = ?').run(parsed.id);
1494
- for (const alias of parsed.aliases ?? []) {
1495
- this.db().prepare('INSERT INTO aliases(alias, agent_id) VALUES (?, ?)').run(alias, parsed.id);
1496
- }
1657
+ this.insertAliases(parsed.id, parsed.aliases ?? []);
1497
1658
  }
1498
1659
 
1499
1660
  getAgent(idOrAlias: string): AgentProfile | undefined {
1500
- const direct = this.db()
1501
- .prepare('SELECT profile_json FROM agents WHERE id = ?')
1502
- .get(idOrAlias) as ProfileRow | undefined;
1503
- if (direct) return profileSchema.parse(JSON.parse(direct.profile_json));
1504
- const alias = this.db()
1661
+ const resolved = this.resolveAgent(idOrAlias);
1662
+ return resolved.match;
1663
+ }
1664
+
1665
+ /**
1666
+ * Resolves a name to a canonical agent, reporting ambiguity rather than
1667
+ * guessing.
1668
+ *
1669
+ * The plan is specific: `mycroft`, `Mycroft` and `Mike` may all resolve to one
1670
+ * agent, but a name two visible agents both claim must return candidates. An
1671
+ * alias that collides with a different agent's canonical ID is exactly that
1672
+ * case — silently preferring the ID would attribute work to the wrong agent,
1673
+ * and the person who typed the name would never know.
1674
+ */
1675
+ resolveAgent(query: string): { match?: AgentProfile; candidates: AgentProfile[] } {
1676
+ const normalized = query.trim().toLowerCase();
1677
+ const rows = this.db()
1505
1678
  .prepare(
1506
- 'SELECT a.profile_json FROM agents a JOIN aliases x ON x.agent_id = a.id WHERE x.alias = ?',
1679
+ `SELECT DISTINCT a.profile_json
1680
+ FROM agents a
1681
+ LEFT JOIN aliases x ON x.agent_id = a.id
1682
+ WHERE lower(a.id) = ? OR x.normalized_alias = ?
1683
+ ORDER BY a.id ASC`,
1507
1684
  )
1508
- .get(idOrAlias) as ProfileRow | undefined;
1509
- return alias ? profileSchema.parse(JSON.parse(alias.profile_json)) : undefined;
1685
+ .all(normalized, normalized) as unknown as ProfileRow[];
1686
+ const candidates = rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
1687
+ return candidates.length === 1 ? { match: candidates[0]!, candidates } : { candidates };
1688
+ }
1689
+
1690
+ /* ------------------------------------------------------- runtime bindings */
1691
+
1692
+ listRuntimeBindings(agentId: string): AgentRuntimeBinding[] {
1693
+ const rows = this.db()
1694
+ .prepare(
1695
+ `SELECT id, agent_id, installation_id, runtime, profile, capabilities_json,
1696
+ bound_at, last_seen_at
1697
+ FROM agent_runtime_bindings WHERE agent_id = ? ORDER BY bound_at ASC`,
1698
+ )
1699
+ .all(agentId) as unknown as Array<{
1700
+ id: string;
1701
+ agent_id: string;
1702
+ installation_id: string | null;
1703
+ runtime: string;
1704
+ profile: string | null;
1705
+ capabilities_json: string;
1706
+ bound_at: string;
1707
+ last_seen_at: string | null;
1708
+ }>;
1709
+ return rows.map((row) => ({
1710
+ id: row.id,
1711
+ agentId: row.agent_id,
1712
+ ...(row.installation_id ? { installationId: row.installation_id } : {}),
1713
+ runtime: row.runtime,
1714
+ ...(row.profile ? { profile: row.profile } : {}),
1715
+ capabilities: JSON.parse(row.capabilities_json) as Record<string, JsonValue>,
1716
+ boundAt: row.bound_at,
1717
+ ...(row.last_seen_at ? { lastSeenAt: row.last_seen_at } : {}),
1718
+ }));
1719
+ }
1720
+
1721
+ bindRuntime(binding: {
1722
+ id: string;
1723
+ agentId: string;
1724
+ installationId?: string;
1725
+ runtime: string;
1726
+ profile?: string;
1727
+ capabilities?: Record<string, JsonValue>;
1728
+ boundAt: string;
1729
+ }): void {
1730
+ this.db()
1731
+ .prepare(
1732
+ `INSERT INTO agent_runtime_bindings(
1733
+ id, agent_id, installation_id, runtime, profile, capabilities_json, bound_at
1734
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
1735
+ ON CONFLICT(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''))
1736
+ DO UPDATE SET capabilities_json = excluded.capabilities_json`,
1737
+ )
1738
+ .run(
1739
+ binding.id,
1740
+ binding.agentId,
1741
+ binding.installationId ?? null,
1742
+ binding.runtime,
1743
+ binding.profile ?? null,
1744
+ JSON.stringify(binding.capabilities ?? {}),
1745
+ binding.boundAt,
1746
+ );
1747
+ }
1748
+
1749
+ unbindRuntime(bindingId: string): boolean {
1750
+ const result = this.db()
1751
+ .prepare('DELETE FROM agent_runtime_bindings WHERE id = ?')
1752
+ .run(bindingId);
1753
+ return Number(result.changes) > 0;
1754
+ }
1755
+
1756
+ /**
1757
+ * Advisory only. Records that Synomem observed this binding act — never that
1758
+ * the runtime is reachable now, and never that a delivery succeeded.
1759
+ */
1760
+ touchRuntimeBinding(agentId: string, runtime: string, at: string): void {
1761
+ this.db()
1762
+ .prepare(
1763
+ 'UPDATE agent_runtime_bindings SET last_seen_at = ? WHERE agent_id = ? AND runtime = ?',
1764
+ )
1765
+ .run(at, agentId, runtime);
1766
+ }
1767
+
1768
+ /**
1769
+ * Writes an agent's aliases, refusing any that would make a name ambiguous.
1770
+ *
1771
+ * Two collisions matter and both are rejected here rather than at lookup: an
1772
+ * alias another agent already claims, and an alias equal to a different
1773
+ * agent's canonical ID. Catching them at write means the person adding the
1774
+ * alias sees the conflict, instead of a later reader silently getting one of
1775
+ * two possible agents.
1776
+ */
1777
+ private insertAliases(agentId: string, aliases: string[]): void {
1778
+ for (const alias of aliases) {
1779
+ const normalized = alias.trim().toLowerCase();
1780
+ const conflictingAgent = this.db()
1781
+ .prepare('SELECT id FROM agents WHERE lower(id) = ? AND id != ?')
1782
+ .get(normalized, agentId) as { id: string } | undefined;
1783
+ if (conflictingAgent) {
1784
+ throw new SynomemError(
1785
+ 'ALIAS_CONFLICT',
1786
+ `Alias "${alias}" is already the canonical ID of agent ${conflictingAgent.id}. Aliases must resolve to exactly one agent.`,
1787
+ );
1788
+ }
1789
+ const conflictingAlias = this.db()
1790
+ .prepare('SELECT agent_id FROM aliases WHERE normalized_alias = ? AND agent_id != ?')
1791
+ .get(normalized, agentId) as { agent_id: string } | undefined;
1792
+ if (conflictingAlias) {
1793
+ throw new SynomemError(
1794
+ 'ALIAS_CONFLICT',
1795
+ `Alias "${alias}" already belongs to agent ${conflictingAlias.agent_id}. Aliases must resolve to exactly one agent.`,
1796
+ );
1797
+ }
1798
+ this.db()
1799
+ .prepare('INSERT INTO aliases(alias, agent_id, normalized_alias) VALUES (?, ?, ?)')
1800
+ .run(alias, agentId, normalized);
1801
+ }
1510
1802
  }
1511
1803
 
1512
1804
  listAgents(): AgentProfile[] {