synomem 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/ARCHITECTURE.md +2 -2
  2. package/CHANGELOG.md +37 -1
  3. package/README.md +28 -15
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +311 -70
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts +96 -17
  8. package/dist/client.d.ts.map +1 -1
  9. package/dist/client.js +333 -76
  10. package/dist/client.js.map +1 -1
  11. package/dist/config.d.ts +2 -2
  12. package/dist/config.js +8 -8
  13. package/dist/import.d.ts +207 -13
  14. package/dist/import.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -2
  18. package/dist/index.js.map +1 -1
  19. package/dist/mcp/index.d.ts.map +1 -1
  20. package/dist/mcp/index.js +156 -31
  21. package/dist/mcp/index.js.map +1 -1
  22. package/dist/mcp-server.js +54 -8
  23. package/dist/mcp-server.js.map +1 -1
  24. package/dist/ports/repository.d.ts +18 -1
  25. package/dist/ports/repository.d.ts.map +1 -1
  26. package/dist/projections.d.ts +18 -1
  27. package/dist/projections.d.ts.map +1 -1
  28. package/dist/projections.js +137 -44
  29. package/dist/projections.js.map +1 -1
  30. package/dist/remote.d.ts +57 -9
  31. package/dist/remote.d.ts.map +1 -1
  32. package/dist/remote.js +43 -2
  33. package/dist/remote.js.map +1 -1
  34. package/dist/schemas.d.ts +302 -21
  35. package/dist/schemas.d.ts.map +1 -1
  36. package/dist/schemas.js +142 -26
  37. package/dist/schemas.js.map +1 -1
  38. package/dist/service.d.ts +57 -10
  39. package/dist/service.d.ts.map +1 -1
  40. package/dist/skill-install.d.ts +8 -2
  41. package/dist/skill-install.d.ts.map +1 -1
  42. package/dist/skill-install.js +6 -11
  43. package/dist/skill-install.js.map +1 -1
  44. package/dist/storage.d.ts +41 -1
  45. package/dist/storage.d.ts.map +1 -1
  46. package/dist/storage.js +254 -36
  47. package/dist/storage.js.map +1 -1
  48. package/dist/types.d.ts +213 -38
  49. package/dist/types.d.ts.map +1 -1
  50. package/docs/cli.md +53 -20
  51. package/docs/examples.md +4 -4
  52. package/docs/mcp.md +12 -4
  53. package/docs/skill.md +10 -7
  54. package/docs/storage-format.md +4 -4
  55. package/openapi/synomem-v1.yaml +24 -24
  56. package/package.json +1 -1
  57. package/skills/synomem/SKILL.md +24 -8
  58. package/skills/synomem/agents/openai.yaml +1 -1
  59. package/skills/synomem/references/examples.md +1 -1
  60. package/src/cli.ts +572 -173
  61. package/src/client.ts +391 -79
  62. package/src/config.ts +8 -8
  63. package/src/index.ts +11 -1
  64. package/src/mcp/index.ts +189 -30
  65. package/src/mcp-server.ts +58 -8
  66. package/src/ports/repository.ts +16 -0
  67. package/src/projections.ts +139 -44
  68. package/src/remote.ts +118 -8
  69. package/src/schemas.ts +147 -26
  70. package/src/service.ts +59 -7
  71. package/src/skill-install.ts +14 -17
  72. package/src/storage.ts +326 -34
  73. package/src/types.ts +228 -39
@@ -16,9 +16,24 @@ import type {
16
16
  KudosRecord,
17
17
  MemoRecord,
18
18
  NoteRecord,
19
+ TaskDue,
20
+ TaskRecord,
19
21
  TodoRecord,
20
22
  } from './types.js';
21
23
 
