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/cli.ts CHANGED
@@ -1,10 +1,27 @@
1
1
  #!/usr/bin/env node
2
- import { existsSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { existsSync, readFileSync, realpathSync, rmSync } from 'node:fs';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { join, resolve } from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
  import { Command, CommanderError, Option } from 'commander';
7
7
  import { configuredServiceFactory, readSynomemConfig, writeSynomemBackend } from './backend.js';
8
+ import { cloudApiUrl } from './cloud.js';
9
+ import { resolveHome } from './config.js';
10
+ import {
11
+ assertInteractive,
12
+ confirmPlan,
13
+ credentialFingerprint,
14
+ credentialStoreChoices,
15
+ environmentInstructions,
16
+ readAccessToken,
17
+ runConfigWizard,
18
+ writeCredentialFile,
19
+ type AuthChoice,
20
+ type BackendChoice,
21
+ type ConfigPlan,
22
+ type CredentialStoreChoice,
23
+ } from './configure.js';
24
+ import { defaultPromptIo, type PromptIo } from './prompt.js';
8
25
  import { credentialReference, OsCredentialStore, type CredentialStore } from './credentials.js';
9
26
  import { asSynomemError, SynomemError, type SynomemErrorCode } from './errors.js';
10
27
  import { atomicWriteFile } from './fs-utils.js';
@@ -45,6 +62,8 @@ export interface CliIo {
45
62
  }
46
63
 
47
64
  export interface CliDependencies {
65
+ /** Injected so the wizard can be driven by a test without a terminal. */
66
+ promptIo?: PromptIo;
48
67
  credentialStore?: CredentialStore;
49
68
  oauthLogin?: (options: OAuthLoginOptions) => Promise<void>;
50
69
  env?: NodeJS.ProcessEnv;
@@ -281,6 +300,7 @@ export function createCli(
281
300
  const env = dependencies.env ?? process.env;
282
301
  const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
283
302
  const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
303
+ const promptIo = dependencies.promptIo ?? defaultPromptIo();
284
304
  const verifyRemoteCredential =
285
305
  dependencies.verifyRemoteCredential ??
286
306
  (async (options) => {
@@ -314,7 +334,7 @@ export function createCli(
314
334
  'Local-first communication, memory, recognition, and task infrastructure for agents',
315
335
  )
316
336
  .version(packageVersion())
317
- .option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.agents)')
337
+ .option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.synomem)')
318
338
  .option('--json', 'emit stable machine-readable JSON', false)
319
339
  .showSuggestionAfterError()
320
340
  .configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
@@ -416,6 +436,263 @@ export function createCli(
416
436
  });
417
437
  });
418
438
 
