synomem 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +4 -4
  3. package/dist/backend.d.ts.map +1 -1
  4. package/dist/backend.js +8 -2
  5. package/dist/backend.js.map +1 -1
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -1
  8. package/dist/cli.js +379 -16
  9. package/dist/cli.js.map +1 -1
  10. package/dist/client.d.ts +68 -2
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/client.js +283 -12
  13. package/dist/client.js.map +1 -1
  14. package/dist/cloud.d.ts +16 -0
  15. package/dist/cloud.d.ts.map +1 -0
  16. package/dist/cloud.js +19 -0
  17. package/dist/cloud.js.map +1 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +8 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/configure.d.ts +55 -0
  22. package/dist/configure.d.ts.map +1 -0
  23. package/dist/configure.js +177 -0
  24. package/dist/configure.js.map +1 -0
  25. package/dist/credentials.d.ts +15 -2
  26. package/dist/credentials.d.ts.map +1 -1
  27. package/dist/credentials.js.map +1 -1
  28. package/dist/import.d.ts +202 -38
  29. package/dist/import.d.ts.map +1 -1
  30. package/dist/index.d.ts +2 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/mcp/index.d.ts.map +1 -1
  35. package/dist/mcp/index.js +72 -6
  36. package/dist/mcp/index.js.map +1 -1
  37. package/dist/oauth.d.ts.map +1 -1
  38. package/dist/oauth.js +8 -1
  39. package/dist/oauth.js.map +1 -1
  40. package/dist/ports/repository.d.ts +4 -1
  41. package/dist/ports/repository.d.ts.map +1 -1
  42. package/dist/projections.d.ts +9 -1
  43. package/dist/projections.d.ts.map +1 -1
  44. package/dist/projections.js +75 -4
  45. package/dist/projections.js.map +1 -1
  46. package/dist/prompt.d.ts +28 -0
  47. package/dist/prompt.d.ts.map +1 -0
  48. package/dist/prompt.js +72 -0
  49. package/dist/prompt.js.map +1 -0
  50. package/dist/remote.d.ts +30 -1
  51. package/dist/remote.d.ts.map +1 -1
  52. package/dist/remote.js +14 -0
  53. package/dist/remote.js.map +1 -1
  54. package/dist/schemas.d.ts +270 -55
  55. package/dist/schemas.d.ts.map +1 -1
  56. package/dist/schemas.js +120 -6
  57. package/dist/schemas.js.map +1 -1
  58. package/dist/service.d.ts +30 -1
  59. package/dist/service.d.ts.map +1 -1
  60. package/dist/storage.d.ts +25 -2
  61. package/dist/storage.d.ts.map +1 -1
  62. package/dist/storage.js +168 -15
  63. package/dist/storage.js.map +1 -1
  64. package/dist/types.d.ts +123 -4
  65. package/dist/types.d.ts.map +1 -1
  66. package/docs/cli.md +1 -1
  67. package/docs/examples.md +1 -1
  68. package/docs/mcp.md +1 -1
  69. package/docs/skill.md +1 -1
  70. package/docs/storage-format.md +1 -1
  71. package/package.json +8 -8
  72. package/src/backend.ts +8 -2
  73. package/src/cli.ts +543 -19
  74. package/src/client.ts +331 -11
  75. package/src/cloud.ts +19 -0
  76. package/src/config.ts +8 -1
  77. package/src/configure.ts +233 -0
  78. package/src/credentials.ts +17 -2
  79. package/src/index.ts +7 -1
  80. package/src/mcp/index.ts +95 -5
  81. package/src/oauth.ts +8 -1
  82. package/src/ports/repository.ts +5 -0
  83. package/src/projections.ts +74 -4
  84. package/src/prompt.ts +88 -0
  85. package/src/remote.ts +72 -0
  86. package/src/schemas.ts +125 -6
  87. package/src/service.ts +30 -0
  88. package/src/storage.ts +217 -14
  89. package/src/types.ts +129 -3
@@ -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
  }
@@ -326,6 +340,82 @@ export async function createSynomemMcpServer(
326
340
  },
327
341
  );
328
342
 
