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/client.js CHANGED
@@ -4,8 +4,8 @@ import { ulid } from 'ulid';
4
4
  import { resolveHome } from './config.js';
5
5
  import { asSynomemError, SynomemError } from './errors.js';
6
6
  import { assertNoSymlinkEscape } from './fs-utils.js';
7
- import { escapeMarkdown, memoRecordsFromEvents, noteRecordsFromEvents, ProjectionManager, recordsFromEvents, todoRecordsFromEvents, } from './projections.js';
8
- import { actorSchema, agentIdSchema, createAgentSchema, createNoteSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTodoSchema, updateAgentSchema, } from './schemas.js';
7
+ import { escapeMarkdown, memoRecordsFromEvents, noteRecordsFromEvents, postRecordsFromEvents, ProjectionManager, recordsFromEvents, taskRecordsFromEvents, todoRecordsFromEvents, } from './projections.js';
8
+ import { actorSchema, agentLookupSchema, bindRuntimeSchema, createAgentSchema, createPostSchema, updatePostSchema, createNoteSchema, createTaskSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTaskSchema, updateTodoSchema, updateAgentSchema, } from './schemas.js';
9
9
  import { SynomemStorage } from './storage.js';
10
10
  export class SynomemCore {
11
11
  actor;
@@ -21,6 +21,11 @@ export class SynomemCore {
21
21
  update: (id, changes) => this.updateAgent(id, changes),
22
22
  get: (idOrAlias) => this.getAgent(idOrAlias),
23
23
  list: () => this.listAgents(),
24
+ resolve: (query) => this.resolveAgent(query),
25
+ directory: () => this.agentDirectory(),
26
+ bindings: (idOrAlias) => this.listRuntimeBindings(idOrAlias),
27
+ bindRuntime: (input) => this.bindRuntime(input),
28
+ unbindRuntime: (bindingId) => this.unbindRuntime(bindingId),
24
29
  };
25
30
  kudos = {
26
31
  give: (input) => this.giveKudos(input),
@@ -37,6 +42,22 @@ export class SynomemCore {
37
42
  read: (input) => this.readMemo(input),
38
43
  archive: (input) => this.archiveMemo(input),
39
44
  };
45
+ posts = {
46
+ create: (input) => this.createPost(input),
47
+ list: (input = {}) => this.listItems({ ...input, kinds: ['post'] }),
48
+ get: (id) => this.getPost(id),
49
+ update: (input) => this.updatePost(input),
50
+ archive: (input) => this.archivePost(input),
51
+ /*
52
+ * Acknowledging is an explicit call and always speaks for the caller alone.
53
+ * There is no bulk form and no acknowledge-on-behalf-of: an acknowledgement
54
+ * is one actor saying "I have seen this", and reading a post must never
55
+ * append one, or the roster stops meaning anything.
56
+ */
57
+ acknowledge: (input) => this.acknowledgePost(input),
58
+ withdrawAcknowledgment: (input) => this.withdrawPostAcknowledgment(input),
59
+ roster: (postId) => this.postRoster(postId),
60
+ };
40
61
  notes = {
41
62
  create: (input) => this.createNote(input),
42
63
  list: (input = {}) => this.listItems({ ...input, kinds: ['note'] }),
@@ -44,16 +65,59 @@ export class SynomemCore {
44
65
  revise: (input) => this.reviseNote(input),
45
66
  archive: (input) => this.archiveNote(input),
46
67
  };
68
+ tasks = {
69
+ create: (input) => this.createTask(input),
70
+ list: (input = {}) => this.listItems({ ...input, kinds: ['task'] }),
71
+ get: (id) => this.getTask(id),
72
+ update: (input) => this.updateTask(input),
73
+ // A response is optional when accepting and required when rejecting: a
74
+ // refusal without a reason leaves the assigner unable to act on it.
75
+ accept: (input) => this.acceptTask(input),
76
+ reject: (input) => this.rejectTask(input),
77
+ complete: (input) => this.completeTask(input),
78
+ reopen: (input) => this.reopenTask(input),
79
+ cancel: (input) => this.cancelTask(input),
80
+ };
47
81
  todos = {
48
82
  create: (input) => this.createTodo(input),
49
83
  list: (input = {}) => this.listItems({ ...input, kinds: ['todo'] }),
50
84
  get: (id) => this.getTodo(id),
51
85
  update: (input) => this.updateTodo(input),
52
- accept: (input) => this.acceptTodo(input),
53
- reject: (input) => this.rejectTodo(input),
54
- complete: (input) => this.completeTodo(input),
55
- reopen: (input) => this.reopenTodo(input),
56
- cancel: (input) => this.cancelTodo(input),
86
+ complete: (input) => this.todoTransition(input, 'todo.completed'),
87
+ reopen: (input) => this.todoTransition(input, 'todo.reopened'),
88
+ cancel: (input) => this.todoTransition(input, 'todo.canceled'),
89
+ archive: (input) => this.todoTransition(input, 'todo.archived'),
90
+ };
91
+ /**
92
+ * Unanswered and overdue discovery.
93
+ *
94
+ * The plan asks for shared work to be observable without treating runtime
95
+ * metadata as a delivery guarantee. These are query states derived from
96
+ * durable events: they say nobody has answered yet, not that the agent was
97
+ * offline, missed a notification, or lacks a capability it claimed.
98
+ */
99
+ discovery = {
100
+ /**
101
+ * Tasks awaiting acceptance, unread memos, and unacknowledged kudos —
102
+ * optionally only those older than a given age or instant.
103
+ */
104
+ unanswered: (input = {}) => {
105
+ const { olderThanHours, awaitingSince, ...rest } = input;
106
+ const since = awaitingSince ??
107
+ (olderThanHours !== undefined
108
+ ? new Date(Date.now() - olderThanHours * 3_600_000).toISOString()
109
+ : undefined);
110
+ return this.listItems({
111
+ ...rest,
112
+ awaitingResponse: true,
113
+ ...(since ? { awaitingSince: since } : {}),
114
+ });
115
+ },
116
+ /** Open work whose deadline has passed. Defaults to "now". */
117
+ overdue: (input = {}) => {
118
+ const { asOf, ...rest } = input;
119
+ return this.listItems({ ...rest, overdueAsOf: asOf ?? new Date().toISOString() });
120
+ },
57
121
  };
58
122
  items = {
59
123
  list: (input = {}) => this.listItems(input),
@@ -152,7 +216,7 @@ export class SynomemCore {
152
216
  async updateAgent(idOrAlias, changes) {
153
217
  this.checkAbort();
154
218
  await this.repository.assertEventCompatibility();
155
- this.validate(() => agentIdSchema.parse(idOrAlias));
219
+ this.validate(() => agentLookupSchema.parse(idOrAlias));
156
220
  const parsed = this.validate(() => updateAgentSchema.parse(changes));
157
221
  const existing = await this.repository.getAgent(idOrAlias);
158
222
  if (!existing)
@@ -188,7 +252,7 @@ export class SynomemCore {
188
252
  }
189
253
  async getAgent(idOrAlias) {
190
254
  this.checkAbort();
191
- this.validate(() => agentIdSchema.parse(idOrAlias));
255
+ this.validate(() => agentLookupSchema.parse(idOrAlias));
192
256
  const profile = await this.repository.getAgent(idOrAlias);
193
257
  if (!profile)
194
258
  throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
@@ -198,6 +262,71 @@ export class SynomemCore {
198
262
  this.checkAbort();
199
263
  return await this.repository.listAgents();
200
264
  }
265
+ /**
266
+ * Resolves a name without ever choosing between equally valid answers.
267
+ *
268
+ * Callers that want a single agent should treat an empty `match` as a
269
+ * question for the user, not as "not found": `candidates` distinguishes the
270
+ * two cases.
271
+ */
272
+ async resolveAgent(query) {
273
+ this.checkAbort();
274
+ const trimmed = query.trim();
275
+ if (!trimmed)
276
+ throw new SynomemError('INVALID_INPUT', 'A lookup name is required.');
277
+ const resolved = await this.repository.resolveAgent(trimmed);
278
+ return {
279
+ query: trimmed,
280
+ ...(resolved.match ? { match: resolved.match } : {}),
281
+ candidates: resolved.candidates,
282
+ };
283
+ }
284
+ async agentDirectory() {
285
+ this.checkAbort();
286
+ const profiles = await this.repository.listAgents();
287
+ const entries = [];
288
+ for (const profile of profiles) {
289
+ entries.push({
290
+ profile,
291
+ runtimeBindings: await this.repository.listRuntimeBindings(profile.id),
292
+ });
293
+ }
294
+ return entries;
295
+ }
296
+ async listRuntimeBindings(idOrAlias) {
297
+ const profile = await this.getAgent(idOrAlias);
298
+ return await this.repository.listRuntimeBindings(profile.id);
299
+ }
300
+ /**
301
+ * Records where an agent runs. Re-binding the same runtime and profile
302
+ * updates the claim in place rather than accumulating duplicates, because a
303
+ * reinstall is the same agent in the same place, not a second one.
304
+ */
305
+ async bindRuntime(input) {
306
+ this.checkAbort();
307
+ const parsed = this.validate(() => bindRuntimeSchema.parse(input));
308
+ const profile = await this.getAgent(parsed.agentId);
309
+ await this.repository.bindRuntime({
310
+ id: this.idGenerator(),
311
+ agentId: profile.id,
312
+ ...(parsed.installationId !== undefined ? { installationId: parsed.installationId } : {}),
313
+ runtime: parsed.runtime,
314
+ ...(parsed.profile !== undefined ? { profile: parsed.profile } : {}),
315
+ ...(parsed.capabilities !== undefined ? { capabilities: parsed.capabilities } : {}),
316
+ boundAt: this.now(),
317
+ });
318
+ const bindings = await this.repository.listRuntimeBindings(profile.id);
319
+ const binding = bindings.find((candidate) => candidate.runtime === parsed.runtime &&
320
+ (candidate.profile ?? '') === (parsed.profile ?? '') &&
321
+ (candidate.installationId ?? '') === (parsed.installationId ?? ''));
322
+ if (!binding)
323
+ throw new SynomemError('INTERNAL_ERROR', 'Runtime binding was not persisted.');
324
+ return binding;
325
+ }
326
+ async unbindRuntime(bindingId) {
327
+ this.checkAbort();
328
+ return await this.repository.unbindRuntime(bindingId);
329
+ }
201
330
  async giveKudos(input) {
202
331
  this.checkAbort();
203
332
  await this.repository.assertEventCompatibility();
@@ -351,7 +480,7 @@ export class SynomemCore {
351
480
  ? 'MEMO_NOT_FOUND'
352
481
  : kind === 'note'
353
482
  ? 'NOTE_NOT_FOUND'
354
- : kind === 'todo'
483
+ : kind === 'task'
355
484
  ? 'TODO_NOT_FOUND'
356
485
  : 'KUDOS_NOT_FOUND';
357
486
  throw new SynomemError(code, `Unknown ${kind}: ${id}`);
@@ -456,6 +585,146 @@ export class SynomemCore {
456
585
  await this.projectionWriter.syncAgent(record.event.recipientAgentId);
457
586
  return await this.getMemoRecord(input.memoId);
458
587
  }
588
+ async getPostRecord(id) {
589
+ await this.requireVisibleItem(id, 'post');
590
+ const record = postRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
591
+ if (!record)
592
+ throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${id}`);
593
+ return record;
594
+ }
595
+ async getPost(id) {
596
+ this.checkAbort();
597
+ return await this.getPostRecord(id);
598
+ }
599
+ async createPost(input) {
600
+ this.checkAbort();
601
+ await this.repository.assertEventCompatibility();
602
+ const parsed = this.validate(() => createPostSchema.parse(input));
603
+ // A reply inherits its parent's workspace by construction, and cannot name
604
+ // a different target — there is no target to name.
605
+ if (parsed.replyTo)
606
+ await this.requireVisibleItem(parsed.replyTo, 'post');
607
+ const outcome = await this.repository.transaction(async () => {
608
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'post.created');
609
+ if (prior?.type === 'post.created')
610
+ return { id: prior.id, created: false };
611
+ const id = this.nextId();
612
+ const event = {
613
+ ...this.eventBase(id, 1, id),
614
+ type: 'post.created',
615
+ title: parsed.title,
616
+ body: parsed.body,
617
+ tags: [...new Set(parsed.tags ?? [])].sort(),
618
+ ...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
619
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
620
+ ...(parsed.source ? { source: parsed.source } : {}),
621
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
622
+ };
623
+ await this.repository.insertEvent(event);
624
+ return { id, created: true };
625
+ });
626
+ return {
627
+ record: await this.getPostRecord(outcome.id),
628
+ created: outcome.created,
629
+ deduplicated: !outcome.created,
630
+ };
631
+ }
632
+ /** Only the author edits a post. Everyone else responds to it. */
633
+ assertPostAuthor(record) {
634
+ if (this.administrative)
635
+ return;
636
+ if (record.event.actor.id !== this.actor.id || record.event.actor.kind !== this.actor.kind) {
637
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the author can change a post.');
638
+ }
639
+ }
640
+ async updatePost(input) {
641
+ this.checkAbort();
642
+ const parsed = this.validate(() => updatePostSchema.parse(input));
643
+ const record = await this.getPostRecord(parsed.postId);
644
+ this.assertPostAuthor(record);
645
+ if (record.status === 'archived') {
646
+ throw new SynomemError('MUTATION_FORBIDDEN', 'An archived post cannot be edited.');
647
+ }
648
+ if (record.version !== parsed.expectedVersion) {
649
+ throw new SynomemError('REVISION_CONFLICT', `Post ${parsed.postId} is at version ${record.version}.`);
650
+ }
651
+ await this.repository.transaction(async () => {
652
+ const event = {
653
+ ...this.eventBase(parsed.postId, await this.repository.nextAggregateVersion(parsed.postId)),
654
+ type: 'post.edited',
655
+ postId: parsed.postId,
656
+ title: parsed.title ?? record.title,
657
+ body: parsed.body ?? record.body,
658
+ tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
659
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
660
+ };
661
+ await this.repository.insertEvent(event);
662
+ });
663
+ return await this.getPostRecord(parsed.postId);
664
+ }
665
+ async archivePost(input) {
666
+ this.checkAbort();
667
+ const record = await this.getPostRecord(input.postId);
668
+ this.assertPostAuthor(record);
669
+ if (record.status !== 'archived') {
670
+ await this.repository.transaction(async () => {
671
+ const event = {
672
+ ...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
673
+ type: 'post.archived',
674
+ postId: input.postId,
675
+ ...(input.reason ? { reason: input.reason } : {}),
676
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
677
+ };
678
+ await this.repository.insertEvent(event);
679
+ });
680
+ }
681
+ return await this.getPostRecord(input.postId);
682
+ }
683
+ async acknowledgePost(input) {
684
+ this.checkAbort();
685
+ const record = await this.getPostRecord(input.postId);
686
+ // Acknowledging twice is the same statement, so the second is a no-op
687
+ // rather than a second row or an error.
688
+ const already = record.acknowledgments.some((entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind);
689
+ if (!already) {
690
+ await this.repository.transaction(async () => {
691
+ const event = {
692
+ ...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
693
+ type: 'post.acknowledged',
694
+ postId: input.postId,
695
+ ...(input.note ? { note: input.note } : {}),
696
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
697
+ };
698
+ await this.repository.insertEvent(event);
699
+ });
700
+ }
701
+ return await this.getPostRecord(input.postId);
702
+ }
703
+ async withdrawPostAcknowledgment(input) {
704
+ this.checkAbort();
705
+ const record = await this.getPostRecord(input.postId);
706
+ const mine = record.acknowledgments.some((entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind);
707
+ if (mine) {
708
+ await this.repository.transaction(async () => {
709
+ const event = {
710
+ ...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
711
+ type: 'post.acknowledgment.withdrawn',
712
+ postId: input.postId,
713
+ ...(input.reason ? { reason: input.reason } : {}),
714
+ };
715
+ await this.repository.insertEvent(event);
716
+ });
717
+ }
718
+ return await this.getPostRecord(input.postId);
719
+ }
720
+ async postRoster(postId) {
721
+ this.checkAbort();
722
+ await this.requireVisibleItem(postId, 'post');
723
+ const roster = await this.repository.postRoster(postId);
724
+ if (!roster)
725
+ throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${postId}`);
726
+ return roster;
727
+ }
459
728
  async getNoteRecord(id) {
460
729
  await this.requireVisibleItem(id, 'note');
461
730
  const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
@@ -567,40 +836,40 @@ export class SynomemCore {
567
836
  await this.projectionWriter.syncAgent(record.event.ownerAgentId);
568
837
  return await this.getNoteRecord(input.noteId);
569
838
  }
570
- async getTodoRecord(id) {
571
- await this.requireVisibleItem(id, 'todo');
572
- const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
839
+ async getTaskRecord(id) {
840
+ await this.requireVisibleItem(id, 'task');
841
+ const record = taskRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
573
842
  if (!record)
574
- throw new SynomemError('TODO_NOT_FOUND', `Unknown todo: ${id}`);
843
+ throw new SynomemError('TODO_NOT_FOUND', `Unknown task: ${id}`);
575
844
  return record;
576
845
  }
577
- async getTodo(id) {
846
+ async getTask(id) {
578
847
  this.checkAbort();
579
- return await this.getTodoRecord(id);
848
+ return await this.getTaskRecord(id);
580
849
  }
581
- async createTodo(input) {
850
+ async createTask(input) {
582
851
  this.checkAbort();
583
852
  await this.repository.assertEventCompatibility();
584
- const parsed = this.validate(() => createTodoSchema.parse(input));
853
+ const parsed = this.validate(() => createTaskSchema.parse(input));
585
854
  const assigneeId = parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
586
855
  if (!assigneeId)
587
856
  throw new SynomemError('INVALID_INPUT', 'A human or system actor must specify assigneeAgentId.');
588
857
  const assignee = await this.repository.getAgent(assigneeId);
589
858
  if (!assignee)
590
- throw new SynomemError('AGENT_NOT_FOUND', `Unknown todo assignee: ${assigneeId}`);
859
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown task assignee: ${assigneeId}`);
591
860
  if (this.actor.kind === 'agent' &&
592
861
  this.actor.id !== assignee.id &&
593
- !this.repository.config.allowCrossAgentTodos)
594
- throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent todo assignment is disabled.');
862
+ !this.repository.config.allowCrossAgentTasks)
863
+ throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent task assignment is disabled.');
595
864
  const outcome = await this.repository.transaction(async () => {
596
- const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
597
- if (prior?.type === 'todo.created')
865
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'task.created');
866
+ if (prior?.type === 'task.created')
598
867
  return { id: prior.id, created: false };
599
868
  const id = this.nextId();
600
869
  const requiresAcceptance = this.actor.kind !== 'agent' || this.actor.id !== assignee.id;
601
870
  const event = {
602
871
  ...this.eventBase(id, 1, id),
603
- type: 'todo.created',
872
+ type: 'task.created',
604
873
  assigneeAgentId: assignee.id,
605
874
  assigneeDisplayName: assignee.displayName,
606
875
  title: parsed.title,
@@ -620,43 +889,43 @@ export class SynomemCore {
620
889
  if (outcome.created)
621
890
  await this.projectionWriter.syncAgent(assignee.id);
622
891
  return {
623
- record: await this.getTodoRecord(outcome.id),
892
+ record: await this.getTaskRecord(outcome.id),
624
893
  created: outcome.created,
625
894
  deduplicated: !outcome.created,
626
895
  };
627
896
  }
628
- assertTodoParticipant(record) {
897
+ assertTaskParticipant(record) {
629
898
  const isCreator = record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
630
899
  const isAssignee = this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId;
631
900
  if (!this.administrative && !isCreator && !isAssignee)
632
- throw new SynomemError('MUTATION_FORBIDDEN', 'Only the todo creator, assignee, or a human administrator may change it.');
901
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the task creator, assignee, or a human administrator may change it.');
633
902
  }
634
- assertTodoAssignee(record) {
903
+ assertTaskAssignee(record) {
635
904
  if (!this.administrative &&
636
905
  !(this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId)) {
637
- throw new SynomemError('MUTATION_FORBIDDEN', 'Only the assigned agent or a human administrator may accept or reject this todo.');
906
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the assigned agent or a human administrator may accept or reject this task.');
638
907
  }
639
908
  }
640
- async updateTodo(input) {
641
- const parsed = this.validate(() => updateTodoSchema.parse(input));
642
- const record = await this.getTodoRecord(parsed.todoId);
643
- this.assertTodoParticipant(record);
909
+ async updateTask(input) {
910
+ const parsed = this.validate(() => updateTaskSchema.parse(input));
911
+ const record = await this.getTaskRecord(parsed.taskId);
912
+ this.assertTaskParticipant(record);
644
913
  if (record.status !== 'open')
645
- throw new SynomemError('INVALID_INPUT', 'Only open todos can be updated.');
914
+ throw new SynomemError('INVALID_INPUT', 'Only open tasks can be updated.');
646
915
  if (parsed.expectedVersion !== record.current.version)
647
- throw new SynomemError('REVISION_CONFLICT', `Expected todo version ${parsed.expectedVersion}; current version is ${record.current.version}.`);
916
+ throw new SynomemError('REVISION_CONFLICT', `Expected task version ${parsed.expectedVersion}; current version is ${record.current.version}.`);
648
917
  await this.repository.transaction(async () => {
649
- const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
918
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'task.updated');
650
919
  if (prior)
651
920
  return;
652
921
  if ((await this.repository.nextAggregateVersion(record.event.id)) !==
653
922
  parsed.expectedVersion + 1)
654
- throw new SynomemError('REVISION_CONFLICT', 'The todo changed before this update was stored.');
923
+ throw new SynomemError('REVISION_CONFLICT', 'The task changed before this update was stored.');
655
924
  const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
656
925
  const event = {
657
926
  ...this.eventBase(record.event.id, parsed.expectedVersion + 1),
658
- type: 'todo.updated',
659
- todoId: record.event.id,
927
+ type: 'task.updated',
928
+ taskId: record.event.id,
660
929
  title: parsed.title ?? record.current.title,
661
930
  ...(parsed.description !== undefined
662
931
  ? { description: parsed.description }
@@ -674,35 +943,40 @@ export class SynomemCore {
674
943
  await this.repository.insertEvent(event);
675
944
  });
676
945
  await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
677
- return await this.getTodoRecord(parsed.todoId);
946
+ return await this.getTaskRecord(parsed.taskId);
678
947
  }
679
- async todoTransition(input, type) {
680
- const record = await this.getTodoRecord(input.todoId);
681
- if (type === 'todo.accepted' || type === 'todo.rejected')
682
- this.assertTodoAssignee(record);
948
+ async taskTransition(input, type) {
949
+ const record = await this.getTaskRecord(input.taskId);
950
+ // A rejection must say why. Enforced here as well as in the schema so the
951
+ // failure names the missing thing rather than surfacing as a parse error.
952
+ if (type === 'task.rejected' && !input.response?.trim()) {
953
+ throw new SynomemError('INVALID_INPUT', 'Rejecting a task requires a response explaining why, so the assigner knows whether to reassign it, wait, or change the request.');
954
+ }
955
+ if (type === 'task.accepted' || type === 'task.rejected')
956
+ this.assertTaskAssignee(record);
683
957
  else
684
- this.assertTodoParticipant(record);
685
- if ((type === 'todo.accepted' || type === 'todo.rejected') && record.status !== 'assigned') {
686
- if (type === 'todo.accepted' && record.status === 'open')
958
+ this.assertTaskParticipant(record);
959
+ if ((type === 'task.accepted' || type === 'task.rejected') && record.status !== 'assigned') {
960
+ if (type === 'task.accepted' && record.status === 'open')
687
961
  return record;
688
- if (type === 'todo.rejected' && record.status === 'rejected')
962
+ if (type === 'task.rejected' && record.status === 'rejected')
689
963
  return record;
690
- throw new SynomemError('INVALID_INPUT', 'Only assigned todos may be accepted or rejected.');
964
+ throw new SynomemError('INVALID_INPUT', 'Only assigned tasks may be accepted or rejected.');
691
965
  }
692
- if (type === 'todo.completed' && record.status !== 'open') {
966
+ if (type === 'task.completed' && record.status !== 'open') {
693
967
  if (record.status === 'completed')
694
968
  return record;
695
- throw new SynomemError('INVALID_INPUT', 'Only accepted or self-created open todos may be completed.');
969
+ throw new SynomemError('INVALID_INPUT', 'Only accepted or self-created open tasks may be completed.');
696
970
  }
697
- if (type === 'todo.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
971
+ if (type === 'task.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
698
972
  if (record.status === 'open')
699
973
  return record;
700
- throw new SynomemError('INVALID_INPUT', 'Only completed or canceled todos may be reopened.');
974
+ throw new SynomemError('INVALID_INPUT', 'Only completed or canceled tasks may be reopened.');
701
975
  }
702
- if (type === 'todo.canceled' && record.status !== 'assigned' && record.status !== 'open') {
976
+ if (type === 'task.canceled' && record.status !== 'assigned' && record.status !== 'open') {
703
977
  if (record.status === 'canceled')
704
978
  return record;
705
- throw new SynomemError('INVALID_INPUT', 'Only assigned or open todos may be canceled.');
979
+ throw new SynomemError('INVALID_INPUT', 'Only assigned or open tasks may be canceled.');
706
980
  }
707
981
  await this.repository.transaction(async () => {
708
982
  const prior = await this.priorMutation(input.idempotencyKey, type);
@@ -710,33 +984,163 @@ export class SynomemCore {
710
984
  return;
711
985
  const base = {
712
986
  ...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
713
- todoId: record.event.id,
987
+ taskId: record.event.id,
714
988
  ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
715
989
  };
716
- const event = type === 'todo.completed'
990
+ const event = type === 'task.completed'
717
991
  ? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
718
- : type === 'todo.canceled' || type === 'todo.rejected'
719
- ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
720
- : { ...base, type };
992
+ : type === 'task.rejected'
993
+ ? { ...base, type, response: input.response.trim() }
994
+ : type === 'task.accepted'
995
+ ? {
996
+ ...base,
997
+ type,
998
+ ...(input.response ? { response: input.response.trim() } : {}),
999
+ }
1000
+ : type === 'task.canceled'
1001
+ ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
1002
+ : { ...base, type };
721
1003
  await this.repository.insertEvent(event);
722
1004
  });
723
1005
  await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
724
- return await this.getTodoRecord(input.todoId);
1006
+ return await this.getTaskRecord(input.taskId);
1007
+ }
1008
+ completeTask(input) {
1009
+ return this.taskTransition(input, 'task.completed');
1010
+ }
1011
+ acceptTask(input) {
1012
+ return this.taskTransition(input, 'task.accepted');
725
1013
  }
726
- completeTodo(input) {
727
- return this.todoTransition(input, 'todo.completed');
1014
+ rejectTask(input) {
1015
+ return this.taskTransition(input, 'task.rejected');
728
1016
  }
729
- acceptTodo(input) {
730
- return this.todoTransition(input, 'todo.accepted');
1017
+ reopenTask(input) {
1018
+ return this.taskTransition(input, 'task.reopened');
731
1019
  }
732
- rejectTodo(input) {
733
- return this.todoTransition(input, 'todo.rejected');
1020
+ cancelTask(input) {
1021
+ return this.taskTransition(input, 'task.canceled');
734
1022
  }
735
- reopenTodo(input) {
736
- return this.todoTransition(input, 'todo.reopened');
1023
+ /* ----------------------------------------------------------------- todos *
1024
+ * A Todo is a private reminder an agent creates for itself. There is no
1025
+ * assignee, no acceptance, and no visibility choice: it belongs to its author
1026
+ * and only its author reads it. Every method below asserts that ownership
1027
+ * rather than relying on a visibility filter, so a Todo cannot be reached by
1028
+ * guessing its id.
1029
+ */
1030
+ async createTodo(input) {
1031
+ this.checkAbort();
1032
+ await this.repository.assertEventCompatibility();
1033
+ const parsed = this.validate(() => createTodoSchema.parse(input));
1034
+ const outcome = await this.repository.transaction(async () => {
1035
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
1036
+ if (prior?.type === 'todo.created')
1037
+ return { id: prior.id, created: false };
1038
+ const id = this.nextId();
1039
+ const event = {
1040
+ ...this.eventBase(id, 1, id),
1041
+ type: 'todo.created',
1042
+ title: parsed.title,
1043
+ ...(parsed.details !== undefined ? { details: parsed.details } : {}),
1044
+ priority: parsed.priority ?? 3,
1045
+ ...(parsed.due ? { due: parsed.due } : {}),
1046
+ tags: [...new Set(parsed.tags ?? [])].sort(),
1047
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1048
+ ...(parsed.source ? { source: parsed.source } : {}),
1049
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
1050
+ };
1051
+ await this.repository.insertEvent(event);
1052
+ return { id, created: true };
1053
+ });
1054
+ return {
1055
+ record: await this.getTodoRecord(outcome.id),
1056
+ created: outcome.created,
1057
+ deduplicated: !outcome.created,
1058
+ };
1059
+ }
1060
+ async getTodoRecord(id) {
1061
+ const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
1062
+ if (!record)
1063
+ throw new SynomemError('ITEM_NOT_FOUND', `Unknown todo: ${id}`);
1064
+ this.assertTodoOwner(record);
1065
+ return record;
1066
+ }
1067
+ /**
1068
+ * Todos are owner-only. No role, however broad, reads another actor's
1069
+ * private reminders — that is a named administrative capability this release
1070
+ * does not have, not something a permission check quietly allows.
1071
+ */
1072
+ assertTodoOwner(record) {
1073
+ const owner = record.event.actor;
1074
+ if (owner.kind !== this.actor.kind || owner.id !== this.actor.id) {
1075
+ throw new SynomemError('MUTATION_FORBIDDEN', 'A todo is private to the actor who created it.');
1076
+ }
1077
+ }
1078
+ async getTodo(id) {
1079
+ this.checkAbort();
1080
+ return await this.getTodoRecord(id);
1081
+ }
1082
+ async updateTodo(input) {
1083
+ this.checkAbort();
1084
+ const parsed = this.validate(() => updateTodoSchema.parse(input));
1085
+ const record = await this.getTodoRecord(parsed.todoId);
1086
+ if (record.current.version !== parsed.expectedVersion) {
1087
+ throw new SynomemError('REVISION_CONFLICT', `Todo ${parsed.todoId} is at version ${record.current.version}.`);
1088
+ }
1089
+ const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
1090
+ await this.repository.transaction(async () => {
1091
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
1092
+ if (prior)
1093
+ return;
1094
+ const event = {
1095
+ ...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
1096
+ type: 'todo.updated',
1097
+ todoId: record.event.id,
1098
+ title: parsed.title ?? record.current.title,
1099
+ ...((parsed.details ?? record.current.details)
1100
+ ? { details: parsed.details ?? record.current.details }
1101
+ : {}),
1102
+ priority: parsed.priority ?? record.current.priority,
1103
+ ...(due ? { due } : {}),
1104
+ tags: [...new Set(parsed.tags ?? record.current.tags)].sort(),
1105
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1106
+ };
1107
+ await this.repository.insertEvent(event);
1108
+ });
1109
+ return await this.getTodoRecord(parsed.todoId);
737
1110
  }
738
- cancelTodo(input) {
739
- return this.todoTransition(input, 'todo.canceled');
1111
+ async todoTransition(input, type) {
1112
+ const record = await this.getTodoRecord(input.todoId);
1113
+ if (type === 'todo.completed' && record.status !== 'open') {
1114
+ if (record.status === 'completed')
1115
+ return record;
1116
+ throw new SynomemError('INVALID_INPUT', 'Only an open todo may be completed.');
1117
+ }
1118
+ if (type === 'todo.reopened' && record.status === 'open')
1119
+ return record;
1120
+ if (type === 'todo.canceled' && record.status !== 'open') {
1121
+ if (record.status === 'canceled')
1122
+ return record;
1123
+ throw new SynomemError('INVALID_INPUT', 'Only an open todo may be canceled.');
1124
+ }
1125
+ if (type === 'todo.archived' && record.status === 'archived')
1126
+ return record;
1127
+ await this.repository.transaction(async () => {
1128
+ const prior = await this.priorMutation(input.idempotencyKey, type);
1129
+ if (prior)
1130
+ return;
1131
+ const base = {
1132
+ ...this.eventBase(record.event.id, await this.repository.nextAggregateVersion(record.event.id)),
1133
+ todoId: record.event.id,
1134
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
1135
+ };
1136
+ const event = type === 'todo.completed'
1137
+ ? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
1138
+ : type === 'todo.canceled'
1139
+ ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
1140
+ : { ...base, type };
1141
+ await this.repository.insertEvent(event);
1142
+ });
1143
+ return await this.getTodoRecord(input.todoId);
740
1144
  }
741
1145
  async listItems(input) {
742
1146
  this.checkAbort();
@@ -765,7 +1169,7 @@ export class SynomemCore {
765
1169
  return this.getMemo(id);
766
1170
  if (summary.kind === 'note')
767
1171
  return this.getNote(id);
768
- return this.getTodo(id);
1172
+ return this.getTask(id);
769
1173
  }
770
1174
  async stats(input = {}) {
771
1175
  this.checkAbort();
@@ -818,6 +1222,14 @@ export class SynomemCore {
818
1222
  return stats;
819
1223
  }
820
1224
  }
1225
+ /**
1226
+ * The schema version this package writes and expects. Named rather than
1227
+ * repeated as a literal because it is asserted in three places, and a doctor
1228
+ * check that silently lags the migration runner reports a healthy database as
1229
+ * broken.
1230
+ */
1231
+ const CURRENT_SCHEMA_VERSION = 6;
1232
+ const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6];
821
1233
  export class SynomemClient extends SynomemCore {
822
1234
  home;
823
1235
  storage;
@@ -915,8 +1327,9 @@ export class SynomemClient extends SynomemCore {
915
1327
  message: `${itemHealth.indexed} of ${itemHealth.created} item aggregates are present in the shared current-state index.`,
916
1328
  });
917
1329
  const migrationState = this.storage.migrationState();
918
- const migrationsValid = migrationState.schemaVersion === 3 &&
919
- JSON.stringify(migrationState.appliedVersions) === JSON.stringify([1, 2, 3]);
1330
+ const migrationsValid = migrationState.schemaVersion === CURRENT_SCHEMA_VERSION &&
1331
+ JSON.stringify(migrationState.appliedVersions) ===
1332
+ JSON.stringify(EXPECTED_APPLIED_MIGRATIONS);
920
1333
  diagnostics.push({
921
1334
  level: migrationsValid ? 'ok' : 'error',
922
1335
  code: migrationsValid ? 'MIGRATIONS_VALID' : 'MIGRATIONS_INCONSISTENT',
@@ -987,7 +1400,7 @@ export class SynomemClient extends SynomemCore {
987
1400
  const records = recordsFromEvents(events);
988
1401
  const memos = memoRecordsFromEvents(events);
989
1402
  const notes = noteRecordsFromEvents(events);
990
- const todos = todoRecordsFromEvents(events);
1403
+ const tasks = taskRecordsFromEvents(events);
991
1404
  const warning = scan.invalid.length
992
1405
  ? `> Warning: ${scan.invalid.length} unsupported or malformed event(s) omitted from this Markdown view: ${scan.invalid.map((item) => item.id).join(', ')}\n\n`
993
1406
  : '';
@@ -995,7 +1408,7 @@ export class SynomemClient extends SynomemCore {
995
1408
  ...records.map((record) => `## Kudos: ${escapeMarkdown(record.event.title)}\n\n${escapeMarkdown(record.event.reason)}\n\nStatus: ${record.revocationStatus === 'revoked' ? 'Revoked' : record.status}\n\nID: \`${record.event.id}\``),
996
1409
  ...memos.map((record) => `## Memo: ${escapeMarkdown(record.event.subject)}\n\n${escapeMarkdown(record.event.body)}\n\nStatus: ${record.status}\n\nID: \`${record.event.id}\``),
997
1410
  ...notes.map((record) => `## Note: ${escapeMarkdown(record.current.title)}\n\n${escapeMarkdown(record.current.body)}\n\nStatus: ${record.status}; version ${record.current.version}\n\nID: \`${record.event.id}\``),
998
- ...todos.map((record) => `## Todo: ${escapeMarkdown(record.current.title)}\n\n${record.current.description ? `${escapeMarkdown(record.current.description)}\n\n` : ''}Status: ${record.status}; priority ${record.current.priority}\n\nID: \`${record.event.id}\``),
1411
+ ...tasks.map((record) => `## Task: ${escapeMarkdown(record.current.title)}\n\n${record.current.description ? `${escapeMarkdown(record.current.description)}\n\n` : ''}Status: ${record.status}; priority ${record.current.priority}\n\nID: \`${record.event.id}\``),
999
1412
  ];
1000
1413
  return `${warning}${sections.join('\n\n')}\n`;
1001
1414
  }