439
+ /*
440
+ * `synomem config` is the canonical entry point. `configure` and `setup` are
441
+ * accepted because people reach for them, and a setup program that rejects
442
+ * the word somebody guessed is needlessly unhelpful.
443
+ */
444
+ const configCommand = program
445
+ .command('config')
446
+ .aliases(['configure', 'setup'])
447
+ .description('Set up Synomem, interactively or deterministically');
448
+
449
+ const applyPlan = async (
450
+ plan: ConfigPlan,
451
+ token: string | undefined,
452
+ global: { home?: string; json: boolean },
453
+ ): Promise<void> => {
454
+ const home = plan.home;
455
+ const config = writeSynomemBackend(
456
+ plan.backend === 'local'
457
+ ? { kind: 'local' }
458
+ : {
459
+ kind: 'remote',
460
+ baseUrl: plan.serviceUrl ?? cloudApiUrl(env),
461
+ workspaceId: plan.workspaceId!,
462
+ },
463
+ home,
464
+ );
465
+
466
+ let credentialLocation: string | undefined;
467
+ if (token) {
468
+ if (plan.credentialStore === 'environment') {
469
+ io.stdout(`${environmentInstructions(token)}\n`);
470
+ credentialLocation = 'environment';
471
+ } else if (plan.credentialStore === 'file') {
472
+ credentialLocation = writeCredentialFile(home, token);
473
+ } else {
474
+ // The platform store is the default, and a failure falls back to the
475
+ // restricted file rather than leaving the credential nowhere.
476
+ try {
477
+ await credentialStore.set(`synomem:${plan.workspaceId}`, {
478
+ kind: 'installation-key',
479
+ accessToken: token,
480
+ });
481
+ credentialLocation = 'platform credential store';
482
+ } catch {
483
+ credentialLocation = writeCredentialFile(home, token);
484
+ }
485
+ }
486
+ }
487
+
488
+ // Diagnostics run before success is claimed: a configuration that cannot
489
+ // open its own database is not a finished setup.
490
+ const diagnostics = await withClient(home, defaultActor(env, 'system', 'cli'), (client) =>
491
+ client.doctor(),
492
+ );
493
+
494
+ output(
495
+ io,
496
+ global.json,
497
+ {
498
+ backend: config.backend,
499
+ home,
500
+ ...(credentialLocation ? { credentialSource: credentialLocation } : {}),
501
+ ...(token ? { credential: credentialFingerprint(token) } : {}),
502
+ healthy: diagnostics.healthy,
503
+ },
504
+ [
505
+ '',
506
+ 'Synomem is ready.',
507
+ '',
508
+ ` Backend: ${config.backend.kind === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
509
+ ` Home: ${home}`,
510
+ ...(config.backend.kind === 'remote'
511
+ ? [` Service: ${config.backend.baseUrl}`, ` Workspace: ${config.backend.workspaceId}`]
512
+ : []),
513
+ ...(credentialLocation ? [` Credential: ${credentialLocation}`] : []),
514
+ ` Database: ${diagnostics.healthy ? 'Healthy' : 'Needs attention — run synomem doctor'}`,
515
+ ].join('\n'),
516
+ );
517
+ };
518
+
519
+ configCommand.action(async (_options, command: Command) => {
520
+ const global = globals(command);
521
+ assertInteractive(promptIo);
522
+ const plan = await runConfigWizard(promptIo, {
523
+ ...(global.home ? { home: global.home } : {}),
524
+ env,
525
+ });
526
+ const token = plan.auth === 'access-key' ? await readAccessToken(promptIo) : undefined;
527
+ if (!(await confirmPlan(promptIo, plan))) {
528
+ output(io, global.json, { applied: false }, 'Nothing was changed.');
529
+ return;
530
+ }
531
+ await applyPlan(plan, token, global);
532
+ });
533
+
534
+ configCommand
535
+ .command('init')
536
+ .description('Configure Synomem without prompting')
537
+ .option('--backend <kind>', 'local or remote')
538
+ .option('--auth <method>', 'browser or access-key')
539
+ .option('--workspace <id>', 'remote workspace ID')
540
+ .option('--credential-store <where>', 'auto, keychain, file, or environment', 'auto')
541
+ // The token is read from stdin, never taken as an argument: an argument is
542
+ // kept by the shell history and visible in the process list.
543
+ .option('--access-token-stdin', 'read the installation access key from stdin', false)
544
+ .option('--yes', 'apply without confirming', false)
545
+ .action(
546
+ async (
547
+ options: {
548
+ backend?: string;
549
+ auth?: string;
550
+ workspace?: string;
551
+ credentialStore: string;
552
+ accessTokenStdin: boolean;
553
+ yes: boolean;
554
+ },
555
+ command: Command,
556
+ ) => {
557
+ const global = globals(command);
558
+ if (options.backend !== 'local' && options.backend !== 'remote') {
559
+ throw new SynomemError('INVALID_INPUT', 'Pass --backend local or --backend remote.');
560
+ }
561
+ const backend: BackendChoice = options.backend;
562
+ if (backend === 'remote' && !options.workspace) {
563
+ throw new SynomemError('INVALID_INPUT', 'Remote setup requires --workspace.');
564
+ }
565
+ const token = options.accessTokenStdin ? await readAccessToken(promptIo) : undefined;
566
+ if (backend === 'remote' && options.auth === 'access-key' && !token) {
567
+ throw new SynomemError(
568
+ 'INVALID_INPUT',
569
+ 'Access-key setup requires --access-token-stdin so the key is not passed as an argument.',
570
+ );
571
+ }
572
+ const plan: ConfigPlan = {
573
+ backend,
574
+ home: resolveHome(global.home),
575
+ ...(backend === 'remote'
576
+ ? {
577
+ serviceUrl: cloudApiUrl(env),
578
+ auth: (options.auth as AuthChoice | undefined) ?? 'access-key',
579
+ workspaceId: options.workspace,
580
+ credentialStore: options.credentialStore as CredentialStoreChoice,
581
+ }
582
+ : {}),
583
+ };
584
+ if (!options.yes) {
585
+ throw new SynomemError('INVALID_INPUT', 'Re-run with --yes to apply this configuration.');
586
+ }
587
+ await applyPlan(plan, token, global);
588
+ },
589
+ );
590
+
591
+ configCommand
592
+ .command('show')
593
+ .description('Show the current configuration without revealing secrets')
594
+ .action(async (_options, command: Command) => {
595
+ const global = globals(command);
596
+ const home = resolveHome(global.home);
597
+ const config = readSynomemConfig(global.home, env);
598
+ const backend = config?.backend ?? { kind: 'local' as const };
599
+ const credentialSource = env.SYNOMEM_ACCESS_TOKEN
600
+ ? 'environment (SYNOMEM_ACCESS_TOKEN)'
601
+ : existsSync(join(home, 'credentials', 'installation.json'))
602
+ ? 'restricted file'
603
+ : 'platform credential store or none';
604
+ output(
605
+ io,
606
+ global.json,
607
+ // Never the secret itself, only where it comes from.
608
+ { backend, home, credentialSource, stores: credentialStoreChoices().map((c) => c.value) },
609
+ [
610
+ `Backend: ${backend.kind === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
611
+ `Home: ${home}`,
612
+ ...(backend.kind === 'remote'
613
+ ? [`Service: ${backend.baseUrl}`, `Workspace: ${backend.workspaceId}`]
614
+ : []),
615
+ `Credential: ${credentialSource}`,
616
+ ].join('\n'),
617
+ );
618
+ });
619
+
620
+ program
621
+ .command('reset')
622
+ .description('Remove Synomem configuration, database and credentials')
623
+ // Integrations are opt-in because they live in other tools' directories.
624
+ // Removing somebody's harness configuration as a side effect of resetting
625
+ // Synomem would be a surprise with no undo.
626
+ .option('--integrations', 'also remove installed skills and MCP registrations', false)
627
+ .option('--yes', 'apply the displayed plan', false)
628
+ .action(async (options: { integrations: boolean; yes: boolean }, command: Command) => {
629
+ const global = globals(command);
630
+ const home = resolveHome(global.home);
631
+
632
+ /*
633
+ * Every target is an exact path, listed before anything is touched. No
634
+ * recursive delete is ever derived from a variable that might be empty:
635
+ * a reset that computes `rm -rf $HOME/` from an unset home is the
636
+ * failure this shape exists to make impossible.
637
+ */
638
+ const targets = [
639
+ join(home, 'config.json'),
640
+ join(home, 'synomem.sqlite3'),
641
+ join(home, 'synomem.sqlite3-wal'),
642
+ join(home, 'synomem.sqlite3-shm'),
643
+ join(home, 'credentials', 'installation.json'),
644
+ ].filter((path) => existsSync(path));
645
+
646
+ const skillPlan = options.integrations ? uninstallSkill({ apply: false }) : undefined;
647
+ const skillTargets =
648
+ skillPlan?.locations
649
+ // Installed Synomem-owned copies only; an unowned directory at
650
+ // the same path is not ours to remove.
651
+ .filter((location) => location.state === 'current' || location.state === 'stale')
652
+ .map((location) => location.target) ?? [];
653
+
654
+ if (!options.yes) {
655
+ output(
656
+ io,
657
+ global.json,
658
+ { targets, skillTargets, applied: false },
659
+ [
660
+ 'This will remove:',
661
+ ...(targets.length ? targets.map((path) => ` ${path}`) : [' (nothing found)']),
662
+ ...(skillTargets.length ? ['', 'And these Synomem-owned skills:'] : []),
663
+ ...skillTargets.map((path) => ` ${path}`),
664
+ '',
665
+ ...(options.integrations
666
+ ? []
667
+ : ['Installed skills and MCP registrations are left alone.', '']),
668
+ 'Run with --yes to continue.',
669
+ ].join('\n'),
670
+ );
671
+ return;
672
+ }
673
+
674
+ const removed: string[] = [];
675
+ for (const path of targets) {
676
+ rmSync(path, { force: true });
677
+ removed.push(path);
678
+ }
679
+ // Only ownership-stamped Synomem skills are removed, which uninstall
680
+ // already enforces — an unowned directory at the same path is left.
681
+ const skillResult = options.integrations ? uninstallSkill({ apply: true }) : undefined;
682
+
683
+ output(
684
+ io,
685
+ global.json,
686
+ { removed, skills: skillResult?.locations ?? [] },
687
+ [
688
+ `Removed ${removed.length} file(s).`,
689
+ ...(skillResult
690
+ ? [`Skill locations processed: ${skillResult.locations.length}.`]
691
+ : ['Installed skills and MCP registrations were left alone.']),
692
+ ].join('\n'),
693
+ );
694
+ });
695
+
419
696
  const backendCommand = program
