synomem 0.6.11 → 0.7.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 (49) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +92 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client.d.ts +22 -1
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/client.js +164 -3
  7. package/dist/client.js.map +1 -1
  8. package/dist/errors.d.ts +1 -1
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/errors.js +2 -0
  11. package/dist/errors.js.map +1 -1
  12. package/dist/import.d.ts +83 -0
  13. package/dist/import.d.ts.map +1 -1
  14. package/dist/mcp/index.d.ts.map +1 -1
  15. package/dist/mcp/index.js +110 -2
  16. package/dist/mcp/index.js.map +1 -1
  17. package/dist/ports/repository.d.ts +10 -1
  18. package/dist/ports/repository.d.ts.map +1 -1
  19. package/dist/projections.d.ts.map +1 -1
  20. package/dist/projections.js +6 -0
  21. package/dist/projections.js.map +1 -1
  22. package/dist/remote.d.ts +10 -1
  23. package/dist/remote.d.ts.map +1 -1
  24. package/dist/remote.js +9 -0
  25. package/dist/remote.js.map +1 -1
  26. package/dist/schemas.d.ts +130 -0
  27. package/dist/schemas.d.ts.map +1 -1
  28. package/dist/schemas.js +77 -0
  29. package/dist/schemas.js.map +1 -1
  30. package/dist/service.d.ts +14 -1
  31. package/dist/service.d.ts.map +1 -1
  32. package/dist/storage.d.ts +12 -1
  33. package/dist/storage.d.ts.map +1 -1
  34. package/dist/storage.js +136 -19
  35. package/dist/storage.js.map +1 -1
  36. package/dist/types.d.ts +72 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli.ts +153 -0
  40. package/src/client.ts +184 -2
  41. package/src/errors.ts +2 -0
  42. package/src/mcp/index.ts +140 -1
  43. package/src/ports/repository.ts +8 -0
  44. package/src/projections.ts +6 -0
  45. package/src/remote.ts +45 -0
  46. package/src/schemas.ts +85 -0
  47. package/src/service.ts +18 -0
  48. package/src/storage.ts +167 -13
  49. package/src/types.ts +82 -2
package/src/client.ts CHANGED
@@ -33,6 +33,10 @@ import {
33
33
  updateTaskSchema,
34
34
  updateTodoSchema,
35
35
  updateAgentSchema,
36
+ createTopicSchema,
37
+ updateTopicSchema,
38
+ topicListInputSchema,
39
+ topicLookupSchema,
36
40
  } from './schemas.js';
37
41
  import { SynomemStorage } from './storage.js';
