synomem 0.2.0 → 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 (46) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +99 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/client.d.ts +36 -1
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +160 -4
  8. package/dist/client.js.map +1 -1
  9. package/dist/import.d.ts +149 -0
  10. package/dist/import.d.ts.map +1 -1
  11. package/dist/mcp/index.d.ts.map +1 -1
  12. package/dist/mcp/index.js +57 -0
  13. package/dist/mcp/index.js.map +1 -1
  14. package/dist/ports/repository.d.ts +4 -1
  15. package/dist/ports/repository.d.ts.map +1 -1
  16. package/dist/projections.d.ts +9 -1
  17. package/dist/projections.d.ts.map +1 -1
  18. package/dist/projections.js +71 -0
  19. package/dist/projections.js.map +1 -1
  20. package/dist/remote.d.ts +26 -1
  21. package/dist/remote.d.ts.map +1 -1
  22. package/dist/remote.js +10 -0
  23. package/dist/remote.js.map +1 -1
  24. package/dist/schemas.d.ts +174 -0
  25. package/dist/schemas.d.ts.map +1 -1
  26. package/dist/schemas.js +63 -1
  27. package/dist/schemas.js.map +1 -1
  28. package/dist/service.d.ts +26 -1
  29. package/dist/service.d.ts.map +1 -1
  30. package/dist/storage.d.ts +11 -1
  31. package/dist/storage.d.ts.map +1 -1
  32. package/dist/storage.js +116 -4
  33. package/dist/storage.js.map +1 -1
  34. package/dist/types.d.ts +99 -2
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/cli.ts +166 -0
  38. package/src/client.ts +198 -2
  39. package/src/mcp/index.ts +76 -0
  40. package/src/ports/repository.ts +5 -0
  41. package/src/projections.ts +70 -0
  42. package/src/remote.ts +48 -0
  43. package/src/schemas.ts +65 -1
  44. package/src/service.ts +26 -0
  45. package/src/storage.ts +150 -4
  46. package/src/types.ts +105 -1