420
697
  .command('backend')
421
698
  .description('Inspect or select the canonical backend');
@@ -436,23 +713,28 @@ export function createCli(
436
713
  .command('use')
437
714
  .description('Select local or remote canonical state')
438
715
  .argument('<kind>', 'local or remote')
439
- .option('--url <url>', 'remote HTTPS origin')
716
+ // --url is for development and private deployments. It stays out of the
717
+ // README, the public docs and the packaged skill: public onboarding must
718
+ // never ask for a service address, because a person has no way to tell a
719
+ // real one from a phished one.
720
+ .option('--url <url>', 'internal: alternate HTTPS origin')
440
721
  .option('--workspace <id>', 'remote workspace ID')
441
722
  .action((kind: string, options: { url?: string; workspace?: string }, command: Command) => {
442
723
  const global = globals(command);
443
724
  if (kind !== 'local' && kind !== 'remote') {
444
725
  throw new SynomemError('INVALID_INPUT', 'Backend kind must be local or remote.');
445
726
  }
446
- if (kind === 'remote' && (!options.url || !options.workspace)) {
447
- throw new SynomemError(
448
- 'INVALID_INPUT',
449
- 'Remote backend selection requires --url and --workspace.',
450
- );
727
+ if (kind === 'remote' && !options.workspace) {
728
+ throw new SynomemError('INVALID_INPUT', 'Remote backend selection requires --workspace.');
451
729
  }
452
730
  const config = writeSynomemBackend(
453
731
  kind === 'local'
454
732
  ? { kind: 'local' }
455
- : { kind: 'remote', baseUrl: options.url!, workspaceId: options.workspace! },
733
+ : {
734
+ kind: 'remote',
735
+ baseUrl: options.url ?? cloudApiUrl(env),
736
+ workspaceId: options.workspace!,
737
+ },
456
738
  global.home,
457
739
  );
458
740
  output(
@@ -599,14 +881,14 @@ export function createCli(
599
881
  .command('agent')
600
882
  .description('Create and inspect stable agent identities');
601
883
  agentCommand
602
- .command('create <id>')
603
- .description('Create a stable agent profile')
884
+ .command('create <handle>')
885
+ .description('Create an agent. The canonical ID is generated, not chosen.')
604
886
  .requiredOption('--name <display-name>', 'display name')
605
- .option('--alias <id>', 'alias (repeatable)', collect, [])
887
+ .option('--alias <name>', 'alias (repeatable)', collect, [])
606
888
  .option('--description <text>')
607
889
  .action(
608
890
  async (
609
- id: string,
891
+ handle: string,
610
892
  options: { name: string; alias: string[]; description?: string },
611
893
  command: Command,
612
894
  ) => {
@@ -616,16 +898,87 @@ export function createCli(
616
898
  defaultActor(env, 'system', 'cli'),
617
899
  (client) =>
618
900
  client.agents.create({
619
- id,
901
+ handle,
620
902
  displayName: options.name,
621
903
  ...(options.alias.length ? { aliases: options.alias } : {}),
622
904
  ...(options.description ? { description: options.description } : {}),
623
905
  }),
624
906
  );
625
- output(io, global.json, profile, `Created ${profile.displayName} (${profile.id})`);
907
+ // Both are printed because both matter: the handle is what people type,
908
+ // the ID is what every event records and what MCP registration uses.
909
+ output(
910
+ io,
911
+ global.json,
912
+ profile,
913
+ `Created ${profile.displayName}\n\nHandle: ${profile.handle}\nAgent ID: ${profile.id}`,
914
+ );
626
915
  },
627
916
  );
628
917
 
918
+ const aliasCommand = agentCommand
919
+ .command('alias')
920
+ .description('Add or remove discovery aliases without replacing the set');
921
+
922
+ aliasCommand
923
+ .command('add <agent> <alias...>')
924
+ .description('Add aliases, keeping the ones already there')
925
+ .action(async (agent: string, aliases: string[], _options, command: Command) => {
926
+ const global = globals(command);
927
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
928
+ client.agents.addAliases(agent, aliases),
929
+ );
930
+ output(io, global.json, profile, `Aliases: ${(profile.aliases ?? []).join(', ') || 'none'}`);
931
+ });
932
+
933
+ aliasCommand
934
+ .command('remove <agent> <alias...>')
935
+ .description('Remove aliases, keeping the rest')
936
+ .action(async (agent: string, aliases: string[], _options, command: Command) => {
937
+ const global = globals(command);
938
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
939
+ client.agents.removeAliases(agent, aliases),
940
+ );
941
+ output(io, global.json, profile, `Aliases: ${(profile.aliases ?? []).join(', ') || 'none'}`);
942
+ });
943
+
944
+ agentCommand
945
+ .command('rename <agent> <handle>')
946
+ .description('Change an agent handle. Its canonical ID never changes.')
947
+ .action(async (agent: string, handle: string, _options, command: Command) => {
948
+ const global = globals(command);
949
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
950
+ client.agents.update(agent, { handle }),
951
+ );
952
+ output(
953
+ io,
954
+ global.json,
955
+ profile,
956
+ `Handle: ${profile.handle}\nAgent ID: ${profile.id} (unchanged)`,
957
+ );
958
+ });
959
+
960
+ agentCommand
961
+ .command('archive <agent>')
962
+ .description('Stop an agent acting, keeping its records and history')
963
+ .action(async (agent: string, _options, command: Command) => {
964
+ const global = globals(command);
965
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
966
+ client.agents.archive(agent),
967
+ );
968
+ output(io, global.json, profile, `Archived ${profile.handle} (${profile.id})`);
969
+ });
970
+
971
+ agentCommand
972
+ .command('restore <agent>')
973
+ .description('Let an archived agent act again')
974
+ .action(async (agent: string, _options, command: Command) => {
975
+ const global = globals(command);
976
+ const profile = await withClient(global.home, defaultActor(env, 'system', 'cli'), (client) =>
977
+ client.agents.restore(agent),
978
+ );
979
+ output(io, global.json, profile, `Restored ${profile.handle} (${profile.id})`);
980
+ });
981
+
629
982
  const skillCommand = program
630
983
  .command('skill')
631
984
  .description('Install and maintain the packaged agent skill');
@@ -750,7 +1103,11 @@ export function createCli(
750
1103
  ? agents
751
1104
  .map(
752
1105
  (profile) =>
753
- `${profile.id} ${profile.displayName}${profile.aliases?.length ? ` aliases: ${profile.aliases.join(', ')}` : ''}`,
1106
+ // Handle first: it is what people type. The canonical ID
1107
+ // follows because MCP registration needs it.
1108
+ `${profile.handle} ${profile.displayName}${
1109
+ profile.status === 'archived' ? ' [archived]' : ''
1110
+ }${profile.aliases?.length ? ` aliases: ${profile.aliases.join(', ')}` : ''}\n ${profile.id}`,
754
1111
  )
755
1112
  .join('\n')
756
1113
  : 'No agents configured.';
@@ -769,7 +1126,7 @@ export function createCli(
769
1126
  io,
770
1127
  global.json,
771
1128
  profile,
772
- `${profile.displayName} (${profile.id})\n${profile.description ?? 'No description.'}`,
1129
+ `${profile.displayName}\n\nHandle: ${profile.handle}\nAgent ID: ${profile.id}\nStatus: ${profile.status}\n\n${profile.description ?? 'No description.'}`,
773
1130
  );
774
1131
  });
775
1132
 
@@ -923,6 +1280,172 @@ export function createCli(
923
1280
  );
924
1281
  });
