synomem 0.5.3 → 0.6.1

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 (77) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +30 -12
  3. package/dist/cli.d.ts +5 -3
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +245 -47
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +10 -0
  9. package/dist/client.js.map +1 -1
  10. package/dist/config.d.ts +1 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +4 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/configure.d.ts.map +1 -1
  15. package/dist/configure.js +5 -4
  16. package/dist/configure.js.map +1 -1
  17. package/dist/discover.d.ts +9 -11
  18. package/dist/discover.d.ts.map +1 -1
  19. package/dist/discover.js +14 -15
  20. package/dist/discover.js.map +1 -1
  21. package/dist/import.d.ts +4 -4
  22. package/dist/index.d.ts +5 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/mcp/index.d.ts.map +1 -1
  27. package/dist/mcp/index.js +46 -0
  28. package/dist/mcp/index.js.map +1 -1
  29. package/dist/mcp-server.js +26 -3
  30. package/dist/mcp-server.js.map +1 -1
  31. package/dist/ports/projections.d.ts +8 -0
  32. package/dist/ports/projections.d.ts.map +1 -1
  33. package/dist/project.d.ts +51 -0
  34. package/dist/project.d.ts.map +1 -0
  35. package/dist/project.js +143 -0
  36. package/dist/project.js.map +1 -0
  37. package/dist/projections.d.ts +14 -0
  38. package/dist/projections.d.ts.map +1 -1
  39. package/dist/projections.js +36 -1
  40. package/dist/projections.js.map +1 -1
  41. package/dist/remote.d.ts.map +1 -1
  42. package/dist/remote.js +9 -0
  43. package/dist/remote.js.map +1 -1
  44. package/dist/schemas.d.ts +18 -4
  45. package/dist/schemas.d.ts.map +1 -1
  46. package/dist/schemas.js +29 -2
  47. package/dist/schemas.js.map +1 -1
  48. package/dist/service.d.ts +1 -0
  49. package/dist/service.d.ts.map +1 -1
  50. package/dist/storage.d.ts +13 -0
  51. package/dist/storage.d.ts.map +1 -1
  52. package/dist/storage.js +24 -0
  53. package/dist/storage.js.map +1 -1
  54. package/dist/types.d.ts +1 -0
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/workspaces.d.ts +41 -0
  57. package/dist/workspaces.d.ts.map +1 -0
  58. package/dist/workspaces.js +96 -0
  59. package/dist/workspaces.js.map +1 -0
  60. package/package.json +1 -1
  61. package/src/cli.ts +305 -50
  62. package/src/client.ts +10 -0
  63. package/src/config.ts +4 -0
  64. package/src/configure.ts +5 -4
  65. package/src/discover.ts +18 -19
  66. package/src/index.ts +22 -1
  67. package/src/mcp/index.ts +62 -0
  68. package/src/mcp-server.ts +32 -5
  69. package/src/ports/projections.ts +9 -0
  70. package/src/project.ts +168 -0
  71. package/src/projections.ts +38 -1
  72. package/src/remote.ts +9 -0
  73. package/src/schemas.ts +44 -12
  74. package/src/service.ts +1 -0
  75. package/src/storage.ts +28 -0
  76. package/src/types.ts +1 -0
  77. package/src/workspaces.ts +107 -0
