synomem 0.6.11 → 0.7.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 (49) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +92 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/client.d.ts +22 -1
  5. package/dist/client.d.ts.map +1 -1
  6. package/dist/client.js +164 -3
  7. package/dist/client.js.map +1 -1
  8. package/dist/errors.d.ts +1 -1
  9. package/dist/errors.d.ts.map +1 -1
  10. package/dist/errors.js +2 -0
  11. package/dist/errors.js.map +1 -1
  12. package/dist/import.d.ts +83 -0
  13. package/dist/import.d.ts.map +1 -1
  14. package/dist/mcp/index.d.ts.map +1 -1
  15. package/dist/mcp/index.js +110 -2
  16. package/dist/mcp/index.js.map +1 -1
  17. package/dist/ports/repository.d.ts +10 -1
  18. package/dist/ports/repository.d.ts.map +1 -1
  19. package/dist/projections.d.ts.map +1 -1
  20. package/dist/projections.js +6 -0
  21. package/dist/projections.js.map +1 -1
  22. package/dist/remote.d.ts +10 -1
  23. package/dist/remote.d.ts.map +1 -1
  24. package/dist/remote.js +9 -0
  25. package/dist/remote.js.map +1 -1
  26. package/dist/schemas.d.ts +130 -0
  27. package/dist/schemas.d.ts.map +1 -1
  28. package/dist/schemas.js +77 -0
  29. package/dist/schemas.js.map +1 -1
  30. package/dist/service.d.ts +14 -1
  31. package/dist/service.d.ts.map +1 -1
  32. package/dist/storage.d.ts +12 -1
  33. package/dist/storage.d.ts.map +1 -1
  34. package/dist/storage.js +136 -19
  35. package/dist/storage.js.map +1 -1
  36. package/dist/types.d.ts +72 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli.ts +153 -0
  40. package/src/client.ts +184 -2
  41. package/src/errors.ts +2 -0
  42. package/src/mcp/index.ts +140 -1
  43. package/src/ports/repository.ts +8 -0
  44. package/src/projections.ts +6 -0
  45. package/src/remote.ts +45 -0
  46. package/src/schemas.ts +85 -0
  47. package/src/service.ts +18 -0
  48. package/src/storage.ts +167 -13
  49. package/src/types.ts +82 -2
package/dist/client.js CHANGED
@@ -5,7 +5,7 @@ import { resolveHome } from './config.js';
5
5
  import { asSynomemError, SynomemError } from './errors.js';
6
6
  import { assertNoSymlinkEscape } from './fs-utils.js';
7
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';
8
+ import { actorSchema, agentLookupSchema, bindRuntimeSchema, createAgentSchema, createPostSchema, updatePostSchema, createNoteSchema, createTaskSchema, createTodoSchema, changesInputSchema, giveKudosSchema, itemListInputSchema, listInputSchema, reviseNoteSchema, sendMemoSchema, updateTaskSchema, updateTodoSchema, updateAgentSchema, createTopicSchema, updateTopicSchema, topicListInputSchema, topicLookupSchema, } from './schemas.js';
9
9
  import { SynomemStorage } from './storage.js';
