synomem 0.2.0 → 0.4.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 (89) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +4 -4
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/backend.js +8 -2
  5. package/dist/backend.js.map +1 -1
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +379 -16
  9. package/dist/cli.js.map +1 -1
  10. package/dist/client.d.ts +68 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +283 -12
  13. package/dist/client.js.map +1 -1
  14. package/dist/cloud.d.ts +16 -0
  15. package/dist/cloud.d.ts.map +1 -0
  16. package/dist/cloud.js +19 -0
  17. package/dist/cloud.js.map +1 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +8 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/configure.d.ts +55 -0
  22. package/dist/configure.d.ts.map +1 -0
  23. package/dist/configure.js +177 -0
  24. package/dist/configure.js.map +1 -0
  25. package/dist/credentials.d.ts +15 -2
  26. package/dist/credentials.d.ts.map +1 -1
  27. package/dist/credentials.js.map +1 -1
  28. package/dist/import.d.ts +202 -38
  29. package/dist/import.d.ts.map +1 -1
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/mcp/index.d.ts.map +1 -1
  35. package/dist/mcp/index.js +72 -6
  36. package/dist/mcp/index.js.map +1 -1
  37. package/dist/oauth.d.ts.map +1 -1
  38. package/dist/oauth.js +8 -1
  39. package/dist/oauth.js.map +1 -1
  40. package/dist/ports/repository.d.ts +4 -1
  41. package/dist/ports/repository.d.ts.map +1 -1
  42. package/dist/projections.d.ts +9 -1
  43. package/dist/projections.d.ts.map +1 -1
  44. package/dist/projections.js +75 -4
  45. package/dist/projections.js.map +1 -1
  46. package/dist/prompt.d.ts +28 -0
  47. package/dist/prompt.d.ts.map +1 -0
  48. package/dist/prompt.js +72 -0
  49. package/dist/prompt.js.map +1 -0
  50. package/dist/remote.d.ts +30 -1
  51. package/dist/remote.d.ts.map +1 -1
  52. package/dist/remote.js +14 -0
  53. package/dist/remote.js.map +1 -1
  54. package/dist/schemas.d.ts +270 -55
  55. package/dist/schemas.d.ts.map +1 -1
  56. package/dist/schemas.js +120 -6
  57. package/dist/schemas.js.map +1 -1
  58. package/dist/service.d.ts +30 -1
  59. package/dist/service.d.ts.map +1 -1
  60. package/dist/storage.d.ts +25 -2
  61. package/dist/storage.d.ts.map +1 -1
  62. package/dist/storage.js +168 -15
  63. package/dist/storage.js.map +1 -1
  64. package/dist/types.d.ts +123 -4
  65. package/dist/types.d.ts.map +1 -1
  66. package/docs/cli.md +1 -1
  67. package/docs/examples.md +1 -1
  68. package/docs/mcp.md +1 -1
  69. package/docs/skill.md +1 -1
  70. package/docs/storage-format.md +1 -1
  71. package/package.json +8 -8
  72. package/src/backend.ts +8 -2
  73. package/src/cli.ts +543 -19
  74. package/src/client.ts +331 -11
  75. package/src/cloud.ts +19 -0
  76. package/src/config.ts +8 -1
  77. package/src/configure.ts +233 -0
  78. package/src/credentials.ts +17 -2
  79. package/src/index.ts +7 -1
  80. package/src/mcp/index.ts +95 -5
  81. package/src/oauth.ts +8 -1
  82. package/src/ports/repository.ts +5 -0
  83. package/src/projections.ts +74 -4
  84. package/src/prompt.ts +88 -0
  85. package/src/remote.ts +72 -0
  86. package/src/schemas.ts +125 -6
  87. package/src/service.ts +30 -0
  88. package/src/storage.ts +217 -14
  89. package/src/types.ts +129 -3