package/src/configure.ts CHANGED
@@ -203,10 +203,11 @@ export async function runConfigWizard(
203
203
  * Deliberately no workspace prompt here.
204
204
  *
205
205
  * A hosted workspace ID looks like `ws-04psqx2rkt8ttft7a1t2z69r97`, and the
206
- * credential authorized in the next step already knows which workspace it
207
- * reaches -- an installation key is bound to exactly one, and a browser
208
- * sign-in can list the ones the account belongs to. Asking first means
209
- * asking a person to go and look something up that we are about to be told.
206
+ * credential authorized in the next step already knows the answer -- an
207
+ * access key can list every workspace its organization has, and a browser
208
+ * sign-in can list the ones the account belongs to across every
209
+ * organization. Asking first means asking a person to go and look something
210
+ * up that we are about to be told.
210
211
  */
211
212
 
212
213
  const credentialStore =
package/src/discover.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /*
2
- * Finding out which workspace a credential belongs to, so nobody has to type
2
+ * Finding out which workspaces a credential can reach, so nobody has to type
3
3
  * one from memory.
4
4
  *
5
5
  * A hosted workspace ID looks like `ws-04psqx2rkt8ttft7a1t2z69r97`. Asking a
@@ -9,11 +9,12 @@
9
9
  *
10
10
  * Two credentials, two routes, because they carry different authority:
11
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.
12
+ * A member-owned access key authenticates the ACCOUNT that created it, and
13
+ * reaches every workspace in that account's organization -- never just one
14
+ * -- so `discoverAccessKeyWorkspaces` lists them off the data plane, with no
15
+ * workspace chosen yet, which is exactly what setup cannot supply up front.
15
16
  *
16
- * A browser sign-in authorizes an ACCOUNT, which may reach several
17
+ * A browser sign-in also authorizes an ACCOUNT, which may reach several
17
18
  * organizations, each with several workspaces. `discoverOrganizations` lists
18
19
  * them for selection.
19
20
  */
@@ -82,23 +83,21 @@ async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T>
82
83
  }
83
84
 
84
85
  /**
85
- * The single workspace an installation access key can address.
86
+ * Every workspace a member-owned access key can reach, asked before any one
87
+ * of them has been chosen.
86
88
  *
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.
89
+ * Answered by the data plane rather than the control plane: an access key is
90
+ * not an account principal the control plane recognizes, but the data plane
91
+ * already knows which organization owns it and can list that organization's
92
+ * workspaces without requiring one to be named first.
90
93
  */
91
- export async function discoverBoundWorkspace(
94
+ export async function discoverAccessKeyWorkspaces(
92
95
  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 };
96
+ ): Promise<{ organizationId: string; workspaces: DiscoveredWorkspace[] }> {
97
+ return await readJson<{ organizationId: string; workspaces: DiscoveredWorkspace[] }>(
98
+ options,
99
+ 'v1/access-keys/workspaces',
100
+ );
102
101
  }
103
102
 
