synomem 0.5.3 → 0.6.1

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 (77) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +30 -12
  3. package/dist/cli.d.ts +5 -3
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +245 -47
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +10 -0
  9. package/dist/client.js.map +1 -1
  10. package/dist/config.d.ts +1 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +4 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/configure.d.ts.map +1 -1
  15. package/dist/configure.js +5 -4
  16. package/dist/configure.js.map +1 -1
  17. package/dist/discover.d.ts +9 -11
  18. package/dist/discover.d.ts.map +1 -1
  19. package/dist/discover.js +14 -15
  20. package/dist/discover.js.map +1 -1
  21. package/dist/import.d.ts +4 -4
  22. package/dist/index.d.ts +5 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/mcp/index.d.ts.map +1 -1
  27. package/dist/mcp/index.js +46 -0
  28. package/dist/mcp/index.js.map +1 -1
  29. package/dist/mcp-server.js +26 -3
  30. package/dist/mcp-server.js.map +1 -1
  31. package/dist/ports/projections.d.ts +8 -0
  32. package/dist/ports/projections.d.ts.map +1 -1
  33. package/dist/project.d.ts +51 -0
  34. package/dist/project.d.ts.map +1 -0
  35. package/dist/project.js +143 -0
  36. package/dist/project.js.map +1 -0
  37. package/dist/projections.d.ts +14 -0
  38. package/dist/projections.d.ts.map +1 -1
  39. package/dist/projections.js +36 -1
  40. package/dist/projections.js.map +1 -1
  41. package/dist/remote.d.ts.map +1 -1
  42. package/dist/remote.js +9 -0
  43. package/dist/remote.js.map +1 -1
  44. package/dist/schemas.d.ts +18 -4
  45. package/dist/schemas.d.ts.map +1 -1
  46. package/dist/schemas.js +29 -2
  47. package/dist/schemas.js.map +1 -1
  48. package/dist/service.d.ts +1 -0
  49. package/dist/service.d.ts.map +1 -1
  50. package/dist/storage.d.ts +13 -0
  51. package/dist/storage.d.ts.map +1 -1
  52. package/dist/storage.js +24 -0
  53. package/dist/storage.js.map +1 -1
  54. package/dist/types.d.ts +1 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/workspaces.d.ts +41 -0
  57. package/dist/workspaces.d.ts.map +1 -0
  58. package/dist/workspaces.js +96 -0
  59. package/dist/workspaces.js.map +1 -0
  60. package/package.json +1 -1
  61. package/src/cli.ts +305 -50
  62. package/src/client.ts +10 -0
  63. package/src/config.ts +4 -0
  64. package/src/configure.ts +5 -4
  65. package/src/discover.ts +18 -19
  66. package/src/index.ts +22 -1
  67. package/src/mcp/index.ts +62 -0
  68. package/src/mcp-server.ts +32 -5
  69. package/src/ports/projections.ts +9 -0
  70. package/src/project.ts +168 -0
  71. package/src/projections.ts +38 -1
  72. package/src/remote.ts +9 -0
  73. package/src/schemas.ts +44 -12
  74. package/src/service.ts +1 -0
  75. package/src/storage.ts +28 -0
  76. package/src/types.ts +1 -0
  77. package/src/workspaces.ts +107 -0
package/src/cli.ts CHANGED
@@ -21,8 +21,19 @@ import {
21
21
  type ConfigPlan,
22
22
  type CredentialStoreChoice,
23
23
  } from './configure.js';
24
- import { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
25
- import { defaultPromptIo, type PromptIo } from './prompt.js';
24
+ import {
25
+ discoverAccessKeyWorkspaces,
26
+ discoverOrganizations,
27
+ workspaceChoices,
28
+ type DiscoveredWorkspace,
29
+ } from './discover.js';
30
+ import { DEFAULT_WORKSPACE, listLocalWorkspaces, localWorkspaceHome } from './workspaces.js';
31
+ import {
32
+ findProjectSelection,
33
+ resolveWorkspaceSelection,
34
+ writeProjectSelection,
35
+ } from './project.js';
36
+ import { defaultPromptIo, select, type PromptIo } from './prompt.js';
26
37
  import { credentialReference, OsCredentialStore, type CredentialStore } from './credentials.js';
27
38
  import { asSynomemError, SynomemError, type SynomemErrorCode } from './errors.js';
28
39
  import { atomicWriteFile } from './fs-utils.js';
@@ -78,12 +89,12 @@ export interface CliDependencies {
78
89
  }) => Promise<void>;
79
90
  /*
80
91
  * Injected so setup can be tested without a network. The default asks the
81
- * service which workspace an access key is bound to.
92
+ * service which workspaces an access key can reach.
82
93
  */
