synomem 0.9.6 → 0.10.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 (118) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +5 -3
  3. package/dist/actor-directory.d.ts +34 -0
  4. package/dist/actor-directory.d.ts.map +1 -0
  5. package/dist/actor-directory.js +52 -0
  6. package/dist/actor-directory.js.map +1 -0
  7. package/dist/backend.d.ts +3 -0
  8. package/dist/backend.d.ts.map +1 -1
  9. package/dist/backend.js +26 -3
  10. package/dist/backend.js.map +1 -1
  11. package/dist/bookmarks.d.ts +30 -0
  12. package/dist/bookmarks.d.ts.map +1 -0
  13. package/dist/bookmarks.js +87 -0
  14. package/dist/bookmarks.js.map +1 -0
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +291 -61
  17. package/dist/cli.js.map +1 -1
  18. package/dist/client.d.ts +115 -4
  19. package/dist/client.d.ts.map +1 -1
  20. package/dist/client.js +854 -138
  21. package/dist/client.js.map +1 -1
  22. package/dist/cursors.d.ts +30 -0
  23. package/dist/cursors.d.ts.map +1 -0
  24. package/dist/cursors.js +101 -0
  25. package/dist/cursors.js.map +1 -0
  26. package/dist/errors.d.ts +1 -1
  27. package/dist/errors.d.ts.map +1 -1
  28. package/dist/errors.js +2 -0
  29. package/dist/errors.js.map +1 -1
  30. package/dist/import.d.ts +605 -39
  31. package/dist/import.d.ts.map +1 -1
  32. package/dist/index.d.ts +19 -1
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +11 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/mcp/index.d.ts +1 -1
  37. package/dist/mcp/index.d.ts.map +1 -1
  38. package/dist/mcp/index.js +329 -22
  39. package/dist/mcp/index.js.map +1 -1
  40. package/dist/mutation-receipts.d.ts +8 -0
  41. package/dist/mutation-receipts.d.ts.map +1 -0
  42. package/dist/mutation-receipts.js +2 -0
  43. package/dist/mutation-receipts.js.map +1 -0
  44. package/dist/notifications.d.ts +73 -0
  45. package/dist/notifications.d.ts.map +1 -0
  46. package/dist/notifications.js +309 -0
  47. package/dist/notifications.js.map +1 -0
  48. package/dist/participation-repository.d.ts +46 -0
  49. package/dist/participation-repository.d.ts.map +1 -0
  50. package/dist/participation-repository.js +296 -0
  51. package/dist/participation-repository.js.map +1 -0
  52. package/dist/participation.d.ts +97 -0
  53. package/dist/participation.d.ts.map +1 -0
  54. package/dist/participation.js +10 -0
  55. package/dist/participation.js.map +1 -0
  56. package/dist/policy.d.ts +31 -0
  57. package/dist/policy.d.ts.map +1 -0
  58. package/dist/policy.js +71 -0
  59. package/dist/policy.js.map +1 -0
  60. package/dist/ports/repository.d.ts +23 -0
  61. package/dist/ports/repository.d.ts.map +1 -1
  62. package/dist/profiles.d.ts +12 -0
  63. package/dist/profiles.d.ts.map +1 -1
  64. package/dist/profiles.js +2 -1
  65. package/dist/profiles.js.map +1 -1
  66. package/dist/projections.d.ts.map +1 -1
  67. package/dist/projections.js +29 -11
  68. package/dist/projections.js.map +1 -1
  69. package/dist/remote.d.ts +79 -0
  70. package/dist/remote.d.ts.map +1 -1
  71. package/dist/remote.js +65 -6
  72. package/dist/remote.js.map +1 -1
  73. package/dist/schemas.d.ts +746 -58
  74. package/dist/schemas.d.ts.map +1 -1
  75. package/dist/schemas.js +114 -16
  76. package/dist/schemas.js.map +1 -1
  77. package/dist/search.d.ts +25 -0
  78. package/dist/search.d.ts.map +1 -0
  79. package/dist/search.js +2 -0
  80. package/dist/search.js.map +1 -0
  81. package/dist/service.d.ts +89 -0
  82. package/dist/service.d.ts.map +1 -1
  83. package/dist/storage.d.ts +38 -3
  84. package/dist/storage.d.ts.map +1 -1
  85. package/dist/storage.js +494 -365
  86. package/dist/storage.js.map +1 -1
  87. package/dist/types.d.ts +65 -34
  88. package/dist/types.d.ts.map +1 -1
  89. package/docs/cli.md +6 -0
  90. package/docs/human-participation.md +20 -0
  91. package/docs/mcp.md +2 -0
  92. package/docs/storage-format.md +4 -15
  93. package/openapi/synomem-v1.yaml +187 -10
  94. package/package.json +2 -1
  95. package/skills/synomem/SKILL.md +2 -0
  96. package/src/actor-directory.ts +87 -0
  97. package/src/backend.ts +29 -3
  98. package/src/bookmarks.ts +127 -0
  99. package/src/cli.ts +941 -546
  100. package/src/client.ts +1143 -279
  101. package/src/cursors.ts +132 -0
  102. package/src/errors.ts +2 -0
  103. package/src/index.ts +50 -0
  104. package/src/mcp/index.ts +412 -96
  105. package/src/mutation-receipts.ts +7 -0
  106. package/src/notifications.ts +483 -0
  107. package/src/participation-repository.ts +406 -0
  108. package/src/participation.ts +103 -0
  109. package/src/policy.ts +109 -0
  110. package/src/ports/repository.ts +33 -0
  111. package/src/profiles.ts +2 -1
  112. package/src/projections.ts +49 -48
  113. package/src/remote.ts +227 -37
  114. package/src/schemas.ts +116 -16
  115. package/src/search.ts +24 -0
  116. package/src/service.ts +122 -8
  117. package/src/storage.ts +1039 -697
  118. package/src/types.ts +67 -32