104
103
  /**
package/src/index.ts CHANGED
@@ -41,7 +41,28 @@ export type {
41
41
  } from './service.js';
42
42
  export { defaultConfig, resolveHome } from './config.js';
43
43
  export { cloudApiUrl, SYNOMEM_CLOUD_API_URL } from './cloud.js';
44
- export { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
44
+ export {
45
+ DEFAULT_WORKSPACE,
46
+ listLocalWorkspaces,
47
+ localWorkspaceHome,
48
+ WORKSPACES_DIRECTORY,
49
+ workspaceNameSchema,
50
+ } from './workspaces.js';
51
+ export type { LocalWorkspace } from './workspaces.js';
52
+ export {
53
+ findProjectSelection,
54
+ PROJECT_CONFIG_FILE,
55
+ PROJECT_DIRECTORY,
56
+ projectConfigSchema,
57
+ resolveWorkspaceSelection,
58
+ writeProjectSelection,
59
+ } from './project.js';
60
+ export type { ProjectConfig, ProjectSelection } from './project.js';
61
+ export {
62
+ discoverAccessKeyWorkspaces,
63
+ discoverOrganizations,
64
+ workspaceChoices,
65
+ } from './discover.js';
45
66
  export type { DiscoveredOrganization, DiscoveredWorkspace, DiscoveryOptions } from './discover.js';
46
67
  export { asSynomemError, errorCodes, SynomemError } from './errors.js';
47
68
  export {
package/src/mcp/index.ts CHANGED
@@ -321,6 +321,68 @@ export async function createSynomemMcpServer(
321
321
  },
322
322
  );
323
323
 
324
+ server.registerTool(
325
+ 'synomem_agent_archive',
326
+ {
327
+ title: 'Archive an agent identity',
328
+ description:
329
+ 'Administrative tool for archiving an agent identity, not deleting it. Everything it authored keeps its name and stays exactly as it is; the agent simply cannot act again until restored with synomem_agent_restore. Disabled by default so runtime agents cannot silently disable each other.',
330
+ inputSchema: z.object({
331
+ idOrAlias: z.string().min(1).describe('An agent ID or alias, in any casing.'),
332
+ }),
333
+ outputSchema,
334
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
335
+ },
336
+ async ({ idOrAlias }) => {
337
+ try {
338
+ const capabilities = await client.capabilities();
339
+ if (!capabilities.administration.agentArchiveViaMcp) {
340
+ throw new SynomemError(
341
+ 'POLICY_FORBIDDEN',
342
+ 'Agent archiving via MCP is disabled by configuration.',
343
+ );
344
+ }
345
+ const profile = await client.agents.archive(idOrAlias);
346
+ return success(actor, `Archived agent ${profile.displayName} (${profile.id}).`, {
347
+ profile,
348
+ });
349
+ } catch (error) {
350
+ return failure(actor, error);
351
+ }
352
+ },
353
+ );
354
+
355
+ server.registerTool(
356
+ 'synomem_agent_restore',
357
+ {
358
+ title: 'Restore an archived agent identity',
359
+ description:
360
+ 'Administrative tool for letting a previously archived agent act again, using the same agent ID it always had. Disabled by default alongside synomem_agent_archive.',
361
+ inputSchema: z.object({
362
+ idOrAlias: z.string().min(1).describe('An agent ID or alias, in any casing.'),
363
+ }),
364
+ outputSchema,
365
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
366
+ },
367
+ async ({ idOrAlias }) => {
368
+ try {
369
+ const capabilities = await client.capabilities();
370
+ if (!capabilities.administration.agentArchiveViaMcp) {
371
+ throw new SynomemError(
372
+ 'POLICY_FORBIDDEN',
373
+ 'Agent restoring via MCP is disabled by configuration.',
374
+ );
375
+ }
376
+ const profile = await client.agents.restore(idOrAlias);
377
+ return success(actor, `Restored agent ${profile.displayName} (${profile.id}).`, {
378
+ profile,
379
+ });
380
+ } catch (error) {
381
+ return failure(actor, error);
382
+ }
383
+ },
384
+ );
385
+
324
386
  server.registerTool(
325
387
  'synomem_agent_list',
326
388
  {
package/src/mcp-server.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
3
  import { createConfiguredService } from './backend.js';
4
+ import { resolveWorkspaceSelection } from './project.js';
4
5
  import { SynomemError } from './errors.js';
5
6
  import { actorSchema } from './schemas.js';
6
7
  import { startMcpServer } from './mcp/index.js';
@@ -12,6 +13,7 @@ const version = packageVersion();
12
13
  const { values } = parseArgs({
13
14
  options: {
14
15
  home: { type: 'string' },
16
+ workspace: { type: 'string' },
15
17
  'agent-id': { type: 'string' },
16
18
  'actor-id': { type: 'string' },
17
19
  'actor-kind': { type: 'string' },
@@ -59,12 +61,17 @@ async function resolveAgentActor(agentId: string, home?: string): Promise<ActorI
59
61
  }
60
62
 
61
63
  if (values.help) {
62
- process.stdout.write(`synomem-mcp ${version}
64
+ process.stdout.write(
65
+ `synomem-mcp ${version}
63
66
 
64
67
  Actor-bound Synomem MCP server (stdio transport)
65
68
 
66
69
  Options:
67
70
  --home <path> Storage root
71
+ --workspace <name> Local workspace to act in. Defaults to the workspace
72
+ named by .synomem/config.json in the working directory
73
+ or any directory above it, then to SYNOMEM_WORKSPACE,
74
+ then to the default workspace.
68
75
  --agent-id <id> Bound agent, whose identity is read from Synomem
69
76
  (or SYNOMEM_AGENT_ID)
70
77
  --actor-id <id> Bound non-agent actor ID (or SYNOMEM_ACTOR_ID)
@@ -76,13 +83,33 @@ Options:
76
83
 
77
84
  Prefer --agent-id for an agent runtime: the display name and kind then come
78
85
  from the agent's profile instead of from whatever the harness was told to pass.
79
- `);
86
+ `,
87
+ );
80
88
  } else if (values.version) {
81
89
  process.stdout.write(`${version}\n`);
82
90
  } else {
83
- const agentId = values['agent-id'] ?? process.env.SYNOMEM_AGENT_ID;
91
+ /*
92
+ * The workspace is resolved from where the server was STARTED, which is what
93
+ * makes a project binding work at all.
94
+ *
95
+ * A harness launches this process in the repository it opened, so a
96
+ * `.synomem/config.json` there selects the workspace for the whole session
97
+ * without the harness knowing anything about workspaces, and without anybody
98
+ * repeating a flag. `--home` still wins, because it names a home outright
99
+ * rather than a workspace inside one.
100
+ */
101
+ const selection = values.home
102
+ ? undefined
103
+ : resolveWorkspaceSelection({
104
+ ...(values.workspace ? { flag: values.workspace } : {}),
105
+ env: process.env,
106
+ });
107
+ const home = values.home ?? selection?.home;
108
+
109
+ const agentId =
110
+ values['agent-id'] ?? process.env.SYNOMEM_AGENT_ID ?? selection?.actor ?? undefined;
84
111
  const actor = agentId
