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
@@ -0,0 +1,624 @@
1
+ import { existsSync, lstatSync, unlinkSync } from 'node:fs';
2
+ import { join, relative, sep } from 'node:path';
3
+ import { SynomemError } from './errors.js';
4
+ import {
5
+ assertNoSymlinkEscape,
6
+ atomicWriteDerivedFile,
7
+ atomicWriteFile,
8
+ ensureDirectory,
9
+ } from './fs-utils.js';
10
+ import type { SynomemStorage } from './storage.js';
11
+ import type { ProjectionWriter } from './ports/projections.js';
12
+ import type {
13
+ AgentProfile,
14
+ EvidenceReference,
15
+ SynomemEvent,
16
+ KudosRecord,
17
+ MemoRecord,
18
+ NoteRecord,
19
+ TodoRecord,
20
+ } from './types.js';
21
+
22
+ const generatedMarker = '<!-- Generated by Synomem. Rebuild with `synomem rebuild`. -->';
23
+
24
+ function manifestPath(home: string, path: string): string {
25
+ return relative(home, path).split(sep).join('/');
26
+ }
27
+
28
+ export function escapeMarkdown(value: string): string {
29
+ return value
30
+ .replaceAll('&', '&amp;')
31
+ .replaceAll('<', '&lt;')
32
+ .replaceAll('>', '&gt;')
33
+ .replace(/([\\`*_{}[\]()#+!|])/g, '\\$1')
34
+ .replace(/^(\s*)(-|\d+\.)\s/gm, '$1\\$2 ');
35
+ }
36
+
37
+ function evidenceLine(evidence: EvidenceReference): string {
38
+ const kind = evidence.kind.replace('-', ' ');
39
+ const label = evidence.label ? `${escapeMarkdown(evidence.label)} — ` : '';
40
+ return `- **${escapeMarkdown(kind[0]?.toUpperCase() + kind.slice(1))}:** ${label}${escapeMarkdown(evidence.value)}`;
41
+ }
42
+
43
+ function recordMarkdown(record: KudosRecord, includeTitle = true): string {
44
+ const event = record.event;
45
+ const lines = [
46
+ ...(includeTitle ? [`### ${escapeMarkdown(event.title)}`, ''] : []),
47
+ `**Received:** ${event.createdAt.slice(0, 10)} `,
48
+ `**From:** ${escapeMarkdown(event.actor.displayName ?? event.actor.id)} `,
49
+ `**Status:** ${record.status === 'acknowledged' ? 'Acknowledged' : 'Unacknowledged'} `,
50
+ ];
51
+ if (event.tags?.length) lines.push(`**Tags:** ${event.tags.map(escapeMarkdown).join(', ')} `);
52
+ lines.push('', escapeMarkdown(event.reason), '');
53
+ if (event.evidence?.length) {
54
+ lines.push('**Evidence**', '', ...event.evidence.map(evidenceLine), '');
55
+ }
56
+ if (record.acknowledgment?.note) {
57
+ lines.push(`**Acknowledgment note:** ${escapeMarkdown(record.acknowledgment.note)}`, '');
58
+ }
59
+ lines.push(`**Kudos ID:** \`${event.id}\``, '');
60
+ return lines.join('\n');
61
+ }
62
+
63
+ function winsMarkdown(profile: AgentProfile, records: KudosRecord[], rebuiltAt: string): string {
64
+ const active = records.filter((record) => !record.revocation);
65
+ const lines = [
66
+ generatedMarker,
67
+ '',
68
+ `# Wins — ${escapeMarkdown(profile.displayName)}`,
69
+ '',
70
+ `_Last rebuilt: ${rebuiltAt}_`,
71
+ '',
72
+ ];
73
+ if (active.length === 0) {
74
+ lines.push('_No kudos yet. Excellent work remains entirely possible._', '');
75
+ return `${lines.join('\n')}\n`;
76
+ }
77
+ let year = '';
78
+ for (const record of active) {
79
+ const nextYear = record.event.createdAt.slice(0, 4);
80
+ if (nextYear !== year) {
81
+ year = nextYear;
82
+ lines.push(`## ${year}`, '');
83
+ }
84
+ lines.push(recordMarkdown(record));
85
+ }
86
+ return `${lines.join('\n')}\n`;
87
+ }
88
+
89
+ function inboxMarkdown(record: KudosRecord): string {
90
+ return `${[
91
+ generatedMarker,
92
+ '',
93
+ `# ${escapeMarkdown(record.event.title)}`,
94
+ '',
95
+ recordMarkdown(record, false),
96
+ ].join('\n')}\n`;
97
+ }
98
+
99
+ function memoInboxMarkdown(record: MemoRecord): string {
100
+ return `${[
101
+ generatedMarker,
102
+ '',
103
+ `# ${escapeMarkdown(record.event.subject)}`,
104
+ '',
105
+ `**From:** ${escapeMarkdown(record.event.actor.displayName ?? record.event.actor.id)} `,
106
+ `**Sent:** ${record.event.createdAt} `,
107
+ `**Memo ID:** \`${record.event.id}\``,
108
+ '',
109
+ escapeMarkdown(record.event.body),
110
+ '',
111
+ ].join('\n')}\n`;
112
+ }
113
+
114
+ function todoInboxMarkdown(record: TodoRecord): string {
115
+ const due =
116
+ record.current.due?.kind === 'date'
117
+ ? record.current.due.date
118
+ : record.current.due
119
+ ? `${record.current.due.datetime} (${record.current.due.timeZone})`
120
+ : 'None';
121
+ return `${[
122
+ generatedMarker,
123
+ '',
124
+ `# ${escapeMarkdown(record.current.title)}`,
125
+ '',
126
+ `**Status:** ${record.status} `,
127
+ `**Priority:** ${record.current.priority} `,
128
+ `**Due:** ${escapeMarkdown(due)} `,
129
+ `**Todo ID:** \`${record.event.id}\``,
130
+ '',
131
+ ...(record.current.description ? [escapeMarkdown(record.current.description), ''] : []),
132
+ ].join('\n')}\n`;
133
+ }
134
+
135
+ function memoryMarkdown(profile: AgentProfile, records: NoteRecord[], rebuiltAt: string): string {
136
+ const active = records.filter((record) => record.status === 'active');
137
+ const lines = [
138
+ generatedMarker,
139
+ '',
140
+ `# Memory — ${escapeMarkdown(profile.displayName)}`,
141
+ '',
142
+ `_Last rebuilt: ${rebuiltAt}_`,
143
+ '',
144
+ ];
145
+ if (!active.length) lines.push('_No canonical notes yet._', '');
146
+ for (const record of active) {
147
+ lines.push(
148
+ `## ${escapeMarkdown(record.current.title)}`,
149
+ '',
150
+ escapeMarkdown(record.current.body),
151
+ '',
152
+ `**Version:** ${record.current.version} `,
153
+ `**Note ID:** \`${record.event.id}\``,
154
+ '',
155
+ );
156
+ }
157
+ return `${lines.join('\n')}\n`;
158
+ }
159
+
160
+ function todosMarkdown(profile: AgentProfile, records: TodoRecord[], rebuiltAt: string): string {
161
+ const lines = [
162
+ generatedMarker,
163
+ '',
164
+ `# Todos — ${escapeMarkdown(profile.displayName)}`,
165
+ '',
166
+ `_Last rebuilt: ${rebuiltAt}_`,
167
+ '',
168
+ ];
169
+ if (!records.length) lines.push('_No todos yet._', '');
170
+ for (const record of records) {
171
+ const due =
172
+ record.current.due?.kind === 'date'
173
+ ? record.current.due.date
174
+ : record.current.due
175
+ ? `${record.current.due.datetime} (${record.current.due.timeZone})`
176
+ : 'None';
177
+ lines.push(
178
+ `## ${escapeMarkdown(record.current.title)}`,
179
+ '',
180
+ `**Status:** ${record.status} `,
181
+ `**Priority:** ${record.current.priority} `,
182
+ `**Due:** ${escapeMarkdown(due)} `,
183
+ `**Todo ID:** \`${record.event.id}\``,
184
+ '',
185
+ );
186
+ }
187
+ return `${lines.join('\n')}\n`;
188
+ }
189
+
190
+ export function recordsFromEvents(events: SynomemEvent[]): KudosRecord[] {
191
+ const acknowledgments = new Map<string, KudosRecord['acknowledgment']>();
192
+ const revocations = new Map<string, KudosRecord['revocation']>();
193
+ for (const event of events) {
194
+ if (event.type === 'kudos.acknowledged' && !acknowledgments.has(event.kudosId)) {
195
+ acknowledgments.set(event.kudosId, event);
196
+ }
197
+ if (event.type === 'kudos.revoked' && !revocations.has(event.kudosId)) {
198
+ revocations.set(event.kudosId, event);
199
+ }
200
+ }
201
+ return events
202
+ .filter((event) => event.type === 'kudos.given')
203
+ .map((event) => {
204
+ const acknowledgment = acknowledgments.get(event.id);
205
+ const revocation = revocations.get(event.id);
206
+ return {
207
+ event,
208
+ ...(acknowledgment ? { acknowledgment } : {}),
209
+ ...(revocation ? { revocation } : {}),
210
+ status: acknowledgment ? 'acknowledged' : 'unacknowledged',
211
+ revocationStatus: revocation ? 'revoked' : 'active',
212
+ } satisfies KudosRecord;
213
+ })
214
+ .sort(
215
+ (a, b) =>
216
+ b.event.createdAt.localeCompare(a.event.createdAt) || b.event.id.localeCompare(a.event.id),
217
+ );
218
+ }
219
+
220
+ export function memoRecordsFromEvents(events: SynomemEvent[]): MemoRecord[] {
221
+ const records = new Map<string, MemoRecord>();
222
+ for (const event of events) {
223
+ if (event.type === 'memo.sent') {
224
+ records.set(event.id, { event, status: 'unread' });
225
+ } else if (event.type === 'memo.read') {
226
+ const record = records.get(event.memoId);
227
+ if (record) {
228
+ record.read = event;
229
+ if (!record.archived) record.status = 'read';
230
+ }
231
+ } else if (event.type === 'memo.archived') {
232
+ const record = records.get(event.memoId);
233
+ if (record) {
234
+ record.archived = event;
235
+ record.status = 'archived';
236
+ }
237
+ }
238
+ }
239
+ return [...records.values()].sort((a, b) => b.event.createdAt.localeCompare(a.event.createdAt));
240
+ }
241
+
242
+ export function noteRecordsFromEvents(events: SynomemEvent[]): NoteRecord[] {
243
+ const records = new Map<string, NoteRecord>();
244
+ for (const event of events) {
245
+ if (event.type === 'note.created') {
246
+ records.set(event.id, {
247
+ event,
248
+ current: {
249
+ title: event.title,
250
+ body: event.body,
251
+ tags: event.tags ?? [],
252
+ visibility: event.visibility,
253
+ version: event.aggregateVersion,
254
+ },
255
+ status: 'active',
256
+ });
257
+ } else if (event.type === 'note.revised') {
258
+ const record = records.get(event.noteId);
259
+ if (record) {
260
+ record.revision = event;
261
+ record.current = {
262
+ title: event.title,
263
+ body: event.body,
264
+ tags: event.tags ?? [],
265
+ visibility: event.visibility,
266
+ version: event.aggregateVersion,
267
+ };
268
+ }
269
+ } else if (event.type === 'note.archived') {
270
+ const record = records.get(event.noteId);
271
+ if (record) {
272
+ record.archived = event;
273
+ record.current.version = event.aggregateVersion;
274
+ record.status = 'archived';
275
+ }
276
+ }
277
+ }
278
+ return [...records.values()].sort((a, b) => b.event.createdAt.localeCompare(a.event.createdAt));
279
+ }
280
+
281
+ export function todoRecordsFromEvents(events: SynomemEvent[]): TodoRecord[] {
282
+ const records = new Map<string, TodoRecord>();
283
+ for (const event of events) {
284
+ if (event.type === 'todo.created') {
285
+ records.set(event.id, {
286
+ event,
287
+ current: {
288
+ title: event.title,
289
+ ...(event.description !== undefined ? { description: event.description } : {}),
290
+ priority: event.priority,
291
+ ...(event.due ? { due: event.due } : {}),
292
+ tags: event.tags ?? [],
293
+ visibility: event.visibility,
294
+ version: event.aggregateVersion,
295
+ },
296
+ status: event.requiresAcceptance ? 'assigned' : 'open',
297
+ });
298
+ } else if (event.type === 'todo.updated') {
299
+ const record = records.get(event.todoId);
300
+ if (record) {
301
+ record.update = event;
302
+ record.current = {
303
+ title: event.title,
304
+ ...(event.description !== undefined ? { description: event.description } : {}),
305
+ priority: event.priority,
306
+ ...(event.due ? { due: event.due } : {}),
307
+ tags: event.tags ?? [],
308
+ visibility: event.visibility,
309
+ version: event.aggregateVersion,
310
+ };
311
+ }
312
+ } else if (event.type === 'todo.accepted') {
313
+ const record = records.get(event.todoId);
314
+ if (record) {
315
+ record.current.version = event.aggregateVersion;
316
+ record.status = 'open';
317
+ }
318
+ } else if (
319
+ event.type === 'todo.completed' ||
320
+ event.type === 'todo.rejected' ||
321
+ event.type === 'todo.canceled'
322
+ ) {
323
+ const record = records.get(event.todoId);
324
+ if (record) {
325
+ record.terminal = event;
326
+ record.current.version = event.aggregateVersion;
327
+ record.status =
328
+ event.type === 'todo.completed'
329
+ ? 'completed'
330
+ : event.type === 'todo.rejected'
331
+ ? 'rejected'
332
+ : 'canceled';
333
+ }
334
+ } else if (event.type === 'todo.reopened') {
335
+ const record = records.get(event.todoId);
336
+ if (record) {
337
+ record.reopened = event;
338
+ record.current.version = event.aggregateVersion;
339
+ record.status = 'open';
340
+ }
341
+ }
342
+ }
343
+ return [...records.values()].sort((a, b) => b.event.createdAt.localeCompare(a.event.createdAt));
344
+ }
345
+
346
+ /** Render a portable human-readable export without reading generated projections. */
347
+ export function renderMarkdownExport(events: SynomemEvent[]): string {
348
+ const sections = [
349
+ ...recordsFromEvents(events).map(
350
+ (record) =>
351
+ `## Kudos: ${escapeMarkdown(record.event.title)}\n\n${escapeMarkdown(record.event.reason)}\n\nStatus: ${record.revocationStatus === 'revoked' ? 'Revoked' : record.status}\n\nID: \`${record.event.id}\``,
352
+ ),
353
+ ...memoRecordsFromEvents(events).map(
354
+ (record) =>
355
+ `## Memo: ${escapeMarkdown(record.event.subject)}\n\n${escapeMarkdown(record.event.body)}\n\nStatus: ${record.status}\n\nID: \`${record.event.id}\``,
356
+ ),
357
+ ...noteRecordsFromEvents(events).map(
358
+ (record) =>
359
+ `## Note: ${escapeMarkdown(record.current.title)}\n\n${escapeMarkdown(record.current.body)}\n\nStatus: ${record.status}; version ${record.current.version}\n\nID: \`${record.event.id}\``,
360
+ ),
361
+ ...todoRecordsFromEvents(events).map(
362
+ (record) =>
363
+ `## 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}\``,
364
+ ),
365
+ ];
366
+ return `${sections.join('\n\n')}\n`;
367
+ }
368
+
369
+ export class ProjectionManager implements ProjectionWriter {
370
+ constructor(private readonly storage: SynomemStorage) {}
371
+
372
+ rebuild(): { generated: string[]; removed: string[] } {
373
+ this.storage.assertWritable();
374
+ const events = this.storage.getEvents();
375
+ this.storage.transactionSync(() => {
376
+ this.storage.rebuildKudosCurrentIndex();
377
+ this.storage.rebuildItemsCurrentIndex();
378
+ });
379
+ const profiles = this.storage.listAgents();
380
+ const records = recordsFromEvents(events);
381
+ const memos = memoRecordsFromEvents(events);
382
+ const notes = noteRecordsFromEvents(events);
383
+ const todos = todoRecordsFromEvents(events);
384
+ const rebuiltAt = events.at(-1)?.createdAt ?? new Date(0).toISOString();
385
+ const generated: string[] = [];
386
+
387
+ for (const profile of profiles) {
388
+ const agentDirectory = join(this.storage.home, profile.id);
389
+ assertNoSymlinkEscape(this.storage.home, agentDirectory);
390
+ ensureDirectory(agentDirectory);
391
+ const profilePath = join(agentDirectory, 'profile.json');
392
+ const winsPath = join(agentDirectory, 'WINS.md');
393
+ const notesPath = join(agentDirectory, 'NOTES.md');
394
+ const inboxDirectory = join(agentDirectory, 'inbox');
395
+ const kudosInboxDirectory = join(inboxDirectory, 'kudos');
396
+ const memoInboxDirectory = join(inboxDirectory, 'memos');
397
+ const todoInboxDirectory = join(inboxDirectory, 'todos');
398
+ assertNoSymlinkEscape(this.storage.home, inboxDirectory);
399
+ ensureDirectory(inboxDirectory);
400
+ ensureDirectory(kudosInboxDirectory);
401
+ ensureDirectory(memoInboxDirectory);
402
+ ensureDirectory(todoInboxDirectory);
403
+
404
+ atomicWriteDerivedFile(
405
+ profilePath,
406
+ `${JSON.stringify({ _generatedBy: 'synomem', ...profile }, null, 2)}\n`,
407
+ );
408
+ generated.push(manifestPath(this.storage.home, profilePath));
409
+ if (!existsSync(notesPath)) {
410
+ atomicWriteFile(
411
+ notesPath,
412
+ `# Notes — ${profile.displayName}\n\n<!-- Human-owned. Synomem will never overwrite this file. -->\n`,
413
+ );
414
+ }
415
+
416
+ const agentRecords = records.filter((record) => record.event.recipientAgentId === profile.id);
417
+ if (this.storage.config.projection.writeWinsMarkdown) {
418
+ atomicWriteDerivedFile(winsPath, winsMarkdown(profile, agentRecords, rebuiltAt));
419
+ generated.push(manifestPath(this.storage.home, winsPath));
420
+ }
421
+ const agentNotes = notes.filter((record) => record.event.ownerAgentId === profile.id);
422
+ if (this.storage.config.projection.writeMemoryMarkdown) {
423
+ const memoryPath = join(agentDirectory, 'MEMORY.md');
424
+ atomicWriteDerivedFile(memoryPath, memoryMarkdown(profile, agentNotes, rebuiltAt));
425
+ generated.push(manifestPath(this.storage.home, memoryPath));
426
+ }
427
+ const agentTodos = todos.filter((record) => record.event.assigneeAgentId === profile.id);
428
+ if (this.storage.config.projection.writeTodosMarkdown) {
429
+ const todosPath = join(agentDirectory, 'TODOS.md');
430
+ atomicWriteDerivedFile(todosPath, todosMarkdown(profile, agentTodos, rebuiltAt));
431
+ generated.push(manifestPath(this.storage.home, todosPath));
432
+ }
433
+ if (this.storage.config.projection.writeInboxEntries) {
434
+ for (const record of agentRecords.filter(
435
+ (item) => item.status === 'unacknowledged' && item.revocationStatus === 'active',
436
+ )) {
437
+ const inboxPath = join(kudosInboxDirectory, `${record.event.id}.md`);
438
+ atomicWriteDerivedFile(inboxPath, inboxMarkdown(record));
439
+ generated.push(manifestPath(this.storage.home, inboxPath));
440
+ }
441
+ for (const record of memos.filter(
442
+ (item) => item.event.recipientAgentId === profile.id && item.status === 'unread',
443
+ )) {
444
+ const inboxPath = join(memoInboxDirectory, `${record.event.id}.md`);
445
+ atomicWriteDerivedFile(inboxPath, memoInboxMarkdown(record));
446
+ generated.push(manifestPath(this.storage.home, inboxPath));
447
+ }
448
+ for (const record of agentTodos.filter(
449
+ (item) => item.status === 'open' || item.status === 'assigned',
450
+ )) {
451
+ const inboxPath = join(todoInboxDirectory, `${record.event.id}.md`);
452
+ atomicWriteDerivedFile(inboxPath, todoInboxMarkdown(record));
453
+ generated.push(manifestPath(this.storage.home, inboxPath));
454
+ }
455
+ }
456
+ }
457
+
458
+ const keep = new Set(generated);
459
+ const removed: string[] = [];
460
+ for (const stale of this.storage.projectionManifest()) {
461
+ if (keep.has(stale)) continue;
462
+ const path = join(this.storage.home, stale);
463
+ assertNoSymlinkEscape(this.storage.home, path);
464
+ if (!existsSync(path)) continue;
465
+ const stat = lstatSync(path);
466
+ if (!stat.isFile() || stat.isSymbolicLink()) {
467
+ throw new SynomemError('UNSAFE_PATH', `Refusing to remove unsafe generated path: ${stale}`);
468
+ }
469
+ unlinkSync(path);
470
+ removed.push(stale);
471
+ }
472
+ this.storage.replaceProjectionManifest(generated, rebuiltAt);
473
+ return { generated: generated.sort(), removed: removed.sort() };
474
+ }
475
+
476
+ syncAgent(agentId: string): { generated: string[]; removed: string[] } {
477
+ this.storage.assertWritable();
478
+ const profile = this.storage.getAgent(agentId);
479
+ if (!profile) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${agentId}`);
480
+ const events = this.storage.getEvents();
481
+ const records = recordsFromEvents(events).filter(
482
+ (record) => record.event.recipientAgentId === profile.id,
483
+ );
484
+ const memos = memoRecordsFromEvents(events).filter(
485
+ (record) => record.event.recipientAgentId === profile.id,
486
+ );
487
+ const notes = noteRecordsFromEvents(events).filter(
488
+ (record) => record.event.ownerAgentId === profile.id,
489
+ );
490
+ const todos = todoRecordsFromEvents(events).filter(
491
+ (record) => record.event.assigneeAgentId === profile.id,
492
+ );
493
+ const rebuiltAt = events.at(-1)?.createdAt ?? new Date(0).toISOString();
494
+ const agentDirectory = join(this.storage.home, profile.id);
495
+ const inboxDirectory = join(agentDirectory, 'inbox');
496
+ const kudosInboxDirectory = join(inboxDirectory, 'kudos');
497
+ const memoInboxDirectory = join(inboxDirectory, 'memos');
498
+ const todoInboxDirectory = join(inboxDirectory, 'todos');
499
+ assertNoSymlinkEscape(this.storage.home, agentDirectory);
500
+ assertNoSymlinkEscape(this.storage.home, inboxDirectory);
501
+ ensureDirectory(agentDirectory);
502
+ ensureDirectory(inboxDirectory);
503
+ ensureDirectory(kudosInboxDirectory);
504
+ ensureDirectory(memoInboxDirectory);
505
+ ensureDirectory(todoInboxDirectory);
506
+
507
+ const generated: string[] = [];
508
+ const profilePath = join(agentDirectory, 'profile.json');
509
+ atomicWriteDerivedFile(
510
+ profilePath,
511
+ `${JSON.stringify({ _generatedBy: 'synomem', ...profile }, null, 2)}\n`,
512
+ );
513
+ generated.push(manifestPath(this.storage.home, profilePath));
514
+
515
+ const notesPath = join(agentDirectory, 'NOTES.md');
516
+ if (!existsSync(notesPath)) {
517
+ atomicWriteFile(
518
+ notesPath,
519
+ `# Notes — ${profile.displayName}\n\n<!-- Human-owned. Synomem will never overwrite this file. -->\n`,
520
+ );
521
+ }
522
+
523
+ if (this.storage.config.projection.writeWinsMarkdown) {
524
+ const winsPath = join(agentDirectory, 'WINS.md');
525
+ atomicWriteDerivedFile(winsPath, winsMarkdown(profile, records, rebuiltAt));
526
+ generated.push(manifestPath(this.storage.home, winsPath));
527
+ }
528
+
529
+ if (this.storage.config.projection.writeMemoryMarkdown) {
530
+ const memoryPath = join(agentDirectory, 'MEMORY.md');
531
+ atomicWriteDerivedFile(memoryPath, memoryMarkdown(profile, notes, rebuiltAt));
532
+ generated.push(manifestPath(this.storage.home, memoryPath));
533
+ }
534
+
535
+ if (this.storage.config.projection.writeTodosMarkdown) {
536
+ const todosPath = join(agentDirectory, 'TODOS.md');
537
+ atomicWriteDerivedFile(todosPath, todosMarkdown(profile, todos, rebuiltAt));
538
+ generated.push(manifestPath(this.storage.home, todosPath));
539
+ }
540
+
541
+ if (this.storage.config.projection.writeInboxEntries) {
542
+ for (const record of records.filter(
543
+ (item) => item.status === 'unacknowledged' && item.revocationStatus === 'active',
544
+ )) {
545
+ const inboxPath = join(kudosInboxDirectory, `${record.event.id}.md`);
546
+ const relativePath = manifestPath(this.storage.home, inboxPath);
547
+ if (!existsSync(inboxPath)) atomicWriteDerivedFile(inboxPath, inboxMarkdown(record));
548
+ generated.push(relativePath);
549
+ }
550
+ for (const record of memos.filter((item) => item.status === 'unread')) {
551
+ const inboxPath = join(memoInboxDirectory, `${record.event.id}.md`);
552
+ const relativePath = manifestPath(this.storage.home, inboxPath);
553
+ if (!existsSync(inboxPath)) atomicWriteDerivedFile(inboxPath, memoInboxMarkdown(record));
554
+ generated.push(relativePath);
555
+ }
556
+ for (const record of todos.filter(
557
+ (item) => item.status === 'open' || item.status === 'assigned',
558
+ )) {
559
+ const inboxPath = join(todoInboxDirectory, `${record.event.id}.md`);
560
+ const relativePath = manifestPath(this.storage.home, inboxPath);
561
+ if (!existsSync(inboxPath)) atomicWriteDerivedFile(inboxPath, todoInboxMarkdown(record));
562
+ generated.push(relativePath);
563
+ }
564
+ }
565
+
566
+ const keep = new Set(generated);
567
+ const prefixes = [`${profile.id}/`, `${profile.id}\\`];
568
+ const removed: string[] = [];
569
+ for (const stale of this.storage
570
+ .projectionManifest()
571
+ .filter((path) => prefixes.some((prefix) => path.startsWith(prefix)))) {
572
+ if (keep.has(stale)) continue;
573
+ const path = join(this.storage.home, stale);
574
+ assertNoSymlinkEscape(this.storage.home, path);
575
+ if (!existsSync(path)) continue;
576
+ const stat = lstatSync(path);
577
+ if (!stat.isFile() || stat.isSymbolicLink()) {
578
+ throw new SynomemError('UNSAFE_PATH', `Refusing to remove unsafe generated path: ${stale}`);
579
+ }
580
+ unlinkSync(path);
581
+ removed.push(stale);
582
+ }
583
+ this.storage.replaceAgentProjectionManifest(profile.id, generated, rebuiltAt);
584
+ return { generated: generated.sort(), removed: removed.sort() };
585
+ }
586
+
587
+ expectedPaths(): string[] {
588
+ const profiles = this.storage.listAgents();
589
+ const events = this.storage.getReadableEvents();
590
+ const records = recordsFromEvents(events);
591
+ const memos = memoRecordsFromEvents(events);
592
+ const todos = todoRecordsFromEvents(events);
593
+ const paths: string[] = [];
594
+ for (const profile of profiles) {
595
+ paths.push(`${profile.id}/profile.json`);
596
+ if (this.storage.config.projection.writeWinsMarkdown) paths.push(`${profile.id}/WINS.md`);
597
+ if (this.storage.config.projection.writeMemoryMarkdown) paths.push(`${profile.id}/MEMORY.md`);
598
+ if (this.storage.config.projection.writeTodosMarkdown) paths.push(`${profile.id}/TODOS.md`);
599
+ if (this.storage.config.projection.writeInboxEntries) {
600
+ for (const record of records.filter(
601
+ (item) =>
602
+ item.event.recipientAgentId === profile.id &&
603
+ item.status === 'unacknowledged' &&
604
+ item.revocationStatus === 'active',
605
+ )) {
606
+ paths.push(`${profile.id}/inbox/kudos/${record.event.id}.md`);
607
+ }
608
+ for (const record of memos.filter(
609
+ (item) => item.event.recipientAgentId === profile.id && item.status === 'unread',
610
+ )) {
611
+ paths.push(`${profile.id}/inbox/memos/${record.event.id}.md`);
612
+ }
613
+ for (const record of todos.filter(
614
+ (item) =>
615
+ item.event.assigneeAgentId === profile.id &&
616
+ (item.status === 'open' || item.status === 'assigned'),
617
+ )) {
618
+ paths.push(`${profile.id}/inbox/todos/${record.event.id}.md`);
619
+ }
620
+ }
621
+ }
622
+ return paths.sort();
623
+ }
624
+ }