synomem 0.1.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 (130) hide show
  1. package/AGENTS.md +42 -0
  2. package/ARCHITECTURE.md +126 -0
  3. package/CHANGELOG.md +35 -0
  4. package/CODE_OF_CONDUCT.md +29 -0
  5. package/CONTRIBUTING.md +48 -0
  6. package/LICENSE +21 -0
  7. package/README.md +247 -0
  8. package/SECURITY.md +117 -0
  9. package/dist/backend.d.ts +9 -0
  10. package/dist/backend.d.ts.map +1 -0
  11. package/dist/backend.js +60 -0
  12. package/dist/backend.js.map +1 -0
  13. package/dist/cli.d.ts +34 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +1040 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/client.d.ts +168 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1031 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/config.d.ts +33 -0
  22. package/dist/config.d.ts.map +1 -0
  23. package/dist/config.js +153 -0
  24. package/dist/config.js.map +1 -0
  25. package/dist/credentials.d.ts +36 -0
  26. package/dist/credentials.d.ts.map +1 -0
  27. package/dist/credentials.js +127 -0
  28. package/dist/credentials.js.map +1 -0
  29. package/dist/errors.d.ts +9 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +60 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/fs-utils.d.ts +7 -0
  34. package/dist/fs-utils.d.ts.map +1 -0
  35. package/dist/fs-utils.js +68 -0
  36. package/dist/fs-utils.js.map +1 -0
  37. package/dist/import.d.ts +655 -0
  38. package/dist/import.d.ts.map +1 -0
  39. package/dist/import.js +226 -0
  40. package/dist/import.js.map +1 -0
  41. package/dist/index.d.ts +20 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +11 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/mcp/index.d.ts +14 -0
  46. package/dist/mcp/index.d.ts.map +1 -0
  47. package/dist/mcp/index.js +698 -0
  48. package/dist/mcp/index.js.map +1 -0
  49. package/dist/mcp-server.d.ts +3 -0
  50. package/dist/mcp-server.d.ts.map +1 -0
  51. package/dist/mcp-server.js +45 -0
  52. package/dist/mcp-server.js.map +1 -0
  53. package/dist/oauth.d.ts +27 -0
  54. package/dist/oauth.d.ts.map +1 -0
  55. package/dist/oauth.js +201 -0
  56. package/dist/oauth.js.map +1 -0
  57. package/dist/ports/projections.d.ts +6 -0
  58. package/dist/ports/projections.d.ts.map +1 -0
  59. package/dist/ports/projections.js +2 -0
  60. package/dist/ports/projections.js.map +1 -0
  61. package/dist/ports/repository.d.ts +31 -0
  62. package/dist/ports/repository.d.ts.map +1 -0
  63. package/dist/ports/repository.js +2 -0
  64. package/dist/ports/repository.js.map +1 -0
  65. package/dist/projections.d.ts +24 -0
  66. package/dist/projections.d.ts.map +1 -0
  67. package/dist/projections.js +531 -0
  68. package/dist/projections.js.map +1 -0
  69. package/dist/remote.d.ts +120 -0
  70. package/dist/remote.d.ts.map +1 -0
  71. package/dist/remote.js +286 -0
  72. package/dist/remote.js.map +1 -0
  73. package/dist/schemas.d.ts +901 -0
  74. package/dist/schemas.d.ts.map +1 -0
  75. package/dist/schemas.js +424 -0
  76. package/dist/schemas.js.map +1 -0
  77. package/dist/service.d.ts +126 -0
  78. package/dist/service.d.ts.map +1 -0
  79. package/dist/service.js +2 -0
  80. package/dist/service.js.map +1 -0
  81. package/dist/skill-install.d.ts +34 -0
  82. package/dist/skill-install.d.ts.map +1 -0
  83. package/dist/skill-install.js +256 -0
  84. package/dist/skill-install.js.map +1 -0
  85. package/dist/storage.d.ts +94 -0
  86. package/dist/storage.d.ts.map +1 -0
  87. package/dist/storage.js +1229 -0
  88. package/dist/storage.js.map +1 -0
  89. package/dist/types.d.ts +442 -0
  90. package/dist/types.d.ts.map +1 -0
  91. package/dist/types.js +2 -0
  92. package/dist/types.js.map +1 -0
  93. package/dist/version.d.ts +3 -0
  94. package/dist/version.d.ts.map +1 -0
  95. package/dist/version.js +14 -0
  96. package/dist/version.js.map +1 -0
  97. package/docs/cli.md +180 -0
  98. package/docs/examples.md +70 -0
  99. package/docs/mcp.md +96 -0
  100. package/docs/recovery.md +45 -0
  101. package/docs/releasing.md +61 -0
  102. package/docs/skill.md +93 -0
  103. package/docs/storage-format.md +83 -0
  104. package/openapi/synomem-v1.yaml +674 -0
  105. package/package.json +100 -0
  106. package/skills/synomem/SKILL.md +71 -0
  107. package/skills/synomem/agents/openai.yaml +8 -0
  108. package/skills/synomem/references/examples.md +28 -0
  109. package/src/backend.ts +91 -0
  110. package/src/cli.ts +1623 -0
  111. package/src/client.ts +1261 -0
  112. package/src/config.ts +170 -0
  113. package/src/credentials.ts +194 -0
  114. package/src/errors.ts +64 -0
  115. package/src/fs-utils.ts +85 -0
  116. package/src/import.ts +284 -0
  117. package/src/index.ts +61 -0
  118. package/src/mcp/index.ts +973 -0
  119. package/src/mcp-server.ts +46 -0
  120. package/src/oauth.ts +252 -0
  121. package/src/ports/projections.ts +6 -0
  122. package/src/ports/repository.ts +67 -0
  123. package/src/projections.ts +624 -0
  124. package/src/remote.ts +516 -0
  125. package/src/schemas.ts +457 -0
  126. package/src/service.ts +140 -0
  127. package/src/skill-install.ts +332 -0
  128. package/src/storage.ts +1603 -0
  129. package/src/types.ts +458 -0
  130. package/src/version.ts +16 -0