83
- discoverBoundWorkspace?: (options: {
94
+ discoverAccessKeyWorkspaces?: (options: {
84
95
  baseUrl: string;
85
96
  accessToken: string;
86
- }) => Promise<{ workspaceId: string }>;
97
+ }) => Promise<{ organizationId: string; workspaces: DiscoveredWorkspace[] }>;
87
98
  createImportBundle?: (home: string) => Promise<ImportBundle>;
88
99
  remoteImport?: (options: {
89
100
  baseUrl: string;
@@ -148,10 +159,18 @@ function defaultActor(
148
159
  env: NodeJS.ProcessEnv,
149
160
  fallbackKind: string,
150
161
  fallbackId: string,
162
+ /** `--actor`, which outranks the environment: it is said on this invocation. */
163
+ override?: string,
151
164
  ): ActorIdentity {
152
- const id = env.SYNOMEM_ACTOR_ID?.trim();
165
+ const id = override?.trim() || env.SYNOMEM_ACTOR_ID?.trim();
153
166
  if (!id) return actor(fallbackKind, fallbackId);
154
- return actor(env.SYNOMEM_ACTOR_KIND?.trim() || fallbackKind, id, env.SYNOMEM_ACTOR_NAME?.trim());
167
+ const kind = override?.trim()
168
+ ? // An explicit --actor names an agent unless told otherwise; the historical
169
+ // fallbacks here are `system`/`cli`, which is not what somebody means when
170
+ // they name one.
171
+ env.SYNOMEM_ACTOR_KIND?.trim() || 'agent'
172
+ : env.SYNOMEM_ACTOR_KIND?.trim() || fallbackKind;
173
+ return actor(kind, id, env.SYNOMEM_ACTOR_NAME?.trim());
155
174
  }
156
175
 
157
176
  function taskDue(options: {
@@ -245,10 +264,30 @@ function output(io: CliIo, json: boolean, value: unknown, human: string): void {
245
264
  io.stdout(json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`);
246
265
  }
247
266
 
248
- function globals(command: Command): { home?: string; json: boolean } {
249
- return command.optsWithGlobals<{ home?: string; json: boolean }>();
267
+ /**
268
+ * The resolved global options for a command.
269
+ *
270
+ * `--workspace` is turned into a home HERE, before any service exists, which is
271
+ * the whole reason it costs nothing downstream: a local workspace is a separate
272
+ * database in its own home, and choosing one is choosing a home. Nothing in the
273
+ * domain, the commands, or the MCP tools learns that a workspace was selected.
274
+ *
275
+ * On a remote backend the name means a hosted workspace instead, which
276
+ * `backend use remote --workspace` already handles; passing both here would be
277
+ * two different answers to the same question, so it is refused.
278
+ */
279
+ /** `parent child`, so a subcommand name cannot be confused with another's. */
280
+ function commandPath(command: Command): string {
281
+ const parent = command.parent?.name();
282
+ return parent && parent !== 'synomem' ? `${parent} ${command.name()}` : command.name();
250
283
  }
251
284
 
285
+ /**
286
+ * Commands where `--workspace` names a HOSTED workspace being configured,
287
+ * rather than a local one to act in.
288
+ */
289
+ const CONFIGURES_BACKEND = new Set(['config init', 'backend use', 'remote import']);
290
+
252
291
  async function withService<T>(
253
292
  serviceFactory: SynomemServiceFactory,
254
293
  home: string | undefined,
@@ -302,15 +341,91 @@ function listInput(options: Record<string, string>): KudosListInput {
302
341
  };
303
342
  }
304
343
 
344
+ /**
345
+ * The value `--actor` should supply to commands that name an actor.
346
+ *
347
+ * Read from argv directly, before the commands are built, because Commander
348
+ * evaluates option defaults at DECLARATION time: a `--as` declared without one
349
+ * is required, and a `--as` declared with one is already satisfied. Supplying
350
+ * it here is a single change point instead of a fallback threaded through
351
+ * twenty action bodies, and `--as` still wins when both are given because an
352
+ * explicitly passed option overrides its default.
353
+ */
354
+ function actorDefault(argv: string[], env: NodeJS.ProcessEnv): string | undefined {
355
+ for (let index = 0; index < argv.length; index += 1) {
356
+ const argument = argv[index]!;
357
+ if (argument === '--actor') return argv[index + 1]?.trim() || undefined;
358
+ if (argument.startsWith('--actor='))
359
+ return argument.slice('--actor='.length).trim() || undefined;
360
+ }
361
+ if (env.SYNOMEM_ACTOR_ID?.trim()) return env.SYNOMEM_ACTOR_ID.trim();
362
+ /*
363
+ * Last, the project's own binding. `workspace use --as` exists so a
364
+ * repository can settle both questions once — which workspace, and as whom —
365
+ * and a command run there needs neither flag afterwards.
366
+ */
367
+ try {
368
+ return findProjectSelection()?.actor;
369
+ } catch {
370
+ // A malformed project file is reported by the resolver when the command
371
+ // actually runs, with the path in the message. Failing here would turn it
372
+ // into an error before any command had been parsed.
373
+ return undefined;
374
+ }
375
+ }
376
+
305
377
  export function createCli(
306
378
  io: CliIo = defaultIo,
307
379
  serviceFactory: SynomemServiceFactory = configuredServiceFactory,
308
380
  dependencies: CliDependencies = {},
381
+ argv: string[] = process.argv,
309
382
  ): Command {
310
383
  const env = dependencies.env ?? process.env;
384
+ const actingDefault = actorDefault(argv, env);
385
+
386
+ const globals = (
387
+ command: Command,
388
+ ): { home?: string; json: boolean; actor?: string; workspace?: string } => {
389
+ const options = command.optsWithGlobals<{
390
+ home?: string;
391
+ json: boolean;
392
+ workspace?: string;
393
+ actor?: string;
394
+ }>();
395
+ if (CONFIGURES_BACKEND.has(commandPath(command))) return options;
396
+ /*
397
+ * Resolution runs even with no `--workspace`, because a project's
398
+ * `.synomem/config.json` selects one without anybody passing a flag — that is
399
+ * the whole point of it. `--home` still wins outright: it names a home
400
+ * directly rather than a workspace within one.
401
+ */
402
+ if (!options.home) {
403
+ const selection = resolveWorkspaceSelection({
404
+ ...(options.workspace ? { flag: options.workspace } : {}),
405
+ ...(options.actor ? { actorFlag: options.actor } : {}),
406
+ env,
407
+ });
408
+ return { ...options, home: selection.home, actor: selection.actor ?? options.actor };
409
+ }
410
+ if (!options.workspace) return options;
411
+ /*
412
+ * One flag, one meaning — "which workspace" — resolved differently by the
413
+ * handful of commands that CONFIGURE a backend rather than act inside one.
414
+ * For those, the value is a hosted workspace ID to be written to the config,
415
+ * so it is passed through raw and they read `workspace`. Everywhere else it
416
+ * names a local workspace, which is a home.
417
+ *
418
+ * These commands used to declare their own `--workspace`, which does not
419
+ * work: Commander gives a duplicated long flag to the parent, so the
420
+ * subcommand never received it at all.
421
+ */
422
+ return { ...options, home: localWorkspaceHome(options.workspace, options.home) };
423
+ };
424
+
311
425
  const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
312
426
  const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
313
- const discoverWorkspace = dependencies.discoverBoundWorkspace ?? discoverBoundWorkspace;
427
+ const discoverWorkspaces =
428
+ dependencies.discoverAccessKeyWorkspaces ?? discoverAccessKeyWorkspaces;
314
429
  const promptIo = dependencies.promptIo ?? defaultPromptIo();
315
430
  const verifyRemoteCredential =
316
431
  dependencies.verifyRemoteCredential ??
@@ -346,10 +461,113 @@ export function createCli(
346
461
  )
347
462
  .version(packageVersion())
348
463
  .option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.synomem)')
464
+ // A local workspace is its own database under the root, so this selects a
465
+ // home. On a remote backend the hosted workspace is chosen by
466
+ // `backend use remote --workspace` instead.
467
+ .option('--workspace <name>', 'local workspace to act in (see `synomem workspace list`)')
468
+ .option('--actor <id>', 'act as this agent (overrides SYNOMEM_ACTOR_ID)')
349
469
  .option('--json', 'emit stable machine-readable JSON', false)
350
470
  .showSuggestionAfterError()
351
471
  .configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
352
472
 
473
+ /*
474
+ * Local workspaces.
475
+ *
476
+ * Each is a separate database in its own home, which is what makes the
477
+ * isolation real: SQLite has no row-level security, so a shared file would
478
+ * rest on every query remembering to filter, with nothing to catch a miss.
479
+ * Separate files mean cross-workspace leakage is not something anybody can
480
+ * write by accident.
481
+ */
482
+ const workspaceCommand = program
483
+ .command('workspace')
484
+ .description('Work in a separate local store, isolated from the others');
485
+
486
+ workspaceCommand
487
+ .command('list')
488
+ .description('List the local workspaces on this machine')
489
+ .action((_options, command: Command) => {
490
+ const global = globals(command);
491
+ // Read from disk, so nothing is listed that does not exist.
492
+ const workspaces = listLocalWorkspaces(global.home);
493
+ // Which one is in effect here, and what decided it — a flag, the
494
+ // environment, a project file, or nothing.
495
+ const selection = resolveWorkspaceSelection({ env });
496
+ const human = workspaces
497
+ .map(
498
+ (workspace) =>
499
+ `${workspace.name === DEFAULT_WORKSPACE ? '*' : ' '} ${workspace.name.padEnd(24)} ${
500
+ workspace.initialized ? workspace.home : `${workspace.home} (not initialized)`
501
+ }`,
502
+ )
503
+ .join('\n');
504
+ output(
505
+ io,
506
+ global.json,
507
+ { workspaces, active: selection.workspace ?? DEFAULT_WORKSPACE, source: selection.source },
508
+ [
509
+ human,
510
+ '',
511
+ `Acting in: ${selection.workspace ?? DEFAULT_WORKSPACE} (from ${selection.source})`,
512
+ '',
513
+ 'Bind a directory with `synomem workspace use <name>`, or pass --workspace once.',
514
+ 'A local workspace is a separate store on this machine; a hosted workspace is shared,',
515
+ 'and is selected with `backend use remote --workspace`.',
516
+ ].join('\n'),
517
+ );
518
+ });
519
+
520
+ workspaceCommand
521
+ .command('use <name>')
522
+ .description('Bind this directory to a workspace, for every session opened here')
523
+ .option('--as <actor-id>', 'also always write as this agent', actingDefault)
524
+ .action((name: string, options: { as?: string }, command: Command) => {
525
+ const global = globals(command);
526
+ // Validated by resolving it, so a name that could never work is refused
527
+ // before a file claiming it is written.
528
+ const home = localWorkspaceHome(name, undefined);
529
+ const path = writeProjectSelection(process.cwd(), {
530
+ workspace: name,
531
+ ...(options.as ? { actor: options.as } : {}),
532
+ });
533
+ output(
534
+ io,
535
+ global.json,
536
+ { path, workspace: name, home, ...(options.as ? { actor: options.as } : {}) },
537
+ [
538
+ `Wrote ${path}`,
539
+ '',
540
+ `Every Synomem command and MCP server started in this directory now acts in ${name}${
541
+ options.as ? ` as ${options.as}` : ''
542
+ }, with no flag.`,
543
+ `Records live in ${home} — nothing is stored in this directory.`,
544
+ '',
545
+ 'Commit it to share the choice with the repository, or ignore it to keep it yours.',
546
+ ].join('\n'),
547
+ );
548
+ });
549
+
550
+ workspaceCommand
551
+ .command('create <name>')
552
+ .description('Create a local workspace and initialize its store')
553
+ .action(async (name: string, _options, command: Command) => {
554
+ const global = globals(command);
555
+ const home = localWorkspaceHome(name, global.home);
556
+ if (readSynomemConfig(home)) {
557
+ throw new SynomemError('INVALID_INPUT', `Workspace already exists: ${name}`);
558
+ }
559
+ writeSynomemBackend({ kind: 'local' }, home);
560
+ // Opening it once creates the database, so `list` does not report a
561
+ // workspace that exists in name only.
562
+ await withClient(home, defaultActor(env, 'system', 'cli'), async () => undefined);
563
+ output(
564
+ io,
565
+ global.json,
566
+ { name, home },
567
+ `Created workspace ${name} at ${home}.\nAct in it with --workspace ${name}.`,
568
+ );
569
+ });
570
+
353
571
  const remoteCommand = program.command('remote').description('Administer a remote workspace');
354
572
 
355
573
  /*
@@ -511,18 +729,54 @@ export function createCli(
511
729
  const serviceUrl = plan.serviceUrl ?? cloudApiUrl(env);
512
730
 
513
731
  /*
514
- * The workspace is discovered, not typed.
515
- *
516
- * An installation access key is bound to exactly one workspace, so the
517
- * service can be asked which one rather than the person. An explicit
518
- * --workspace still wins, because automation should not depend on a
519
- * network round trip to configure a machine.
732
+ * A member-owned access key reaches every workspace in its organization,
733
+ * never just one, so the workspace is discovered and then chosen — not
734
+ * typed from memory, and not assumed. An explicit --workspace still wins
735
+ * regardless: automation should not depend on a network round trip, and a
736
+ * person who already knows which one they want should not be asked again.
520
737
  */
521
738
  let workspaceId = plan.workspaceId;
522
739
  if (plan.backend === 'remote' && !workspaceId && token) {
523
- const bound = await discoverWorkspace({ baseUrl: serviceUrl, accessToken: token });
524
- workspaceId = bound.workspaceId;
525
- io.stdout(`Access key is bound to workspace ${workspaceId}.\n`);
740
+ const discovered = await discoverWorkspaces({ baseUrl: serviceUrl, accessToken: token });
741
+ if (discovered.workspaces.length === 0) {
742
+ throw new SynomemError(
743
+ 'INVALID_INPUT',
744
+ [
745
+ "This access key's organization has no workspaces yet.",
746
+ 'Create one in the Synomem portal, then re-run this command — or pass',
747
+ '--workspace <workspace-id> once one exists.',
748
+ ].join('\n'),
749
+ );
750
+ } else if (discovered.workspaces.length === 1) {
751
+ workspaceId = discovered.workspaces[0]!.id;
752
+ io.stdout(
753
+ `Using this key's only workspace: ${discovered.workspaces[0]!.displayName} (${workspaceId}).\n`,
754
+ );
755
+ } else if (promptIo.interactive) {
756
+ workspaceId = await select(
757
+ promptIo,
758
+ 'Which workspace should this machine use?',
759
+ discovered.workspaces.map((workspace) => ({
760
+ value: workspace.id,
761
+ label: workspace.displayName,
762
+ detail: workspace.id,
763
+ })),
764
+ );
765
+ } else {
766
+ throw new SynomemError(
767
+ 'INVALID_INPUT',
768
+ [
769
+ 'This access key can reach more than one workspace, so a non-interactive',
770
+ 'setup needs to be told which one:',
771
+ '',
772
+ ...discovered.workspaces.map(
773
+ (workspace) => ` ${workspace.id} ${workspace.displayName}`,
774
+ ),
775
+ '',
776
+ 'Re-run with --workspace <workspace-id>.',
777
+ ].join('\n'),
778
+ );
779
+ }
526
780
  }
527
781
  if (plan.backend === 'remote' && !workspaceId) {
528
782
  /*
@@ -631,7 +885,6 @@ export function createCli(
631
885
  .description('Configure Synomem without prompting')
632
886
  .option('--backend <kind>', 'local or remote')
633
887
  .option('--auth <method>', 'browser or access-key')
634
- .option('--workspace <id>', 'remote workspace ID')
635
888
  .option('--credential-store <where>', 'auto, keychain, file, or environment', 'auto')
636
889
  // The token is read from stdin, never taken as an argument: an argument is
637
890
  // kept by the shell history and visible in the process list.
@@ -642,7 +895,6 @@ export function createCli(
642
895
  options: {
643
896
  backend?: string;
644
897
  auth?: string;
645
- workspace?: string;
646
898
  credentialStore: string;
647
899
  accessTokenStdin: boolean;
648
900
  yes: boolean;
@@ -654,12 +906,12 @@ export function createCli(
654
906
  throw new SynomemError('INVALID_INPUT', 'Pass --backend local or --backend remote.');
655
907
  }
656
908
  const backend: BackendChoice = options.backend;
657
- // An access key names its own workspace, so --workspace is only
658
- // required when there is no key to ask.
659
- if (backend === 'remote' && !options.workspace && !options.accessTokenStdin) {
909
+ // An access key can be asked which workspaces it reaches, so
910
+ // --workspace is only required when there is no key to ask.
911
+ if (backend === 'remote' && !global.workspace && !options.accessTokenStdin) {
660
912
  throw new SynomemError(
661
913
  'INVALID_INPUT',
662
- 'Remote setup requires --workspace, or --access-token-stdin so the key can name its own.',
914
+ 'Remote setup requires --workspace, or --access-token-stdin so the key can be asked.',
663
915
  );
664
916
  }
665
917
  const token = options.accessTokenStdin ? await readAccessToken(promptIo) : undefined;
@@ -676,7 +928,7 @@ export function createCli(
676
928
  ? {
677
929
  serviceUrl: cloudApiUrl(env),
678
930
  auth: (options.auth as AuthChoice | undefined) ?? 'access-key',
679
- workspaceId: options.workspace,
931
+ workspaceId: global.workspace,
680
932
  credentialStore: options.credentialStore as CredentialStoreChoice,
681
933
  }
682
934
  : {}),
@@ -818,13 +1070,12 @@ export function createCli(
818
1070
  // never ask for a service address, because a person has no way to tell a
819
1071
  // real one from a phished one.
820
1072
  .option('--url <url>', 'internal: alternate HTTPS origin')
821
- .option('--workspace <id>', 'remote workspace ID')
822
- .action((kind: string, options: { url?: string; workspace?: string }, command: Command) => {
1073
+ .action((kind: string, options: { url?: string }, command: Command) => {
823
1074
  const global = globals(command);
824
1075
  if (kind !== 'local' && kind !== 'remote') {
825
1076
  throw new SynomemError('INVALID_INPUT', 'Backend kind must be local or remote.');
826
1077
  }
827
- if (kind === 'remote' && !options.workspace) {
1078
+ if (kind === 'remote' && !global.workspace) {
828
1079
  throw new SynomemError('INVALID_INPUT', 'Remote backend selection requires --workspace.');
829
1080
  }
830
1081
  const config = writeSynomemBackend(
@@ -833,7 +1084,7 @@ export function createCli(
833
1084
  : {
834
1085
  kind: 'remote',
835
1086
  baseUrl: options.url ?? cloudApiUrl(env),
836
- workspaceId: options.workspace!,
1087
+ workspaceId: global.workspace!,
837
1088
  },
838
1089
  global.home,
839
1090
  );
@@ -1493,7 +1744,7 @@ export function createCli(
1493
1744
  postCommand
1494
1745
  .command('create')
1495
1746
  .description('Publish a post the whole workspace can read')
1496
- .requiredOption('--as <actor-id>')
1747
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1497
1748
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1498
1749
  .requiredOption('--title <title>')
1499
1750
  .requiredOption('--body <body>')
@@ -1530,7 +1781,7 @@ export function createCli(
1530
1781
  postCommand
1531
1782
  .command('list')
1532
1783
  .description('List posts in this workspace')
1533
- .requiredOption('--as <actor-id>')
1784
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1534
1785
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1535
1786
  .option('--limit <n>', 'default 10, maximum 50')
1536
1787
  .action(
@@ -1549,7 +1800,7 @@ export function createCli(
1549
1800
  postCommand
1550
1801
  .command('show <post-id>')
1551
1802
  .description('Show one post with its acknowledgements')
1552
- .requiredOption('--as <actor-id>')
1803
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1553
1804
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1554
1805
  .action(
1555
1806
  async (postId: string, options: { as: string; actorKind: string }, command: Command) => {
@@ -1576,7 +1827,7 @@ export function createCli(
1576
1827
  postCommand
1577
1828
  .command('acknowledge <post-id>')
1578
1829
  .description('Say you have seen a post')
1579
- .requiredOption('--as <actor-id>')
1830
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1580
1831
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1581
1832
  .option('--note <text>', 'optional context for the author')
1582
1833
  .action(
@@ -1602,7 +1853,7 @@ export function createCli(
1602
1853
  postCommand
1603
1854
  .command('roster <post-id>')
1604
1855
  .description('Who has acknowledged a post, and who has not')
1605
- .requiredOption('--as <actor-id>')
1856
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1606
1857
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1607
1858
  .action(
1608
1859
  async (postId: string, options: { as: string; actorKind: string }, command: Command) => {
@@ -1634,7 +1885,7 @@ export function createCli(
1634
1885
  postCommand
1635
1886
  .command('archive <post-id>')
1636
1887
  .description('Archive a post you wrote')
1637
- .requiredOption('--as <actor-id>')
1888
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1638
1889
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1639
1890
  .option('--reason <text>')
1640
1891
  .action(
@@ -1659,7 +1910,11 @@ export function createCli(
1659
1910
  kudosCommand
1660
1911
  .command('give <recipient>')
1661
1912
  .description('Give specific, evidence-based kudos to an agent')
1662
- .requiredOption('--from <actor-id>', 'stable ID of the giver')
1913
+ .requiredOption(
1914
+ '--from <actor-id>',
1915
+ 'stable ID of the giver (defaults to --actor)',
1916
+ actingDefault,
1917
+ )
1663
1918
  .requiredOption('--actor-kind <kind>', 'human, agent, or system')
1664
1919
  .option('--actor-name <display-name>')
1665
1920
  .requiredOption('--title <title>')
@@ -1828,7 +2083,7 @@ export function createCli(
1828
2083
  const memoCommand = program.command('memo').description('Send and manage durable messages');
1829
2084
  memoCommand
1830
2085
  .command('send <recipient>')
1831
- .requiredOption('--from <actor-id>')
2086
+ .requiredOption('--from <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1832
2087
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1833
2088
  .option('--actor-name <name>')
1834
2089
  .requiredOption('--subject <subject>')
@@ -1945,7 +2200,7 @@ export function createCli(
1945
2200
  .description('Retain and revise agent-owned knowledge');
1946
2201
  noteCommand
1947
2202
  .command('create')
1948
- .requiredOption('--as <actor-id>')
2203
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1949
2204
  .option('--actor-kind <kind>', 'agent or human', 'agent')
1950
2205
  .option('--owner <agent-id>')
1951
2206
  .requiredOption('--title <title>')
@@ -2023,7 +2278,7 @@ export function createCli(
2023
2278
  });
2024
2279
  noteCommand
2025
2280
  .command('revise <note-id>')
2026
- .requiredOption('--as <actor-id>')
2281
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
2027
2282
  .option('--actor-kind <kind>', 'agent or human', 'agent')
2028
2283
  .requiredOption('--expected-version <number>')
2029
2284
  .option('--title <title>')
@@ -2063,7 +2318,7 @@ export function createCli(
2063
2318
  );
2064
2319
  noteCommand
2065
2320
  .command('archive <note-id>')
2066
- .requiredOption('--as <actor-id>')
2321
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
2067
2322
  .option('--actor-kind <kind>', 'agent or human', 'agent')
2068
2323
  .option('--idempotency-key <key>')
2069
2324
  .action(
@@ -2091,7 +2346,7 @@ export function createCli(
2091
2346
  .description('Create and manage your own private reminders');
2092
2347
  todoCommand
2093
2348
  .command('create')
2094
- .requiredOption('--as <actor-id>')
2349
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
2095
2350
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
2096
2351
  .requiredOption('--title <title>')
2097
2352
  .option('--details <text>', 'private working detail')
@@ -2141,7 +2396,7 @@ export function createCli(
2141
2396
  );
2142
2397
  todoCommand
2143
2398
  .command('list')
2144
- .requiredOption('--as <actor-id>')
2399
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
2145
2400
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
2146
2401
  .option('--status <status>')
2147
2402
  .option('--limit <number>', 'maximum results', '10')
@@ -2171,7 +2426,7 @@ export function createCli(
2171
2426
  );
2172
2427
  todoCommand
2173
2428
  .command('show <todo-id>')
2174
- .requiredOption('--as <actor-id>')
2429
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
2175
2430
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
2176
2431
  .action(async (id: string, options: { as: string; actorKind: string }, command: Command) => {
2177
2432
  const global = globals(command);
@@ -2188,7 +2443,7 @@ export function createCli(
2188
2443
  for (const operation of ['complete', 'reopen', 'cancel', 'archive'] as const) {
2189
2444
  todoCommand
2190
2445
  .command(`${operation} <todo-id>`)
2191
- .requiredOption('--as <actor-id>')
2446
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
2192
2447
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
2193
2448
  .option('--note <text>')
2194
2449
  .option('--reason <text>')
@@ -2244,7 +2499,7 @@ export function createCli(
2244
2499
  const taskCommand = program.command('task').description('Create and manage agent tasks');
2245
2500
  taskCommand
2246
2501
  .command('create <assignee>')
2247
- .requiredOption('--from <actor-id>')
2502
+ .requiredOption('--from <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
2248
2503
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
2249
2504
  .requiredOption('--title <title>')
2250
2505
  .option('--description <text>')
@@ -2334,7 +2589,7 @@ export function createCli(
2334
2589
  });
2335
2590
  taskCommand
2336
2591
  .command('update <task-id>')
2337
- .requiredOption('--as <actor-id>')
2592
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
2338
2593
  .option('--actor-kind <kind>', 'agent or human', 'agent')
2339
2594
  .requiredOption('--expected-version <number>')
2340
2595
  .option('--title <title>')
@@ -2388,7 +2643,7 @@ export function createCli(
2388
2643
  for (const operation of ['accept', 'reject', 'complete', 'reopen', 'cancel'] as const) {
2389
2644
  const command_ = taskCommand
2390
2645
  .command(`${operation} <task-id>`)
2391
- .requiredOption('--as <actor-id>')
2646
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
2392
2647
  .option('--actor-kind <kind>', 'agent or human', 'agent')
2393
2648
  .option('--note <text>')
2394
2649
  .option('--reason <text>')
@@ -2497,7 +2752,7 @@ export function createCli(
2497
2752
  kudosCommand
2498
2753
  .command('revoke <kudos-id>')
2499
2754
  .description('Record a revocation while preserving history')
2500
- .requiredOption('--as <actor-id>')
2755
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
2501
2756
  .option('--actor-kind <kind>', 'human, agent, or system', 'human')
2502
2757
  .requiredOption('--reason <reason>')
2503
2758
  .option('--administrative', 'mark as an administrative revocation', false)
@@ -2727,7 +2982,7 @@ export async function runCli(
2727
2982
  serviceFactory: SynomemServiceFactory = configuredServiceFactory,
2728
2983
  dependencies: CliDependencies = {},
2729
2984
  ): Promise<number> {
2730
- const program = createCli(io, serviceFactory, dependencies);
2985
+ const program = createCli(io, serviceFactory, dependencies, argv);
2731
2986
  program.exitOverride();
2732
2987
  try {
2733
2988
  await program.parseAsync(argv);
package/src/client.ts CHANGED
@@ -435,6 +435,15 @@ export class SynomemCore implements SynomemDomainService {
435
435
  await this.repository.updateAgent(updated, event.createdAt);
436
436
  await this.repository.insertEvent(event);
437
437
  });
438
+ /*
439
+ * Projections are named by handle, so a rename has to move the directory
440
+ * before it is regenerated. Otherwise the generated files appear under the
441
+ * new handle and `NOTES.md` -- which belongs to the reader and is never
442
+ * deleted -- is left stranded under the old one.
443
+ */
444
+ if (existing.handle !== updated.handle && this.projectionWriter.renameAgentDirectory) {
445
+ await this.projectionWriter.renameAgentDirectory(existing.handle, updated.handle);
446
+ }
438
447
  await this.projectionWriter.syncAgent(updated.id);
439
448
  return updated;
440
449
  }
@@ -1915,6 +1924,7 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1915
1924
  binding: { workspaceId: this.storage.config.workspaceId, actor: this.actor },
1916
1925
  administration: {
1917
1926
  agentCreationViaMcp: this.storage.config.allowAgentCreationViaMcp,
1927
+ agentArchiveViaMcp: this.storage.config.allowAgentArchiveViaMcp,
1918
1928
  rebuildViaMcp: this.storage.config.allowRebuildViaMcp,
1919
1929
  },
1920
1930
  projections: { ...this.storage.config.projection },
package/src/config.ts CHANGED
@@ -12,6 +12,7 @@ export const defaultConfig: SynomemConfig = {
12
12
  allowSelfAwards: false,
13
13
  allowCrossAgentTasks: true,
14
14
  allowAgentCreationViaMcp: false,
15
+ allowAgentArchiveViaMcp: false,
15
16
  allowRebuildViaMcp: false,
16
17
  includePrivateInStats: false,
17
18
  projection: {
@@ -55,6 +56,7 @@ const policySchema = z.object({
55
56
  allowSelfAwards: z.boolean(),
56
57
  allowCrossAgentTasks: z.boolean(),
57
58
  allowAgentCreationViaMcp: z.boolean(),
59
+ allowAgentArchiveViaMcp: z.boolean(),
58
60
  allowRebuildViaMcp: z.boolean(),
59
61
  includePrivateInStats: z.boolean(),
60
62
  projection: z.object({
@@ -156,6 +158,7 @@ function environmentConfig(env: NodeJS.ProcessEnv): SynomemConfigOverrides {
156
158
  const allowSelfAwards = optionalBoolean(env, 'SYNOMEM_ALLOW_SELF_AWARDS');
157
159
  const allowCrossAgentTasks = optionalBoolean(env, 'SYNOMEM_ALLOW_CROSS_AGENT_TODOS');
158
160
  const allowAgentCreationViaMcp = optionalBoolean(env, 'SYNOMEM_ALLOW_AGENT_CREATION_VIA_MCP');
161
+ const allowAgentArchiveViaMcp = optionalBoolean(env, 'SYNOMEM_ALLOW_AGENT_ARCHIVE_VIA_MCP');
159
162
  const allowRebuildViaMcp = optionalBoolean(env, 'SYNOMEM_ALLOW_REBUILD_VIA_MCP');
160
163
  const includePrivateInStats = optionalBoolean(env, 'SYNOMEM_INCLUDE_PRIVATE_IN_STATS');
161
164
  const projection = {
@@ -170,6 +173,7 @@ function environmentConfig(env: NodeJS.ProcessEnv): SynomemConfigOverrides {
170
173
  ...(allowSelfAwards !== undefined ? { allowSelfAwards } : {}),
171
174
  ...(allowCrossAgentTasks !== undefined ? { allowCrossAgentTasks } : {}),
172
175
  ...(allowAgentCreationViaMcp !== undefined ? { allowAgentCreationViaMcp } : {}),
176
+ ...(allowAgentArchiveViaMcp !== undefined ? { allowAgentArchiveViaMcp } : {}),
173
177
  ...(allowRebuildViaMcp !== undefined ? { allowRebuildViaMcp } : {}),
174
178
  ...(includePrivateInStats !== undefined ? { includePrivateInStats } : {}),
175
179
  ...(Object.keys(projection).length ? { projection } : {}),