38
42
  import type {
@@ -78,6 +82,11 @@ import type {
78
82
  TodoRecord,
79
83
  ItemListInput,
80
84
  ItemRecord,
85
+ Topic,
86
+ CreateTopicInput,
87
+ UpdateTopicInput,
88
+ TopicListInput,
89
+ TopicResolution,
81
90
  ItemSummary,
82
91
  ChangesInput,
83
92
  KudosChangesInput,
@@ -137,6 +146,17 @@ export class SynomemCore implements SynomemDomainService {
137
146
  unbindRuntime: (bindingId: string) => this.unbindRuntime(bindingId),
138
147
  };
139
148
 
149
+ readonly topics = {
150
+ create: (input: CreateTopicInput) => this.createTopic(input),
151
+ update: (idOrAlias: string, changes: UpdateTopicInput) =>
152
+ this.updateTopicRecord(idOrAlias, changes),
153
+ get: (idOrAlias: string) => this.getTopicRecord(idOrAlias),
154
+ list: (input: TopicListInput = {}) => this.listTopicsRecord(input),
155
+ resolve: (query: string) => this.resolveTopicRecord(query),
156
+ archive: (idOrAlias: string) => this.setTopicStatus(idOrAlias, 'archived'),
157
+ restore: (idOrAlias: string) => this.setTopicStatus(idOrAlias, 'active'),
158
+ };
159
+
140
160
  readonly kudos = {
141
161
  give: (input: GiveKudosInput) => this.giveKudos(input),
142
162
  list: (input: KudosListInput = {}) => this.listKudos(input),
@@ -578,6 +598,148 @@ export class SynomemCore implements SynomemDomainService {
578
598
  return await this.repository.unbindRuntime(bindingId);
579
599
  }
580
600
 
601
+ /* ---------------------------------------------------------------- topics *
602
+ * A topic is a controlled, reusable subject a record can be filed under —
603
+ * one canonical display name and a set of aliases, so a stable "Synomem"
604
+ * page survives a rename without retagging every record that carries it.
605
+ * Any actor may create one freely, the same as a tag; only the creator or
606
+ * an administrator renames or archives it, matching how an agent's own
607
+ * identity is managed.
608
+ */
609
+
610
+ private async createTopic(input: CreateTopicInput): Promise<Topic> {
611
+ this.checkAbort();
612
+ await this.repository.assertEventCompatibility();
613
+ const parsed = this.validate(() => createTopicSchema.parse(input));
614
+ if (await this.repository.getTopic(parsed.displayName)) {
615
+ throw new SynomemError(
616
+ 'TOPIC_EXISTS',
617
+ `Topic or alias already exists: ${parsed.displayName}`,
618
+ );
619
+ }
620
+ const aliases = [...new Set(parsed.aliases ?? [])].sort();
621
+ for (const alias of aliases) {
622
+ if (await this.repository.getTopic(alias)) {
623
+ throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to a topic: ${alias}`);
624
+ }
625
+ }
626
+ const topic: Topic = {
627
+ id: this.nextId(),
628
+ displayName: parsed.displayName,
629
+ ...(aliases.length ? { aliases } : {}),
630
+ status: 'active',
631
+ createdAt: this.now(),
632
+ };
633
+ const event: SynomemEvent = {
634
+ ...this.eventBase(topic.id, 1),
635
+ type: 'topic.created',
636
+ topic,
637
+ };
638
+ await this.repository.transaction(async () => {
639
+ await this.repository.insertTopic(topic);
640
+ await this.repository.insertEvent(event);
641
+ });
642
+ return topic;
643
+ }
644
+
645
+ private async updateTopicRecord(idOrAlias: string, changes: UpdateTopicInput): Promise<Topic> {
646
+ this.checkAbort();
647
+ await this.repository.assertEventCompatibility();
648
+ this.validate(() => topicLookupSchema.parse(idOrAlias));
649
+ const parsed = this.validate(() => updateTopicSchema.parse(changes));
650
+ const existing = await this.repository.getTopic(idOrAlias);
651
+ if (!existing) throw new SynomemError('TOPIC_NOT_FOUND', `Unknown topic: ${idOrAlias}`);
652
+ if (parsed.displayName && parsed.displayName !== existing.displayName) {
653
+ const owner = await this.repository.getTopic(parsed.displayName);
654
+ if (owner && owner.id !== existing.id) {
655
+ throw new SynomemError(
656
+ 'ALIAS_CONFLICT',
657
+ `Display name already belongs to ${owner.id}: ${parsed.displayName}`,
658
+ );
659
+ }
660
+ }
661
+ const aliases = parsed.aliases ? [...new Set(parsed.aliases)].sort() : existing.aliases;
662
+ for (const alias of aliases ?? []) {
663
+ const owner = await this.repository.getTopic(alias);
664
+ if (owner && owner.id !== existing.id) {
665
+ throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to ${owner.id}: ${alias}`);
666
+ }
667
+ }
668
+ const updated: Topic = {
669
+ ...existing,
670
+ ...parsed,
671
+ ...(aliases?.length ? { aliases } : { aliases: undefined }),
672
+ };
673
+ const changesForEvent = { ...parsed, ...(parsed.aliases ? { aliases } : {}) };
674
+ await this.repository.transaction(async () => {
675
+ const event: SynomemEvent = {
676
+ ...this.eventBase(existing.id, await this.repository.nextAggregateVersion(existing.id)),
677
+ type: 'topic.updated',
678
+ topicId: existing.id,
679
+ changes: changesForEvent,
680
+ };
681
+ await this.repository.updateTopic(updated, event.createdAt);
682
+ await this.repository.insertEvent(event);
683
+ });
684
+ return updated;
685
+ }
686
+
687
+ private async setTopicStatus(idOrAlias: string, status: 'active' | 'archived'): Promise<Topic> {
688
+ this.checkAbort();
689
+ await this.repository.assertEventCompatibility();
690
+ this.validate(() => topicLookupSchema.parse(idOrAlias));
691
+ const existing = await this.repository.getTopic(idOrAlias);
692
+ if (!existing) throw new SynomemError('TOPIC_NOT_FOUND', `Unknown topic: ${idOrAlias}`);
693
+ if (existing.status === status) return existing;
694
+ const updated: Topic = { ...existing, status };
695
+ await this.repository.transaction(async () => {
696
+ const event: SynomemEvent = {
697
+ ...this.eventBase(existing.id, await this.repository.nextAggregateVersion(existing.id)),
698
+ type: 'topic.updated',
699
+ topicId: existing.id,
700
+ changes: { status },
701
+ };
702
+ await this.repository.updateTopic(updated, event.createdAt);
703
+ await this.repository.insertEvent(event);
704
+ });
705
+ return updated;
706
+ }
707
+
708
+ private async getTopicRecord(idOrAlias: string): Promise<Topic> {
709
+ this.checkAbort();
710
+ this.validate(() => topicLookupSchema.parse(idOrAlias));
711
+ const topic = await this.repository.getTopic(idOrAlias);
712
+ if (!topic) throw new SynomemError('TOPIC_NOT_FOUND', `Unknown topic: ${idOrAlias}`);
713
+ return topic;
714
+ }
715
+
716
+ private async listTopicsRecord(input: TopicListInput): Promise<Topic[]> {
717
+ this.checkAbort();
718
+ const parsed = this.validate(() => topicListInputSchema.parse(input));
719
+ return await this.repository.listTopics(parsed.status);
720
+ }
721
+
722
+ private async resolveTopicRecord(query: string): Promise<TopicResolution> {
723
+ this.checkAbort();
724
+ const trimmed = this.validate(() => topicLookupSchema.parse(query));
725
+ const resolved = await this.repository.resolveTopic(trimmed);
726
+ return { query: trimmed, ...resolved };
727
+ }
728
+
729
+ /**
730
+ * Every topic in `topicIds` must already exist and be active — a record
731
+ * cannot be filed under a subject that does not exist, or one somebody
732
+ * archived precisely to stop new records collecting under it.
733
+ */
734
+ private async assertTopicsExist(topicIds: string[] | undefined): Promise<void> {
735
+ for (const topicId of topicIds ?? []) {
736
+ const topic = await this.repository.getTopic(topicId);
737
+ if (!topic || topic.status !== 'active') {
738
+ throw new SynomemError('TOPIC_NOT_FOUND', `Unknown or archived topic: ${topicId}`);
739
+ }
740
+ }
741
+ }
742
+
581
743
  private async giveKudos(input: GiveKudosInput): Promise<GiveKudosResult> {
582
744
  this.checkAbort();
583
745
  await this.repository.assertEventCompatibility();
@@ -587,6 +749,7 @@ export class SynomemCore implements SynomemDomainService {
587
749
  visibility: input.visibility ?? this.repository.config.defaultVisibility,
588
750
  }),
589
751
  );
752
+ await this.assertTopicsExist(parsed.topicIds);
590
753
  const recipient = await this.repository.getAgent(parsed.recipientAgentId);
591
754
  if (!recipient) {
592
755
  throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipientAgentId}`);
@@ -616,6 +779,7 @@ export class SynomemCore implements SynomemDomainService {
616
779
  visibility: parsed.visibility,
617
780
  ...(parsed.evidence ? { evidence: parsed.evidence } : {}),
618
781
  ...(parsed.tags ? { tags: [...new Set(parsed.tags)].sort() } : {}),
782
+ ...(parsed.topicIds ? { topicIds: [...new Set(parsed.topicIds)].sort() } : {}),
619
783
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
620
784
  ...(parsed.source ? { source: parsed.source } : {}),
621
785
  ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
@@ -803,6 +967,7 @@ export class SynomemCore implements SynomemDomainService {
803
967
  this.checkAbort();
804
968
  await this.repository.assertEventCompatibility();
805
969
  const parsed = this.validate(() => sendMemoSchema.parse(input));
970
+ await this.assertTopicsExist(parsed.topicIds);
806
971
  const recipient = await this.repository.getAgent(parsed.recipientAgentId);
807
972
  if (!recipient)
808
973
  throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipientAgentId}`);
