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
@@ -0,0 +1,155 @@
1
+ /*
2
+ * Finding out which workspace a credential belongs to, so nobody has to type
3
+ * one from memory.
4
+ *
5
+ * A hosted workspace ID looks like `ws-04psqx2rkt8ttft7a1t2z69r97`. Asking a
6
+ * person to enter that during setup is asking them to leave and go find it, and
7
+ * the credential they just authorized already knows the answer -- or knows
8
+ * enough to offer a short list.
9
+ *
10
+ * Two credentials, two routes, because they carry different authority:
11
+ *
12
+ * An installation access key is bound to exactly one workspace. There is
13
+ * nothing to choose, so `discoverBoundWorkspace` reads it off the data plane
14
+ * and setup asks nothing at all.
15
+ *
16
+ * A browser sign-in authorizes an ACCOUNT, which may reach several
17
+ * organizations, each with several workspaces. `discoverOrganizations` lists
18
+ * them for selection.
19
+ */
20
+ import { SynomemError } from './errors.js';
21
+
22
+ export interface DiscoveredWorkspace {
23
+ id: string;
24
+ displayName: string;
25
+ }
26
+
27
+ export interface DiscoveredOrganization {
28
+ id: string;
29
+ slug: string;
30
+ displayName: string;
31
+ role: string;
32
+ workspaces: DiscoveredWorkspace[];
33
+ }
34
+
35
+ export interface DiscoveryOptions {
36
+ baseUrl: string;
37
+ accessToken: string;
38
+ /** Injected by tests. Defaults to the global fetch. */
39
+ fetch?: typeof globalThis.fetch;
40
+ signal?: AbortSignal;
41
+ }
42
+
43
+ interface Envelope<T> {
44
+ ok?: boolean;
45
+ data?: T;
46
+ error?: { code?: string; message?: string };
47
+ }
48
+
49
+ async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T> {
50
+ const request = options.fetch ?? globalThis.fetch;
51
+ const url = new URL(
52
+ path,
53
+ options.baseUrl.endsWith('/') ? options.baseUrl : `${options.baseUrl}/`,
54
+ );
55
+ let response: Response;
56
+ try {
57
+ response = await request(url, {
58
+ headers: { authorization: `Bearer ${options.accessToken}`, accept: 'application/json' },
59
+ ...(options.signal ? { signal: options.signal } : {}),
60
+ });
61
+ } catch (error) {
62
+ throw new SynomemError(
63
+ 'REMOTE_UNAVAILABLE',
64
+ `Could not reach ${url.origin}: ${error instanceof Error ? error.message : String(error)}`,
65
+ );
66
+ }
67
+ let body: Envelope<T>;
68
+ try {
69
+ body = (await response.json()) as Envelope<T>;
70
+ } catch {
71
+ throw new SynomemError('REMOTE_PROTOCOL', `${url.pathname} did not return JSON.`);
72
+ }
73
+ if (!response.ok || body.ok !== true || body.data === undefined) {
74
+ // 401 and 403 are the ones a person can act on, so they keep their own
75
+ // codes rather than being flattened into a generic protocol error.
76
+ const code =
77
+ response.status === 401 ? 'AUTH_REQUIRED' : response.status === 403 ? 'AUTH_FORBIDDEN' : null;
78
+ const message = body.error?.message ?? `${url.pathname} returned ${response.status}.`;
79
+ throw new SynomemError(code ?? 'REMOTE_PROTOCOL', message);
80
+ }
81
+ return body.data;
82
+ }
83
+
84
+ /**
85
+ * The single workspace an installation access key can address.
86
+ *
87
+ * Answered by the data plane rather than the control plane: an installation key
88
+ * is not an account principal, so it cannot list organizations, but it can
89
+ * always say where it is bound.
90
+ */
91
+ export async function discoverBoundWorkspace(
92
+ options: DiscoveryOptions,
93
+ ): Promise<{ workspaceId: string; actor: { kind: string; id: string; displayName?: string } }> {
94
+ const identity = await readJson<{
95
+ workspaceId: string;
96
+ actor: { kind: string; id: string; displayName?: string };
97
+ }>(options, 'v1/identity');
98
+ if (!identity.workspaceId) {
99
+ throw new SynomemError('REMOTE_PROTOCOL', 'The service did not report a bound workspace.');
100
+ }
101
+ return { workspaceId: identity.workspaceId, actor: identity.actor };
102
+ }
103
+
104
+ /**
105
+ * Every organization this account belongs to, each with its workspaces.
106
+ *
107
+ * Organizations are listed even when they hold no workspaces yet, because
108
+ * "you belong to this organization and it is empty" is a different and more
109
+ * useful answer than omitting it and appearing to have found nothing.
110
+ */
111
+ export async function discoverOrganizations(
112
+ options: DiscoveryOptions,
113
+ ): Promise<DiscoveredOrganization[]> {
114
+ const me = await readJson<{
115
+ organizations: { id: string; slug: string; displayName: string; role: string }[];
116
+ }>(options, 'v1/me');
117
+ const organizations: DiscoveredOrganization[] = [];
118
+ for (const organization of me.organizations ?? []) {
119
+ const workspaces = await readJson<{ id: string; displayName: string }[]>(
120
+ options,
121
+ `v1/organizations/${encodeURIComponent(organization.id)}/workspaces`,
122
+ );
123
+ organizations.push({
124
+ id: organization.id,
125
+ slug: organization.slug,
126
+ displayName: organization.displayName,
127
+ role: organization.role,
128
+ workspaces: (workspaces ?? []).map((workspace) => ({
129
+ id: workspace.id,
130
+ displayName: workspace.displayName,
131
+ })),
132
+ });
133
+ }
134
+ return organizations;
135
+ }
136
+
137
+ /** Flattens discovery into the choices a person picks from. */
138
+ export function workspaceChoices(
139
+ organizations: DiscoveredOrganization[],
140
+ ): Array<{ value: string; label: string; detail?: string }> {
141
+ const choices: Array<{ value: string; label: string; detail?: string }> = [];
142
+ for (const organization of organizations) {
143
+ for (const workspace of organization.workspaces) {
144
+ choices.push({
145
+ value: workspace.id,
146
+ // The organization is part of the label, not the detail: two
147
+ // organizations may both have a workspace called "Production", and the
148
+ // label is the only part a person is guaranteed to read.
149
+ label: `${organization.displayName} / ${workspace.displayName}`,
150
+ detail: workspace.id,
151
+ });
152
+ }
153
+ }
154
+ return choices;
155
+ }
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,9 @@ 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';
44
+ export { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
45
+ export type { DiscoveredOrganization, DiscoveredWorkspace, DiscoveryOptions } from './discover.js';
38
46
  export { asSynomemError, errorCodes, SynomemError } from './errors.js';
39
47
  export {
40
48
  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,10 +745,18 @@ 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
 
752
+ /*
753
+ * The paths a rebuild would produce, for comparison against the manifest.
754
+ *
755
+ * Directories are named by HANDLE, matching what the writers above create,
756
+ * while the record filters match on the canonical agent ID, which is what
757
+ * stored events carry. Mixing the two up makes the comparison never agree,
758
+ * and `doctor` then reports every workspace's projections as stale forever.
759
+ */
752
760
  expectedPaths(): string[] {
753
761
  const profiles = this.storage.listAgents();
754
762
  const events = this.storage.getReadableEvents();
@@ -757,10 +765,12 @@ export class ProjectionManager implements ProjectionWriter {
757
765
  const tasks = taskRecordsFromEvents(events);
758
766
  const paths: string[] = [];
759
767
  for (const profile of profiles) {
760
- paths.push(`${profile.id}/profile.json`);
761
- if (this.storage.config.projection.writeWinsMarkdown) paths.push(`${profile.id}/WINS.md`);
762
- if (this.storage.config.projection.writeMemoryMarkdown) paths.push(`${profile.id}/MEMORY.md`);
763
- if (this.storage.config.projection.writeTasksMarkdown) paths.push(`${profile.id}/TASKS.md`);
768
+ paths.push(`${profile.handle}/profile.json`);
769
+ if (this.storage.config.projection.writeWinsMarkdown) paths.push(`${profile.handle}/WINS.md`);
770
+ if (this.storage.config.projection.writeMemoryMarkdown)
771
+ paths.push(`${profile.handle}/MEMORY.md`);
772
+ if (this.storage.config.projection.writeTasksMarkdown)
773
+ paths.push(`${profile.handle}/TASKS.md`);
764
774
  if (this.storage.config.projection.writeInboxEntries) {
765
775
  for (const record of records.filter(
766
776
  (item) =>
@@ -768,19 +778,19 @@ export class ProjectionManager implements ProjectionWriter {
768
778
  item.status === 'unacknowledged' &&
769
779
  item.revocationStatus === 'active',
770
780
  )) {
771
- paths.push(`${profile.id}/inbox/kudos/${record.event.id}.md`);
781
+ paths.push(`${profile.handle}/inbox/kudos/${record.event.id}.md`);
772
782
  }
773
783
  for (const record of memos.filter(
774
784
  (item) => item.event.recipientAgentId === profile.id && item.status === 'unread',
775
785
  )) {
776
- paths.push(`${profile.id}/inbox/memos/${record.event.id}.md`);
786
+ paths.push(`${profile.handle}/inbox/memos/${record.event.id}.md`);
777
787
  }
778
788
  for (const record of tasks.filter(
779
789
  (item) =>
780
790
  item.event.assigneeAgentId === profile.id &&
781
791
  (item.status === 'open' || item.status === 'assigned'),
782
792
  )) {
783
- paths.push(`${profile.id}/inbox/tasks/${record.event.id}.md`);
793
+ paths.push(`${profile.handle}/inbox/tasks/${record.event.id}.md`);
784
794
  }
785
795
  }
786
796
  }
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
+ }
package/src/remote.ts CHANGED
@@ -179,6 +179,30 @@ export class RemoteSynomemService implements SynomemService {
179
179
  'GET',
180
180
  `agents/resolve?query=${encodeURIComponent(query)}`,
181
181
  ),
182
+ archive: (idOrAlias: string) =>
183
+ this.mutation<Awaited<ReturnType<SynomemService['agents']['archive']>>>(
184
+ 'POST',
185
+ `agents/${encodeURIComponent(idOrAlias)}/archive`,
186
+ {},
187
+ ),
188
+ restore: (idOrAlias: string) =>
189
+ this.mutation<Awaited<ReturnType<SynomemService['agents']['restore']>>>(
190
+ 'POST',
191
+ `agents/${encodeURIComponent(idOrAlias)}/restore`,
192
+ {},
193
+ ),
194
+ addAliases: (idOrAlias: string, aliases: string[]) =>
195
+ this.mutation<Awaited<ReturnType<SynomemService['agents']['addAliases']>>>(
196
+ 'POST',
197
+ `agents/${encodeURIComponent(idOrAlias)}/aliases`,
198
+ { aliases },
199
+ ),
200
+ removeAliases: (idOrAlias: string, aliases: string[]) =>
201
+ this.mutation<Awaited<ReturnType<SynomemService['agents']['removeAliases']>>>(
202
+ 'POST',
203
+ `agents/${encodeURIComponent(idOrAlias)}/aliases/remove`,
204
+ { aliases },
205
+ ),
182
206
  directory: () =>
183
207
  this.request<Awaited<ReturnType<SynomemService['agents']['directory']>>>(
184
208
  'GET',
package/src/schemas.ts CHANGED
@@ -17,12 +17,34 @@ const reservedIds = new Set([
17
17
  'lpt1',
18
18
  ]);
19
19
 
20
- export const agentIdSchema = z
20
+ /**
21
+ * A handle: the human-friendly name for an agent, unique within its workspace.
22
+ *
23
+ * Mutable, unlike the canonical ID. People and agents type this, so it stays
24
+ * lowercase kebab and refuses the reserved words that would collide with
25
+ * filesystem or route segments.
26
+ */
27
+ export const agentHandleSchema = z
21
28
  .string()
22
29
  .min(1)
23
30
  .max(63)
24
31
  .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lowercase ASCII letters, digits, and hyphens')
25
- .refine((id) => !reservedIds.has(id), 'Reserved agent ID');
32
+ .refine((handle) => !reservedIds.has(handle), 'Reserved agent handle');
33
+
34
+ /** A canonical opaque agent ID: a ULID, uppercase Crockford base32. */
35
+ export const agentUlidSchema = z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/);
36
+
37
+ /**
38
+ * An actor ID as it appears in an event.
39
+ *
40
+ * Accepts a ULID or a handle-shaped name. New agents are created with an opaque
41
+ * ULID so a handle can be renamed without orphaning the events that reference
42
+ * the actor; agents that predate that, and human and system actors, carry a
43
+ * name-shaped ID. Widening rather than replacing keeps append-only history
44
+ * readable — rewriting the actor ID inside stored events to tidy the format
45
+ * would be exactly the rewrite the event log exists to prevent.
46
+ */
47
+ export const agentIdSchema = z.union([agentUlidSchema, agentHandleSchema]);
26
48
 
27
49
  /**
28
50
  * An alias as written, folded to the canonical lowercase form.
@@ -110,16 +132,42 @@ export const evidenceSchema = z
110
132
  });
111
133
 
112
134
  export const profileSchema = z.object({
135
+ /** Canonical, opaque and immutable. Events reference this, never the handle. */
113
136
  id: agentIdSchema,
137
+ handle: agentHandleSchema,
114
138
  displayName: z.string().trim().min(1).max(200),
115
139
  aliases: z.array(agentAliasSchema).max(50).optional(),
116
140
  description: z.string().trim().max(2000).optional(),
141
+ /** Archived agents keep their history and stop being able to act. */
142
+ status: z.enum(['active', 'archived']).default('active'),
117
143
  createdAt: z.string().datetime({ offset: true }),
118
144
  metadata: metadataSchema.optional(),
119
145
  });
120
146
 
121
- export const createAgentSchema = profileSchema.omit({ createdAt: true });
122
- export const updateAgentSchema = createAgentSchema.omit({ id: true }).partial();
147
+ /**
148
+ * Creating an agent names a handle; the canonical ID is generated, never
149
+ * supplied. A caller that could choose the ID could choose one that collides
150
+ * with an archived agent's history.
151
+ */
152
+ export const createAgentSchema = z
153
+ .object({
154
+ handle: agentHandleSchema,
155
+ displayName: z.string().trim().min(1).max(200),
156
+ aliases: z.array(agentAliasSchema).max(50).optional(),
157
+ description: z.string().trim().max(2000).optional(),
158
+ metadata: metadataSchema.optional(),
159
+ })
160
+ .strict();
161
+
162
+ export const updateAgentSchema = z
163
+ .object({
164
+ handle: agentHandleSchema.optional(),
165
+ displayName: z.string().trim().min(1).max(200).optional(),
166
+ aliases: z.array(agentAliasSchema).max(50).optional(),
167
+ description: z.string().trim().max(2000).optional(),
168
+ metadata: metadataSchema.optional(),
169
+ })
170
+ .strict();
123
171
 
124
172
  /**
125
173
  * A runtime binding is a claim about where an agent runs, so the fields stay
@@ -196,7 +244,14 @@ const agentCreatedSchema = baseEventSchema.extend({
196
244
  const agentUpdatedSchema = baseEventSchema.extend({
197
245
  type: z.literal('agent.updated'),
198
246
  agentId: agentIdSchema,
199
- changes: updateAgentSchema,
247
+ /*
248
+ * Archiving is recorded as an update, so `status` belongs in the event even
249
+ * though callers cannot set it through `agents.update` — it moves through
250
+ * `archive` and `restore`, which keep the transition explicit.
251
+ */
252
+ changes: updateAgentSchema.extend({
253
+ status: z.enum(['active', 'archived']).optional(),
254
+ }),
200
255
  });
