synomem 0.1.1 → 0.3.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 -0
  3. package/README.md +26 -13
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +379 -51
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts +131 -17
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +489 -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 +356 -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 +213 -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 +21 -1
  25. package/dist/ports/repository.d.ts.map +1 -1
  26. package/dist/projections.d.ts +26 -1
  27. package/dist/projections.d.ts.map +1 -1
  28. package/dist/projections.js +208 -44
  29. package/dist/projections.js.map +1 -1
  30. package/dist/remote.d.ts +82 -9
  31. package/dist/remote.d.ts.map +1 -1
  32. package/dist/remote.js +53 -2
  33. package/dist/remote.js.map +1 -1
  34. package/dist/schemas.d.ts +476 -21
  35. package/dist/schemas.d.ts.map +1 -1
  36. package/dist/schemas.js +204 -26
  37. package/dist/schemas.js.map +1 -1
  38. package/dist/service.d.ts +82 -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 +51 -1
  45. package/dist/storage.d.ts.map +1 -1
  46. package/dist/storage.js +366 -36
  47. package/dist/storage.js.map +1 -1
  48. package/dist/types.d.ts +310 -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 +610 -95
  61. package/src/client.ts +587 -79
  62. package/src/config.ts +8 -8
  63. package/src/index.ts +11 -1
  64. package/src/mcp/index.ts +265 -30
  65. package/src/mcp-server.ts +58 -8
  66. package/src/ports/repository.ts +21 -0
  67. package/src/projections.ts +209 -44
  68. package/src/remote.ts +166 -8
  69. package/src/schemas.ts +211 -26
  70. package/src/service.ts +85 -7
  71. package/src/skill-install.ts +14 -17
  72. package/src/storage.ts +472 -34
  73. package/src/types.ts +332 -39
package/src/storage.ts CHANGED
@@ -22,13 +22,18 @@ 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
+ PostAcknowledgment,
35
+ PostRoster,
36
+ JsonValue,
32
37
  KudosChange,
33
38
  SynomemEvent,
34
39
  ItemChange,
@@ -186,6 +191,70 @@ CREATE INDEX kudos_current_actor ON kudos_current(actor_kind, actor_id, given_se
186
191
  CREATE INDEX kudos_current_status ON kudos_current(status, revocation_status, given_sequence DESC);
187
192
  `;
188
193
 
194
+ /**
195
+ * v4 records a task's or todo's deadline on the item index.
196
+ *
197
+ * The plan asks for "accepted Tasks past their due date" as a bounded query.
198
+ * Answering that from the event log means replaying every task on every call,
199
+ * so the deadline is projected alongside the rest of the summary. It is stored
200
+ * as an ISO instant: a date-only due date resolves to the end of that day, so
201
+ * "overdue" means the day has actually passed rather than merely started.
202
+ */
203
+ const migrationV4 = `
204
+ ALTER TABLE items_current ADD COLUMN due_at TEXT;
205
+ CREATE INDEX items_current_due ON items_current(kind, status, due_at);
206
+ `;
207
+
208
+ /**
209
+ * v5 makes alias lookup case-insensitive and unambiguous.
210
+ *
211
+ * The plan requires that `mycroft`, `Mycroft` and `Mike` may all resolve to one
212
+ * canonical agent, while a name two agents both claim must return candidates
213
+ * rather than guessing. A normalized column with a unique index enforces the
214
+ * second half at write time: the collision is refused when the alias is added,
215
+ * not discovered later by whoever happens to look it up first.
216
+ *
217
+ * Runtime bindings arrive here too. An agent is not one harness forever —
218
+ * Mycroft may run Hermes on two machines, or move between harnesses — so the
219
+ * binding is its own row keyed by agent, installation, runtime and profile
220
+ * rather than a field on the agent.
221
+ */
222
+ const migrationV5 = `
223
+ ALTER TABLE aliases ADD COLUMN normalized_alias TEXT;
224
+ UPDATE aliases SET normalized_alias = lower(alias);
225
+ CREATE UNIQUE INDEX aliases_normalized ON aliases(normalized_alias);
226
+
227
+ CREATE TABLE agent_runtime_bindings (
228
+ id TEXT PRIMARY KEY,
229
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
230
+ installation_id TEXT,
231
+ runtime TEXT NOT NULL,
232
+ profile TEXT,
233
+ capabilities_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(capabilities_json)),
234
+ bound_at TEXT NOT NULL,
235
+ last_seen_at TEXT
236
+ ) STRICT;
237
+ CREATE UNIQUE INDEX agent_runtime_bindings_unique
238
+ ON agent_runtime_bindings(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''));
239
+ CREATE INDEX agent_runtime_bindings_agent ON agent_runtime_bindings(agent_id);
240
+ `;
241
+
242
+ const migrationV6 = `
243
+ -- Acknowledgements are their own rows rather than a column on the post: many
244
+ -- actors acknowledge one post independently, and the interesting question is
245
+ -- who, not how many.
246
+ CREATE TABLE post_acknowledgments (
247
+ post_id TEXT NOT NULL,
248
+ actor_kind TEXT NOT NULL,
249
+ actor_id TEXT NOT NULL,
250
+ actor_display_name TEXT,
251
+ note TEXT,
252
+ acknowledged_at TEXT NOT NULL,
253
+ PRIMARY KEY (post_id, actor_kind, actor_id)
254
+ ) STRICT;
255
+ CREATE INDEX post_acknowledgments_post ON post_acknowledgments(post_id);
256
+ `;
257
+
189
258
  const migrationV3 = `