343
+ server.registerTool(
344
+ 'synomem_post_create',
345
+ {
346
+ title: 'Publish a post',
347
+ description:
348
+ 'Publish something the whole workspace can read. Use for an announcement, a decision, or context several agents need. A post has no recipient — if one named actor must act, send a memo or assign a task instead.',
349
+ inputSchema: z.object({
350
+ title: z.string().trim().min(1).max(200),
351
+ body: z.string().trim().min(1).max(32_000),
352
+ tags: z.array(z.string()).max(20).optional(),
353
+ replyTo: z.string().length(26).optional(),
354
+ idempotencyKey: z.string().max(200).optional(),
355
+ }),
356
+ outputSchema,
357
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
358
+ },
359
+ async (input) => {
360
+ try {
361
+ const result = await client.posts.create(input);
362
+ return success(actor, `Published post ${result.record.event.id}.`, {
363
+ post: result.record,
364
+ });
365
+ } catch (error) {
366
+ return failure(actor, error);
367
+ }
368
+ },
369
+ );
370
+
371
+ server.registerTool(
372
+ 'synomem_post_acknowledge',
373
+ {
374
+ title: 'Acknowledge a post',
375
+ description:
376
+ 'Record that YOU have seen a post. This speaks only for the configured actor and is never implied by reading one: acknowledge when you have actually taken it in, not to clear a list. An optional note tells the author something useful, such as work already done.',
377
+ inputSchema: z.object({
378
+ postId: z.string().length(26),
379
+ note: z.string().trim().min(1).max(2000).optional(),
380
+ idempotencyKey: z.string().max(200).optional(),
381
+ }),
382
+ outputSchema,
383
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
384
+ },
385
+ async (input) => {
386
+ try {
387
+ const record = await client.posts.acknowledge(input);
388
+ return success(actor, `Acknowledged post ${input.postId}.`, { post: record });
389
+ } catch (error) {
390
+ return failure(actor, error);
391
+ }
392
+ },
393
+ );
394
+
395
+ server.registerTool(
396
+ 'synomem_post_roster',
397
+ {
398
+ title: 'See who has acknowledged a post',
399
+ description:
400
+ 'Who has acknowledged a post and who has not. An outstanding entry means no acknowledgement was recorded — never that somebody has not read it. Agents created after the post are counted separately, because they were not there when it was written. This is read-only and does not acknowledge anything.',
401
+ inputSchema: z.object({ postId: z.string().length(26) }),
402
+ outputSchema,
403
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
404
+ },
405
+ async ({ postId }) => {
406
+ try {
407
+ const roster = await client.posts.roster(postId);
408
+ return success(
409
+ actor,
410
+ `${roster.acknowledged.length} acknowledged, ${roster.outstanding.length} with no acknowledgement recorded.`,
411
+ roster,
412
+ );
413
+ } catch (error) {
414
+ return failure(actor, error);
415
+ }
416
+ },
417
+ );
418
+
329
419
  server.registerTool(
330
420
  'synomem_agent_resolve',
331
421
  {
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;
@@ -5,6 +5,8 @@ import type {
5
5
  ChangePage,
6
6
  ItemListInput,
7
7
  JsonValue,
8
+ PostAcknowledgment,
9
+ PostRoster,
8
10
  ItemSummary,
9
11
  KudosListInput,
10
12
  KudosSummary,
@@ -45,6 +47,9 @@ export interface SynomemRepository {
45
47
  listAgents(): Awaitable<AgentProfile[]>;
46
48
  /** Resolves a name case-insensitively, reporting ambiguity instead of guessing. */
47
49
  resolveAgent(query: string): Awaitable<{ match?: AgentProfile; candidates: AgentProfile[] }>;
50
+ listPostAcknowledgments(postId: string): Awaitable<PostAcknowledgment[]>;
51
+ /** Who has acknowledged a post and who has not; see PostRoster. */
52
+ postRoster(postId: string): Awaitable<PostRoster | undefined>;
48
53
  listRuntimeBindings(agentId: string): Awaitable<AgentRuntimeBinding[]>;
49
54
  bindRuntime(binding: {
50
55
  id: string;
@@ -16,6 +16,7 @@ import type {
16
16
  KudosRecord,
17
17
  MemoRecord,
18
18
  NoteRecord,
19
+ PostRecord,
19
20
  TaskDue,
20
21
  TaskRecord,
21
22
  TodoRecord,
@@ -254,6 +255,75 @@ export function memoRecordsFromEvents(events: SynomemEvent[]): MemoRecord[] {
254
255
  return [...records.values()].sort((a, b) => b.event.createdAt.localeCompare(a.event.createdAt));
255
256
  }
256
257
 
258
+ /**
259
+ * Rebuilds posts from their events, acknowledgements included.
260
+ *
261
+ * Edits are kept as a list rather than collapsed into the current text. A reader
262
+ * has to be able to see that a post changed after somebody acknowledged it —
263
+ * silently rewriting what was acknowledged is how a record becomes a lie.
264
+ */
265
+ export function postRecordsFromEvents(events: SynomemEvent[]): PostRecord[] {
266
+ const records = new Map<string, PostRecord>();
267
+ for (const event of events) {
268
+ if (event.type === 'post.created') {
269
+ records.set(event.id, {
270
+ event,
271
+ edits: [],
272
+ acknowledgments: [],
273
+ status: 'active',
274
+ title: event.title,
275
+ body: event.body,
276
+ ...(event.tags ? { tags: event.tags } : {}),
277
+ // The text version, counting only changes to the text.
278
+ //
279
+ // Deliberately not the aggregate version, which also counts every
280
+ // acknowledgement. If they were the same number, somebody
281
+ // acknowledging a post would invalidate an edit the author was in the
282
+ // middle of making — a conflict with nothing to reconcile.
283
+ version: 1,
284
+ });
285
+ } else if (event.type === 'post.edited') {
286
+ const record = records.get(event.postId);
287
+ if (record) {
288
+ record.edits.push(event);
289
+ record.title = event.title;
290
+ record.body = event.body;
291
+ if (event.tags) record.tags = event.tags;
292
+ record.version += 1;
293
+ }
294
+ } else if (event.type === 'post.archived') {
295
+ const record = records.get(event.postId);
296
+ if (record) {
297
+ record.archived = event;
298
+ record.status = 'archived';
299
+ }
300
+ } else if (event.type === 'post.acknowledged') {
301
+ const record = records.get(event.postId);
302
+ if (record) {
303
+ // One statement per actor: a repeat replaces rather than accumulates.
304
+ const existing = record.acknowledgments.findIndex(
305
+ (entry) => entry.actor.id === event.actor.id && entry.actor.kind === event.actor.kind,
306
+ );
307
+ const entry = {
308
+ actor: event.actor,
309
+ acknowledgedAt: event.createdAt,
310
+ ...(event.note ? { note: event.note } : {}),
311
+ };
312
+ if (existing >= 0) record.acknowledgments[existing] = entry;
313
+ else record.acknowledgments.push(entry);
314
+ }
315
+ } else if (event.type === 'post.acknowledgment.withdrawn') {
316
+ const record = records.get(event.postId);
317
+ if (record) {
318
+ record.acknowledgments = record.acknowledgments.filter(
319
+ (entry) => !(entry.actor.id === event.actor.id && entry.actor.kind === event.actor.kind),
320
+ );
321
+ }
322
+ }
323
+ }
324
+ return [...records.values()];
325
+ }
326
+
257
327
  export function noteRecordsFromEvents(events: SynomemEvent[]): NoteRecord[] {
258
328
  const records = new Map<string, NoteRecord>();
259
329
  for (const event of events) {
@@ -480,7 +550,7 @@ export class ProjectionManager implements ProjectionWriter {
480
550
  const generated: string[] = [];
481
551
 
482
552
  for (const profile of profiles) {
483
- const agentDirectory = join(this.storage.home, profile.id);
553
+ const agentDirectory = join(this.storage.home, profile.handle);
484
554
  assertNoSymlinkEscape(this.storage.home, agentDirectory);
485
555
  ensureDirectory(agentDirectory);
486
556
  const profilePath = join(agentDirectory, 'profile.json');
@@ -586,7 +656,7 @@ export class ProjectionManager implements ProjectionWriter {
586
656
  (record) => record.event.assigneeAgentId === profile.id,
587
657
  );
588
658
  const rebuiltAt = events.at(-1)?.createdAt ?? new Date(0).toISOString();
589
- const agentDirectory = join(this.storage.home, profile.id);
659
+ const agentDirectory = join(this.storage.home, profile.handle);
590
660
  const inboxDirectory = join(agentDirectory, 'inbox');
591
661
  const kudosInboxDirectory = join(inboxDirectory, 'kudos');
592
662
  const memoInboxDirectory = join(inboxDirectory, 'memos');
@@ -659,7 +729,7 @@ export class ProjectionManager implements ProjectionWriter {
659
729
  }
660
730
 
661
731
  const keep = new Set(generated);
662
- const prefixes = [`${profile.id}/`, `${profile.id}\\`];
732
+ const prefixes = [`${profile.handle}/`, `${profile.handle}\\`];
663
733
  const removed: string[] = [];
664
734
  for (const stale of this.storage
665
735
  .projectionManifest()
@@ -675,7 +745,7 @@ export class ProjectionManager implements ProjectionWriter {
675
745
  unlinkSync(path);
676
746
  removed.push(stale);
677
747
  }
678
- this.storage.replaceAgentProjectionManifest(profile.id, generated, rebuiltAt);
748
+ this.storage.replaceAgentProjectionManifest(profile.handle, generated, rebuiltAt);
679
749
  return { generated: generated.sort(), removed: removed.sort() };
680
750
  }
681
751
 
package/src/prompt.ts ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * A small prompt boundary, injectable so the wizard is testable.
3
+ *
4
+ * Deliberately not a dependency: the wizard needs a select, a line, a masked
5
+ * line and a confirm, and a library for that would be more surface than
6
+ * substance. Everything reads from an injected stream, so tests drive the flow
7
+ * without a terminal.
8
+ */
9
+ import { createInterface } from 'node:readline/promises';
10
+ import type { Readable, Writable } from 'node:stream';
11
+
12
+ export interface PromptIo {
13
+ input: Readable;
14
+ output: Writable;
15
+ /**
16
+ * Whether a person is actually there. A wizard must never wait forever on a
17
+ * pipe, so a non-interactive stream is refused with instructions instead.
18
+ */
19
+ interactive: boolean;
20
+ }
21
+
22
+ export function defaultPromptIo(): PromptIo {
23
+ return {
24
+ input: process.stdin,
25
+ output: process.stdout,
26
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
27
+ };
28
+ }
29
+
30
+ async function readLine(io: PromptIo, question: string): Promise<string> {
31
+ const rl = createInterface({ input: io.input, output: io.output, terminal: io.interactive });
32
+ try {
33
+ return (await rl.question(question)).trim();
34
+ } finally {
35
+ rl.close();
36
+ }
37
+ }
38
+
39
+ export async function ask(io: PromptIo, question: string, fallback?: string): Promise<string> {
40
+ const suffix = fallback ? ` [${fallback}]` : '';
41
+ const answer = await readLine(io, `${question}${suffix}: `);
42
+ return answer || fallback || '';
43
+ }
44
+
45
+ export async function confirm(io: PromptIo, question: string): Promise<boolean> {
46
+ const answer = await readLine(io, `${question} [y/N]: `);
47
+ return /^y(es)?$/i.test(answer);
48
+ }
49
+
50
+ /**
51
+ * Reads a secret from a non-interactive stream, or prompts for one.
52
+ *
53
+ * Piping a secret in is the SAFE path and is why this accepts a closed stream
54
+ * rather than refusing it: a token passed as a command-line argument is kept by
55
+ * both the shell history and the process list, so `--access-token-stdin` has to
56
+ * work without a terminal.
57
+ */
58
+ export async function askSecret(io: PromptIo, question: string): Promise<string> {
59
+ if (!io.interactive) {
60
+ const chunks: Buffer[] = [];
61
+ for await (const chunk of io.input) chunks.push(Buffer.from(chunk as Uint8Array));
62
+ return Buffer.concat(chunks).toString('utf8').trim();
63
+ }
64
+ // Readline echoes, so an interactive secret is read the same way and the
65
+ // caller is told not to expect masking rather than being silently exposed.
66
+ io.output.write('The value you type will be visible. Paste it, or pipe it in instead.\n');
67
+ return await readLine(io, `${question}: `);
68
+ }
69
+
70
+ export async function select<T extends string>(
71
+ io: PromptIo,
72
+ question: string,
73
+ choices: Array<{ value: T; label: string; detail?: string }>,
74
+ ): Promise<T> {
75
+ io.output.write(`\n${question}\n\n`);
76
+ choices.forEach((choice, index) => {
77
+ io.output.write(` ${index + 1}) ${choice.label}\n`);
78
+ if (choice.detail) io.output.write(` ${choice.detail}\n`);
79
+ });
80
+ io.output.write('\n');
81
+ for (;;) {
82
+ const answer = await readLine(io, `Choose 1-${choices.length} [1]: `);
83
+ const index = Number(answer || '1');
84
+ const choice = choices[index - 1];
85
+ if (choice) return choice.value;
86
+ io.output.write('Enter one of the listed numbers.\n');
87
+ }
88
+ }