synomem 0.3.0 → 0.5.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 (91) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/README.md +54 -14
  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 +9 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +451 -24
  9. package/dist/cli.js.map +1 -1
  10. package/dist/client.d.ts +34 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +162 -11
  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 +193 -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 +9 -1
  28. package/dist/credentials.js.map +1 -1
  29. package/dist/discover.d.ts +48 -0
  30. package/dist/discover.d.ts.map +1 -0
  31. package/dist/discover.js +106 -0
  32. package/dist/discover.js.map +1 -0
  33. package/dist/import.d.ts +58 -43
  34. package/dist/import.d.ts.map +1 -1
  35. package/dist/index.d.ts +4 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +2 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/mcp/index.d.ts.map +1 -1
  40. package/dist/mcp/index.js +15 -6
  41. package/dist/mcp/index.js.map +1 -1
  42. package/dist/oauth.d.ts.map +1 -1
  43. package/dist/oauth.js +8 -1
  44. package/dist/oauth.js.map +1 -1
  45. package/dist/projections.d.ts.map +1 -1
  46. package/dist/projections.js +19 -11
  47. package/dist/projections.js.map +1 -1
  48. package/dist/prompt.d.ts +28 -0
  49. package/dist/prompt.d.ts.map +1 -0
  50. package/dist/prompt.js +72 -0
  51. package/dist/prompt.js.map +1 -0
  52. package/dist/remote.d.ts +4 -0
  53. package/dist/remote.d.ts.map +1 -1
  54. package/dist/remote.js +4 -0
  55. package/dist/remote.js.map +1 -1
  56. package/dist/schemas.d.ts +101 -60
  57. package/dist/schemas.d.ts.map +1 -1
  58. package/dist/schemas.js +57 -5
  59. package/dist/schemas.js.map +1 -1
  60. package/dist/service.d.ts +6 -1
  61. package/dist/service.d.ts.map +1 -1
  62. package/dist/storage.d.ts +19 -1
  63. package/dist/storage.d.ts.map +1 -1
  64. package/dist/storage.js +61 -14
  65. package/dist/storage.js.map +1 -1
  66. package/dist/types.d.ts +50 -2
  67. package/dist/types.d.ts.map +1 -1
  68. package/docs/cli.md +68 -4
  69. package/docs/examples.md +1 -1
  70. package/docs/mcp.md +1 -1
  71. package/docs/skill.md +1 -1
  72. package/docs/storage-format.md +1 -1
  73. package/package.json +8 -8
  74. package/src/backend.ts +8 -2
  75. package/src/cli.ts +597 -31
  76. package/src/client.ts +176 -12
  77. package/src/cloud.ts +19 -0
  78. package/src/config.ts +8 -1
  79. package/src/configure.ts +249 -0
  80. package/src/credentials.ts +26 -3
  81. package/src/discover.ts +155 -0
  82. package/src/index.ts +9 -1
  83. package/src/mcp/index.ts +19 -5
  84. package/src/oauth.ts +8 -1
  85. package/src/projections.ts +21 -11
  86. package/src/prompt.ts +88 -0
  87. package/src/remote.ts +24 -0
  88. package/src/schemas.ts +60 -5
  89. package/src/service.ts +12 -0
  90. package/src/storage.ts +79 -13
  91. package/src/types.ts +45 -2
