synomem 0.5.2 → 0.6.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 (66) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +30 -12
  3. package/dist/cli.d.ts +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +205 -32
  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/import.d.ts +4 -4
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/mcp/index.d.ts.map +1 -1
  20. package/dist/mcp/index.js +49 -3
  21. package/dist/mcp/index.js.map +1 -1
  22. package/dist/mcp-server.js +26 -3
  23. package/dist/mcp-server.js.map +1 -1
  24. package/dist/ports/projections.d.ts +8 -0
  25. package/dist/ports/projections.d.ts.map +1 -1
  26. package/dist/project.d.ts +51 -0
  27. package/dist/project.d.ts.map +1 -0
  28. package/dist/project.js +143 -0
  29. package/dist/project.js.map +1 -0
  30. package/dist/projections.d.ts +14 -0
  31. package/dist/projections.d.ts.map +1 -1
  32. package/dist/projections.js +36 -1
  33. package/dist/projections.js.map +1 -1
  34. package/dist/schemas.d.ts +18 -4
  35. package/dist/schemas.d.ts.map +1 -1
  36. package/dist/schemas.js +29 -2
  37. package/dist/schemas.js.map +1 -1
  38. package/dist/service.d.ts +1 -0
  39. package/dist/service.d.ts.map +1 -1
  40. package/dist/storage.d.ts +13 -0
  41. package/dist/storage.d.ts.map +1 -1
  42. package/dist/storage.js +24 -0
  43. package/dist/storage.js.map +1 -1
  44. package/dist/types.d.ts +1 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/workspaces.d.ts +41 -0
  47. package/dist/workspaces.d.ts.map +1 -0
  48. package/dist/workspaces.js +96 -0
  49. package/dist/workspaces.js.map +1 -0
  50. package/package.json +1 -1
  51. package/skills/synomem/SKILL.md +9 -3
  52. package/skills/synomem/references/examples.md +5 -3
  53. package/src/cli.ts +245 -32
  54. package/src/client.ts +10 -0
  55. package/src/config.ts +4 -0
  56. package/src/index.ts +17 -0
  57. package/src/mcp/index.ts +65 -3
  58. package/src/mcp-server.ts +32 -5
  59. package/src/ports/projections.ts +9 -0
  60. package/src/project.ts +168 -0
  61. package/src/projections.ts +38 -1
  62. package/src/schemas.ts +44 -12
  63. package/src/service.ts +1 -0
  64. package/src/storage.ts +28 -0
  65. package/src/types.ts +1 -0
  66. package/src/workspaces.ts +107 -0
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
  {
@@ -516,7 +578,7 @@ export async function createSynomemMcpServer(
516
578
  {
517
579
  title: 'List Synomem items',
518
580
  description:
519
- 'Discover a bounded page of compact kudos, memo, note, and task summaries. Full bodies, reasons, evidence, descriptions, source, and metadata are omitted; use synomem_get for one selected item.',
581
+ 'Discover a bounded page of compact kudos, memo, note, post, task, and todo summaries. Pass kinds to narrow it: posts and todos are reachable only this way, because synomem_inbox holds only what another actor is waiting on. Full bodies, reasons, evidence, descriptions, source, and metadata are omitted; use synomem_get for one selected item.',
520
582
  inputSchema: itemListInputSchema,
521
583
  outputSchema,
522
584
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
@@ -540,7 +602,7 @@ export async function createSynomemMcpServer(
540
602
  {
541
603
  title: 'Get one Synomem item',
542
604
  description:
543
- 'Read the full authorized record for one explicitly selected kudos, memo, note, or task ID.',
605
+ 'Read the full authorized record for one explicitly selected kudos, memo, note, post, task, or todo ID.',
544
606
  inputSchema: z.object({ itemId: z.string().length(26) }),
545
607
  outputSchema,
546
608
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
@@ -584,7 +646,7 @@ export async function createSynomemMcpServer(
584
646
  {
585
647
  title: 'Review an agent inbox',
586
648
  description:
587
- 'Return compact pending kudos, unread memos, and open tasks for the configured agent. An agent may inspect only its own private items.',
649
+ 'Return compact pending kudos, unread memos, and open tasks for the configured agent -- what another actor is waiting on it for, and nothing else. Notes, posts, and todos are never here, because nobody is waiting: reach those through synomem_list with kinds. An agent may inspect only its own private items.',
588
650
  inputSchema: z.object({
589
651
  limit: z.number().int().min(1).max(50).default(10),
590
652
  cursor: z.string().max(500).optional(),
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/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: {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Local workspaces.
3
+ *
4
+ * On Synomem Cloud a workspace is a row, and isolation is enforced by the
5
+ * database: every record table carries `workspace_id`, has row-level security
6
+ * enabled, and every query runs with `synomem.workspace_id` set — so a query
7
+ * that forgets to filter returns nothing rather than another workspace's rows.
8
+ *
9
+ * SQLite has no row-level security, so the same schema locally would not buy
10
+ * the same guarantee: isolation would rest on every one of eighty query sites
11
+ * staying correct, with nothing underneath to catch a miss, and a miss would
12
+ * silently mix workspaces rather than fail. So a local workspace is a separate
13
+ * DATABASE — its own home, its own file. The filesystem does the isolating, and
14
+ * cross-workspace leakage stops being a thing anybody can write by accident.
15
+ *
16
+ * That is also why nothing downstream needs to know. A local workspace resolves
17
+ * to a home and a hosted one resolves to an id, both at the single seam in
18
+ * `backend.ts` that already chooses between the two backends. No domain code,
19
+ * CLI command or MCP tool can tell which it got.
20
+ */
21
+ import { existsSync, lstatSync, readdirSync } from 'node:fs';
22
+ import { join } from 'node:path';
23
+ import { z } from 'zod';
24
+ import { SynomemError } from './errors.js';
25
+ import { resolveHome } from './config.js';
26
+
27
+ /**
28
+ * The directory named workspaces live under, relative to the root.
29
+ *
30
+ * `workspaces` is a reserved agent handle for exactly this reason: projected
31
+ * agent directories sit at `<home>/<handle>/`, so an agent allowed to call
32
+ * itself `workspaces` would collide with this.
33
+ */
34
+ export const WORKSPACES_DIRECTORY = 'workspaces';
35
+
36
+ /**
37
+ * The workspace whose home is the root itself.
38
+ *
39
+ * The root stays a complete Synomem home rather than becoming a container, so
40
+ * an existing install keeps its database exactly where it is and needs no
41
+ * migration. Named workspaces are added alongside it.
42
+ */
43
+ export const DEFAULT_WORKSPACE = 'default';
44
+
45
+ /** Same shape as an agent handle: a name somebody types, and a directory name. */
46
+ export const workspaceNameSchema = z
47
+ .string()
48
+ .trim()
49
+ .min(1)
50
+ .max(63)
51
+ .regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lowercase ASCII letters, digits, and hyphens')
52
+ .refine((name) => name !== WORKSPACES_DIRECTORY, 'Reserved workspace name');
53
+
54
+ export interface LocalWorkspace {
55
+ name: string;
56
+ home: string;
57
+ /** False until `init` or the first write has created the store. */
58
+ initialized: boolean;
59
+ }
60
+
61
+ /**
62
+ * Where a named local workspace lives.
63
+ *
64
+ * The name is validated rather than trusted: it becomes a directory, so a value
65
+ * containing a separator or `..` would escape the root.
66
+ */
67
+ export function localWorkspaceHome(name: string, explicitRoot?: string): string {
68
+ const root = resolveHome(explicitRoot);
69
+ if (name === DEFAULT_WORKSPACE) return root;
70
+ const parsed = workspaceNameSchema.safeParse(name);
71
+ if (!parsed.success) {
72
+ throw new SynomemError(
73
+ 'INVALID_INPUT',
74
+ `Invalid workspace name: ${name}. ${parsed.error.issues[0]?.message ?? ''}`.trim(),
75
+ );
76
+ }
77
+ return join(root, WORKSPACES_DIRECTORY, parsed.data);
78
+ }
79
+
80
+ /**
81
+ * The local workspaces on this machine, discovered from disk.
82
+ *
83
+ * Read from the filesystem rather than a registry file, so a workspace cannot
84
+ * be listed and then turn out not to exist — and a directory copied in by hand
85
+ * is found without having to be registered.
86
+ */
87
+ export function listLocalWorkspaces(explicitRoot?: string): LocalWorkspace[] {
88
+ const root = resolveHome(explicitRoot);
89
+ const found: LocalWorkspace[] = [
90
+ { name: DEFAULT_WORKSPACE, home: root, initialized: existsSync(join(root, 'config.json')) },
91
+ ];
92
+
93
+ const container = join(root, WORKSPACES_DIRECTORY);
94
+ if (!existsSync(container)) return found;
95
+ for (const entry of readdirSync(container, { withFileTypes: true })) {
96
+ // A symbolic link here would point the store outside the root.
97
+ if (!entry.isDirectory() || lstatSync(join(container, entry.name)).isSymbolicLink()) continue;
98
+ if (!workspaceNameSchema.safeParse(entry.name).success) continue;
99
+ const home = join(container, entry.name);
100
+ found.push({
101
+ name: entry.name,
102
+ home,
103
+ initialized: existsSync(join(home, 'config.json')),
104
+ });
105
+ }
106
+ return found.sort((left, right) => left.name.localeCompare(right.name));
107
+ }