package/src/client.ts ADDED
@@ -0,0 +1,1261 @@
1
+ import { accessSync, constants as fsConstants, existsSync, lstatSync } from 'node:fs';
2
+ import { join, relative, resolve } from 'node:path';
3
+ import { ulid } from 'ulid';
4
+ import { resolveHome } from './config.js';
5
+ import { asSynomemError, SynomemError } from './errors.js';
6
+ import { assertNoSymlinkEscape } from './fs-utils.js';
7
+ import {
8
+ escapeMarkdown,
9
+ memoRecordsFromEvents,
10
+ noteRecordsFromEvents,
11
+ ProjectionManager,
12
+ recordsFromEvents,
13
+ todoRecordsFromEvents,
14
+ } from './projections.js';
15
+ import {
16
+ actorSchema,
17
+ agentIdSchema,
18
+ createAgentSchema,
19
+ createNoteSchema,
20
+ createTodoSchema,
21
+ changesInputSchema,
22
+ giveKudosSchema,
23
+ itemListInputSchema,
24
+ listInputSchema,
25
+ reviseNoteSchema,
26
+ sendMemoSchema,
27
+ updateTodoSchema,
28
+ updateAgentSchema,
29
+ } from './schemas.js';
30
+ import { SynomemStorage } from './storage.js';
31
+ import type {
32
+ SynomemService,
33
+ SynomemDomainService,
34
+ SynomemServiceCapabilities,
35
+ SynomemServiceInfo,
36
+ ProjectionRebuildResult,
37
+ } from './service.js';
38
+ import type { SynomemRepository } from './ports/repository.js';
39
+ import type { ProjectionWriter } from './ports/projections.js';
40
+ import type {
41
+ ActorIdentity,
42
+ AgentProfile,
43
+ CreateAgentInput,
44
+ Diagnostic,
45
+ DoctorResult,
46
+ GiveKudosInput,
47
+ GiveKudosResult,
48
+ SendMemoInput,
49
+ SendMemoResult,
50
+ MemoRecord,
51
+ CreateNoteInput,
52
+ CreateNoteResult,
53
+ ReviseNoteInput,
54
+ NoteRecord,
55
+ CreateTodoInput,
56
+ CreateTodoResult,
57
+ UpdateTodoInput,
58
+ TodoRecord,
59
+ ItemListInput,
60
+ ItemRecord,
61
+ ItemSummary,
62
+ ChangesInput,
63
+ KudosChangesInput,
64
+ KudosAcknowledgedEvent,
65
+ SynomemClientOptions,
66
+ SynomemEvent,
67
+ KudosGivenEvent,
68
+ KudosListInput,
69
+ KudosRecord,
70
+ KudosRevokedEvent,
71
+ KudosStats,
72
+ KudosSummary,
73
+ ChangePage,
74
+ Page,
75
+ UpdateAgentInput,
76
+ } from './types.js';
77
+
78
+ export interface SynomemCoreOptions {
79
+ repository: SynomemRepository;
80
+ projectionWriter: ProjectionWriter;
81
+ actor?: ActorIdentity;
82
+ clock?: () => Date;
83
+ idGenerator?: () => string;
84
+ signal?: AbortSignal;
85
+ /** Explicit administrator authority. Defaults to true only for local-style human actors. */
86
+ administrative?: boolean;
87
+ }
88
+
89
+ export class SynomemCore implements SynomemDomainService {
90
+ readonly actor: ActorIdentity;
91
+ private readonly repository: SynomemRepository;
92
+ private readonly projectionWriter: ProjectionWriter;
93
+ private readonly clock: () => Date;
94
+ private readonly idGenerator: () => string;
95
+ private readonly signal?: AbortSignal;
96
+ private readonly administrative: boolean;
97
+ private initialized = false;
98
+
99
+ readonly agents = {
100
+ create: (input: CreateAgentInput) => this.createAgent(input),
101
+ update: (id: string, changes: UpdateAgentInput) => this.updateAgent(id, changes),
102
+ get: (idOrAlias: string) => this.getAgent(idOrAlias),
103
+ list: () => this.listAgents(),
104
+ };
105
+
106
+ readonly kudos = {
107
+ give: (input: GiveKudosInput) => this.giveKudos(input),
108
+ list: (input: KudosListInput = {}) => this.listKudos(input),
109
+ changes: (input: KudosChangesInput = {}) => this.listKudosChanges(input),
110
+ get: (id: string) => this.getKudos(id),
111
+ acknowledge: (input: { kudosId: string; note?: string }) => this.acknowledgeKudos(input),
112
+ revoke: (input: { kudosId: string; reason: string; administrative?: boolean }) =>
113
+ this.revokeKudos(input),
114
+ };
115
+
116
+ readonly memos = {
117
+ send: (input: SendMemoInput) => this.sendMemo(input),
118
+ list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
119
+ this.listItems({ ...input, kinds: ['memo'] }),
120
+ get: (id: string) => this.getMemo(id),
121
+ read: (input: { memoId: string; idempotencyKey?: string }) => this.readMemo(input),
122
+ archive: (input: { memoId: string; idempotencyKey?: string }) => this.archiveMemo(input),
123
+ };
124
+
125
+ readonly notes = {
126
+ create: (input: CreateNoteInput) => this.createNote(input),
127
+ list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
128
+ this.listItems({ ...input, kinds: ['note'] }),
129
+ get: (id: string) => this.getNote(id),
130
+ revise: (input: ReviseNoteInput) => this.reviseNote(input),
131
+ archive: (input: { noteId: string; idempotencyKey?: string }) => this.archiveNote(input),
132
+ };
133
+
134
+ readonly todos = {
135
+ create: (input: CreateTodoInput) => this.createTodo(input),
136
+ list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
137
+ this.listItems({ ...input, kinds: ['todo'] }),
138
+ get: (id: string) => this.getTodo(id),
139
+ update: (input: UpdateTodoInput) => this.updateTodo(input),
140
+ accept: (input: { todoId: string; idempotencyKey?: string }) => this.acceptTodo(input),
141
+ reject: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
142
+ this.rejectTodo(input),
143
+ complete: (input: { todoId: string; note?: string; idempotencyKey?: string }) =>
144
+ this.completeTodo(input),
145
+ reopen: (input: { todoId: string; idempotencyKey?: string }) => this.reopenTodo(input),
146
+ cancel: (input: { todoId: string; reason?: string; idempotencyKey?: string }) =>
147
+ this.cancelTodo(input),
148
+ };
149
+
150
+ readonly items = {
151
+ list: (input: ItemListInput = {}) => this.listItems(input),
152
+ get: (id: string) => this.getItem(id),
153
+ changes: (input: ChangesInput = {}) => this.listItemChanges(input),
154
+ };
155
+
156
+ constructor(options: SynomemCoreOptions) {
157
+ try {
158
+ this.actor = actorSchema.parse(options.actor ?? { kind: 'system', id: 'workspace' });
159
+ } catch (error) {
160
+ throw asSynomemError(error);
161
+ }
162
+ this.clock = options.clock ?? (() => new Date());
163
+ this.idGenerator = options.idGenerator ?? (() => ulid(this.clock().getTime()));
164
+ this.signal = options.signal;
165
+ this.administrative = options.administrative ?? this.actor.kind === 'human';
166
+ this.repository = options.repository;
167
+ this.projectionWriter = options.projectionWriter;
168
+ }
169
+
170
+ async init(): Promise<void> {
171
+ this.checkAbort();
172
+ if (this.initialized) return;
173
+ await this.repository.init();
174
+ this.initialized = true;
175
+ }
176
+
177
+ async close(): Promise<void> {
178
+ await this.repository.close();
179
+ this.initialized = false;
180
+ }
181
+
182
+ private now(): string {
183
+ return this.clock().toISOString();
184
+ }
185
+
186
+ private nextId(): string {
187
+ return this.idGenerator();
188
+ }
189
+
190
+ private eventBase(aggregateId: string, aggregateVersion: number, id = this.nextId()) {
191
+ return {
192
+ schemaVersion: 1 as const,
193
+ id,
194
+ workspaceId: this.repository.config.workspaceId,
195
+ aggregateId,
196
+ aggregateVersion,
197
+ createdAt: this.now(),
198
+ actor: this.actor,
199
+ };
200
+ }
201
+
202
+ protected checkAbort(): void {
203
+ this.signal?.throwIfAborted();
204
+ }
205
+
206
+ private validate<T>(operation: () => T): T {
207
+ try {
208
+ return operation();
209
+ } catch (error) {
210
+ throw asSynomemError(error);
211
+ }
212
+ }
213
+
214
+ private async createAgent(input: CreateAgentInput): Promise<AgentProfile> {
215
+ this.checkAbort();
216
+ await this.repository.assertEventCompatibility();
217
+ const parsed = this.validate(() => createAgentSchema.parse(input));
218
+ if (await this.repository.getAgent(parsed.id)) {
219
+ throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.id}`);
220
+ }
221
+ const aliases = [...new Set(parsed.aliases ?? [])].sort();
222
+ if (aliases.includes(parsed.id)) {
223
+ throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own ID as an alias.');
224
+ }
225
+ for (const alias of aliases) {
226
+ if (await this.repository.getAgent(alias)) {
227
+ throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to an agent: ${alias}`);
228
+ }
229
+ }
230
+ const profile: AgentProfile = {
231
+ id: parsed.id,
232
+ displayName: parsed.displayName,
233
+ ...(aliases.length ? { aliases } : {}),
234
+ ...(parsed.description !== undefined ? { description: parsed.description } : {}),
235
+ createdAt: this.now(),
236
+ ...(parsed.metadata !== undefined ? { metadata: parsed.metadata } : {}),
237
+ };
238
+ const event: SynomemEvent = {
239
+ ...this.eventBase(profile.id, 1),
240
+ type: 'agent.created',
241
+ agent: profile,
242
+ };
243
+ await this.repository.transaction(async () => {
244
+ await this.repository.insertAgent(profile);
245
+ await this.repository.insertEvent(event);
246
+ });
247
+ await this.projectionWriter.syncAgent(profile.id);
248
+ return profile;
249
+ }
250
+
251
+ private async updateAgent(idOrAlias: string, changes: UpdateAgentInput): Promise<AgentProfile> {
252
+ this.checkAbort();
253
+ await this.repository.assertEventCompatibility();
254
+ this.validate(() => agentIdSchema.parse(idOrAlias));
255
+ const parsed = this.validate(() => updateAgentSchema.parse(changes));
256
+ const existing = await this.repository.getAgent(idOrAlias);
257
+ if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
258
+ const aliases = parsed.aliases ? [...new Set(parsed.aliases)].sort() : existing.aliases;
259
+ if (aliases?.includes(existing.id)) {
260
+ throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own ID as an alias.');
261
+ }
262
+ for (const alias of aliases ?? []) {
263
+ const owner = await this.repository.getAgent(alias);
264
+ if (owner && owner.id !== existing.id) {
265
+ throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to ${owner.id}: ${alias}`);
266
+ }
267
+ }
268
+ const updated: AgentProfile = {
269
+ ...existing,
270
+ ...parsed,
271
+ ...(aliases?.length ? { aliases } : { aliases: undefined }),
272
+ };
273
+ const changesForEvent = { ...parsed, ...(parsed.aliases ? { aliases } : {}) };
274
+ await this.repository.transaction(async () => {
275
+ const event: SynomemEvent = {
276
+ ...this.eventBase(existing.id, await this.repository.nextAggregateVersion(existing.id)),
277
+ type: 'agent.updated',
278
+ agentId: existing.id,
279
+ changes: changesForEvent,
280
+ };
281
+ await this.repository.updateAgent(updated, event.createdAt);
282
+ await this.repository.insertEvent(event);
283
+ });
284
+ await this.projectionWriter.syncAgent(updated.id);
285
+ return updated;
286
+ }
287
+
288
+ private async getAgent(idOrAlias: string): Promise<AgentProfile> {
289
+ this.checkAbort();
290
+ this.validate(() => agentIdSchema.parse(idOrAlias));
291
+ const profile = await this.repository.getAgent(idOrAlias);
292
+ if (!profile) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
293
+ return profile;
294
+ }
295
+
296
+ private async listAgents(): Promise<AgentProfile[]> {
297
+ this.checkAbort();
298
+ return await this.repository.listAgents();
299
+ }
300
+
301
+ private async giveKudos(input: GiveKudosInput): Promise<GiveKudosResult> {
302
+ this.checkAbort();
303
+ await this.repository.assertEventCompatibility();
304
+ const parsed = this.validate(() =>
305
+ giveKudosSchema.parse({
306
+ ...input,
307
+ visibility: input.visibility ?? this.repository.config.defaultVisibility,
308
+ }),
309
+ );
310
+ const recipient = await this.repository.getAgent(parsed.recipientAgentId);
311
+ if (!recipient) {
312
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipientAgentId}`);
313
+ }
314
+ if (
315
+ !this.repository.config.allowSelfAwards &&
316
+ this.actor.kind !== 'human' &&
317
+ this.actor.id === recipient.id
318
+ ) {
319
+ throw new SynomemError(
320
+ 'SELF_AWARD_FORBIDDEN',
321
+ 'Non-human actors cannot award kudos to a matching agent identity.',
322
+ );
323
+ }
324
+
325
+ const outcome = await this.repository.transaction(async () => {
326
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'kudos.given');
327
+ if (prior?.type === 'kudos.given') return { event: prior, created: false };
328
+ const id = this.nextId();
329
+ const event: KudosGivenEvent = {
330
+ ...this.eventBase(id, 1, id),
331
+ type: 'kudos.given',
332
+ recipientAgentId: recipient.id,
333
+ recipientDisplayName: recipient.displayName,
334
+ title: parsed.title,
335
+ reason: parsed.reason,
336
+ visibility: parsed.visibility,
337
+ ...(parsed.evidence ? { evidence: parsed.evidence } : {}),
338
+ ...(parsed.tags ? { tags: [...new Set(parsed.tags)].sort() } : {}),
339
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
340
+ ...(parsed.source ? { source: parsed.source } : {}),
341
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
342
+ };
343
+ await this.repository.insertEvent(event);
344
+ return { event, created: true };
345
+ });
346
+ if (outcome.created) await this.projectionWriter.syncAgent(recipient.id);
347
+ const record = await this.getKudosRecord(outcome.event.id);
348
+ return { record, created: outcome.created, deduplicated: !outcome.created };
349
+ }
350
+
351
+ private async getKudosRecord(id: string): Promise<KudosRecord> {
352
+ await this.requireVisibleItem(id, 'kudos');
353
+ const record = recordsFromEvents(await this.repository.getReadableSynomemEvents(id))[0];
354
+ if (!record) throw new SynomemError('KUDOS_NOT_FOUND', `Unknown kudos: ${id}`);
355
+ return record;
356
+ }
357
+
358
+ private async getKudos(id: string): Promise<KudosRecord> {
359
+ this.checkAbort();
360
+ return await this.getKudosRecord(id);
361
+ }
362
+
363
+ private async listKudos(input: KudosListInput): Promise<Page<KudosSummary>> {
364
+ this.checkAbort();
365
+ const filters = this.validate(() => listInputSchema.parse(input));
366
+ const recipient = filters.recipientAgentId
367
+ ? await this.repository.getAgent(filters.recipientAgentId)
368
+ : undefined;
369
+ if (filters.recipientAgentId && !recipient) {
370
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipientAgentId}`);
371
+ }
372
+ return await this.repository.listKudosSummaries(
373
+ {
374
+ ...filters,
375
+ ...(recipient ? { recipientAgentId: recipient.id } : {}),
376
+ },
377
+ this.actor,
378
+ );
379
+ }
380
+
381
+ private async listKudosChanges(input: KudosChangesInput): Promise<ChangePage> {
382
+ this.checkAbort();
383
+ const parsed = this.validate(() => changesInputSchema.parse(input));
384
+ return await this.repository.listKudosChanges(parsed.after, parsed.limit, this.actor);
385
+ }
386
+
387
+ private async acknowledgeKudos(input: { kudosId: string; note?: string }): Promise<KudosRecord> {
388
+ this.checkAbort();
389
+ await this.repository.assertEventCompatibility();
390
+ if (input.note !== undefined && (input.note.trim().length < 1 || input.note.length > 2000)) {
391
+ throw new SynomemError('INVALID_INPUT', 'Acknowledgment notes must be 1–2000 characters.');
392
+ }
393
+ const record = await this.getKudosRecord(input.kudosId);
394
+ if (record.acknowledgment) return record;
395
+ if (record.revocation)
396
+ throw new SynomemError('INVALID_INPUT', 'Revoked kudos cannot be acknowledged.');
397
+ const isRecipient =
398
+ this.actor.kind === 'agent' && this.actor.id === record.event.recipientAgentId;
399
+ if (!this.administrative && !isRecipient) {
400
+ throw new SynomemError(
401
+ 'ACKNOWLEDGMENT_FORBIDDEN',
402
+ 'Only the recipient agent or a human administrator may acknowledge kudos.',
403
+ );
404
+ }
405
+ const event: KudosAcknowledgedEvent = {
406
+ ...this.eventBase(record.event.id, 2),
407
+ type: 'kudos.acknowledged',
408
+ kudosId: record.event.id,
409
+ recipientAgentId: record.event.recipientAgentId,
410
+ ...(input.note ? { note: input.note.trim() } : {}),
411
+ };
412
+ await this.repository.transaction(() => this.repository.insertEvent(event));
413
+ await this.projectionWriter.syncAgent(record.event.recipientAgentId);
414
+ return await this.getKudosRecord(input.kudosId);
415
+ }
416
+
417
+ private async revokeKudos(input: {
418
+ kudosId: string;
419
+ reason: string;
420
+ administrative?: boolean;
421
+ }): Promise<KudosRecord> {
422
+ this.checkAbort();
423
+ await this.repository.assertEventCompatibility();
424
+ const reason = input.reason.trim();
425
+ if (!reason || reason.length > 2000) {
426
+ throw new SynomemError('INVALID_INPUT', 'Revocation reasons must be 1–2000 characters.');
427
+ }
428
+ const record = await this.getKudosRecord(input.kudosId);
429
+ if (record.revocation) return record;
430
+ const isOriginalActor =
431
+ record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
432
+ if (input.administrative === true && !this.administrative) {
433
+ throw new SynomemError(
434
+ 'REVOCATION_FORBIDDEN',
435
+ 'Only an administrator may request an administrative revocation.',
436
+ );
437
+ }
438
+ const administrative = this.administrative && !isOriginalActor;
439
+ if (!isOriginalActor && !this.administrative) {
440
+ throw new SynomemError(
441
+ 'REVOCATION_FORBIDDEN',
442
+ 'Only the original actor or an administrator may revoke kudos.',
443
+ );
444
+ }
445
+ const event: KudosRevokedEvent = {
446
+ ...this.eventBase(record.event.id, record.acknowledgment ? 3 : 2),
447
+ type: 'kudos.revoked',
448
+ kudosId: record.event.id,
449
+ reason,
450
+ mode: administrative && !isOriginalActor ? 'administrative' : 'actor-requested',
451
+ };
452
+ await this.repository.transaction(() => this.repository.insertEvent(event));
453
+ await this.projectionWriter.syncAgent(record.event.recipientAgentId);
454
+ return await this.getKudosRecord(input.kudosId);
455
+ }
456
+
457
+ private async priorMutation(
458
+ idempotencyKey: string | undefined,
459
+ expectedType: SynomemEvent['type'],
460
+ ) {
461
+ if (!idempotencyKey) return undefined;
462
+ const prior = await this.repository.getEventByIdempotency(
463
+ this.actor.kind,
464
+ this.actor.id,
465
+ idempotencyKey,
466
+ );
467
+ if (prior && prior.type !== expectedType) {
468
+ throw new SynomemError(
469
+ 'IDEMPOTENCY_CONFLICT',
470
+ `Idempotency key was already used for ${prior.type}.`,
471
+ );
472
+ }
473
+ return prior;
474
+ }
475
+
476
+ private canViewItem(summary: ItemSummary): boolean {
477
+ return (
478
+ this.administrative ||
479
+ summary.visibility !== 'private' ||
480
+ (summary.actor.kind === this.actor.kind && summary.actor.id === this.actor.id) ||
481
+ (this.actor.kind === 'agent' &&
482
+ (summary.recipientAgentId === this.actor.id ||
483
+ summary.ownerAgentId === this.actor.id ||
484
+ summary.assigneeAgentId === this.actor.id))
485
+ );
486
+ }
487
+
488
+ private async requireVisibleItem(id: string, kind: ItemSummary['kind']): Promise<ItemSummary> {
489
+ const summary = await this.repository.getItemSummary(id);
490
+ if (!summary || summary.kind !== kind) {
491
+ const code =
492
+ kind === 'memo'
493
+ ? 'MEMO_NOT_FOUND'
494
+ : kind === 'note'
495
+ ? 'NOTE_NOT_FOUND'
496
+ : kind === 'todo'
497
+ ? 'TODO_NOT_FOUND'
498
+ : 'KUDOS_NOT_FOUND';
499
+ throw new SynomemError(code, `Unknown ${kind}: ${id}`);
500
+ }
501
+ if (!this.canViewItem(summary)) {
502
+ throw new SynomemError(
503
+ 'POLICY_FORBIDDEN',
504
+ `This ${kind} is not visible to the configured actor.`,
505
+ );
506
+ }
507
+ return summary;
508
+ }
509
+
510
+ private async getMemoRecord(id: string): Promise<MemoRecord> {
511
+ await this.requireVisibleItem(id, 'memo');
512
+ const record = memoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
513
+ if (!record) throw new SynomemError('MEMO_NOT_FOUND', `Unknown memo: ${id}`);
514
+ return record;
515
+ }
516
+
517
+ private async getMemo(id: string): Promise<MemoRecord> {
518
+ this.checkAbort();
519
+ return await this.getMemoRecord(id);
520
+ }
521
+
522
+ private async sendMemo(input: SendMemoInput): Promise<SendMemoResult> {
523
+ this.checkAbort();
524
+ await this.repository.assertEventCompatibility();
525
+ const parsed = this.validate(() => sendMemoSchema.parse(input));
526
+ const recipient = await this.repository.getAgent(parsed.recipientAgentId);
527
+ if (!recipient)
528
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipientAgentId}`);
529
+ const outcome = await this.repository.transaction(async () => {
530
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'memo.sent');
531
+ if (prior?.type === 'memo.sent') return { id: prior.id, created: false };
532
+ const id = this.nextId();
533
+ const event: SynomemEvent = {
534
+ ...this.eventBase(id, 1, id),
535
+ type: 'memo.sent',
536
+ recipientAgentId: recipient.id,
537
+ recipientDisplayName: recipient.displayName,
538
+ subject: parsed.subject,
539
+ body: parsed.body,
540
+ tags: [...new Set(parsed.tags ?? [])].sort(),
541
+ visibility: parsed.visibility ?? this.repository.config.defaultVisibility,
542
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
543
+ ...(parsed.source ? { source: parsed.source } : {}),
544
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
545
+ };
546
+ await this.repository.insertEvent(event);
547
+ return { id, created: true };
548
+ });
549
+ if (outcome.created) await this.projectionWriter.syncAgent(recipient.id);
550
+ return {
551
+ record: await this.getMemoRecord(outcome.id),
552
+ created: outcome.created,
553
+ deduplicated: !outcome.created,
554
+ };
555
+ }
556
+
557
+ private assertRecipient(recipientAgentId: string, operation: string): void {
558
+ if (
559
+ !this.administrative &&
560
+ !(this.actor.kind === 'agent' && this.actor.id === recipientAgentId)
561
+ ) {
562
+ throw new SynomemError(
563
+ 'MUTATION_FORBIDDEN',
564
+ `Only the recipient agent or a human administrator may ${operation}.`,
565
+ );
566
+ }
567
+ }
568
+
569
+ private async readMemo(input: { memoId: string; idempotencyKey?: string }): Promise<MemoRecord> {
570
+ const record = await this.getMemoRecord(input.memoId);
571
+ this.assertRecipient(record.event.recipientAgentId, 'mark this memo read');
572
+ if (record.read) return record;
573
+ await this.repository.transaction(async () => {
574
+ const prior = await this.priorMutation(input.idempotencyKey, 'memo.read');
575
+ if (prior) return;
576
+ const event: SynomemEvent = {
577
+ ...this.eventBase(
578
+ record.event.id,
579
+ await this.repository.nextAggregateVersion(record.event.id),
580
+ ),
581
+ type: 'memo.read',
582
+ memoId: record.event.id,
583
+ recipientAgentId: record.event.recipientAgentId,
584
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
585
+ };
586
+ await this.repository.insertEvent(event);
587
+ });
588
+ await this.projectionWriter.syncAgent(record.event.recipientAgentId);
589
+ return await this.getMemoRecord(input.memoId);
590
+ }
591
+
592
+ private async archiveMemo(input: {
593
+ memoId: string;
594
+ idempotencyKey?: string;
595
+ }): Promise<MemoRecord> {
596
+ const record = await this.getMemoRecord(input.memoId);
597
+ this.assertRecipient(record.event.recipientAgentId, 'archive this memo');
598
+ if (record.archived) return record;
599
+ await this.repository.transaction(async () => {
600
+ const prior = await this.priorMutation(input.idempotencyKey, 'memo.archived');
601
+ if (prior) return;
602
+ const event: SynomemEvent = {
603
+ ...this.eventBase(
604
+ record.event.id,
605
+ await this.repository.nextAggregateVersion(record.event.id),
606
+ ),
607
+ type: 'memo.archived',
608
+ memoId: record.event.id,
609
+ recipientAgentId: record.event.recipientAgentId,
610
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
611
+ };
612
+ await this.repository.insertEvent(event);
613
+ });
614
+ await this.projectionWriter.syncAgent(record.event.recipientAgentId);
615
+ return await this.getMemoRecord(input.memoId);
616
+ }
617
+
618
+ private async getNoteRecord(id: string): Promise<NoteRecord> {
619
+ await this.requireVisibleItem(id, 'note');
620
+ const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
621
+ if (!record) throw new SynomemError('NOTE_NOT_FOUND', `Unknown note: ${id}`);
622
+ return record;
623
+ }
624
+ private async getNote(id: string): Promise<NoteRecord> {
625
+ this.checkAbort();
626
+ return await this.getNoteRecord(id);
627
+ }
628
+
629
+ private async createNote(input: CreateNoteInput): Promise<CreateNoteResult> {
630
+ this.checkAbort();
631
+ await this.repository.assertEventCompatibility();
632
+ const parsed = this.validate(() => createNoteSchema.parse(input));
633
+ const ownerId =
634
+ parsed.ownerAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
635
+ if (!ownerId)
636
+ throw new SynomemError('INVALID_INPUT', 'A human or system actor must specify ownerAgentId.');
637
+ const owner = await this.repository.getAgent(ownerId);
638
+ if (!owner) throw new SynomemError('AGENT_NOT_FOUND', `Unknown note owner: ${ownerId}`);
639
+ if (!this.administrative && (this.actor.kind !== 'agent' || this.actor.id !== owner.id)) {
640
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Agents may create notes only for themselves.');
641
+ }
642
+ const outcome = await this.repository.transaction(async () => {
643
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'note.created');
644
+ if (prior?.type === 'note.created') return { id: prior.id, created: false };
645
+ const id = this.nextId();
646
+ const event: SynomemEvent = {
647
+ ...this.eventBase(id, 1, id),
648
+ type: 'note.created',
649
+ ownerAgentId: owner.id,
650
+ ownerDisplayName: owner.displayName,
651
+ title: parsed.title,
652
+ body: parsed.body,
653
+ tags: [...new Set(parsed.tags ?? [])].sort(),
654
+ visibility: 'private',
655
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
656
+ ...(parsed.source ? { source: parsed.source } : {}),
657
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
658
+ };
659
+ await this.repository.insertEvent(event);
660
+ return { id, created: true };
661
+ });
662
+ if (outcome.created) await this.projectionWriter.syncAgent(owner.id);
663
+ return {
664
+ record: await this.getNoteRecord(outcome.id),
665
+ created: outcome.created,
666
+ deduplicated: !outcome.created,
667
+ };
668
+ }
669
+
670
+ private assertNoteOwner(record: NoteRecord): void {
671
+ if (
672
+ !this.administrative &&
673
+ !(this.actor.kind === 'agent' && this.actor.id === record.event.ownerAgentId)
674
+ ) {
675
+ throw new SynomemError(
676
+ 'MUTATION_FORBIDDEN',
677
+ 'Only the note owner or a human administrator may change it.',
678
+ );
679
+ }
680
+ }
681
+
682
+ private async reviseNote(input: ReviseNoteInput): Promise<NoteRecord> {
683
+ const parsed = this.validate(() => reviseNoteSchema.parse(input));
684
+ const record = await this.getNoteRecord(parsed.noteId);
685
+ this.assertNoteOwner(record);
686
+ if (record.status === 'archived')
687
+ throw new SynomemError('INVALID_INPUT', 'Archived notes cannot be revised.');
688
+ if (parsed.expectedVersion !== record.current.version)
689
+ throw new SynomemError(
690
+ 'REVISION_CONFLICT',
691
+ `Expected note version ${parsed.expectedVersion}; current version is ${record.current.version}.`,
692
+ );
693
+ await this.repository.transaction(async () => {
694
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'note.revised');
695
+ if (prior) return;
696
+ if (
697
+ (await this.repository.nextAggregateVersion(record.event.id)) !==
698
+ parsed.expectedVersion + 1
699
+ )
700
+ throw new SynomemError(
701
+ 'REVISION_CONFLICT',
702
+ 'The note changed before this revision was stored.',
703
+ );
704
+ const event: SynomemEvent = {
705
+ ...this.eventBase(record.event.id, parsed.expectedVersion + 1),
706
+ type: 'note.revised',
707
+ noteId: record.event.id,
708
+ title: parsed.title ?? record.current.title,
709
+ body: parsed.body ?? record.current.body,
710
+ tags: parsed.tags ?? record.current.tags,
711
+ visibility: 'private',
712
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
713
+ ...(parsed.source ? { source: parsed.source } : {}),
714
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
715
+ };
716
+ await this.repository.insertEvent(event);
717
+ });
718
+ await this.projectionWriter.syncAgent(record.event.ownerAgentId);
719
+ return await this.getNoteRecord(parsed.noteId);
720
+ }
721
+
722
+ private async archiveNote(input: {
723
+ noteId: string;
724
+ idempotencyKey?: string;
725
+ }): Promise<NoteRecord> {
726
+ const record = await this.getNoteRecord(input.noteId);
727
+ this.assertNoteOwner(record);
728
+ if (record.archived) return record;
729
+ await this.repository.transaction(async () => {
730
+ const prior = await this.priorMutation(input.idempotencyKey, 'note.archived');
731
+ if (prior) return;
732
+ const event: SynomemEvent = {
733
+ ...this.eventBase(
734
+ record.event.id,
735
+ await this.repository.nextAggregateVersion(record.event.id),
736
+ ),
737
+ type: 'note.archived',
738
+ noteId: record.event.id,
739
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
740
+ };
741
+ await this.repository.insertEvent(event);
742
+ });
743
+ await this.projectionWriter.syncAgent(record.event.ownerAgentId);
744
+ return await this.getNoteRecord(input.noteId);
745
+ }
746
+
747
+ private async getTodoRecord(id: string): Promise<TodoRecord> {
748
+ await this.requireVisibleItem(id, 'todo');
749
+ const record = todoRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
750
+ if (!record) throw new SynomemError('TODO_NOT_FOUND', `Unknown todo: ${id}`);
751
+ return record;
752
+ }
753
+ private async getTodo(id: string): Promise<TodoRecord> {
754
+ this.checkAbort();
755
+ return await this.getTodoRecord(id);
756
+ }
757
+
758
+ private async createTodo(input: CreateTodoInput): Promise<CreateTodoResult> {
759
+ this.checkAbort();
760
+ await this.repository.assertEventCompatibility();
761
+ const parsed = this.validate(() => createTodoSchema.parse(input));
762
+ const assigneeId =
763
+ parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
764
+ if (!assigneeId)
765
+ throw new SynomemError(
766
+ 'INVALID_INPUT',
767
+ 'A human or system actor must specify assigneeAgentId.',
768
+ );
769
+ const assignee = await this.repository.getAgent(assigneeId);
770
+ if (!assignee)
771
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown todo assignee: ${assigneeId}`);
772
+ if (
773
+ this.actor.kind === 'agent' &&
774
+ this.actor.id !== assignee.id &&
775
+ !this.repository.config.allowCrossAgentTodos
776
+ )
777
+ throw new SynomemError('POLICY_FORBIDDEN', 'Cross-agent todo assignment is disabled.');
778
+ const outcome = await this.repository.transaction(async () => {
779
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
780
+ if (prior?.type === 'todo.created') return { id: prior.id, created: false };
781
+ const id = this.nextId();
782
+ const requiresAcceptance = this.actor.kind !== 'agent' || this.actor.id !== assignee.id;
783
+ const event: SynomemEvent = {
784
+ ...this.eventBase(id, 1, id),
785
+ type: 'todo.created',
786
+ assigneeAgentId: assignee.id,
787
+ assigneeDisplayName: assignee.displayName,
788
+ title: parsed.title,
789
+ ...(parsed.description !== undefined ? { description: parsed.description } : {}),
790
+ priority: parsed.priority ?? 3,
791
+ ...(parsed.due ? { due: parsed.due } : {}),
792
+ tags: [...new Set(parsed.tags ?? [])].sort(),
793
+ visibility: parsed.visibility ?? this.repository.config.defaultVisibility,
794
+ requiresAcceptance,
795
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
796
+ ...(parsed.source ? { source: parsed.source } : {}),
797
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
798
+ };
799
+ await this.repository.insertEvent(event);
800
+ return { id, created: true };
801
+ });
802
+ if (outcome.created) await this.projectionWriter.syncAgent(assignee.id);
803
+ return {
804
+ record: await this.getTodoRecord(outcome.id),
805
+ created: outcome.created,
806
+ deduplicated: !outcome.created,
807
+ };
808
+ }
809
+
810
+ private assertTodoParticipant(record: TodoRecord): void {
811
+ const isCreator =
812
+ record.event.actor.kind === this.actor.kind && record.event.actor.id === this.actor.id;
813
+ const isAssignee =
814
+ this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId;
815
+ if (!this.administrative && !isCreator && !isAssignee)
816
+ throw new SynomemError(
817
+ 'MUTATION_FORBIDDEN',
818
+ 'Only the todo creator, assignee, or a human administrator may change it.',
819
+ );
820
+ }
821
+
822
+ private assertTodoAssignee(record: TodoRecord): void {
823
+ if (
824
+ !this.administrative &&
825
+ !(this.actor.kind === 'agent' && this.actor.id === record.event.assigneeAgentId)
826
+ ) {
827
+ throw new SynomemError(
828
+ 'MUTATION_FORBIDDEN',
829
+ 'Only the assigned agent or a human administrator may accept or reject this todo.',
830
+ );
831
+ }
832
+ }
833
+
834
+ private async updateTodo(input: UpdateTodoInput): Promise<TodoRecord> {
835
+ const parsed = this.validate(() => updateTodoSchema.parse(input));
836
+ const record = await this.getTodoRecord(parsed.todoId);
837
+ this.assertTodoParticipant(record);
838
+ if (record.status !== 'open')
839
+ throw new SynomemError('INVALID_INPUT', 'Only open todos can be updated.');
840
+ if (parsed.expectedVersion !== record.current.version)
841
+ throw new SynomemError(
842
+ 'REVISION_CONFLICT',
843
+ `Expected todo version ${parsed.expectedVersion}; current version is ${record.current.version}.`,
844
+ );
845
+ await this.repository.transaction(async () => {
846
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.updated');
847
+ if (prior) return;
848
+ if (
849
+ (await this.repository.nextAggregateVersion(record.event.id)) !==
850
+ parsed.expectedVersion + 1
851
+ )
852
+ throw new SynomemError(
853
+ 'REVISION_CONFLICT',
854
+ 'The todo changed before this update was stored.',
855
+ );
856
+ const due = parsed.due === null ? undefined : (parsed.due ?? record.current.due);
857
+ const event: SynomemEvent = {
858
+ ...this.eventBase(record.event.id, parsed.expectedVersion + 1),
859
+ type: 'todo.updated',
860
+ todoId: record.event.id,
861
+ title: parsed.title ?? record.current.title,
862
+ ...(parsed.description !== undefined
863
+ ? { description: parsed.description }
864
+ : record.current.description !== undefined
865
+ ? { description: record.current.description }
866
+ : {}),
867
+ priority: parsed.priority ?? record.current.priority,
868
+ ...(due ? { due } : {}),
869
+ tags: parsed.tags ?? record.current.tags,
870
+ visibility: parsed.visibility ?? record.current.visibility,
871
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
872
+ ...(parsed.source ? { source: parsed.source } : {}),
873
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
874
+ };
875
+ await this.repository.insertEvent(event);
876
+ });
877
+ await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
878
+ return await this.getTodoRecord(parsed.todoId);
879
+ }
880
+
881
+ private async todoTransition(
882
+ input: { todoId: string; idempotencyKey?: string; note?: string; reason?: string },
883
+ type: 'todo.accepted' | 'todo.rejected' | 'todo.completed' | 'todo.reopened' | 'todo.canceled',
884
+ ): Promise<TodoRecord> {
885
+ const record = await this.getTodoRecord(input.todoId);
886
+ if (type === 'todo.accepted' || type === 'todo.rejected') this.assertTodoAssignee(record);
887
+ else this.assertTodoParticipant(record);
888
+ if ((type === 'todo.accepted' || type === 'todo.rejected') && record.status !== 'assigned') {
889
+ if (type === 'todo.accepted' && record.status === 'open') return record;
890
+ if (type === 'todo.rejected' && record.status === 'rejected') return record;
891
+ throw new SynomemError('INVALID_INPUT', 'Only assigned todos may be accepted or rejected.');
892
+ }
893
+ if (type === 'todo.completed' && record.status !== 'open') {
894
+ if (record.status === 'completed') return record;
895
+ throw new SynomemError(
896
+ 'INVALID_INPUT',
897
+ 'Only accepted or self-created open todos may be completed.',
898
+ );
899
+ }
900
+ if (type === 'todo.reopened' && record.status !== 'completed' && record.status !== 'canceled') {
901
+ if (record.status === 'open') return record;
902
+ throw new SynomemError('INVALID_INPUT', 'Only completed or canceled todos may be reopened.');
903
+ }
904
+ if (type === 'todo.canceled' && record.status !== 'assigned' && record.status !== 'open') {
905
+ if (record.status === 'canceled') return record;
906
+ throw new SynomemError('INVALID_INPUT', 'Only assigned or open todos may be canceled.');
907
+ }
908
+ await this.repository.transaction(async () => {
909
+ const prior = await this.priorMutation(input.idempotencyKey, type);
910
+ if (prior) return;
911
+ const base = {
912
+ ...this.eventBase(
913
+ record.event.id,
914
+ await this.repository.nextAggregateVersion(record.event.id),
915
+ ),
916
+ todoId: record.event.id,
917
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
918
+ };
919
+ const event: SynomemEvent =
920
+ type === 'todo.completed'
921
+ ? { ...base, type, ...(input.note ? { note: input.note.trim() } : {}) }
922
+ : type === 'todo.canceled' || type === 'todo.rejected'
923
+ ? { ...base, type, ...(input.reason ? { reason: input.reason.trim() } : {}) }
924
+ : { ...base, type };
925
+ await this.repository.insertEvent(event);
926
+ });
927
+ await this.projectionWriter.syncAgent(record.event.assigneeAgentId);
928
+ return await this.getTodoRecord(input.todoId);
929
+ }
930
+ private completeTodo(input: { todoId: string; note?: string; idempotencyKey?: string }) {
931
+ return this.todoTransition(input, 'todo.completed');
932
+ }
933
+ private acceptTodo(input: { todoId: string; idempotencyKey?: string }) {
934
+ return this.todoTransition(input, 'todo.accepted');
935
+ }
936
+ private rejectTodo(input: { todoId: string; reason?: string; idempotencyKey?: string }) {
937
+ return this.todoTransition(input, 'todo.rejected');
938
+ }
939
+ private reopenTodo(input: { todoId: string; idempotencyKey?: string }) {
940
+ return this.todoTransition(input, 'todo.reopened');
941
+ }
942
+ private cancelTodo(input: { todoId: string; reason?: string; idempotencyKey?: string }) {
943
+ return this.todoTransition(input, 'todo.canceled');
944
+ }
945
+
946
+ private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
947
+ this.checkAbort();
948
+ const parsed = this.validate(() => itemListInputSchema.parse(input));
949
+ return await this.repository.listItemSummaries(parsed, this.actor);
950
+ }
951
+ private async listItemChanges(input: ChangesInput): Promise<ChangePage> {
952
+ this.checkAbort();
953
+ const parsed = this.validate(() =>
954
+ changesInputSchema
955
+ .extend({
956
+ kinds: itemListInputSchema.shape.kinds,
957
+ })
958
+ .parse(input),
959
+ );
960
+ return await this.repository.listItemChanges(
961
+ parsed.after,
962
+ parsed.limit,
963
+ this.actor,
964
+ parsed.kinds,
965
+ );
966
+ }
967
+ private async getItem(id: string): Promise<ItemRecord> {
968
+ const summary = await this.repository.getItemSummary(id);
969
+ if (!summary) throw new SynomemError('ITEM_NOT_FOUND', `Unknown item: ${id}`);
970
+ if (!this.canViewItem(summary)) {
971
+ throw new SynomemError(
972
+ 'POLICY_FORBIDDEN',
973
+ 'This item is not visible to the configured actor.',
974
+ );
975
+ }
976
+ if (summary.kind === 'kudos') return this.getKudos(id);
977
+ if (summary.kind === 'memo') return this.getMemo(id);
978
+ if (summary.kind === 'note') return this.getNote(id);
979
+ return this.getTodo(id);
980
+ }
981
+
982
+ async stats(input: KudosListInput = {}): Promise<KudosStats> {
983
+ this.checkAbort();
984
+ const parsed = this.validate(() => listInputSchema.parse(input));
985
+ const { cursor: _cursor, limit: _limit, offset: _offset, ...filters } = parsed;
986
+ void _cursor;
987
+ void _limit;
988
+ void _offset;
989
+ const recipient = filters.recipientAgentId
990
+ ? await this.repository.getAgent(filters.recipientAgentId)
991
+ : undefined;
992
+ if (filters.recipientAgentId && !recipient) {
993
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${filters.recipientAgentId}`);
994
+ }
995
+ const records: KudosSummary[] = [];
996
+ let cursor: string | undefined;
997
+ const viewer = this.actor;
998
+ let hasMore = true;
999
+ while (hasMore) {
1000
+ const page = await this.repository.listKudosSummaries(
1001
+ {
1002
+ ...filters,
1003
+ ...(recipient ? { recipientAgentId: recipient.id } : {}),
1004
+ limit: 50,
1005
+ offset: 0,
1006
+ ...(cursor ? { cursor } : {}),
1007
+ },
1008
+ viewer,
1009
+ );
1010
+ records.push(...page.items);
1011
+ cursor = page.nextCursor;
1012
+ hasMore = page.hasMore && Boolean(cursor) && page.items.length > 0;
1013
+ }
1014
+ const includedRecords = this.repository.config.includePrivateInStats
1015
+ ? records
1016
+ : records.filter((record) => record.visibility !== 'private');
1017
+ const stats: KudosStats = {
1018
+ total: includedRecords.length,
1019
+ active: includedRecords.filter((record) => record.revocationStatus === 'active').length,
1020
+ acknowledged: includedRecords.filter(
1021
+ (record) => record.status === 'acknowledged' && record.revocationStatus === 'active',
1022
+ ).length,
1023
+ revoked: includedRecords.filter((record) => record.revocationStatus === 'revoked').length,
1024
+ byAgent: {},
1025
+ byActor: {},
1026
+ byTag: {},
1027
+ };
1028
+ for (const record of includedRecords) {
1029
+ stats.byAgent[record.recipientAgentId] = (stats.byAgent[record.recipientAgentId] ?? 0) + 1;
1030
+ const actor = `${record.actor.kind}:${record.actor.id}`;
1031
+ stats.byActor[actor] = (stats.byActor[actor] ?? 0) + 1;
1032
+ for (const tag of record.tags) stats.byTag[tag] = (stats.byTag[tag] ?? 0) + 1;
1033
+ }
1034
+ return stats;
1035
+ }
1036
+ }
1037
+
1038
+ export class SynomemClient extends SynomemCore implements SynomemService {
1039
+ readonly home: string;
1040
+ readonly storage: SynomemStorage;
1041
+ readonly projections: ProjectionManager;
1042
+
1043
+ constructor(options: SynomemClientOptions = {}) {
1044
+ const home = resolveHome(options.home);
1045
+ const storage = new SynomemStorage({
1046
+ home,
1047
+ readOnly: options.readOnly ?? false,
1048
+ ...(options.config ? { config: options.config } : {}),
1049
+ });
1050
+ const projections = new ProjectionManager(storage);
1051
+ super({
1052
+ repository: storage,
1053
+ projectionWriter: projections,
1054
+ ...(options.actor ? { actor: options.actor } : {}),
1055
+ ...(options.clock ? { clock: options.clock } : {}),
1056
+ ...(options.idGenerator ? { idGenerator: options.idGenerator } : {}),
1057
+ ...(options.signal ? { signal: options.signal } : {}),
1058
+ });
1059
+ this.home = home;
1060
+ this.storage = storage;
1061
+ this.projections = projections;
1062
+ }
1063
+
1064
+ async doctor(): Promise<DoctorResult> {
1065
+ this.checkAbort();
1066
+ const diagnostics: Diagnostic[] = [];
1067
+ try {
1068
+ try {
1069
+ accessSync(this.home, fsConstants.R_OK | (this.storage.readOnly ? 0 : fsConstants.W_OK));
1070
+ diagnostics.push({
1071
+ level: 'ok',
1072
+ code: 'HOME_PERMISSIONS_OK',
1073
+ message: `Storage home is ${this.storage.readOnly ? 'readable' : 'readable and writable'}.`,
1074
+ });
1075
+ } catch {
1076
+ diagnostics.push({
1077
+ level: 'error',
1078
+ code: 'HOME_PERMISSIONS_FAILED',
1079
+ message: 'Storage home permissions do not permit the configured access mode.',
1080
+ path: this.home,
1081
+ });
1082
+ }
1083
+ const integrity = this.storage.integrityCheck();
1084
+ if (integrity.length === 1 && integrity[0] === 'ok') {
1085
+ diagnostics.push({
1086
+ level: 'ok',
1087
+ code: 'SQLITE_INTEGRITY_OK',
1088
+ message: 'SQLite integrity check passed.',
1089
+ });
1090
+ } else {
1091
+ diagnostics.push({
1092
+ level: 'error',
1093
+ code: 'SQLITE_INTEGRITY_FAILED',
1094
+ message: integrity.join('; '),
1095
+ });
1096
+ }
1097
+ const journal = this.storage.journalMode();
1098
+ diagnostics.push({
1099
+ level: journal === 'wal' || this.storage.readOnly ? 'ok' : 'warning',
1100
+ code: 'SQLITE_JOURNAL_MODE',
1101
+ message: `SQLite journal mode is ${journal}.`,
1102
+ });
1103
+ const eventScan = this.storage.scanEvents();
1104
+ if (eventScan.invalid.length) {
1105
+ for (const invalid of eventScan.invalid) {
1106
+ diagnostics.push({
1107
+ level: 'error',
1108
+ code: invalid.error.code,
1109
+ message: invalid.error.message,
1110
+ });
1111
+ }
1112
+ } else {
1113
+ diagnostics.push({
1114
+ level: 'ok',
1115
+ code: 'EVENTS_VALID',
1116
+ message: `${eventScan.events.length} canonical event${eventScan.events.length === 1 ? '' : 's'} validated.`,
1117
+ });
1118
+ }
1119
+ const indexHealth = this.storage.currentIndexHealth();
1120
+ const indexValid =
1121
+ indexHealth.given === indexHealth.indexed && indexHealth.stateMismatches === 0;
1122
+ diagnostics.push({
1123
+ level: indexValid ? 'ok' : 'error',
1124
+ code: indexValid ? 'CURRENT_INDEX_VALID' : 'CURRENT_INDEX_INCONSISTENT',
1125
+ message: `${indexHealth.indexed} of ${indexHealth.given} kudos are present in the current-state index; ${indexHealth.stateMismatches} state mismatch${indexHealth.stateMismatches === 1 ? '' : 'es'} detected.`,
1126
+ });
1127
+ const itemHealth = this.storage.itemIndexHealth();
1128
+ const itemsValid = itemHealth.created === itemHealth.indexed;
1129
+ diagnostics.push({
1130
+ level: itemsValid ? 'ok' : 'error',
1131
+ code: itemsValid ? 'ITEM_INDEX_VALID' : 'ITEM_INDEX_INCONSISTENT',
1132
+ message: `${itemHealth.indexed} of ${itemHealth.created} item aggregates are present in the shared current-state index.`,
1133
+ });
1134
+ const migrationState = this.storage.migrationState();
1135
+ const migrationsValid =
1136
+ migrationState.schemaVersion === 3 &&
1137
+ JSON.stringify(migrationState.appliedVersions) === JSON.stringify([1, 2, 3]);
1138
+ diagnostics.push({
1139
+ level: migrationsValid ? 'ok' : 'error',
1140
+ code: migrationsValid ? 'MIGRATIONS_VALID' : 'MIGRATIONS_INCONSISTENT',
1141
+ message: `Database schema version is ${migrationState.schemaVersion}; recorded migrations: ${migrationState.appliedVersions.join(', ') || 'none'}.`,
1142
+ });
1143
+ const aliasConflicts = this.storage.aliasIdentityConflicts();
1144
+ diagnostics.push({
1145
+ level: aliasConflicts.length ? 'error' : 'ok',
1146
+ code: aliasConflicts.length ? 'ALIAS_CONFLICTS_FOUND' : 'ALIASES_VALID',
1147
+ message: aliasConflicts.length
1148
+ ? `Aliases collide with direct agent identities: ${aliasConflicts.map((item) => `${item.alias}→${item.agentId}`).join(', ')}.`
1149
+ : 'No aliases collide with direct agent identities.',
1150
+ });
1151
+ const expected = this.projections.expectedPaths();
1152
+ const manifest = this.storage.projectionManifest().sort();
1153
+ const stale = JSON.stringify(expected) === JSON.stringify(manifest) ? [] : expected;
1154
+ diagnostics.push({
1155
+ level: stale.length ? 'warning' : 'ok',
1156
+ code: stale.length ? 'PROJECTIONS_STALE' : 'PROJECTIONS_CURRENT',
1157
+ message: stale.length
1158
+ ? 'Generated projections need rebuilding.'
1159
+ : 'Projection manifest is current.',
1160
+ });
1161
+ for (const profile of this.storage.listAgents()) {
1162
+ const directory = join(this.home, profile.id);
1163
+ try {
1164
+ assertNoSymlinkEscape(this.home, directory);
1165
+ if (existsSync(directory) && lstatSync(directory).isSymbolicLink()) {
1166
+ throw new SynomemError('UNSAFE_PATH', 'Agent directory is a symbolic link.');
1167
+ }
1168
+ } catch (error) {
1169
+ diagnostics.push({
1170
+ level: 'error',
1171
+ code: 'UNSAFE_SYMLINK',
1172
+ message: error instanceof Error ? error.message : String(error),
1173
+ path: relative(this.home, directory),
1174
+ });
1175
+ }
1176
+ }
1177
+ } catch (error) {
1178
+ diagnostics.push({
1179
+ level: 'error',
1180
+ code: error instanceof SynomemError ? error.code : 'DOCTOR_FAILED',
1181
+ message: error instanceof Error ? error.message : String(error),
1182
+ });
1183
+ }
1184
+ return { healthy: !diagnostics.some((item) => item.level === 'error'), diagnostics };
1185
+ }
1186
+
1187
+ async export(format: 'json' | 'jsonl' | 'markdown'): Promise<string> {
1188
+ this.checkAbort();
1189
+ const rows = this.storage.rawEventRows();
1190
+ if (format === 'jsonl') return `${rows.map((row) => row.payload).join('\n')}\n`;
1191
+ const exported = rows.map((row) => {
1192
+ try {
1193
+ return JSON.parse(row.payload) as unknown;
1194
+ } catch {
1195
+ return { _synomemUnreadableEvent: { id: row.id, rawPayload: row.payload } };
1196
+ }
1197
+ });
1198
+ if (format === 'json') return `${JSON.stringify(exported, null, 2)}\n`;
1199
+ const scan = this.storage.scanEvents();
1200
+ const events = scan.events;
1201
+ const records = recordsFromEvents(events);
1202
+ const memos = memoRecordsFromEvents(events);
1203
+ const notes = noteRecordsFromEvents(events);
1204
+ const todos = todoRecordsFromEvents(events);
1205
+ const warning = scan.invalid.length
1206
+ ? `> Warning: ${scan.invalid.length} unsupported or malformed event(s) omitted from this Markdown view: ${scan.invalid.map((item) => item.id).join(', ')}\n\n`
1207
+ : '';
1208
+ const sections = [
1209
+ ...records.map(
1210
+ (record) =>
1211
+ `## Kudos: ${escapeMarkdown(record.event.title)}\n\n${escapeMarkdown(record.event.reason)}\n\nStatus: ${record.revocationStatus === 'revoked' ? 'Revoked' : record.status}\n\nID: \`${record.event.id}\``,
1212
+ ),
1213
+ ...memos.map(
1214
+ (record) =>
1215
+ `## Memo: ${escapeMarkdown(record.event.subject)}\n\n${escapeMarkdown(record.event.body)}\n\nStatus: ${record.status}\n\nID: \`${record.event.id}\``,
1216
+ ),
1217
+ ...notes.map(
1218
+ (record) =>
1219
+ `## Note: ${escapeMarkdown(record.current.title)}\n\n${escapeMarkdown(record.current.body)}\n\nStatus: ${record.status}; version ${record.current.version}\n\nID: \`${record.event.id}\``,
1220
+ ),
1221
+ ...todos.map(
1222
+ (record) =>
1223
+ `## Todo: ${escapeMarkdown(record.current.title)}\n\n${record.current.description ? `${escapeMarkdown(record.current.description)}\n\n` : ''}Status: ${record.status}; priority ${record.current.priority}\n\nID: \`${record.event.id}\``,
1224
+ ),
1225
+ ];
1226
+ return `${warning}${sections.join('\n\n')}\n`;
1227
+ }
1228
+
1229
+ async backup(destination: string): Promise<string> {
1230
+ this.checkAbort();
1231
+ return this.storage.backup(resolve(destination));
1232
+ }
1233
+
1234
+ async rebuild(): Promise<ProjectionRebuildResult> {
1235
+ this.checkAbort();
1236
+ return this.projections.rebuild();
1237
+ }
1238
+
1239
+ async capabilities(): Promise<SynomemServiceCapabilities> {
1240
+ this.checkAbort();
1241
+ return {
1242
+ backend: 'local',
1243
+ binding: { workspaceId: this.storage.config.workspaceId, actor: this.actor },
1244
+ administration: {
1245
+ agentCreationViaMcp: this.storage.config.allowAgentCreationViaMcp,
1246
+ rebuildViaMcp: this.storage.config.allowRebuildViaMcp,
1247
+ },
1248
+ projections: { ...this.storage.config.projection },
1249
+ };
1250
+ }
1251
+
1252
+ async info(): Promise<SynomemServiceInfo> {
1253
+ this.checkAbort();
1254
+ return { backend: 'local', home: this.home, databasePath: this.storage.databasePath };
1255
+ }
1256
+
1257
+ async getCanonicalEvent(id: string): Promise<SynomemEvent | undefined> {
1258
+ this.checkAbort();
1259
+ return this.storage.getEvent(id);
1260
+ }
1261
+ }