package/src/remote.ts CHANGED
@@ -6,6 +6,8 @@ import type {
6
6
  ChangesInput,
7
7
  BindRuntimeInput,
8
8
  CreateAgentInput,
9
+ CreatePostInput,
10
+ UpdatePostInput,
9
11
  CreateNoteInput,
10
12
  CreateTaskInput,
11
13
  GiveKudosInput,
@@ -177,6 +179,30 @@ export class RemoteSynomemService implements SynomemService {
177
179
  'GET',
178
180
  `agents/resolve?query=${encodeURIComponent(query)}`,
179
181
  ),
182
+ archive: (idOrAlias: string) =>
183
+ this.mutation<Awaited<ReturnType<SynomemService['agents']['archive']>>>(
184
+ 'POST',
185
+ `agents/${encodeURIComponent(idOrAlias)}/archive`,
186
+ {},
187
+ ),
188
+ restore: (idOrAlias: string) =>
189
+ this.mutation<Awaited<ReturnType<SynomemService['agents']['restore']>>>(
190
+ 'POST',
191
+ `agents/${encodeURIComponent(idOrAlias)}/restore`,
192
+ {},
193
+ ),
194
+ addAliases: (idOrAlias: string, aliases: string[]) =>
195
+ this.mutation<Awaited<ReturnType<SynomemService['agents']['addAliases']>>>(
196
+ 'POST',
197
+ `agents/${encodeURIComponent(idOrAlias)}/aliases`,
198
+ { aliases },
199
+ ),
200
+ removeAliases: (idOrAlias: string, aliases: string[]) =>
201
+ this.mutation<Awaited<ReturnType<SynomemService['agents']['removeAliases']>>>(
202
+ 'POST',
203
+ `agents/${encodeURIComponent(idOrAlias)}/aliases/remove`,
204
+ { aliases },
205
+ ),
180
206
  directory: () =>
181
207
  this.request<Awaited<ReturnType<SynomemService['agents']['directory']>>>(
182
208
  'GET',
@@ -200,6 +226,52 @@ export class RemoteSynomemService implements SynomemService {
200
226
  ),
201
227
  };
202
228
 
229
+ readonly posts = {
230
+ create: (input: CreatePostInput) =>
231
+ this.mutation<Awaited<ReturnType<SynomemService['posts']['create']>>>('POST', 'posts', input),
232
+ list: (input: Omit<ItemListInput, 'kinds'> = {}) =>
233
+ this.request<Awaited<ReturnType<SynomemService['posts']['list']>>>(
234
+ 'GET',
235
+ `posts${queryString(input)}`,
236
+ ),
237
+ get: (id: string) =>
238
+ this.request<Awaited<ReturnType<SynomemService['posts']['get']>>>(
239
+ 'GET',
240
+ `posts/${encodeURIComponent(id)}`,
241
+ ),
242
+ update: (input: UpdatePostInput) =>
243
+ this.mutation<Awaited<ReturnType<SynomemService['posts']['update']>>>(
244
+ 'POST',
245
+ `posts/${encodeURIComponent(input.postId)}/revisions`,
246
+ input,
247
+ ['postId'],
248
+ ),
249
+ archive: (input: { postId: string; reason?: string; idempotencyKey?: string }) =>
250
+ this.mutation<Awaited<ReturnType<SynomemService['posts']['archive']>>>(
251
+ 'POST',
252
+ `posts/${encodeURIComponent(input.postId)}/archive`,
253
+ input,
254
+ ['postId'],
255
+ ),
256
+ acknowledge: (input: { postId: string; note?: string; idempotencyKey?: string }) =>
257
+ this.mutation<Awaited<ReturnType<SynomemService['posts']['acknowledge']>>>(
258
+ 'POST',
259
+ `posts/${encodeURIComponent(input.postId)}/acknowledgment`,
260
+ input,
261
+ ['postId'],
262
+ ),
263
+ withdrawAcknowledgment: (input: { postId: string; reason?: string }) =>
264
+ this.request<Awaited<ReturnType<SynomemService['posts']['withdrawAcknowledgment']>>>(
265
+ 'DELETE',
266
+ `posts/${encodeURIComponent(input.postId)}/acknowledgment`,
267
+ ),
268
+ roster: (postId: string) =>
269
+ this.request<Awaited<ReturnType<SynomemService['posts']['roster']>>>(
270
+ 'GET',
271
+ `posts/${encodeURIComponent(postId)}/roster`,
272
+ ),
273
+ };
274
+
203
275
  readonly kudos = {
204
276
  give: (input: GiveKudosInput) =>
205
277
  this.mutation<Awaited<ReturnType<SynomemService['kudos']['give']>>>('POST', 'kudos', input),
package/src/schemas.ts CHANGED
@@ -17,12 +17,34 @@ const reservedIds = new Set([
17
17
  'lpt1',
18
18
  ]);
19
19
 
20
- export const agentIdSchema = z
20
+ /**
21
+ * A handle: the human-friendly name for an agent, unique within its workspace.
22
+ *
23
+ * Mutable, unlike the canonical ID. People and agents type this, so it stays
24
+ * lowercase kebab and refuses the reserved words that would collide with
25
+ * filesystem or route segments.
26
+ */
27
+ export const agentHandleSchema = z
21
28
  .string()
22
29
  .min(1)
23
30
  .max(63)
24
31
  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lowercase ASCII letters, digits, and hyphens')
25
- .refine((id) => !reservedIds.has(id), 'Reserved agent ID');
32
+ .refine((handle) => !reservedIds.has(handle), 'Reserved agent handle');
33
+
34
+ /** A canonical opaque agent ID: a ULID, uppercase Crockford base32. */
35
+ export const agentUlidSchema = z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/);
36
+
37
+ /**
38
+ * An actor ID as it appears in an event.
39
+ *
40
+ * Accepts a ULID or a handle-shaped name. New agents are created with an opaque
41
+ * ULID so a handle can be renamed without orphaning the events that reference
42
+ * the actor; agents that predate that, and human and system actors, carry a
43
+ * name-shaped ID. Widening rather than replacing keeps append-only history
44
+ * readable — rewriting the actor ID inside stored events to tidy the format
45
+ * would be exactly the rewrite the event log exists to prevent.
46
+ */
47
+ export const agentIdSchema = z.union([agentUlidSchema, agentHandleSchema]);
26
48
 
27
49
  /**
28
50
  * An alias as written, folded to the canonical lowercase form.
@@ -110,16 +132,42 @@ export const evidenceSchema = z
110
132
  });
111
133
 
112
134
  export const profileSchema = z.object({
135
+ /** Canonical, opaque and immutable. Events reference this, never the handle. */
113
136
  id: agentIdSchema,
137
+ handle: agentHandleSchema,
114
138
  displayName: z.string().trim().min(1).max(200),
115
139
  aliases: z.array(agentAliasSchema).max(50).optional(),
116
140
  description: z.string().trim().max(2000).optional(),
141
+ /** Archived agents keep their history and stop being able to act. */
142
+ status: z.enum(['active', 'archived']).default('active'),
117
143
  createdAt: z.string().datetime({ offset: true }),
118
144
  metadata: metadataSchema.optional(),
119
145
  });
