synomem 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +99 -0
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +36 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +160 -4
- package/dist/client.js.map +1 -1
- package/dist/import.d.ts +149 -0
- package/dist/import.d.ts.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +57 -0
- package/dist/mcp/index.js.map +1 -1
- package/dist/ports/repository.d.ts +4 -1
- package/dist/ports/repository.d.ts.map +1 -1
- package/dist/projections.d.ts +9 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +71 -0
- package/dist/projections.js.map +1 -1
- package/dist/remote.d.ts +26 -1
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +10 -0
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +174 -0
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +63 -1
- package/dist/schemas.js.map +1 -1
- package/dist/service.d.ts +26 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +11 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +116 -4
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +99 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +166 -0
- package/src/client.ts +198 -2
- package/src/mcp/index.ts +76 -0
- package/src/ports/repository.ts +5 -0
- package/src/projections.ts +70 -0
- package/src/remote.ts +48 -0
- package/src/schemas.ts +65 -1
- package/src/service.ts +26 -0
- package/src/storage.ts +150 -4
- package/src/types.ts +105 -1
package/src/client.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
escapeMarkdown,
|
|
9
9
|
memoRecordsFromEvents,
|
|
10
10
|
noteRecordsFromEvents,
|
|
11
|
+
postRecordsFromEvents,
|
|
11
12
|
ProjectionManager,
|
|
12
13
|
recordsFromEvents,
|
|
13
14
|
taskRecordsFromEvents,
|
|
@@ -18,6 +19,8 @@ import {
|
|
|
18
19
|
agentLookupSchema,
|
|
19
20
|
bindRuntimeSchema,
|
|
20
21
|
createAgentSchema,
|
|
22
|
+
createPostSchema,
|
|
23
|
+
updatePostSchema,
|
|
21
24
|
createNoteSchema,
|
|
22
25
|
createTaskSchema,
|
|
23
26
|
createTodoSchema,
|
|
@@ -49,6 +52,10 @@ import type {
|
|
|
49
52
|
AgentRuntimeBinding,
|
|
50
53
|
BindRuntimeInput,
|
|
51
54
|
CreateAgentInput,
|
|
55
|
+
CreatePostInput,
|
|
56
|
+
PostRecord,
|
|
57
|
+
PostRoster,
|
|
58
|
+
UpdatePostInput,
|
|
52
59
|
Diagnostic,
|
|
53
60
|
DoctorResult,
|
|
54
61
|
GiveKudosInput,
|
|
@@ -139,6 +146,27 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
139
146
|
archive: (input: { memoId: string; idempotencyKey?: string }) => this.archiveMemo(input),
|
|
140
147
|
};
|
|
141
148
|
|
|
149
|
+
readonly posts = {
|
|
150
|
+
create: (input: CreatePostInput) => this.createPost(input),
|
|
151
|
+
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
152
|
+
this.listItems({ ...input, kinds: ['post'] }),
|
|
153
|
+
get: (id: string) => this.getPost(id),
|
|
154
|
+
update: (input: UpdatePostInput) => this.updatePost(input),
|
|
155
|
+
archive: (input: { postId: string; reason?: string; idempotencyKey?: string }) =>
|
|
156
|
+
this.archivePost(input),
|
|
157
|
+
/*
|
|
158
|
+
* Acknowledging is an explicit call and always speaks for the caller alone.
|
|
159
|
+
* There is no bulk form and no acknowledge-on-behalf-of: an acknowledgement
|
|
160
|
+
* is one actor saying "I have seen this", and reading a post must never
|
|
161
|
+
* append one, or the roster stops meaning anything.
|
|
162
|
+
*/
|
|
163
|
+
acknowledge: (input: { postId: string; note?: string; idempotencyKey?: string }) =>
|
|
164
|
+
this.acknowledgePost(input),
|
|
165
|
+
withdrawAcknowledgment: (input: { postId: string; reason?: string }) =>
|
|
166
|
+
this.withdrawPostAcknowledgment(input),
|
|
167
|
+
roster: (postId: string) => this.postRoster(postId),
|
|
168
|
+
};
|
|
169
|
+
|
|
142
170
|
readonly notes = {
|
|
143
171
|
create: (input: CreateNoteInput) => this.createNote(input),
|
|
144
172
|
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
@@ -757,6 +785,174 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
757
785
|
return await this.getMemoRecord(input.memoId);
|
|
758
786
|
}
|
|
759
787
|
|
|
788
|
+
private async getPostRecord(id: string): Promise<PostRecord> {
|
|
789
|
+
await this.requireVisibleItem(id, 'post');
|
|
790
|
+
const record = postRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
791
|
+
if (!record) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${id}`);
|
|
792
|
+
return record;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private async getPost(id: string): Promise<PostRecord> {
|
|
796
|
+
this.checkAbort();
|
|
797
|
+
return await this.getPostRecord(id);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
private async createPost(input: CreatePostInput): Promise<{
|
|
801
|
+
record: PostRecord;
|
|
802
|
+
created: boolean;
|
|
803
|
+
deduplicated: boolean;
|
|
804
|
+
}> {
|
|
805
|
+
this.checkAbort();
|
|
806
|
+
await this.repository.assertEventCompatibility();
|
|
807
|
+
const parsed = this.validate(() => createPostSchema.parse(input));
|
|
808
|
+
|
|
809
|
+
// A reply inherits its parent's workspace by construction, and cannot name
|
|
810
|
+
// a different target — there is no target to name.
|
|
811
|
+
if (parsed.replyTo) await this.requireVisibleItem(parsed.replyTo, 'post');
|
|
812
|
+
|
|
813
|
+
const outcome = await this.repository.transaction(async () => {
|
|
814
|
+
const prior = await this.priorMutation(parsed.idempotencyKey, 'post.created');
|
|
815
|
+
if (prior?.type === 'post.created') return { id: prior.id, created: false };
|
|
816
|
+
const id = this.nextId();
|
|
817
|
+
const event: SynomemEvent = {
|
|
818
|
+
...this.eventBase(id, 1, id),
|
|
819
|
+
type: 'post.created',
|
|
820
|
+
title: parsed.title,
|
|
821
|
+
body: parsed.body,
|
|
822
|
+
tags: [...new Set(parsed.tags ?? [])].sort(),
|
|
823
|
+
...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
|
|
824
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
825
|
+
...(parsed.source ? { source: parsed.source } : {}),
|
|
826
|
+
...(parsed.metadata ? { metadata: parsed.metadata } : {}),
|
|
827
|
+
};
|
|
828
|
+
await this.repository.insertEvent(event);
|
|
829
|
+
return { id, created: true };
|
|
830
|
+
});
|
|
831
|
+
return {
|
|
832
|
+
record: await this.getPostRecord(outcome.id),
|
|
833
|
+
created: outcome.created,
|
|
834
|
+
deduplicated: !outcome.created,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/** Only the author edits a post. Everyone else responds to it. */
|
|
839
|
+
private assertPostAuthor(record: PostRecord): void {
|
|
840
|
+
if (this.administrative) return;
|
|
841
|
+
if (record.event.actor.id !== this.actor.id || record.event.actor.kind !== this.actor.kind) {
|
|
842
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'Only the author can change a post.');
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private async updatePost(input: UpdatePostInput): Promise<PostRecord> {
|
|
847
|
+
this.checkAbort();
|
|
848
|
+
const parsed = this.validate(() => updatePostSchema.parse(input));
|
|
849
|
+
const record = await this.getPostRecord(parsed.postId);
|
|
850
|
+
this.assertPostAuthor(record);
|
|
851
|
+
if (record.status === 'archived') {
|
|
852
|
+
throw new SynomemError('MUTATION_FORBIDDEN', 'An archived post cannot be edited.');
|
|
853
|
+
}
|
|
854
|
+
if (record.version !== parsed.expectedVersion) {
|
|
855
|
+
throw new SynomemError(
|
|
856
|
+
'REVISION_CONFLICT',
|
|
857
|
+
`Post ${parsed.postId} is at version ${record.version}.`,
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
await this.repository.transaction(async () => {
|
|
861
|
+
const event: SynomemEvent = {
|
|
862
|
+
...this.eventBase(parsed.postId, await this.repository.nextAggregateVersion(parsed.postId)),
|
|
863
|
+
type: 'post.edited',
|
|
864
|
+
postId: parsed.postId,
|
|
865
|
+
title: parsed.title ?? record.title,
|
|
866
|
+
body: parsed.body ?? record.body,
|
|
867
|
+
tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
|
|
868
|
+
...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
|
|
869
|
+
};
|
|
870
|
+
await this.repository.insertEvent(event);
|
|
871
|
+
});
|
|
872
|
+
return await this.getPostRecord(parsed.postId);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
private async archivePost(input: {
|
|
876
|
+
postId: string;
|
|
877
|
+
reason?: string;
|
|
878
|
+
idempotencyKey?: string;
|
|
879
|
+
}): Promise<PostRecord> {
|
|
880
|
+
this.checkAbort();
|
|
881
|
+
const record = await this.getPostRecord(input.postId);
|
|
882
|
+
this.assertPostAuthor(record);
|
|
883
|
+
if (record.status !== 'archived') {
|
|
884
|
+
await this.repository.transaction(async () => {
|
|
885
|
+
const event: SynomemEvent = {
|
|
886
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
887
|
+
type: 'post.archived',
|
|
888
|
+
postId: input.postId,
|
|
889
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
890
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
891
|
+
};
|
|
892
|
+
await this.repository.insertEvent(event);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
return await this.getPostRecord(input.postId);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
private async acknowledgePost(input: {
|
|
899
|
+
postId: string;
|
|
900
|
+
note?: string;
|
|
901
|
+
idempotencyKey?: string;
|
|
902
|
+
}): Promise<PostRecord> {
|
|
903
|
+
this.checkAbort();
|
|
904
|
+
const record = await this.getPostRecord(input.postId);
|
|
905
|
+
// Acknowledging twice is the same statement, so the second is a no-op
|
|
906
|
+
// rather than a second row or an error.
|
|
907
|
+
const already = record.acknowledgments.some(
|
|
908
|
+
(entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind,
|
|
909
|
+
);
|
|
910
|
+
if (!already) {
|
|
911
|
+
await this.repository.transaction(async () => {
|
|
912
|
+
const event: SynomemEvent = {
|
|
913
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
914
|
+
type: 'post.acknowledged',
|
|
915
|
+
postId: input.postId,
|
|
916
|
+
...(input.note ? { note: input.note } : {}),
|
|
917
|
+
...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
|
|
918
|
+
};
|
|
919
|
+
await this.repository.insertEvent(event);
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
return await this.getPostRecord(input.postId);
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
private async withdrawPostAcknowledgment(input: {
|
|
926
|
+
postId: string;
|
|
927
|
+
reason?: string;
|
|
928
|
+
}): Promise<PostRecord> {
|
|
929
|
+
this.checkAbort();
|
|
930
|
+
const record = await this.getPostRecord(input.postId);
|
|
931
|
+
const mine = record.acknowledgments.some(
|
|
932
|
+
(entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind,
|
|
933
|
+
);
|
|
934
|
+
if (mine) {
|
|
935
|
+
await this.repository.transaction(async () => {
|
|
936
|
+
const event: SynomemEvent = {
|
|
937
|
+
...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
|
|
938
|
+
type: 'post.acknowledgment.withdrawn',
|
|
939
|
+
postId: input.postId,
|
|
940
|
+
...(input.reason ? { reason: input.reason } : {}),
|
|
941
|
+
};
|
|
942
|
+
await this.repository.insertEvent(event);
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
return await this.getPostRecord(input.postId);
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
private async postRoster(postId: string): Promise<PostRoster> {
|
|
949
|
+
this.checkAbort();
|
|
950
|
+
await this.requireVisibleItem(postId, 'post');
|
|
951
|
+
const roster = await this.repository.postRoster(postId);
|
|
952
|
+
if (!roster) throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${postId}`);
|
|
953
|
+
return roster;
|
|
954
|
+
}
|
|
955
|
+
|
|
760
956
|
private async getNoteRecord(id: string): Promise<NoteRecord> {
|
|
761
957
|
await this.requireVisibleItem(id, 'note');
|
|
762
958
|
const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
|
|
@@ -1343,8 +1539,8 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1343
1539
|
* check that silently lags the migration runner reports a healthy database as
|
|
1344
1540
|
* broken.
|
|
1345
1541
|
*/
|
|
1346
|
-
const CURRENT_SCHEMA_VERSION =
|
|
1347
|
-
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5];
|
|
1542
|
+
const CURRENT_SCHEMA_VERSION = 6;
|
|
1543
|
+
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6];
|
|
1348
1544
|
|
|
1349
1545
|
export class SynomemClient extends SynomemCore implements SynomemService {
|
|
1350
1546
|
readonly home: string;
|
package/src/mcp/index.ts
CHANGED
|
@@ -326,6 +326,82 @@ export async function createSynomemMcpServer(
|
|
|
326
326
|
},
|
|
327
327
|
);
|
|
328
328
|
|
|
329
|
+
server.registerTool(
|
|
330
|
+
'synomem_post_create',
|
|
331
|
+
{
|
|
332
|
+
title: 'Publish a post',
|
|
333
|
+
description:
|
|
334
|
+
'Publish something the whole workspace can read. Use for an announcement, a decision, or context several agents need. A post has no recipient — if one named actor must act, send a memo or assign a task instead.',
|
|
335
|
+
inputSchema: z.object({
|
|
336
|
+
title: z.string().trim().min(1).max(200),
|
|
337
|
+
body: z.string().trim().min(1).max(32_000),
|
|
338
|
+
tags: z.array(z.string()).max(20).optional(),
|
|
339
|
+
replyTo: z.string().length(26).optional(),
|
|
340
|
+
idempotencyKey: z.string().max(200).optional(),
|
|
341
|
+
}),
|
|
342
|
+
outputSchema,
|
|
343
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
344
|
+
},
|
|
345
|
+
async (input) => {
|
|
346
|
+
try {
|
|
347
|
+
const result = await client.posts.create(input);
|
|
348
|
+
return success(actor, `Published post ${result.record.event.id}.`, {
|
|
349
|
+
post: result.record,
|
|
350
|
+
});
|
|
351
|
+
} catch (error) {
|
|
352
|
+
return failure(actor, error);
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
server.registerTool(
|
|
358
|
+
'synomem_post_acknowledge',
|
|
359
|
+
{
|
|
360
|
+
title: 'Acknowledge a post',
|
|
361
|
+
description:
|
|
362
|
+
'Record that YOU have seen a post. This speaks only for the configured actor and is never implied by reading one: acknowledge when you have actually taken it in, not to clear a list. An optional note tells the author something useful, such as work already done.',
|
|
363
|
+
inputSchema: z.object({
|
|
364
|
+
postId: z.string().length(26),
|
|
365
|
+
note: z.string().trim().min(1).max(2000).optional(),
|
|
366
|
+
idempotencyKey: z.string().max(200).optional(),
|
|
367
|
+
}),
|
|
368
|
+
outputSchema,
|
|
369
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
370
|
+
},
|
|
371
|
+
async (input) => {
|
|
372
|
+
try {
|
|
373
|
+
const record = await client.posts.acknowledge(input);
|
|
374
|
+
return success(actor, `Acknowledged post ${input.postId}.`, { post: record });
|
|
375
|
+
} catch (error) {
|
|
376
|
+
return failure(actor, error);
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
server.registerTool(
|
|
382
|
+
'synomem_post_roster',
|
|
383
|
+
{
|
|
384
|
+
title: 'See who has acknowledged a post',
|
|
385
|
+
description:
|
|
386
|
+
'Who has acknowledged a post and who has not. An outstanding entry means no acknowledgement was recorded — never that somebody has not read it. Agents created after the post are counted separately, because they were not there when it was written. This is read-only and does not acknowledge anything.',
|
|
387
|
+
inputSchema: z.object({ postId: z.string().length(26) }),
|
|
388
|
+
outputSchema,
|
|
389
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
390
|
+
},
|
|
391
|
+
async ({ postId }) => {
|
|
392
|
+
try {
|
|
393
|
+
const roster = await client.posts.roster(postId);
|
|
394
|
+
return success(
|
|
395
|
+
actor,
|
|
396
|
+
`${roster.acknowledged.length} acknowledged, ${roster.outstanding.length} with no acknowledgement recorded.`,
|
|
397
|
+
roster,
|
|
398
|
+
);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
return failure(actor, error);
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
);
|
|
404
|
+
|
|
329
405
|
server.registerTool(
|
|
330
406
|
'synomem_agent_resolve',
|
|
331
407
|
{
|
package/src/ports/repository.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
ChangePage,
|
|
6
6
|
ItemListInput,
|
|
7
7
|
JsonValue,
|
|
8
|
+
PostAcknowledgment,
|
|
9
|
+
PostRoster,
|
|
8
10
|
ItemSummary,
|
|
9
11
|
KudosListInput,
|
|
10
12
|
KudosSummary,
|
|
@@ -45,6 +47,9 @@ export interface SynomemRepository {
|
|
|
45
47
|
listAgents(): Awaitable<AgentProfile[]>;
|
|
46
48
|
/** Resolves a name case-insensitively, reporting ambiguity instead of guessing. */
|
|
47
49
|
resolveAgent(query: string): Awaitable<{ match?: AgentProfile; candidates: AgentProfile[] }>;
|
|
50
|
+
listPostAcknowledgments(postId: string): Awaitable<PostAcknowledgment[]>;
|
|
51
|
+
/** Who has acknowledged a post and who has not; see PostRoster. */
|
|
52
|
+
postRoster(postId: string): Awaitable<PostRoster | undefined>;
|
|
48
53
|
listRuntimeBindings(agentId: string): Awaitable<AgentRuntimeBinding[]>;
|
|
49
54
|
bindRuntime(binding: {
|
|
50
55
|
id: string;
|
package/src/projections.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
KudosRecord,
|
|
17
17
|
MemoRecord,
|
|
18
18
|
NoteRecord,
|
|
19
|
+
PostRecord,
|
|
19
20
|
TaskDue,
|
|
20
21
|
TaskRecord,
|
|
21
22
|
TodoRecord,
|
|
@@ -254,6 +255,75 @@ export function memoRecordsFromEvents(events: SynomemEvent[]): MemoRecord[] {
|
|
|
254
255
|
return [...records.values()].sort((a, b) => b.event.createdAt.localeCompare(a.event.createdAt));
|
|
255
256
|
}
|
|
256
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Rebuilds posts from their events, acknowledgements included.
|
|
260
|
+
*
|
|
261
|
+
* Edits are kept as a list rather than collapsed into the current text. A reader
|
|
262
|
+
* has to be able to see that a post changed after somebody acknowledged it —
|
|
263
|
+
* silently rewriting what was acknowledged is how a record becomes a lie.
|
|
264
|
+
*/
|
|
265
|
+
export function postRecordsFromEvents(events: SynomemEvent[]): PostRecord[] {
|
|
266
|
+
const records = new Map<string, PostRecord>();
|
|
267
|
+
for (const event of events) {
|
|
268
|
+
if (event.type === 'post.created') {
|
|
269
|
+
records.set(event.id, {
|
|
270
|
+
event,
|
|
271
|
+
edits: [],
|
|
272
|
+
acknowledgments: [],
|
|
273
|
+
status: 'active',
|
|
274
|
+
title: event.title,
|
|
275
|
+
body: event.body,
|
|
276
|
+
...(event.tags ? { tags: event.tags } : {}),
|
|
277
|
+
// The text version, counting only changes to the text.
|
|
278
|
+
//
|
|
279
|
+
// Deliberately not the aggregate version, which also counts every
|
|
280
|
+
// acknowledgement. If they were the same number, somebody
|
|
281
|
+
// acknowledging a post would invalidate an edit the author was in the
|
|
282
|
+
// middle of making — a conflict with nothing to reconcile.
|
|
283
|
+
version: 1,
|
|
284
|
+
});
|
|
285
|
+
} else if (event.type === 'post.edited') {
|
|
286
|
+
const record = records.get(event.postId);
|
|
287
|
+
if (record) {
|
|
288
|
+
record.edits.push(event);
|
|
289
|
+
record.title = event.title;
|
|
290
|
+
record.body = event.body;
|
|
291
|
+
if (event.tags) record.tags = event.tags;
|
|
292
|
+
record.version += 1;
|
|
293
|
+
}
|
|
294
|
+
} else if (event.type === 'post.archived') {
|
|
295
|
+
const record = records.get(event.postId);
|
|
296
|
+
if (record) {
|
|
297
|
+
record.archived = event;
|
|
298
|
+
record.status = 'archived';
|
|
299
|
+
}
|
|
300
|
+
} else if (event.type === 'post.acknowledged') {
|
|
301
|
+
const record = records.get(event.postId);
|
|
302
|
+
if (record) {
|
|
303
|
+
// One statement per actor: a repeat replaces rather than accumulates.
|
|
304
|
+
const existing = record.acknowledgments.findIndex(
|
|
305
|
+
(entry) => entry.actor.id === event.actor.id && entry.actor.kind === event.actor.kind,
|
|
306
|
+
);
|
|
307
|
+
const entry = {
|
|
308
|
+
actor: event.actor,
|
|
309
|
+
acknowledgedAt: event.createdAt,
|
|
310
|
+
...(event.note ? { note: event.note } : {}),
|
|
311
|
+
};
|
|
312
|
+
if (existing >= 0) record.acknowledgments[existing] = entry;
|
|
313
|
+
else record.acknowledgments.push(entry);
|
|
314
|
+
}
|
|
315
|
+
} else if (event.type === 'post.acknowledgment.withdrawn') {
|
|
316
|
+
const record = records.get(event.postId);
|
|
317
|
+
if (record) {
|
|
318
|
+
record.acknowledgments = record.acknowledgments.filter(
|
|
319
|
+
(entry) => !(entry.actor.id === event.actor.id && entry.actor.kind === event.actor.kind),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return [...records.values()];
|
|
325
|
+
}
|
|
326
|
+
|
|
257
327
|
export function noteRecordsFromEvents(events: SynomemEvent[]): NoteRecord[] {
|
|
258
328
|
const records = new Map<string, NoteRecord>();
|
|
259
329
|
for (const event of events) {
|
package/src/remote.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type {
|
|
|
6
6
|
ChangesInput,
|
|
7
7
|
BindRuntimeInput,
|
|
8
8
|
CreateAgentInput,
|
|
9
|
+
CreatePostInput,
|
|
10
|
+
UpdatePostInput,
|
|
9
11
|
CreateNoteInput,
|
|
10
12
|
CreateTaskInput,
|
|
11
13
|
GiveKudosInput,
|
|
@@ -200,6 +202,52 @@ export class RemoteSynomemService implements SynomemService {
|
|
|
200
202
|
),
|
|
201
203
|
};
|
|
202
204
|
|
|
205
|
+
readonly posts = {
|
|
206
|
+
create: (input: CreatePostInput) =>
|
|
207
|
+
this.mutation<Awaited<ReturnType<SynomemService['posts']['create']>>>('POST', 'posts', input),
|
|
208
|
+
list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
|
|
209
|
+
this.request<Awaited<ReturnType<SynomemService['posts']['list']>>>(
|
|
210
|
+
'GET',
|
|
211
|
+
`posts${queryString(input)}`,
|
|
212
|
+
),
|
|
213
|
+
get: (id: string) =>
|
|
214
|
+
this.request<Awaited<ReturnType<SynomemService['posts']['get']>>>(
|
|
215
|
+
'GET',
|
|
216
|
+
`posts/${encodeURIComponent(id)}`,
|
|
217
|
+
),
|
|
218
|
+
update: (input: UpdatePostInput) =>
|
|
219
|
+
this.mutation<Awaited<ReturnType<SynomemService['posts']['update']>>>(
|
|
220
|
+
'POST',
|
|
221
|
+
`posts/${encodeURIComponent(input.postId)}/revisions`,
|
|
222
|
+
input,
|
|
223
|
+
['postId'],
|
|
224
|
+
),
|
|
225
|
+
archive: (input: { postId: string; reason?: string; idempotencyKey?: string }) =>
|
|
226
|
+
this.mutation<Awaited<ReturnType<SynomemService['posts']['archive']>>>(
|
|
227
|
+
'POST',
|
|
228
|
+
`posts/${encodeURIComponent(input.postId)}/archive`,
|
|
229
|
+
input,
|
|
230
|
+
['postId'],
|
|
231
|
+
),
|
|
232
|
+
acknowledge: (input: { postId: string; note?: string; idempotencyKey?: string }) =>
|
|
233
|
+
this.mutation<Awaited<ReturnType<SynomemService['posts']['acknowledge']>>>(
|
|
234
|
+
'POST',
|
|
235
|
+
`posts/${encodeURIComponent(input.postId)}/acknowledgment`,
|
|
236
|
+
input,
|
|
237
|
+
['postId'],
|
|
238
|
+
),
|
|
239
|
+
withdrawAcknowledgment: (input: { postId: string; reason?: string }) =>
|
|
240
|
+
this.request<Awaited<ReturnType<SynomemService['posts']['withdrawAcknowledgment']>>>(
|
|
241
|
+
'DELETE',
|
|
242
|
+
`posts/${encodeURIComponent(input.postId)}/acknowledgment`,
|
|
243
|
+
),
|
|
244
|
+
roster: (postId: string) =>
|
|
245
|
+
this.request<Awaited<ReturnType<SynomemService['posts']['roster']>>>(
|
|
246
|
+
'GET',
|
|
247
|
+
`posts/${encodeURIComponent(postId)}/roster`,
|
|
248
|
+
),
|
|
249
|
+
};
|
|
250
|
+
|
|
203
251
|
readonly kudos = {
|
|
204
252
|
give: (input: GiveKudosInput) =>
|
|
205
253
|
this.mutation<Awaited<ReturnType<SynomemService['kudos']['give']>>>('POST', 'kudos', input),
|
package/src/schemas.ts
CHANGED
|
@@ -257,6 +257,47 @@ const noteArchivedSchema = baseEventSchema.extend({
|
|
|
257
257
|
noteId: z.string().length(26),
|
|
258
258
|
});
|
|
259
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Post events. A post carries no recipient, assignee or visibility: it is
|
|
262
|
+
* addressed to the workspace, and workspace membership is the audience. Adding
|
|
263
|
+
* a visibility field would create a second, weaker way to hide a record.
|
|
264
|
+
*/
|
|
265
|
+
const postFields = {
|
|
266
|
+
title: z
|
|
267
|
+
.string()
|
|
268
|
+
.trim()
|
|
269
|
+
.min(1)
|
|
270
|
+
.max(200)
|
|
271
|
+
.regex(/^[^\r\n]+$/),
|
|
272
|
+
body: z.string().trim().min(1).max(32_000),
|
|
273
|
+
tags: z.array(kudosTagSchema).max(20).optional(),
|
|
274
|
+
};
|
|
275
|
+
const postCreatedSchema = baseEventSchema.extend({
|
|
276
|
+
type: z.literal('post.created'),
|
|
277
|
+
...postFields,
|
|
278
|
+
replyTo: z.string().length(26).optional(),
|
|
279
|
+
});
|
|
280
|
+
const postEditedSchema = baseEventSchema.extend({
|
|
281
|
+
type: z.literal('post.edited'),
|
|
282
|
+
postId: z.string().length(26),
|
|
283
|
+
...postFields,
|
|
284
|
+
});
|
|
285
|
+
const postArchivedSchema = baseEventSchema.extend({
|
|
286
|
+
type: z.literal('post.archived'),
|
|
287
|
+
postId: z.string().length(26),
|
|
288
|
+
reason: z.string().trim().min(1).max(2000).optional(),
|
|
289
|
+
});
|
|
290
|
+
const postAcknowledgedSchema = baseEventSchema.extend({
|
|
291
|
+
type: z.literal('post.acknowledged'),
|
|
292
|
+
postId: z.string().length(26),
|
|
293
|
+
note: z.string().trim().min(1).max(2000).optional(),
|
|
294
|
+
});
|
|
295
|
+
const postAcknowledgmentWithdrawnSchema = baseEventSchema.extend({
|
|
296
|
+
type: z.literal('post.acknowledgment.withdrawn'),
|
|
297
|
+
postId: z.string().length(26),
|
|
298
|
+
reason: z.string().trim().min(1).max(2000).optional(),
|
|
299
|
+
});
|
|
300
|
+
|
|
260
301
|
const taskDueSchema = z.discriminatedUnion('kind', [
|
|
261
302
|
z.object({
|
|
262
303
|
kind: z.literal('date'),
|
|
@@ -380,6 +421,11 @@ const todoArchivedSchema = baseEventSchema.extend({
|
|
|
380
421
|
});
|
|
381
422
|
|
|
382
423
|
export const eventSchema = z.discriminatedUnion('type', [
|
|
424
|
+
postCreatedSchema,
|
|
425
|
+
postEditedSchema,
|
|
426
|
+
postArchivedSchema,
|
|
427
|
+
postAcknowledgedSchema,
|
|
428
|
+
postAcknowledgmentWithdrawnSchema,
|
|
383
429
|
kudosGivenSchema,
|
|
384
430
|
acknowledgedSchema,
|
|
385
431
|
revokedSchema,
|
|
@@ -477,6 +523,24 @@ export const sendMemoSchema = z
|
|
|
477
523
|
...mutationMetadata,
|
|
478
524
|
})
|
|
479
525
|
.strict();
|
|
526
|
+
export const createPostSchema = z
|
|
527
|
+
.object({
|
|
528
|
+
...postFields,
|
|
529
|
+
replyTo: z.string().length(26).optional(),
|
|
530
|
+
...mutationMetadata,
|
|
531
|
+
})
|
|
532
|
+
.strict();
|
|
533
|
+
export const updatePostSchema = z
|
|
534
|
+
.object({
|
|
535
|
+
postId: z.string().length(26),
|
|
536
|
+
expectedVersion: z.number().int().min(1),
|
|
537
|
+
title: postFields.title.optional(),
|
|
538
|
+
body: postFields.body.optional(),
|
|
539
|
+
tags: postFields.tags,
|
|
540
|
+
idempotencyKey: z.string().trim().min(1).max(200).optional(),
|
|
541
|
+
})
|
|
542
|
+
.strict();
|
|
543
|
+
|
|
480
544
|
export const createNoteSchema = z
|
|
481
545
|
.object({
|
|
482
546
|
ownerAgentId: agentIdSchema.optional(),
|
|
@@ -553,7 +617,7 @@ export const updateTodoSchema = z
|
|
|
553
617
|
export const itemListInputSchema = z
|
|
554
618
|
.object({
|
|
555
619
|
kinds: z
|
|
556
|
-
.array(z.enum(['kudos', 'memo', 'note', 'task', 'todo']))
|
|
620
|
+
.array(z.enum(['kudos', 'memo', 'note', 'post', 'task', 'todo']))
|
|
557
621
|
.max(5)
|
|
558
622
|
.optional(),
|
|
559
623
|
participantAgentId: agentIdSchema.optional(),
|
package/src/service.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type {
|
|
|
2
2
|
ActorIdentity,
|
|
3
3
|
AgentDirectoryEntry,
|
|
4
4
|
AgentProfile,
|
|
5
|
+
CreatePostInput,
|
|
6
|
+
PostRecord,
|
|
7
|
+
PostRoster,
|
|
8
|
+
UpdatePostInput,
|
|
5
9
|
AgentResolution,
|
|
6
10
|
AgentRuntimeBinding,
|
|
7
11
|
BindRuntimeInput,
|
|
@@ -80,6 +84,28 @@ export interface SynomemDomainService {
|
|
|
80
84
|
bindRuntime(input: BindRuntimeInput): Promise<AgentRuntimeBinding>;
|
|
81
85
|
unbindRuntime(bindingId: string): Promise<boolean>;
|
|
82
86
|
};
|
|
87
|
+
readonly posts: {
|
|
88
|
+
create(input: CreatePostInput): Promise<{
|
|
89
|
+
record: PostRecord;
|
|
90
|
+
created: boolean;
|
|
91
|
+
deduplicated: boolean;
|
|
92
|
+
}>;
|
|
93
|
+
list(input?: Omit<ItemListInput, 'kinds'>): Promise<Page<ItemSummary>>;
|
|
94
|
+
get(id: string): Promise<PostRecord>;
|
|
95
|
+
update(input: UpdatePostInput): Promise<PostRecord>;
|
|
96
|
+
archive(input: {
|
|
97
|
+
postId: string;
|
|
98
|
+
reason?: string;
|
|
99
|
+
idempotencyKey?: string;
|
|
100
|
+
}): Promise<PostRecord>;
|
|
101
|
+
acknowledge(input: {
|
|
102
|
+
postId: string;
|
|
103
|
+
note?: string;
|
|
104
|
+
idempotencyKey?: string;
|
|
105
|
+
}): Promise<PostRecord>;
|
|
106
|
+
withdrawAcknowledgment(input: { postId: string; reason?: string }): Promise<PostRecord>;
|
|
107
|
+
roster(postId: string): Promise<PostRoster>;
|
|
108
|
+
};
|
|
83
109
|
readonly kudos: {
|
|
84
110
|
give(input: GiveKudosInput): Promise<GiveKudosResult>;
|
|
85
111
|
list(input?: KudosListInput): Promise<Page<KudosSummary>>;
|