synomem 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +4 -4
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/backend.js +8 -2
  5. package/dist/backend.js.map +1 -1
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +280 -16
  9. package/dist/cli.js.map +1 -1
  10. package/dist/client.d.ts +32 -1
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +125 -10
  13. package/dist/client.js.map +1 -1
  14. package/dist/cloud.d.ts +16 -0
  15. package/dist/cloud.d.ts.map +1 -0
  16. package/dist/cloud.js +19 -0
  17. package/dist/cloud.js.map +1 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +8 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/configure.d.ts +55 -0
  22. package/dist/configure.d.ts.map +1 -0
  23. package/dist/configure.js +177 -0
  24. package/dist/configure.js.map +1 -0
  25. package/dist/credentials.d.ts +15 -2
  26. package/dist/credentials.d.ts.map +1 -1
  27. package/dist/credentials.js.map +1 -1
  28. package/dist/import.d.ts +58 -43
  29. package/dist/import.d.ts.map +1 -1
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/mcp/index.d.ts.map +1 -1
  35. package/dist/mcp/index.js +15 -6
  36. package/dist/mcp/index.js.map +1 -1
  37. package/dist/oauth.d.ts.map +1 -1
  38. package/dist/oauth.js +8 -1
  39. package/dist/oauth.js.map +1 -1
  40. package/dist/projections.js +4 -4
  41. package/dist/projections.js.map +1 -1
  42. package/dist/prompt.d.ts +28 -0
  43. package/dist/prompt.d.ts.map +1 -0
  44. package/dist/prompt.js +72 -0
  45. package/dist/prompt.js.map +1 -0
  46. package/dist/remote.d.ts +4 -0
  47. package/dist/remote.d.ts.map +1 -1
  48. package/dist/remote.js +4 -0
  49. package/dist/remote.js.map +1 -1
  50. package/dist/schemas.d.ts +101 -60
  51. package/dist/schemas.d.ts.map +1 -1
  52. package/dist/schemas.js +57 -5
  53. package/dist/schemas.js.map +1 -1
  54. package/dist/service.d.ts +4 -0
  55. package/dist/service.d.ts.map +1 -1
  56. package/dist/storage.d.ts +14 -1
  57. package/dist/storage.d.ts.map +1 -1
  58. package/dist/storage.js +55 -14
  59. package/dist/storage.js.map +1 -1
  60. package/dist/types.d.ts +24 -2
  61. package/dist/types.d.ts.map +1 -1
  62. package/docs/cli.md +1 -1
  63. package/docs/examples.md +1 -1
  64. package/docs/mcp.md +1 -1
  65. package/docs/skill.md +1 -1
  66. package/docs/storage-format.md +1 -1
  67. package/package.json +8 -8
  68. package/src/backend.ts +8 -2
  69. package/src/cli.ts +377 -19
  70. package/src/client.ts +135 -11
  71. package/src/cloud.ts +19 -0
  72. package/src/config.ts +8 -1
  73. package/src/configure.ts +233 -0
  74. package/src/credentials.ts +17 -2
  75. package/src/index.ts +7 -1
  76. package/src/mcp/index.ts +19 -5
  77. package/src/oauth.ts +8 -1
  78. package/src/projections.ts +4 -4
  79. package/src/prompt.ts +88 -0
  80. package/src/remote.ts +24 -0
  81. package/src/schemas.ts +60 -5
  82. package/src/service.ts +4 -0
  83. package/src/storage.ts +70 -13
  84. package/src/types.ts +24 -2
package/src/client.ts CHANGED
@@ -106,7 +106,11 @@ export interface SynomemCoreOptions {
106
106
  }
107
107
 