120
146
 
121
- export const createAgentSchema = profileSchema.omit({ createdAt: true });
122
- export const updateAgentSchema = createAgentSchema.omit({ id: true }).partial();
147
+ /**
148
+ * Creating an agent names a handle; the canonical ID is generated, never
149
+ * supplied. A caller that could choose the ID could choose one that collides
150
+ * with an archived agent's history.
151
+ */
152
+ export const createAgentSchema = z
153
+ .object({
154
+ handle: agentHandleSchema,
155
+ displayName: z.string().trim().min(1).max(200),
156
+ aliases: z.array(agentAliasSchema).max(50).optional(),
157
+ description: z.string().trim().max(2000).optional(),
158
+ metadata: metadataSchema.optional(),
159
+ })
160
+ .strict();
161
+
162
+ export const updateAgentSchema = z
163
+ .object({
164
+ handle: agentHandleSchema.optional(),
165
+ displayName: z.string().trim().min(1).max(200).optional(),
166
+ aliases: z.array(agentAliasSchema).max(50).optional(),
167
+ description: z.string().trim().max(2000).optional(),
168
+ metadata: metadataSchema.optional(),
169
+ })
170
+ .strict();
123
171
 
124
172
  /**
125
173
  * A runtime binding is a claim about where an agent runs, so the fields stay
@@ -196,7 +244,14 @@ const agentCreatedSchema = baseEventSchema.extend({
196
244
  const agentUpdatedSchema = baseEventSchema.extend({
197
245
  type: z.literal('agent.updated'),
198
246
  agentId: agentIdSchema,
199
- changes: updateAgentSchema,
247
+ /*
248
+ * Archiving is recorded as an update, so `status` belongs in the event even
249
+ * though callers cannot set it through `agents.update` — it moves through
250
+ * `archive` and `restore`, which keep the transition explicit.
251
+ */
252
+ changes: updateAgentSchema.extend({
253
+ status: z.enum(['active', 'archived']).optional(),
254
+ }),
200
255
  });
201
256
 
