synomem 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +82 -1
- package/README.md +54 -14
- package/dist/backend.d.ts.map +1 -1
- package/dist/backend.js +8 -2
- package/dist/backend.js.map +1 -1
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +451 -24
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +34 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +162 -11
- package/dist/client.js.map +1 -1
- package/dist/cloud.d.ts +16 -0
- package/dist/cloud.d.ts.map +1 -0
- package/dist/cloud.js +19 -0
- package/dist/cloud.js.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +8 -1
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts +55 -0
- package/dist/configure.d.ts.map +1 -0
- package/dist/configure.js +193 -0
- package/dist/configure.js.map +1 -0
- package/dist/credentials.d.ts +15 -2
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +9 -1
- package/dist/credentials.js.map +1 -1
- package/dist/discover.d.ts +48 -0
- package/dist/discover.d.ts.map +1 -0
- package/dist/discover.js +106 -0
- package/dist/discover.js.map +1 -0
- package/dist/import.d.ts +58 -43
- package/dist/import.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- 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 +15 -6
- package/dist/mcp/index.js.map +1 -1
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +8 -1
- package/dist/oauth.js.map +1 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +19 -11
- package/dist/projections.js.map +1 -1
- package/dist/prompt.d.ts +28 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +72 -0
- package/dist/prompt.js.map +1 -0
- package/dist/remote.d.ts +4 -0
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +4 -0
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +101 -60
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +57 -5
- package/dist/schemas.js.map +1 -1
- package/dist/service.d.ts +6 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +19 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +61 -14
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +50 -2
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +68 -4
- package/docs/examples.md +1 -1
- package/docs/mcp.md +1 -1
- package/docs/skill.md +1 -1
- package/docs/storage-format.md +1 -1
- package/package.json +8 -8
- package/src/backend.ts +8 -2
- package/src/cli.ts +597 -31
- package/src/client.ts +176 -12
- package/src/cloud.ts +19 -0
- package/src/config.ts +8 -1
- package/src/configure.ts +249 -0
- package/src/credentials.ts +26 -3
- package/src/discover.ts +155 -0
- package/src/index.ts +9 -1
- package/src/mcp/index.ts +19 -5
- package/src/oauth.ts +8 -1
- package/src/projections.ts +21 -11
- package/src/prompt.ts +88 -0
- package/src/remote.ts +24 -0
- package/src/schemas.ts +60 -5
- package/src/service.ts +12 -0
- package/src/storage.ts +79 -13
- package/src/types.ts +45 -2
package/src/discover.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Finding out which workspace a credential belongs to, so nobody has to type
|
|
3
|
+
* one from memory.
|
|
4
|
+
*
|
|
5
|
+
* A hosted workspace ID looks like `ws-04psqx2rkt8ttft7a1t2z69r97`. Asking a
|
|
6
|
+
* person to enter that during setup is asking them to leave and go find it, and
|
|
7
|
+
* the credential they just authorized already knows the answer -- or knows
|
|
8
|
+
* enough to offer a short list.
|
|
9
|
+
*
|
|
10
|
+
* Two credentials, two routes, because they carry different authority:
|
|
11
|
+
*
|
|
12
|
+
* An installation access key is bound to exactly one workspace. There is
|
|
13
|
+
* nothing to choose, so `discoverBoundWorkspace` reads it off the data plane
|
|
14
|
+
* and setup asks nothing at all.
|
|
15
|
+
*
|
|
16
|
+
* A browser sign-in authorizes an ACCOUNT, which may reach several
|
|
17
|
+
* organizations, each with several workspaces. `discoverOrganizations` lists
|
|
18
|
+
* them for selection.
|
|
19
|
+
*/
|
|
20
|
+
import { SynomemError } from './errors.js';
|
|
21
|
+
|
|
22
|
+
export interface DiscoveredWorkspace {
|
|
23
|
+
id: string;
|
|
24
|
+
displayName: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DiscoveredOrganization {
|
|
28
|
+
id: string;
|
|
29
|
+
slug: string;
|
|
30
|
+
displayName: string;
|
|
31
|
+
role: string;
|
|
32
|
+
workspaces: DiscoveredWorkspace[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DiscoveryOptions {
|
|
36
|
+
baseUrl: string;
|
|
37
|
+
accessToken: string;
|
|
38
|
+
/** Injected by tests. Defaults to the global fetch. */
|
|
39
|
+
fetch?: typeof globalThis.fetch;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Envelope<T> {
|
|
44
|
+
ok?: boolean;
|
|
45
|
+
data?: T;
|
|
46
|
+
error?: { code?: string; message?: string };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function readJson<T>(options: DiscoveryOptions, path: string): Promise<T> {
|
|
50
|
+
const request = options.fetch ?? globalThis.fetch;
|
|
51
|
+
const url = new URL(
|
|
52
|
+
path,
|
|
53
|
+
options.baseUrl.endsWith('/') ? options.baseUrl : `${options.baseUrl}/`,
|
|
54
|
+
);
|
|
55
|
+
let response: Response;
|
|
56
|
+
try {
|
|
57
|
+
response = await request(url, {
|
|
58
|
+
headers: { authorization: `Bearer ${options.accessToken}`, accept: 'application/json' },
|
|
59
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
60
|
+
});
|
|
61
|
+
} catch (error) {
|
|
62
|
+
throw new SynomemError(
|
|
63
|
+
'REMOTE_UNAVAILABLE',
|
|
64
|
+
`Could not reach ${url.origin}: ${error instanceof Error ? error.message : String(error)}`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
let body: Envelope<T>;
|
|
68
|
+
try {
|
|
69
|
+
body = (await response.json()) as Envelope<T>;
|
|
70
|
+
} catch {
|
|
71
|
+
throw new SynomemError('REMOTE_PROTOCOL', `${url.pathname} did not return JSON.`);
|
|
72
|
+
}
|
|
73
|
+
if (!response.ok || body.ok !== true || body.data === undefined) {
|
|
74
|
+
// 401 and 403 are the ones a person can act on, so they keep their own
|
|
75
|
+
// codes rather than being flattened into a generic protocol error.
|
|
76
|
+
const code =
|
|
77
|
+
response.status === 401 ? 'AUTH_REQUIRED' : response.status === 403 ? 'AUTH_FORBIDDEN' : null;
|
|
78
|
+
const message = body.error?.message ?? `${url.pathname} returned ${response.status}.`;
|
|
79
|
+
throw new SynomemError(code ?? 'REMOTE_PROTOCOL', message);
|
|
80
|
+
}
|
|
81
|
+
return body.data;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The single workspace an installation access key can address.
|
|
86
|
+
*
|
|
87
|
+
* Answered by the data plane rather than the control plane: an installation key
|
|
88
|
+
* is not an account principal, so it cannot list organizations, but it can
|
|
89
|
+
* always say where it is bound.
|
|
90
|
+
*/
|
|
91
|
+
export async function discoverBoundWorkspace(
|
|
92
|
+
options: DiscoveryOptions,
|
|
93
|
+
): Promise<{ workspaceId: string; actor: { kind: string; id: string; displayName?: string } }> {
|
|
94
|
+
const identity = await readJson<{
|
|
95
|
+
workspaceId: string;
|
|
96
|
+
actor: { kind: string; id: string; displayName?: string };
|
|
97
|
+
}>(options, 'v1/identity');
|
|
98
|
+
if (!identity.workspaceId) {
|
|
99
|
+
throw new SynomemError('REMOTE_PROTOCOL', 'The service did not report a bound workspace.');
|
|
100
|
+
}
|
|
101
|
+
return { workspaceId: identity.workspaceId, actor: identity.actor };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Every organization this account belongs to, each with its workspaces.
|
|
106
|
+
*
|
|
107
|
+
* Organizations are listed even when they hold no workspaces yet, because
|
|
108
|
+
* "you belong to this organization and it is empty" is a different and more
|
|
109
|
+
* useful answer than omitting it and appearing to have found nothing.
|
|
110
|
+
*/
|
|
111
|
+
export async function discoverOrganizations(
|
|
112
|
+
options: DiscoveryOptions,
|
|
113
|
+
): Promise<DiscoveredOrganization[]> {
|
|
114
|
+
const me = await readJson<{
|
|
115
|
+
organizations: { id: string; slug: string; displayName: string; role: string }[];
|
|
116
|
+
}>(options, 'v1/me');
|
|
117
|
+
const organizations: DiscoveredOrganization[] = [];
|
|
118
|
+
for (const organization of me.organizations ?? []) {
|
|
119
|
+
const workspaces = await readJson<{ id: string; displayName: string }[]>(
|
|
120
|
+
options,
|
|
121
|
+
`v1/organizations/${encodeURIComponent(organization.id)}/workspaces`,
|
|
122
|
+
);
|
|
123
|
+
organizations.push({
|
|
124
|
+
id: organization.id,
|
|
125
|
+
slug: organization.slug,
|
|
126
|
+
displayName: organization.displayName,
|
|
127
|
+
role: organization.role,
|
|
128
|
+
workspaces: (workspaces ?? []).map((workspace) => ({
|
|
129
|
+
id: workspace.id,
|
|
130
|
+
displayName: workspace.displayName,
|
|
131
|
+
})),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return organizations;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Flattens discovery into the choices a person picks from. */
|
|
138
|
+
export function workspaceChoices(
|
|
139
|
+
organizations: DiscoveredOrganization[],
|
|
140
|
+
): Array<{ value: string; label: string; detail?: string }> {
|
|
141
|
+
const choices: Array<{ value: string; label: string; detail?: string }> = [];
|
|
142
|
+
for (const organization of organizations) {
|
|
143
|
+
for (const workspace of organization.workspaces) {
|
|
144
|
+
choices.push({
|
|
145
|
+
value: workspace.id,
|
|
146
|
+
// The organization is part of the label, not the detail: two
|
|
147
|
+
// organizations may both have a workspace called "Production", and the
|
|
148
|
+
// label is the only part a person is guaranteed to read.
|
|
149
|
+
label: `${organization.displayName} / ${workspace.displayName}`,
|
|
150
|
+
detail: workspace.id,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return choices;
|
|
155
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -11,7 +11,12 @@ export {
|
|
|
11
11
|
export { RemoteSynomemService, environmentCredentialProvider } from './remote.js';
|
|
12
12
|
export type { SynomemCredentialProvider, RemoteSynomemOptions } from './remote.js';
|
|
13
13
|
export { credentialReference, OsCredentialStore } from './credentials.js';
|
|
14
|
-
export type {
|
|
14
|
+
export type {
|
|
15
|
+
CredentialStore,
|
|
16
|
+
StoredCredential,
|
|
17
|
+
StoredInstallationKey,
|
|
18
|
+
StoredOAuthCredential,
|
|
19
|
+
} from './credentials.js';
|
|
15
20
|
export { loginWithOAuth, StoredCredentialProvider } from './oauth.js';
|
|
16
21
|
export type { OAuthLoginOptions } from './oauth.js';
|
|
17
22
|
export {
|
|
@@ -35,6 +40,9 @@ export type {
|
|
|
35
40
|
ProjectionRebuildResult,
|
|
36
41
|
} from './service.js';
|
|
37
42
|
export { defaultConfig, resolveHome } from './config.js';
|
|
43
|
+
export { cloudApiUrl, SYNOMEM_CLOUD_API_URL } from './cloud.js';
|
|
44
|
+
export { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
|
|
45
|
+
export type { DiscoveredOrganization, DiscoveredWorkspace, DiscoveryOptions } from './discover.js';
|
|
38
46
|
export { asSynomemError, errorCodes, SynomemError } from './errors.js';
|
|
39
47
|
export {
|
|
40
48
|
dueInstant,
|
package/src/mcp/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { configuredServiceFactory } from '../backend.js';
|
|
|
6
6
|
import { SynomemError, asSynomemError } from '../errors.js';
|
|
7
7
|
import {
|
|
8
8
|
actorSchema,
|
|
9
|
+
agentHandleSchema,
|
|
9
10
|
agentIdSchema,
|
|
10
11
|
changesInputSchema,
|
|
11
12
|
createNoteSchema,
|
|
@@ -88,9 +89,18 @@ export async function createSynomemMcpServer(
|
|
|
88
89
|
options: SynomemMcpOptions,
|
|
89
90
|
serviceFactory: SynomemServiceFactory = configuredServiceFactory,
|
|
90
91
|
): Promise<SynomemMcpRuntime> {
|
|
91
|
-
const
|
|
92
|
-
const client = serviceFactory({ ...options, actor });
|
|
92
|
+
const requested = actorSchema.parse(options.actor);
|
|
93
|
+
const client = serviceFactory({ ...options, actor: requested });
|
|
93
94
|
await client.init();
|
|
95
|
+
/*
|
|
96
|
+
* Every tool reports the CANONICAL actor, not the one that was asked for.
|
|
97
|
+
*
|
|
98
|
+
* A harness registers with a handle because that is what a person typed, but
|
|
99
|
+
* init resolves it against stored state — so the identity echoed back is the
|
|
100
|
+
* one the events will actually carry. Reporting the requested name would let
|
|
101
|
+
* a misconfigured runtime appear to be acting as somebody it is not.
|
|
102
|
+
*/
|
|
103
|
+
const actor = client.actor;
|
|
94
104
|
const server = new McpServer(
|
|
95
105
|
{ name: 'synomem', version: packageVersion() },
|
|
96
106
|
{
|
|
@@ -282,9 +292,9 @@ export async function createSynomemMcpServer(
|
|
|
282
292
|
description:
|
|
283
293
|
'Administrative tool for creating a stable agent identity. Disabled by default so runtime agents cannot silently create identities.',
|
|
284
294
|
inputSchema: z.object({
|
|
285
|
-
|
|
295
|
+
handle: agentHandleSchema,
|
|
286
296
|
displayName: z.string().trim().min(1).max(200),
|
|
287
|
-
aliases: z.array(
|
|
297
|
+
aliases: z.array(agentHandleSchema).max(50).optional(),
|
|
288
298
|
description: z.string().trim().max(2000).optional(),
|
|
289
299
|
}),
|
|
290
300
|
outputSchema,
|
|
@@ -300,7 +310,11 @@ export async function createSynomemMcpServer(
|
|
|
300
310
|
);
|
|
301
311
|
}
|
|
302
312
|
const profile = await client.agents.create(input);
|
|
303
|
-
return success(
|
|
313
|
+
return success(
|
|
314
|
+
actor,
|
|
315
|
+
`Created agent ${profile.displayName}: handle ${profile.handle}, ID ${profile.id}.`,
|
|
316
|
+
{ profile },
|
|
317
|
+
);
|
|
304
318
|
} catch (error) {
|
|
305
319
|
return failure(actor, error);
|
|
306
320
|
}
|
package/src/oauth.ts
CHANGED
|
@@ -207,7 +207,14 @@ export class StoredCredentialProvider implements SynomemCredentialProvider {
|
|
|
207
207
|
async getAccessToken(signal?: AbortSignal): Promise<string | undefined> {
|
|
208
208
|
if (this.env.SYNOMEM_ACCESS_TOKEN) return this.env.SYNOMEM_ACCESS_TOKEN;
|
|
209
209
|
if (!this.loaded) {
|
|
210
|
-
|
|
210
|
+
const stored = await this.store.get(this.reference);
|
|
211
|
+
/*
|
|
212
|
+
* An installation key is not an OAuth credential: it cannot be refreshed
|
|
213
|
+
* and has no client or token endpoint. Treating one as OAuth would mean
|
|
214
|
+
* trying to renew something that never renews that way, so this path
|
|
215
|
+
* ignores it and lets the installation-key path handle it.
|
|
216
|
+
*/
|
|
217
|
+
this.credential = stored && 'kind' in stored ? undefined : stored;
|
|
211
218
|
this.loaded = true;
|
|
212
219
|
}
|
|
213
220
|
const credential = this.credential;
|
package/src/projections.ts
CHANGED
|
@@ -550,7 +550,7 @@ export class ProjectionManager implements ProjectionWriter {
|
|
|
550
550
|
const generated: string[] = [];
|
|
551
551
|
|
|
552
552
|
for (const profile of profiles) {
|
|
553
|
-
const agentDirectory = join(this.storage.home, profile.
|
|
553
|
+
const agentDirectory = join(this.storage.home, profile.handle);
|
|
554
554
|
assertNoSymlinkEscape(this.storage.home, agentDirectory);
|
|
555
555
|
ensureDirectory(agentDirectory);
|
|
556
556
|
const profilePath = join(agentDirectory, 'profile.json');
|
|
@@ -656,7 +656,7 @@ export class ProjectionManager implements ProjectionWriter {
|
|
|
656
656
|
(record) => record.event.assigneeAgentId === profile.id,
|
|
657
657
|
);
|
|
658
658
|
const rebuiltAt = events.at(-1)?.createdAt ?? new Date(0).toISOString();
|
|
659
|
-
const agentDirectory = join(this.storage.home, profile.
|
|
659
|
+
const agentDirectory = join(this.storage.home, profile.handle);
|
|
660
660
|
const inboxDirectory = join(agentDirectory, 'inbox');
|
|
661
661
|
const kudosInboxDirectory = join(inboxDirectory, 'kudos');
|
|
662
662
|
const memoInboxDirectory = join(inboxDirectory, 'memos');
|
|
@@ -729,7 +729,7 @@ export class ProjectionManager implements ProjectionWriter {
|
|
|
729
729
|
}
|
|
730
730
|
|
|
731
731
|
const keep = new Set(generated);
|
|
732
|
-
const prefixes = [`${profile.
|
|
732
|
+
const prefixes = [`${profile.handle}/`, `${profile.handle}\\`];
|
|
733
733
|
const removed: string[] = [];
|
|
734
734
|
for (const stale of this.storage
|
|
735
735
|
.projectionManifest()
|
|
@@ -745,10 +745,18 @@ export class ProjectionManager implements ProjectionWriter {
|
|
|
745
745
|
unlinkSync(path);
|
|
746
746
|
removed.push(stale);
|
|
747
747
|
}
|
|
748
|
-
this.storage.replaceAgentProjectionManifest(profile.
|
|
748
|
+
this.storage.replaceAgentProjectionManifest(profile.handle, generated, rebuiltAt);
|
|
749
749
|
return { generated: generated.sort(), removed: removed.sort() };
|
|
750
750
|
}
|
|
751
751
|
|
|
752
|
+
/*
|
|
753
|
+
* The paths a rebuild would produce, for comparison against the manifest.
|
|
754
|
+
*
|
|
755
|
+
* Directories are named by HANDLE, matching what the writers above create,
|
|
756
|
+
* while the record filters match on the canonical agent ID, which is what
|
|
757
|
+
* stored events carry. Mixing the two up makes the comparison never agree,
|
|
758
|
+
* and `doctor` then reports every workspace's projections as stale forever.
|
|
759
|
+
*/
|
|
752
760
|
expectedPaths(): string[] {
|
|
753
761
|
const profiles = this.storage.listAgents();
|
|
754
762
|
const events = this.storage.getReadableEvents();
|
|
@@ -757,10 +765,12 @@ export class ProjectionManager implements ProjectionWriter {
|
|
|
757
765
|
const tasks = taskRecordsFromEvents(events);
|
|
758
766
|
const paths: string[] = [];
|
|
759
767
|
for (const profile of profiles) {
|
|
760
|
-
paths.push(`${profile.
|
|
761
|
-
if (this.storage.config.projection.writeWinsMarkdown) paths.push(`${profile.
|
|
762
|
-
if (this.storage.config.projection.writeMemoryMarkdown)
|
|
763
|
-
|
|
768
|
+
paths.push(`${profile.handle}/profile.json`);
|
|
769
|
+
if (this.storage.config.projection.writeWinsMarkdown) paths.push(`${profile.handle}/WINS.md`);
|
|
770
|
+
if (this.storage.config.projection.writeMemoryMarkdown)
|
|
771
|
+
paths.push(`${profile.handle}/MEMORY.md`);
|
|
772
|
+
if (this.storage.config.projection.writeTasksMarkdown)
|
|
773
|
+
paths.push(`${profile.handle}/TASKS.md`);
|
|
764
774
|
if (this.storage.config.projection.writeInboxEntries) {
|
|
765
775
|
for (const record of records.filter(
|
|
766
776
|
(item) =>
|
|
@@ -768,19 +778,19 @@ export class ProjectionManager implements ProjectionWriter {
|
|
|
768
778
|
item.status === 'unacknowledged' &&
|
|
769
779
|
item.revocationStatus === 'active',
|
|
770
780
|
)) {
|
|
771
|
-
paths.push(`${profile.
|
|
781
|
+
paths.push(`${profile.handle}/inbox/kudos/${record.event.id}.md`);
|
|
772
782
|
}
|
|
773
783
|
for (const record of memos.filter(
|
|
774
784
|
(item) => item.event.recipientAgentId === profile.id && item.status === 'unread',
|
|
775
785
|
)) {
|
|
776
|
-
paths.push(`${profile.
|
|
786
|
+
paths.push(`${profile.handle}/inbox/memos/${record.event.id}.md`);
|
|
777
787
|
}
|
|
778
788
|
for (const record of tasks.filter(
|
|
779
789
|
(item) =>
|
|
780
790
|
item.event.assigneeAgentId === profile.id &&
|
|
781
791
|
(item.status === 'open' || item.status === 'assigned'),
|
|
782
792
|
)) {
|
|
783
|
-
paths.push(`${profile.
|
|
793
|
+
paths.push(`${profile.handle}/inbox/tasks/${record.event.id}.md`);
|
|
784
794
|
}
|
|
785
795
|
}
|
|
786
796
|
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small prompt boundary, injectable so the wizard is testable.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately not a dependency: the wizard needs a select, a line, a masked
|
|
5
|
+
* line and a confirm, and a library for that would be more surface than
|
|
6
|
+
* substance. Everything reads from an injected stream, so tests drive the flow
|
|
7
|
+
* without a terminal.
|
|
8
|
+
*/
|
|
9
|
+
import { createInterface } from 'node:readline/promises';
|
|
10
|
+
import type { Readable, Writable } from 'node:stream';
|
|
11
|
+
|
|
12
|
+
export interface PromptIo {
|
|
13
|
+
input: Readable;
|
|
14
|
+
output: Writable;
|
|
15
|
+
/**
|
|
16
|
+
* Whether a person is actually there. A wizard must never wait forever on a
|
|
17
|
+
* pipe, so a non-interactive stream is refused with instructions instead.
|
|
18
|
+
*/
|
|
19
|
+
interactive: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function defaultPromptIo(): PromptIo {
|
|
23
|
+
return {
|
|
24
|
+
input: process.stdin,
|
|
25
|
+
output: process.stdout,
|
|
26
|
+
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function readLine(io: PromptIo, question: string): Promise<string> {
|
|
31
|
+
const rl = createInterface({ input: io.input, output: io.output, terminal: io.interactive });
|
|
32
|
+
try {
|
|
33
|
+
return (await rl.question(question)).trim();
|
|
34
|
+
} finally {
|
|
35
|
+
rl.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function ask(io: PromptIo, question: string, fallback?: string): Promise<string> {
|
|
40
|
+
const suffix = fallback ? ` [${fallback}]` : '';
|
|
41
|
+
const answer = await readLine(io, `${question}${suffix}: `);
|
|
42
|
+
return answer || fallback || '';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function confirm(io: PromptIo, question: string): Promise<boolean> {
|
|
46
|
+
const answer = await readLine(io, `${question} [y/N]: `);
|
|
47
|
+
return /^y(es)?$/i.test(answer);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Reads a secret from a non-interactive stream, or prompts for one.
|
|
52
|
+
*
|
|
53
|
+
* Piping a secret in is the SAFE path and is why this accepts a closed stream
|
|
54
|
+
* rather than refusing it: a token passed as a command-line argument is kept by
|
|
55
|
+
* both the shell history and the process list, so `--access-token-stdin` has to
|
|
56
|
+
* work without a terminal.
|
|
57
|
+
*/
|
|
58
|
+
export async function askSecret(io: PromptIo, question: string): Promise<string> {
|
|
59
|
+
if (!io.interactive) {
|
|
60
|
+
const chunks: Buffer[] = [];
|
|
61
|
+
for await (const chunk of io.input) chunks.push(Buffer.from(chunk as Uint8Array));
|
|
62
|
+
return Buffer.concat(chunks).toString('utf8').trim();
|
|
63
|
+
}
|
|
64
|
+
// Readline echoes, so an interactive secret is read the same way and the
|
|
65
|
+
// caller is told not to expect masking rather than being silently exposed.
|
|
66
|
+
io.output.write('The value you type will be visible. Paste it, or pipe it in instead.\n');
|
|
67
|
+
return await readLine(io, `${question}: `);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function select<T extends string>(
|
|
71
|
+
io: PromptIo,
|
|
72
|
+
question: string,
|
|
73
|
+
choices: Array<{ value: T; label: string; detail?: string }>,
|
|
74
|
+
): Promise<T> {
|
|
75
|
+
io.output.write(`\n${question}\n\n`);
|
|
76
|
+
choices.forEach((choice, index) => {
|
|
77
|
+
io.output.write(` ${index + 1}) ${choice.label}\n`);
|
|
78
|
+
if (choice.detail) io.output.write(` ${choice.detail}\n`);
|
|
79
|
+
});
|
|
80
|
+
io.output.write('\n');
|
|
81
|
+
for (;;) {
|
|
82
|
+
const answer = await readLine(io, `Choose 1-${choices.length} [1]: `);
|
|
83
|
+
const index = Number(answer || '1');
|
|
84
|
+
const choice = choices[index - 1];
|
|
85
|
+
if (choice) return choice.value;
|
|
86
|
+
io.output.write('Enter one of the listed numbers.\n');
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/remote.ts
CHANGED
|
@@ -179,6 +179,30 @@ export class RemoteSynomemService implements SynomemService {
|
|
|
179
179
|
'GET',
|
|
180
180
|
`agents/resolve?query=${encodeURIComponent(query)}`,
|
|
181
181
|
),
|
|
182
|
+
archive: (idOrAlias: string) =>
|
|
183
|
+
this.mutation<Awaited<ReturnType<SynomemService['agents']['archive']>>>(
|
|
184
|
+
'POST',
|
|
185
|
+
`agents/${encodeURIComponent(idOrAlias)}/archive`,
|
|
186
|
+
{},
|
|
187
|
+
),
|
|
188
|
+
restore: (idOrAlias: string) =>
|
|
189
|
+
this.mutation<Awaited<ReturnType<SynomemService['agents']['restore']>>>(
|
|
190
|
+
'POST',
|
|
191
|
+
`agents/${encodeURIComponent(idOrAlias)}/restore`,
|
|
192
|
+
{},
|
|
193
|
+
),
|
|
194
|
+
addAliases: (idOrAlias: string, aliases: string[]) =>
|
|
195
|
+
this.mutation<Awaited<ReturnType<SynomemService['agents']['addAliases']>>>(
|
|
196
|
+
'POST',
|
|
197
|
+
`agents/${encodeURIComponent(idOrAlias)}/aliases`,
|
|
198
|
+
{ aliases },
|
|
199
|
+
),
|
|
200
|
+
removeAliases: (idOrAlias: string, aliases: string[]) =>
|
|
201
|
+
this.mutation<Awaited<ReturnType<SynomemService['agents']['removeAliases']>>>(
|
|
202
|
+
'POST',
|
|
203
|
+
`agents/${encodeURIComponent(idOrAlias)}/aliases/remove`,
|
|
204
|
+
{ aliases },
|
|
205
|
+
),
|
|
182
206
|
directory: () =>
|
|
183
207
|
this.request<Awaited<ReturnType<SynomemService['agents']['directory']>>>(
|
|
184
208
|
'GET',
|
package/src/schemas.ts
CHANGED
|
@@ -17,12 +17,34 @@ const reservedIds = new Set([
|
|
|
17
17
|
'lpt1',
|
|
18
18
|
]);
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* A handle: the human-friendly name for an agent, unique within its workspace.
|
|
22
|
+
*
|
|
23
|
+
* Mutable, unlike the canonical ID. People and agents type this, so it stays
|
|
24
|
+
* lowercase kebab and refuses the reserved words that would collide with
|
|
25
|
+
* filesystem or route segments.
|
|
26
|
+
*/
|
|
27
|
+
export const agentHandleSchema = z
|
|
21
28
|
.string()
|
|
22
29
|
.min(1)
|
|
23
30
|
.max(63)
|
|
24
31
|
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, 'Use lowercase ASCII letters, digits, and hyphens')
|
|
25
|
-
.refine((
|
|
32
|
+
.refine((handle) => !reservedIds.has(handle), 'Reserved agent handle');
|
|
33
|
+
|
|
34
|
+
/** A canonical opaque agent ID: a ULID, uppercase Crockford base32. */
|
|
35
|
+
export const agentUlidSchema = z.string().regex(/^[0-9A-HJKMNP-TV-Z]{26}$/);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* An actor ID as it appears in an event.
|
|
39
|
+
*
|
|
40
|
+
* Accepts a ULID or a handle-shaped name. New agents are created with an opaque
|
|
41
|
+
* ULID so a handle can be renamed without orphaning the events that reference
|
|
42
|
+
* the actor; agents that predate that, and human and system actors, carry a
|
|
43
|
+
* name-shaped ID. Widening rather than replacing keeps append-only history
|
|
44
|
+
* readable — rewriting the actor ID inside stored events to tidy the format
|
|
45
|
+
* would be exactly the rewrite the event log exists to prevent.
|
|
46
|
+
*/
|
|
47
|
+
export const agentIdSchema = z.union([agentUlidSchema, agentHandleSchema]);
|
|
26
48
|
|
|
27
49
|
/**
|
|
28
50
|
* An alias as written, folded to the canonical lowercase form.
|
|
@@ -110,16 +132,42 @@ export const evidenceSchema = z
|
|
|
110
132
|
});
|
|
111
133
|
|
|
112
134
|
export const profileSchema = z.object({
|
|
135
|
+
/** Canonical, opaque and immutable. Events reference this, never the handle. */
|
|
113
136
|
id: agentIdSchema,
|
|
137
|
+
handle: agentHandleSchema,
|
|
114
138
|
displayName: z.string().trim().min(1).max(200),
|
|
115
139
|
aliases: z.array(agentAliasSchema).max(50).optional(),
|
|
116
140
|
description: z.string().trim().max(2000).optional(),
|
|
141
|
+
/** Archived agents keep their history and stop being able to act. */
|
|
142
|
+
status: z.enum(['active', 'archived']).default('active'),
|
|
117
143
|
createdAt: z.string().datetime({ offset: true }),
|
|
118
144
|
metadata: metadataSchema.optional(),
|
|
119
145
|
});
|
|
120
146
|
|
|
121
|
-
|
|
122
|
-
|
|
147
|
+
/**
|
|
148
|
+
* Creating an agent names a handle; the canonical ID is generated, never
|
|
149
|
+
* supplied. A caller that could choose the ID could choose one that collides
|
|
150
|
+
* with an archived agent's history.
|
|
151
|
+
*/
|
|
152
|
+
export const createAgentSchema = z
|
|
153
|
+
.object({
|
|
154
|
+
handle: agentHandleSchema,
|
|
155
|
+
displayName: z.string().trim().min(1).max(200),
|
|
156
|
+
aliases: z.array(agentAliasSchema).max(50).optional(),
|
|
157
|
+
description: z.string().trim().max(2000).optional(),
|
|
158
|
+
metadata: metadataSchema.optional(),
|
|
159
|
+
})
|
|
160
|
+
.strict();
|
|
161
|
+
|
|
162
|
+
export const updateAgentSchema = z
|
|
163
|
+
.object({
|
|
164
|
+
handle: agentHandleSchema.optional(),
|
|
165
|
+
displayName: z.string().trim().min(1).max(200).optional(),
|
|
166
|
+
aliases: z.array(agentAliasSchema).max(50).optional(),
|
|
167
|
+
description: z.string().trim().max(2000).optional(),
|
|
168
|
+
metadata: metadataSchema.optional(),
|
|
169
|
+
})
|
|
170
|
+
.strict();
|
|
123
171
|
|
|
124
172
|
/**
|
|
125
173
|
* A runtime binding is a claim about where an agent runs, so the fields stay
|
|
@@ -196,7 +244,14 @@ const agentCreatedSchema = baseEventSchema.extend({
|
|
|
196
244
|
const agentUpdatedSchema = baseEventSchema.extend({
|
|
197
245
|
type: z.literal('agent.updated'),
|
|
198
246
|
agentId: agentIdSchema,
|
|
199
|
-
|
|
247
|
+
/*
|
|
248
|
+
* Archiving is recorded as an update, so `status` belongs in the event even
|
|
249
|
+
* though callers cannot set it through `agents.update` — it moves through
|
|
250
|
+
* `archive` and `restore`, which keep the transition explicit.
|
|
251
|
+
*/
|
|
252
|
+
changes: updateAgentSchema.extend({
|
|
253
|
+
status: z.enum(['active', 'archived']).optional(),
|
|
254
|
+
}),
|
|
200
255
|
});
|
|
201
256
|
|
|
202
257
|
const memoSentSchema = baseEventSchema.extend({
|
package/src/service.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
CreateTodoResult,
|
|
20
20
|
CreateTaskResult,
|
|
21
21
|
DoctorResult,
|
|
22
|
+
ProjectionStatus,
|
|
22
23
|
GiveKudosInput,
|
|
23
24
|
GiveKudosResult,
|
|
24
25
|
ItemListInput,
|
|
@@ -79,6 +80,10 @@ export interface SynomemDomainService {
|
|
|
79
80
|
get(idOrAlias: string): Promise<AgentProfile>;
|
|
80
81
|
list(): Promise<AgentProfile[]>;
|
|
81
82
|
resolve(query: string): Promise<AgentResolution>;
|
|
83
|
+
archive(idOrAlias: string): Promise<AgentProfile>;
|
|
84
|
+
restore(idOrAlias: string): Promise<AgentProfile>;
|
|
85
|
+
addAliases(idOrAlias: string, aliases: string[]): Promise<AgentProfile>;
|
|
86
|
+
removeAliases(idOrAlias: string, aliases: string[]): Promise<AgentProfile>;
|
|
82
87
|
directory(): Promise<AgentDirectoryEntry[]>;
|
|
83
88
|
bindings(idOrAlias: string): Promise<AgentRuntimeBinding[]>;
|
|
84
89
|
bindRuntime(input: BindRuntimeInput): Promise<AgentRuntimeBinding>;
|
|
@@ -209,6 +214,13 @@ export interface SynomemService extends SynomemDomainService {
|
|
|
209
214
|
doctor(): Promise<DoctorResult>;
|
|
210
215
|
export(format: 'json' | 'jsonl' | 'markdown'): Promise<string>;
|
|
211
216
|
backup?(destination: string): Promise<string>;
|
|
217
|
+
/*
|
|
218
|
+
* Optional, because only a backend that writes projected files can report on
|
|
219
|
+
* them. The remote backend keeps no filesystem projections at all, and
|
|
220
|
+
* inventing an empty answer there would read as "nothing is stale" rather
|
|
221
|
+
* than "there is nothing to be stale".
|
|
222
|
+
*/
|
|
223
|
+
projectionStatus?(): Promise<ProjectionStatus>;
|
|
212
224
|
rebuild(): Promise<ProjectionRebuildResult>;
|
|
213
225
|
capabilities(): Promise<SynomemServiceCapabilities>;
|
|
214
226
|
info(): Promise<SynomemServiceInfo>;
|