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.
- package/CHANGELOG.md +64 -0
- package/README.md +30 -12
- package/dist/cli.d.ts +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +205 -32
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +10 -0
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +4 -0
- package/dist/config.js.map +1 -1
- package/dist/import.d.ts +4 -4
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +49 -3
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp-server.js +26 -3
- package/dist/mcp-server.js.map +1 -1
- package/dist/ports/projections.d.ts +8 -0
- package/dist/ports/projections.d.ts.map +1 -1
- package/dist/project.d.ts +51 -0
- package/dist/project.d.ts.map +1 -0
- package/dist/project.js +143 -0
- package/dist/project.js.map +1 -0
- package/dist/projections.d.ts +14 -0
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +36 -1
- package/dist/projections.js.map +1 -1
- package/dist/schemas.d.ts +18 -4
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +29 -2
- package/dist/schemas.js.map +1 -1
- package/dist/service.d.ts +1 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +13 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +24 -0
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/workspaces.d.ts +41 -0
- package/dist/workspaces.d.ts.map +1 -0
- package/dist/workspaces.js +96 -0
- package/dist/workspaces.js.map +1 -0
- package/package.json +1 -1
- package/skills/synomem/SKILL.md +9 -3
- package/skills/synomem/references/examples.md +5 -3
- package/src/cli.ts +245 -32
- package/src/client.ts +10 -0
- package/src/config.ts +4 -0
- package/src/index.ts +17 -0
- package/src/mcp/index.ts +65 -3
- package/src/mcp-server.ts +32 -5
- package/src/ports/projections.ts +9 -0
- package/src/project.ts +168 -0
- package/src/projections.ts +38 -1
- package/src/schemas.ts +44 -12
- package/src/service.ts +1 -0
- package/src/storage.ts +28 -0
- package/src/types.ts +1 -0
- 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,
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
-
...(
|
|
121
|
+
...(home ? { home } : {}),
|
|
95
122
|
});
|
|
96
123
|
}
|
package/src/ports/projections.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/projections.ts
CHANGED
|
@@ -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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
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
|
+
}
|