202
257
  const memoSentSchema = baseEventSchema.extend({
@@ -257,6 +312,47 @@ const noteArchivedSchema = baseEventSchema.extend({
257
312
  noteId: z.string().length(26),
258
313
  });
259
314
 
315
+ /**
316
+ * Post events. A post carries no recipient, assignee or visibility: it is
317
+ * addressed to the workspace, and workspace membership is the audience. Adding
318
+ * a visibility field would create a second, weaker way to hide a record.
319
+ */
320
+ const postFields = {
321
+ title: z
322
+ .string()
323
+ .trim()
324
+ .min(1)
325
+ .max(200)
326
+ .regex(/^[^\r\n]+$/),
327
+ body: z.string().trim().min(1).max(32_000),
328
+ tags: z.array(kudosTagSchema).max(20).optional(),
329
+ };
330
+ const postCreatedSchema = baseEventSchema.extend({
331
+ type: z.literal('post.created'),
332
+ ...postFields,
333
+ replyTo: z.string().length(26).optional(),
334
+ });
335
+ const postEditedSchema = baseEventSchema.extend({
336
+ type: z.literal('post.edited'),
337
+ postId: z.string().length(26),
338
+ ...postFields,
339
+ });
340
+ const postArchivedSchema = baseEventSchema.extend({
341
+ type: z.literal('post.archived'),
342
+ postId: z.string().length(26),
343
+ reason: z.string().trim().min(1).max(2000).optional(),
344
+ });
345
+ const postAcknowledgedSchema = baseEventSchema.extend({
346
+ type: z.literal('post.acknowledged'),
347
+ postId: z.string().length(26),
348
+ note: z.string().trim().min(1).max(2000).optional(),
349
+ });
350
+ const postAcknowledgmentWithdrawnSchema = baseEventSchema.extend({
351
+ type: z.literal('post.acknowledgment.withdrawn'),
352
+ postId: z.string().length(26),
353
+ reason: z.string().trim().min(1).max(2000).optional(),
354
+ });
355
+
260
356
  const taskDueSchema = z.discriminatedUnion('kind', [
261
357
  z.object({
262
358
  kind: z.literal('date'),
@@ -380,6 +476,11 @@ const todoArchivedSchema = baseEventSchema.extend({
380
476
  });
381
477
 
382
478
  export const eventSchema = z.discriminatedUnion('type', [
479
+ postCreatedSchema,
480
+ postEditedSchema,
481
+ postArchivedSchema,
482
+ postAcknowledgedSchema,
483
+ postAcknowledgmentWithdrawnSchema,
383
484
  kudosGivenSchema,
384
485
  acknowledgedSchema,
385
486
  revokedSchema,
@@ -477,6 +578,24 @@ export const sendMemoSchema = z
477
578
  ...mutationMetadata,
478
579
  })
479
580
  .strict();
581
+ export const createPostSchema = z
582
+ .object({
583
+ ...postFields,
584
+ replyTo: z.string().length(26).optional(),
585
+ ...mutationMetadata,
586
+ })
587
+ .strict();
588
+ export const updatePostSchema = z
589
+ .object({
590
+ postId: z.string().length(26),
591
+ expectedVersion: z.number().int().min(1),
592
+ title: postFields.title.optional(),
593
+ body: postFields.body.optional(),
594
+ tags: postFields.tags,
595
+ idempotencyKey: z.string().trim().min(1).max(200).optional(),
596
+ })
597
+ .strict();
598
+
480
599
  export const createNoteSchema = z
481
600
  .object({
482
601
  ownerAgentId: agentIdSchema.optional(),
@@ -553,7 +672,7 @@ export const updateTodoSchema = z
553
672
  export const itemListInputSchema = z
554
673
  .object({
555
674
  kinds: z
556
- .array(z.enum(['kudos', 'memo', 'note', 'task', 'todo']))
675
+ .array(z.enum(['kudos', 'memo', 'note', 'post', 'task', 'todo']))
557
676
  .max(5)
558
677
  .optional(),
559
678
  participantAgentId: agentIdSchema.optional(),
package/src/service.ts CHANGED
@@ -2,6 +2,10 @@ import type {
2
2
  ActorIdentity,
3
3
  AgentDirectoryEntry,
4
4
  AgentProfile,
5
+ CreatePostInput,
6
+ PostRecord,
7
+ PostRoster,
8
+ UpdatePostInput,
5
9
  AgentResolution,
6
10
  AgentRuntimeBinding,
7
11
  BindRuntimeInput,
@@ -75,11 +79,37 @@ export interface SynomemDomainService {
75
79
  get(idOrAlias: string): Promise<AgentProfile>;
76
80
  list(): Promise<AgentProfile[]>;
77
81
  resolve(query: string): Promise<AgentResolution>;
82
+ archive(idOrAlias: string): Promise<AgentProfile>;
83
+ restore(idOrAlias: string): Promise<AgentProfile>;
84
+ addAliases(idOrAlias: string, aliases: string[]): Promise<AgentProfile>;
85
+ removeAliases(idOrAlias: string, aliases: string[]): Promise<AgentProfile>;
78
86
  directory(): Promise<AgentDirectoryEntry[]>;
79
87
  bindings(idOrAlias: string): Promise<AgentRuntimeBinding[]>;
80
88
  bindRuntime(input: BindRuntimeInput): Promise<AgentRuntimeBinding>;
81
89
  unbindRuntime(bindingId: string): Promise<boolean>;
82
90
  };
91
+ readonly posts: {
92
+ create(input: CreatePostInput): Promise<{
93
+ record: PostRecord;
94
+ created: boolean;
95
+ deduplicated: boolean;
96
+ }>;
97
+ list(input?: Omit<ItemListInput, 'kinds'>): Promise<Page<ItemSummary>>;
98
+ get(id: string): Promise<PostRecord>;
99
+ update(input: UpdatePostInput): Promise<PostRecord>;
100
+ archive(input: {
101
+ postId: string;
102
+ reason?: string;
103
+ idempotencyKey?: string;
104
+ }): Promise<PostRecord>;
105
+ acknowledge(input: {
106
+ postId: string;
107
+ note?: string;
108
+ idempotencyKey?: string;
109
+ }): Promise<PostRecord>;
110
+ withdrawAcknowledgment(input: { postId: string; reason?: string }): Promise<PostRecord>;
111
+ roster(postId: string): Promise<PostRoster>;
112
+ };
83
113
  readonly kudos: {
84
114
  give(input: GiveKudosInput): Promise<GiveKudosResult>;
85
115
  list(input?: KudosListInput): Promise<Page<KudosSummary>>;
package/src/storage.ts CHANGED
@@ -31,6 +31,8 @@ import type {
31
31
  AgentProfile,
32
32
  AgentRuntimeBinding,
33
33
  ChangePage,
34
+ PostAcknowledgment,
35
+ PostRoster,
34
36
  JsonValue,
35
37
  KudosChange,
36
38
  SynomemEvent,
@@ -237,6 +239,37 @@ CREATE UNIQUE INDEX agent_runtime_bindings_unique
237
239
  CREATE INDEX agent_runtime_bindings_agent ON agent_runtime_bindings(agent_id);
238
240
  `;
239
241
 
242
+ const migrationV6 = `
243
+ -- Acknowledgements are their own rows rather than a column on the post: many
244
+ -- actors acknowledge one post independently, and the interesting question is
245
+ -- who, not how many.
246
+ CREATE TABLE post_acknowledgments (
247
+ post_id TEXT NOT NULL,
248
+ actor_kind TEXT NOT NULL,
249
+ actor_id TEXT NOT NULL,
250
+ actor_display_name TEXT,
251
+ note TEXT,
252
+ acknowledged_at TEXT NOT NULL,
253
+ PRIMARY KEY (post_id, actor_kind, actor_id)
254
+ ) STRICT;
255
+ CREATE INDEX post_acknowledgments_post ON post_acknowledgments(post_id);
256
+ `;
257
+
258
+ const migrationV7 = `
259
+ -- Opaque canonical IDs, with the handle as a separate mutable name.
260
+ --
261
+ -- Existing agents keep their name-shaped ID and take it as their handle too.
262
+ -- Rewriting the actor ID inside stored events to tidy the format would be
263
+ -- exactly the rewrite an append-only log exists to prevent, so history stays
264
+ -- as written and only NEW agents get a generated opaque ID.
265
+ ALTER TABLE agents ADD COLUMN handle TEXT;
266
+ UPDATE agents SET handle = id WHERE handle IS NULL;
267
+ CREATE UNIQUE INDEX agents_handle ON agents(handle);
268
+
269
+ -- Archived agents keep their records and stop being able to act.
270
+ ALTER TABLE agents ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
271
+ `;
272
+
240
273
  const migrationV3 = `
241
274
  DROP TRIGGER IF EXISTS events_append_only_update;
242
275
  DROP TRIGGER IF EXISTS events_append_only_delete;
@@ -365,6 +398,7 @@ function eventKind(event: SynomemEvent): RecordKind | undefined {
365
398
  if (event.type.startsWith('kudos.')) return 'kudos';
366
399
  if (event.type.startsWith('memo.')) return 'memo';
367
400
  if (event.type.startsWith('note.')) return 'note';
401
+ if (event.type.startsWith('post.')) return 'post';
368
402
  if (event.type.startsWith('task.')) return 'task';
369
403
  if (event.type.startsWith('todo.')) return 'todo';
370
404
  return undefined;
@@ -411,7 +445,8 @@ export class SynomemStorage implements SynomemRepository {
411
445
 
412
446
  constructor(options: StorageOptions) {
413
447
  this.home = resolve(options.home);
414
- this.storageDirectory = join(this.home, 'synomem');
448
+ // The home is the storage directory; see configLocation in backend.ts.
449
+ this.storageDirectory = this.home;
415
450
  this.databasePath = join(this.storageDirectory, 'synomem.sqlite3');
416
451
  this.configPath = join(this.storageDirectory, 'config.json');
417
452
  this.readOnly = options.readOnly;
@@ -499,7 +534,7 @@ export class SynomemStorage implements SynomemRepository {
499
534
  const version = Number(
500
535
  (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
501
536
  );
502
- if (version > 5) {
537
+ if (version > 7) {
503
538
  throw new SynomemError(
504
539
  'UNSUPPORTED_SCHEMA',
505
540
  `Database schema version ${version} is newer than this package supports.`,
@@ -567,14 +602,38 @@ export class SynomemStorage implements SynomemRepository {
567
602
  db.exec('PRAGMA user_version = 5');
568
603
  });
569
604
  }
605
+ const afterV5 = Number(
606
+ (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
607
+ );
608
+ if (afterV5 === 5) {
609
+ this.transactionSync(() => {
610
+ db.exec(migrationV6);
611
+ db.prepare(
612
+ 'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
613
+ ).run(6, new Date().toISOString());
614
+ db.exec('PRAGMA user_version = 6');
615
+ });
616
+ }
617
+ const afterV6 = Number(
618
+ (db.prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
619
+ );
620
+ if (afterV6 === 6) {
621
+ this.transactionSync(() => {
622
+ db.exec(migrationV7);
623
+ db.prepare(
624
+ 'INSERT OR IGNORE INTO schema_migrations(version, applied_at) VALUES (?, ?)',
625
+ ).run(7, new Date().toISOString());
626
+ db.exec('PRAGMA user_version = 7');
627
+ });
628
+ }
570
629
  }
571
630
 
572
631
  private assertSchemaSupported(): void {
573
632
  const version = Number(
574
633
  (this.db().prepare('PRAGMA user_version').get() as { user_version: number }).user_version,
575
634
  );
576
- if (version !== 5) {
577
- if (version >= 1 && version <= 4) {
635
+ if (version !== 7) {
636
+ if (version >= 1 && version <= 6) {
578
637
  throw new SynomemError(
579
638
  'UNSUPPORTED_SCHEMA',
580
639
  `Database schema version ${version} requires migration. Open this home once with readOnly: false, then retry the read-only client.`,
@@ -835,6 +894,61 @@ export class SynomemStorage implements SynomemRepository {
835
894
  ownerAgentId: event.ownerAgentId,
836
895
  ownerDisplayName: event.ownerDisplayName,
837
896
  });
897
+ } else if (event.type === 'post.created') {
898
+ /*
899
+ * A post is addressed to the workspace, so it is recorded as `workspace`
900
+ * visibility rather than carrying a visibility of its own. There is no
901
+ * owner-scoped or recipient-scoped read filter to apply: everyone who can
902
+ * read the workspace can read it, which is the whole point of the domain.
903
+ */
904
+ insert({
905
+ kind: 'post',
906
+ title: event.title,
907
+ tags: event.tags,
908
+ visibility: 'workspace',
909
+ status: 'active',
910
+ });
911
+ } else if (event.type === 'post.edited') {
912
+ this.db()
913
+ .prepare(
914
+ `UPDATE items_current SET title = ?, tags_json = ?,
915
+ updated_sequence = ?, updated_at = ? WHERE item_id = ?`,
916
+ )
917
+ .run(
918
+ event.title,
919
+ JSON.stringify(event.tags ?? []),
920
+ sequence,
921
+ event.createdAt,
922
+ event.postId,
923
+ );
924
+ } else if (event.type === 'post.archived') {
925
+ updateStatus('archived');
926
+ } else if (event.type === 'post.acknowledged') {
927
+ // One row per actor per post: acknowledging twice is the same statement,
928
+ // not a second one.
929
+ this.db()
930
+ .prepare(
931
+ `INSERT INTO post_acknowledgments
932
+ (post_id, actor_kind, actor_id, actor_display_name, note, acknowledged_at)
933
+ VALUES (?, ?, ?, ?, ?, ?)
934
+ ON CONFLICT(post_id, actor_kind, actor_id) DO UPDATE SET
935
+ note = excluded.note,
936
+ acknowledged_at = excluded.acknowledged_at`,
937
+ )
938
+ .run(
939
+ event.postId,
940
+ event.actor.kind,
941
+ event.actor.id,
942
+ event.actor.displayName ?? null,
943
+ event.note ?? null,
944
+ event.createdAt,
945
+ );
946
+ } else if (event.type === 'post.acknowledgment.withdrawn') {
947
+ this.db()
948
+ .prepare(
949
+ 'DELETE FROM post_acknowledgments WHERE post_id = ? AND actor_kind = ? AND actor_id = ?',
950
+ )
951
+ .run(event.postId, event.actor.kind, event.actor.id);
838
952
  } else if (event.type === 'note.revised') {
839
953
  this.db()
840
954
  .prepare(
@@ -1418,7 +1532,7 @@ export class SynomemStorage implements SynomemRepository {
1418
1532
  this.db()
1419
1533
  .prepare(
1420
1534
  `SELECT COUNT(*) AS count FROM events WHERE type IN
1421
- ('kudos.given', 'memo.sent', 'note.created', 'task.created')`,
1535
+ ('kudos.given', 'memo.sent', 'note.created', 'post.created', 'task.created')`,
1422
1536
  )
1423
1537
  .get() as { count: number }
1424
1538
  ).count,
@@ -1444,12 +1558,23 @@ export class SynomemStorage implements SynomemRepository {
1444
1558
  return { schemaVersion, appliedVersions };
1445
1559
  }
1446
1560
 
1561
+ /**
1562
+ * Aliases that also name an agent directly.
1563
+ *
1564
+ * Matches against the HANDLE as well as the canonical ID. With opaque IDs an
1565
+ * alias can no longer accidentally equal one, but it can easily equal another
1566
+ * agent's handle — which is the collision that actually makes a lookup
1567
+ * ambiguous now.
1568
+ */
1447
1569
  aliasIdentityConflicts(): Array<{ alias: string; agentId: string }> {
1448
1570
  return this.db()
1449
1571
  .prepare(
1450
1572
  `SELECT x.alias, x.agent_id AS agentId
1451
- FROM aliases x JOIN agents a ON a.id = x.alias
1452
- ORDER BY x.alias`,
1573
+ FROM aliases x
1574
+ JOIN agents a ON lower(a.id) = x.normalized_alias
1575
+ OR lower(a.handle) = x.normalized_alias
1576
+ WHERE a.id != x.agent_id
1577
+ ORDER BY x.alias`,
1453
1578
  )
1454
1579
  .all() as unknown as Array<{ alias: string; agentId: string }>;
1455
1580
  }
@@ -1571,6 +1696,11 @@ export class SynomemStorage implements SynomemRepository {
1571
1696
  'note.created',
1572
1697
  'note.revised',
1573
1698
  'note.archived',
1699
+ 'post.created',
1700
+ 'post.edited',
1701
+ 'post.archived',
1702
+ 'post.acknowledged',
1703
+ 'post.acknowledgment.withdrawn',
1574
1704
  'task.created',
1575
1705
  'task.updated',
1576
1706
  'task.completed',
@@ -1636,10 +1766,13 @@ export class SynomemStorage implements SynomemRepository {
1636
1766
  const parsed = profileSchema.parse(profile);
1637
1767
  this.db()
1638
1768
  .prepare(
1639
- 'INSERT INTO agents(id, display_name, profile_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?)',
1769
+ `INSERT INTO agents(id, handle, status, display_name, profile_json, created_at, updated_at)
1770
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
1640
1771
  )
1641
1772
  .run(
1642
1773
  parsed.id,
1774
+ parsed.handle,
1775
+ parsed.status,
1643
1776
  parsed.displayName,
1644
1777
  JSON.stringify(parsed),
1645
1778
  parsed.createdAt,
@@ -1651,8 +1784,18 @@ export class SynomemStorage implements SynomemRepository {
1651
1784
  updateAgent(profile: AgentProfile, updatedAt: string): void {
1652
1785
  const parsed = profileSchema.parse(profile);
1653
1786
  this.db()
1654
- .prepare('UPDATE agents SET display_name = ?, profile_json = ?, updated_at = ? WHERE id = ?')
1655
- .run(parsed.displayName, JSON.stringify(parsed), updatedAt, parsed.id);
1787
+ .prepare(
1788
+ `UPDATE agents SET handle = ?, status = ?, display_name = ?, profile_json = ?,
1789
+ updated_at = ? WHERE id = ?`,
1790
+ )
1791
+ .run(
1792
+ parsed.handle,
1793
+ parsed.status,
1794
+ parsed.displayName,
1795
+ JSON.stringify(parsed),
1796
+ updatedAt,
1797
+ parsed.id,
1798
+ );
1656
1799
  this.db().prepare('DELETE FROM aliases WHERE agent_id = ?').run(parsed.id);
1657
1800
  this.insertAliases(parsed.id, parsed.aliases ?? []);
1658
1801
  }
@@ -1679,14 +1822,69 @@ export class SynomemStorage implements SynomemRepository {
1679
1822
  `SELECT DISTINCT a.profile_json
1680
1823
  FROM agents a
1681
1824
  LEFT JOIN aliases x ON x.agent_id = a.id
1682
- WHERE lower(a.id) = ? OR x.normalized_alias = ?
1825
+ WHERE lower(a.id) = ? OR lower(a.handle) = ? OR x.normalized_alias = ?
1683
1826
  ORDER BY a.id ASC`,
1684
1827
  )
1685
- .all(normalized, normalized) as unknown as ProfileRow[];
1828
+ .all(normalized, normalized, normalized) as unknown as ProfileRow[];
1686
1829
  const candidates = rows.map((row) => profileSchema.parse(JSON.parse(row.profile_json)));
1687
1830
  return candidates.length === 1 ? { match: candidates[0]!, candidates } : { candidates };
1688
1831
  }
1689
1832
 
1833
+ /* -------------------------------------------------------- post acknowledgment */
1834
+
1835
+ listPostAcknowledgments(postId: string): PostAcknowledgment[] {
1836
+ const rows = this.db()
1837
+ .prepare(
1838
+ `SELECT actor_kind, actor_id, actor_display_name, note, acknowledged_at
1839
+ FROM post_acknowledgments WHERE post_id = ? ORDER BY acknowledged_at ASC`,
1840
+ )
1841
+ .all(postId) as unknown as Array<{
1842
+ actor_kind: string;
1843
+ actor_id: string;
1844
+ actor_display_name: string | null;
1845
+ note: string | null;
1846
+ acknowledged_at: string;
1847
+ }>;
1848
+ return rows.map((row) => ({
1849
+ actor: {
1850
+ kind: row.actor_kind as ActorIdentity['kind'],
1851
+ id: row.actor_id,
1852
+ ...(row.actor_display_name ? { displayName: row.actor_display_name } : {}),
1853
+ },
1854
+ acknowledgedAt: row.acknowledged_at,
1855
+ ...(row.note ? { note: row.note } : {}),
1856
+ }));
1857
+ }
1858
+
1859
+ /**
1860
+ * Who has acknowledged a post and who has not.
1861
+ *
1862
+ * The denominator is agents that existed when the post was written. An agent
1863
+ * created afterwards is counted separately rather than listed as
1864
+ * outstanding — it was not there, and a roster that says otherwise accuses a
1865
+ * newcomer of ignoring something written before it arrived.
1866
+ */
1867
+ postRoster(postId: string): PostRoster | undefined {
1868
+ const post = this.db()
1869
+ .prepare("SELECT created_at FROM items_current WHERE item_id = ? AND kind = 'post'")
1870
+ .get(postId) as { created_at: string } | undefined;
1871
+ if (!post) return undefined;
1872
+
1873
+ const acknowledged = this.listPostAcknowledgments(postId);
1874
+ const acknowledgedIds = new Set(acknowledged.map((entry) => entry.actor.id));
1875
+
1876
+ const eligible = this.db()
1877
+ .prepare('SELECT id, display_name, created_at FROM agents ORDER BY id ASC')
1878
+ .all() as unknown as Array<{ id: string; display_name: string; created_at: string }>;
1879
+
1880
+ const outstanding = eligible
1881
+ .filter((agent) => agent.created_at <= post.created_at && !acknowledgedIds.has(agent.id))
1882
+ .map((agent) => ({ id: agent.id, displayName: agent.display_name }));
1883
+ const joinedSince = eligible.filter((agent) => agent.created_at > post.created_at).length;
1884
+
1885
+ return { postId, acknowledged, outstanding, joinedSince };
1886
+ }
1887
+
1690
1888
  /* ------------------------------------------------------- runtime bindings */
1691
1889
 
1692
1890
  listRuntimeBindings(agentId: string): AgentRuntimeBinding[] {
@@ -1818,11 +2016,16 @@ export class SynomemStorage implements SynomemRepository {
1818
2016
  });
1819
2017
  }
1820
2018
 
1821
- replaceAgentProjectionManifest(agentId: string, paths: string[], generatedAt: string): void {
2019
+ /**
2020
+ * @param directory the agent's projection directory name, which is its
2021
+ * handle rather than its canonical ID: these rows are keyed by the path on
2022
+ * disk, and projections are named for people to read.
2023
+ */
2024
+ replaceAgentProjectionManifest(directory: string, paths: string[], generatedAt: string): void {
1822
2025
  this.transactionSync(() => {
1823
2026
  this.db()
1824
2027
  .prepare('DELETE FROM projection_manifest WHERE path LIKE ? OR path LIKE ?')
1825
- .run(`${agentId}/%`, `${agentId}\\%`);
2028
+ .run(`${directory}/%`, `${directory}\\%`);
1826
2029
  const insert = this.db().prepare(
1827
2030
  'INSERT INTO projection_manifest(path, generated_at) VALUES (?, ?)',
1828
2031
  );