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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +92 -0
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +22 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +164 -3
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/import.d.ts +83 -0
- package/dist/import.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +110 -2
- package/dist/mcp/index.js.map +1 -1
- package/dist/ports/repository.d.ts +10 -1
- package/dist/ports/repository.d.ts.map +1 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +6 -0
- package/dist/projections.js.map +1 -1
- package/dist/remote.d.ts +10 -1
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +9 -0
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +130 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +77 -0
- package/dist/schemas.js.map +1 -1
- package/dist/service.d.ts +14 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +12 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +136 -19
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +72 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +153 -0
- package/src/client.ts +184 -2
- package/src/errors.ts +2 -0
- package/src/mcp/index.ts +140 -1
- package/src/ports/repository.ts +8 -0
- package/src/projections.ts +6 -0
- package/src/remote.ts +45 -0
- package/src/schemas.ts +85 -0
- package/src/service.ts +18 -0
- package/src/storage.ts +167 -13
- 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 =
|
|
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
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
|
{
|
package/src/ports/repository.ts
CHANGED
|
@@ -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,
|
package/src/projections.ts
CHANGED
|
@@ -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),
|