package/src/client.ts CHANGED
@@ -58,6 +58,7 @@ import type {
58
58
  UpdatePostInput,
59
59
  Diagnostic,
60
60
  DoctorResult,
61
+ ProjectionStatus,
61
62
  GiveKudosInput,
62
63
  GiveKudosResult,
63
64
  SendMemoInput,
@@ -106,7 +107,11 @@ export interface SynomemCoreOptions {
106
107
  }
107
108
 
108
109
  export class SynomemCore implements SynomemDomainService {
109
- readonly actor: ActorIdentity;
110
+ /**
111
+ * Mutable because an agent actor is resolved to its canonical identity on
112
+ * init: callers name a handle, events record the opaque ID.
113
+ */
114
+ actor: ActorIdentity;
110
115
  private readonly repository: SynomemRepository;
111
116
  private readonly projectionWriter: ProjectionWriter;
112
117
  private readonly clock: () => Date;
@@ -121,6 +126,11 @@ export class SynomemCore implements SynomemDomainService {
121
126
  get: (idOrAlias: string) => this.getAgent(idOrAlias),
122
127
  list: () => this.listAgents(),
123
128
  resolve: (query: string) => this.resolveAgent(query),
129
+ archive: (idOrAlias: string) => this.setAgentStatus(idOrAlias, 'archived'),
130
+ restore: (idOrAlias: string) => this.setAgentStatus(idOrAlias, 'active'),
131
+ addAliases: (idOrAlias: string, aliases: string[]) => this.addAgentAliases(idOrAlias, aliases),
132
+ removeAliases: (idOrAlias: string, aliases: string[]) =>
133
+ this.removeAgentAliases(idOrAlias, aliases),
124
134
  directory: () => this.agentDirectory(),
125
135
  bindings: (idOrAlias: string) => this.listRuntimeBindings(idOrAlias),
126
136
  bindRuntime: (input: BindRuntimeInput) => this.bindRuntime(input),
@@ -271,6 +281,30 @@ export class SynomemCore implements SynomemDomainService {
271
281
  if (this.initialized) return;
272
282
  await this.repository.init();
273
283
  this.initialized = true;
284
+
285
+ /*
286
+ * An agent actor is resolved to its canonical identity here.
287
+ *
288
+ * Callers name a handle because that is what people and harnesses know,
289
+ * but every event must record the opaque ID — otherwise renaming a handle
290
+ * would orphan the history written under the old one. The display name
291
+ * comes from the profile for the same reason a harness cannot assert it on
292
+ * the command line: the stored record is the authority, not the argument.
293
+ *
294
+ * An unresolvable name is left as given rather than rejected, so a system
295
+ * actor can still create the agent that does not exist yet. Writing as an
296
+ * unknown agent is refused later by the checks that already exist.
297
+ */
298
+ if (this.actor.kind === 'agent') {
299
+ const resolved = await this.repository.resolveAgent(this.actor.id);
300
+ if (resolved.match) {
301
+ this.actor = {
302
+ kind: 'agent',
303
+ id: resolved.match.id,
304
+ ...(resolved.match.displayName ? { displayName: resolved.match.displayName } : {}),
305
+ };
306
+ }
307
+ }
274
308
  }
275
309
 
276
310
  async close(): Promise<void> {
@@ -314,20 +348,29 @@ export class SynomemCore implements SynomemDomainService {
314
348
  this.checkAbort();
315
349
  await this.repository.assertEventCompatibility();
316
350
  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}`);
351
+ if (await this.repository.getAgent(parsed.handle)) {
352
+ throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.handle}`);
319
353
  }
320
354
  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.');
355
+ if (aliases.includes(parsed.handle)) {
356
+ throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
323
357
  }
324
358
  for (const alias of aliases) {
325
359
  if (await this.repository.getAgent(alias)) {
326
360
  throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to an agent: ${alias}`);
327
361
  }
328
362
  }
363
+ /*
364
+ * The canonical ID is generated here and never supplied by the caller.
365
+ * Every event references it permanently, so it has to be free of meaning:
366
+ * a caller that could choose it could choose one that collides with an
367
+ * archived agent's history, and a meaningful ID becomes a handle nobody can
368
+ * rename.
369
+ */
329
370
  const profile: AgentProfile = {
330
- id: parsed.id,
371
+ id: this.nextId(),
372
+ handle: parsed.handle,
373
+ status: 'active',
331
374
  displayName: parsed.displayName,
332
375
  ...(aliases.length ? { aliases } : {}),
333
376
  ...(parsed.description !== undefined ? { description: parsed.description } : {}),
@@ -355,8 +398,20 @@ export class SynomemCore implements SynomemDomainService {
355
398
  const existing = await this.repository.getAgent(idOrAlias);
356
399
  if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
357
400
  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.');
401
+ const handle = parsed.handle ?? existing.handle;
402
+ if (aliases?.includes(handle)) {
403
+ throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
404
+ }
405
+ // Renaming the handle is allowed and is why the canonical ID exists, but a
406
+ // handle another agent already answers to is still refused.
407
+ if (parsed.handle && parsed.handle !== existing.handle) {
408
+ const owner = await this.repository.getAgent(parsed.handle);
409
+ if (owner && owner.id !== existing.id) {
410
+ throw new SynomemError(
411
+ 'ALIAS_CONFLICT',
412
+ `Handle already belongs to ${owner.id}: ${parsed.handle}`,
413
+ );
414
+ }
360
415
  }
361
416
  for (const alias of aliases ?? []) {
362
417
  const owner = await this.repository.getAgent(alias);
@@ -384,6 +439,52 @@ export class SynomemCore implements SynomemDomainService {
384
439
  return updated;
385
440
  }
386
441
 
442
+ /**
443
+ * Archiving stops an agent acting without erasing it.
444
+ *
445
+ * Events reference the actor permanently, so deleting an agent would leave
446
+ * history pointing at nothing. Archived agents keep their records and their
447
+ * handle, and can be restored.
448
+ */
449
+ private async setAgentStatus(
450
+ idOrAlias: string,
451
+ status: 'active' | 'archived',
452
+ ): Promise<AgentProfile> {
453
+ this.checkAbort();
454
+ await this.repository.assertEventCompatibility();
455
+ this.validate(() => agentLookupSchema.parse(idOrAlias));
456
+ const existing = await this.repository.getAgent(idOrAlias);
457
+ if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
458
+ if (existing.status === status) return existing;
459
+ const updated: AgentProfile = { ...existing, status };
460
+ await this.repository.transaction(async () => {
461
+ const event: SynomemEvent = {
462
+ ...this.eventBase(existing.id, await this.repository.nextAggregateVersion(existing.id)),
463
+ type: 'agent.updated',
464
+ agentId: existing.id,
465
+ changes: { status },
466
+ };
467
+ await this.repository.updateAgent(updated, event.createdAt);
468
+ await this.repository.insertEvent(event);
469
+ });
470
+ await this.projectionWriter.syncAgent(updated.id);
471
+ return updated;
472
+ }
473
+
474
+ /** Adds aliases without disturbing the ones already there. */
475
+ private async addAgentAliases(idOrAlias: string, add: string[]): Promise<AgentProfile> {
476
+ const existing = await this.getAgent(idOrAlias);
477
+ const merged = [...new Set([...(existing.aliases ?? []), ...add])].sort();
478
+ return await this.updateAgent(existing.id, { aliases: merged });
479
+ }
480
+
481
+ private async removeAgentAliases(idOrAlias: string, remove: string[]): Promise<AgentProfile> {
482
+ const existing = await this.getAgent(idOrAlias);
483
+ const drop = new Set(remove.map((alias) => alias.trim().toLowerCase()));
484
+ const kept = (existing.aliases ?? []).filter((alias) => !drop.has(alias));
485
+ return await this.updateAgent(existing.id, { aliases: kept });
486
+ }
487
+
387
488
  private async getAgent(idOrAlias: string): Promise<AgentProfile> {
388
489
  this.checkAbort();
389
490
  this.validate(() => agentLookupSchema.parse(idOrAlias));
@@ -1441,10 +1542,34 @@ export class SynomemCore implements SynomemDomainService {
1441
1542
  return await this.getTodoRecord(input.todoId);
1442
1543
  }
1443
1544
 
1545
+ /**
1546
+ * Turns an agent name in a filter into the canonical ID the records hold.
1547
+ *
1548
+ * Callers filter by the name they know — a handle or an alias — while every
1549
+ * record stores the opaque ID. Without this the filter silently matches
1550
+ * nothing, which reads as "there is nothing here" rather than "that name
1551
+ * means something else now".
1552
+ *
1553
+ * An unresolvable name is passed through unchanged so it can match a legacy
1554
+ * name-shaped ID rather than being swallowed.
1555
+ */
1556
+ private async canonicalAgentId(name: string | undefined): Promise<string | undefined> {
1557
+ if (!name) return name;
1558
+ const resolved = await this.repository.resolveAgent(name);
1559
+ return resolved.match?.id ?? name;
1560
+ }
1561
+
1444
1562
  private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
1445
1563
  this.checkAbort();
1446
1564
  const parsed = this.validate(() => itemListInputSchema.parse(input));
1447
- return await this.repository.listItemSummaries(parsed, this.actor);
1565
+ const resolved = {
1566
+ ...parsed,
1567
+ ...(parsed.participantAgentId
1568
+ ? { participantAgentId: await this.canonicalAgentId(parsed.participantAgentId) }
1569
+ : {}),
1570
+ ...(parsed.actorId ? { actorId: await this.canonicalAgentId(parsed.actorId) } : {}),
1571
+ };
1572
+ return await this.repository.listItemSummaries(resolved, this.actor);
1448
1573
  }
1449
1574
  private async listItemChanges(input: ChangesInput): Promise<ChangePage> {
1450
1575
  this.checkAbort();
@@ -1539,8 +1664,8 @@ export class SynomemCore implements SynomemDomainService {
1539
1664
  * check that silently lags the migration runner reports a healthy database as
1540
1665
  * broken.
1541
1666
  */
1542
- const CURRENT_SCHEMA_VERSION = 6;
1543
- const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6];
1667
+ const CURRENT_SCHEMA_VERSION = 7;
1668
+ const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7];
1544
1669
 
1545
1670
  export class SynomemClient extends SynomemCore implements SynomemService {
1546
1671
  readonly home: string;
@@ -1568,6 +1693,41 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1568
1693
  this.projections = projections;
1569
1694
  }
1570
1695
 
1696
+ /*
1697
+ * Answers "would a rebuild change anything, and when did one last run?"
1698
+ *
1699
+ * The comparison is against the manifest rather than a directory walk, so a
1700
+ * file a person dropped into the projection tree by hand is not reported as
1701
+ * drift -- Synomem only claims authority over what it wrote.
1702
+ */
1703
+ async projectionStatus(): Promise<ProjectionStatus> {
1704
+ this.checkAbort();
1705
+ const expected = this.projections.expectedPaths();
1706
+ const entries = this.storage.projectionManifestEntries();
1707
+ const manifest = entries.map((entry) => entry.path);
1708
+ const inManifest = new Set(manifest);
1709
+ const inExpected = new Set(expected);
1710
+ const missing = expected.filter(
1711
+ (path) => !inManifest.has(path) || !existsSync(join(this.home, path)),
1712
+ );
1713
+ const unexpected = manifest.filter((path) => !inExpected.has(path));
1714
+ const limit = 20;
1715
+ return {
1716
+ directory: this.home,
1717
+ settings: { ...this.storage.config.projection },
1718
+ current: missing.length === 0 && unexpected.length === 0,
1719
+ ...(entries[0] ? { lastRebuiltAt: entries[0].generatedAt } : {}),
1720
+ counts: {
1721
+ expected: expected.length,
1722
+ manifest: manifest.length,
1723
+ missing: missing.length,
1724
+ unexpected: unexpected.length,
1725
+ },
1726
+ missing: missing.slice(0, limit),
1727
+ unexpected: unexpected.slice(0, limit),
1728
+ };
1729
+ }
1730
+
1571
1731
  async doctor(): Promise<DoctorResult> {
1572
1732
  this.checkAbort();
1573
1733
  const diagnostics: Diagnostic[] = [];
@@ -1667,7 +1827,11 @@ export class SynomemClient extends SynomemCore implements SynomemService {
1667
1827
  : 'Projection manifest is current.',
1668
1828
  });
1669
1829
  for (const profile of this.storage.listAgents()) {
1670
- const directory = join(this.home, profile.id);
1830
+ // Named by handle, which is what the projection writers create. Using
1831
+ // the canonical ID here checks a directory that does not exist, which
1832
+ // makes the check pass on a workspace whose agent directory really has
1833
+ // been replaced with a symbolic link.
1834
+ const directory = join(this.home, profile.handle);
1671
1835
  try {
1672
1836
  assertNoSymlinkEscape(this.home, directory);
1673
1837
  if (existsSync(directory) && lstatSync(directory).isSymbolicLink()) {
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,249 @@
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
+ * Windows has no native option here yet, so the restricted file is
56
+ * the recommendation rather than Credential Manager. Offering a
57
+ * store the credential layer cannot actually read back would fail
58
+ * at the first use, after the wizard had already told the person
59
+ * their credential was safely stored.
60
+ */
61
+ {
62
+ value: 'file' as const,
63
+ label: 'A restricted file in the Synomem home',
64
+ detail: 'Recommended on Windows until Credential Manager support lands.',
65
+ }
66
+ : {
67
+ value: 'keychain' as const,
68
+ label: 'Secret Service (libsecret)',
69
+ detail: 'Recommended where a desktop keyring is running.',
70
+ };
71
+ return [
72
+ native,
73
+ ...(native.value === 'file'
74
+ ? []
75
+ : [
76
+ {
77
+ value: 'file' as const,
78
+ label: 'A restricted file in the Synomem home',
79
+ detail: 'Mode 0600. Use on headless machines with no keyring.',
80
+ },
81
+ ]),
82
+ {
83
+ value: 'environment',
84
+ label: 'Print environment-variable instructions',
85
+ detail: 'Nothing is stored. Synomem never edits your shell profile.',
86
+ },
87
+ ];
88
+ }
89
+
90
+ /** Refuses to guess when there is nobody to ask. */
91
+ export function assertInteractive(io: PromptIo): void {
92
+ if (io.interactive) return;
93
+ throw new SynomemError(
94
+ 'INVALID_INPUT',
95
+ [
96
+ 'synomem config needs an interactive terminal.',
97
+ '',
98
+ 'For automation, use the deterministic form instead:',
99
+ '',
100
+ ' synomem config init --backend local --yes',
101
+ '',
102
+ ' synomem config init --backend remote --auth access-key \\',
103
+ ' --workspace <workspace-id> --access-token-stdin --yes',
104
+ '',
105
+ 'Pipe the token in rather than passing it as an argument: an argument is',
106
+ 'kept by both the shell history and the process list.',
107
+ ].join('\n'),
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Stores an access key in a restricted file.
113
+ *
114
+ * Separate from `config.json` so a configuration file can be read, copied or
115
+ * pasted into an issue without carrying a secret with it.
116
+ */
117
+ export function writeCredentialFile(home: string, token: string): string {
118
+ const directory = join(home, 'credentials');
119
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
120
+ chmodSync(directory, 0o700);
121
+ const path = join(directory, 'installation.json');
122
+ writeFileSync(path, `${JSON.stringify({ accessToken: token }, null, 2)}\n`, { mode: 0o600 });
123
+ chmodSync(path, 0o600);
124
+ return path;
125
+ }
126
+
127
+ /** A key's identifying prefix. Never the key. */
128
+ export function credentialFingerprint(token: string): string {
129
+ const head = token.slice(0, 12);
130
+ return `${head}${token.length > 12 ? '\u2026' : ''}`;
131
+ }
132
+
133
+ export function environmentInstructions(token: string): string {
134
+ return [
135
+ 'Set this value for the current shell:',
136
+ '',
137
+ ` export SYNOMEM_ACCESS_TOKEN='${token}'`,
138
+ '',
139
+ 'To persist it, add that to a secret-aware shell configuration or to your',
140
+ 'agent runtime environment. Synomem will not edit your shell profile for',
141
+ 'you: silently rewriting a dotfile is not a thing a setup program should do.',
142
+ ].join('\n');
143
+ }
144
+
145
+ /** The interactive flow, returning the plan it settled on. */
146
+ export async function runConfigWizard(
147
+ io: PromptIo,
148
+ options: { home?: string; env?: NodeJS.ProcessEnv } = {},
149
+ ): Promise<ConfigPlan> {
150
+ assertInteractive(io);
151
+ const env = options.env ?? process.env;
152
+
153
+ io.output.write(
154
+ [
155
+ '',
156
+ 'Welcome to Synomem',
157
+ '',
158
+ 'Synomem gives agents durable notes, messages, tasks, todos, kudos,',
159
+ 'and shared coordination.',
160
+ '',
161
+ ].join('\n'),
162
+ );
163
+
164
+ const backend = await select<BackendChoice>(io, 'Where should Synomem store canonical state?', [
165
+ {
166
+ value: 'local',
167
+ label: 'Local \u2014 SQLite on this machine',
168
+ detail: 'Nothing is uploaded. One implicit workspace.',
169
+ },
170
+ {
171
+ value: 'remote',
172
+ label: 'Synomem Cloud \u2014 shared across machines and agents',
173
+ detail: 'Organizations, workspaces, roles and administration.',
174
+ },
175
+ ]);
176
+
177
+ const home = await ask(io, 'Where should Synomem store its data?', resolveHome(options.home));
178
+
179
+ if (backend === 'local') {
180
+ return { backend, home };
181
+ }
182
+
183
+ const serviceUrl = cloudApiUrl(env);
184
+ io.output.write(`\nConnecting to Synomem Cloud at ${serviceUrl}\n`);
185
+
186
+ const auth = await select<AuthChoice>(io, 'How would you like to sign in?', [
187
+ {
188
+ value: 'browser',
189
+ label: 'Sign in with your browser',
190
+ detail: 'Opens the authorization server and returns through a loopback callback.',
191
+ },
192
+ {
193
+ value: 'access-key',
194
+ label: 'Use an installation access key',
195
+ detail: 'Create one at https://portal.synomem.ai/installations',
196
+ },
197
+ ]);
198
+
199
+ /*
200
+ * Deliberately no workspace prompt here.
201
+ *
202
+ * A hosted workspace ID looks like `ws-04psqx2rkt8ttft7a1t2z69r97`, and the
203
+ * credential authorized in the next step already knows which workspace it
204
+ * reaches -- an installation key is bound to exactly one, and a browser
205
+ * sign-in can list the ones the account belongs to. Asking first means
206
+ * asking a person to go and look something up that we are about to be told.
207
+ */
208
+
209
+ const credentialStore =
210
+ auth === 'access-key'
211
+ ? await select<CredentialStoreChoice>(
212
+ io,
213
+ 'Where should Synomem store this credential?',
214
+ credentialStoreChoices(),
215
+ )
216
+ : 'auto';
217
+
218
+ return { backend, home, serviceUrl, auth, credentialStore };
219
+ }
220
+
221
+ /** Reads an access key without ever accepting it as an argument. */
222
+ export async function readAccessToken(io: PromptIo): Promise<string> {
223
+ const token = await askSecret(io, 'Installation access key');
224
+ if (!token) throw new SynomemError('INVALID_INPUT', 'No access key was provided.');
225
+ return token;
226
+ }
227
+
228
+ export async function confirmPlan(io: PromptIo, plan: ConfigPlan): Promise<boolean> {
229
+ io.output.write(
230
+ [
231
+ '',
232
+ 'Synomem will be configured as:',
233
+ '',
234
+ ` Backend: ${plan.backend === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
235
+ ` Home: ${plan.home}`,
236
+ ...(plan.serviceUrl ? [` Service: ${plan.serviceUrl}`] : []),
237
+ ...(plan.workspaceId ? [` Workspace: ${plan.workspaceId}`] : []),
238
+ ...(plan.credentialStore && plan.credentialStore !== 'auto'
239
+ ? [` Credential: ${plan.credentialStore}`]
240
+ : []),
241
+ '',
242
+ ].join('\n'),
243
+ );
244
+ return await confirm(io, 'Apply this?');
245
+ }
246
+
247
+ export function homeExists(home: string): boolean {
248
+ return existsSync(home);
249
+ }
@@ -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
 
@@ -185,10 +200,18 @@ export class OsCredentialStore implements CredentialStore {
185
200
  return result.code === 0;
186
201
  }
187
202
 
203
+ /*
204
+ * Windows Credential Manager is not implemented yet. `cmdkey` can write a
205
+ * generic credential but deliberately will not read the secret back, so a
206
+ * store built on it would accept a credential and then never return it --
207
+ * worse than saying so plainly.
208
+ */
188
209
  private unsupported(): never {
189
210
  throw new SynomemError(
190
211
  'CONFIG_INVALID',
191
- 'Interactive credential storage is currently supported on macOS and Linux with secret-tool.',
212
+ this.platform === 'win32'
213
+ ? 'Windows Credential Manager storage is not supported yet. Run `synomem config init` and choose the restricted-file store, or set SYNOMEM_ACCESS_TOKEN.'
214
+ : 'Operating-system credential storage needs the macOS Keychain, or secret-tool on Linux. Run `synomem config init` and choose the restricted-file store, or set SYNOMEM_ACCESS_TOKEN.',
192
215
  );
193
216
  }
194
217
  }