201
256
 
202
257
  const memoSentSchema = baseEventSchema.extend({
package/src/service.ts CHANGED
@@ -19,6 +19,7 @@ import type {
19
19
  CreateTodoResult,
20
20
  CreateTaskResult,
21
21
  DoctorResult,
22
+ ProjectionStatus,
22
23
  GiveKudosInput,
23
24
  GiveKudosResult,
24
25
  ItemListInput,
@@ -79,6 +80,10 @@ export interface SynomemDomainService {
79
80
  get(idOrAlias: string): Promise<AgentProfile>;
80
81
  list(): Promise<AgentProfile[]>;
81
82
  resolve(query: string): Promise<AgentResolution>;
83
+ archive(idOrAlias: string): Promise<AgentProfile>;
84
+ restore(idOrAlias: string): Promise<AgentProfile>;
85
+ addAliases(idOrAlias: string, aliases: string[]): Promise<AgentProfile>;
86
+ removeAliases(idOrAlias: string, aliases: string[]): Promise<AgentProfile>;
82
87
  directory(): Promise<AgentDirectoryEntry[]>;
83
88
  bindings(idOrAlias: string): Promise<AgentRuntimeBinding[]>;
84
89
  bindRuntime(input: BindRuntimeInput): Promise<AgentRuntimeBinding>;
@@ -209,6 +214,13 @@ export interface SynomemService extends SynomemDomainService {
209
214
  doctor(): Promise<DoctorResult>;
210
215
  export(format: 'json' | 'jsonl' | 'markdown'): Promise<string>;
211
216
  backup?(destination: string): Promise<string>;
217
+ /*
218
+ * Optional, because only a backend that writes projected files can report on
219
+ * them. The remote backend keeps no filesystem projections at all, and
220
+ * inventing an empty answer there would read as "nothing is stale" rather
221
+ * than "there is nothing to be stale".
222
+ */
223
+ projectionStatus?(): Promise<ProjectionStatus>;
212
224
  rebuild(): Promise<ProjectionRebuildResult>;
213
225
  capabilities(): Promise<SynomemServiceCapabilities>;
214
226
  info(): Promise<SynomemServiceInfo>;