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/dist/client.js CHANGED
@@ -4,10 +4,14 @@ import { ulid } from 'ulid';
4
4
  import { resolveHome } from './config.js';
5
5
  import { asSynomemError, SynomemError } from './errors.js';
6
6
  import { assertNoSymlinkEscape } from './fs-utils.js';
7
- import { escapeMarkdown, memoRecordsFromEvents, noteRecordsFromEvents, ProjectionManager, recordsFromEvents, taskRecordsFromEvents, todoRecordsFromEvents, } from './projections.js';
8
- import { actorSchema, agentLookupSchema, bindRuntimeSchema, createAgentSchema, createNoteSchema, createTaskSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTaskSchema, updateTodoSchema, updateAgentSchema, } from './schemas.js';
7
+ import { escapeMarkdown, memoRecordsFromEvents, noteRecordsFromEvents, postRecordsFromEvents, ProjectionManager, recordsFromEvents, taskRecordsFromEvents, todoRecordsFromEvents, } from './projections.js';
8
+ import { actorSchema, agentLookupSchema, bindRuntimeSchema, createAgentSchema, createPostSchema, updatePostSchema, createNoteSchema, createTaskSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTaskSchema, updateTodoSchema, updateAgentSchema, } from './schemas.js';
9
9
  import { SynomemStorage } from './storage.js';