10
10
  export class SynomemCore {
11
11
  /**
@@ -35,6 +35,15 @@ export class SynomemCore {
35
35
  bindRuntime: (input) => this.bindRuntime(input),
36
36
  unbindRuntime: (bindingId) => this.unbindRuntime(bindingId),
37
37
  };
38
+ topics = {
39
+ create: (input) => this.createTopic(input),
40
+ update: (idOrAlias, changes) => this.updateTopicRecord(idOrAlias, changes),
41
+ get: (idOrAlias) => this.getTopicRecord(idOrAlias),
42
+ list: (input = {}) => this.listTopicsRecord(input),
43
+ resolve: (query) => this.resolveTopicRecord(query),
44
+ archive: (idOrAlias) => this.setTopicStatus(idOrAlias, 'archived'),
45
+ restore: (idOrAlias) => this.setTopicStatus(idOrAlias, 'active'),
46
+ };
38
47
  kudos = {
39
48
  give: (input) => this.giveKudos(input),
40
49
  list: (input = {}) => this.listKudos(input),
@@ -427,6 +436,138 @@ export class SynomemCore {
427
436
  this.checkAbort();
428
437
  return await this.repository.unbindRuntime(bindingId);
429
438
  }
439
+ /* ---------------------------------------------------------------- topics *
440
+ * A topic is a controlled, reusable subject a record can be filed under —
441
+ * one canonical display name and a set of aliases, so a stable "Synomem"
442
+ * page survives a rename without retagging every record that carries it.
443
+ * Any actor may create one freely, the same as a tag; only the creator or
444
+ * an administrator renames or archives it, matching how an agent's own
445
+ * identity is managed.
446
+ */
447
+ async createTopic(input) {
448
+ this.checkAbort();
449
+ await this.repository.assertEventCompatibility();
450
+ const parsed = this.validate(() => createTopicSchema.parse(input));
451
+ if (await this.repository.getTopic(parsed.displayName)) {
452
+ throw new SynomemError('TOPIC_EXISTS', `Topic or alias already exists: ${parsed.displayName}`);
453
+ }
454
+ const aliases = [...new Set(parsed.aliases ?? [])].sort();
455
+ for (const alias of aliases) {
456
+ if (await this.repository.getTopic(alias)) {
457
+ throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to a topic: ${alias}`);
458
+ }
459
+ }
460
+ const topic = {
461
+ id: this.nextId(),
462
+ displayName: parsed.displayName,
463
+ ...(aliases.length ? { aliases } : {}),
464
+ status: 'active',
465
+ createdAt: this.now(),
466
+ };
467
+ const event = {
468
+ ...this.eventBase(topic.id, 1),
469
+ type: 'topic.created',
470
+ topic,
471
+ };
472
+ await this.repository.transaction(async () => {
473
+ await this.repository.insertTopic(topic);
474
+ await this.repository.insertEvent(event);
475
+ });
476
+ return topic;
477
+ }
478
+ async updateTopicRecord(idOrAlias, changes) {
479
+ this.checkAbort();
480
+ await this.repository.assertEventCompatibility();
481
+ this.validate(() => topicLookupSchema.parse(idOrAlias));
482
+ const parsed = this.validate(() => updateTopicSchema.parse(changes));
483
+ const existing = await this.repository.getTopic(idOrAlias);
484
+ if (!existing)
485
+ throw new SynomemError('TOPIC_NOT_FOUND', `Unknown topic: ${idOrAlias}`);
486
+ if (parsed.displayName && parsed.displayName !== existing.displayName) {
487
+ const owner = await this.repository.getTopic(parsed.displayName);
488
+ if (owner && owner.id !== existing.id) {
489
+ throw new SynomemError('ALIAS_CONFLICT', `Display name already belongs to ${owner.id}: ${parsed.displayName}`);
490
+ }
491
+ }
492
+ const aliases = parsed.aliases ? [...new Set(parsed.aliases)].sort() : existing.aliases;
493
+ for (const alias of aliases ?? []) {
494
+ const owner = await this.repository.getTopic(alias);
495
+ if (owner && owner.id !== existing.id) {
496
+ throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to ${owner.id}: ${alias}`);
497
+ }
498
+ }
499
+ const updated = {
500
+ ...existing,
501
+ ...parsed,
502
+ ...(aliases?.length ? { aliases } : { aliases: undefined }),
503
+ };
504
+ const changesForEvent = { ...parsed, ...(parsed.aliases ? { aliases } : {}) };
505
+ await this.repository.transaction(async () => {
506
+ const event = {
507
+ ...this.eventBase(existing.id, await this.repository.nextAggregateVersion(existing.id)),
508
+ type: 'topic.updated',
509
+ topicId: existing.id,
510
+ changes: changesForEvent,
511
+ };
512
+ await this.repository.updateTopic(updated, event.createdAt);
513
+ await this.repository.insertEvent(event);
514
+ });
515
+ return updated;
516
+ }
517
+ async setTopicStatus(idOrAlias, status) {
518
+ this.checkAbort();
519
+ await this.repository.assertEventCompatibility();
520
+ this.validate(() => topicLookupSchema.parse(idOrAlias));
521
+ const existing = await this.repository.getTopic(idOrAlias);
522
+ if (!existing)
523
+ throw new SynomemError('TOPIC_NOT_FOUND', `Unknown topic: ${idOrAlias}`);
524
+ if (existing.status === status)
525
+ return existing;
526
+ const updated = { ...existing, status };
527
+ await this.repository.transaction(async () => {
528
+ const event = {
529
+ ...this.eventBase(existing.id, await this.repository.nextAggregateVersion(existing.id)),
530
+ type: 'topic.updated',
531
+ topicId: existing.id,
532
+ changes: { status },
533
+ };
534
+ await this.repository.updateTopic(updated, event.createdAt);
535
+ await this.repository.insertEvent(event);
536
+ });
537
+ return updated;
538
+ }
539
+ async getTopicRecord(idOrAlias) {
540
+ this.checkAbort();
541
+ this.validate(() => topicLookupSchema.parse(idOrAlias));
542
+ const topic = await this.repository.getTopic(idOrAlias);
543
+ if (!topic)
544
+ throw new SynomemError('TOPIC_NOT_FOUND', `Unknown topic: ${idOrAlias}`);
545
+ return topic;
546
+ }
547
+ async listTopicsRecord(input) {
548
+ this.checkAbort();
549
+ const parsed = this.validate(() => topicListInputSchema.parse(input));
550
+ return await this.repository.listTopics(parsed.status);
551
+ }
552
+ async resolveTopicRecord(query) {
553
+ this.checkAbort();
554
+ const trimmed = this.validate(() => topicLookupSchema.parse(query));
555
+ const resolved = await this.repository.resolveTopic(trimmed);
556
+ return { query: trimmed, ...resolved };
557
+ }
558
+ /**
559
+ * Every topic in `topicIds` must already exist and be active — a record
560
+ * cannot be filed under a subject that does not exist, or one somebody
561
+ * archived precisely to stop new records collecting under it.
562
+ */
563
+ async assertTopicsExist(topicIds) {
564
+ for (const topicId of topicIds ?? []) {
565
+ const topic = await this.repository.getTopic(topicId);
566
+ if (!topic || topic.status !== 'active') {
567
+ throw new SynomemError('TOPIC_NOT_FOUND', `Unknown or archived topic: ${topicId}`);
568
+ }
569
+ }
570
+ }
430
571
  async giveKudos(input) {
431
572
  this.checkAbort();
432
573
  await this.repository.assertEventCompatibility();
@@ -434,6 +575,7 @@ export class SynomemCore {
434
575
  ...input,
435
576
  visibility: input.visibility ?? this.repository.config.defaultVisibility,
436
577
  }));