85
- ? await resolveAgentActor(agentId, values.home)
112
+ ? await resolveAgentActor(agentId, home)
86
113
  : actorSchema.parse({
87
114
  id: values['actor-id'] ?? process.env.SYNOMEM_ACTOR_ID,
88
115
  kind: values['actor-kind'] ?? process.env.SYNOMEM_ACTOR_KIND,
@@ -91,6 +118,6 @@ from the agent's profile instead of from whatever the harness was told to pass.
91
118
 
92
119
  await startMcpServer({
93
120
  actor,
94
- ...(values.home ? { home: values.home } : {}),
121
+ ...(home ? { home } : {}),
95
122
  });
96
123
  }
@@ -3,4 +3,13 @@ import type { Awaitable } from './repository.js';
3
3
 
4
4
  export interface ProjectionWriter {
5
5
  syncAgent(agentId: string): Awaitable<ProjectionRebuildResult>;
6
+
7
+ /**
8
+ * Move an agent's projected directory when its handle changes.
9
+ *
10
+ * Optional: only a writer that owns a filesystem has a directory to move. A
11
+ * backend that keeps no projections implements nothing and the rename is
12
+ * simply a database change.
13
+ */
14
+ renameAgentDirectory?(previousHandle: string, nextHandle: string): Awaitable<void>;
6
15
  }
package/src/project.ts ADDED
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Per-project workspace selection.
3
+ *
4
+ * The goal is that opening an agent in a repository is enough: every note,
5
+ * memo, task, todo and post it writes lands in that repository's workspace,
6
+ * with nobody naming the workspace again for the rest of the session.
7
+ *
8
+ * That cannot be a command typed into a running session. The stdio MCP server
9
+ * is bound to a home and an actor when the harness launches it, so a later
10
+ * instruction has nothing to retarget. It also should not be one global
11
+ * "current workspace": two agents open in two repositories would fight over
12
+ * it, which is exactly the case this exists to serve.
13
+ *
14
+ * So it is a file in the project, found by walking up from the working
15
+ * directory — the same shape as `.git`, `.nvmrc` or `.npmrc`, and for the same
16
+ * reason: the directory somebody is working in is the thing that knows which
17
+ * project this is.
18
+ *
19
+ * `.synomem/config.json` holds a POINTER, never a store:
20
+ *
21
+ * { "workspace": "lumina", "actor": "claude" }
22
+ *
23
+ * The database stays under the Synomem home. Putting one in the repository
24
+ * would mean an append-only event log inside somebody's git history, committed
25
+ * by accident the first time they ran `git add -A`.
26
+ */
27
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
28
+ import { dirname, join, parse, resolve } from 'node:path';
29
+ import { resolveHome } from './config.js';
30
+ import { z } from 'zod';
31
+ import { SynomemError } from './errors.js';
32
+ import { localWorkspaceHome, workspaceNameSchema } from './workspaces.js';
33
+
34
+ export const PROJECT_DIRECTORY = '.synomem';
35
+ export const PROJECT_CONFIG_FILE = 'config.json';
36
+
37
+ export const projectConfigSchema = z.object({
38
+ /** The local workspace this project's records belong in. */
39
+ workspace: workspaceNameSchema.optional(),
40
+ /** The agent this project's records are written by, when it is always one. */
41
+ actor: z.string().trim().min(1).max(63).optional(),
42
+ });
43
+
44
+ export type ProjectConfig = z.infer<typeof projectConfigSchema>;
45
+
46
+ export interface ProjectSelection extends ProjectConfig {
47
+ /** The directory whose `.synomem` was used, so tools can say where it came from. */
48
+ directory: string;
49
+ }
50
+
51
+ /**
52
+ * The nearest project configuration at or above `from`.
53
+ *
54
+ * Walking up stops at the filesystem root, and never treats the Synomem home
55
+ * itself as a project: `~/.synomem/config.json` is a real Synomem config with a
56
+ * different shape, and reading it as a project pointer would silently apply a
57
+ * home's settings to every command run anywhere under the home directory.
58
+ */
59
+ export function findProjectSelection(
60
+ from: string = process.cwd(),
61
+ home?: string,
62
+ ): ProjectSelection | undefined {
63
+ const stopAt = parse(resolve(from)).root;
64
+ let directory = resolve(from);
65
+ for (;;) {
66
+ const candidate = join(directory, PROJECT_DIRECTORY, PROJECT_CONFIG_FILE);
67
+ // `<home>/config.json` is the home's own config, not a project pointer, and
68
+ // the home is never a project directory.
69
+ const isHome = home !== undefined && resolve(home) === join(directory, PROJECT_DIRECTORY);
70
+ if (!isHome && existsSync(candidate)) {
71
+ return { ...readProjectConfig(candidate), directory };
72
+ }
73
+ if (directory === stopAt) return undefined;
74
+ const parent = dirname(directory);
75
+ if (parent === directory) return undefined;
76
+ directory = parent;
77
+ }
78
+ }
79
+
80
+ function readProjectConfig(path: string): ProjectConfig {
81
+ let raw: unknown;
82
+ try {
83
+ raw = JSON.parse(readFileSync(path, 'utf8'));
84
+ } catch {
85
+ throw new SynomemError('CONFIG_INVALID', `${path} is not readable JSON.`);
86
+ }
87
+ const parsed = projectConfigSchema.safeParse(raw);
88
+ if (!parsed.success) {
89
+ throw new SynomemError(
90
+ 'CONFIG_INVALID',
91
+ `${path} is not a valid Synomem project file: ${parsed.error.issues[0]?.message ?? 'unknown problem'}`,
92
+ );
93
+ }
94
+ return parsed.data;
95
+ }
96
+
97
+ /** Writes the pointer for a directory, creating `.synomem/` if needed. */
98
+ export function writeProjectSelection(directory: string, config: ProjectConfig): string {
99
+ const parsed = projectConfigSchema.parse(config);
100
+ const folder = join(resolve(directory), PROJECT_DIRECTORY);
101
+ mkdirSync(folder, { recursive: true });
102
+ const path = join(folder, PROJECT_CONFIG_FILE);
103
+ writeFileSync(path, `${JSON.stringify(parsed, null, 2)}\n`);
104
+ return path;
105
+ }
106
+
107
+ /**
108
+ * Which workspace to act in, and as whom.
109
+ *
110
+ * One resolver, used by both the CLI and the stdio MCP server, so the two
111
+ * cannot disagree about precedence. Order, most specific first:
112
+ *
113
+ * 1. `--workspace` / `--actor` — said on this invocation
114
+ * 2. `SYNOMEM_WORKSPACE` / `SYNOMEM_ACTOR_ID` — set for this process
115
+ * 3. `.synomem/config.json` in the project, found by walking up from the
116
+ * working directory
117
+ * 4. the root home, which is the default workspace
118
+ *
119
+ * The project file is third rather than first because a flag someone typed
120
+ * should always beat a file they may have forgotten is there.
121
+ */
122
+ export function resolveWorkspaceSelection(input: {
123
+ flag?: string;
124
+ actorFlag?: string;
125
+ env?: NodeJS.ProcessEnv;
126
+ cwd?: string;
127
+ explicitRoot?: string;
128
+ }): { home: string; workspace?: string; actor?: string; source: string } {
129
+ const env = input.env ?? process.env;
130
+ const root = resolveHome(input.explicitRoot);
131
+
132
+ if (input.flag) {
133
+ return {
134
+ home: localWorkspaceHome(input.flag, input.explicitRoot),
135
+ workspace: input.flag,
136
+ ...(input.actorFlag ? { actor: input.actorFlag } : {}),
137
+ source: 'the --workspace option',
138
+ };
139
+ }
140
+
141
+ const fromEnv = env.SYNOMEM_WORKSPACE?.trim();
142
+ if (fromEnv) {
143
+ return {
144
+ home: localWorkspaceHome(fromEnv, input.explicitRoot),
145
+ workspace: fromEnv,
146
+ ...((input.actorFlag ?? env.SYNOMEM_ACTOR_ID?.trim())
147
+ ? { actor: input.actorFlag ?? env.SYNOMEM_ACTOR_ID?.trim() }
148
+ : {}),
149
+ source: 'SYNOMEM_WORKSPACE',
150
+ };
151
+ }
152
+
153
+ const project = findProjectSelection(input.cwd ?? process.cwd(), root);
154
+ if (project?.workspace) {
155
+ return {
156
+ home: localWorkspaceHome(project.workspace, input.explicitRoot),
157
+ workspace: project.workspace,
158
+ ...((input.actorFlag ?? project.actor) ? { actor: input.actorFlag ?? project.actor } : {}),
159
+ source: join(project.directory, PROJECT_DIRECTORY, PROJECT_CONFIG_FILE),
160
+ };
161
+ }
162
+
163
+ return {
164
+ home: root,
165
+ ...((input.actorFlag ?? project?.actor) ? { actor: input.actorFlag ?? project?.actor } : {}),
166
+ source: 'the default workspace',
167
+ };
168
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync, unlinkSync } from 'node:fs';
1
+ import { existsSync, lstatSync, renameSync, unlinkSync } from 'node:fs';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  import { SynomemError } from './errors.js';
4
4
  import {
@@ -638,6 +638,43 @@ export class ProjectionManager implements ProjectionWriter {
638
638
  return { generated: generated.sort(), removed: removed.sort() };
639
639
  }
640
640
 
641
+ /**
642
+ * Move an agent's directory when its handle changes.
643
+ *
644
+ * Without this a rename left the old directory behind, and with it `NOTES.md`
645
+ * -- the one file in there that is the reader's rather than Synomem's, and so
646
+ * the one file a rebuild will never delete. The generated files moved to the
647
+ * new handle and the hand-written notes stayed at the old one.
648
+ *
649
+ * Moving rather than copy-and-delete, and moving rather than letting the
650
+ * rebuild sort it out, because a rename is a known identity change: the
651
+ * destination is new, so nothing is overwritten, and the human file arrives
652
+ * intact instead of being stranded.
653
+ */
654
+ renameAgentDirectory(previousHandle: string, nextHandle: string): void {
655
+ if (previousHandle === nextHandle) return;
656
+ this.storage.assertWritable();
657
+ const from = join(this.storage.home, previousHandle);
658
+ const to = join(this.storage.home, nextHandle);
659
+ assertNoSymlinkEscape(this.storage.home, from);
660
+ assertNoSymlinkEscape(this.storage.home, to);
661
+ if (!existsSync(from)) return;
662
+ // A directory already at the destination means the handle is in use, which
663
+ // the caller checks before reaching here. Refuse rather than merge.
664
+ if (existsSync(to)) {
665
+ throw new SynomemError(
666
+ 'UNSAFE_PATH',
667
+ `Refusing to rename over an existing directory: ${nextHandle}`,
668
+ );
669
+ }
670
+ const stat = lstatSync(from);
671
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
672
+ throw new SynomemError('UNSAFE_PATH', `Agent directory is not a plain directory: ${from}`);
673
+ }
674
+ renameSync(from, to);
675
+ this.storage.renameProjectionManifestPrefix(previousHandle, nextHandle);
676
+ }
677
+
641
678
  syncAgent(agentId: string): { generated: string[]; removed: string[] } {
642
679
  this.storage.assertWritable();
643
680
  const profile = this.storage.getAgent(agentId);
package/src/remote.ts CHANGED
@@ -647,6 +647,15 @@ export class RemoteSynomemService implements SynomemService {
647
647
  headers: {
648
648
  accept: 'application/json',
649
649
  authorization: `Bearer ${accessToken}`,
650
+ /*
651
+ * Harmless for an OAuth actor token (its workspace and identity are
652
+ * already bound into the token itself), and required for an access
653
+ * key: an access key authenticates the member who owns it, not a
654
+ * machine or a workspace, so which workspace and which agent are
655
+ * meant have to be named on every request.
656
+ */
657
+ 'synomem-workspace-id': this.workspaceId,
658
+ ...(this.actor.kind === 'agent' ? { 'synomem-agent-id': this.actor.id } : {}),
650
659
  ...(body ? { 'content-type': 'application/json' } : {}),
651
660
  ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
652
661
  },
package/src/schemas.ts CHANGED
@@ -9,6 +9,10 @@ const reservedIds = new Set([
9
9
  'synomem',
10
10
  'exports',
11
11
  'inbox',
12
+ // Named local workspaces live at `<home>/workspaces/<name>`, and projected
13
+ // agent directories at `<home>/<handle>` — so an agent called `workspaces`
14
+ // would collide with them.
15
+ 'workspaces',
12
16
  'con',
13
17
  'prn',
14
18
  'aux',
@@ -131,18 +135,46 @@ export const evidenceSchema = z
131
135
  }
132
136
  });
133
137
 
134
- export const profileSchema = z.object({
135
- /** Canonical, opaque and immutable. Events reference this, never the handle. */
136
- id: agentIdSchema,
137
- handle: agentHandleSchema,
138
- displayName: z.string().trim().min(1).max(200),
139
- aliases: z.array(agentAliasSchema).max(50).optional(),
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'),
143
- createdAt: z.string().datetime({ offset: true }),
144
- metadata: metadataSchema.optional(),
145
- });
138
+ /**
139
+ * An agent profile, tolerant of the shape written before handles existed.
140
+ *
141
+ * Agents gained a mutable handle alongside their canonical ID in schema 7.
142
+ * Every `agent.created` event written before that carries an id and no handle,
143
+ * and those events are in an append-only log: rewriting them to add the field
144
+ * is precisely what such a log exists to prevent. So the READER widens instead.
145
+ *
146
+ * A missing handle means the record predates handles, and back then the ID *was*
147
+ * the name somebody typed — so the ID is not a placeholder here, it is the
148
+ * correct handle. Without this, one pre-7 agent made the compatibility check
149
+ * refuse the whole event stream, and every write in that workspace failed with
150
+ * `UNSUPPORTED_EVENT`.
151
+ */
152
+ export const profileSchema = z.preprocess(
153
+ (value) => {
154
+ if (
155
+ value !== null &&
156
+ typeof value === 'object' &&
157
+ !Array.isArray(value) &&
158
+ !('handle' in value) &&
159
+ typeof (value as { id?: unknown }).id === 'string'
160
+ ) {
161
+ return { ...(value as Record<string, unknown>), handle: (value as { id: string }).id };
162
+ }
163
+ return value;
164
+ },
165
+ z.object({
166
+ /** Canonical, opaque and immutable. Events reference this, never the handle. */
167
+ id: agentIdSchema,
168
+ handle: agentHandleSchema,
169
+ displayName: z.string().trim().min(1).max(200),
170
+ aliases: z.array(agentAliasSchema).max(50).optional(),
171
+ description: z.string().trim().max(2000).optional(),
172
+ /** Archived agents keep their history and stop being able to act. */
173
+ status: z.enum(['active', 'archived']).default('active'),
174
+ createdAt: z.string().datetime({ offset: true }),
175
+ metadata: metadataSchema.optional(),
176
+ }),
177
+ );
146
178
 
147
179
  /**
148
180
  * Creating an agent names a handle; the canonical ID is generated, never
package/src/service.ts CHANGED
@@ -53,6 +53,7 @@ export interface SynomemServiceCapabilities {
53
53
  };
54
54
  administration: {
55
55
  agentCreationViaMcp: boolean;
56
+ agentArchiveViaMcp: boolean;
56
57
  rebuildViaMcp: boolean;
57
58
  };
58
59
  projections: {
package/src/storage.ts CHANGED
@@ -2041,6 +2041,34 @@ export class SynomemStorage implements SynomemRepository {
2041
2041
  ).map((row) => row.path);
2042
2042
  }
2043
2043
 
2044
+ /**
2045
+ * Re-point manifest paths from one agent directory to another.
2046
+ *
2047
+ * The manifest records what Synomem generated and is what the stale-file
2048
+ * cleanup consults. After a directory moves, entries still naming the old
2049
+ * handle describe files that are no longer there, and the moved ones would
2050
+ * look unaccounted for.
2051
+ *
2052
+ * Rewritten row by row rather than with a LIKE update: a manifest is small,
2053
+ * and comparing an exact path prefix needs no thought about what characters
2054
+ * a pattern would treat specially.
2055
+ */
2056
+ renameProjectionManifestPrefix(previousHandle: string, nextHandle: string): void {
2057
+ const prefixes = [`${previousHandle}/`, `${previousHandle}\\`];
2058
+ const rows = this.projectionManifestEntries().filter((entry) =>
2059
+ prefixes.some((prefix) => entry.path.startsWith(prefix)),
2060
+ );
2061
+ if (!rows.length) return;
2062
+ const remove = this.db().prepare('DELETE FROM projection_manifest WHERE path = ?');
2063
+ const insert = this.db().prepare(
2064
+ 'INSERT OR REPLACE INTO projection_manifest(path, generated_at) VALUES (?, ?)',
2065
+ );
2066
+ for (const row of rows) {
2067
+ remove.run(row.path);
2068
+ insert.run(`${nextHandle}${row.path.slice(previousHandle.length)}`, row.generatedAt);
2069
+ }
2070
+ }
2071
+
2044
2072
  /** The manifest with the time each path was written, newest first. */
2045
2073
  projectionManifestEntries(): { path: string; generatedAt: string }[] {
2046
2074
  return (
package/src/types.ts CHANGED
@@ -770,6 +770,7 @@ export interface SynomemConfig {
770
770
  allowSelfAwards: boolean;
771
771
  allowCrossAgentTasks: boolean;
772
772
  allowAgentCreationViaMcp: boolean;
773
+ allowAgentArchiveViaMcp: boolean;
773
774
  allowRebuildViaMcp: boolean;
774
775
  includePrivateInStats: boolean;
775
776
  projection: {