24
+ /**
25
+ * The instant a deadline has passed.
26
+ *
27
+ * A date-only deadline does not invent a time of day; it lapses at the end of
28
+ * that UTC day, which is the latest reading that cannot call work late while
29
+ * the stated day is still running. Every projection must use this one rule, or
30
+ * two stores would disagree about what is overdue.
31
+ */
32
+ export function dueInstant(due: TaskDue | undefined): string | undefined {
33
+ if (!due) return undefined;
34
+ return due.kind === 'date' ? `${due.date}T23:59:59.999Z` : due.datetime;
35
+ }
36
+
22
37
  const generatedMarker = '<!-- Generated by Synomem. Rebuild with `synomem rebuild`. -->';
23
38
 
24
39
  function manifestPath(home: string, path: string): string {
@@ -111,7 +126,7 @@ function memoInboxMarkdown(record: MemoRecord): string {
111
126
  ].join('\n')}\n`;
112
127
  }
113
128
 
114
- function todoInboxMarkdown(record: TodoRecord): string {
129
+ function taskInboxMarkdown(record: TaskRecord): string {
115
130
  const due =
116
131
  record.current.due?.kind === 'date'
117
132
  ? record.current.due.date
@@ -126,7 +141,7 @@ function todoInboxMarkdown(record: TodoRecord): string {
126
141
  `**Status:** ${record.status} `,
127
142
  `**Priority:** ${record.current.priority} `,
128
143
  `**Due:** ${escapeMarkdown(due)} `,
129
- `**Todo ID:** \`${record.event.id}\``,
144
+ `**Task ID:** \`${record.event.id}\``,
130
145
  '',
131
146
  ...(record.current.description ? [escapeMarkdown(record.current.description), ''] : []),
132
147
  ].join('\n')}\n`;
@@ -157,16 +172,16 @@ function memoryMarkdown(profile: AgentProfile, records: NoteRecord[], rebuiltAt:
157
172
  return `${lines.join('\n')}\n`;
158
173
  }
159
174
 
160
- function todosMarkdown(profile: AgentProfile, records: TodoRecord[], rebuiltAt: string): string {
175
+ function tasksMarkdown(profile: AgentProfile, records: TaskRecord[], rebuiltAt: string): string {
161
176
  const lines = [
162
177
  generatedMarker,
163
178
  '',
164
- `# Todos — ${escapeMarkdown(profile.displayName)}`,
179
+ `# Tasks — ${escapeMarkdown(profile.displayName)}`,
165
180
  '',
166
181
  `_Last rebuilt: ${rebuiltAt}_`,
167
182
  '',
168
183
  ];
169
- if (!records.length) lines.push('_No todos yet._', '');
184
+ if (!records.length) lines.push('_No tasks yet._', '');
170
185
  for (const record of records) {
171
186
  const due =
172
187
  record.current.due?.kind === 'date'
@@ -180,7 +195,7 @@ function todosMarkdown(profile: AgentProfile, records: TodoRecord[], rebuiltAt:
180
195
  `**Status:** ${record.status} `,
181
196
  `**Priority:** ${record.current.priority} `,
182
197
  `**Due:** ${escapeMarkdown(due)} `,
183
- `**Todo ID:** \`${record.event.id}\``,
198
+ `**Task ID:** \`${record.event.id}\``,
184
199
  '',
185
200
  );
186
201
  }
@@ -278,6 +293,13 @@ export function noteRecordsFromEvents(events: SynomemEvent[]): NoteRecord[] {
278
293
  return [...records.values()].sort((a, b) => b.event.createdAt.localeCompare(a.event.createdAt));
279
294
  }
280
295
 
