synomem 0.1.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 (130) hide show
  1. package/AGENTS.md +42 -0
  2. package/ARCHITECTURE.md +126 -0
  3. package/CHANGELOG.md +35 -0
  4. package/CODE_OF_CONDUCT.md +29 -0
  5. package/CONTRIBUTING.md +48 -0
  6. package/LICENSE +21 -0
  7. package/README.md +247 -0
  8. package/SECURITY.md +117 -0
  9. package/dist/backend.d.ts +9 -0
  10. package/dist/backend.d.ts.map +1 -0
  11. package/dist/backend.js +60 -0
  12. package/dist/backend.js.map +1 -0
  13. package/dist/cli.d.ts +34 -0
  14. package/dist/cli.d.ts.map +1 -0
  15. package/dist/cli.js +1040 -0
  16. package/dist/cli.js.map +1 -0
  17. package/dist/client.d.ts +168 -0
  18. package/dist/client.d.ts.map +1 -0
  19. package/dist/client.js +1031 -0
  20. package/dist/client.js.map +1 -0
  21. package/dist/config.d.ts +33 -0
  22. package/dist/config.d.ts.map +1 -0
  23. package/dist/config.js +153 -0
  24. package/dist/config.js.map +1 -0
  25. package/dist/credentials.d.ts +36 -0
  26. package/dist/credentials.d.ts.map +1 -0
  27. package/dist/credentials.js +127 -0
  28. package/dist/credentials.js.map +1 -0
  29. package/dist/errors.d.ts +9 -0
  30. package/dist/errors.d.ts.map +1 -0
  31. package/dist/errors.js +60 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/fs-utils.d.ts +7 -0
  34. package/dist/fs-utils.d.ts.map +1 -0
  35. package/dist/fs-utils.js +68 -0
  36. package/dist/fs-utils.js.map +1 -0
  37. package/dist/import.d.ts +655 -0
  38. package/dist/import.d.ts.map +1 -0
  39. package/dist/import.js +226 -0
  40. package/dist/import.js.map +1 -0
  41. package/dist/index.d.ts +20 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +11 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/mcp/index.d.ts +14 -0
  46. package/dist/mcp/index.d.ts.map +1 -0
  47. package/dist/mcp/index.js +698 -0
  48. package/dist/mcp/index.js.map +1 -0
  49. package/dist/mcp-server.d.ts +3 -0
  50. package/dist/mcp-server.d.ts.map +1 -0
  51. package/dist/mcp-server.js +45 -0
  52. package/dist/mcp-server.js.map +1 -0
  53. package/dist/oauth.d.ts +27 -0
  54. package/dist/oauth.d.ts.map +1 -0
  55. package/dist/oauth.js +201 -0
  56. package/dist/oauth.js.map +1 -0
  57. package/dist/ports/projections.d.ts +6 -0
  58. package/dist/ports/projections.d.ts.map +1 -0
  59. package/dist/ports/projections.js +2 -0
  60. package/dist/ports/projections.js.map +1 -0
  61. package/dist/ports/repository.d.ts +31 -0
  62. package/dist/ports/repository.d.ts.map +1 -0
  63. package/dist/ports/repository.js +2 -0
  64. package/dist/ports/repository.js.map +1 -0
  65. package/dist/projections.d.ts +24 -0
  66. package/dist/projections.d.ts.map +1 -0
  67. package/dist/projections.js +531 -0
  68. package/dist/projections.js.map +1 -0
  69. package/dist/remote.d.ts +120 -0
  70. package/dist/remote.d.ts.map +1 -0
  71. package/dist/remote.js +286 -0
  72. package/dist/remote.js.map +1 -0
  73. package/dist/schemas.d.ts +901 -0
  74. package/dist/schemas.d.ts.map +1 -0
  75. package/dist/schemas.js +424 -0
  76. package/dist/schemas.js.map +1 -0
  77. package/dist/service.d.ts +126 -0
  78. package/dist/service.d.ts.map +1 -0
  79. package/dist/service.js +2 -0
  80. package/dist/service.js.map +1 -0
  81. package/dist/skill-install.d.ts +34 -0
  82. package/dist/skill-install.d.ts.map +1 -0
  83. package/dist/skill-install.js +256 -0
  84. package/dist/skill-install.js.map +1 -0
  85. package/dist/storage.d.ts +94 -0
  86. package/dist/storage.d.ts.map +1 -0
  87. package/dist/storage.js +1229 -0
  88. package/dist/storage.js.map +1 -0
  89. package/dist/types.d.ts +442 -0
  90. package/dist/types.d.ts.map +1 -0
  91. package/dist/types.js +2 -0
  92. package/dist/types.js.map +1 -0
  93. package/dist/version.d.ts +3 -0
  94. package/dist/version.d.ts.map +1 -0
  95. package/dist/version.js +14 -0
  96. package/dist/version.js.map +1 -0
  97. package/docs/cli.md +180 -0
  98. package/docs/examples.md +70 -0
  99. package/docs/mcp.md +96 -0
  100. package/docs/recovery.md +45 -0
  101. package/docs/releasing.md +61 -0
  102. package/docs/skill.md +93 -0
  103. package/docs/storage-format.md +83 -0
  104. package/openapi/synomem-v1.yaml +674 -0
  105. package/package.json +100 -0
  106. package/skills/synomem/SKILL.md +71 -0
  107. package/skills/synomem/agents/openai.yaml +8 -0
  108. package/skills/synomem/references/examples.md +28 -0
  109. package/src/backend.ts +91 -0
  110. package/src/cli.ts +1623 -0
  111. package/src/client.ts +1261 -0
  112. package/src/config.ts +170 -0
  113. package/src/credentials.ts +194 -0
  114. package/src/errors.ts +64 -0
  115. package/src/fs-utils.ts +85 -0
  116. package/src/import.ts +284 -0
  117. package/src/index.ts +61 -0
  118. package/src/mcp/index.ts +973 -0
  119. package/src/mcp-server.ts +46 -0
  120. package/src/oauth.ts +252 -0
  121. package/src/ports/projections.ts +6 -0
  122. package/src/ports/repository.ts +67 -0
  123. package/src/projections.ts +624 -0
  124. package/src/remote.ts +516 -0
  125. package/src/schemas.ts +457 -0
  126. package/src/service.ts +140 -0
  127. package/src/skill-install.ts +332 -0
  128. package/src/storage.ts +1603 -0
  129. package/src/types.ts +458 -0
  130. package/src/version.ts +16 -0
