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/dist/storage.js CHANGED
@@ -5,6 +5,7 @@ import { DatabaseSync } from 'node:sqlite';
5
5
  import { defaultConfig, mergeConfig } from './config.js';
6
6
  import { asSynomemError, SynomemError } from './errors.js';
7
7
  import { assertNoSymlinkEscape, atomicWriteFile, ensureDirectory, readJsonFile, } from './fs-utils.js';
8
+ import { dueInstant } from './projections.js';
8
9
  import { actorSchema, eventSchema, profileSchema } from './schemas.js';
9
10
  const migrationV1 = `
10
11
  CREATE TABLE IF NOT EXISTS schema_migrations (
@@ -96,6 +97,67 @@ CREATE INDEX kudos_current_recipient ON kudos_current(recipient_agent_id, given_
96
97
  CREATE INDEX kudos_current_actor ON kudos_current(actor_kind, actor_id, given_sequence DESC);
97
98
  CREATE INDEX kudos_current_status ON kudos_current(status, revocation_status, given_sequence DESC);
98
99
  `;
100
+ /**
101
+ * v4 records a task's or todo's deadline on the item index.
102
+ *
103
+ * The plan asks for "accepted Tasks past their due date" as a bounded query.
104
+ * Answering that from the event log means replaying every task on every call,
105
+ * so the deadline is projected alongside the rest of the summary. It is stored
106
+ * as an ISO instant: a date-only due date resolves to the end of that day, so
107
+ * "overdue" means the day has actually passed rather than merely started.
108
+ */
109
+ const migrationV4 = `
110
+ ALTER TABLE items_current ADD COLUMN due_at TEXT;
111
+ CREATE INDEX items_current_due ON items_current(kind, status, due_at);
112
+ `;
113
+ /**
114
+ * v5 makes alias lookup case-insensitive and unambiguous.
115
+ *
116
+ * The plan requires that `mycroft`, `Mycroft` and `Mike` may all resolve to one
117
+ * canonical agent, while a name two agents both claim must return candidates
118
+ * rather than guessing. A normalized column with a unique index enforces the
119
+ * second half at write time: the collision is refused when the alias is added,
120
+ * not discovered later by whoever happens to look it up first.
121
+ *
122
+ * Runtime bindings arrive here too. An agent is not one harness forever —
123
+ * Mycroft may run Hermes on two machines, or move between harnesses — so the
124
+ * binding is its own row keyed by agent, installation, runtime and profile
125
+ * rather than a field on the agent.
126
+ */
127
+ const migrationV5 = `
128
+ ALTER TABLE aliases ADD COLUMN normalized_alias TEXT;
129
+ UPDATE aliases SET normalized_alias = lower(alias);
130
+ CREATE UNIQUE INDEX aliases_normalized ON aliases(normalized_alias);
131
+
132
+ CREATE TABLE agent_runtime_bindings (
133
+ id TEXT PRIMARY KEY,
134
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
135
+ installation_id TEXT,
136
+ runtime TEXT NOT NULL,
137
+ profile TEXT,
138
+ capabilities_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(capabilities_json)),
139
+ bound_at TEXT NOT NULL,
140
+ last_seen_at TEXT
141
+ ) STRICT;
142
+ CREATE UNIQUE INDEX agent_runtime_bindings_unique
143
+ ON agent_runtime_bindings(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''));
144
+ CREATE INDEX agent_runtime_bindings_agent ON agent_runtime_bindings(agent_id);
145
+ `;
146
+ const migrationV6 = `
147
+ -- Acknowledgements are their own rows rather than a column on the post: many
148
+ -- actors acknowledge one post independently, and the interesting question is
149
+ -- who, not how many.
150
+ CREATE TABLE post_acknowledgments (
151
+ post_id TEXT NOT NULL,
152
+ actor_kind TEXT NOT NULL,
153
+ actor_id TEXT NOT NULL,
154
+ actor_display_name TEXT,
155
+ note TEXT,
156
+ acknowledged_at TEXT NOT NULL,
157
+ PRIMARY KEY (post_id, actor_kind, actor_id)
158
+ ) STRICT;
159
+ CREATE INDEX post_acknowledgments_post ON post_acknowledgments(post_id);
160
+ `;
99
161
  const migrationV3 = `
100
162
  DROP TRIGGER IF EXISTS events_append_only_update;
101
163
  DROP TRIGGER IF EXISTS events_append_only_delete;
@@ -203,6 +265,13 @@ function itemSummaryFromRow(row) {
203
265
  ...(row.assignee_agent_id ? { assigneeAgentId: row.assignee_agent_id } : {}),
204
266
  };
205
267
  }
268
+ /**
269
+ * Resolves a due value to a comparable instant.
270
+ *
271
+ * A date-only deadline resolves to the END of that day, so "overdue" means the
272
+ * day has passed rather than merely begun. Treating 2026-09-15 as midnight
273
+ * would report a task due today as already late.
274
+ */
206
275
  function eventKind(event) {
207
276
  if (event.type.startsWith('kudos.'))
208
277
  return 'kudos';
@@ -210,6 +279,10 @@ function eventKind(event) {
210
279
  return 'memo';
211
280
  if (event.type.startsWith('note.'))
212
281
  return 'note';
282
+ if (event.type.startsWith('post.'))
283
+ return 'post';
284
+ if (event.type.startsWith('task.'))
285
+ return 'task';
213
286
  if (event.type.startsWith('todo.'))
214
287
  return 'todo';
215
288
  return undefined;
@@ -321,7 +394,7 @@ export class SynomemStorage {
321
394
  migrate() {
322
395
  const db = this.db();
323
396
  const version = Number(db.prepare('PRAGMA user_version').get().user_version);
324
- if (version > 3) {
397
+ if (version > 6) {
325
398
  throw new SynomemError('UNSUPPORTED_SCHEMA', `Database schema version ${version} is newer than this package supports.`);
326
399
  }
327
400
  if (version === 0) {
@@ -344,19 +417,46 @@ export class SynomemStorage {
344
417
  if (afterV2 === 2) {
345
418
  this.transactionSync(() => {
346
419
  db.exec(migrationV3);
347
- this.rebuildItemsCurrentIndex();
420
+ // The item index is left empty here and populated by v4, which adds the
421
+ // due-date column the projection writes. Rebuilding before that column
422
+ // exists fails on the first task.
348
423
  db.prepare('INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)').run(3, new Date().toISOString());
349
424
  db.exec('PRAGMA user_version = 3');
350
425
  });
351
426
  }
427
+ const afterV3 = Number(db.prepare('PRAGMA user_version').get().user_version);
428
+ if (afterV3 === 3) {
429
+ this.transactionSync(() => {
430
+ db.exec(migrationV4);
431
+ this.rebuildItemsCurrentIndex();
432
+ db.prepare('INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)').run(4, new Date().toISOString());
433
+ db.exec('PRAGMA user_version = 4');
434
+ });
435
+ }
436
+ const afterV4 = Number(db.prepare('PRAGMA user_version').get().user_version);
437
+ if (afterV4 === 4) {
438
+ this.transactionSync(() => {
439
+ db.exec(migrationV5);
440
+ db.prepare('INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)').run(5, new Date().toISOString());
441
+ db.exec('PRAGMA user_version = 5');
442
+ });
443
+ }
444
+ const afterV5 = Number(db.prepare('PRAGMA user_version').get().user_version);
445
+ if (afterV5 === 5) {
446
+ this.transactionSync(() => {
447
+ db.exec(migrationV6);
448
+ db.prepare('INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)').run(6, new Date().toISOString());
449
+ db.exec('PRAGMA user_version = 6');
450
+ });
451
+ }
352
452
  }
353
453
  assertSchemaSupported() {
354
454
  const version = Number(this.db().prepare('PRAGMA user_version').get().user_version);
355
- if (version !== 3) {
356
- if (version === 1 || version === 2) {
357
- throw new SynomemError('UNSUPPORTED_SCHEMA', 'Database schema version 1 requires migration. Open this home once with readOnly: false, then retry the read-only client.');
455
+ if (version !== 6) {
456
+ if (version >= 1 && version <= 5) {
457
+ throw new SynomemError('UNSUPPORTED_SCHEMA', `Database schema version ${version} requires migration. Open this home once with readOnly: false, then retry the read-only client.`);
358
458
  }
359
- throw new SynomemError('UNSUPPORTED_SCHEMA', `Expected database schema version 3; found ${version}.`);
459
+ throw new SynomemError('UNSUPPORTED_SCHEMA', `Expected database schema version 5; found ${version}.`);
360
460
  }
361
461
  }
362
462
  db() {
@@ -477,9 +577,9 @@ export class SynomemStorage {
477
577
  item_id, kind, created_sequence, updated_sequence, created_at, updated_at,
478
578
  actor_kind, actor_id, actor_display_name, title, tags_json, visibility, status,
479
579
  recipient_agent_id, recipient_display_name, owner_agent_id, owner_display_name,
480
- assignee_agent_id, assignee_display_name
481
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
482
- .run(event.aggregateId, values.kind, sequence, sequence, event.createdAt, event.createdAt, event.actor.kind, event.actor.id, event.actor.displayName ?? null, values.title, JSON.stringify(values.tags ?? []), values.visibility, values.status, values.recipientAgentId ?? null, values.recipientDisplayName ?? null, values.ownerAgentId ?? null, values.ownerDisplayName ?? null, values.assigneeAgentId ?? null, values.assigneeDisplayName ?? null);
580
+ assignee_agent_id, assignee_display_name, due_at
581
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
582
+ .run(event.aggregateId, values.kind, sequence, sequence, event.createdAt, event.createdAt, event.actor.kind, event.actor.id, event.actor.displayName ?? null, values.title, JSON.stringify(values.tags ?? []), values.visibility, values.status, values.recipientAgentId ?? null, values.recipientDisplayName ?? null, values.ownerAgentId ?? null, values.ownerDisplayName ?? null, values.assigneeAgentId ?? null, values.assigneeDisplayName ?? null, values.dueAt ?? null);
483
583
  };
484
584
  const updateStatus = (status) => {
485
585
  this.db()
@@ -527,6 +627,47 @@ export class SynomemStorage {
527
627
  ownerDisplayName: event.ownerDisplayName,
528
628
  });
529
629
  }
630
+ else if (event.type === 'post.created') {
631
+ /*
632
+ * A post is addressed to the workspace, so it is recorded as `workspace`
633
+ * visibility rather than carrying a visibility of its own. There is no
634
+ * owner-scoped or recipient-scoped read filter to apply: everyone who can
635
+ * read the workspace can read it, which is the whole point of the domain.
636
+ */
637
+ insert({
638
+ kind: 'post',
639
+ title: event.title,
640
+ tags: event.tags,
641
+ visibility: 'workspace',
642
+ status: 'active',
643
+ });
644
+ }
645
+ else if (event.type === 'post.edited') {
646
+ this.db()
647
+ .prepare(`UPDATE items_current SET title = ?, tags_json = ?,
648
+ updated_sequence = ?, updated_at = ? WHERE item_id = ?`)
649
+ .run(event.title, JSON.stringify(event.tags ?? []), sequence, event.createdAt, event.postId);
650
+ }
651
+ else if (event.type === 'post.archived') {
652
+ updateStatus('archived');
653
+ }
654
+ else if (event.type === 'post.acknowledged') {
655
+ // One row per actor per post: acknowledging twice is the same statement,
656
+ // not a second one.
657
+ this.db()
658
+ .prepare(`INSERT INTO post_acknowledgments
659
+ (post_id, actor_kind, actor_id, actor_display_name, note, acknowledged_at)
660
+ VALUES (?, ?, ?, ?, ?, ?)
661
+ ON CONFLICT(post_id, actor_kind, actor_id) DO UPDATE SET
662
+ note = excluded.note,
663
+ acknowledged_at = excluded.acknowledged_at`)
664
+ .run(event.postId, event.actor.kind, event.actor.id, event.actor.displayName ?? null, event.note ?? null, event.createdAt);
665
+ }
666
+ else if (event.type === 'post.acknowledgment.withdrawn') {
667
+ this.db()
668
+ .prepare('DELETE FROM post_acknowledgments WHERE post_id = ? AND actor_kind = ? AND actor_id = ?')
669
+ .run(event.postId, event.actor.kind, event.actor.id);
670
+ }
530
671
  else if (event.type === 'note.revised') {
531
672
  this.db()
532
673
  .prepare(`UPDATE items_current SET title = ?, tags_json = ?, visibility = ?,