296
+ /**
297
+ * Builds private Todo records.
298
+ *
299
+ * Deliberately simpler than the Task reducer: there is no acceptance state and
300
+ * no response history, because there is nobody to negotiate with. A Todo goes
301
+ * open → completed / canceled / archived, and back to open on reopen.
302
+ */
281
303
  export function todoRecordsFromEvents(events: SynomemEvent[]): TodoRecord[] {
282
304
  const records = new Map<string, TodoRecord>();
283
305
  for (const event of events) {
@@ -286,14 +308,13 @@ export function todoRecordsFromEvents(events: SynomemEvent[]): TodoRecord[] {
286
308
  event,
287
309
  current: {
288
310
  title: event.title,
289
- ...(event.description !== undefined ? { description: event.description } : {}),
311
+ ...(event.details !== undefined ? { details: event.details } : {}),
290
312
  priority: event.priority,
291
313
  ...(event.due ? { due: event.due } : {}),
292
314
  tags: event.tags ?? [],
293
- visibility: event.visibility,
294
315
  version: event.aggregateVersion,
295
316
  },
296
- status: event.requiresAcceptance ? 'assigned' : 'open',
317
+ status: 'open',
297
318
  });
298
319
  } else if (event.type === 'todo.updated') {
299
320
  const record = records.get(event.todoId);
@@ -301,24 +322,25 @@ export function todoRecordsFromEvents(events: SynomemEvent[]): TodoRecord[] {
301
322
  record.update = event;
302
323
  record.current = {
303
324
  title: event.title,
304
- ...(event.description !== undefined ? { description: event.description } : {}),
325
+ ...(event.details !== undefined ? { details: event.details } : {}),
305
326
  priority: event.priority,
306
327
  ...(event.due ? { due: event.due } : {}),
307
328
  tags: event.tags ?? [],
308
- visibility: event.visibility,
309
329
  version: event.aggregateVersion,
310
330
  };
311
331
  }
312
- } else if (event.type === 'todo.accepted') {
332
+ } else if (event.type === 'todo.reopened') {
313
333
  const record = records.get(event.todoId);
314
334
  if (record) {
335
+ record.reopened = event;
315
336
  record.current.version = event.aggregateVersion;
316
337
  record.status = 'open';
338
+ delete record.terminal;
317
339
  }
318
340
  } else if (
319
341
  event.type === 'todo.completed' ||
320
- event.type === 'todo.rejected' ||
321
- event.type === 'todo.canceled'
342
+ event.type === 'todo.canceled' ||
343
+ event.type === 'todo.archived'
322
344
  ) {
323
345
  const record = records.get(event.todoId);
324
346
  if (record) {
@@ -327,12 +349,85 @@ export function todoRecordsFromEvents(events: SynomemEvent[]): TodoRecord[] {
327
349
  record.status =
328
350
  event.type === 'todo.completed'
329
351
  ? 'completed'
330
- : event.type === 'todo.rejected'
352
+ : event.type === 'todo.canceled'
353
+ ? 'canceled'
354
+ : 'archived';
355
+ }
356
+ }
357
+ }
358
+ return [...records.values()];
359
+ }
360
+
361
+ export function taskRecordsFromEvents(events: SynomemEvent[]): TaskRecord[] {
362
+ const records = new Map<string, TaskRecord>();
363
+ for (const event of events) {
364
+ if (event.type === 'task.created') {
365
+ records.set(event.id, {
366
+ event,
367
+ current: {
368
+ title: event.title,
369
+ ...(event.description !== undefined ? { description: event.description } : {}),
370
+ priority: event.priority,
371
+ ...(event.due ? { due: event.due } : {}),
372
+ tags: event.tags ?? [],
373
+ visibility: event.visibility,
374
+ version: event.aggregateVersion,
375
+ },
376
+ status: event.requiresAcceptance ? 'assigned' : 'open',
377
+ responses: [],
378
+ });
379
+ } else if (event.type === 'task.updated') {
380
+ const record = records.get(event.taskId);
381
+ if (record) {
382
+ record.update = event;
383
+ record.current = {
384
+ title: event.title,
385
+ ...(event.description !== undefined ? { description: event.description } : {}),
386
+ priority: event.priority,
387
+ ...(event.due ? { due: event.due } : {}),
388
+ tags: event.tags ?? [],
389
+ visibility: event.visibility,
390
+ version: event.aggregateVersion,
391
+ };
392
+ }
393
+ } else if (event.type === 'task.accepted') {
394
+ const record = records.get(event.taskId);
395
+ if (record) {
396
+ record.current.version = event.aggregateVersion;
397
+ record.status = 'open';
398
+ record.responses.push({
399
+ kind: 'accepted',
400
+ ...(event.response !== undefined ? { response: event.response } : {}),
401
+ actor: event.actor,
402
+ at: event.createdAt,
403
+ });
404
+ }
405
+ } else if (
406
+ event.type === 'task.completed' ||
407
+ event.type === 'task.rejected' ||
408
+ event.type === 'task.canceled'
409
+ ) {
410
+ const record = records.get(event.taskId);
411
+ if (record) {
412
+ record.terminal = event;
413
+ record.current.version = event.aggregateVersion;
414
+ if (event.type === 'task.rejected') {
415
+ record.responses.push({
416
+ kind: 'rejected',
417
+ response: event.response,
418
+ actor: event.actor,
419
+ at: event.createdAt,
420
+ });
421
+ }
422
+ record.status =
423
+ event.type === 'task.completed'
424
+ ? 'completed'
425
+ : event.type === 'task.rejected'
331
426
  ? 'rejected'
332
427
  : 'canceled';
333
428
  }
334
- } else if (event.type === 'todo.reopened') {
335
- const record = records.get(event.todoId);
429
+ } else if (event.type === 'task.reopened') {
430
+ const record = records.get(event.taskId);
336
431
  if (record) {
337
432
  record.reopened = event;
338
433
  record.current.version = event.aggregateVersion;
@@ -358,9 +453,9 @@ export function renderMarkdownExport(events: SynomemEvent[]): string {
358
453
  (record) =>
359
454
  `## Note: ${escapeMarkdown(record.current.title)}\n\n${escapeMarkdown(record.current.body)}\n\nStatus: ${record.status}; version ${record.current.version}\n\nID: \`${record.event.id}\``,
360
455
  ),
361
- ...todoRecordsFromEvents(events).map(
456
+ ...taskRecordsFromEvents(events).map(
362
457
  (record) =>
363
- `## 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}\``,
458
+ `## 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}\``,
364
459
  ),
365
460
  ];
366
461
  return `${sections.join('\n\n')}\n`;
@@ -380,7 +475,7 @@ export class ProjectionManager implements ProjectionWriter {
380
475
  const records = recordsFromEvents(events);
381
476
  const memos = memoRecordsFromEvents(events);
382
477
  const notes = noteRecordsFromEvents(events);
383
- const todos = todoRecordsFromEvents(events);
478
+ const tasks = taskRecordsFromEvents(events);
384
479
  const rebuiltAt = events.at(-1)?.createdAt ?? new Date(0).toISOString();
385
480
  const generated: string[] = [];
386
481
 
@@ -394,12 +489,12 @@ export class ProjectionManager implements ProjectionWriter {
394
489
  const inboxDirectory = join(agentDirectory, 'inbox');
395
490
  const kudosInboxDirectory = join(inboxDirectory, 'kudos');
396
491
  const memoInboxDirectory = join(inboxDirectory, 'memos');
397
- const todoInboxDirectory = join(inboxDirectory, 'todos');
492
+ const taskInboxDirectory = join(inboxDirectory, 'tasks');
398
493
  assertNoSymlinkEscape(this.storage.home, inboxDirectory);
399
494
  ensureDirectory(inboxDirectory);
400
495
  ensureDirectory(kudosInboxDirectory);
401
496
  ensureDirectory(memoInboxDirectory);
402
- ensureDirectory(todoInboxDirectory);
497
+ ensureDirectory(taskInboxDirectory);
403
498
 
404
499
  atomicWriteDerivedFile(
405
500
  profilePath,
@@ -424,11 +519,11 @@ export class ProjectionManager implements ProjectionWriter {
424
519
  atomicWriteDerivedFile(memoryPath, memoryMarkdown(profile, agentNotes, rebuiltAt));
425
520
  generated.push(manifestPath(this.storage.home, memoryPath));
426
521
  }
427
- const agentTodos = todos.filter((record) => record.event.assigneeAgentId === profile.id);
428
- if (this.storage.config.projection.writeTodosMarkdown) {
429
- const todosPath = join(agentDirectory, 'TODOS.md');
430
- atomicWriteDerivedFile(todosPath, todosMarkdown(profile, agentTodos, rebuiltAt));
431
- generated.push(manifestPath(this.storage.home, todosPath));
522
+ const agentTasks = tasks.filter((record) => record.event.assigneeAgentId === profile.id);
523
+ if (this.storage.config.projection.writeTasksMarkdown) {
524
+ const tasksPath = join(agentDirectory, 'TASKS.md');
525
+ atomicWriteDerivedFile(tasksPath, tasksMarkdown(profile, agentTasks, rebuiltAt));
526
+ generated.push(manifestPath(this.storage.home, tasksPath));
432
527
  }
433
528
  if (this.storage.config.projection.writeInboxEntries) {
434
529
  for (const record of agentRecords.filter(
@@ -445,11 +540,11 @@ export class ProjectionManager implements ProjectionWriter {
445
540
  atomicWriteDerivedFile(inboxPath, memoInboxMarkdown(record));
446
541
  generated.push(manifestPath(this.storage.home, inboxPath));
447
542
  }
448
- for (const record of agentTodos.filter(
543
+ for (const record of agentTasks.filter(
449
544
  (item) => item.status === 'open' || item.status === 'assigned',
450
545
  )) {
451
- const inboxPath = join(todoInboxDirectory, `${record.event.id}.md`);
452
- atomicWriteDerivedFile(inboxPath, todoInboxMarkdown(record));
546
+ const inboxPath = join(taskInboxDirectory, `${record.event.id}.md`);
547
+ atomicWriteDerivedFile(inboxPath, taskInboxMarkdown(record));
453
548
  generated.push(manifestPath(this.storage.home, inboxPath));
454
549
  }
455
550
  }
@@ -487,7 +582,7 @@ export class ProjectionManager implements ProjectionWriter {
487
582
  const notes = noteRecordsFromEvents(events).filter(
488
583
  (record) => record.event.ownerAgentId === profile.id,
489
584
  );
490
- const todos = todoRecordsFromEvents(events).filter(
585
+ const tasks = taskRecordsFromEvents(events).filter(
491
586
  (record) => record.event.assigneeAgentId === profile.id,
492
587
  );
493
588
  const rebuiltAt = events.at(-1)?.createdAt ?? new Date(0).toISOString();
@@ -495,14 +590,14 @@ export class ProjectionManager implements ProjectionWriter {
495
590
  const inboxDirectory = join(agentDirectory, 'inbox');
496
591
  const kudosInboxDirectory = join(inboxDirectory, 'kudos');
497
592
  const memoInboxDirectory = join(inboxDirectory, 'memos');
498
- const todoInboxDirectory = join(inboxDirectory, 'todos');
593
+ const taskInboxDirectory = join(inboxDirectory, 'tasks');
499
594
  assertNoSymlinkEscape(this.storage.home, agentDirectory);
500
595
  assertNoSymlinkEscape(this.storage.home, inboxDirectory);
501
596
  ensureDirectory(agentDirectory);
502
597
  ensureDirectory(inboxDirectory);
503
598
  ensureDirectory(kudosInboxDirectory);
504
599
  ensureDirectory(memoInboxDirectory);
505
- ensureDirectory(todoInboxDirectory);
600
+ ensureDirectory(taskInboxDirectory);
506
601
 
507
602
  const generated: string[] = [];
508
603
  const profilePath = join(agentDirectory, 'profile.json');
@@ -532,10 +627,10 @@ export class ProjectionManager implements ProjectionWriter {
532
627
  generated.push(manifestPath(this.storage.home, memoryPath));
533
628
  }
534
629
 
535
- if (this.storage.config.projection.writeTodosMarkdown) {
536
- const todosPath = join(agentDirectory, 'TODOS.md');
537
- atomicWriteDerivedFile(todosPath, todosMarkdown(profile, todos, rebuiltAt));
538
- generated.push(manifestPath(this.storage.home, todosPath));
630
+ if (this.storage.config.projection.writeTasksMarkdown) {
631
+ const tasksPath = join(agentDirectory, 'TASKS.md');
632
+ atomicWriteDerivedFile(tasksPath, tasksMarkdown(profile, tasks, rebuiltAt));
633
+ generated.push(manifestPath(this.storage.home, tasksPath));
539
634
  }
540
635
 
541
636
  if (this.storage.config.projection.writeInboxEntries) {
@@ -553,12 +648,12 @@ export class ProjectionManager implements ProjectionWriter {
553
648
  if (!existsSync(inboxPath)) atomicWriteDerivedFile(inboxPath, memoInboxMarkdown(record));
554
649
  generated.push(relativePath);
555
650
  }
556
- for (const record of todos.filter(
651
+ for (const record of tasks.filter(
557
652
  (item) => item.status === 'open' || item.status === 'assigned',
558
653
  )) {
559
- const inboxPath = join(todoInboxDirectory, `${record.event.id}.md`);
654
+ const inboxPath = join(taskInboxDirectory, `${record.event.id}.md`);
560
655
  const relativePath = manifestPath(this.storage.home, inboxPath);
561
- if (!existsSync(inboxPath)) atomicWriteDerivedFile(inboxPath, todoInboxMarkdown(record));
656
+ if (!existsSync(inboxPath)) atomicWriteDerivedFile(inboxPath, taskInboxMarkdown(record));
562
657
  generated.push(relativePath);
563
658
  }
564
659
  }
@@ -589,13 +684,13 @@ export class ProjectionManager implements ProjectionWriter {
589
684
  const events = this.storage.getReadableEvents();
590
685
  const records = recordsFromEvents(events);
591
686
  const memos = memoRecordsFromEvents(events);
592
- const todos = todoRecordsFromEvents(events);
687
+ const tasks = taskRecordsFromEvents(events);
593
688
  const paths: string[] = [];
594
689
  for (const profile of profiles) {
595
690
  paths.push(`${profile.id}/profile.json`);
596
691
  if (this.storage.config.projection.writeWinsMarkdown) paths.push(`${profile.id}/WINS.md`);
597
692
  if (this.storage.config.projection.writeMemoryMarkdown) paths.push(`${profile.id}/MEMORY.md`);
598
- if (this.storage.config.projection.writeTodosMarkdown) paths.push(`${profile.id}/TODOS.md`);
693
+ if (this.storage.config.projection.writeTasksMarkdown) paths.push(`${profile.id}/TASKS.md`);
599
694
  if (this.storage.config.projection.writeInboxEntries) {
600
695
  for (const record of records.filter(
601
696
  (item) =>
@@ -610,12 +705,12 @@ export class ProjectionManager implements ProjectionWriter {
610
705
  )) {
611
706
  paths.push(`${profile.id}/inbox/memos/${record.event.id}.md`);
612
707
  }
613
- for (const record of todos.filter(
708
+ for (const record of tasks.filter(
614
709
  (item) =>
615
710
  item.event.assigneeAgentId === profile.id &&
616
711
  (item.status === 'open' || item.status === 'assigned'),
617
712
  )) {
618
- paths.push(`${profile.id}/inbox/todos/${record.event.id}.md`);
713
+ paths.push(`${profile.id}/inbox/tasks/${record.event.id}.md`);
619
714
  }
620
715
  }
621
716
  }
package/src/remote.ts CHANGED
@@ -4,16 +4,19 @@ import type { SynomemService, SynomemServiceCapabilities, SynomemServiceInfo } f
4
4
  import type {
5
5
  ActorIdentity,
6
6
  ChangesInput,
7
+ BindRuntimeInput,
7
8
  CreateAgentInput,
8
9
  CreateNoteInput,
9
- CreateTodoInput,
10
+ CreateTaskInput,
10
11
  GiveKudosInput,
11
12
  ItemListInput,
12
13
  KudosListInput,
13
14
  ReviseNoteInput,
14
15
  SendMemoInput,
15
16
  UpdateAgentInput,
17
+ UpdateTaskInput,
16
18
  UpdateTodoInput,
19
+ CreateTodoInput,
17
20
  } from './types.js';
18
21
 
19
22
  const defaultMaximumResponseBytes = 1024 * 1024;
@@ -169,6 +172,32 @@ export class RemoteSynomemService implements SynomemService {
169
172
  ),
170
173
  list: () =>
171
174
  this.request<Awaited<ReturnType<SynomemService['agents']['list']>>>('GET', 'agents'),
175
+ resolve: (query: string) =>
176
+ this.request<Awaited<ReturnType<SynomemService['agents']['resolve']>>>(
177
+ 'GET',
178
+ `agents/resolve?query=${encodeURIComponent(query)}`,
179
+ ),
180
+ directory: () =>
181
+ this.request<Awaited<ReturnType<SynomemService['agents']['directory']>>>(
182
+ 'GET',
183
+ 'agents/directory',
184
+ ),
185
+ bindings: (idOrAlias: string) =>
186
+ this.request<Awaited<ReturnType<SynomemService['agents']['bindings']>>>(
187
+ 'GET',
188
+ `agents/${encodeURIComponent(idOrAlias)}/runtimes`,
189
+ ),
190
+ bindRuntime: (input: BindRuntimeInput) =>
191
+ this.mutation<Awaited<ReturnType<SynomemService['agents']['bindRuntime']>>>(
192
+ 'POST',
193
+ `agents/${encodeURIComponent(input.agentId)}/runtimes`,
194
+ input,
195
+ ),
196
+ unbindRuntime: (bindingId: string) =>
197
+ this.request<Awaited<ReturnType<SynomemService['agents']['unbindRuntime']>>>(
198
+ 'DELETE',
199
+ `agents/runtimes/${encodeURIComponent(bindingId)}`,
200
+ ),
172
201
  };
173
202
 
174
203
  readonly kudos = {
@@ -261,6 +290,42 @@ export class RemoteSynomemService implements SynomemService {
261
290
  ),
262
291
  };
263
292
 
293
+ readonly tasks = {
294
+ create: (input: CreateTaskInput) =>
295
+ this.mutation<Awaited<ReturnType<SynomemService['tasks']['create']>>>('POST', 'tasks', input),
296
+ list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
297
+ this.request<Awaited<ReturnType<SynomemService['tasks']['list']>>>(
298
+ 'GET',
299
+ `tasks${queryString(input)}`,
300
+ ),
301
+ get: (id: string) =>
302
+ this.request<Awaited<ReturnType<SynomemService['tasks']['get']>>>(
303
+ 'GET',
304
+ `tasks/${encodeURIComponent(id)}`,
305
+ ),
306
+ update: (input: UpdateTaskInput) =>
307
+ this.mutation<Awaited<ReturnType<SynomemService['tasks']['update']>>>(
308
+ 'POST',
309
+ `tasks/${encodeURIComponent(input.taskId)}/revisions`,
310
+ input,
311
+ ['taskId'],
312
+ ),
313
+ accept: (input: { taskId: string; response?: string; idempotencyKey?: string }) =>
314
+ this.taskTransition('accept', input),
315
+ reject: (input: { taskId: string; response: string; idempotencyKey?: string }) =>
316
+ this.taskTransition('reject', input),
317
+ complete: (input: { taskId: string; note?: string; idempotencyKey?: string }) =>
318
+ this.taskTransition('complete', input),
319
+ reopen: (input: { taskId: string; idempotencyKey?: string }) =>
320
+ this.taskTransition('reopen', input),
321
+ cancel: (input: { taskId: string; reason?: string; idempotencyKey?: string }) =>
322
+ this.taskTransition('cancel', input),
323
+ };
324
+
325
+ /**
326
+ * Private self-reminders. The remote surface mirrors the local one exactly so
327
+ * a caller does not have to know which backend it is talking to.
328
+ */
264
329
  readonly todos = {
265
330
  create: (input: CreateTodoInput) =>
266
331
  this.mutation<Awaited<ReturnType<SynomemService['todos']['create']>>>('POST', 'todos', input),
@@ -281,16 +346,43 @@ export class RemoteSynomemService implements SynomemService {
281
346
  input,
282
347
  ['todoId'],
283
348
  ),
284
- accept: (input: { todoId: string; idempotencyKey?: string }) =>
285
- this.todoTransition('accept', input),
286
- reject: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
287
- this.todoTransition('reject', input),
288
349
  complete: (input: { todoId: string; note?: string; idempotencyKey?: string }) =>
289
350
  this.todoTransition('complete', input),
290
351
  reopen: (input: { todoId: string; idempotencyKey?: string }) =>
291
352
  this.todoTransition('reopen', input),
292
353
  cancel: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
293
354
  this.todoTransition('cancel', input),
355
+ archive: (input: { todoId: string; idempotencyKey?: string }) =>
356
+ this.todoTransition('archive', input),
357
+ };
358
+
359
+ /**
360
+ * Discovery mirrors the local semantics: the age/deadline is resolved here so
361
+ * both backends answer the same question, rather than each server deciding
362
+ * what "older than 24 hours" means.
363
+ */
364
+ readonly discovery = {
365
+ unanswered: (
366
+ input: Omit<ItemListInput, 'awaitingResponse' | 'pending'> & { olderThanHours?: number } = {},
367
+ ) => {
368
+ const { olderThanHours, awaitingSince, ...rest } = input;
369
+ const since =
370
+ awaitingSince ??
371
+ (olderThanHours !== undefined
372
+ ? new Date(Date.now() - olderThanHours * 3_600_000).toISOString()
373
+ : undefined);
374
+ return this.request<Awaited<ReturnType<SynomemService['items']['list']>>>(
375
+ 'GET',
376
+ `items${queryString({ ...rest, awaitingResponse: true, ...(since ? { awaitingSince: since } : {}) })}`,
377
+ );
378
+ },
379
+ overdue: (input: Omit<ItemListInput, 'overdueAsOf'> & { asOf?: string } = {}) => {
380
+ const { asOf, ...rest } = input;
381
+ return this.request<Awaited<ReturnType<SynomemService['items']['list']>>>(
382
+ 'GET',
383
+ `items${queryString({ ...rest, overdueAsOf: asOf ?? new Date().toISOString() })}`,
384
+ );
385
+ },
294
386
  };
295
387
 
296
388
  readonly items = {
@@ -410,11 +502,29 @@ export class RemoteSynomemService implements SynomemService {
410
502
  );
411
503
  }
412
504
 
413
- private todoTransition(
505
+ private taskTransition(
414
506
  transition: 'accept' | 'reject' | 'complete' | 'reopen' | 'cancel',
507
+ input: {
508
+ taskId: string;
509
+ idempotencyKey?: string;
510
+ reason?: string;
511
+ note?: string;
512
+ response?: string;
513
+ },
514
+ ) {
515
+ return this.mutation<Awaited<ReturnType<SynomemService['tasks']['accept']>>>(
516
+ 'POST',
517
+ `tasks/${encodeURIComponent(input.taskId)}/${transition}`,
518
+ input,
519
+ ['taskId'],
520
+ );
521
+ }
522
+
523
+ private todoTransition(
524
+ transition: 'complete' | 'reopen' | 'cancel' | 'archive',
415
525
  input: { todoId: string; idempotencyKey?: string; reason?: string; note?: string },
416
526
  ) {
417
- return this.mutation<Awaited<ReturnType<SynomemService['todos']['accept']>>>(
527
+ return this.mutation<Awaited<ReturnType<SynomemService['todos']['complete']>>>(
418
528
  'POST',
419
529
  `todos/${encodeURIComponent(input.todoId)}/${transition}`,
420
530
  input,
@@ -437,7 +547,7 @@ export class RemoteSynomemService implements SynomemService {
437
547
  }
438
548
 
439
549
  private async request<T>(
440
- method: 'GET' | 'POST' | 'PATCH',
550
+ method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
441
551
  path: string,
442
552
  body?: object,
443
553
  idempotencyKey?: string,