package/src/cli.ts ADDED
@@ -0,0 +1,1623 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
3
+ import { spawn } from 'node:child_process';
4
+ import { join, resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { Command, CommanderError, Option } from 'commander';
7
+ import { configuredServiceFactory, readSynomemConfig, writeSynomemBackend } from './backend.js';
8
+ import { credentialReference, OsCredentialStore, type CredentialStore } from './credentials.js';
9
+ import { asSynomemError, SynomemError, type SynomemErrorCode } from './errors.js';
10
+ import { atomicWriteFile } from './fs-utils.js';
11
+ import { startMcpServer } from './mcp/index.js';
12
+ import { loginWithOAuth, StoredCredentialProvider, type OAuthLoginOptions } from './oauth.js';
13
+ import { RemoteSynomemService } from './remote.js';
14
+ import {
15
+ createLocalImportBundle,
16
+ RemoteImportClient,
17
+ type ImportBundle,
18
+ type ImportPreview,
19
+ type ImportResult,
20
+ } from './import.js';
21
+ import {
22
+ formatSkillResult,
23
+ installSkill,
24
+ skillRuntimeNames,
25
+ skillStatus,
26
+ uninstallSkill,
27
+ type SkillRuntime,
28
+ } from './skill-install.js';
29
+ import type {
30
+ ActorIdentity,
31
+ EvidenceReference,
32
+ KudosListInput,
33
+ KudosRecord,
34
+ KudosSummary,
35
+ ItemListInput,
36
+ ItemSummary,
37
+ TodoDue,
38
+ } from './types.js';
39
+ import { packageVersion } from './version.js';
40
+ import type { SynomemService, SynomemServiceFactory } from './service.js';
41
+
42
+ export interface CliIo {
43
+ stdout: (text: string) => void;
44
+ stderr: (text: string) => void;
45
+ }
46
+
47
+ export interface CliDependencies {
48
+ credentialStore?: CredentialStore;
49
+ oauthLogin?: (options: OAuthLoginOptions) => Promise<void>;
50
+ env?: NodeJS.ProcessEnv;
51
+ verifyRemoteCredential?: (options: {
52
+ baseUrl: string;
53
+ workspaceId: string;
54
+ actor: ActorIdentity;
55
+ reference: string;
56
+ credentialStore: CredentialStore;
57
+ }) => Promise<void>;
58
+ createImportBundle?: (home: string) => Promise<ImportBundle>;
59
+ remoteImport?: (options: {
60
+ baseUrl: string;
61
+ workspaceId: string;
62
+ actor: ActorIdentity;
63
+ bundle: ImportBundle;
64
+ planId?: string;
65
+ }) => Promise<ImportPreview | ImportResult>;
66
+ }
67
+
68
+ const defaultIo: CliIo = {
69
+ stdout: (text) => process.stdout.write(text),
70
+ stderr: (text) => process.stderr.write(text),
71
+ };
72
+
73
+ const cliExitCodes = new WeakMap<Command, number>();
74
+
75
+ function collect(value: string, previous: string[]): string[] {
76
+ return [...previous, value];
77
+ }
78
+
79
+ function skillRuntimes(values: string[]): SkillRuntime[] | undefined {
80
+ const normalized = values.map((value) => (value === 'grokbot' ? 'grok' : value));
81
+ const invalid = normalized.find(
82
+ (value) => value !== 'all' && !skillRuntimeNames.some((runtime) => runtime === value),
83
+ );
84
+ if (invalid) {
85
+ throw new SynomemError('INVALID_INPUT', `Unsupported skill runtime: ${invalid}.`);
86
+ }
87
+ if (!normalized.length || normalized.includes('all')) return undefined;
88
+ return normalized as SkillRuntime[];
89
+ }
90
+
91
+ const skillRuntimeHelp = `${skillRuntimeNames.join(', ')}, grokbot (alias for grok), or all`;
92
+
93
+ function parseEvidence(value: string): EvidenceReference {
94
+ const separator = value.indexOf(':');
95
+ if (separator < 1 || separator === value.length - 1) {
96
+ throw new SynomemError('INVALID_INPUT', 'Evidence must use kind:value syntax.');
97
+ }
98
+ return {
99
+ kind: value.slice(0, separator) as EvidenceReference['kind'],
100
+ value: value.slice(separator + 1),
101
+ };
102
+ }
103
+
104
+ function actor(kind: string, id: string, displayName?: string): ActorIdentity {
105
+ return {
106
+ kind: kind as ActorIdentity['kind'],
107
+ id,
108
+ ...(displayName ? { displayName } : {}),
109
+ };
110
+ }
111
+
112
+ function todoDue(options: {
113
+ dueDate?: string;
114
+ dueAt?: string;
115
+ timeZone?: string;
116
+ }): TodoDue | undefined {
117
+ if (options.dueDate && options.dueAt)
118
+ throw new SynomemError('INVALID_INPUT', 'Use due-date or due-at, not both.');
119
+ if (options.dueDate) return { kind: 'date', date: options.dueDate };
120
+ if (options.dueAt) {
121
+ if (!options.timeZone)
122
+ throw new SynomemError('INVALID_INPUT', '--time-zone is required with --due-at.');
123
+ return { kind: 'datetime', datetime: options.dueAt, timeZone: options.timeZone };
124
+ }
125
+ return undefined;
126
+ }
127
+
128
+ function exitCode(code: SynomemErrorCode): number {
129
+ if (code.endsWith('_NOT_FOUND')) return 3;
130
+ if (
131
+ code.endsWith('_FORBIDDEN') ||
132
+ code === 'READ_ONLY' ||
133
+ code === 'AUTH_REQUIRED' ||
134
+ code === 'AUTH_FORBIDDEN'
135
+ )
136
+ return 4;
137
+ if (code.startsWith('DATABASE_') || code === 'UNSUPPORTED_SCHEMA' || code === 'UNSUPPORTED_EVENT')
138
+ return 5;
139
+ if (code.startsWith('REMOTE_') || code === 'RATE_LIMITED') return 5;
140
+ if (code === 'INTERNAL_ERROR') return 1;
141
+ return 2;
142
+ }
143
+
144
+ function lineForSummary(record: KudosSummary): string {
145
+ const state =
146
+ record.revocationStatus === 'revoked'
147
+ ? 'revoked'
148
+ : record.status === 'acknowledged'
149
+ ? 'acknowledged'
150
+ : 'new';
151
+ return `${record.id} ${record.createdAt.slice(0, 10)} ${record.recipientAgentId} [${state}] ${record.title}`;
152
+ }
153
+
154
+ function lineForItem(item: ItemSummary): string {
155
+ return `${item.id} ${item.createdAt.slice(0, 10)} ${item.kind.padEnd(5)} [${item.status}] ${item.title}`;
156
+ }
157
+
158
+ function itemListInput(options: Record<string, string | string[]>): ItemListInput {
159
+ return {
160
+ ...(Array.isArray(options.kind) && options.kind.length
161
+ ? { kinds: options.kind as ItemListInput['kinds'] }
162
+ : {}),
163
+ ...(typeof options.participant === 'string' ? { participantAgentId: options.participant } : {}),
164
+ ...(typeof options.actor === 'string' ? { actorId: options.actor } : {}),
165
+ ...(typeof options.status === 'string' ? { status: options.status } : {}),
166
+ ...(typeof options.tag === 'string' ? { tag: options.tag } : {}),
167
+ ...(typeof options.visibility === 'string'
168
+ ? { visibility: options.visibility as ItemListInput['visibility'] }
169
+ : {}),
170
+ ...(typeof options.cursor === 'string' ? { cursor: options.cursor } : {}),
171
+ limit: Number(options.limit ?? 10),
172
+ offset: Number(options.offset ?? 0),
173
+ };
174
+ }
175
+
176
+ function showRecord(record: KudosRecord): string {
177
+ const event = record.event;
178
+ const evidence = event.evidence?.map((item) => ` - ${item.kind}: ${item.value}`).join('\n');
179
+ return [
180
+ event.title,
181
+ `ID: ${event.id}`,
182
+ `Recipient: ${event.recipientDisplayName} (${event.recipientAgentId})`,
183
+ `From: ${event.actor.displayName ?? event.actor.id} (${event.actor.kind}:${event.actor.id})`,
184
+ `Date: ${event.createdAt}`,
185
+ `Visibility: ${event.visibility}`,
186
+ `Status: ${record.status}`,
187
+ `Revocation: ${record.revocationStatus}`,
188
+ event.tags?.length ? `Tags: ${event.tags.join(', ')}` : undefined,
189
+ '',
190
+ event.reason,
191
+ evidence ? `\nEvidence:\n${evidence}` : undefined,
192
+ record.acknowledgment?.note ? `\nAcknowledgment: ${record.acknowledgment.note}` : undefined,
193
+ record.revocation ? `\nRevoked: ${record.revocation.reason}` : undefined,
194
+ ]
195
+ .filter((value) => value !== undefined)
196
+ .join('\n');
197
+ }
198
+
199
+ function output(io: CliIo, json: boolean, value: unknown, human: string): void {
200
+ io.stdout(json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`);
201
+ }
202
+
203
+ function globals(command: Command): { home?: string; json: boolean } {
204
+ return command.optsWithGlobals<{ home?: string; json: boolean }>();
205
+ }
206
+
207
+ async function withService<T>(
208
+ serviceFactory: SynomemServiceFactory,
209
+ home: string | undefined,
210
+ configuredActor: ActorIdentity,
211
+ operation: (client: SynomemService) => Promise<T>,
212
+ ): Promise<T> {
213
+ const client = serviceFactory({ ...(home ? { home } : {}), actor: configuredActor });
214
+ await client.init();
215
+ try {
216
+ return await operation(client);
217
+ } finally {
218
+ await client.close();
219
+ }
220
+ }
221
+
222
+ function addListOptions(command: Command): Command {
223
+ return command
224
+ .option('--recipient <agent>')
225
+ .option('--actor <id>')
226
+ .option('--actor-kind <kind>', 'human, agent, or system')
227
+ .option('--tag <tag>')
228
+ .option('--status <status>', 'acknowledged or unacknowledged')
229
+ .option('--visibility <visibility>', 'private, local, or public')
230
+ .addOption(
231
+ new Option('--revoked <state>').choices(['include', 'only', 'exclude']).default('include'),
232
+ )
233
+ .option('--from-date <iso>')
234
+ .option('--to-date <iso>')
235
+ .option('--limit <number>', 'maximum results (default 10, maximum 50)', '10')
236
+ .option('--cursor <cursor>', 'opaque cursor returned by the previous page')
237
+ .option('--offset <number>', 'pagination offset', '0');
238
+ }
239
+
240
+ function listInput(options: Record<string, string>): KudosListInput {
241
+ return {
242
+ ...(options.recipient ? { recipientAgentId: options.recipient } : {}),
243
+ ...(options.actor ? { actorId: options.actor } : {}),
244
+ ...(options.actorKind ? { actorKind: options.actorKind as KudosListInput['actorKind'] } : {}),
245
+ ...(options.tag ? { tag: options.tag } : {}),
246
+ ...(options.status ? { status: options.status as KudosListInput['status'] } : {}),
247
+ ...(options.visibility
248
+ ? { visibility: options.visibility as KudosListInput['visibility'] }
249
+ : {}),
250
+ ...(options.revoked === 'only' ? { revoked: true } : {}),
251
+ ...(options.revoked === 'exclude' ? { revoked: false } : {}),
252
+ ...(options.fromDate ? { from: options.fromDate } : {}),
253
+ ...(options.toDate ? { to: options.toDate } : {}),
254
+ ...(options.cursor ? { cursor: options.cursor } : {}),
255
+ limit: Number(options.limit),
256
+ offset: Number(options.offset),
257
+ };
258
+ }
259
+
260
+ export function createCli(
261
+ io: CliIo = defaultIo,
262
+ serviceFactory: SynomemServiceFactory = configuredServiceFactory,
263
+ dependencies: CliDependencies = {},
264
+ ): Command {
265
+ const env = dependencies.env ?? process.env;
266
+ const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
267
+ const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
268
+ const verifyRemoteCredential =
269
+ dependencies.verifyRemoteCredential ??
270
+ (async (options) => {
271
+ const remote = new RemoteSynomemService({
272
+ baseUrl: options.baseUrl,
273
+ workspaceId: options.workspaceId,
274
+ expectedActor: options.actor,
275
+ credentialProvider: new StoredCredentialProvider(
276
+ options.reference,
277
+ options.credentialStore,
278
+ {},
279
+ ),
280
+ });
281
+ try {
282
+ await remote.init();
283
+ } finally {
284
+ await remote.close();
285
+ }
286
+ });
287
+
288
+ const program = new Command();
289
+ const withClient = <T>(
290
+ home: string | undefined,
291
+ configuredActor: ActorIdentity,
292
+ operation: (client: SynomemService) => Promise<T>,
293
+ ) => withService(serviceFactory, home, configuredActor, operation);
294
+ cliExitCodes.set(program, 0);
295
+ program
296
+ .name('synomem')
297
+ .description(
298
+ 'Local-first communication, memory, recognition, and task infrastructure for agents',
299
+ )
300
+ .version(packageVersion())
301
+ .option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.agents)')
302
+ .option('--json', 'emit stable machine-readable JSON', false)
303
+ .showSuggestionAfterError()
304
+ .configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
305
+
306
+ const remoteCommand = program.command('remote').description('Administer a remote workspace');
307
+ remoteCommand
308
+ .command('import')
309
+ .description('Preview or confirm a one-way import from a local Synomem home')
310
+ .requiredOption('--from-home <path>', 'source local Synomem home')
311
+ .requiredOption('--actor-id <id>', 'bound human administrator actor ID')
312
+ .option('--actor-name <name>', 'expected administrator display name')
313
+ .option('--preview', 'validate and return a short-lived import plan')
314
+ .option('--confirm <plan-id>', 'commit the exact bundle authorized by a preview')
315
+ .action(
316
+ async (
317
+ options: {
318
+ fromHome: string;
319
+ actorId: string;
320
+ actorName?: string;
321
+ preview?: boolean;
322
+ confirm?: string;
323
+ },
324
+ command: Command,
325
+ ) => {
326
+ const global = globals(command);
327
+ if (Boolean(options.preview) === Boolean(options.confirm)) {
328
+ throw new SynomemError('INVALID_INPUT', 'Choose exactly one of --preview or --confirm.');
329
+ }
330
+ const config = readSynomemConfig(global.home, env);
331
+ if (config?.backend.kind !== 'remote') {
332
+ throw new SynomemError('INVALID_INPUT', 'Select a remote backend before importing.');
333
+ }
334
+ const backend = config.backend;
335
+ const administrator = actor('human', options.actorId, options.actorName);
336
+ const bundle = await (dependencies.createImportBundle ?? createLocalImportBundle)(
337
+ options.fromHome,
338
+ );
339
+ const result = dependencies.remoteImport
340
+ ? await dependencies.remoteImport({
341
+ baseUrl: backend.baseUrl,
342
+ workspaceId: backend.workspaceId,
343
+ actor: administrator,
344
+ bundle,
345
+ ...(options.confirm ? { planId: options.confirm } : {}),
346
+ })
347
+ : await (async () => {
348
+ const credentialProvider = env.SYNOMEM_ACCESS_TOKEN
349
+ ? { getAccessToken: async () => env.SYNOMEM_ACCESS_TOKEN }
350
+ : new StoredCredentialProvider(
351
+ credentialReference(backend.baseUrl, backend.workspaceId, administrator),
352
+ credentialStore,
353
+ env,
354
+ );
355
+ const importer = new RemoteImportClient({
356
+ baseUrl: backend.baseUrl,
357
+ workspaceId: backend.workspaceId,
358
+ credentialProvider,
359
+ });
360
+ return options.confirm
361
+ ? await importer.confirm(bundle, options.confirm)
362
+ : await importer.preview(bundle);
363
+ })();
364
+ const human =
365
+ 'planId' in result
366
+ ? [
367
+ `Import preview: ${result.events} events and ${result.profiles} agent profiles.`,
368
+ `Source: ${result.sourceWorkspaceId}`,
369
+ `Target: ${result.targetWorkspaceId}`,
370
+ `Checksum: ${result.checksum}`,
371
+ `Plan expires: ${result.expiresAt}`,
372
+ `Plan ID: ${result.planId}`,
373
+ 'Nothing was imported. Re-run with --confirm <plan-id> to commit this exact snapshot.',
374
+ ].join('\n')
375
+ : `Imported ${result.events} events and ${result.profiles} agent profiles into ${result.targetWorkspaceId}.`;
376
+ output(io, global.json, result, human);
377
+ },
378
+ );
379
+
380
+ program
381
+ .command('init')
382
+ .description('Initialize the local Synomem database')
383
+ .action(async (_options, command: Command) => {
384
+ const options = globals(command);
385
+ const persisted = readSynomemConfig(options.home);
386
+ if (persisted?.backend.kind === 'remote') {
387
+ throw new SynomemError('INVALID_INPUT', 'The init command requires a local backend.');
388
+ }
389
+ await withClient(options.home, actor('system', 'cli'), async (client) => {
390
+ const info = await client.info();
391
+ if (info.backend !== 'local') {
392
+ throw new SynomemError('INVALID_INPUT', 'The init command requires a local backend.');
393
+ }
394
+ output(
395
+ io,
396
+ options.json,
397
+ { home: info.home, database: info.databasePath },
398
+ `Initialized Synomem at ${info.home}`,
399
+ );
400
+ });
401
+ });
402
+
403
+ const backendCommand = program
404
+ .command('backend')
405
+ .description('Inspect or select the canonical backend');
406
+ backendCommand
407
+ .command('show')
408
+ .description('Show backend selection without connecting')
409
+ .action((_options, command: Command) => {
410
+ const global = globals(command);
411
+ const config = readSynomemConfig(global.home);
412
+ const backend = config?.backend ?? { kind: 'local' as const };
413
+ const human =
414
+ backend.kind === 'local'
415
+ ? `Backend: local${config ? `\nWorkspace: ${config.workspaceId}` : ' (not initialized)'}`
416
+ : `Backend: remote\nURL: ${backend.baseUrl}\nWorkspace: ${backend.workspaceId}`;
417
+ output(io, global.json, { backend, initialized: config !== undefined }, human);
418
+ });
419
+ backendCommand
420
+ .command('use')
421
+ .description('Select local or remote canonical state')
422
+ .argument('<kind>', 'local or remote')
423
+ .option('--url <url>', 'remote HTTPS origin')
424
+ .option('--workspace <id>', 'remote workspace ID')
425
+ .action((kind: string, options: { url?: string; workspace?: string }, command: Command) => {
426
+ const global = globals(command);
427
+ if (kind !== 'local' && kind !== 'remote') {
428
+ throw new SynomemError('INVALID_INPUT', 'Backend kind must be local or remote.');
429
+ }
430
+ if (kind === 'remote' && (!options.url || !options.workspace)) {
431
+ throw new SynomemError(
432
+ 'INVALID_INPUT',
433
+ 'Remote backend selection requires --url and --workspace.',
434
+ );
435
+ }
436
+ const config = writeSynomemBackend(
437
+ kind === 'local'
438
+ ? { kind: 'local' }
439
+ : { kind: 'remote', baseUrl: options.url!, workspaceId: options.workspace! },
440
+ global.home,
441
+ );
442
+ output(
443
+ io,
444
+ global.json,
445
+ { backend: config.backend },
446
+ `Selected ${config.backend.kind} Synomem backend.`,
447
+ );
448
+ });
449
+
450
+ const authCommand = program.command('auth').description('Inspect remote authentication');
451
+ authCommand
452
+ .command('status')
453
+ .description('Report token availability without printing it')
454
+ .option('--actor-id <id>', 'bound actor ID (or SYNOMEM_ACTOR_ID)')
455
+ .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
456
+ .action(async (options: { actorId?: string; actorKind: string }, command: Command) => {
457
+ const global = globals(command);
458
+ if (env.SYNOMEM_ACCESS_TOKEN) {
459
+ output(
460
+ io,
461
+ global.json,
462
+ { authenticated: true, source: 'environment' },
463
+ 'Remote authentication token is available from SYNOMEM_ACCESS_TOKEN.',
464
+ );
465
+ return;
466
+ }
467
+ const config = readSynomemConfig(global.home, env);
468
+ if (config?.backend.kind !== 'remote') {
469
+ throw new SynomemError(
470
+ 'INVALID_INPUT',
471
+ 'Select a remote backend before checking authentication.',
472
+ );
473
+ }
474
+ const actorId = options.actorId ?? env.SYNOMEM_ACTOR_ID;
475
+ if (!actorId) throw new SynomemError('INVALID_INPUT', 'Specify --actor-id.');
476
+ const reference = credentialReference(
477
+ config.backend.baseUrl,
478
+ config.backend.workspaceId,
479
+ actor(options.actorKind, actorId),
480
+ );
481
+ const available = Boolean(await credentialStore.get(reference));
482
+ output(
483
+ io,
484
+ global.json,
485
+ { authenticated: available, source: available ? 'os-credential-store' : undefined },
486
+ available
487
+ ? 'A remote credential is available in the operating-system credential store.'
488
+ : 'Remote authentication is not configured.',
489
+ );
490
+ });
491
+ authCommand
492
+ .command('login')
493
+ .description('Authorize this actor with OAuth 2.1 authorization code and PKCE')
494
+ .requiredOption('--actor-id <id>', 'bound actor ID')
495
+ .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
496
+ .option('--actor-name <name>', 'expected actor display name')
497
+ .option('--client-id <id>', 'registered public OAuth client ID (or SYNOMEM_OAUTH_CLIENT_ID)')
498
+ .option('--scope <scope>', 'requested OAuth scopes')
499
+ .option('--callback-port <port>', 'loopback callback port', '43817')
500
+ .action(
501
+ async (
502
+ options: {
503
+ actorId: string;
504
+ actorKind: string;
505
+ actorName?: string;
506
+ clientId?: string;
507
+ scope?: string;
508
+ callbackPort: string;
509
+ },
510
+ command: Command,
511
+ ) => {
512
+ const global = globals(command);
513
+ const config = readSynomemConfig(global.home, env);
514
+ if (config?.backend.kind !== 'remote') {
515
+ throw new SynomemError('INVALID_INPUT', 'Select a remote backend before login.');
516
+ }
517
+ const configuredActor = actor(options.actorKind, options.actorId, options.actorName);
518
+ const clientId = options.clientId ?? env.SYNOMEM_OAUTH_CLIENT_ID;
519
+ if (!clientId) throw new SynomemError('INVALID_INPUT', 'Specify --client-id.');
520
+ const callbackPort = Number(options.callbackPort);
521
+ if (!Number.isSafeInteger(callbackPort) || callbackPort < 1 || callbackPort > 65_535) {
522
+ throw new SynomemError('INVALID_INPUT', '--callback-port must be from 1 through 65535.');
523
+ }
524
+ const reference = credentialReference(
525
+ config.backend.baseUrl,
526
+ config.backend.workspaceId,
527
+ configuredActor,
528
+ );
529
+ await oauthLogin({
530
+ baseUrl: config.backend.baseUrl,
531
+ clientId,
532
+ credentialReference: reference,
533
+ credentialStore,
534
+ callbackPort,
535
+ ...(options.scope ? { scope: options.scope } : {}),
536
+ });
537
+ try {
538
+ await verifyRemoteCredential({
539
+ baseUrl: config.backend.baseUrl,
540
+ workspaceId: config.backend.workspaceId,
541
+ actor: configuredActor,
542
+ reference,
543
+ credentialStore,
544
+ });
545
+ } catch (error) {
546
+ await credentialStore.delete(reference);
547
+ throw error;
548
+ }
549
+ output(
550
+ io,
551
+ global.json,
552
+ { authenticated: true, source: 'os-credential-store', actor: configuredActor },
553
+ `Authorized ${configuredActor.kind}:${configuredActor.id}; the credential is stored by the operating system.`,
554
+ );
555
+ },
556
+ );
557
+ authCommand
558
+ .command('logout')
559
+ .description('Remove the stored OAuth credential for one actor')
560
+ .requiredOption('--actor-id <id>', 'bound actor ID')
561
+ .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
562
+ .action(async (options: { actorId: string; actorKind: string }, command: Command) => {
563
+ const global = globals(command);
564
+ const config = readSynomemConfig(global.home, env);
565
+ if (config?.backend.kind !== 'remote') {
566
+ throw new SynomemError('INVALID_INPUT', 'Select a remote backend before logout.');
567
+ }
568
+ const configuredActor = actor(options.actorKind, options.actorId);
569
+ const removed = await credentialStore.delete(
570
+ credentialReference(config.backend.baseUrl, config.backend.workspaceId, configuredActor),
571
+ );
572
+ output(
573
+ io,
574
+ global.json,
575
+ { authenticated: false, removed },
576
+ removed
577
+ ? 'Removed the stored Synomem credential.'
578
+ : 'No stored Synomem credential existed.',
579
+ );
580
+ });
581
+
582
+ const agentCommand = program
583
+ .command('agent')
584
+ .description('Create and inspect stable agent identities');
585
+ agentCommand
586
+ .command('create <id>')
587
+ .description('Create a stable agent profile')
588
+ .requiredOption('--name <display-name>', 'display name')
589
+ .option('--alias <id>', 'alias (repeatable)', collect, [])
590
+ .option('--description <text>')
591
+ .action(
592
+ async (
593
+ id: string,
594
+ options: { name: string; alias: string[]; description?: string },
595
+ command: Command,
596
+ ) => {
597
+ const global = globals(command);
598
+ const profile = await withClient(global.home, actor('system', 'cli'), (client) =>
599
+ client.agents.create({
600
+ id,
601
+ displayName: options.name,
602
+ ...(options.alias.length ? { aliases: options.alias } : {}),
603
+ ...(options.description ? { description: options.description } : {}),
604
+ }),
605
+ );
606
+ output(io, global.json, profile, `Created ${profile.displayName} (${profile.id})`);
607
+ },
608
+ );
609
+
610
+ const skillCommand = program
611
+ .command('skill')
612
+ .description('Install and maintain the packaged agent skill');
613
+
614
+ skillCommand
615
+ .command('install')
616
+ .description('Plan or install the skill for detected agent runtimes')
617
+ .option('--runtime <runtime>', `${skillRuntimeHelp} (repeatable)`, collect, [])
618
+ .option('--yes', 'apply the displayed plan', false)
619
+ .option('--force', 'replace a conflicting synomem directory', false)
620
+ .option('--link', 'symlink to the packaged skill instead of copying it', false)
621
+ .option('--actor-id <id>', 'print actor-bound MCP registration commands')
622
+ .option('--actor-name <name>', 'display name used in MCP registration commands')
623
+ .action(
624
+ (
625
+ options: {
626
+ runtime: string[];
627
+ yes: boolean;
628
+ force: boolean;
629
+ link: boolean;
630
+ actorId?: string;
631
+ actorName?: string;
632
+ },
633
+ command: Command,
634
+ ) => {
635
+ const global = globals(command);
636
+ const result = installSkill({
637
+ runtimes: skillRuntimes(options.runtime),
638
+ apply: options.yes,
639
+ force: options.force,
640
+ link: options.link,
641
+ actorId: options.actorId,
642
+ actorName: options.actorName,
643
+ });
644
+ output(io, global.json, result, formatSkillResult(result, 'install'));
645
+ },
646
+ );
647
+
648
+ skillCommand
649
+ .command('status')
650
+ .description('Show installed, stale, missing, or conflicting skill copies')
651
+ .option('--runtime <runtime>', `${skillRuntimeHelp} (repeatable)`, collect, [])
652
+ .option('--actor-id <id>', 'print actor-bound MCP registration commands')
653
+ .option('--actor-name <name>', 'display name used in MCP registration commands')
654
+ .action(
655
+ (options: { runtime: string[]; actorId?: string; actorName?: string }, command: Command) => {
656
+ const global = globals(command);
657
+ const result = skillStatus({
658
+ runtimes: skillRuntimes(options.runtime),
659
+ actorId: options.actorId,
660
+ actorName: options.actorName,
661
+ });
662
+ output(io, global.json, result, formatSkillResult(result, 'status'));
663
+ },
664
+ );
665
+
666
+ skillCommand
667
+ .command('uninstall')
668
+ .description('Plan or remove Synomem-owned skill installations')
669
+ .option('--runtime <runtime>', `${skillRuntimeHelp} (repeatable)`, collect, [])
670
+ .option('--yes', 'apply the displayed plan', false)
671
+ .option('--force', 'remove a conflicting synomem directory', false)
672
+ .action((options: { runtime: string[]; yes: boolean; force: boolean }, command: Command) => {
673
+ const global = globals(command);
674
+ const result = uninstallSkill({
675
+ runtimes: skillRuntimes(options.runtime),
676
+ apply: options.yes,
677
+ force: options.force,
678
+ });
679
+ output(io, global.json, result, formatSkillResult(result, 'uninstall'));
680
+ });
681
+
682
+ agentCommand
683
+ .command('list')
684
+ .description('List known agent identities')
685
+ .action(async (_options, command: Command) => {
686
+ const global = globals(command);
687
+ const agents = await withClient(global.home, actor('system', 'cli'), (client) =>
688
+ client.agents.list(),
689
+ );
690
+ const human = agents.length
691
+ ? agents
692
+ .map(
693
+ (profile) =>
694
+ `${profile.id} ${profile.displayName}${profile.aliases?.length ? ` aliases: ${profile.aliases.join(', ')}` : ''}`,
695
+ )
696
+ .join('\n')
697
+ : 'No agents configured.';
698
+ output(io, global.json, { agents }, human);
699
+ });
700
+
701
+ agentCommand
702
+ .command('show <id>')
703
+ .description('Show one agent profile, resolving aliases')
704
+ .action(async (id: string, _options, command: Command) => {
705
+ const global = globals(command);
706
+ const profile = await withClient(global.home, actor('system', 'cli'), (client) =>
707
+ client.agents.get(id),
708
+ );
709
+ output(
710
+ io,
711
+ global.json,
712
+ profile,
713
+ `${profile.displayName} (${profile.id})\n${profile.description ?? 'No description.'}`,
714
+ );
715
+ });
716
+
717
+ agentCommand
718
+ .command('update <id>')
719
+ .description('Update an agent profile without rewriting history')
720
+ .option('--name <display-name>')
721
+ .option('--alias <id>', 'replace aliases (repeatable)', collect, [])
722
+ .option('--clear-aliases', 'remove every alias', false)
723
+ .option('--description <text>')
724
+ .action(
725
+ async (
726
+ id: string,
727
+ options: { name?: string; alias: string[]; clearAliases: boolean; description?: string },
728
+ command: Command,
729
+ ) => {
730
+ const global = globals(command);
731
+ const hasAliases = options.clearAliases || options.alias.length > 0;
732
+ const profile = await withClient(global.home, actor('system', 'cli'), (client) =>
733
+ client.agents.update(id, {
734
+ ...(options.name ? { displayName: options.name } : {}),
735
+ ...(hasAliases ? { aliases: options.clearAliases ? [] : options.alias } : {}),
736
+ ...(options.description !== undefined ? { description: options.description } : {}),
737
+ }),
738
+ );
739
+ output(io, global.json, profile, `Updated ${profile.displayName} (${profile.id})`);
740
+ },
741
+ );
742
+
743
+ const kudosCommand = program.command('kudos').description('Give and manage agent recognition');
744
+
745
+ kudosCommand
746
+ .command('give <recipient>')
747
+ .description('Give specific, evidence-based kudos to an agent')
748
+ .requiredOption('--from <actor-id>', 'stable ID of the giver')
749
+ .requiredOption('--actor-kind <kind>', 'human, agent, or system')
750
+ .option('--actor-name <display-name>')
751
+ .requiredOption('--title <title>')
752
+ .requiredOption('--reason <reason>')
753
+ .option('--tag <tag>', 'tag (repeatable)', collect, [])
754
+ .option('--evidence <kind:value>', 'sanitized evidence (repeatable)', collect, [])
755
+ .option('--visibility <visibility>', 'private, local, or public', 'workspace')
756
+ .option('--idempotency-key <key>')
757
+ .action(
758
+ async (
759
+ recipient: string,
760
+ options: {
761
+ from: string;
762
+ actorKind: string;
763
+ actorName?: string;
764
+ title: string;
765
+ reason: string;
766
+ tag: string[];
767
+ evidence: string[];
768
+ visibility: 'private' | 'workspace' | 'public';
769
+ idempotencyKey?: string;
770
+ },
771
+ command: Command,
772
+ ) => {
773
+ const global = globals(command);
774
+ const result = await withClient(
775
+ global.home,
776
+ actor(options.actorKind, options.from, options.actorName),
777
+ (client) =>
778
+ client.kudos.give({
779
+ recipientAgentId: recipient,
780
+ title: options.title,
781
+ reason: options.reason,
782
+ visibility: options.visibility,
783
+ ...(options.tag.length ? { tags: options.tag } : {}),
784
+ ...(options.evidence.length ? { evidence: options.evidence.map(parseEvidence) } : {}),
785
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
786
+ }),
787
+ );
788
+ const event = result.record.event;
789
+ output(
790
+ io,
791
+ global.json,
792
+ result,
793
+ `${result.deduplicated ? 'Found existing' : 'Created'} kudos for ${event.recipientDisplayName}\nTitle: ${event.title}\nDate: ${event.createdAt}\nID: ${event.id}`,
794
+ );
795
+ },
796
+ );
797
+
798
+ program
799
+ .command('inbox [agent]')
800
+ .description('Show pending kudos, memos, and todos for an agent')
801
+ .option('--as <agent-id>', 'defaults to the positional agent')
802
+ .option('--limit <number>', 'maximum results (default 10, maximum 50)', '10')
803
+ .option('--cursor <cursor>', 'opaque cursor returned by the previous page')
804
+ .action(
805
+ async (
806
+ agentId: string | undefined,
807
+ options: { as?: string; limit: string; cursor?: string },
808
+ command: Command,
809
+ ) => {
810
+ const global = globals(command);
811
+ const recipient = agentId ?? options.as;
812
+ if (!recipient) throw new SynomemError('INVALID_INPUT', 'Specify an agent inbox.');
813
+ const page = await withClient(
814
+ global.home,
815
+ actor('agent', options.as ?? recipient),
816
+ (client) =>
817
+ client.items.list({
818
+ participantAgentId: recipient,
819
+ pending: true,
820
+ limit: Number(options.limit),
821
+ ...(options.cursor ? { cursor: options.cursor } : {}),
822
+ }),
823
+ );
824
+ output(
825
+ io,
826
+ global.json,
827
+ page,
828
+ page.items.length
829
+ ? `${page.items.map(lineForItem).join('\n')}${page.hasMore ? `\nNext cursor: ${page.nextCursor}` : ''}`
830
+ : 'Inbox is clear.',
831
+ );
832
+ },
833
+ );
834
+
835
+ addListOptions(kudosCommand.command('list').description('List and filter kudos')).action(
836
+ async (options: Record<string, string>, command: Command) => {
837
+ const global = globals(command);
838
+ const page = await withClient(global.home, actor('human', 'local-cli'), (client) =>
839
+ client.kudos.list(listInput(options)),
840
+ );
841
+ output(
842
+ io,
843
+ global.json,
844
+ page,
845
+ page.items.length
846
+ ? `${page.items.map(lineForSummary).join('\n')}${page.hasMore ? `\nNext cursor: ${page.nextCursor}` : ''}`
847
+ : 'No kudos found.',
848
+ );
849
+ },
850
+ );
851
+
852
+ program
853
+ .command('list')
854
+ .description('List compact summaries across all record types')
855
+ .option('--kind <kind>', 'kudos, memo, note, or todo (repeatable)', collect, [])
856
+ .option('--participant <agent>')
857
+ .option('--actor <id>')
858
+ .option('--tag <tag>')
859
+ .option('--status <status>')
860
+ .option('--visibility <visibility>', 'private, workspace, or public')
861
+ .option('--limit <number>', 'maximum results (default 10, maximum 50)', '10')
862
+ .option('--cursor <cursor>')
863
+ .option('--offset <number>', 'deprecated offset', '0')
864
+ .action(async (options: Record<string, string | string[]>, command: Command) => {
865
+ const global = globals(command);
866
+ const page = await withClient(global.home, actor('human', 'local-cli'), (client) =>
867
+ client.items.list(itemListInput(options)),
868
+ );
869
+ output(
870
+ io,
871
+ global.json,
872
+ page,
873
+ page.items.length
874
+ ? `${page.items.map(lineForItem).join('\n')}${page.hasMore ? `\nNext cursor: ${page.nextCursor}` : ''}`
875
+ : 'No items found.',
876
+ );
877
+ });
878
+
879
+ program
880
+ .command('changes')
881
+ .description('List compact changes across all record types after an opaque watermark')
882
+ .option('--kind <kind>', 'filter by kind (repeatable)', collect, [])
883
+ .option('--after <watermark>', 'watermark or change cursor from a previous response')
884
+ .option('--limit <number>', 'maximum changes (default 20, maximum 100)', '20')
885
+ .action(
886
+ async (options: { after?: string; limit: string; kind: string[] }, command: Command) => {
887
+ const global = globals(command);
888
+ const page = await withClient(global.home, actor('human', 'local-cli'), (client) =>
889
+ client.items.changes({
890
+ limit: Number(options.limit),
891
+ ...(options.kind.length ? { kinds: options.kind as ItemListInput['kinds'] } : {}),
892
+ ...(options.after ? { after: options.after } : {}),
893
+ }),
894
+ );
895
+ const human = page.items.length
896
+ ? `${page.items
897
+ .map(
898
+ (change) =>
899
+ `${change.sequence} ${change.createdAt} ${change.type} ${change.itemId ?? '-'}`,
900
+ )
901
+ .join('\n')}\nWatermark: ${page.nextCursor}`
902
+ : `No new changes. Watermark: ${page.watermark}`;
903
+ output(io, global.json, page, human);
904
+ },
905
+ );
906
+
907
+ const memoCommand = program.command('memo').description('Send and manage durable messages');
908
+ memoCommand
909
+ .command('send <recipient>')
910
+ .requiredOption('--from <actor-id>')
911
+ .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
912
+ .option('--actor-name <name>')
913
+ .requiredOption('--subject <subject>')
914
+ .requiredOption('--body <body>')
915
+ .option('--tag <tag>', 'tag (repeatable)', collect, [])
916
+ .option('--visibility <visibility>', 'private, workspace, or public', 'workspace')
917
+ .option('--idempotency-key <key>')
918
+ .action(
919
+ async (
920
+ recipient: string,
921
+ options: {
922
+ from: string;
923
+ actorKind: string;
924
+ actorName?: string;
925
+ subject: string;
926
+ body: string;
927
+ tag: string[];
928
+ visibility: 'private' | 'workspace' | 'public';
929
+ idempotencyKey?: string;
930
+ },
931
+ command: Command,
932
+ ) => {
933
+ const global = globals(command);
934
+ const result = await withClient(
935
+ global.home,
936
+ actor(options.actorKind, options.from, options.actorName),
937
+ (client) =>
938
+ client.memos.send({
939
+ recipientAgentId: recipient,
940
+ subject: options.subject,
941
+ body: options.body,
942
+ visibility: options.visibility,
943
+ ...(options.tag.length ? { tags: options.tag } : {}),
944
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
945
+ }),
946
+ );
947
+ output(
948
+ io,
949
+ global.json,
950
+ result,
951
+ `${result.deduplicated ? 'Found existing' : 'Sent'} memo to ${result.record.event.recipientDisplayName}\nSubject: ${result.record.event.subject}\nID: ${result.record.event.id}`,
952
+ );
953
+ },
954
+ );
955
+ memoCommand
956
+ .command('list')
957
+ .option('--participant <agent>')
958
+ .option('--status <status>')
959
+ .option('--limit <number>', 'maximum results', '10')
960
+ .option('--cursor <cursor>')
961
+ .action(
962
+ async (
963
+ options: { participant?: string; status?: string; limit: string; cursor?: string },
964
+ command: Command,
965
+ ) => {
966
+ const global = globals(command);
967
+ const page = await withClient(global.home, actor('human', 'local-cli'), (client) =>
968
+ client.memos.list({
969
+ ...(options.participant ? { participantAgentId: options.participant } : {}),
970
+ ...(options.status ? { status: options.status } : {}),
971
+ limit: Number(options.limit),
972
+ ...(options.cursor ? { cursor: options.cursor } : {}),
973
+ }),
974
+ );
975
+ output(io, global.json, page, page.items.map(lineForItem).join('\n') || 'No memos found.');
976
+ },
977
+ );
978
+ memoCommand.command('show <memo-id>').action(async (id: string, _options, command: Command) => {
979
+ const global = globals(command);
980
+ const record = await withClient(global.home, actor('human', 'local-cli'), (client) =>
981
+ client.memos.get(id),
982
+ );
983
+ output(
984
+ io,
985
+ global.json,
986
+ record,
987
+ `${record.event.subject}\nID: ${record.event.id}\nStatus: ${record.status}\n\n${record.event.body}`,
988
+ );
989
+ });
990
+ for (const operation of ['read', 'archive'] as const) {
991
+ memoCommand
992
+ .command(`${operation} <memo-id>`)
993
+ .requiredOption('--as <agent-id>')
994
+ .option('--actor-kind <kind>', 'agent or human', 'agent')
995
+ .option('--idempotency-key <key>')
996
+ .action(
997
+ async (
998
+ id: string,
999
+ options: { as: string; actorKind: string; idempotencyKey?: string },
1000
+ command: Command,
1001
+ ) => {
1002
+ const global = globals(command);
1003
+ const record = await withClient(
1004
+ global.home,
1005
+ actor(options.actorKind, options.as),
1006
+ (client) =>
1007
+ client.memos[operation]({
1008
+ memoId: id,
1009
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1010
+ }),
1011
+ );
1012
+ output(io, global.json, record, `Memo ${id} is ${record.status}.`);
1013
+ },
1014
+ );
1015
+ }
1016
+
1017
+ const noteCommand = program
1018
+ .command('note')
1019
+ .description('Retain and revise agent-owned knowledge');
1020
+ noteCommand
1021
+ .command('create')
1022
+ .requiredOption('--as <actor-id>')
1023
+ .option('--actor-kind <kind>', 'agent or human', 'agent')
1024
+ .option('--owner <agent-id>')
1025
+ .requiredOption('--title <title>')
1026
+ .requiredOption('--body <body>')
1027
+ .option('--tag <tag>', 'tag (repeatable)', collect, [])
1028
+ .option('--idempotency-key <key>')
1029
+ .action(
1030
+ async (
1031
+ options: {
1032
+ as: string;
1033
+ actorKind: string;
1034
+ owner?: string;
1035
+ title: string;
1036
+ body: string;
1037
+ tag: string[];
1038
+ idempotencyKey?: string;
1039
+ },
1040
+ command: Command,
1041
+ ) => {
1042
+ const global = globals(command);
1043
+ const result = await withClient(
1044
+ global.home,
1045
+ actor(options.actorKind, options.as),
1046
+ (client) =>
1047
+ client.notes.create({
1048
+ ...(options.owner ? { ownerAgentId: options.owner } : {}),
1049
+ title: options.title,
1050
+ body: options.body,
1051
+ ...(options.tag.length ? { tags: options.tag } : {}),
1052
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1053
+ }),
1054
+ );
1055
+ output(
1056
+ io,
1057
+ global.json,
1058
+ result,
1059
+ `${result.deduplicated ? 'Found existing' : 'Created'} note\nTitle: ${result.record.current.title}\nID: ${result.record.event.id}`,
1060
+ );
1061
+ },
1062
+ );
1063
+ noteCommand
1064
+ .command('list')
1065
+ .option('--owner <agent>')
1066
+ .option('--status <status>')
1067
+ .option('--limit <number>', 'maximum results', '10')
1068
+ .action(
1069
+ async (options: { owner?: string; status?: string; limit: string }, command: Command) => {
1070
+ const global = globals(command);
1071
+ const page = await withClient(global.home, actor('human', 'local-cli'), (client) =>
1072
+ client.notes.list({
1073
+ ...(options.owner ? { participantAgentId: options.owner } : {}),
1074
+ ...(options.status ? { status: options.status } : {}),
1075
+ limit: Number(options.limit),
1076
+ }),
1077
+ );
1078
+ output(io, global.json, page, page.items.map(lineForItem).join('\n') || 'No notes found.');
1079
+ },
1080
+ );
1081
+ noteCommand.command('show <note-id>').action(async (id: string, _options, command: Command) => {
1082
+ const global = globals(command);
1083
+ const record = await withClient(global.home, actor('human', 'local-cli'), (client) =>
1084
+ client.notes.get(id),
1085
+ );
1086
+ output(
1087
+ io,
1088
+ global.json,
1089
+ record,
1090
+ `${record.current.title}\nID: ${record.event.id}\nVersion: ${record.current.version}\n\n${record.current.body}`,
1091
+ );
1092
+ });
1093
+ noteCommand
1094
+ .command('revise <note-id>')
1095
+ .requiredOption('--as <actor-id>')
1096
+ .option('--actor-kind <kind>', 'agent or human', 'agent')
1097
+ .requiredOption('--expected-version <number>')
1098
+ .option('--title <title>')
1099
+ .option('--body <body>')
1100
+ .option('--tag <tag>', 'replace tags', collect, [])
1101
+ .option('--idempotency-key <key>')
1102
+ .action(
1103
+ async (
1104
+ id: string,
1105
+ options: {
1106
+ as: string;
1107
+ actorKind: string;
1108
+ expectedVersion: string;
1109
+ title?: string;
1110
+ body?: string;
1111
+ tag: string[];
1112
+ idempotencyKey?: string;
1113
+ },
1114
+ command: Command,
1115
+ ) => {
1116
+ const global = globals(command);
1117
+ const record = await withClient(
1118
+ global.home,
1119
+ actor(options.actorKind, options.as),
1120
+ (client) =>
1121
+ client.notes.revise({
1122
+ noteId: id,
1123
+ expectedVersion: Number(options.expectedVersion),
1124
+ ...(options.title ? { title: options.title } : {}),
1125
+ ...(options.body ? { body: options.body } : {}),
1126
+ ...(options.tag.length ? { tags: options.tag } : {}),
1127
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1128
+ }),
1129
+ );
1130
+ output(io, global.json, record, `Revised note ${id} to version ${record.current.version}.`);
1131
+ },
1132
+ );
1133
+ noteCommand
1134
+ .command('archive <note-id>')
1135
+ .requiredOption('--as <actor-id>')
1136
+ .option('--actor-kind <kind>', 'agent or human', 'agent')
1137
+ .option('--idempotency-key <key>')
1138
+ .action(
1139
+ async (
1140
+ id: string,
1141
+ options: { as: string; actorKind: string; idempotencyKey?: string },
1142
+ command: Command,
1143
+ ) => {
1144
+ const global = globals(command);
1145
+ const record = await withClient(
1146
+ global.home,
1147
+ actor(options.actorKind, options.as),
1148
+ (client) =>
1149
+ client.notes.archive({
1150
+ noteId: id,
1151
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1152
+ }),
1153
+ );
1154
+ output(io, global.json, record, `Archived note ${id}.`);
1155
+ },
1156
+ );
1157
+
1158
+ const todoCommand = program.command('todo').description('Create and manage agent todos');
1159
+ todoCommand
1160
+ .command('create <assignee>')
1161
+ .requiredOption('--from <actor-id>')
1162
+ .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1163
+ .requiredOption('--title <title>')
1164
+ .option('--description <text>')
1165
+ .option('--priority <number>', '1 highest, 4 lowest', '3')
1166
+ .option('--due-date <date>')
1167
+ .option('--due-at <datetime>')
1168
+ .option('--time-zone <iana-zone>')
1169
+ .option('--tag <tag>', 'tag (repeatable)', collect, [])
1170
+ .option('--visibility <visibility>', 'private, workspace, or public', 'workspace')
1171
+ .option('--idempotency-key <key>')
1172
+ .action(
1173
+ async (
1174
+ assignee: string,
1175
+ options: {
1176
+ from: string;
1177
+ actorKind: string;
1178
+ title: string;
1179
+ description?: string;
1180
+ priority: string;
1181
+ dueDate?: string;
1182
+ dueAt?: string;
1183
+ timeZone?: string;
1184
+ tag: string[];
1185
+ visibility: 'private' | 'workspace' | 'public';
1186
+ idempotencyKey?: string;
1187
+ },
1188
+ command: Command,
1189
+ ) => {
1190
+ const global = globals(command);
1191
+ const result = await withClient(
1192
+ global.home,
1193
+ actor(options.actorKind, options.from),
1194
+ (client) =>
1195
+ client.todos.create({
1196
+ assigneeAgentId: assignee,
1197
+ title: options.title,
1198
+ ...(options.description ? { description: options.description } : {}),
1199
+ priority: Number(options.priority) as 1 | 2 | 3 | 4,
1200
+ ...((due) => (due ? { due } : {}))(todoDue(options)),
1201
+ ...(options.tag.length ? { tags: options.tag } : {}),
1202
+ visibility: options.visibility,
1203
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1204
+ }),
1205
+ );
1206
+ output(
1207
+ io,
1208
+ global.json,
1209
+ result,
1210
+ `${result.deduplicated ? 'Found existing' : 'Created'} todo for ${result.record.event.assigneeDisplayName}\nTitle: ${result.record.current.title}\nID: ${result.record.event.id}`,
1211
+ );
1212
+ },
1213
+ );
1214
+ todoCommand
1215
+ .command('list')
1216
+ .option('--assignee <agent>')
1217
+ .option('--status <status>')
1218
+ .option('--limit <number>', 'maximum results', '10')
1219
+ .action(
1220
+ async (options: { assignee?: string; status?: string; limit: string }, command: Command) => {
1221
+ const global = globals(command);
1222
+ const page = await withClient(global.home, actor('human', 'local-cli'), (client) =>
1223
+ client.todos.list({
1224
+ ...(options.assignee ? { participantAgentId: options.assignee } : {}),
1225
+ ...(options.status ? { status: options.status } : {}),
1226
+ limit: Number(options.limit),
1227
+ }),
1228
+ );
1229
+ output(io, global.json, page, page.items.map(lineForItem).join('\n') || 'No todos found.');
1230
+ },
1231
+ );
1232
+ todoCommand.command('show <todo-id>').action(async (id: string, _options, command: Command) => {
1233
+ const global = globals(command);
1234
+ const record = await withClient(global.home, actor('human', 'local-cli'), (client) =>
1235
+ client.todos.get(id),
1236
+ );
1237
+ output(
1238
+ io,
1239
+ global.json,
1240
+ record,
1241
+ `${record.current.title}\nID: ${record.event.id}\nStatus: ${record.status}\nVersion: ${record.current.version}`,
1242
+ );
1243
+ });
1244
+ todoCommand
1245
+ .command('update <todo-id>')
1246
+ .requiredOption('--as <actor-id>')
1247
+ .option('--actor-kind <kind>', 'agent or human', 'agent')
1248
+ .requiredOption('--expected-version <number>')
1249
+ .option('--title <title>')
1250
+ .option('--description <text>')
1251
+ .option('--priority <number>')
1252
+ .option('--due-date <date>')
1253
+ .option('--due-at <datetime>')
1254
+ .option('--time-zone <iana-zone>')
1255
+ .option('--clear-due')
1256
+ .option('--visibility <visibility>')
1257
+ .option('--idempotency-key <key>')
1258
+ .action(
1259
+ async (
1260
+ id: string,
1261
+ options: {
1262
+ as: string;
1263
+ actorKind: string;
1264
+ expectedVersion: string;
1265
+ title?: string;
1266
+ description?: string;
1267
+ priority?: string;
1268
+ dueDate?: string;
1269
+ dueAt?: string;
1270
+ timeZone?: string;
1271
+ clearDue?: boolean;
1272
+ visibility?: 'private' | 'workspace' | 'public';
1273
+ idempotencyKey?: string;
1274
+ },
1275
+ command: Command,
1276
+ ) => {
1277
+ const global = globals(command);
1278
+ const parsedDue = todoDue(options);
1279
+ const record = await withClient(
1280
+ global.home,
1281
+ actor(options.actorKind, options.as),
1282
+ (client) =>
1283
+ client.todos.update({
1284
+ todoId: id,
1285
+ expectedVersion: Number(options.expectedVersion),
1286
+ ...(options.title ? { title: options.title } : {}),
1287
+ ...(options.description !== undefined ? { description: options.description } : {}),
1288
+ ...(options.priority ? { priority: Number(options.priority) as 1 | 2 | 3 | 4 } : {}),
1289
+ ...(options.clearDue ? { due: null } : parsedDue ? { due: parsedDue } : {}),
1290
+ ...(options.visibility ? { visibility: options.visibility } : {}),
1291
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1292
+ }),
1293
+ );
1294
+ output(io, global.json, record, `Updated todo ${id} to version ${record.current.version}.`);
1295
+ },
1296
+ );
1297
+ for (const operation of ['accept', 'reject', 'complete', 'reopen', 'cancel'] as const) {
1298
+ todoCommand
1299
+ .command(`${operation} <todo-id>`)
1300
+ .requiredOption('--as <actor-id>')
1301
+ .option('--actor-kind <kind>', 'agent or human', 'agent')
1302
+ .option('--note <text>')
1303
+ .option('--reason <text>')
1304
+ .option('--idempotency-key <key>')
1305
+ .action(
1306
+ async (
1307
+ id: string,
1308
+ options: {
1309
+ as: string;
1310
+ actorKind: string;
1311
+ note?: string;
1312
+ reason?: string;
1313
+ idempotencyKey?: string;
1314
+ },
1315
+ command: Command,
1316
+ ) => {
1317
+ const global = globals(command);
1318
+ const record = await withClient(
1319
+ global.home,
1320
+ actor(options.actorKind, options.as),
1321
+ (client) =>
1322
+ operation === 'accept'
1323
+ ? client.todos.accept({
1324
+ todoId: id,
1325
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1326
+ })
1327
+ : operation === 'reject'
1328
+ ? client.todos.reject({
1329
+ todoId: id,
1330
+ ...(options.reason ? { reason: options.reason } : {}),
1331
+ ...(options.idempotencyKey ? { idempotencyKey: options.idempotencyKey } : {}),
1332
+ })
1333
+ : operation === 'complete'
1334
+ ? client.todos.complete({
1335
+ todoId: id,
1336
+ ...(options.note ? { note: options.note } : {}),
1337
+ ...(options.idempotencyKey
1338
+ ? { idempotencyKey: options.idempotencyKey }
1339
+ : {}),
1340
+ })
1341
+ : operation === 'cancel'
1342
+ ? client.todos.cancel({
1343
+ todoId: id,
1344
+ ...(options.reason ? { reason: options.reason } : {}),
1345
+ ...(options.idempotencyKey
1346
+ ? { idempotencyKey: options.idempotencyKey }
1347
+ : {}),
1348
+ })
1349
+ : client.todos.reopen({
1350
+ todoId: id,
1351
+ ...(options.idempotencyKey
1352
+ ? { idempotencyKey: options.idempotencyKey }
1353
+ : {}),
1354
+ }),
1355
+ );
1356
+ output(io, global.json, record, `Todo ${id} is ${record.status}.`);
1357
+ },
1358
+ );
1359
+ }
1360
+
1361
+ kudosCommand
1362
+ .command('show <kudos-id>')
1363
+ .description('Show one kudos item and its current state')
1364
+ .action(async (id: string, _options, command: Command) => {
1365
+ const global = globals(command);
1366
+ const record = await withClient(global.home, actor('system', 'cli'), (client) =>
1367
+ client.kudos.get(id),
1368
+ );
1369
+ output(io, global.json, record, showRecord(record));
1370
+ });
1371
+
1372
+ kudosCommand
1373
+ .command('acknowledge <kudos-id>')
1374
+ .description('Record that a recipient reviewed kudos')
1375
+ .requiredOption('--as <agent-id>', 'recipient agent identity')
1376
+ .option('--actor-kind <kind>', 'agent, human, or system', 'agent')
1377
+ .option('--name <display-name>')
1378
+ .option('--note <text>')
1379
+ .action(
1380
+ async (
1381
+ id: string,
1382
+ options: { as: string; actorKind: string; name?: string; note?: string },
1383
+ command: Command,
1384
+ ) => {
1385
+ const global = globals(command);
1386
+ const record = await withClient(
1387
+ global.home,
1388
+ actor(options.actorKind, options.as, options.name),
1389
+ (client) =>
1390
+ client.kudos.acknowledge({
1391
+ kudosId: id,
1392
+ ...(options.note ? { note: options.note } : {}),
1393
+ }),
1394
+ );
1395
+ output(io, global.json, record, `Acknowledged ${id} as ${options.as}.`);
1396
+ },
1397
+ );
1398
+
1399
+ kudosCommand
1400
+ .command('revoke <kudos-id>')
1401
+ .description('Record a revocation while preserving history')
1402
+ .requiredOption('--as <actor-id>')
1403
+ .option('--actor-kind <kind>', 'human, agent, or system', 'human')
1404
+ .requiredOption('--reason <reason>')
1405
+ .option('--administrative', 'mark as an administrative revocation', false)
1406
+ .action(
1407
+ async (
1408
+ id: string,
1409
+ options: { as: string; actorKind: string; reason: string; administrative: boolean },
1410
+ command: Command,
1411
+ ) => {
1412
+ const global = globals(command);
1413
+ const record = await withClient(
1414
+ global.home,
1415
+ actor(options.actorKind, options.as),
1416
+ (client) =>
1417
+ client.kudos.revoke({
1418
+ kudosId: id,
1419
+ reason: options.reason,
1420
+ administrative: options.administrative,
1421
+ }),
1422
+ );
1423
+ output(io, global.json, record, `Revoked ${id}; the audit trail was preserved.`);
1424
+ },
1425
+ );
1426
+
1427
+ kudosCommand
1428
+ .command('wins [agent]')
1429
+ .description('Print the generated WINS.md path or content')
1430
+ .option('--open', 'open WINS.md in the system GUI', false)
1431
+ .option('--print', 'print Markdown content', false)
1432
+ .action(
1433
+ async (
1434
+ agentId: string | undefined,
1435
+ options: { open: boolean; print: boolean },
1436
+ command: Command,
1437
+ ) => {
1438
+ const global = globals(command);
1439
+ if (!agentId) throw new SynomemError('INVALID_INPUT', 'Specify an agent.');
1440
+ const details = await withClient(global.home, actor('system', 'cli'), async (client) => {
1441
+ const profile = await client.agents.get(agentId);
1442
+ const info = await client.info();
1443
+ if (info.backend !== 'local') {
1444
+ throw new SynomemError(
1445
+ 'INVALID_INPUT',
1446
+ 'Generated WINS.md files are available only with the local backend.',
1447
+ );
1448
+ }
1449
+ const capabilities = await client.capabilities();
1450
+ const path = join(info.home, profile.id, 'WINS.md');
1451
+ if (!existsSync(path)) {
1452
+ const hint = capabilities.projections.writeWinsMarkdown
1453
+ ? 'Run `synomem rebuild` to generate it.'
1454
+ : 'Enable projection.writeWinsMarkdown and run `synomem rebuild`.';
1455
+ throw new SynomemError(
1456
+ 'INVALID_INPUT',
1457
+ `No generated WINS.md exists for ${profile.id}. ${hint}`,
1458
+ );
1459
+ }
1460
+ return { profile, path, content: readFileSync(path, 'utf8') };
1461
+ });
1462
+ if (options.open) {
1463
+ const commandName =
1464
+ process.platform === 'darwin'
1465
+ ? 'open'
1466
+ : process.platform === 'win32'
1467
+ ? 'cmd'
1468
+ : 'xdg-open';
1469
+ const args =
1470
+ process.platform === 'win32' ? ['/c', 'start', '', details.path] : [details.path];
1471
+ spawn(commandName, args, { detached: true, stdio: 'ignore' }).unref();
1472
+ }
1473
+ output(io, global.json, details, options.print ? details.content.trimEnd() : details.path);
1474
+ },
1475
+ );
1476
+
1477
+ addListOptions(
1478
+ kudosCommand.command('stats').description('Show aggregate kudos statistics'),
1479
+ ).action(async (options: Record<string, string>, command: Command) => {
1480
+ const global = globals(command);
1481
+ const stats = await withClient(global.home, actor('system', 'cli'), (client) =>
1482
+ client.stats(listInput(options)),
1483
+ );
1484
+ output(
1485
+ io,
1486
+ global.json,
1487
+ stats,
1488
+ `Total: ${stats.total}\nActive: ${stats.active}\nAcknowledged: ${stats.acknowledged}\nRevoked: ${stats.revoked}`,
1489
+ );
1490
+ });
1491
+
1492
+ program
1493
+ .command('rebuild')
1494
+ .description('Regenerate current-state and filesystem projections from canonical events')
1495
+ .action(async (_options, command: Command) => {
1496
+ const global = globals(command);
1497
+ const result = await withClient(global.home, actor('system', 'cli'), (client) =>
1498
+ client.rebuild(),
1499
+ );
1500
+ output(
1501
+ io,
1502
+ global.json,
1503
+ result,
1504
+ `Rebuilt ${result.generated.length} file(s); removed ${result.removed.length} stale file(s).`,
1505
+ );
1506
+ });
1507
+
1508
+ program
1509
+ .command('backup <destination>')
1510
+ .description('Create a transactionally consistent SQLite backup')
1511
+ .action(async (destination: string, _options, command: Command) => {
1512
+ const global = globals(command);
1513
+ const path = await withClient(global.home, actor('system', 'cli'), (client) => {
1514
+ if (!client.backup) {
1515
+ throw new SynomemError(
1516
+ 'INVALID_INPUT',
1517
+ 'Filesystem backup is available only with the local backend.',
1518
+ );
1519
+ }
1520
+ return client.backup(destination);
1521
+ });
1522
+ output(io, global.json, { path }, `Created backup at ${path}`);
1523
+ });
1524
+
1525
+ program
1526
+ .command('export')
1527
+ .description('Export canonical events for portability')
1528
+ .addOption(
1529
+ new Option('--format <format>').choices(['json', 'jsonl', 'markdown']).default('json'),
1530
+ )
1531
+ .option('--output <path>', 'write to an explicit destination instead of stdout')
1532
+ .action(
1533
+ async (
1534
+ options: { format: 'json' | 'jsonl' | 'markdown'; output?: string },
1535
+ command: Command,
1536
+ ) => {
1537
+ const global = globals(command);
1538
+ const content = await withClient(global.home, actor('system', 'cli'), (client) =>
1539
+ client.export(options.format),
1540
+ );
1541
+ if (options.output) {
1542
+ const destination = resolve(options.output);
1543
+ atomicWriteFile(destination, content, 0o600);
1544
+ output(
1545
+ io,
1546
+ global.json,
1547
+ { path: destination, format: options.format },
1548
+ `Exported ${options.format} to ${destination}`,
1549
+ );
1550
+ } else {
1551
+ io.stdout(content);
1552
+ }
1553
+ },
1554
+ );
1555
+
1556
+ program
1557
+ .command('doctor')
1558
+ .description('Run safe diagnostics')
1559
+ .action(async (_options, command: Command) => {
1560
+ const global = globals(command);
1561
+ const result = await withClient(global.home, actor('system', 'cli'), (client) =>
1562
+ client.doctor(),
1563
+ );
1564
+ const human = result.diagnostics
1565
+ .map((item) => `${item.level.toUpperCase().padEnd(7)} ${item.code}: ${item.message}`)
1566
+ .join('\n');
1567
+ output(io, global.json, result, human);
1568
+ if (!result.healthy) cliExitCodes.set(program, 5);
1569
+ });
1570
+
1571
+ program
1572
+ .command('mcp')
1573
+ .description('Run the actor-bound MCP server over stdio')
1574
+ .requiredOption('--actor-id <id>')
1575
+ .requiredOption('--actor-kind <kind>', 'human, agent, or system')
1576
+ .option('--actor-name <display-name>')
1577
+ .action(
1578
+ async (
1579
+ options: { actorId: string; actorKind: string; actorName?: string },
1580
+ command: Command,
1581
+ ) => {
1582
+ const global = globals(command);
1583
+ await startMcpServer({
1584
+ ...(global.home ? { home: global.home } : {}),
1585
+ actor: actor(options.actorKind, options.actorId, options.actorName),
1586
+ });
1587
+ },
1588
+ );
1589
+
1590
+ return program;
1591
+ }
1592
+
1593
+ export async function runCli(
1594
+ argv = process.argv,
1595
+ io: CliIo = defaultIo,
1596
+ serviceFactory: SynomemServiceFactory = configuredServiceFactory,
1597
+ dependencies: CliDependencies = {},
1598
+ ): Promise<number> {
1599
+ const program = createCli(io, serviceFactory, dependencies);
1600
+ program.exitOverride();
1601
+ try {
1602
+ await program.parseAsync(argv);
1603
+ return cliExitCodes.get(program) ?? 0;
1604
+ } catch (error) {
1605
+ if (error instanceof CommanderError && error.exitCode === 0) return 0;
1606
+ if (error instanceof CommanderError) {
1607
+ if (!error.message.startsWith('error:')) io.stderr(`${error.message}\n`);
1608
+ return 2;
1609
+ }
1610
+ const kudosError = asSynomemError(error);
1611
+ const json = argv.includes('--json');
1612
+ io.stderr(
1613
+ json
1614
+ ? `${JSON.stringify({ ok: false, error: { code: kudosError.code, message: kudosError.message } })}\n`
1615
+ : `Error [${kudosError.code}]: ${kudosError.message}\n`,
1616
+ );
1617
+ return exitCode(kudosError.code);
1618
+ }
1619
+ }
1620
+
1621
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
1622
+ process.exitCode = await runCli();
1623
+ }