10
10
  export class SynomemCore {
11
+ /**
12
+ * Mutable because an agent actor is resolved to its canonical identity on
13
+ * init: callers name a handle, events record the opaque ID.
14
+ */
11
15
  actor;
12
16
  repository;
13
17
  projectionWriter;
@@ -22,6 +26,10 @@ export class SynomemCore {
22
26
  get: (idOrAlias) => this.getAgent(idOrAlias),
23
27
  list: () => this.listAgents(),
24
28
  resolve: (query) => this.resolveAgent(query),
29
+ archive: (idOrAlias) => this.setAgentStatus(idOrAlias, 'archived'),
30
+ restore: (idOrAlias) => this.setAgentStatus(idOrAlias, 'active'),
31
+ addAliases: (idOrAlias, aliases) => this.addAgentAliases(idOrAlias, aliases),
32
+ removeAliases: (idOrAlias, aliases) => this.removeAgentAliases(idOrAlias, aliases),
25
33
  directory: () => this.agentDirectory(),
26
34
  bindings: (idOrAlias) => this.listRuntimeBindings(idOrAlias),
27
35
  bindRuntime: (input) => this.bindRuntime(input),
@@ -42,6 +50,22 @@ export class SynomemCore {
42
50
  read: (input) => this.readMemo(input),
43
51
  archive: (input) => this.archiveMemo(input),
44
52
  };
53
+ posts = {
54
+ create: (input) => this.createPost(input),
55
+ list: (input = {}) => this.listItems({ ...input, kinds: ['post'] }),
56
+ get: (id) => this.getPost(id),
57
+ update: (input) => this.updatePost(input),
58
+ archive: (input) => this.archivePost(input),
59
+ /*
60
+ * Acknowledging is an explicit call and always speaks for the caller alone.
61
+ * There is no bulk form and no acknowledge-on-behalf-of: an acknowledgement
62
+ * is one actor saying "I have seen this", and reading a post must never
63
+ * append one, or the roster stops meaning anything.
64
+ */
65
+ acknowledge: (input) => this.acknowledgePost(input),
66
+ withdrawAcknowledgment: (input) => this.withdrawPostAcknowledgment(input),
67
+ roster: (postId) => this.postRoster(postId),
68
+ };
45
69
  notes = {
46
70
  create: (input) => this.createNote(input),
47
71
  list: (input = {}) => this.listItems({ ...input, kinds: ['note'] }),
@@ -128,6 +152,29 @@ export class SynomemCore {
128
152
  return;
129
153
  await this.repository.init();
130
154
  this.initialized = true;
155
+ /*
156
+ * An agent actor is resolved to its canonical identity here.
157
+ *
158
+ * Callers name a handle because that is what people and harnesses know,
159
+ * but every event must record the opaque ID — otherwise renaming a handle
160
+ * would orphan the history written under the old one. The display name
161
+ * comes from the profile for the same reason a harness cannot assert it on
162
+ * the command line: the stored record is the authority, not the argument.
163
+ *
164
+ * An unresolvable name is left as given rather than rejected, so a system
165
+ * actor can still create the agent that does not exist yet. Writing as an
166
+ * unknown agent is refused later by the checks that already exist.
167
+ */
168
+ if (this.actor.kind === 'agent') {
169
+ const resolved = await this.repository.resolveAgent(this.actor.id);
170
+ if (resolved.match) {
171
+ this.actor = {
172
+ kind: 'agent',
173
+ id: resolved.match.id,
174
+ ...(resolved.match.displayName ? { displayName: resolved.match.displayName } : {}),
175
+ };
176
+ }
177
+ }
131
178
  }
132
179
  async close() {
133
180
  await this.repository.close();
@@ -165,20 +212,29 @@ export class SynomemCore {
165
212
  this.checkAbort();
166
213
  await this.repository.assertEventCompatibility();
167
214
  const parsed = this.validate(() => createAgentSchema.parse(input));
168
- if (await this.repository.getAgent(parsed.id)) {
169
- throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.id}`);
215
+ if (await this.repository.getAgent(parsed.handle)) {
216
+ throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.handle}`);
170
217
  }
171
218
  const aliases = [...new Set(parsed.aliases ?? [])].sort();
172
- if (aliases.includes(parsed.id)) {
173
- throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own ID as an alias.');
219
+ if (aliases.includes(parsed.handle)) {
220
+ throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
174
221
  }
175
222
  for (const alias of aliases) {
176
223
  if (await this.repository.getAgent(alias)) {
177
224
  throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to an agent: ${alias}`);
178
225
  }
179
226
  }
227
+ /*
228
+ * The canonical ID is generated here and never supplied by the caller.
229
+ * Every event references it permanently, so it has to be free of meaning:
230
+ * a caller that could choose it could choose one that collides with an
231
+ * archived agent's history, and a meaningful ID becomes a handle nobody can
232
+ * rename.
233
+ */
180
234
  const profile = {
181
- id: parsed.id,
235
+ id: this.nextId(),
236
+ handle: parsed.handle,
237
+ status: 'active',
182
238
  displayName: parsed.displayName,
183
239
  ...(aliases.length ? { aliases } : {}),
184
240
  ...(parsed.description !== undefined ? { description: parsed.description } : {}),
@@ -206,8 +262,17 @@ export class SynomemCore {
206
262
  if (!existing)
207
263
  throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
208
264
  const aliases = parsed.aliases ? [...new Set(parsed.aliases)].sort() : existing.aliases;
209
- if (aliases?.includes(existing.id)) {
210
- throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own ID as an alias.');
265
+ const handle = parsed.handle ?? existing.handle;
266
+ if (aliases?.includes(handle)) {
267
+ throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
268
+ }
269
+ // Renaming the handle is allowed and is why the canonical ID exists, but a
270
+ // handle another agent already answers to is still refused.
271
+ if (parsed.handle && parsed.handle !== existing.handle) {
272
+ const owner = await this.repository.getAgent(parsed.handle);
273
+ if (owner && owner.id !== existing.id) {
274
+ throw new SynomemError('ALIAS_CONFLICT', `Handle already belongs to ${owner.id}: ${parsed.handle}`);
275
+ }
211
276
  }
212
277
  for (const alias of aliases ?? []) {
213
278
  const owner = await this.repository.getAgent(alias);
@@ -234,6 +299,48 @@ export class SynomemCore {
234
299
  await this.projectionWriter.syncAgent(updated.id);
235
300
  return updated;
236
301
  }
302
+ /**
303
+ * Archiving stops an agent acting without erasing it.
304
+ *
305
+ * Events reference the actor permanently, so deleting an agent would leave
306
+ * history pointing at nothing. Archived agents keep their records and their
307
+ * handle, and can be restored.
308
+ */
309
+ async setAgentStatus(idOrAlias, status) {
310
+ this.checkAbort();
311
+ await this.repository.assertEventCompatibility();
312
+ this.validate(() => agentLookupSchema.parse(idOrAlias));
313
+ const existing = await this.repository.getAgent(idOrAlias);
314
+ if (!existing)
315
+ throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
316
+ if (existing.status === status)
317
+ return existing;
318
+ const updated = { ...existing, status };
319
+ await this.repository.transaction(async () => {
320
+ const event = {
321
+ ...this.eventBase(existing.id, await this.repository.nextAggregateVersion(existing.id)),
322
+ type: 'agent.updated',
323
+ agentId: existing.id,
324
+ changes: { status },
325
+ };
326
+ await this.repository.updateAgent(updated, event.createdAt);
327
+ await this.repository.insertEvent(event);
328
+ });
329
+ await this.projectionWriter.syncAgent(updated.id);
330
+ return updated;
331
+ }
332
+ /** Adds aliases without disturbing the ones already there. */
333
+ async addAgentAliases(idOrAlias, add) {
334
+ const existing = await this.getAgent(idOrAlias);
335
+ const merged = [...new Set([...(existing.aliases ?? []), ...add])].sort();
336
+ return await this.updateAgent(existing.id, { aliases: merged });
337
+ }
338
+ async removeAgentAliases(idOrAlias, remove) {
339
+ const existing = await this.getAgent(idOrAlias);
340
+ const drop = new Set(remove.map((alias) => alias.trim().toLowerCase()));
341
+ const kept = (existing.aliases ?? []).filter((alias) => !drop.has(alias));
342
+ return await this.updateAgent(existing.id, { aliases: kept });
343
+ }
237
344
  async getAgent(idOrAlias) {
238
345
  this.checkAbort();
239
346
  this.validate(() => agentLookupSchema.parse(idOrAlias));
@@ -569,6 +676,146 @@ export class SynomemCore {
569
676
  await this.projectionWriter.syncAgent(record.event.recipientAgentId);
570
677
  return await this.getMemoRecord(input.memoId);
571
678
  }
679
+ async getPostRecord(id) {
680
+ await this.requireVisibleItem(id, 'post');
681
+ const record = postRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
682
+ if (!record)
683
+ throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${id}`);
684
+ return record;
685
+ }
686
+ async getPost(id) {
687
+ this.checkAbort();
688
+ return await this.getPostRecord(id);
689
+ }
690
+ async createPost(input) {
691
+ this.checkAbort();
692
+ await this.repository.assertEventCompatibility();
693
+ const parsed = this.validate(() => createPostSchema.parse(input));
694
+ // A reply inherits its parent's workspace by construction, and cannot name
695
+ // a different target — there is no target to name.
696
+ if (parsed.replyTo)
697
+ await this.requireVisibleItem(parsed.replyTo, 'post');
698
+ const outcome = await this.repository.transaction(async () => {
699
+ const prior = await this.priorMutation(parsed.idempotencyKey, 'post.created');
700
+ if (prior?.type === 'post.created')
701
+ return { id: prior.id, created: false };
702
+ const id = this.nextId();
703
+ const event = {
704
+ ...this.eventBase(id, 1, id),
705
+ type: 'post.created',
706
+ title: parsed.title,
707
+ body: parsed.body,
708
+ tags: [...new Set(parsed.tags ?? [])].sort(),
709
+ ...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
710
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
711
+ ...(parsed.source ? { source: parsed.source } : {}),
712
+ ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
713
+ };
714
+ await this.repository.insertEvent(event);
715
+ return { id, created: true };
716
+ });
717
+ return {
718
+ record: await this.getPostRecord(outcome.id),
719
+ created: outcome.created,
720
+ deduplicated: !outcome.created,
721
+ };
722
+ }
723
+ /** Only the author edits a post. Everyone else responds to it. */
724
+ assertPostAuthor(record) {
725
+ if (this.administrative)
726
+ return;
727
+ if (record.event.actor.id !== this.actor.id || record.event.actor.kind !== this.actor.kind) {
728
+ throw new SynomemError('MUTATION_FORBIDDEN', 'Only the author can change a post.');
729
+ }
730
+ }
731
+ async updatePost(input) {
732
+ this.checkAbort();
733
+ const parsed = this.validate(() => updatePostSchema.parse(input));
734
+ const record = await this.getPostRecord(parsed.postId);
735
+ this.assertPostAuthor(record);
736
+ if (record.status === 'archived') {
737
+ throw new SynomemError('MUTATION_FORBIDDEN', 'An archived post cannot be edited.');
738
+ }
739
+ if (record.version !== parsed.expectedVersion) {
740
+ throw new SynomemError('REVISION_CONFLICT', `Post ${parsed.postId} is at version ${record.version}.`);
741
+ }
742
+ await this.repository.transaction(async () => {
743
+ const event = {
744
+ ...this.eventBase(parsed.postId, await this.repository.nextAggregateVersion(parsed.postId)),
745
+ type: 'post.edited',
746
+ postId: parsed.postId,
747
+ title: parsed.title ?? record.title,
748
+ body: parsed.body ?? record.body,
749
+ tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
750
+ ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
751
+ };
752
+ await this.repository.insertEvent(event);
753
+ });
754
+ return await this.getPostRecord(parsed.postId);
755
+ }
756
+ async archivePost(input) {
757
+ this.checkAbort();
758
+ const record = await this.getPostRecord(input.postId);
759
+ this.assertPostAuthor(record);
760
+ if (record.status !== 'archived') {
761
+ await this.repository.transaction(async () => {
762
+ const event = {
763
+ ...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
764
+ type: 'post.archived',
765
+ postId: input.postId,
766
+ ...(input.reason ? { reason: input.reason } : {}),
767
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
768
+ };
769
+ await this.repository.insertEvent(event);
770
+ });
771
+ }
772
+ return await this.getPostRecord(input.postId);
773
+ }
774
+ async acknowledgePost(input) {
775
+ this.checkAbort();
776
+ const record = await this.getPostRecord(input.postId);
777
+ // Acknowledging twice is the same statement, so the second is a no-op
778
+ // rather than a second row or an error.
779
+ const already = record.acknowledgments.some((entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind);
780
+ if (!already) {
781
+ await this.repository.transaction(async () => {
782
+ const event = {
783
+ ...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
784
+ type: 'post.acknowledged',
785
+ postId: input.postId,
786
+ ...(input.note ? { note: input.note } : {}),
787
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
788
+ };
789
+ await this.repository.insertEvent(event);
790
+ });
791
+ }
792
+ return await this.getPostRecord(input.postId);
793
+ }
794
+ async withdrawPostAcknowledgment(input) {
795
+ this.checkAbort();
796
+ const record = await this.getPostRecord(input.postId);
797
+ const mine = record.acknowledgments.some((entry) => entry.actor.id === this.actor.id && entry.actor.kind === this.actor.kind);
798
+ if (mine) {
799
+ await this.repository.transaction(async () => {
800
+ const event = {
801
+ ...this.eventBase(input.postId, await this.repository.nextAggregateVersion(input.postId)),
802
+ type: 'post.acknowledgment.withdrawn',
803
+ postId: input.postId,
804
+ ...(input.reason ? { reason: input.reason } : {}),
805
+ };
806
+ await this.repository.insertEvent(event);
807
+ });
808
+ }
809
+ return await this.getPostRecord(input.postId);
810
+ }
811
+ async postRoster(postId) {
812
+ this.checkAbort();
813
+ await this.requireVisibleItem(postId, 'post');
814
+ const roster = await this.repository.postRoster(postId);
815
+ if (!roster)
816
+ throw new SynomemError('ITEM_NOT_FOUND', `Unknown post: ${postId}`);
817
+ return roster;
818
+ }
572
819
  async getNoteRecord(id) {
573
820
  await this.requireVisibleItem(id, 'note');
574
821
  const record = noteRecordsFromEvents(await this.repository.getReadableItemEvents(id))[0];
@@ -986,10 +1233,34 @@ export class SynomemCore {
986
1233
  });
987
1234
  return await this.getTodoRecord(input.todoId);
988
1235
  }
1236
+ /**
1237
+ * Turns an agent name in a filter into the canonical ID the records hold.
1238
+ *
1239
+ * Callers filter by the name they know — a handle or an alias — while every
1240
+ * record stores the opaque ID. Without this the filter silently matches
1241
+ * nothing, which reads as "there is nothing here" rather than "that name
1242
+ * means something else now".
1243
+ *
1244
+ * An unresolvable name is passed through unchanged so it can match a legacy
1245
+ * name-shaped ID rather than being swallowed.
1246
+ */
1247
+ async canonicalAgentId(name) {
1248
+ if (!name)
1249
+ return name;
1250
+ const resolved = await this.repository.resolveAgent(name);
1251
+ return resolved.match?.id ?? name;
1252
+ }
989
1253
  async listItems(input) {
990
1254
  this.checkAbort();
991
1255
  const parsed = this.validate(() => itemListInputSchema.parse(input));
992
- return await this.repository.listItemSummaries(parsed, this.actor);
1256
+ const resolved = {
1257
+ ...parsed,
1258
+ ...(parsed.participantAgentId
1259
+ ? { participantAgentId: await this.canonicalAgentId(parsed.participantAgentId) }
1260
+ : {}),
1261
+ ...(parsed.actorId ? { actorId: await this.canonicalAgentId(parsed.actorId) } : {}),
1262
+ };
1263
+ return await this.repository.listItemSummaries(resolved, this.actor);
993
1264
  }
994
1265
  async listItemChanges(input) {
995
1266
  this.checkAbort();
@@ -1072,8 +1343,8 @@ export class SynomemCore {
1072
1343
  * check that silently lags the migration runner reports a healthy database as
1073
1344
  * broken.
1074
1345
  */
1075
- const CURRENT_SCHEMA_VERSION = 5;
1076
- const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5];
1346
+ const CURRENT_SCHEMA_VERSION = 7;
1347
+ const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7];
1077
1348
  export class SynomemClient extends SynomemCore {
1078
1349
  home;
1079
1350
  storage;