108
108
  export class SynomemCore implements SynomemDomainService {
109
- readonly actor: ActorIdentity;
109
+ /**
110
+ * Mutable because an agent actor is resolved to its canonical identity on
111
+ * init: callers name a handle, events record the opaque ID.
112
+ */
113
+ actor: ActorIdentity;
110
114
  private readonly repository: SynomemRepository;
111
115
  private readonly projectionWriter: ProjectionWriter;
112
116
  private readonly clock: () => Date;
@@ -121,6 +125,11 @@ export class SynomemCore implements SynomemDomainService {
121
125
  get: (idOrAlias: string) => this.getAgent(idOrAlias),
122
126
  list: () => this.listAgents(),
123
127
  resolve: (query: string) => this.resolveAgent(query),
128
+ archive: (idOrAlias: string) => this.setAgentStatus(idOrAlias, 'archived'),
129
+ restore: (idOrAlias: string) => this.setAgentStatus(idOrAlias, 'active'),
130
+ addAliases: (idOrAlias: string, aliases: string[]) => this.addAgentAliases(idOrAlias, aliases),
131
+ removeAliases: (idOrAlias: string, aliases: string[]) =>
132
+ this.removeAgentAliases(idOrAlias, aliases),
124
133
  directory: () => this.agentDirectory(),
125
134
  bindings: (idOrAlias: string) => this.listRuntimeBindings(idOrAlias),
126
135
  bindRuntime: (input: BindRuntimeInput) => this.bindRuntime(input),
@@ -271,6 +280,30 @@ export class SynomemCore implements SynomemDomainService {
271
280
  if (this.initialized) return;
272
281
  await this.repository.init();
273
282
  this.initialized = true;
283
+
284
+ /*
285
+ * An agent actor is resolved to its canonical identity here.
286
+ *
287
+ * Callers name a handle because that is what people and harnesses know,
288
+ * but every event must record the opaque ID — otherwise renaming a handle
289
+ * would orphan the history written under the old one. The display name
290
+ * comes from the profile for the same reason a harness cannot assert it on
291
+ * the command line: the stored record is the authority, not the argument.
292
+ *
293
+ * An unresolvable name is left as given rather than rejected, so a system
294
+ * actor can still create the agent that does not exist yet. Writing as an
295
+ * unknown agent is refused later by the checks that already exist.
296
+ */
297
+ if (this.actor.kind === 'agent') {
298
+ const resolved = await this.repository.resolveAgent(this.actor.id);
299
+ if (resolved.match) {
300
+ this.actor = {
301
+ kind: 'agent',
302
+ id: resolved.match.id,
303
+ ...(resolved.match.displayName ? { displayName: resolved.match.displayName } : {}),
304
+ };
305
+ }
306
+ }
274
307
  }
275
308
 
276
309
  async close(): Promise<void> {
@@ -314,20 +347,29 @@ export class SynomemCore implements SynomemDomainService {
314
347
  this.checkAbort();
315
348
  await this.repository.assertEventCompatibility();
316
349
  const parsed = this.validate(() => createAgentSchema.parse(input));
317
- if (await this.repository.getAgent(parsed.id)) {
318
- throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.id}`);
350
+ if (await this.repository.getAgent(parsed.handle)) {
351
+ throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.handle}`);
319
352
  }
320
353
  const aliases = [...new Set(parsed.aliases ?? [])].sort();
321
- if (aliases.includes(parsed.id)) {
322
- throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own ID as an alias.');
354
+ if (aliases.includes(parsed.handle)) {
355
+ throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
323
356
  }
324
357
  for (const alias of aliases) {
325
358
  if (await this.repository.getAgent(alias)) {
326
359
  throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to an agent: ${alias}`);
327
360
  }
328
361
  }
362
+ /*
363
+ * The canonical ID is generated here and never supplied by the caller.
364
+ * Every event references it permanently, so it has to be free of meaning:
365
+ * a caller that could choose it could choose one that collides with an
366
+ * archived agent's history, and a meaningful ID becomes a handle nobody can
367
+ * rename.
368
+ */
329
369
  const profile: AgentProfile = {
330
- id: parsed.id,
370
+ id: this.nextId(),
371
+ handle: parsed.handle,
372
+ status: 'active',
331
373
  displayName: parsed.displayName,
332
374
  ...(aliases.length ? { aliases } : {}),
333
375
  ...(parsed.description !== undefined ? { description: parsed.description } : {}),
@@ -355,8 +397,20 @@ export class SynomemCore implements SynomemDomainService {
355
397
  const existing = await this.repository.getAgent(idOrAlias);
356
398
  if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
357
399
  const aliases = parsed.aliases ? [...new Set(parsed.aliases)].sort() : existing.aliases;
358
- if (aliases?.includes(existing.id)) {
359
- throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own ID as an alias.');
400
+ const handle = parsed.handle ?? existing.handle;
401
+ if (aliases?.includes(handle)) {
402
+ throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
403
+ }
404
+ // Renaming the handle is allowed and is why the canonical ID exists, but a
405
+ // handle another agent already answers to is still refused.
406
+ if (parsed.handle && parsed.handle !== existing.handle) {
407
+ const owner = await this.repository.getAgent(parsed.handle);
408
+ if (owner && owner.id !== existing.id) {
409
+ throw new SynomemError(
410
+ 'ALIAS_CONFLICT',
411
+ `Handle already belongs to ${owner.id}: ${parsed.handle}`,
412
+ );
413
+ }
360
414
  }
361
415
  for (const alias of aliases ?? []) {
362
416
  const owner = await this.repository.getAgent(alias);
@@ -384,6 +438,52 @@ export class SynomemCore implements SynomemDomainService {
384
438
  return updated;
385
439
  }
386
440
 
441
+ /**
442
+ * Archiving stops an agent acting without erasing it.
443
+ *
444
+ * Events reference the actor permanently, so deleting an agent would leave
445
+ * history pointing at nothing. Archived agents keep their records and their
446
+ * handle, and can be restored.
447
+ */
448
+ private async setAgentStatus(
449
+ idOrAlias: string,
450
+ status: 'active' | 'archived',
451
+ ): Promise<AgentProfile> {
452
+ this.checkAbort();
453
+ await this.repository.assertEventCompatibility();
454
+ this.validate(() => agentLookupSchema.parse(idOrAlias));
455
+ const existing = await this.repository.getAgent(idOrAlias);
456
+ if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
457
+ if (existing.status === status) return existing;
458
+ const updated: AgentProfile = { ...existing, status };
459
+ await this.repository.transaction(async () => {
460
+ const event: SynomemEvent = {
461
+ ...this.eventBase(existing.id, await this.repository.nextAggregateVersion(existing.id)),
462
+ type: 'agent.updated',
463
+ agentId: existing.id,
464
+ changes: { status },
465
+ };
466
+ await this.repository.updateAgent(updated, event.createdAt);
467
+ await this.repository.insertEvent(event);
468
+ });
469
+ await this.projectionWriter.syncAgent(updated.id);
470
+ return updated;
471
+ }
472
+
473
+ /** Adds aliases without disturbing the ones already there. */
474
+ private async addAgentAliases(idOrAlias: string, add: string[]): Promise<AgentProfile> {
475
+ const existing = await this.getAgent(idOrAlias);
476
+ const merged = [...new Set([...(existing.aliases ?? []), ...add])].sort();
477
+ return await this.updateAgent(existing.id, { aliases: merged });
478
+ }
479
+
480
+ private async removeAgentAliases(idOrAlias: string, remove: string[]): Promise<AgentProfile> {
481
+ const existing = await this.getAgent(idOrAlias);
482
+ const drop = new Set(remove.map((alias) => alias.trim().toLowerCase()));
483
+ const kept = (existing.aliases ?? []).filter((alias) => !drop.has(alias));
484
+ return await this.updateAgent(existing.id, { aliases: kept });
485
+ }
486
+
387
487
  private async getAgent(idOrAlias: string): Promise<AgentProfile> {
388
488
  this.checkAbort();
389
489
  this.validate(() => agentLookupSchema.parse(idOrAlias));
@@ -1441,10 +1541,34 @@ export class SynomemCore implements SynomemDomainService {
1441
1541
  return await this.getTodoRecord(input.todoId);
1442
1542
  }
1443
1543
 
1544
+ /**
1545
+ * Turns an agent name in a filter into the canonical ID the records hold.
1546
+ *
1547
+ * Callers filter by the name they know — a handle or an alias — while every
1548
+ * record stores the opaque ID. Without this the filter silently matches
1549
+ * nothing, which reads as "there is nothing here" rather than "that name
1550
+ * means something else now".
1551
+ *
1552
+ * An unresolvable name is passed through unchanged so it can match a legacy
1553
+ * name-shaped ID rather than being swallowed.
1554
+ */
1555
+ private async canonicalAgentId(name: string | undefined): Promise<string | undefined> {
1556
+ if (!name) return name;
1557
+ const resolved = await this.repository.resolveAgent(name);
1558
+ return resolved.match?.id ?? name;
1559
+ }
1560
+
1444
1561
  private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
1445
1562
  this.checkAbort();
1446
1563
  const parsed = this.validate(() => itemListInputSchema.parse(input));
1447
- return await this.repository.listItemSummaries(parsed, this.actor);
1564
+ const resolved = {
1565
+ ...parsed,
1566
+ ...(parsed.participantAgentId
1567
+ ? { participantAgentId: await this.canonicalAgentId(parsed.participantAgentId) }
1568
+ : {}),
1569
+ ...(parsed.actorId ? { actorId: await this.canonicalAgentId(parsed.actorId) } : {}),
1570
+ };
1571
+ return await this.repository.listItemSummaries(resolved, this.actor);
1448
1572
  }
1449
1573
  private async listItemChanges(input: ChangesInput): Promise<ChangePage> {
1450
1574
  this.checkAbort();
@@ -1539,8 +1663,8 @@ export class SynomemCore implements SynomemDomainService {
1539
1663
  * check that silently lags the migration runner reports a healthy database as
1540
1664
  * broken.
1541
1665
  */
1542
- const CURRENT_SCHEMA_VERSION = 6;
1543
- const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6];
1666
+ const CURRENT_SCHEMA_VERSION = 7;
1667
+ const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7];
1544
1668
 
1545
1669
  export class SynomemClient extends SynomemCore implements SynomemService {
1546
1670
  readonly home: string;
package/src/cloud.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Synomem Cloud, as a constant rather than a question.
3
+ *
4
+ * Public onboarding must never ask for a service URL. A person setting up
5
+ * Synomem has no way to know whether an address they were given is the real
6
+ * one, and a prompt that accepts any origin is a prompt that can be phished.
7
+ * The hosted service therefore has one address, compiled in.
8
+ *
9
+ * `SYNOMEM_API_URL` remains for development and private deployments. It is
10
+ * deliberately undocumented in the README, the public docs, the packaged skill
11
+ * and ordinary help output — a private deployment is configured by whoever runs
12
+ * it, not discovered by an ordinary user.
13
+ */
14
+ export const SYNOMEM_CLOUD_API_URL = 'https://api.synomem.ai';
15
+
16
+ export function cloudApiUrl(env: NodeJS.ProcessEnv = process.env): string {
17
+ const override = env.SYNOMEM_API_URL?.trim();
18
+ return override || SYNOMEM_CLOUD_API_URL;
19
+ }
package/src/config.ts CHANGED
@@ -73,7 +73,14 @@ export const configSchema = policySchema.extend({
73
73
  });
74
74
 
75
75
  export function resolveHome(explicitHome?: string): string {
76
- const candidate = explicitHome ?? process.env.SYNOMEM_HOME ?? resolve(homedir(), '.agents');
76
+ /*
77
+ * `~/.synomem`, resolved as an exact directory.
78
+ *
79
+ * No detection of or migration from `~/.agents`: the project is greenfield,
80
+ * and code that quietly moves somebody's database is worse than a clear
81
+ * message telling them where the new home is.
82
+ */
83
+ const candidate = explicitHome ?? process.env.SYNOMEM_HOME ?? resolve(homedir(), '.synomem');
77
84
  if (candidate.includes('\0')) throw new SynomemError('UNSAFE_PATH', 'Storage home contains NUL.');
78
85
  return resolve(candidate);
79
86
  }
@@ -0,0 +1,233 @@
1
+ /**
2
+ * `synomem config` — the onboarding wizard, and its deterministic equivalent.
3
+ *
4
+ * Two rules shape this file. Every interactive step has a non-interactive
5
+ * counterpart, so an agent can configure a machine without a terminal. And the
6
+ * wizard refuses to run at all when nobody is there to answer: a setup program
7
+ * that blocks forever on a pipe is worse than one that says which flags it
8
+ * needs.
9
+ */
10
+ import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { cloudApiUrl } from './cloud.js';
13
+ import { SynomemError } from './errors.js';
14
+ import { resolveHome } from './config.js';
15
+ import { ask, askSecret, confirm, select, type PromptIo } from './prompt.js';
16
+
17
+ export type BackendChoice = 'local' | 'remote';
18
+ export type AuthChoice = 'browser' | 'access-key';
19
+ export type CredentialStoreChoice = 'auto' | 'keychain' | 'file' | 'environment';
20
+
21
+ export interface ConfigInitOptions {
22
+ backend?: BackendChoice;
23
+ home?: string;
24
+ auth?: AuthChoice;
25
+ workspace?: string;
26
+ accessToken?: string;
27
+ credentialStore?: CredentialStoreChoice;
28
+ yes?: boolean;
29
+ }
30
+
31
+ export interface ConfigPlan {
32
+ backend: BackendChoice;
33
+ home: string;
34
+ serviceUrl?: string;
35
+ auth?: AuthChoice;
36
+ workspaceId?: string;
37
+ credentialStore?: CredentialStoreChoice;
38
+ }
39
+
40
+ /**
41
+ * Where a credential can actually be kept on this platform.
42
+ *
43
+ * Reported rather than assumed: offering macOS Keychain on Linux, or a Secret
44
+ * Service that is not running, produces a setup that appears to succeed and
45
+ * then cannot read its own credential back.
46
+ */
47
+ export function credentialStoreChoices(
48
+ platform: NodeJS.Platform = process.platform,
49
+ ): Array<{ value: CredentialStoreChoice; label: string; detail?: string }> {
50
+ const native =
51
+ platform === 'darwin'
52
+ ? { value: 'keychain' as const, label: 'macOS Keychain', detail: 'Recommended.' }
53
+ : platform === 'win32'
54
+ ? {
55
+ value: 'keychain' as const,
56
+ label: 'Windows Credential Manager',
57
+ detail: 'Recommended.',
58
+ }
59
+ : {
60
+ value: 'keychain' as const,
61
+ label: 'Secret Service (libsecret)',
62
+ detail: 'Recommended where a desktop keyring is running.',
63
+ };
64
+ return [
65
+ native,
66
+ {
67
+ value: 'file',
68
+ label: 'A restricted file in the Synomem home',
69
+ detail: 'Mode 0600. Use on headless machines with no keyring.',
70
+ },
71
+ {
72
+ value: 'environment',
73
+ label: 'Print environment-variable instructions',
74
+ detail: 'Nothing is stored. Synomem never edits your shell profile.',
75
+ },
76
+ ];
77
+ }
78
+
79
+ /** Refuses to guess when there is nobody to ask. */
80
+ export function assertInteractive(io: PromptIo): void {
81
+ if (io.interactive) return;
82
+ throw new SynomemError(
83
+ 'INVALID_INPUT',
84
+ [
85
+ 'synomem config needs an interactive terminal.',
86
+ '',
87
+ 'For automation, use the deterministic form instead:',
88
+ '',
89
+ ' synomem config init --backend local --yes',
90
+ '',
91
+ ' synomem config init --backend remote --auth access-key \\',
92
+ ' --workspace <workspace-id> --access-token-stdin --yes',
93
+ '',
94
+ 'Pipe the token in rather than passing it as an argument: an argument is',
95
+ 'kept by both the shell history and the process list.',
96
+ ].join('\n'),
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Stores an access key in a restricted file.
102
+ *
103
+ * Separate from `config.json` so a configuration file can be read, copied or
104
+ * pasted into an issue without carrying a secret with it.
105
+ */
106
+ export function writeCredentialFile(home: string, token: string): string {
107
+ const directory = join(home, 'credentials');
108
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
109
+ chmodSync(directory, 0o700);
110
+ const path = join(directory, 'installation.json');
111
+ writeFileSync(path, `${JSON.stringify({ accessToken: token }, null, 2)}\n`, { mode: 0o600 });
112
+ chmodSync(path, 0o600);
113
+ return path;
114
+ }
115
+
116
+ /** A key's identifying prefix. Never the key. */
117
+ export function credentialFingerprint(token: string): string {
118
+ const head = token.slice(0, 12);
119
+ return `${head}${token.length > 12 ? '\u2026' : ''}`;
120
+ }
121
+
122
+ export function environmentInstructions(token: string): string {
123
+ return [
124
+ 'Set this value for the current shell:',
125
+ '',
126
+ ` export SYNOMEM_ACCESS_TOKEN='${token}'`,
127
+ '',
128
+ 'To persist it, add that to a secret-aware shell configuration or to your',
129
+ 'agent runtime environment. Synomem will not edit your shell profile for',
130
+ 'you: silently rewriting a dotfile is not a thing a setup program should do.',
131
+ ].join('\n');
132
+ }
133
+
134
+ /** The interactive flow, returning the plan it settled on. */
135
+ export async function runConfigWizard(
136
+ io: PromptIo,
137
+ options: { home?: string; env?: NodeJS.ProcessEnv } = {},
138
+ ): Promise<ConfigPlan> {
139
+ assertInteractive(io);
140
+ const env = options.env ?? process.env;
141
+
142
+ io.output.write(
143
+ [
144
+ '',
145
+ 'Welcome to Synomem',
146
+ '',
147
+ 'Synomem gives agents durable notes, messages, tasks, todos, kudos,',
148
+ 'and shared coordination.',
149
+ '',
150
+ ].join('\n'),
151
+ );
152
+
153
+ const backend = await select<BackendChoice>(io, 'Where should Synomem store canonical state?', [
154
+ {
155
+ value: 'local',
156
+ label: 'Local \u2014 SQLite on this machine',
157
+ detail: 'Nothing is uploaded. One implicit workspace.',
158
+ },
159
+ {
160
+ value: 'remote',
161
+ label: 'Synomem Cloud \u2014 shared across machines and agents',
162
+ detail: 'Organizations, workspaces, roles and administration.',
163
+ },
164
+ ]);
165
+
166
+ const home = await ask(io, 'Where should Synomem store its data?', resolveHome(options.home));
167
+
168
+ if (backend === 'local') {
169
+ return { backend, home };
170
+ }
171
+
172
+ const serviceUrl = cloudApiUrl(env);
173
+ io.output.write(`\nConnecting to Synomem Cloud at ${serviceUrl}\n`);
174
+
175
+ const auth = await select<AuthChoice>(io, 'How would you like to sign in?', [
176
+ {
177
+ value: 'browser',
178
+ label: 'Sign in with your browser',
179
+ detail: 'Opens the authorization server and returns through a loopback callback.',
180
+ },
181
+ {
182
+ value: 'access-key',
183
+ label: 'Use an installation access key',
184
+ detail: 'Create one at https://portal.synomem.ai/installations',
185
+ },
186
+ ]);
187
+
188
+ const workspaceId = await ask(io, 'Workspace ID');
189
+ if (!workspaceId) {
190
+ throw new SynomemError('INVALID_INPUT', 'A workspace ID is required for the hosted backend.');
191
+ }
192
+
193
+ const credentialStore =
194
+ auth === 'access-key'
195
+ ? await select<CredentialStoreChoice>(
196
+ io,
197
+ 'Where should Synomem store this credential?',
198
+ credentialStoreChoices(),
199
+ )
200
+ : 'auto';
201
+
202
+ return { backend, home, serviceUrl, auth, workspaceId, credentialStore };
203
+ }
204
+
205
+ /** Reads an access key without ever accepting it as an argument. */
206
+ export async function readAccessToken(io: PromptIo): Promise<string> {
207
+ const token = await askSecret(io, 'Installation access key');
208
+ if (!token) throw new SynomemError('INVALID_INPUT', 'No access key was provided.');
209
+ return token;
210
+ }
211
+
212
+ export async function confirmPlan(io: PromptIo, plan: ConfigPlan): Promise<boolean> {
213
+ io.output.write(
214
+ [
215
+ '',
216
+ 'Synomem will be configured as:',
217
+ '',
218
+ ` Backend: ${plan.backend === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
219
+ ` Home: ${plan.home}`,
220
+ ...(plan.serviceUrl ? [` Service: ${plan.serviceUrl}`] : []),
221
+ ...(plan.workspaceId ? [` Workspace: ${plan.workspaceId}`] : []),
222
+ ...(plan.credentialStore && plan.credentialStore !== 'auto'
223
+ ? [` Credential: ${plan.credentialStore}`]
224
+ : []),
225
+ '',
226
+ ].join('\n'),
227
+ );
228
+ return await confirm(io, 'Apply this?');
229
+ }
230
+
231
+ export function homeExists(home: string): boolean {
232
+ return existsSync(home);
233
+ }
@@ -6,6 +6,21 @@ import type { ActorIdentity } from './types.js';
6
6
  const serviceName = 'ai.synomem.credentials';
7
7
  const maximumOutputBytes = 128 * 1024;
8
8
 
9
+ /**
10
+ * An installation access key.
11
+ *
12
+ * Not an OAuth credential: it has no refresh, no token endpoint and no client,
13
+ * and it authorizes a MACHINE rather than a person. Keeping it a distinct shape
14
+ * stops code treating it as refreshable, which would mean silently failing to
15
+ * renew something that never expires that way.
16
+ */
17
+ export interface StoredInstallationKey {
18
+ kind: 'installation-key';
19
+ accessToken: string;
20
+ }
21
+
22
+ export type StoredCredential = StoredOAuthCredential | StoredInstallationKey;
23
+
9
24
  export interface StoredOAuthCredential {
10
25
  accessToken: string;
11
26
  refreshToken?: string;
@@ -17,8 +32,8 @@ export interface StoredOAuthCredential {
17
32
  }
18
33
 
19
34
  export interface CredentialStore {
20
- get(reference: string): Promise<StoredOAuthCredential | undefined>;
21
- set(reference: string, credential: StoredOAuthCredential): Promise<void>;
35
+ get(reference: string): Promise<StoredCredential | undefined>;
36
+ set(reference: string, credential: StoredCredential): Promise<void>;
22
37
  delete(reference: string): Promise<boolean>;
23
38
  }
24
39
 
package/src/index.ts CHANGED
@@ -11,7 +11,12 @@ export {
11
11
  export { RemoteSynomemService, environmentCredentialProvider } from './remote.js';
12
12
  export type { SynomemCredentialProvider, RemoteSynomemOptions } from './remote.js';
13
13
  export { credentialReference, OsCredentialStore } from './credentials.js';
14
- export type { CredentialStore, StoredOAuthCredential } from './credentials.js';
14
+ export type {
15
+ CredentialStore,
16
+ StoredCredential,
17
+ StoredInstallationKey,
18
+ StoredOAuthCredential,
19
+ } from './credentials.js';
15
20
  export { loginWithOAuth, StoredCredentialProvider } from './oauth.js';
16
21
  export type { OAuthLoginOptions } from './oauth.js';
17
22
  export {
@@ -35,6 +40,7 @@ export type {
35
40
  ProjectionRebuildResult,
36
41
  } from './service.js';
37
42
  export { defaultConfig, resolveHome } from './config.js';
43
+ export { cloudApiUrl, SYNOMEM_CLOUD_API_URL } from './cloud.js';
38
44
  export { asSynomemError, errorCodes, SynomemError } from './errors.js';
39
45
  export {
40
46
  dueInstant,
package/src/mcp/index.ts CHANGED
@@ -6,6 +6,7 @@ import { configuredServiceFactory } from '../backend.js';
6
6
  import { SynomemError, asSynomemError } from '../errors.js';
7
7
  import {
8
8
  actorSchema,
9
+ agentHandleSchema,
9
10
  agentIdSchema,
10
11
  changesInputSchema,
11
12
  createNoteSchema,
@@ -88,9 +89,18 @@ export async function createSynomemMcpServer(
88
89
  options: SynomemMcpOptions,
89
90
  serviceFactory: SynomemServiceFactory = configuredServiceFactory,
90
91
  ): Promise<SynomemMcpRuntime> {
91
- const actor = actorSchema.parse(options.actor);
92
- const client = serviceFactory({ ...options, actor });
92
+ const requested = actorSchema.parse(options.actor);
93
+ const client = serviceFactory({ ...options, actor: requested });
93
94
  await client.init();
95
+ /*
96
+ * Every tool reports the CANONICAL actor, not the one that was asked for.
97
+ *
98
+ * A harness registers with a handle because that is what a person typed, but
99
+ * init resolves it against stored state — so the identity echoed back is the
100
+ * one the events will actually carry. Reporting the requested name would let
101
+ * a misconfigured runtime appear to be acting as somebody it is not.
102
+ */
103
+ const actor = client.actor;
94
104
  const server = new McpServer(
95
105
  { name: 'synomem', version: packageVersion() },
96
106
  {
@@ -282,9 +292,9 @@ export async function createSynomemMcpServer(
282
292
  description:
283
293
  'Administrative tool for creating a stable agent identity. Disabled by default so runtime agents cannot silently create identities.',
284
294
  inputSchema: z.object({
285
- id: agentIdSchema,
295
+ handle: agentHandleSchema,
286
296
  displayName: z.string().trim().min(1).max(200),
287
- aliases: z.array(agentIdSchema).max(50).optional(),
297
+ aliases: z.array(agentHandleSchema).max(50).optional(),
288
298
  description: z.string().trim().max(2000).optional(),
289
299
  }),
290
300
  outputSchema,
@@ -300,7 +310,11 @@ export async function createSynomemMcpServer(
300
310
  );
301
311
  }
302
312
  const profile = await client.agents.create(input);
303
- return success(actor, `Created agent ${profile.displayName} (${profile.id}).`, { profile });
313
+ return success(
314
+ actor,
315
+ `Created agent ${profile.displayName}: handle ${profile.handle}, ID ${profile.id}.`,
316
+ { profile },
317
+ );
304
318
  } catch (error) {
305
319
  return failure(actor, error);
306
320
  }
package/src/oauth.ts CHANGED
@@ -207,7 +207,14 @@ export class StoredCredentialProvider implements SynomemCredentialProvider {
207
207
  async getAccessToken(signal?: AbortSignal): Promise<string | undefined> {
208
208
  if (this.env.SYNOMEM_ACCESS_TOKEN) return this.env.SYNOMEM_ACCESS_TOKEN;
209
209
  if (!this.loaded) {
210
- this.credential = await this.store.get(this.reference);
210
+ const stored = await this.store.get(this.reference);
211
+ /*
212
+ * An installation key is not an OAuth credential: it cannot be refreshed
213
+ * and has no client or token endpoint. Treating one as OAuth would mean
214
+ * trying to renew something that never renews that way, so this path
215
+ * ignores it and lets the installation-key path handle it.
216
+ */
217
+ this.credential = stored && 'kind' in stored ? undefined : stored;
211
218
  this.loaded = true;
212
219
  }
213
220
  const credential = this.credential;
@@ -550,7 +550,7 @@ export class ProjectionManager implements ProjectionWriter {
550
550
  const generated: string[] = [];
551
551
 
552
552
  for (const profile of profiles) {
553
- const agentDirectory = join(this.storage.home, profile.id);
553
+ const agentDirectory = join(this.storage.home, profile.handle);
554
554
  assertNoSymlinkEscape(this.storage.home, agentDirectory);
555
555
  ensureDirectory(agentDirectory);
556
556
  const profilePath = join(agentDirectory, 'profile.json');
@@ -656,7 +656,7 @@ export class ProjectionManager implements ProjectionWriter {
656
656
  (record) => record.event.assigneeAgentId === profile.id,
657
657
  );
658
658
  const rebuiltAt = events.at(-1)?.createdAt ?? new Date(0).toISOString();
659
- const agentDirectory = join(this.storage.home, profile.id);
659
+ const agentDirectory = join(this.storage.home, profile.handle);
660
660
  const inboxDirectory = join(agentDirectory, 'inbox');
661
661
  const kudosInboxDirectory = join(inboxDirectory, 'kudos');
662
662
  const memoInboxDirectory = join(inboxDirectory, 'memos');
@@ -729,7 +729,7 @@ export class ProjectionManager implements ProjectionWriter {
729
729
  }
730
730
 
731
731
  const keep = new Set(generated);
732
- const prefixes = [`${profile.id}/`, `${profile.id}\\`];
732
+ const prefixes = [`${profile.handle}/`, `${profile.handle}\\`];
733
733
  const removed: string[] = [];
734
734
  for (const stale of this.storage
735
735
  .projectionManifest()
@@ -745,7 +745,7 @@ export class ProjectionManager implements ProjectionWriter {
745
745
  unlinkSync(path);
746
746
  removed.push(stale);
747
747
  }
748
- this.storage.replaceAgentProjectionManifest(profile.id, generated, rebuiltAt);
748
+ this.storage.replaceAgentProjectionManifest(profile.handle, generated, rebuiltAt);
749
749
  return { generated: generated.sort(), removed: removed.sort() };
750
750
  }
751
751