@@ -535,33 +676,63 @@ export class SynomemStorage {
535
676
  }
536
677
  else if (event.type === 'note.archived')
537
678
  updateStatus('archived');
538
- else if (event.type === 'todo.created') {
679
+ else if (event.type === 'task.created') {
539
680
  insert({
540
- kind: 'todo',
681
+ kind: 'task',
541
682
  title: event.title,
542
683
  tags: event.tags,
543
684
  visibility: event.visibility,
544
685
  status: event.requiresAcceptance ? 'assigned' : 'open',
545
686
  assigneeAgentId: event.assigneeAgentId,
546
687
  assigneeDisplayName: event.assigneeDisplayName,
688
+ ...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
547
689
  });
548
690
  }
549
- else if (event.type === 'todo.updated') {
691
+ else if (event.type === 'task.updated') {
550
692
  this.db()
551
- .prepare(`UPDATE items_current SET title = ?, tags_json = ?, visibility = ?,
693
+ .prepare(`UPDATE items_current SET title = ?, tags_json = ?, visibility = ?, due_at = ?,
552
694
  updated_sequence = ?, updated_at = ? WHERE item_id = ?`)
553
- .run(event.title, JSON.stringify(event.tags ?? []), event.visibility, sequence, event.createdAt, event.aggregateId);
695
+ .run(event.title, JSON.stringify(event.tags ?? []), event.visibility, dueInstant(event.due) ?? null, sequence, event.createdAt, event.aggregateId);
554
696
  }
555
- else if (event.type === 'todo.accepted')
697
+ else if (event.type === 'task.accepted')
556
698
  updateStatus('open');
557
- else if (event.type === 'todo.rejected')
699
+ else if (event.type === 'task.rejected')
558
700
  updateStatus('rejected');
701
+ else if (event.type === 'task.completed')
702
+ updateStatus('completed');
703
+ else if (event.type === 'task.reopened')
704
+ updateStatus('open');
705
+ else if (event.type === 'task.canceled')
706
+ updateStatus('canceled');
707
+ else if (event.type === 'todo.created') {
708
+ // A Todo's owner IS its author, and it is always private. Recording the
709
+ // owner explicitly rather than inferring it from the actor keeps the
710
+ // owner-scoped read filters uniform across notes and todos.
711
+ insert({
712
+ kind: 'todo',
713
+ title: event.title,
714
+ tags: event.tags,
715
+ visibility: 'private',
716
+ status: 'open',
717
+ ownerAgentId: event.actor.id,
718
+ ...(event.actor.displayName ? { ownerDisplayName: event.actor.displayName } : {}),
719
+ ...((due) => (due ? { dueAt: due } : {}))(dueInstant(event.due)),
720
+ });
721
+ }
722
+ else if (event.type === 'todo.updated') {
723
+ this.db()
724
+ .prepare(`UPDATE items_current SET title = ?, tags_json = ?, due_at = ?,
725
+ updated_sequence = ?, updated_at = ? WHERE item_id = ?`)
726
+ .run(event.title, JSON.stringify(event.tags ?? []), dueInstant(event.due) ?? null, sequence, event.createdAt, event.aggregateId);
727
+ }
559
728
  else if (event.type === 'todo.completed')
560
729
  updateStatus('completed');
561
730
  else if (event.type === 'todo.reopened')
562
731
  updateStatus('open');
563
732
  else if (event.type === 'todo.canceled')
564
733
  updateStatus('canceled');
734
+ else if (event.type === 'todo.archived')
735
+ updateStatus('archived');
565
736
  }
566
737
  rebuildItemsCurrentIndex() {
567
738
  this.db().prepare('DELETE FROM items_current').run();
@@ -776,7 +947,7 @@ export class SynomemStorage {
776
947
  if (input.pending) {
777
948
  add(`((kind = 'kudos' AND status = 'unacknowledged') OR
778
949
  (kind = 'memo' AND status = 'unread') OR
779
- (kind = 'todo' AND status IN ('assigned', 'open')))`);
950
+ (kind = 'task' AND status IN ('assigned', 'open')))`);
780
951
  }
781
952
  if (input.visibility)
782
953
  add('visibility = ?', input.visibility);
@@ -784,6 +955,31 @@ export class SynomemStorage {
784
955
  add('created_at >= ?', input.from);
785
956
  if (input.to)
786
957
  add('created_at <= ?', input.to);
958
+ // Unanswered discovery: items still waiting for somebody to respond. Kept
959
+ // distinct from `pending`, which also counts accepted-and-in-progress work.
960
+ if (input.awaitingResponse) {
961
+ add(`((kind = 'kudos' AND status = 'unacknowledged') OR
962
+ (kind = 'memo' AND status = 'unread') OR
963
+ (kind = 'task' AND status = 'assigned'))`);
964
+ }
965
+ if (input.awaitingSince)
966
+ add('created_at <= ?', input.awaitingSince);
967
+ // Overdue discovery: a deadline that has passed, on work still open. A
968
+ // completed or canceled item is not overdue, however late it was.
969
+ if (input.overdueAsOf) {
970
+ add(`(due_at IS NOT NULL AND due_at < ? AND status IN ('assigned', 'open'))`, input.overdueAsOf);
971
+ }
972
+ /*
973
+ * Private todos are owner-only for EVERY viewer, including a human.
974
+ *
975
+ * The visibility rules below exempt human actors, on the reasoning that a
976
+ * person operating a local home is its operator. That does not extend to
977
+ * todos: the plan is explicit that a todo is visible only to its owner
978
+ * except through an explicitly authorized administrative capability, and
979
+ * this release has no such capability. Without this clause a human actor
980
+ * would see every agent's private reminders in an item list.
981
+ */
982
+ add(`(kind != 'todo' OR (owner_agent_id = ? AND ? = 'agent') OR (actor_id = ? AND actor_kind = ?))`, viewer.id, viewer.kind, viewer.id, viewer.kind);
787
983
  if (viewer.kind !== 'human') {
788
984
  if (viewer.kind === 'agent') {
789
985
  add(`(visibility != 'private' OR (actor_kind = ? AND actor_id = ?) OR
@@ -954,7 +1150,7 @@ export class SynomemStorage {
954
1150
  itemIndexHealth() {
955
1151
  const created = Number(this.db()
956
1152
  .prepare(`SELECT COUNT(*) AS count FROM events WHERE type IN
957
- ('kudos.given', 'memo.sent', 'note.created', 'todo.created')`)
1153
+ ('kudos.given', 'memo.sent', 'note.created', 'post.created', 'task.created')`)
958
1154
  .get().count);
959
1155
  const indexed = Number(this.db().prepare('SELECT COUNT(*) AS count FROM items_current').get()
960
1156
  .count);
@@ -1071,13 +1267,24 @@ export class SynomemStorage {
1071
1267
  'note.created',
1072
1268
  'note.revised',
1073
1269
  'note.archived',
1270
+ 'post.created',
1271
+ 'post.edited',
1272
+ 'post.archived',
1273
+ 'post.acknowledged',
1274
+ 'post.acknowledgment.withdrawn',
1275
+ 'task.created',
1276
+ 'task.updated',
1277
+ 'task.completed',
1278
+ 'task.reopened',
1279
+ 'task.accepted',
1280
+ 'task.rejected',
1281
+ 'task.canceled',
1074
1282
  'todo.created',
1075
1283
  'todo.updated',
1076
1284
  'todo.completed',
1077
1285
  'todo.reopened',
1078
- 'todo.accepted',
1079
- 'todo.rejected',
1080
1286
  'todo.canceled',
1287
+ 'todo.archived',
1081
1288
  ]);
1082
1289
  if ((typeof candidate.schemaVersion === 'number' && candidate.schemaVersion > 1) ||
1083
1290
  (typeof candidate.type === 'string' && !supportedTypes.has(candidate.type))) {
@@ -1091,7 +1298,7 @@ export class SynomemStorage {
1091
1298
  legacy.kudosId ??
1092
1299
  legacy.memoId ??
1093
1300
  legacy.noteId ??
1094
- legacy.todoId ??
1301
+ legacy.taskId ??
1095
1302
  (typeof legacy.agentId === 'string' ? legacy.agentId : undefined) ??
1096
1303
  (typeof legacy.agent === 'object' && legacy.agent !== null
1097
1304
  ? legacy.agent.id
@@ -1125,9 +1332,7 @@ export class SynomemStorage {
1125
1332
  this.db()
1126
1333
  .prepare('INSERT INTO agents(id, display_name, profile_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)')
1127
1334
  .run(parsed.id, parsed.displayName, JSON.stringify(parsed), parsed.createdAt, parsed.createdAt);
1128
- for (const alias of parsed.aliases ?? []) {
1129
- this.db().prepare('INSERT INTO aliases(alias, agent_id) VALUES (?, ?)').run(alias, parsed.id);
1130
- }
1335
+ this.insertAliases(parsed.id, parsed.aliases ?? []);
1131
1336
  }
1132
1337
  updateAgent(profile, updatedAt) {
1133
1338
  const parsed = profileSchema.parse(profile);
@@ -1135,20 +1340,145 @@ export class SynomemStorage {
1135
1340
  .prepare('UPDATE agents SET display_name = ?, profile_json = ?, updated_at = ? WHERE id = ?')
1136
1341
  .run(parsed.displayName, JSON.stringify(parsed), updatedAt, parsed.id);
1137
1342
  this.db().prepare('DELETE FROM aliases WHERE agent_id = ?').run(parsed.id);
1138
- for (const alias of parsed.aliases ?? []) {
1139
- this.db().prepare('INSERT INTO aliases(alias, agent_id) VALUES (?, ?)').run(alias, parsed.id);
1140
- }
1343
+ this.insertAliases(parsed.id, parsed.aliases ?? []);
1141
1344
  }
1142
1345
  getAgent(idOrAlias) {
1143
- const direct = this.db()
1144
- .prepare('SELECT profile_json FROM agents WHERE id = ?')
1145
- .get(idOrAlias);
1146
- if (direct)
1147
- return profileSchema.parse(JSON.parse(direct.profile_json));
1148
- const alias = this.db()
1149
- .prepare('SELECT a.profile_json FROM agents a JOIN aliases x ON x.agent_id = a.id WHERE x.alias = ?')
1150
- .get(idOrAlias);
1151
- return alias ? profileSchema.parse(JSON.parse(alias.profile_json)) : undefined;
1346
+ const resolved = this.resolveAgent(idOrAlias);
1347
+ return resolved.match;
1348
+ }
1349
+ /**
1350
+ * Resolves a name to a canonical agent, reporting ambiguity rather than
1351
+ * guessing.
1352
+ *
1353
+ * The plan is specific: `mycroft`, `Mycroft` and `Mike` may all resolve to one
1354
+ * agent, but a name two visible agents both claim must return candidates. An
1355
+ * alias that collides with a different agent's canonical ID is exactly that
1356
+ * case — silently preferring the ID would attribute work to the wrong agent,
1357
+ * and the person who typed the name would never know.
1358
+ */
1359
+ resolveAgent(query) {
1360
+ const normalized = query.trim().toLowerCase();
1361
+ const rows = this.db()
1362
+ .prepare(`SELECT DISTINCT a.profile_json
1363
+ FROM agents a
1364
+ LEFT JOIN aliases x ON x.agent_id = a.id
1365
+ WHERE lower(a.id) = ? OR x.normalized_alias = ?
1366
+ ORDER BY a.id ASC`)
1367
+ .all(normalized, normalized);
1368
+ const candidates = rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
1369
+ return candidates.length === 1 ? { match: candidates[0], candidates } : { candidates };
1370
+ }
1371
+ /* -------------------------------------------------------- post acknowledgment */
1372
+ listPostAcknowledgments(postId) {
1373
+ const rows = this.db()
1374
+ .prepare(`SELECT actor_kind, actor_id, actor_display_name, note, acknowledged_at
1375
+ FROM post_acknowledgments WHERE post_id = ? ORDER BY acknowledged_at ASC`)
1376
+ .all(postId);
1377
+ return rows.map((row) => ({
1378
+ actor: {
1379
+ kind: row.actor_kind,
1380
+ id: row.actor_id,
1381
+ ...(row.actor_display_name ? { displayName: row.actor_display_name } : {}),
1382
+ },
1383
+ acknowledgedAt: row.acknowledged_at,
1384
+ ...(row.note ? { note: row.note } : {}),
1385
+ }));
1386
+ }
1387
+ /**
1388
+ * Who has acknowledged a post and who has not.
1389
+ *
1390
+ * The denominator is agents that existed when the post was written. An agent
1391
+ * created afterwards is counted separately rather than listed as
1392
+ * outstanding — it was not there, and a roster that says otherwise accuses a
1393
+ * newcomer of ignoring something written before it arrived.
1394
+ */
1395
+ postRoster(postId) {
1396
+ const post = this.db()
1397
+ .prepare("SELECT created_at FROM items_current WHERE item_id = ? AND kind = 'post'")
1398
+ .get(postId);
1399
+ if (!post)
1400
+ return undefined;
1401
+ const acknowledged = this.listPostAcknowledgments(postId);
1402
+ const acknowledgedIds = new Set(acknowledged.map((entry) => entry.actor.id));
1403
+ const eligible = this.db()
1404
+ .prepare('SELECT id, display_name, created_at FROM agents ORDER BY id ASC')
1405
+ .all();
1406
+ const outstanding = eligible
1407
+ .filter((agent) => agent.created_at <= post.created_at && !acknowledgedIds.has(agent.id))
1408
+ .map((agent) => ({ id: agent.id, displayName: agent.display_name }));
1409
+ const joinedSince = eligible.filter((agent) => agent.created_at > post.created_at).length;
1410
+ return { postId, acknowledged, outstanding, joinedSince };
1411
+ }
1412
+ /* ------------------------------------------------------- runtime bindings */
1413
+ listRuntimeBindings(agentId) {
1414
+ const rows = this.db()
1415
+ .prepare(`SELECT id, agent_id, installation_id, runtime, profile, capabilities_json,
1416
+ bound_at, last_seen_at
1417
+ FROM agent_runtime_bindings WHERE agent_id = ? ORDER BY bound_at ASC`)
1418
+ .all(agentId);
1419
+ return rows.map((row) => ({
1420
+ id: row.id,
1421
+ agentId: row.agent_id,
1422
+ ...(row.installation_id ? { installationId: row.installation_id } : {}),
1423
+ runtime: row.runtime,
1424
+ ...(row.profile ? { profile: row.profile } : {}),
1425
+ capabilities: JSON.parse(row.capabilities_json),
1426
+ boundAt: row.bound_at,
1427
+ ...(row.last_seen_at ? { lastSeenAt: row.last_seen_at } : {}),
1428
+ }));
1429
+ }
1430
+ bindRuntime(binding) {
1431
+ this.db()
1432
+ .prepare(`INSERT INTO agent_runtime_bindings(
1433
+ id, agent_id, installation_id, runtime, profile, capabilities_json, bound_at
1434
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
1435
+ ON CONFLICT(agent_id, runtime, COALESCE(profile, ''), COALESCE(installation_id, ''))
1436
+ DO UPDATE SET capabilities_json = excluded.capabilities_json`)
1437
+ .run(binding.id, binding.agentId, binding.installationId ?? null, binding.runtime, binding.profile ?? null, JSON.stringify(binding.capabilities ?? {}), binding.boundAt);
1438
+ }
1439
+ unbindRuntime(bindingId) {
1440
+ const result = this.db()
1441
+ .prepare('DELETE FROM agent_runtime_bindings WHERE id = ?')
1442
+ .run(bindingId);
1443
+ return Number(result.changes) > 0;
1444
+ }
1445
+ /**
1446
+ * Advisory only. Records that Synomem observed this binding act — never that
1447
+ * the runtime is reachable now, and never that a delivery succeeded.
1448
+ */
1449
+ touchRuntimeBinding(agentId, runtime, at) {
1450
+ this.db()
1451
+ .prepare('UPDATE agent_runtime_bindings SET last_seen_at = ? WHERE agent_id = ? AND runtime = ?')
1452
+ .run(at, agentId, runtime);
1453
+ }
1454
+ /**
1455
+ * Writes an agent's aliases, refusing any that would make a name ambiguous.
1456
+ *
1457
+ * Two collisions matter and both are rejected here rather than at lookup: an
1458
+ * alias another agent already claims, and an alias equal to a different
1459
+ * agent's canonical ID. Catching them at write means the person adding the
1460
+ * alias sees the conflict, instead of a later reader silently getting one of
1461
+ * two possible agents.
1462
+ */
1463
+ insertAliases(agentId, aliases) {
1464
+ for (const alias of aliases) {
1465
+ const normalized = alias.trim().toLowerCase();
1466
+ const conflictingAgent = this.db()
1467
+ .prepare('SELECT id FROM agents WHERE lower(id) = ? AND id != ?')
1468
+ .get(normalized, agentId);
1469
+ if (conflictingAgent) {
1470
+ throw new SynomemError('ALIAS_CONFLICT', `Alias "${alias}" is already the canonical ID of agent ${conflictingAgent.id}. Aliases must resolve to exactly one agent.`);
1471
+ }
1472
+ const conflictingAlias = this.db()
1473
+ .prepare('SELECT agent_id FROM aliases WHERE normalized_alias = ? AND agent_id != ?')
1474
+ .get(normalized, agentId);
1475
+ if (conflictingAlias) {
1476
+ throw new SynomemError('ALIAS_CONFLICT', `Alias "${alias}" already belongs to agent ${conflictingAlias.agent_id}. Aliases must resolve to exactly one agent.`);
1477
+ }
1478
+ this.db()
1479
+ .prepare('INSERT INTO aliases(alias, agent_id, normalized_alias) VALUES (?, ?, ?)')
1480
+ .run(alias, agentId, normalized);
1481
+ }
1152
1482
  }
1153
1483
  listAgents() {
1154
1484
  const rows = this.db()