578
+ await this.assertTopicsExist(parsed.topicIds);
437
579
  const recipient = await this.repository.getAgent(parsed.recipientAgentId);
438
580
  if (!recipient) {
439
581
  throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipientAgentId}`);
@@ -458,6 +600,7 @@ export class SynomemCore {
458
600
  visibility: parsed.visibility,
459
601
  ...(parsed.evidence ? { evidence: parsed.evidence } : {}),
460
602
  ...(parsed.tags ? { tags: [...new Set(parsed.tags)].sort() } : {}),
603
+ ...(parsed.topicIds ? { topicIds: [...new Set(parsed.topicIds)].sort() } : {}),
461
604
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
462
605
  ...(parsed.source ? { source: parsed.source } : {}),
463
606
  ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
@@ -605,6 +748,7 @@ export class SynomemCore {
605
748
  this.checkAbort();
606
749
  await this.repository.assertEventCompatibility();
607
750
  const parsed = this.validate(() => sendMemoSchema.parse(input));
751
+ await this.assertTopicsExist(parsed.topicIds);
608
752
  const recipient = await this.repository.getAgent(parsed.recipientAgentId);
609
753
  if (!recipient)
610
754
  throw new SynomemError('AGENT_NOT_FOUND', `Unknown recipient: ${parsed.recipientAgentId}`);
@@ -621,6 +765,7 @@ export class SynomemCore {
621
765
  subject: parsed.subject,
622
766
  body: parsed.body,
623
767
  tags: [...new Set(parsed.tags ?? [])].sort(),
768
+ topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
624
769
  visibility: parsed.visibility ?? this.repository.config.defaultVisibility,
625
770
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
626
771
  ...(parsed.source ? { source: parsed.source } : {}),
@@ -700,6 +845,7 @@ export class SynomemCore {
700
845
  this.checkAbort();
701
846
  await this.repository.assertEventCompatibility();
702
847
  const parsed = this.validate(() => createPostSchema.parse(input));
848
+ await this.assertTopicsExist(parsed.topicIds);
703
849
  // A reply inherits its parent's workspace by construction, and cannot name
704
850
  // a different target — there is no target to name.
705
851
  if (parsed.replyTo)
@@ -715,6 +861,7 @@ export class SynomemCore {
715
861
  title: parsed.title,
716
862
  body: parsed.body,
717
863
  tags: [...new Set(parsed.tags ?? [])].sort(),
864
+ topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
718
865
  ...(parsed.replyTo ? { replyTo: parsed.replyTo } : {}),
719
866
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
720
867
  ...(parsed.source ? { source: parsed.source } : {}),
@@ -740,6 +887,7 @@ export class SynomemCore {
740
887
  async updatePost(input) {
741
888
  this.checkAbort();
742
889
  const parsed = this.validate(() => updatePostSchema.parse(input));
890
+ await this.assertTopicsExist(parsed.topicIds);
743
891
  const record = await this.getPostRecord(parsed.postId);
744
892
  this.assertPostAuthor(record);
745
893
  if (record.status === 'archived') {
@@ -756,6 +904,7 @@ export class SynomemCore {
756
904
  title: parsed.title ?? record.title,
757
905
  body: parsed.body ?? record.body,
758
906
  tags: [...new Set(parsed.tags ?? record.tags ?? [])].sort(),
907
+ topicIds: [...new Set(parsed.topicIds ?? record.topicIds ?? [])].sort(),
759
908
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
760
909
  };
761
910
  await this.repository.insertEvent(event);
@@ -840,6 +989,7 @@ export class SynomemCore {
840
989
  this.checkAbort();
841
990
  await this.repository.assertEventCompatibility();
842
991
  const parsed = this.validate(() => createNoteSchema.parse(input));
992
+ await this.assertTopicsExist(parsed.topicIds);
843
993
  const ownerId = parsed.ownerAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
844
994
  if (!ownerId)
845
995
  throw new SynomemError('INVALID_INPUT', 'A human or system actor must specify ownerAgentId.');
@@ -862,6 +1012,7 @@ export class SynomemCore {
862
1012
  title: parsed.title,
863
1013
  body: parsed.body,
864
1014
  tags: [...new Set(parsed.tags ?? [])].sort(),
1015
+ topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
865
1016
  visibility: 'private',
866
1017
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
867
1018
  ...(parsed.source ? { source: parsed.source } : {}),
@@ -886,6 +1037,7 @@ export class SynomemCore {
886
1037
  }
887
1038
  async reviseNote(input) {
888
1039
  const parsed = this.validate(() => reviseNoteSchema.parse(input));
1040
+ await this.assertTopicsExist(parsed.topicIds);
889
1041
  const record = await this.getNoteRecord(parsed.noteId);
890
1042
  this.assertNoteOwner(record);
891
1043
  if (record.status === 'archived')
@@ -906,6 +1058,7 @@ export class SynomemCore {
906
1058
  title: parsed.title ?? record.current.title,
907
1059
  body: parsed.body ?? record.current.body,
908
1060
  tags: parsed.tags ?? record.current.tags,
1061
+ topicIds: parsed.topicIds ?? record.current.topicIds,
909
1062
  visibility: 'private',
910
1063
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
911
1064
  ...(parsed.source ? { source: parsed.source } : {}),
@@ -951,6 +1104,7 @@ export class SynomemCore {
951
1104
  this.checkAbort();
952
1105
  await this.repository.assertEventCompatibility();
953
1106
  const parsed = this.validate(() => createTaskSchema.parse(input));
1107
+ await this.assertTopicsExist(parsed.topicIds);
954
1108
  const assigneeId = parsed.assigneeAgentId ?? (this.actor.kind === 'agent' ? this.actor.id : undefined);
955
1109
  if (!assigneeId)
956
1110
  throw new SynomemError('INVALID_INPUT', 'A human or system actor must specify assigneeAgentId.');
@@ -977,6 +1131,7 @@ export class SynomemCore {
977
1131
  priority: parsed.priority ?? 3,
978
1132
  ...(parsed.due ? { due: parsed.due } : {}),
979
1133
  tags: [...new Set(parsed.tags ?? [])].sort(),
1134
+ topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
980
1135
  visibility: parsed.visibility ?? this.repository.config.defaultVisibility,
981
1136
  requiresAcceptance,
982
1137
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
@@ -1008,6 +1163,7 @@ export class SynomemCore {
1008
1163
  }
1009
1164
  async updateTask(input) {
1010
1165
  const parsed = this.validate(() => updateTaskSchema.parse(input));
1166
+ await this.assertTopicsExist(parsed.topicIds);
1011
1167
  const record = await this.getTaskRecord(parsed.taskId);
1012
1168
  this.assertTaskParticipant(record);
1013
1169
  if (record.status !== 'open')
@@ -1035,6 +1191,7 @@ export class SynomemCore {
1035
1191
  priority: parsed.priority ?? record.current.priority,
1036
1192
  ...(due ? { due } : {}),
1037
1193
  tags: parsed.tags ?? record.current.tags,
1194
+ topicIds: parsed.topicIds ?? record.current.topicIds,
1038
1195
  visibility: parsed.visibility ?? record.current.visibility,
1039
1196
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1040
1197
  ...(parsed.source ? { source: parsed.source } : {}),
@@ -1131,6 +1288,7 @@ export class SynomemCore {
1131
1288
  this.checkAbort();
1132
1289
  await this.repository.assertEventCompatibility();
1133
1290
  const parsed = this.validate(() => createTodoSchema.parse(input));
1291
+ await this.assertTopicsExist(parsed.topicIds);
1134
1292
  const outcome = await this.repository.transaction(async () => {
1135
1293
  const prior = await this.priorMutation(parsed.idempotencyKey, 'todo.created');
1136
1294
  if (prior?.type === 'todo.created')
@@ -1144,6 +1302,7 @@ export class SynomemCore {
1144
1302
  priority: parsed.priority ?? 3,
1145
1303
  ...(parsed.due ? { due: parsed.due } : {}),
1146
1304
  tags: [...new Set(parsed.tags ?? [])].sort(),
1305
+ topicIds: [...new Set(parsed.topicIds ?? [])].sort(),
1147
1306
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1148
1307
  ...(parsed.source ? { source: parsed.source } : {}),
1149
1308
  ...(parsed.metadata ? { metadata: parsed.metadata } : {}),
@@ -1188,6 +1347,7 @@ export class SynomemCore {
1188
1347
  async updateTodo(input) {
1189
1348
  this.checkAbort();
1190
1349
  const parsed = this.validate(() => updateTodoSchema.parse(input));
1350
+ await this.assertTopicsExist(parsed.topicIds);
1191
1351
  const record = await this.getTodoRecord(parsed.todoId);
1192
1352
  if (record.current.version !== parsed.expectedVersion) {
1193
1353
  throw new SynomemError('REVISION_CONFLICT', `Todo ${parsed.todoId} is at version ${record.current.version}.`);
@@ -1208,6 +1368,7 @@ export class SynomemCore {
1208
1368
  priority: parsed.priority ?? record.current.priority,
1209
1369
  ...(due ? { due } : {}),
1210
1370
  tags: [...new Set(parsed.tags ?? record.current.tags)].sort(),
1371
+ topicIds: [...new Set(parsed.topicIds ?? record.current.topicIds)].sort(),
1211
1372
  ...(parsed.idempotencyKey ? { idempotencyKey: parsed.idempotencyKey } : {}),
1212
1373
  };
1213
1374
  await this.repository.insertEvent(event);
@@ -1362,8 +1523,8 @@ export class SynomemCore {
1362
1523
  * check that silently lags the migration runner reports a healthy database as
1363
1524
  * broken.
1364
1525
  */
1365
- const CURRENT_SCHEMA_VERSION = 7;
1366
- const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7];
1526
+ const CURRENT_SCHEMA_VERSION = 8;
1527
+ const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7, 8];
1367
1528
  export class SynomemClient extends SynomemCore {
1368
1529
  home;
1369
1530
  storage;