@@ -818,6 +983,7 @@ export class SynomemCore implements SynomemDomainService {
818
983
  subject: parsed.subject,
819
984
  body: parsed.body,
820
985
  tags: [...new Set(parsed.tags ?? [])].sort(),
986
+ topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
821
987
  visibility: parsed.visibility ?? this.repository.config.defaultVisibility,
822
988
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
823
989
  ...(parsed.source ? { source: parsed.source } : {}),
@@ -915,6 +1081,7 @@ export class SynomemCore implements SynomemDomainService {
915
1081
  this.checkAbort();
916
1082
  await this.repository.assertEventCompatibility();
917
1083
  const parsed = this.validate(() => createPostSchema.parse(input));
1084
+ await this.assertTopicsExist(parsed.topicIds);
918
1085
 
919
1086
  // A reply inherits its parent's workspace by construction, and cannot name
920
1087
  // a different target — there is no target to name.
@@ -930,6 +1097,7 @@ export class SynomemCore implements SynomemDomainService {
930
1097
  title: parsed.title,
931
1098
  body: parsed.body,
932
1099
  tags: [...new Set(parsed.tags ?? [])].sort(),
1100
+ topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
933
1101
  ...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
934
1102
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
935
1103
  ...(parsed.source ? { source: parsed.source } : {}),
@@ -956,6 +1124,7 @@ export class SynomemCore implements SynomemDomainService {
956
1124
  private async updatePost(input: UpdatePostInput): Promise<PostRecord> {
957
1125
  this.checkAbort();
958
1126
  const parsed = this.validate(() => updatePostSchema.parse(input));
1127
+ await this.assertTopicsExist(parsed.topicIds);
959
1128
  const record = await this.getPostRecord(parsed.postId);
960
1129
  this.assertPostAuthor(record);
961
1130
  if (record.status === 'archived') {
@@ -975,6 +1144,7 @@ export class SynomemCore implements SynomemDomainService {
975
1144
  title: parsed.title ?? record.title,
976
1145
  body: parsed.body ?? record.body,
977
1146
  tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
1147
+ topicIds: [...new Set(parsed.topicIds ?? record.topicIds ?? [])].sort(),
978
1148
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
979
1149
  };
980
1150
  await this.repository.insertEvent(event);
@@ -1078,6 +1248,7 @@ export class SynomemCore implements SynomemDomainService {
1078
1248
  this.checkAbort();
1079
1249
  await this.repository.assertEventCompatibility();
1080
1250
  const parsed = this.validate(() => createNoteSchema.parse(input));
1251
+ await this.assertTopicsExist(parsed.topicIds);
1081
1252
  const ownerId =
1082
1253
  parsed.ownerAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
1083
1254
  if (!ownerId)
@@ -1099,6 +1270,7 @@ export class SynomemCore implements SynomemDomainService {
1099
1270
  title: parsed.title,
1100
1271
  body: parsed.body,
1101
1272
  tags: [...new Set(parsed.tags ?? [])].sort(),
1273
+ topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
1102
1274
  visibility: 'private',
1103
1275
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1104
1276
  ...(parsed.source ? { source: parsed.source } : {}),
@@ -1129,6 +1301,7 @@ export class SynomemCore implements SynomemDomainService {
1129
1301
 
1130
1302
  private async reviseNote(input: ReviseNoteInput): Promise<NoteRecord> {
1131
1303
  const parsed = this.validate(() => reviseNoteSchema.parse(input));
1304
+ await this.assertTopicsExist(parsed.topicIds);
1132
1305
  const record = await this.getNoteRecord(parsed.noteId);
1133
1306
  this.assertNoteOwner(record);
1134
1307
  if (record.status === 'archived')
@@ -1156,6 +1329,7 @@ export class SynomemCore implements SynomemDomainService {
1156
1329
  title: parsed.title ?? record.current.title,
1157
1330
  body: parsed.body ?? record.current.body,
1158
1331
  tags: parsed.tags ?? record.current.tags,
1332
+ topicIds: parsed.topicIds ?? record.current.topicIds,
1159
1333
  visibility: 'private',
1160
1334
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1161
1335
  ...(parsed.source ? { source: parsed.source } : {}),
@@ -1207,6 +1381,7 @@ export class SynomemCore implements SynomemDomainService {
1207
1381
  this.checkAbort();
1208
1382
  await this.repository.assertEventCompatibility();
1209
1383
  const parsed = this.validate(() => createTaskSchema.parse(input));
1384
+ await this.assertTopicsExist(parsed.topicIds);
1210
1385
  const assigneeId =
1211
1386
  parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
1212
1387
  if (!assigneeId)
@@ -1238,6 +1413,7 @@ export class SynomemCore implements SynomemDomainService {
1238
1413
  priority: parsed.priority ?? 3,
1239
1414
  ...(parsed.due ? { due: parsed.due } : {}),
1240
1415
  tags: [...new Set(parsed.tags ?? [])].sort(),
1416
+ topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
1241
1417
  visibility: parsed.visibility ?? this.repository.config.defaultVisibility,
1242
1418
  requiresAcceptance,
1243
1419
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
@@ -1281,6 +1457,7 @@ export class SynomemCore implements SynomemDomainService {
1281
1457
 
1282
1458
  private async updateTask(input: UpdateTaskInput): Promise<TaskRecord> {
1283
1459
  const parsed = this.validate(() => updateTaskSchema.parse(input));
1460
+ await this.assertTopicsExist(parsed.topicIds);
1284
1461
  const record = await this.getTaskRecord(parsed.taskId);
1285
1462
  this.assertTaskParticipant(record);
1286
1463
  if (record.status !== 'open')
@@ -1315,6 +1492,7 @@ export class SynomemCore implements SynomemDomainService {
1315
1492
  priority: parsed.priority ?? record.current.priority,
1316
1493
  ...(due ? { due } : {}),
1317
1494
  tags: parsed.tags ?? record.current.tags,
1495
+ topicIds: parsed.topicIds ?? record.current.topicIds,
1318
1496
  visibility: parsed.visibility ?? record.current.visibility,
1319
1497
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1320
1498
  ...(parsed.source ? { source: parsed.source } : {}),
@@ -1425,6 +1603,7 @@ export class SynomemCore implements SynomemDomainService {
1425
1603
  this.checkAbort();
1426
1604
  await this.repository.assertEventCompatibility();
1427
1605
  const parsed = this.validate(() => createTodoSchema.parse(input));
1606
+ await this.assertTopicsExist(parsed.topicIds);
1428
1607
  const outcome = await this.repository.transaction(async () => {
1429
1608
  const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
1430
1609
  if (prior?.type === 'todo.created') return { id: prior.id, created: false };
@@ -1437,6 +1616,7 @@ export class SynomemCore implements SynomemDomainService {
1437
1616
  priority: parsed.priority ?? 3,
1438
1617
  ...(parsed.due ? { due: parsed.due } : {}),
1439
1618
  tags: [...new Set(parsed.tags ?? [])].sort(),
1619
+ topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
1440
1620
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1441
1621
  ...(parsed.source ? { source: parsed.source } : {}),
1442
1622
  ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
@@ -1487,6 +1667,7 @@ export class SynomemCore implements SynomemDomainService {
1487
1667
  private async updateTodo(input: UpdateTodoInput): Promise<TodoRecord> {
1488
1668
  this.checkAbort();
1489
1669
  const parsed = this.validate(() => updateTodoSchema.parse(input));
1670
+ await this.assertTopicsExist(parsed.topicIds);
1490
1671
  const record = await this.getTodoRecord(parsed.todoId);
1491
1672
  if (record.current.version !== parsed.expectedVersion) {
1492
1673
  throw new SynomemError(
@@ -1512,6 +1693,7 @@ export class SynomemCore implements SynomemDomainService {
1512
1693
  priority: parsed.priority ?? record.current.priority,
1513
1694
  ...(due ? { due } : {}),
1514
1695
  tags: [...new Set<string>(parsed.tags ?? record.current.tags)].sort(),
1696
+ topicIds: [...new Set<string>(parsed.topicIds ?? record.current.topicIds)].sort(),
1515
1697
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1516
1698
  };
1517
1699
  await this.repository.insertEvent(event);
@@ -1681,8 +1863,8 @@ export class SynomemCore implements SynomemDomainService {
1681
1863
  * check that silently lags the migration runner reports a healthy database as
1682
1864
  * broken.
1683
1865
  */
1684
- const CURRENT_SCHEMA_VERSION = 7;
1685
- const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7];
1866
+ const CURRENT_SCHEMA_VERSION = 8;
1867
+ const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7, 8];
1686
1868
 
1687
1869
  export class SynomemClient extends SynomemCore implements SynomemService {
1688
1870
  readonly home: string;
package/src/errors.ts CHANGED
@@ -8,6 +8,8 @@ export const errorCodes = [
8
8
  'AGENT_NOT_FOUND',
9
9
  'AGENT_EXISTS',
10
10
  'ALIAS_CONFLICT',
11
+ 'TOPIC_NOT_FOUND',
12
+ 'TOPIC_EXISTS',
11
13
  'KUDOS_NOT_FOUND',
12
14
  'ITEM_NOT_FOUND',
13
15
  'MEMO_NOT_FOUND',
package/src/mcp/index.ts CHANGED
@@ -19,6 +19,8 @@ import {
19
19
  sendMemoSchema,
20
20
  updateTaskSchema,
21
21
  updateTodoSchema,
22
+ topicNameSchema,
23
+ topicAliasSchema,
22
24
  } from '../schemas.js';
23
25
  import { packageVersion } from '../version.js';
24
26
  import type { SynomemService, SynomemServiceFactory } from '../service.js';
@@ -132,7 +134,7 @@ export async function createSynomemMcpServer(
132
134
  { name: 'synomem', version: packageVersion() },
133
135
  {
134
136
  instructions:
135
- 'Use Synomem for durable kudos, memos, notes, posts, tasks, and todos. Pick by who the record is for: a task is work assigned to another agent, which they must accept; a todo is your own private reminder that no other agent can see or assign (the human administrator can still see it in the Synomem dashboard); a post tells everyone in the workspace something and records who acknowledged it. Store only necessary, factual content; never secrets or raw sensitive tool output. The server binds every write to its configured actor.',
137
+ 'Use Synomem for durable kudos, memos, notes, posts, tasks, and todos. Pick by who the record is for: a task is work assigned to another agent, which they must accept; a todo is your own private reminder that no other agent can see or assign (the human administrator can still see it in the Synomem dashboard); a post tells everyone in the workspace something and records who acknowledged it. Any record may also carry topicIds — synomem_topic_resolve or synomem_topic_list first, synomem_topic_create only if none already fits — for a stable cross-kind subject (like "Synomem" itself) that tags cannot give, since a tag is a loose free-text label with no identity of its own. Store only necessary, factual content; never secrets or raw sensitive tool output. The server binds every write to its configured actor.',
136
138
  },
137
139
  );
138
140
 
@@ -552,6 +554,143 @@ export async function createSynomemMcpServer(
552
554
  },
553
555
  );
554
556
 
557
+ server.registerTool(
558
+ 'synomem_topic_create',
559
+ {
560
+ title: 'Create a topic',
561
+ description:
562
+ 'Create a controlled, reusable subject records can be filed under — a stable ID, one canonical display name, and optional aliases, distinct from a free-text tag. Any actor may create one. Use synomem_topic_resolve first to check whether the topic you mean already exists, so "Synomem" is not created twice under two different IDs.',
563
+ inputSchema: z.object({
564
+ displayName: topicNameSchema,
565
+ aliases: z.array(topicAliasSchema).max(20).optional(),
566
+ }),
567
+ outputSchema,
568
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
569
+ },
570
+ async (input) => {
571
+ try {
572
+ const topic = await client.topics.create(input);
573
+ return success(actor, `Created topic "${topic.displayName}" (ID ${topic.id}).`, { topic });
574
+ } catch (error) {
575
+ return failure(actor, error);
576
+ }
577
+ },
578
+ );
579
+
580
+ server.registerTool(
581
+ 'synomem_topic_update',
582
+ {
583
+ title: 'Rename a topic or change its aliases',
584
+ description:
585
+ "Rename a topic or replace its aliases without changing its ID — every record already filed under it stays filed under it. Only the topic's creator or an administrator may do this.",
586
+ inputSchema: z.object({
587
+ idOrAlias: z.string().min(1).describe('A topic ID or alias, in any casing.'),
588
+ displayName: topicNameSchema.optional(),
589
+ aliases: z.array(topicAliasSchema).max(20).optional(),
590
+ }),
591
+ outputSchema,
592
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
593
+ },
594
+ async ({ idOrAlias, ...changes }) => {
595
+ try {
596
+ const topic = await client.topics.update(idOrAlias, changes);
597
+ return success(actor, `Updated topic "${topic.displayName}" (ID ${topic.id}).`, { topic });
598
+ } catch (error) {
599
+ return failure(actor, error);
600
+ }
601
+ },
602
+ );
603
+
604
+ server.registerTool(
605
+ 'synomem_topic_list',
606
+ {
607
+ title: 'List topics',
608
+ description: 'List known topics. This is read-only.',
609
+ inputSchema: z.object({ status: z.enum(['active', 'archived']).optional() }),
610
+ outputSchema,
611
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
612
+ },
613
+ async (input) => {
614
+ try {
615
+ const topics = await client.topics.list(input);
616
+ return success(actor, `Found ${topics.length} topic(s).`, { topics });
617
+ } catch (error) {
618
+ return failure(actor, error);
619
+ }
620
+ },
621
+ );
622
+
623
+ server.registerTool(
624
+ 'synomem_topic_resolve',
625
+ {
626
+ title: 'Resolve a topic name',
627
+ description:
628
+ 'Resolve a name or alias to exactly one topic. Matching ignores case. When several topics answer to the name, no match is returned and the candidates are listed instead — ask which one is meant, or use synomem_topic_create only once neither the name nor an alias already exists.',
629
+ inputSchema: z.object({
630
+ query: z.string().min(1).describe('A topic ID or alias, in any casing.'),
631
+ }),
632
+ outputSchema,
633
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
634
+ },
635
+ async ({ query }) => {
636
+ try {
637
+ const resolution = await client.topics.resolve(query);
638
+ const message = resolution.match
639
+ ? `"${resolution.query}" resolves to ${resolution.match.id}.`
640
+ : resolution.candidates.length
641
+ ? `"${resolution.query}" is ambiguous across ${resolution.candidates.length} topics. Ask which one is meant.`
642
+ : `No topic answers to "${resolution.query}".`;
643
+ return success(actor, message, resolution);
644
+ } catch (error) {
645
+ return failure(actor, error);
646
+ }
647
+ },
648
+ );
649
+
650
+ server.registerTool(
651
+ 'synomem_topic_archive',
652
+ {
653
+ title: 'Archive a topic',
654
+ description:
655
+ "Archive a topic so it can no longer be attached to new records; records already carrying it keep it. Only the topic's creator or an administrator may do this.",
656
+ inputSchema: z.object({
657
+ idOrAlias: z.string().min(1).describe('A topic ID or alias, in any casing.'),
658
+ }),
659
+ outputSchema,
660
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
661
+ },
662
+ async ({ idOrAlias }) => {
663
+ try {
664
+ const topic = await client.topics.archive(idOrAlias);
665
+ return success(actor, `Archived topic "${topic.displayName}" (${topic.id}).`, { topic });
666
+ } catch (error) {
667
+ return failure(actor, error);
668
+ }
669
+ },
670
+ );
671
+
672
+ server.registerTool(
673
+ 'synomem_topic_restore',
674
+ {
675
+ title: 'Restore an archived topic',
676
+ description:
677
+ 'Let an archived topic be attached to new records again, using the same ID it always had.',
678
+ inputSchema: z.object({
679
+ idOrAlias: z.string().min(1).describe('A topic ID or alias, in any casing.'),
680
+ }),
681
+ outputSchema,
682
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
683
+ },
684
+ async ({ idOrAlias }) => {
685
+ try {
686
+ const topic = await client.topics.restore(idOrAlias);
687
+ return success(actor, `Restored topic "${topic.displayName}" (${topic.id}).`, { topic });
688
+ } catch (error) {
689
+ return failure(actor, error);
690
+ }
691
+ },
692
+ );
693
+
555
694
  server.registerTool(
556
695
  'synomem_rebuild',
557
696
  {
@@ -14,6 +14,7 @@ import type {
14
14
  SynomemEvent,
15
15
  Page,
16
16
  RecordKind,
17
+ Topic,
17
18
  } from '../types.js';
18
19
 
19
20
  /**
@@ -63,6 +64,13 @@ export interface SynomemRepository {
63
64
  unbindRuntime(bindingId: string): Awaitable<boolean>;
64
65
  touchRuntimeBinding(agentId: string, runtime: string, at: string): Awaitable<void>;
65
66
 
67
+ insertTopic(topic: Topic): Awaitable<void>;
68
+ updateTopic(topic: Topic, updatedAt: string): Awaitable<void>;
69
+ getTopic(idOrAlias: string): Awaitable<Topic | undefined>;
70
+ listTopics(status?: 'active' | 'archived'): Awaitable<Topic[]>;
71
+ /** Resolves a name case-insensitively, reporting ambiguity instead of guessing. */
72
+ resolveTopic(query: string): Awaitable<{ match?: Topic; candidates: Topic[] }>;
73
+
66
74
  listKudosSummaries(
67
75
  input: Required<Pick<KudosListInput, 'limit' | 'offset'>> & KudosListInput,
68
76
  viewer: ActorIdentity,
@@ -334,6 +334,7 @@ export function noteRecordsFromEvents(events: SynomemEvent[]): NoteRecord[] {
334
334
  title: event.title,
335
335
  body: event.body,
336
336
  tags: event.tags ?? [],
337
+ topicIds: event.topicIds ?? [],
337
338
  visibility: event.visibility,
338
339
  version: event.aggregateVersion,
339
340
  },
@@ -347,6 +348,7 @@ export function noteRecordsFromEvents(events: SynomemEvent[]): NoteRecord[] {
347
348
  title: event.title,
348
349
  body: event.body,
349
350
  tags: event.tags ?? [],
351
+ topicIds: event.topicIds ?? [],
350
352
  visibility: event.visibility,
351
353
  version: event.aggregateVersion,
352
354
  };
@@ -382,6 +384,7 @@ export function todoRecordsFromEvents(events: SynomemEvent[]): TodoRecord[] {
382
384
  priority: event.priority,
383
385
  ...(event.due ? { due: event.due } : {}),
384
386
  tags: event.tags ?? [],
387
+ topicIds: event.topicIds ?? [],
385
388
  version: event.aggregateVersion,
386
389
  },
387
390
  status: 'open',
@@ -396,6 +399,7 @@ export function todoRecordsFromEvents(events: SynomemEvent[]): TodoRecord[] {
396
399
  priority: event.priority,
397
400
  ...(event.due ? { due: event.due } : {}),
398
401
  tags: event.tags ?? [],
402
+ topicIds: event.topicIds ?? [],
399
403
  version: event.aggregateVersion,
400
404
  };
401
405
  }
@@ -440,6 +444,7 @@ export function taskRecordsFromEvents(events: SynomemEvent[]): TaskRecord[] {
440
444
  priority: event.priority,
441
445
  ...(event.due ? { due: event.due } : {}),
442
446
  tags: event.tags ?? [],
447
+ topicIds: event.topicIds ?? [],
443
448
  visibility: event.visibility,
444
449
  version: event.aggregateVersion,
445
450
  },
@@ -456,6 +461,7 @@ export function taskRecordsFromEvents(events: SynomemEvent[]): TaskRecord[] {
456
461
  priority: event.priority,
457
462
  ...(event.due ? { due: event.due } : {}),
458
463
  tags: event.tags ?? [],
464
+ topicIds: event.topicIds ?? [],
459
465
  visibility: event.visibility,
460
466
  version: event.aggregateVersion,
461
467
  };
package/src/remote.ts CHANGED
@@ -19,6 +19,9 @@ import type {
19
19
  UpdateTaskInput,
20
20
  UpdateTodoInput,
21
21
  CreateTodoInput,
22
+ CreateTopicInput,
23
+ UpdateTopicInput,
24
+ TopicListInput,
22
25
  } from './types.js';
23
26
 
24
27
  const defaultMaximumResponseBytes = 1024 * 1024;
@@ -240,6 +243,48 @@ export class RemoteSynomemService implements SynomemService {
240
243
  ),
241
244
  };
242
245
 
246
+ readonly topics = {
247
+ create: (input: CreateTopicInput) =>
248
+ this.mutation<Awaited<ReturnType<SynomemService['topics']['create']>>>(
249
+ 'POST',
250
+ 'topics',
251
+ input,
252
+ ),
253
+ update: (idOrAlias: string, changes: UpdateTopicInput) =>
254
+ this.mutation<Awaited<ReturnType<SynomemService['topics']['update']>>>(
255
+ 'PATCH',
256
+ `topics/${encodeURIComponent(idOrAlias)}`,
257
+ changes,
258
+ ),
259
+ get: (idOrAlias: string) =>
260
+ this.request<Awaited<ReturnType<SynomemService['topics']['get']>>>(
261
+ 'GET',
262
+ `topics/${encodeURIComponent(idOrAlias)}`,
263
+ ),
264
+ list: (input: TopicListInput = {}) =>
265
+ this.request<Awaited<ReturnType<SynomemService['topics']['list']>>>(
266
+ 'GET',
267
+ `topics${queryString(input)}`,
268
+ ),
269
+ resolve: (query: string) =>
270
+ this.request<Awaited<ReturnType<SynomemService['topics']['resolve']>>>(
271
+ 'GET',
272
+ `topics/resolve?query=${encodeURIComponent(query)}`,
273
+ ),
274
+ archive: (idOrAlias: string) =>
275
+ this.mutation<Awaited<ReturnType<SynomemService['topics']['archive']>>>(
276
+ 'POST',
277
+ `topics/${encodeURIComponent(idOrAlias)}/archive`,
278
+ {},
279
+ ),
280
+ restore: (idOrAlias: string) =>
281
+ this.mutation<Awaited<ReturnType<SynomemService['topics']['restore']>>>(
282
+ 'POST',
283
+ `topics/${encodeURIComponent(idOrAlias)}/restore`,
284
+ {},
285
+ ),
286
+ };
287
+
243
288
  readonly posts = {
244
289
  create: (input: CreatePostInput) =>
245
290
  this.mutation<Awaited<ReturnType<SynomemService['posts']['create']>>>('POST', 'posts', input),