190
259
  DROP TRIGGER IF EXISTS events_append_only_update;
191
260
  DROP TRIGGER IF EXISTS events_append_only_delete;
@@ -303,10 +372,19 @@ function itemSummaryFromRow(row: ItemRow): ItemSummary {
303
372
  };
304
373
  }
305
374
 
375
+ /**
376
+ * Resolves a due value to a comparable instant.
377
+ *
378
+ * A date-only deadline resolves to the END of that day, so "overdue" means the
379
+ * day has passed rather than merely begun. Treating 2026-09-15 as midnight
380
+ * would report a task due today as already late.
381
+ */
306
382
  function eventKind(event: SynomemEvent): RecordKind | undefined {
307
383
  if (event.type.startsWith('kudos.')) return 'kudos';
308
384
  if (event.type.startsWith('memo.')) return 'memo';
309
385
  if (event.type.startsWith('note.')) return 'note';
386
+ if (event.type.startsWith('post.')) return 'post';
387
+ if (event.type.startsWith('task.')) return 'task';
310
388
  if (event.type.startsWith('todo.')) return 'todo';
311
389
  return undefined;
312
390
  }
@@ -440,7 +518,7 @@ export class SynomemStorage implements SynomemRepository {
440
518
  const version = Number(
441
519
  (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
442
520
  );
443
- if (version > 3) {
521
+ if (version > 6) {
444
522
  throw new SynomemError(
445
523
  'UNSUPPORTED_SCHEMA',
446
524
  `Database schema version ${version} is newer than this package supports.`,
@@ -474,29 +552,68 @@ export class SynomemStorage implements SynomemRepository {
474
552
  if (afterV2 === 2) {
475
553
  this.transactionSync(() => {
476
554
  db.exec(migrationV3);
477
- this.rebuildItemsCurrentIndex();
555
+ // The item index is left empty here and populated by v4, which adds the
556
+ // due-date column the projection writes. Rebuilding before that column
557
+ // exists fails on the first task.
478
558
  db.prepare(
479
559
  'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
480
560
  ).run(3, new Date().toISOString());
481
561
  db.exec('PRAGMA user_version = 3');
482
562
  });
483
563
  }
564
+ const afterV3 = Number(
565
+ (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
566
+ );
567
+ if (afterV3 === 3) {
568
+ this.transactionSync(() => {
569
+ db.exec(migrationV4);
570
+ this.rebuildItemsCurrentIndex();
571
+ db.prepare(
572
+ 'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
573
+ ).run(4, new Date().toISOString());
574
+ db.exec('PRAGMA user_version = 4');
575
+ });
576
+ }
577
+ const afterV4 = Number(
578
+ (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
579
+ );
580
+ if (afterV4 === 4) {
581
+ this.transactionSync(() => {
582
+ db.exec(migrationV5);
583
+ db.prepare(
584
+ 'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
585
+ ).run(5, new Date().toISOString());
586
+ db.exec('PRAGMA user_version = 5');
587
+ });
588
+ }
589
+ const afterV5 = Number(
590
+ (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
591
+ );
592
+ if (afterV5 === 5) {
593
+ this.transactionSync(() => {
594
+ db.exec(migrationV6);
595
+ db.prepare(
596
+ 'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
597
+ ).run(6, new Date().toISOString());
598
+ db.exec('PRAGMA user_version = 6');
599
+ });
600
+ }
484
601
  }
485
602
 
486
603
  private assertSchemaSupported(): void {
487
604
  const version = Number(
488
605
  (this.db().prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
489
606
  );
490
- if (version !== 3) {
491
- if (version === 1 || version === 2) {
607
+ if (version !== 6) {
608
+ if (version >= 1 && version <= 5) {
492
609
  throw new SynomemError(
493
610
  'UNSUPPORTED_SCHEMA',
494
- 'Database schema version 1 requires migration. Open this home once with readOnly: false, then retry the read-only client.',
611
+ `Database schema version ${version} requires migration. Open this home once with readOnly: false, then retry the read-only client.`,
495
612
  );
496
613
  }
497
614
  throw new SynomemError(
498
615
  'UNSUPPORTED_SCHEMA',
499
- `Expected database schema version 3; found ${version}.`,
616
+ `Expected database schema version 5; found ${version}.`,
500
617
  );
501
618
  }
502
619
  }
@@ -673,6 +790,7 @@ export class SynomemStorage implements SynomemRepository {
673
790
  ownerDisplayName?: string;
674
791
  assigneeAgentId?: string;
675
792
  assigneeDisplayName?: string;
793
+ dueAt?: string;
676
794
  }): void => {
677
795
  this.db()
678
796
  .prepare(
@@ -680,8 +798,8 @@ export class SynomemStorage implements SynomemRepository {
680
798
  item_id, kind, created_sequence, updated_sequence, created_at, updated_at,
681
799
  actor_kind, actor_id, actor_display_name, title, tags_json, visibility, status,
682
800
  recipient_agent_id, recipient_display_name, owner_agent_id, owner_display_name,
683
- assignee_agent_id, assignee_display_name
684
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
801
+ assignee_agent_id, assignee_display_name, due_at
802
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
685
803
  )
686
804
  .run(
687
805
  event.aggregateId,
@@ -703,6 +821,7 @@ export class SynomemStorage implements SynomemRepository {
703
821
  values.ownerDisplayName ?? null,
704
822
  values.assigneeAgentId ?? null,
705
823
  values.assigneeDisplayName ?? null,
824
+ values.dueAt ?? null,
706
825
  );
707
826
  };
708
827
  const updateStatus = (status: string): void => {
@@ -747,6 +866,61 @@ export class SynomemStorage implements SynomemRepository {
747
866
  ownerAgentId: event.ownerAgentId,
748
867
  ownerDisplayName: event.ownerDisplayName,
749
868
  });
869
+ } else if (event.type === 'post.created') {
870
+ /*
871
+ * A post is addressed to the workspace, so it is recorded as `workspace`
872
+ * visibility rather than carrying a visibility of its own. There is no
873
+ * owner-scoped or recipient-scoped read filter to apply: everyone who can
874
+ * read the workspace can read it, which is the whole point of the domain.
875
+ */
876
+ insert({
877
+ kind: 'post',
878
+ title: event.title,
879
+ tags: event.tags,
880
+ visibility: 'workspace',
881
+ status: 'active',
882
+ });
883
+ } else if (event.type === 'post.edited') {
884
+ this.db()
885
+ .prepare(
886
+ `UPDATE items_current SET title = ?, tags_json = ?,
887
+ updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
888
+ )
889
+ .run(
890
+ event.title,
891
+ JSON.stringify(event.tags ?? []),
892
+ sequence,
893
+ event.createdAt,
894
+ event.postId,
895
+ );
896
+ } else if (event.type === 'post.archived') {
897
+ updateStatus('archived');
898
+ } else if (event.type === 'post.acknowledged') {
899
+ // One row per actor per post: acknowledging twice is the same statement,
900
+ // not a second one.
901
+ this.db()
902
+ .prepare(
903
+ `INSERT INTO post_acknowledgments
904
+ (post_id, actor_kind, actor_id, actor_display_name, note, acknowledged_at)
905
+ VALUES (?, ?, ?, ?, ?, ?)
906
+ ON CONFLICT(post_id, actor_kind, actor_id) DO UPDATE SET
907
+ note = excluded.note,
908
+ acknowledged_at = excluded.acknowledged_at`,
909
+ )
910
+ .run(
911
+ event.postId,
912
+ event.actor.kind,
913
+ event.actor.id,
914
+ event.actor.displayName ?? null,
915
+ event.note ?? null,
916
+ event.createdAt,
917
+ );
918
+ } else if (event.type === 'post.acknowledgment.withdrawn') {
919
+ this.db()
920
+ .prepare(
921
+ 'DELETE FROM post_acknowledgments WHERE post_id = ? AND actor_kind = ? AND actor_id = ?',
922
+ )
923
+ .run(event.postId, event.actor.kind, event.actor.id);
750
924
  } else if (event.type === 'note.revised') {
751
925
  this.db()
752
926
  .prepare(
@@ -762,35 +936,69 @@ export class SynomemStorage implements SynomemRepository {
762
936
  event.aggregateId,
763
937
  );
764
938
  } else if (event.type === 'note.archived') updateStatus('archived');
765
- else if (event.type === 'todo.created') {
939
+ else if (event.type === 'task.created') {
766
940
  insert({
767
- kind: 'todo',
941
+ kind: 'task',
768
942
  title: event.title,
769
943
  tags: event.tags,
770
944
  visibility: event.visibility,
771
945
  status: event.requiresAcceptance ? 'assigned' : 'open',
772
946
  assigneeAgentId: event.assigneeAgentId,
773
947
  assigneeDisplayName: event.assigneeDisplayName,
948
+ ...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
774
949
  });
775
- } else if (event.type === 'todo.updated') {
950
+ } else if (event.type === 'task.updated') {
776
951
  this.db()
777
952
  .prepare(
778
- `UPDATE items_current SET title = ?, tags_json = ?, visibility = ?,
953
+ `UPDATE items_current SET title = ?, tags_json = ?, visibility = ?, due_at = ?,
779
954
  updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
780
955
  )
781
956
  .run(
782
957
  event.title,
783
958
  JSON.stringify(event.tags ?? []),
784
959
  event.visibility,
960
+ dueInstant(event.due) ?? null,
961
+ sequence,
962
+ event.createdAt,
963
+ event.aggregateId,
964
+ );
965
+ } else if (event.type === 'task.accepted') updateStatus('open');
966
+ else if (event.type === 'task.rejected') updateStatus('rejected');
967
+ else if (event.type === 'task.completed') updateStatus('completed');
968
+ else if (event.type === 'task.reopened') updateStatus('open');
969
+ else if (event.type === 'task.canceled') updateStatus('canceled');
970
+ else if (event.type === 'todo.created') {
971
+ // A Todo's owner IS its author, and it is always private. Recording the
972
+ // owner explicitly rather than inferring it from the actor keeps the
973
+ // owner-scoped read filters uniform across notes and todos.
974
+ insert({
975
+ kind: 'todo',
976
+ title: event.title,
977
+ tags: event.tags,
978
+ visibility: 'private',
979
+ status: 'open',
980
+ ownerAgentId: event.actor.id,
981
+ ...(event.actor.displayName ? { ownerDisplayName: event.actor.displayName } : {}),
982
+ ...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
983
+ });
984
+ } else if (event.type === 'todo.updated') {
985
+ this.db()
986
+ .prepare(
987
+ `UPDATE items_current SET title = ?, tags_json = ?, due_at = ?,
988
+ updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
989
+ )
990
+ .run(
991
+ event.title,
992
+ JSON.stringify(event.tags ?? []),
993
+ dueInstant(event.due) ?? null,
785
994
  sequence,
786
995
  event.createdAt,
787
996
  event.aggregateId,
788
997
  );
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');
998
+ } else if (event.type === 'todo.completed') updateStatus('completed');
792
999
  else if (event.type === 'todo.reopened') updateStatus('open');
793
1000
  else if (event.type === 'todo.canceled') updateStatus('canceled');
1001
+ else if (event.type === 'todo.archived') updateStatus('archived');
794
1002
  }
795
1003
 
796
1004
  rebuildItemsCurrentIndex(): void {
@@ -1037,11 +1245,48 @@ export class SynomemStorage implements SynomemRepository {
1037
1245
  if (input.pending) {
1038
1246
  add(`((kind = 'kudos' AND status = 'unacknowledged') OR
1039
1247
  (kind = 'memo' AND status = 'unread') OR
1040
- (kind = 'todo' AND status IN ('assigned', 'open')))`);
1248
+ (kind = 'task' AND status IN ('assigned', 'open')))`);
1041
1249
  }
1042
1250
  if (input.visibility) add('visibility = ?', input.visibility);
1043
1251
  if (input.from) add('created_at >= ?', input.from);
1044
1252
  if (input.to) add('created_at <= ?', input.to);
1253
+
1254
+ // Unanswered discovery: items still waiting for somebody to respond. Kept
1255
+ // distinct from `pending`, which also counts accepted-and-in-progress work.
1256
+ if (input.awaitingResponse) {
1257
+ add(`((kind = 'kudos' AND status = 'unacknowledged') OR
1258
+ (kind = 'memo' AND status = 'unread') OR
1259
+ (kind = 'task' AND status = 'assigned'))`);
1260
+ }
1261
+ if (input.awaitingSince) add('created_at <= ?', input.awaitingSince);
1262
+
1263
+ // Overdue discovery: a deadline that has passed, on work still open. A
1264
+ // completed or canceled item is not overdue, however late it was.
1265
+ if (input.overdueAsOf) {
1266
+ add(
1267
+ `(due_at IS NOT NULL AND due_at < ? AND status IN ('assigned', 'open'))`,
1268
+ input.overdueAsOf,
1269
+ );
1270
+ }
1271
+
1272
+ /*
1273
+ * Private todos are owner-only for EVERY viewer, including a human.
1274
+ *
1275
+ * The visibility rules below exempt human actors, on the reasoning that a
1276
+ * person operating a local home is its operator. That does not extend to
1277
+ * todos: the plan is explicit that a todo is visible only to its owner
1278
+ * except through an explicitly authorized administrative capability, and
1279
+ * this release has no such capability. Without this clause a human actor
1280
+ * would see every agent's private reminders in an item list.
1281
+ */
1282
+ add(
1283
+ `(kind != 'todo' OR (owner_agent_id = ? AND ? = 'agent') OR (actor_id = ? AND actor_kind = ?))`,
1284
+ viewer.id,
1285
+ viewer.kind,
1286
+ viewer.id,
1287
+ viewer.kind,
1288
+ );
1289
+
1045
1290
  if (viewer.kind !== 'human') {
1046
1291
  if (viewer.kind === 'agent') {
1047
1292
  add(
@@ -1259,7 +1504,7 @@ export class SynomemStorage implements SynomemRepository {
1259
1504
  this.db()
1260
1505
  .prepare(
1261
1506
  `SELECT COUNT(*) AS count FROM events WHERE type IN
1262
- ('kudos.given', 'memo.sent', 'note.created', 'todo.created')`,
1507
+ ('kudos.given', 'memo.sent', 'note.created', 'post.created', 'task.created')`,
1263
1508
  )
1264
1509
  .get() as { count: number }
1265
1510
  ).count,
@@ -1412,13 +1657,24 @@ export class SynomemStorage implements SynomemRepository {
1412
1657
  'note.created',
1413
1658
  'note.revised',
1414
1659
  'note.archived',
1660
+ 'post.created',
1661
+ 'post.edited',
1662
+ 'post.archived',
1663
+ 'post.acknowledged',
1664
+ 'post.acknowledgment.withdrawn',
1665
+ 'task.created',
1666
+ 'task.updated',
1667
+ 'task.completed',
1668
+ 'task.reopened',
1669
+ 'task.accepted',
1670
+ 'task.rejected',
1671
+ 'task.canceled',
1415
1672
  'todo.created',
1416
1673
  'todo.updated',
1417
1674
  'todo.completed',
1418
1675
  'todo.reopened',
1419
- 'todo.accepted',
1420
- 'todo.rejected',
1421
1676
  'todo.canceled',
1677
+ 'todo.archived',
1422
1678
  ]);
1423
1679
  if (
1424
1680
  (typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 1) ||
@@ -1436,7 +1692,7 @@ export class SynomemStorage implements SynomemRepository {
1436
1692
  legacy.kudosId ??
1437
1693
  legacy.memoId ??
1438
1694
  legacy.noteId ??
1439
- legacy.todoId ??
1695
+ legacy.taskId ??
1440
1696
  (typeof legacy.agentId === 'string' ? legacy.agentId : undefined) ??
1441
1697
  (typeof legacy.agent === 'object' && legacy.agent !== null
1442
1698
  ? (legacy.agent as { id?: unknown }).id
@@ -1480,9 +1736,7 @@ export class SynomemStorage implements SynomemRepository {
1480
1736
  parsed.createdAt,
1481
1737
  parsed.createdAt,
1482
1738
  );
1483
- for (const alias of parsed.aliases ?? []) {
1484
- this.db().prepare('INSERT INTO aliases(alias, agent_id) VALUES (?, ?)').run(alias, parsed.id);
1485
- }
1739
+ this.insertAliases(parsed.id, parsed.aliases ?? []);
1486
1740
  }
1487
1741
 
1488
1742
  updateAgent(profile: AgentProfile, updatedAt: string): void {
@@ -1491,22 +1745,206 @@ export class SynomemStorage implements SynomemRepository {
1491
1745
  .prepare('UPDATE agents SET display_name = ?, profile_json = ?, updated_at = ? WHERE id = ?')
1492
1746
  .run(parsed.displayName, JSON.stringify(parsed), updatedAt, parsed.id);
1493
1747
  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
- }
1748
+ this.insertAliases(parsed.id, parsed.aliases ?? []);
1497
1749
  }
1498
1750
 
1499
1751
  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()
1752
+ const resolved = this.resolveAgent(idOrAlias);
1753
+ return resolved.match;
1754
+ }
1755
+
1756
+ /**
1757
+ * Resolves a name to a canonical agent, reporting ambiguity rather than
1758
+ * guessing.
1759
+ *
1760
+ * The plan is specific: `mycroft`, `Mycroft` and `Mike` may all resolve to one
1761
+ * agent, but a name two visible agents both claim must return candidates. An
1762
+ * alias that collides with a different agent's canonical ID is exactly that
1763
+ * case — silently preferring the ID would attribute work to the wrong agent,
1764
+ * and the person who typed the name would never know.
1765
+ */
1766
+ resolveAgent(query: string): { match?: AgentProfile; candidates: AgentProfile[] } {
1767
+ const normalized = query.trim().toLowerCase();
1768
+ const rows = this.db()
1505
1769
  .prepare(
1506
- 'SELECT a.profile_json FROM agents a JOIN aliases x ON x.agent_id = a.id WHERE x.alias = ?',
1770
+ `SELECT DISTINCT a.profile_json
1771
+ FROM agents a
1772
+ LEFT JOIN aliases x ON x.agent_id = a.id
1773
+ WHERE lower(a.id) = ? OR x.normalized_alias = ?
1774
+ ORDER BY a.id ASC`,
1507
1775
  )
1508
- .get(idOrAlias) as ProfileRow | undefined;
1509
- return alias ? profileSchema.parse(JSON.parse(alias.profile_json)) : undefined;
1776
+ .all(normalized, normalized) as unknown as ProfileRow[];
1777
+ const candidates = rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
1778
+ return candidates.length === 1 ? { match: candidates[0]!, candidates } : { candidates };
1779
+ }
1780
+
1781
+ /* -------------------------------------------------------- post acknowledgment */
1782
+
1783
+ listPostAcknowledgments(postId: string): PostAcknowledgment[] {
1784
+ const rows = this.db()
1785
+ .prepare(
1786
+ `SELECT actor_kind, actor_id, actor_display_name, note, acknowledged_at
1787
+ FROM post_acknowledgments WHERE post_id = ? ORDER BY acknowledged_at ASC`,
1788
+ )
1789
+ .all(postId) as unknown as Array<{
1790
+ actor_kind: string;
1791
+ actor_id: string;
1792
+ actor_display_name: string | null;
1793
+ note: string | null;
1794
+ acknowledged_at: string;
1795
+ }>;
1796
+ return rows.map((row) => ({
1797
+ actor: {
1798
+ kind: row.actor_kind as ActorIdentity['kind'],
1799
+ id: row.actor_id,
1800
+ ...(row.actor_display_name ? { displayName: row.actor_display_name } : {}),
1801
+ },
1802
+ acknowledgedAt: row.acknowledged_at,
1803
+ ...(row.note ? { note: row.note } : {}),
1804
+ }));
1805
+ }
1806
+
1807
+ /**
1808
+ * Who has acknowledged a post and who has not.
1809
+ *
1810
+ * The denominator is agents that existed when the post was written. An agent
1811
+ * created afterwards is counted separately rather than listed as
1812
+ * outstanding — it was not there, and a roster that says otherwise accuses a
1813
+ * newcomer of ignoring something written before it arrived.
1814
+ */
1815
+ postRoster(postId: string): PostRoster | undefined {
1816
+ const post = this.db()
1817
+ .prepare("SELECT created_at FROM items_current WHERE item_id = ? AND kind = 'post'")
1818
+ .get(postId) as { created_at: string } | undefined;
1819
+ if (!post) return undefined;
1820
+
1821
+ const acknowledged = this.listPostAcknowledgments(postId);
1822
+ const acknowledgedIds = new Set(acknowledged.map((entry) => entry.actor.id));
1823
+
1824
+ const eligible = this.db()
1825
+ .prepare('SELECT id, display_name, created_at FROM agents ORDER BY id ASC')
1826
+ .all() as unknown as Array<{ id: string; display_name: string; created_at: string }>;
1827
+
1828
+ const outstanding = eligible
1829
+ .filter((agent) => agent.created_at <= post.created_at && !acknowledgedIds.has(agent.id))
1830
+ .map((agent) => ({ id: agent.id, displayName: agent.display_name }));
1831
+ const joinedSince = eligible.filter((agent) => agent.created_at > post.created_at).length;
1832
+
1833
+ return { postId, acknowledged, outstanding, joinedSince };
1834
+ }
1835
+
1836
+ /* ------------------------------------------------------- runtime bindings */
1837
+
1838
+ listRuntimeBindings(agentId: string): AgentRuntimeBinding[] {
1839
+ const rows = this.db()
1840
+ .prepare(
1841
+ `SELECT id, agent_id, installation_id, runtime, profile, capabilities_json,
1842
+ bound_at, last_seen_at
1843
+ FROM agent_runtime_bindings WHERE agent_id = ? ORDER BY bound_at ASC`,
1844
+ )
1845
+ .all(agentId) as unknown as Array<{
1846
+ id: string;
1847
+ agent_id: string;
1848
+ installation_id: string | null;
1849
+ runtime: string;
1850
+ profile: string | null;
1851
+ capabilities_json: string;
1852
+ bound_at: string;
1853
+ last_seen_at: string | null;
1854
+ }>;
1855
+ return rows.map((row) => ({
1856
+ id: row.id,
1857
+ agentId: row.agent_id,
1858
+ ...(row.installation_id ? { installationId: row.installation_id } : {}),
1859
+ runtime: row.runtime,
1860
+ ...(row.profile ? { profile: row.profile } : {}),
1861
+ capabilities: JSON.parse(row.capabilities_json) as Record<string, JsonValue>,
1862
+ boundAt: row.bound_at,
1863
+ ...(row.last_seen_at ? { lastSeenAt: row.last_seen_at } : {}),
1864
+ }));
1865
+ }
1866
+
1867
+ bindRuntime(binding: {
1868
+ id: string;
1869
+ agentId: string;
1870
+ installationId?: string;
1871
+ runtime: string;
1872
+ profile?: string;
1873
+ capabilities?: Record<string, JsonValue>;
1874
+ boundAt: string;
1875
+ }): void {
1876
+ this.db()
1877
+ .prepare(
1878
+ `INSERT INTO agent_runtime_bindings(
1879
+ id, agent_id, installation_id, runtime, profile, capabilities_json, bound_at
1880
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
1881
+ ON CONFLICT(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''))
1882
+ DO UPDATE SET capabilities_json = excluded.capabilities_json`,
1883
+ )
1884
+ .run(
1885
+ binding.id,
1886
+ binding.agentId,
1887
+ binding.installationId ?? null,
1888
+ binding.runtime,
1889
+ binding.profile ?? null,
1890
+ JSON.stringify(binding.capabilities ?? {}),
1891
+ binding.boundAt,
1892
+ );
1893
+ }
1894
+
1895
+ unbindRuntime(bindingId: string): boolean {
1896
+ const result = this.db()
1897
+ .prepare('DELETE FROM agent_runtime_bindings WHERE id = ?')
1898
+ .run(bindingId);
1899
+ return Number(result.changes) > 0;
1900
+ }
1901
+
1902
+ /**
1903
+ * Advisory only. Records that Synomem observed this binding act — never that
1904
+ * the runtime is reachable now, and never that a delivery succeeded.
1905
+ */
1906
+ touchRuntimeBinding(agentId: string, runtime: string, at: string): void {
1907
+ this.db()
1908
+ .prepare(
1909
+ 'UPDATE agent_runtime_bindings SET last_seen_at = ? WHERE agent_id = ? AND runtime = ?',
1910
+ )
1911
+ .run(at, agentId, runtime);
1912
+ }
1913
+
1914
+ /**
1915
+ * Writes an agent's aliases, refusing any that would make a name ambiguous.
1916
+ *
1917
+ * Two collisions matter and both are rejected here rather than at lookup: an
1918
+ * alias another agent already claims, and an alias equal to a different
1919
+ * agent's canonical ID. Catching them at write means the person adding the
1920
+ * alias sees the conflict, instead of a later reader silently getting one of
1921
+ * two possible agents.
1922
+ */
1923
+ private insertAliases(agentId: string, aliases: string[]): void {
1924
+ for (const alias of aliases) {
1925
+ const normalized = alias.trim().toLowerCase();
1926
+ const conflictingAgent = this.db()
1927
+ .prepare('SELECT id FROM agents WHERE lower(id) = ? AND id != ?')
1928
+ .get(normalized, agentId) as { id: string } | undefined;
1929
+ if (conflictingAgent) {
1930
+ throw new SynomemError(
1931
+ 'ALIAS_CONFLICT',
1932
+ `Alias "${alias}" is already the canonical ID of agent ${conflictingAgent.id}. Aliases must resolve to exactly one agent.`,
1933
+ );
1934
+ }
1935
+ const conflictingAlias = this.db()
1936
+ .prepare('SELECT agent_id FROM aliases WHERE normalized_alias = ? AND agent_id != ?')
1937
+ .get(normalized, agentId) as { agent_id: string } | undefined;
1938
+ if (conflictingAlias) {
1939
+ throw new SynomemError(
1940
+ 'ALIAS_CONFLICT',
1941
+ `Alias "${alias}" already belongs to agent ${conflictingAlias.agent_id}. Aliases must resolve to exactly one agent.`,
1942
+ );
1943
+ }
1944
+ this.db()
1945
+ .prepare('INSERT INTO aliases(alias, agent_id, normalized_alias) VALUES (?, ?, ?)')
1946
+ .run(alias, agentId, normalized);
1947
+ }
1510
1948
  }
1511
1949
 
1512
1950
  listAgents(): AgentProfile[] {