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.
Files changed (46) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +99 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/client.d.ts +36 -1
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +160 -4
  8. package/dist/client.js.map +1 -1
  9. package/dist/import.d.ts +149 -0
  10. package/dist/import.d.ts.map +1 -1
  11. package/dist/mcp/index.d.ts.map +1 -1
  12. package/dist/mcp/index.js +57 -0
  13. package/dist/mcp/index.js.map +1 -1
  14. package/dist/ports/repository.d.ts +4 -1
  15. package/dist/ports/repository.d.ts.map +1 -1
  16. package/dist/projections.d.ts +9 -1
  17. package/dist/projections.d.ts.map +1 -1
  18. package/dist/projections.js +71 -0
  19. package/dist/projections.js.map +1 -1
  20. package/dist/remote.d.ts +26 -1
  21. package/dist/remote.d.ts.map +1 -1
  22. package/dist/remote.js +10 -0
  23. package/dist/remote.js.map +1 -1
  24. package/dist/schemas.d.ts +174 -0
  25. package/dist/schemas.d.ts.map +1 -1
  26. package/dist/schemas.js +63 -1
  27. package/dist/schemas.js.map +1 -1
  28. package/dist/service.d.ts +26 -1
  29. package/dist/service.d.ts.map +1 -1
  30. package/dist/storage.d.ts +11 -1
  31. package/dist/storage.d.ts.map +1 -1
  32. package/dist/storage.js +116 -4
  33. package/dist/storage.js.map +1 -1
  34. package/dist/types.d.ts +99 -2
  35. package/dist/types.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/cli.ts +166 -0
  38. package/src/client.ts +198 -2
  39. package/src/mcp/index.ts +76 -0
  40. package/src/ports/repository.ts +5 -0
  41. package/src/projections.ts +70 -0
  42. package/src/remote.ts +48 -0
  43. package/src/schemas.ts +65 -1
  44. package/src/service.ts +26 -0
  45. package/src/storage.ts +150 -4
  46. 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 = 5;
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
  {
@@ -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;
@@ -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>>;