@@ -0,0 +1,406 @@
1
+ import { SynomemError } from './errors.js';
2
+ import type { SignedCursorCodec } from './cursors.js';
3
+ import { cursorFilter } from './cursors.js';
4
+ import type { ActorRef } from './policy.js';
5
+ import type { ActorIdentity, SynomemEvent } from './types.js';
6
+ import type {
7
+ ParticipationRepository,
8
+ ReplyRecord,
9
+ ThreadInput,
10
+ ThreadPage,
11
+ ReactionCode,
12
+ ReactionSummary,
13
+ } from './participation.js';
14
+
15
+ export interface ParticipationBudgets {
16
+ reply: { minute: number; hour: number; workspaceDay: number };
17
+ reaction: { minute: number; hour: number; workspaceDay: number };
18
+ }
19
+ export const defaultParticipationBudgets: ParticipationBudgets = {
20
+ reply: { minute: 30, hour: 300, workspaceDay: 10000 },
21
+ reaction: { minute: 60, hour: 600, workspaceDay: 50000 },
22
+ };
23
+ export function validateParticipationBudgets(budgets: ParticipationBudgets): ParticipationBudgets {
24
+ for (const kind of ['reply', 'reaction'] as const) {
25
+ const values = budgets?.[kind];
26
+ if (
27
+ !values ||
28
+ Object.keys(values).some((key) => !['minute', 'hour', 'workspaceDay'].includes(key)) ||
29
+ ['minute', 'hour', 'workspaceDay'].some(
30
+ (key) =>
31
+ !Number.isSafeInteger(values[key as keyof typeof values]) ||
32
+ values[key as keyof typeof values] < 1,
33
+ )
34
+ )
35
+ throw new SynomemError(
36
+ 'CONFIG_INVALID',
37
+ 'Participation budgets require positive integer minute, hour and workspaceDay limits.',
38
+ );
39
+ }
40
+ return { reply: { ...budgets.reply }, reaction: { ...budgets.reaction } };
41
+ }
42
+ /** Trusted storage adapter. SQL uses positional question marks; hosted adapters bind parameters. */
43
+ export interface ParticipationSql {
44
+ query(sql: string, values: unknown[]): Promise<Array<Record<string, unknown>>>;
45
+ execute(sql: string, values: unknown[]): Promise<void>;
46
+ codec(): SignedCursorCodec;
47
+ workspaceId: string;
48
+ budgets?: ParticipationBudgets;
49
+ }
50
+ export const participationTables = `
51
+ CREATE TABLE replies_current (
52
+ workspace_id TEXT NOT NULL, reply_id TEXT NOT NULL, root_id TEXT NOT NULL, parent_id TEXT,
53
+ author_json TEXT NOT NULL, created_at TEXT NOT NULL, created_sequence BIGINT NOT NULL,
54
+ updated_sequence BIGINT NOT NULL, version INTEGER NOT NULL, body TEXT, mentions_json TEXT NOT NULL,
55
+ deleted_at TEXT, PRIMARY KEY(workspace_id,reply_id), UNIQUE(workspace_id,root_id,reply_id),
56
+ FOREIGN KEY(workspace_id,root_id,parent_id) REFERENCES replies_current(workspace_id,root_id,reply_id)
57
+ );
58
+ CREATE INDEX replies_root_sequence ON replies_current(workspace_id,root_id,created_sequence,reply_id);
59
+ CREATE INDEX replies_root_changes ON replies_current(workspace_id,root_id,updated_sequence,reply_id);
60
+ CREATE TABLE thread_entries (
61
+ workspace_id TEXT NOT NULL, root_id TEXT NOT NULL, event_id TEXT NOT NULL, sequence BIGINT NOT NULL,
62
+ PRIMARY KEY(workspace_id,event_id)
63
+ );
64
+ CREATE INDEX thread_entries_root_sequence ON thread_entries(workspace_id,root_id,sequence,event_id);
65
+ CREATE TABLE thread_members (
66
+ workspace_id TEXT NOT NULL, root_id TEXT NOT NULL, actor_kind TEXT NOT NULL, actor_id TEXT NOT NULL,
67
+ following INTEGER NOT NULL CHECK(following IN (0,1)), muted INTEGER NOT NULL CHECK(muted IN (0,1)),
68
+ last_read_sequence BIGINT NOT NULL DEFAULT 0, joined_sequence BIGINT NOT NULL DEFAULT 0,
69
+ PRIMARY KEY(workspace_id,root_id,actor_kind,actor_id)
70
+ );
71
+ CREATE TABLE reactions_current (
72
+ workspace_id TEXT NOT NULL, target_id TEXT NOT NULL, root_id TEXT NOT NULL,
73
+ actor_kind TEXT NOT NULL, actor_id TEXT NOT NULL, code TEXT NOT NULL,
74
+ event_id TEXT NOT NULL, created_at TEXT NOT NULL,
75
+ PRIMARY KEY(workspace_id,target_id,actor_kind,actor_id,code)
76
+ );
77
+ CREATE INDEX reactions_root ON reactions_current(workspace_id,root_id,target_id);
78
+ CREATE TABLE record_mentions (
79
+ workspace_id TEXT NOT NULL,source_event_id TEXT NOT NULL,root_id TEXT NOT NULL,reply_id TEXT,
80
+ mentioned_kind TEXT NOT NULL,mentioned_id TEXT NOT NULL,
81
+ PRIMARY KEY(workspace_id,source_event_id,mentioned_kind,mentioned_id)
82
+ );
83
+ CREATE INDEX record_mentions_root ON record_mentions(workspace_id,root_id,source_event_id);
84
+ CREATE TABLE mutation_budgets (
85
+ workspace_id TEXT NOT NULL, bucket_key TEXT NOT NULL, bucket_start BIGINT NOT NULL,
86
+ window_seconds INTEGER NOT NULL, used INTEGER NOT NULL,
87
+ PRIMARY KEY(workspace_id,bucket_key,bucket_start,window_seconds)
88
+ );
89
+ CREATE INDEX mutation_budgets_retention ON mutation_budgets(workspace_id,bucket_start);
90
+ `;
91
+ const text = (value: unknown): string => String(value);
92
+ export class SqlParticipationRepository implements ParticipationRepository {
93
+ constructor(private readonly sql: ParticipationSql) {}
94
+ private get workspace(): string {
95
+ return this.sql.workspaceId;
96
+ }
97
+ async apply(event: SynomemEvent, sequence: bigint, replay = false): Promise<void> {
98
+ if (
99
+ event.type === 'reply.created' ||
100
+ event.type === 'post.created' ||
101
+ event.type === 'post.edited'
102
+ )
103
+ for (const mention of event.mentions ?? [])
104
+ await this.sql.execute(
105
+ 'INSERT INTO record_mentions(workspace_id,source_event_id,root_id,reply_id,mentioned_kind,mentioned_id) VALUES(?,?,?,?,?,?) ON CONFLICT DO NOTHING',
106
+ [
107
+ this.workspace,
108
+ event.id,
109
+ event.type === 'reply.created' ? event.rootId : event.aggregateId,
110
+ event.type === 'reply.created' ? event.id : null,
111
+ mention.kind,
112
+ mention.id,
113
+ ],
114
+ );
115
+ if (event.type === 'reply.deleted')
116
+ await this.sql.execute(
117
+ 'DELETE FROM record_mentions WHERE workspace_id=? AND source_event_id=?',
118
+ [this.workspace, event.replyId],
119
+ );
120
+ for (const command of participationStatements(event, sequence, this.workspace))
121
+ if (!replay || !command.sql.includes('thread_members'))
122
+ await this.sql.execute(command.sql, command.values);
123
+ }
124
+ async getReply(id: string): Promise<ReplyRecord | undefined> {
125
+ const row = (
126
+ await this.sql.query('SELECT * FROM replies_current WHERE workspace_id=? AND reply_id=?', [
127
+ this.workspace,
128
+ id,
129
+ ])
130
+ )[0];
131
+ if (!row) return undefined;
132
+ const deleted = row.deleted_at != null;
133
+ return {
134
+ id: text(row.reply_id),
135
+ rootId: text(row.root_id),
136
+ parentId: row.parent_id == null ? null : text(row.parent_id),
137
+ author: JSON.parse(text(row.author_json)) as ActorIdentity,
138
+ createdAt: text(row.created_at),
139
+ createdSequence: text(row.created_sequence),
140
+ updatedSequence: text(row.updated_sequence),
141
+ version: Number(row.version),
142
+ deleted,
143
+ ...(!deleted
144
+ ? { body: text(row.body), mentions: JSON.parse(text(row.mentions_json)) as ActorRef[] }
145
+ : {}),
146
+ };
147
+ }
148
+ async thread(input: ThreadInput, actor: ActorIdentity, purpose = 'thread'): Promise<ThreadPage> {
149
+ const limit = Math.min(100, Math.max(1, input.limit ?? 20));
150
+ const maxRow = (
151
+ await this.sql.query(
152
+ 'SELECT COALESCE(MAX(sequence),0) AS max FROM events WHERE workspace_id=?',
153
+ [this.workspace],
154
+ )
155
+ )[0]!;
156
+ const max = BigInt(text(maxRow.max));
157
+ const binding = {
158
+ purpose,
159
+ workspaceId: this.workspace,
160
+ actor,
161
+ filter: cursorFilter({ rootId: input.rootId }),
162
+ };
163
+ const codec = this.sql.codec();
164
+ const position = input.after ? codec.decode(input.after, binding, max.toString()) : undefined;
165
+ const watermark =
166
+ purpose === 'reply-changes' ? max.toString() : (position?.watermark ?? max.toString());
167
+ const rows = await this.sql.query(
168
+ `SELECT e.payload,e.sequence,r.deleted_at FROM thread_entries t JOIN events e ON e.workspace_id=t.workspace_id AND e.id=t.event_id LEFT JOIN replies_current r ON r.workspace_id=t.workspace_id AND r.reply_id=e.aggregate_id WHERE t.workspace_id=? AND t.root_id=? AND t.sequence>? AND t.sequence<=? ORDER BY t.sequence,t.event_id LIMIT ?`,
169
+ [this.workspace, input.rootId, position?.sequence ?? '0', watermark, limit + 1],
170
+ );
171
+ const entries: ThreadPage['entries'] = [];
172
+ let bytes = 0;
173
+ for (const row of rows.slice(0, limit)) {
174
+ const event = (
175
+ typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload
176
+ ) as SynomemEvent;
177
+ if (event.type === 'reply.created' && row.deleted_at != null) {
178
+ // Return the tombstone shape; never carry the retained canonical body into a timeline.
179
+ const { body: _body, mentions: _mentions, ...base } = event;
180
+ void _body;
181
+ void _mentions;
182
+ const tombstone = { ...base, type: 'reply.deleted' as const, replyId: event.id };
183
+ const entry = { sequence: text(row.sequence), event: tombstone };
184
+ const size = Buffer.byteLength(JSON.stringify(entry));
185
+ if (entries.length && bytes + size > 24576) break;
186
+ entries.push(entry);
187
+ bytes += size;
188
+ } else {
189
+ let entry: ThreadPage['entries'][number] = { sequence: text(row.sequence), event };
190
+ if (Buffer.byteLength(JSON.stringify(entry)) > 20000) {
191
+ const preview = { ...event } as unknown as Record<string, unknown>;
192
+ for (const key of ['body', 'description', 'details', 'reason', 'response'])
193
+ if (typeof preview[key] === 'string') preview[key] = preview[key].slice(0, 1000);
194
+ for (const key of ['source', 'metadata', 'evidence']) delete preview[key];
195
+ entry = { ...entry, event: preview as unknown as SynomemEvent, detailRequired: true };
196
+ }
197
+ const size = Buffer.byteLength(JSON.stringify(entry));
198
+ if (entries.length && bytes + size > 24576) break;
199
+ entries.push(entry);
200
+ bytes += size;
201
+ }
202
+ }
203
+ const hasMore = rows.length > entries.length;
204
+ const checkpoint = entries.at(-1)?.sequence ?? position?.sequence ?? '0';
205
+ const member = (
206
+ await this.sql.query(
207
+ 'SELECT following,muted,last_read_sequence FROM thread_members WHERE workspace_id=? AND root_id=? AND actor_kind=? AND actor_id=?',
208
+ [this.workspace, input.rootId, actor.kind, actor.id],
209
+ )
210
+ )[0];
211
+ return {
212
+ rootId: input.rootId,
213
+ entries,
214
+ hasMore,
215
+ subscription: {
216
+ following: Number(member?.following) === 1,
217
+ muted: Number(member?.muted) === 1,
218
+ lastReadSequence: text(member?.last_read_sequence ?? 0),
219
+ },
220
+ ...(hasMore || purpose === 'reply-changes'
221
+ ? {
222
+ nextCursor: codec.encode(binding, {
223
+ sequence: purpose === 'reply-changes' && !hasMore ? watermark : checkpoint,
224
+ watermark,
225
+ }),
226
+ }
227
+ : {}),
228
+ watermark: codec.encode(
229
+ { ...binding, purpose: 'thread-read' },
230
+ { sequence: checkpoint, watermark },
231
+ ),
232
+ contextLimited: hasMore,
233
+ };
234
+ }
235
+ async changes(input: ThreadInput, actor: ActorIdentity): Promise<ThreadPage> {
236
+ return this.thread(input, actor, 'reply-changes');
237
+ }
238
+ async read(rootId: string, through: string, actor: ActorRef): Promise<void> {
239
+ const max = String(
240
+ (
241
+ await this.sql.query(
242
+ 'SELECT COALESCE(MAX(sequence),0) AS max FROM events WHERE workspace_id=?',
243
+ [this.workspace],
244
+ )
245
+ )[0]!.max,
246
+ );
247
+ const position = this.sql.codec().decode(
248
+ through,
249
+ {
250
+ purpose: 'thread-read',
251
+ workspaceId: this.workspace,
252
+ actor,
253
+ filter: cursorFilter({ rootId }),
254
+ },
255
+ max,
256
+ );
257
+ await this.sql.execute(
258
+ `INSERT INTO thread_members(workspace_id,root_id,actor_kind,actor_id,following,muted,last_read_sequence) VALUES(?,?,?,?,0,0,?) ON CONFLICT(workspace_id,root_id,actor_kind,actor_id) DO UPDATE SET last_read_sequence=CASE WHEN thread_members.last_read_sequence<excluded.last_read_sequence THEN excluded.last_read_sequence ELSE thread_members.last_read_sequence END`,
259
+ [this.workspace, rootId, actor.kind, actor.id, position.sequence],
260
+ );
261
+ await this.sql.execute(
262
+ `UPDATE inbox_notifications SET read_at=COALESCE(read_at,?) WHERE workspace_id=? AND root_id=? AND recipient_kind=? AND recipient_id=? AND source_sequence<=?`,
263
+ [new Date().toISOString(), this.workspace, rootId, actor.kind, actor.id, position.sequence],
264
+ );
265
+ }
266
+ async hasReaction(targetId: string, actor: ActorRef, code: ReactionCode): Promise<boolean> {
267
+ return (
268
+ (
269
+ await this.sql.query(
270
+ 'SELECT 1 FROM reactions_current WHERE workspace_id=? AND target_id=? AND actor_kind=? AND actor_id=? AND code=?',
271
+ [this.workspace, targetId, actor.kind, actor.id, code],
272
+ )
273
+ ).length > 0
274
+ );
275
+ }
276
+ async reactions(targetId: string, actor: ActorRef): Promise<ReactionSummary> {
277
+ const rows = await this.sql.query(
278
+ 'SELECT code,COUNT(*) AS count FROM reactions_current WHERE workspace_id=? AND target_id=? GROUP BY code',
279
+ [this.workspace, targetId],
280
+ );
281
+ const selected = await this.sql.query(
282
+ 'SELECT code FROM reactions_current WHERE workspace_id=? AND target_id=? AND actor_kind=? AND actor_id=? ORDER BY code',
283
+ [this.workspace, targetId, actor.kind, actor.id],
284
+ );
285
+ return {
286
+ targetId,
287
+ counts: Object.fromEntries(rows.map((row) => [text(row.code), Number(row.count)])),
288
+ selected: selected.map((row) => text(row.code) as ReactionCode),
289
+ };
290
+ }
291
+ async subscription(
292
+ rootId: string,
293
+ actor: ActorRef,
294
+ following: boolean,
295
+ muted: boolean,
296
+ ): Promise<void> {
297
+ await this.sql.execute(
298
+ `INSERT INTO thread_members(workspace_id,root_id,actor_kind,actor_id,following,muted) VALUES(?,?,?,?,?,?) ON CONFLICT(workspace_id,root_id,actor_kind,actor_id) DO UPDATE SET following=excluded.following,muted=excluded.muted`,
299
+ [this.workspace, rootId, actor.kind, actor.id, Number(following), Number(muted)],
300
+ );
301
+ }
302
+ async reserveBudget(kind: 'reply' | 'reaction', actor: ActorRef, at: string): Promise<void> {
303
+ const seconds = Math.floor(new Date(at).getTime() / 1000);
304
+ const limits = this.sql.budgets ?? defaultParticipationBudgets;
305
+ const budgets = [
306
+ { key: `${kind}:workspace`, window: 86400, limit: limits[kind].workspaceDay },
307
+ {
308
+ key: `${kind}:${actor.kind}:${actor.id}`,
309
+ window: 3600,
310
+ limit: limits[kind].hour,
311
+ },
312
+ { key: `${kind}:${actor.kind}:${actor.id}`, window: 60, limit: limits[kind].minute },
313
+ ];
314
+ for (const budget of budgets) {
315
+ const bucket = Math.floor(seconds / budget.window) * budget.window;
316
+ const row = (
317
+ await this.sql.query(
318
+ 'SELECT used FROM mutation_budgets WHERE workspace_id=? AND bucket_key=? AND bucket_start=? AND window_seconds=?',
319
+ [this.workspace, budget.key, bucket, budget.window],
320
+ )
321
+ )[0];
322
+ if (Number(row?.used ?? 0) >= budget.limit)
323
+ throw new SynomemError('RATE_LIMITED', 'The mutation rate budget is exhausted.', {
324
+ retryAfter: bucket + budget.window - seconds,
325
+ });
326
+ await this.sql.execute(
327
+ `INSERT INTO mutation_budgets(workspace_id,bucket_key,bucket_start,window_seconds,used) VALUES(?,?,?,?,1) ON CONFLICT(workspace_id,bucket_key,bucket_start,window_seconds) DO UPDATE SET used=mutation_budgets.used+1`,
328
+ [this.workspace, budget.key, bucket, budget.window],
329
+ );
330
+ }
331
+ }
332
+ }
333
+ export function participationStatements(
334
+ event: SynomemEvent,
335
+ sequence: bigint,
336
+ workspace: string,
337
+ ): Array<{ sql: string; values: unknown[] }> {
338
+ const commands: Array<{ sql: string; values: unknown[] }> = [];
339
+ const write = (sql: string, values: unknown[]): void => {
340
+ commands.push({ sql, values });
341
+ };
342
+ let rootId: string | undefined;
343
+ if ('rootId' in event) rootId = event.rootId;
344
+ else if (/^(kudos|memo|post|note|task|todo)\./.test(event.type)) rootId = event.aggregateId;
345
+ if (!rootId) return commands;
346
+ write('INSERT INTO thread_entries(workspace_id,root_id,event_id,sequence) VALUES(?,?,?,?)', [
347
+ workspace,
348
+ rootId,
349
+ event.id,
350
+ sequence.toString(),
351
+ ]);
352
+ if (event.type === 'reply.created') {
353
+ write(
354
+ `INSERT INTO replies_current(workspace_id,reply_id,root_id,parent_id,author_json,created_at,created_sequence,updated_sequence,version,body,mentions_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)`,
355
+ [
356
+ workspace,
357
+ event.id,
358
+ event.rootId,
359
+ event.parentId,
360
+ JSON.stringify(event.actor),
361
+ event.createdAt,
362
+ sequence.toString(),
363
+ sequence.toString(),
364
+ 1,
365
+ event.body,
366
+ JSON.stringify(event.mentions),
367
+ ],
368
+ );
369
+ write(
370
+ `INSERT INTO thread_members(workspace_id,root_id,actor_kind,actor_id,following,muted,joined_sequence) VALUES(?,?,?,?,1,0,?) ON CONFLICT(workspace_id,root_id,actor_kind,actor_id) DO UPDATE SET following=1`,
371
+ [workspace, rootId, event.actor.kind, event.actor.id, sequence.toString()],
372
+ );
373
+ } else if (event.type === 'reply.deleted') {
374
+ write(
375
+ 'UPDATE replies_current SET body=NULL,mentions_json=?,deleted_at=?,version=?,updated_sequence=? WHERE workspace_id=? AND reply_id=?',
376
+ [
377
+ '[]',
378
+ event.createdAt,
379
+ event.aggregateVersion,
380
+ sequence.toString(),
381
+ workspace,
382
+ event.replyId,
383
+ ],
384
+ );
385
+ } else if (event.type === 'reaction.added') {
386
+ write(
387
+ `INSERT INTO reactions_current(workspace_id,target_id,root_id,actor_kind,actor_id,code,event_id,created_at) VALUES(?,?,?,?,?,?,?,?)`,
388
+ [
389
+ workspace,
390
+ event.targetId,
391
+ rootId,
392
+ event.actor.kind,
393
+ event.actor.id,
394
+ event.code,
395
+ event.id,
396
+ event.createdAt,
397
+ ],
398
+ );
399
+ } else if (event.type === 'reaction.removed') {
400
+ write(
401
+ 'DELETE FROM reactions_current WHERE workspace_id=? AND target_id=? AND actor_kind=? AND actor_id=? AND code=?',
402
+ [workspace, event.targetId, event.actor.kind, event.actor.id, event.code],
403
+ );
404
+ }
405
+ return commands;
406
+ }
@@ -0,0 +1,103 @@
1
+ import type { ActorRef } from './policy.js';
2
+ import type { ActorIdentity, BaseEvent, SynomemEvent } from './types.js';
3
+
4
+ export const reactionCodes = [
5
+ 'thumbs_up',
6
+ 'eyes',
7
+ 'complete',
8
+ 'celebrate',
9
+ 'laugh',
10
+ 'thinking',
11
+ 'useful',
12
+ ] as const;
13
+ export type ReactionCode = (typeof reactionCodes)[number];
14
+ export interface ReplyCreatedEvent extends BaseEvent {
15
+ type: 'reply.created';
16
+ rootId: string;
17
+ parentId: string | null;
18
+ body: string;
19
+ mentions: ActorRef[];
20
+ }
21
+ export interface ReplyDeletedEvent extends Omit<
22
+ ReplyCreatedEvent,
23
+ 'type' | 'parentId' | 'body' | 'mentions'
24
+ > {
25
+ type: 'reply.deleted';
26
+ replyId: string;
27
+ reason?: string;
28
+ intervention?: {
29
+ basis: 'operator' | 'workspace_admin' | 'organization_admin' | 'local_owner';
30
+ reason?: string;
31
+ };
32
+ }
33
+ export interface ReactionEvent extends Omit<
34
+ ReplyCreatedEvent,
35
+ 'type' | 'parentId' | 'body' | 'mentions'
36
+ > {
37
+ type: 'reaction.added' | 'reaction.removed';
38
+ targetId: string;
39
+ code: ReactionCode;
40
+ }
41
+ export type ParticipationEvent = ReplyCreatedEvent | ReplyDeletedEvent | ReactionEvent;
42
+ export interface ReplyRecord {
43
+ id: string;
44
+ rootId: string;
45
+ parentId: string | null;
46
+ author: ActorIdentity;
47
+ createdAt: string;
48
+ createdSequence: string;
49
+ updatedSequence: string;
50
+ version: number;
51
+ deleted: boolean;
52
+ body?: string;
53
+ mentions?: ActorRef[];
54
+ }
55
+ export interface ReplyCreateInput {
56
+ rootId: string;
57
+ parentId?: string | null;
58
+ body: string;
59
+ mentions?: ActorRef[];
60
+ idempotencyKey?: string;
61
+ }
62
+ export interface ReplyDeleteInput {
63
+ replyId: string;
64
+ expectedVersion: number;
65
+ reason?: string;
66
+ idempotencyKey?: string;
67
+ }
68
+ export interface ReactionSetInput {
69
+ targetId: string;
70
+ code: ReactionCode;
71
+ present: boolean;
72
+ idempotencyKey?: string;
73
+ }
74
+ export interface ThreadInput {
75
+ rootId: string;
76
+ after?: string;
77
+ limit?: number;
78
+ }
79
+ export interface ThreadPage {
80
+ rootId: string;
81
+ entries: Array<{ sequence: string; event: SynomemEvent; detailRequired?: boolean }>;
82
+ hasMore: boolean;
83
+ nextCursor?: string;
84
+ watermark: string;
85
+ contextLimited: boolean;
86
+ subscription?: { following: boolean; muted: boolean; lastReadSequence: string };
87
+ }
88
+ export interface ReactionSummary {
89
+ targetId: string;
90
+ counts: Partial<Record<ReactionCode, number>>;
91
+ selected: ReactionCode[];
92
+ }
93
+ export interface ParticipationRepository {
94
+ apply(event: SynomemEvent, sequence: bigint): Promise<void>;
95
+ getReply(id: string): Promise<ReplyRecord | undefined>;
96
+ thread(input: ThreadInput, actor: ActorIdentity): Promise<ThreadPage>;
97
+ changes(input: ThreadInput, actor: ActorIdentity): Promise<ThreadPage>;
98
+ read(rootId: string, through: string, actor: ActorRef): Promise<void>;
99
+ hasReaction(targetId: string, actor: ActorRef, code: ReactionCode): Promise<boolean>;
100
+ reactions(targetId: string, actor: ActorRef): Promise<ReactionSummary>;
101
+ subscription(rootId: string, actor: ActorRef, following: boolean, muted: boolean): Promise<void>;
102
+ reserveBudget(kind: 'reply' | 'reaction', actor: ActorRef, at: string): Promise<void>;
103
+ }
package/src/policy.ts ADDED
@@ -0,0 +1,109 @@
1
+ import type { ActorIdentity } from './types.js';
2
+
3
+ /** A delivery/ownership target. Identity is always scoped to a workspace. */
4
+ export type ActorRef = Readonly<{ kind: 'human' | 'agent'; id: string }>;
5
+
6
+ export interface AddressableActor extends ActorRef {
7
+ handle: string;
8
+ displayName: string;
9
+ status: 'active' | 'inactive';
10
+ }
11
+
12
+ /** Verified adapter authority, never accepted from a request or tool argument. */
13
+ export interface RecordAuthority {
14
+ mode: 'local-owner' | 'local-member' | 'hosted';
15
+ workspaceAdministrator: boolean;
16
+ organizationAdministrator: boolean;
17
+ operatedAgentIds: ReadonlySet<string>;
18
+ }
19
+
20
+ export function sameActor(
21
+ a: Pick<ActorIdentity, 'kind' | 'id'>,
22
+ b: Pick<ActorIdentity, 'kind' | 'id'>,
23
+ ): boolean {
24
+ return a.kind === b.kind && a.id === b.id;
25
+ }
26
+
27
+ export function actorKey(actor: Pick<ActorIdentity, 'kind' | 'id'>): string {
28
+ return `${actor.kind}:${actor.id}`;
29
+ }
30
+
31
+ export function resolveAuthority(authority?: RecordAuthority): RecordAuthority {
32
+ return {
33
+ mode: authority?.mode ?? 'local-member',
34
+ workspaceAdministrator: authority?.workspaceAdministrator ?? false,
35
+ organizationAdministrator: authority?.organizationAdministrator ?? false,
36
+ operatedAgentIds: new Set(authority?.operatedAgentIds ?? []),
37
+ };
38
+ }
39
+
40
+ export function isRecordAdministrator(actor: ActorIdentity, authority: RecordAuthority): boolean {
41
+ return (
42
+ actor.kind === 'human' &&
43
+ (authority.mode === 'local-owner' ||
44
+ authority.workspaceAdministrator ||
45
+ authority.organizationAdministrator)
46
+ );
47
+ }
48
+
49
+ export function overseesAgent(
50
+ actor: ActorIdentity,
51
+ authority: RecordAuthority,
52
+ id?: string,
53
+ ): boolean {
54
+ return (
55
+ actor.kind === 'human' &&
56
+ (isRecordAdministrator(actor, authority) || (!!id && authority.operatedAgentIds.has(id)))
57
+ );
58
+ }
59
+
60
+ /** Explicit local filesystem-owner entry points supply this; human profiles do not. */
61
+ export function localOwnerAuthority(): RecordAuthority {
62
+ return {
63
+ mode: 'local-owner',
64
+ workspaceAdministrator: true,
65
+ organizationAdministrator: false,
66
+ operatedAgentIds: new Set(),
67
+ };
68
+ }
69
+
70
+ /** SQL authority predicate; placeholder parameters are independent of the adapter. */
71
+ export function recordVisibilityPredicate(
72
+ viewer: ActorIdentity,
73
+ authority: RecordAuthority,
74
+ kudosOnly = false,
75
+ prefix = '',
76
+ ): { sql: string; values: string[] } {
77
+ if (isRecordAdministrator(viewer, authority)) return { sql: '1=1', values: [] };
78
+ const participantColumns = kudosOnly ? ['recipient'] : ['recipient', 'owner', 'assignee'];
79
+ const ordinary = [
80
+ `${prefix}visibility!='private'`,
81
+ `(${prefix}actor_kind=? AND ${prefix}actor_id=?)`,
82
+ ];
83
+ const values = [viewer.kind, viewer.id];
84
+ for (const column of participantColumns) {
85
+ ordinary.push(`(${prefix}${column}_kind=? AND ${prefix}${column}_id=?)`);
86
+ values.push(viewer.kind, viewer.id);
87
+ }
88
+ if (viewer.kind === 'human')
89
+ for (const id of authority.operatedAgentIds) {
90
+ ordinary.push(`(${prefix}actor_kind='agent' AND ${prefix}actor_id=?)`);
91
+ values.push(id);
92
+ for (const column of participantColumns) {
93
+ ordinary.push(`(${prefix}${column}_kind='agent' AND ${prefix}${column}_id=?)`);
94
+ values.push(id);
95
+ }
96
+ }
97
+ if (kudosOnly) return { sql: `(${ordinary.join(' OR ')})`, values };
98
+ const memory = [`(${prefix}owner_kind=? AND ${prefix}owner_id=?)`];
99
+ const memoryValues = [viewer.kind, viewer.id];
100
+ if (viewer.kind === 'human')
101
+ for (const id of authority.operatedAgentIds) {
102
+ memory.push(`(${prefix}owner_kind='agent' AND ${prefix}owner_id=?)`);
103
+ memoryValues.push(id);
104
+ }
105
+ return {
106
+ sql: `((${prefix}kind NOT IN ('note','todo') AND (${ordinary.join(' OR ')})) OR (${prefix}kind IN ('note','todo') AND (${memory.join(' OR ')})))`,
107
+ values: [...values, ...memoryValues],
108
+ };
109
+ }
@@ -1,3 +1,10 @@
1
+ import type { ActorDirectoryInput, ActorDirectoryPage } from '../actor-directory.js';
2
+ import type { BookmarkRepository } from '../bookmarks.js';
3
+ import type { SearchInput, SearchPage } from '../search.js';
4
+ import type { NotificationRepository } from '../notifications.js';
5
+ import type { ParticipationRepository } from '../participation.js';
6
+ import type { MutationReceipt } from '../mutation-receipts.js';
7
+ import type { ActorRef, AddressableActor, RecordAuthority } from '../policy.js';
1
8
  import type {
2
9
  ActorIdentity,
3
10
  AgentProfile,
@@ -25,8 +32,13 @@ import type {
25
32
  * local SQLite and remote service implementations.
26
33
  */
27
34
  export interface SynomemRepository {
35
+ search?(input: SearchInput, actor: ActorIdentity): Promise<SearchPage>;
36
+ readonly bookmarks: BookmarkRepository;
28
37
  readonly config: SynomemConfig;
38
+ readonly notifications: NotificationRepository;
39
+ readonly participation: ParticipationRepository;
29
40
 
41
+ setAuthority(authority: RecordAuthority): void;
30
42
  init(): Awaitable<void>;
31
43
  close(): Awaitable<void>;
32
44
  assertEventCompatibility(): Awaitable<void>;
@@ -42,6 +54,27 @@ export interface SynomemRepository {
42
54
  getReadableSynomemEvents(kudosId: string): Awaitable<SynomemEvent[]>;
43
55
  getReadableItemEvents(id: string): Awaitable<SynomemEvent[]>;
44
56
 
57
+ getMutationReceipt(
58
+ actorKind: string,
59
+ actorId: string,
60
+ keyHash: string,
61
+ ): Awaitable<MutationReceipt | undefined>;
62
+ insertMutationReceipt(
63
+ actorKind: string,
64
+ actorId: string,
65
+ keyHash: string,
66
+ receipt: MutationReceipt,
67
+ ): Awaitable<void>;
68
+ compactMutationReceipts(before: string): Awaitable<number>;
69
+ canActorReadItem(id: string, actor: ActorRef): Awaitable<boolean>;
70
+ getActor(ref: ActorRef): Awaitable<AddressableActor | undefined>;
71
+ actorCounts(
72
+ target: ActorRef,
73
+ viewer: ActorIdentity,
74
+ ): Awaitable<{ kudosReceived: number; usefulReceived: number }>;
75
+ listActors(input: ActorDirectoryInput, viewer: ActorIdentity): Awaitable<ActorDirectoryPage>;
76
+ registerHuman(actor: AddressableActor): Awaitable<void>;
77
+
45
78
  insertAgent(profile: AgentProfile): Awaitable<void>;
46
79
  updateAgent(profile: AgentProfile, updatedAt: string): Awaitable<void>;
47
80
  getAgent(idOrAlias: string): Awaitable<AgentProfile | undefined>;
package/src/profiles.ts CHANGED
@@ -92,6 +92,7 @@ const actorSnapshotSchema = z
92
92
  const localProfileSchema = z
93
93
  .object({
94
94
  backend: z.literal('local'),
95
+ actorKind: z.enum(['human', 'agent']).optional(),
95
96
  /** The store's home; absent means the root Synomem home. */
96
97
  home: z.string().optional(),
97
98
  actorId: z.string().min(1),
@@ -453,7 +454,7 @@ export function localProfileTarget(
453
454
  ): { home: string; actor: ActorIdentity } {
454
455
  const home = profile.home ?? rootHome;
455
456
  const actor: ActorIdentity = {
456
- kind: 'agent',
457
+ kind: profile.actorKind ?? 'agent',
457
458
  id: profile.actorId,
458
459
  ...(profile.actorName ? { displayName: profile.actorName } : {}),
459
460
  };