925
1282
 
1283
+ const postCommand = program.command('post').description('Publish to everyone in the workspace');
1284
+
1285
+ postCommand
1286
+ .command('create')
1287
+ .description('Publish a post the whole workspace can read')
1288
+ .requiredOption('--as <actor-id>')
1289
+ .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1290
+ .requiredOption('--title <title>')
1291
+ .requiredOption('--body <body>')
1292
+ .option('--tag <tag>', 'repeatable', collect, [])
1293
+ .option('--reply-to <post-id>')
1294
+ .action(
1295
+ async (
1296
+ options: {
1297
+ as: string;
1298
+ actorKind: string;
1299
+ title: string;
1300
+ body: string;
1301
+ tag: string[];
1302
+ replyTo?: string;
1303
+ },
1304
+ command: Command,
1305
+ ) => {
1306
+ const global = globals(command);
1307
+ const result = await withClient(
1308
+ global.home,
1309
+ actor(options.actorKind, options.as),
1310
+ (client) =>
1311
+ client.posts.create({
1312
+ title: options.title,
1313
+ body: options.body,
1314
+ ...(options.tag.length ? { tags: options.tag } : {}),
1315
+ ...(options.replyTo ? { replyTo: options.replyTo } : {}),
1316
+ }),
1317
+ );
1318
+ output(io, global.json, result, `Published ${result.record.event.id}`);
1319
+ },
1320
+ );
1321
+
1322
+ postCommand
1323
+ .command('list')
1324
+ .description('List posts in this workspace')
1325
+ .requiredOption('--as <actor-id>')
1326
+ .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1327
+ .option('--limit <n>', 'default 10, maximum 50')
1328
+ .action(
1329
+ async (options: { as: string; actorKind: string; limit?: string }, command: Command) => {
1330
+ const global = globals(command);
1331
+ const page = await withClient(global.home, actor(options.actorKind, options.as), (client) =>
1332
+ client.posts.list(options.limit ? { limit: Number(options.limit) } : {}),
1333
+ );
1334
+ const human = page.items.length
1335
+ ? page.items.map((item) => `${item.id} ${item.title}`).join('\n')
1336
+ : 'No posts yet.';
1337
+ output(io, global.json, page, human);
1338
+ },
1339
+ );
1340
+
1341
+ postCommand
1342
+ .command('show <post-id>')
1343
+ .description('Show one post with its acknowledgements')
1344
+ .requiredOption('--as <actor-id>')
1345
+ .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1346
+ .action(
1347
+ async (postId: string, options: { as: string; actorKind: string }, command: Command) => {
1348
+ const global = globals(command);
1349
+ const record = await withClient(
1350
+ global.home,
1351
+ actor(options.actorKind, options.as),
1352
+ (client) => client.posts.get(postId),
1353
+ );
1354
+ const acks = record.acknowledgments.length
1355
+ ? record.acknowledgments
1356
+ .map((entry) => ` ${entry.actor.id}${entry.note ? ` — ${entry.note}` : ''}`)
1357
+ .join('\n')
1358
+ : ' none yet';
1359
+ output(
1360
+ io,
1361
+ global.json,
1362
+ record,
1363
+ `${record.title}\n\n${record.body}\n\nAcknowledged by:\n${acks}`,
1364
+ );
1365
+ },
1366
+ );
1367
+
1368
+ postCommand
1369
+ .command('acknowledge <post-id>')
1370
+ .description('Say you have seen a post')
1371
+ .requiredOption('--as <actor-id>')
1372
+ .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1373
+ .option('--note <text>', 'optional context for the author')
1374
+ .action(
1375
+ async (
1376
+ postId: string,
1377
+ options: { as: string; actorKind: string; note?: string },
1378
+ command: Command,
1379
+ ) => {
1380
+ const global = globals(command);
1381
+ const record = await withClient(
1382
+ global.home,
1383
+ actor(options.actorKind, options.as),
1384
+ (client) =>
1385
+ client.posts.acknowledge({
1386
+ postId,
1387
+ ...(options.note ? { note: options.note } : {}),
1388
+ }),
1389
+ );
1390
+ output(io, global.json, record, `Acknowledged ${postId}`);
1391
+ },
1392
+ );
1393
+
1394
+ postCommand
1395
+ .command('roster <post-id>')
1396
+ .description('Who has acknowledged a post, and who has not')
1397
+ .requiredOption('--as <actor-id>')
1398
+ .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1399
+ .action(
1400
+ async (postId: string, options: { as: string; actorKind: string }, command: Command) => {
1401
+ const global = globals(command);
1402
+ const roster = await withClient(
1403
+ global.home,
1404
+ actor(options.actorKind, options.as),
1405
+ (client) => client.posts.roster(postId),
1406
+ );
1407
+ // "Outstanding" means no acknowledgement recorded — never that somebody
1408
+ // has not read it, which this cannot know.
1409
+ const lines = [
1410
+ `Acknowledged (${roster.acknowledged.length}):`,
1411
+ ...(roster.acknowledged.length
1412
+ ? roster.acknowledged.map((entry) => ` ${entry.actor.id}`)
1413
+ : [' none yet']),
1414
+ `No acknowledgement recorded (${roster.outstanding.length}):`,
1415
+ ...(roster.outstanding.length
1416
+ ? roster.outstanding.map((entry) => ` ${entry.id}`)
1417
+ : [' none']),
1418
+ ];
1419
+ if (roster.joinedSince > 0) {
1420
+ lines.push(`${roster.joinedSince} agent(s) joined after this was posted.`);
1421
+ }
1422
+ output(io, global.json, roster, lines.join('\n'));
1423
+ },
1424
+ );
1425
+
1426
+ postCommand
1427
+ .command('archive <post-id>')
1428
+ .description('Archive a post you wrote')
1429
+ .requiredOption('--as <actor-id>')
1430
+ .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1431
+ .option('--reason <text>')
1432
+ .action(
1433
+ async (
1434
+ postId: string,
1435
+ options: { as: string; actorKind: string; reason?: string },
1436
+ command: Command,
1437
+ ) => {
1438
+ const global = globals(command);
1439
+ const record = await withClient(
1440
+ global.home,
1441
+ actor(options.actorKind, options.as),
1442
+ (client) =>
1443
+ client.posts.archive({ postId, ...(options.reason ? { reason: options.reason } : {}) }),
1444
+ );
1445
+ output(io, global.json, record, `Archived ${postId}`);
1446
+ },
1447
+ );
1448
+
926
1449
  const kudosCommand = program.command('kudos').description('Give and manage agent recognition');
927
1450
 
928
1451
  kudosCommand
@@ -1817,14 +2340,15 @@ export function createCli(
1817
2340
  );
1818
2341
  }
1819
2342
  const capabilities = await client.capabilities();
1820
- const path = join(info.home, profile.id, 'WINS.md');
2343
+ // Projections are written under the handle, since they exist to be read.
2344
+ const path = join(info.home, profile.handle, 'WINS.md');
1821
2345
  if (!existsSync(path)) {
1822
2346
  const hint = capabilities.projections.writeWinsMarkdown
1823
2347
  ? 'Run `synomem rebuild` to generate it.'
1824
2348
  : 'Enable projection.writeWinsMarkdown and run `synomem rebuild`.';
1825
2349
  throw new SynomemError(
1826
2350
  'INVALID_INPUT',
1827
- `No generated WINS.md exists for ${profile.id}. ${hint}`,
2351
+ `No generated WINS.md exists for ${profile.handle}. ${hint}`,
1828
2352
  );
1829
2353
  }
1830
2354
  return { profile, path, content: readFileSync(path, 'utf8') };