package/src/storage.ts CHANGED
@@ -31,6 +31,8 @@ import type {
31
31
  AgentProfile,
32
32
  AgentRuntimeBinding,
33
33
  ChangePage,
34
+ PostAcknowledgment,
35
+ PostRoster,
34
36
  JsonValue,
35
37
  KudosChange,
36
38
  SynomemEvent,
@@ -237,6 +239,22 @@ CREATE UNIQUE INDEX agent_runtime_bindings_unique
237
239
  CREATE INDEX agent_runtime_bindings_agent ON agent_runtime_bindings(agent_id);
238
240
  `;
239
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
+
240
258
  const migrationV3 = `
241
259
  DROP TRIGGER IF EXISTS events_append_only_update;
242
260
  DROP TRIGGER IF EXISTS events_append_only_delete;
@@ -365,6 +383,7 @@ function eventKind(event: SynomemEvent): RecordKind | undefined {
365
383
  if (event.type.startsWith('kudos.')) return 'kudos';
366
384
  if (event.type.startsWith('memo.')) return 'memo';
367
385
  if (event.type.startsWith('note.')) return 'note';
386
+ if (event.type.startsWith('post.')) return 'post';
368
387
  if (event.type.startsWith('task.')) return 'task';
369
388
  if (event.type.startsWith('todo.')) return 'todo';
370
389
  return undefined;
@@ -499,7 +518,7 @@ export class SynomemStorage implements SynomemRepository {
499
518
  const version = Number(
500
519
  (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
501
520
  );
502
- if (version > 5) {
521
+ if (version > 6) {
503
522
  throw new SynomemError(
504
523
  'UNSUPPORTED_SCHEMA',
505
524
  `Database schema version ${version} is newer than this package supports.`,
@@ -567,14 +586,26 @@ export class SynomemStorage implements SynomemRepository {
567
586
  db.exec('PRAGMA user_version = 5');
568
587
  });
569
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
+ }
570
601
  }
571
602
 
572
603
  private assertSchemaSupported(): void {
573
604
  const version = Number(
574
605
  (this.db().prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
575
606
  );
576
- if (version !== 5) {
577
- if (version >= 1 && version <= 4) {
607
+ if (version !== 6) {
608
+ if (version >= 1 && version <= 5) {
578
609
  throw new SynomemError(
579
610
  'UNSUPPORTED_SCHEMA',
580
611
  `Database schema version ${version} requires migration. Open this home once with readOnly: false, then retry the read-only client.`,
@@ -835,6 +866,61 @@ export class SynomemStorage implements SynomemRepository {
835
866
  ownerAgentId: event.ownerAgentId,
836
867
  ownerDisplayName: event.ownerDisplayName,
837
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);
838
924
  } else if (event.type === 'note.revised') {
839
925
  this.db()
840
926
  .prepare(
@@ -1418,7 +1504,7 @@ export class SynomemStorage implements SynomemRepository {
1418
1504
  this.db()
1419
1505
  .prepare(
1420
1506
  `SELECT COUNT(*) AS count FROM events WHERE type IN
1421
- ('kudos.given', 'memo.sent', 'note.created', 'task.created')`,
1507
+ ('kudos.given', 'memo.sent', 'note.created', 'post.created', 'task.created')`,
1422
1508
  )
1423
1509
  .get() as { count: number }
1424
1510
  ).count,
@@ -1571,6 +1657,11 @@ export class SynomemStorage implements SynomemRepository {
1571
1657
  'note.created',
1572
1658
  'note.revised',
1573
1659
  'note.archived',
1660
+ 'post.created',
1661
+ 'post.edited',
1662
+ 'post.archived',
1663
+ 'post.acknowledged',
1664
+ 'post.acknowledgment.withdrawn',
1574
1665
  'task.created',
1575
1666
  'task.updated',
1576
1667
  'task.completed',
@@ -1687,6 +1778,61 @@ export class SynomemStorage implements SynomemRepository {
1687
1778
  return candidates.length === 1 ? { match: candidates[0]!, candidates } : { candidates };
1688
1779
  }
1689
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
+
1690
1836
  /* ------------------------------------------------------- runtime bindings */
1691
1837
 
1692
1838
  listRuntimeBindings(agentId: string): AgentRuntimeBinding[] {
package/src/types.ts CHANGED
@@ -3,7 +3,7 @@ export type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue
3
3
 
4
4
  export type ActorKind = 'human' | 'agent' | 'system';
5
5
  export type Visibility = 'private' | 'workspace' | 'public';
6
- export type RecordKind = 'kudos' | 'memo' | 'note' | 'task' | 'todo';
6
+ export type RecordKind = 'kudos' | 'memo' | 'note' | 'post' | 'task' | 'todo';
7
7
 
8
8
  export interface ActorIdentity {
9
9
  kind: ActorKind;
@@ -140,6 +140,54 @@ export interface MemoArchivedEvent extends BaseEvent {
140
140
  recipientAgentId: string;
141
141
  }
142
142
 
143
+ /**
144
+ * A Post is publication: an author says something to the whole workspace rather
145
+ * than to a named recipient.
146
+ *
147
+ * It has no recipient and no assignee, which is what separates it from a memo
148
+ * and a task. Everyone who can read the workspace can read it, and each of them
149
+ * can acknowledge it independently — a memo is read by one person, a post by
150
+ * many, so "who has responded" is the interesting question rather than "was it
151
+ * read".
152
+ */
153
+ export interface PostCreatedEvent extends BaseEvent {
154
+ type: 'post.created';
155
+ title: string;
156
+ body: string;
157
+ tags?: string[];
158
+ /** The post this replies to. A reply inherits its parent's workspace. */
159
+ replyTo?: string;
160
+ }
161
+ export interface PostEditedEvent extends BaseEvent {
162
+ type: 'post.edited';
163
+ postId: string;
164
+ title: string;
165
+ body: string;
166
+ tags?: string[];
167
+ }
168
+ export interface PostArchivedEvent extends BaseEvent {
169
+ type: 'post.archived';
170
+ postId: string;
171
+ reason?: string;
172
+ }
173
+ /**
174
+ * One actor saying "I have seen this", and only ever about themselves.
175
+ *
176
+ * Appended by an explicit call. Reading a post never acknowledges it: a roster
177
+ * built from read receipts would answer "whose client fetched this", which for
178
+ * an agent means "whose runtime happened to poll".
179
+ */
180
+ export interface PostAcknowledgedEvent extends BaseEvent {
181
+ type: 'post.acknowledged';
182
+ postId: string;
183
+ note?: string;
184
+ }
185
+ export interface PostAcknowledgmentWithdrawnEvent extends BaseEvent {
186
+ type: 'post.acknowledgment.withdrawn';
187
+ postId: string;
188
+ reason?: string;
189
+ }
190
+
143
191
  export interface NoteCreatedEvent extends BaseEvent {
144
192
  type: 'note.created';
145
193
  ownerAgentId: string;
@@ -322,6 +370,11 @@ export type SynomemEvent =
322
370
  | MemoSentEvent
323
371
  | MemoReadEvent
324
372
  | MemoArchivedEvent
373
+ | PostCreatedEvent
374
+ | PostEditedEvent
375
+ | PostArchivedEvent
376
+ | PostAcknowledgedEvent
377
+ | PostAcknowledgmentWithdrawnEvent
325
378
  | NoteCreatedEvent
326
379
  | NoteRevisedEvent
327
380
  | NoteArchivedEvent
@@ -356,6 +409,40 @@ export interface MemoRecord {
356
409
  archived?: MemoArchivedEvent;
357
410
  status: 'unread' | 'read' | 'archived';
358
411
  }
412
+ export interface PostAcknowledgment {
413
+ actor: ActorIdentity;
414
+ acknowledgedAt: string;
415
+ note?: string;
416
+ }
417
+ export interface PostRecord {
418
+ event: PostCreatedEvent;
419
+ edits: PostEditedEvent[];
420
+ archived?: PostArchivedEvent;
421
+ /** Everyone who has said they have seen it, in the order they said so. */
422
+ acknowledgments: PostAcknowledgment[];
423
+ status: 'active' | 'archived';
424
+ title: string;
425
+ body: string;
426
+ tags?: string[];
427
+ version: number;
428
+ }
429
+
430
+ /**
431
+ * Who has acknowledged a post, and who has not.
432
+ *
433
+ * `outstanding` is the honest denominator: actors who can read the post now AND
434
+ * could have read it when it was posted. Someone who joined afterwards is
435
+ * neither acknowledged nor outstanding — they were not there — and is reported
436
+ * as a count instead, so a roster never accuses a newcomer of ignoring
437
+ * something written before they arrived.
438
+ */
439
+ export interface PostRoster {
440
+ postId: string;
441
+ acknowledged: PostAcknowledgment[];
442
+ outstanding: Array<{ id: string; displayName: string }>;
443
+ joinedSince: number;
444
+ }
445
+
359
446
  export interface NoteRecord {
360
447
  event: NoteCreatedEvent;
361
448
  revision?: NoteRevisedEvent;
@@ -529,6 +616,23 @@ export interface SendMemoInput extends MutationInput {
529
616
  tags?: string[];
530
617
  visibility?: Visibility;
531
618
  }
619
+ export interface CreatePostInput extends MutationInput {
620
+ title: string;
621
+ body: string;
622
+ tags?: string[];
623
+ /** The post being replied to; a reply inherits its parent's workspace. */
624
+ replyTo?: string;
625
+ }
626
+
627
+ export interface UpdatePostInput {
628
+ postId: string;
629
+ expectedVersion: number;
630
+ title?: string;
631
+ body?: string;
632
+ tags?: string[];
633
+ idempotencyKey?: string;
634
+ }
635
+
532
636
  export interface CreateNoteInput extends MutationInput {
533
637
  ownerAgentId?: string;
534
638
  title: string;