synomem 0.2.0 → 0.4.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 +49 -0
- package/README.md +4 -4
- 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 +3 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +379 -16
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +68 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +283 -12
- 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 +177 -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.map +1 -1
- package/dist/import.d.ts +202 -38
- package/dist/import.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +72 -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/ports/repository.d.ts +4 -1
- package/dist/ports/repository.d.ts.map +1 -1
- package/dist/projections.d.ts +9 -1
- package/dist/projections.d.ts.map +1 -1
- package/dist/projections.js +75 -4
- 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 +30 -1
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +14 -0
- package/dist/remote.js.map +1 -1
- package/dist/schemas.d.ts +270 -55
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +120 -6
- package/dist/schemas.js.map +1 -1
- package/dist/service.d.ts +30 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +25 -2
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +168 -15
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +123 -4
- package/dist/types.d.ts.map +1 -1
- package/docs/cli.md +1 -1
- 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 +543 -19
- package/src/client.ts +331 -11
- package/src/cloud.ts +19 -0
- package/src/config.ts +8 -1
- package/src/configure.ts +233 -0
- package/src/credentials.ts +17 -2
- package/src/index.ts +7 -1
- package/src/mcp/index.ts +95 -5
- package/src/oauth.ts +8 -1
- package/src/ports/repository.ts +5 -0
- package/src/projections.ts +74 -4
- package/src/prompt.ts +88 -0
- package/src/remote.ts +72 -0
- package/src/schemas.ts +125 -6
- package/src/service.ts +30 -0
- package/src/storage.ts +217 -14
- package/src/types.ts +129 -3
package/src/configure.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `synomem config` — the onboarding wizard, and its deterministic equivalent.
|
|
3
|
+
*
|
|
4
|
+
* Two rules shape this file. Every interactive step has a non-interactive
|
|
5
|
+
* counterpart, so an agent can configure a machine without a terminal. And the
|
|
6
|
+
* wizard refuses to run at all when nobody is there to answer: a setup program
|
|
7
|
+
* that blocks forever on a pipe is worse than one that says which flags it
|
|
8
|
+
* needs.
|
|
9
|
+
*/
|
|
10
|
+
import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { cloudApiUrl } from './cloud.js';
|
|
13
|
+
import { SynomemError } from './errors.js';
|
|
14
|
+
import { resolveHome } from './config.js';
|
|
15
|
+
import { ask, askSecret, confirm, select, type PromptIo } from './prompt.js';
|
|
16
|
+
|
|
17
|
+
export type BackendChoice = 'local' | 'remote';
|
|
18
|
+
export type AuthChoice = 'browser' | 'access-key';
|
|
19
|
+
export type CredentialStoreChoice = 'auto' | 'keychain' | 'file' | 'environment';
|
|
20
|
+
|
|
21
|
+
export interface ConfigInitOptions {
|
|
22
|
+
backend?: BackendChoice;
|
|
23
|
+
home?: string;
|
|
24
|
+
auth?: AuthChoice;
|
|
25
|
+
workspace?: string;
|
|
26
|
+
accessToken?: string;
|
|
27
|
+
credentialStore?: CredentialStoreChoice;
|
|
28
|
+
yes?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ConfigPlan {
|
|
32
|
+
backend: BackendChoice;
|
|
33
|
+
home: string;
|
|
34
|
+
serviceUrl?: string;
|
|
35
|
+
auth?: AuthChoice;
|
|
36
|
+
workspaceId?: string;
|
|
37
|
+
credentialStore?: CredentialStoreChoice;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Where a credential can actually be kept on this platform.
|
|
42
|
+
*
|
|
43
|
+
* Reported rather than assumed: offering macOS Keychain on Linux, or a Secret
|
|
44
|
+
* Service that is not running, produces a setup that appears to succeed and
|
|
45
|
+
* then cannot read its own credential back.
|
|
46
|
+
*/
|
|
47
|
+
export function credentialStoreChoices(
|
|
48
|
+
platform: NodeJS.Platform = process.platform,
|
|
49
|
+
): Array<{ value: CredentialStoreChoice; label: string; detail?: string }> {
|
|
50
|
+
const native =
|
|
51
|
+
platform === 'darwin'
|
|
52
|
+
? { value: 'keychain' as const, label: 'macOS Keychain', detail: 'Recommended.' }
|
|
53
|
+
: platform === 'win32'
|
|
54
|
+
? {
|
|
55
|
+
value: 'keychain' as const,
|
|
56
|
+
label: 'Windows Credential Manager',
|
|
57
|
+
detail: 'Recommended.',
|
|
58
|
+
}
|
|
59
|
+
: {
|
|
60
|
+
value: 'keychain' as const,
|
|
61
|
+
label: 'Secret Service (libsecret)',
|
|
62
|
+
detail: 'Recommended where a desktop keyring is running.',
|
|
63
|
+
};
|
|
64
|
+
return [
|
|
65
|
+
native,
|
|
66
|
+
{
|
|
67
|
+
value: 'file',
|
|
68
|
+
label: 'A restricted file in the Synomem home',
|
|
69
|
+
detail: 'Mode 0600. Use on headless machines with no keyring.',
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
value: 'environment',
|
|
73
|
+
label: 'Print environment-variable instructions',
|
|
74
|
+
detail: 'Nothing is stored. Synomem never edits your shell profile.',
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Refuses to guess when there is nobody to ask. */
|
|
80
|
+
export function assertInteractive(io: PromptIo): void {
|
|
81
|
+
if (io.interactive) return;
|
|
82
|
+
throw new SynomemError(
|
|
83
|
+
'INVALID_INPUT',
|
|
84
|
+
[
|
|
85
|
+
'synomem config needs an interactive terminal.',
|
|
86
|
+
'',
|
|
87
|
+
'For automation, use the deterministic form instead:',
|
|
88
|
+
'',
|
|
89
|
+
' synomem config init --backend local --yes',
|
|
90
|
+
'',
|
|
91
|
+
' synomem config init --backend remote --auth access-key \\',
|
|
92
|
+
' --workspace <workspace-id> --access-token-stdin --yes',
|
|
93
|
+
'',
|
|
94
|
+
'Pipe the token in rather than passing it as an argument: an argument is',
|
|
95
|
+
'kept by both the shell history and the process list.',
|
|
96
|
+
].join('\n'),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Stores an access key in a restricted file.
|
|
102
|
+
*
|
|
103
|
+
* Separate from `config.json` so a configuration file can be read, copied or
|
|
104
|
+
* pasted into an issue without carrying a secret with it.
|
|
105
|
+
*/
|
|
106
|
+
export function writeCredentialFile(home: string, token: string): string {
|
|
107
|
+
const directory = join(home, 'credentials');
|
|
108
|
+
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
109
|
+
chmodSync(directory, 0o700);
|
|
110
|
+
const path = join(directory, 'installation.json');
|
|
111
|
+
writeFileSync(path, `${JSON.stringify({ accessToken: token }, null, 2)}\n`, { mode: 0o600 });
|
|
112
|
+
chmodSync(path, 0o600);
|
|
113
|
+
return path;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A key's identifying prefix. Never the key. */
|
|
117
|
+
export function credentialFingerprint(token: string): string {
|
|
118
|
+
const head = token.slice(0, 12);
|
|
119
|
+
return `${head}${token.length > 12 ? '\u2026' : ''}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function environmentInstructions(token: string): string {
|
|
123
|
+
return [
|
|
124
|
+
'Set this value for the current shell:',
|
|
125
|
+
'',
|
|
126
|
+
` export SYNOMEM_ACCESS_TOKEN='${token}'`,
|
|
127
|
+
'',
|
|
128
|
+
'To persist it, add that to a secret-aware shell configuration or to your',
|
|
129
|
+
'agent runtime environment. Synomem will not edit your shell profile for',
|
|
130
|
+
'you: silently rewriting a dotfile is not a thing a setup program should do.',
|
|
131
|
+
].join('\n');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The interactive flow, returning the plan it settled on. */
|
|
135
|
+
export async function runConfigWizard(
|
|
136
|
+
io: PromptIo,
|
|
137
|
+
options: { home?: string; env?: NodeJS.ProcessEnv } = {},
|
|
138
|
+
): Promise<ConfigPlan> {
|
|
139
|
+
assertInteractive(io);
|
|
140
|
+
const env = options.env ?? process.env;
|
|
141
|
+
|
|
142
|
+
io.output.write(
|
|
143
|
+
[
|
|
144
|
+
'',
|
|
145
|
+
'Welcome to Synomem',
|
|
146
|
+
'',
|
|
147
|
+
'Synomem gives agents durable notes, messages, tasks, todos, kudos,',
|
|
148
|
+
'and shared coordination.',
|
|
149
|
+
'',
|
|
150
|
+
].join('\n'),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const backend = await select<BackendChoice>(io, 'Where should Synomem store canonical state?', [
|
|
154
|
+
{
|
|
155
|
+
value: 'local',
|
|
156
|
+
label: 'Local \u2014 SQLite on this machine',
|
|
157
|
+
detail: 'Nothing is uploaded. One implicit workspace.',
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
value: 'remote',
|
|
161
|
+
label: 'Synomem Cloud \u2014 shared across machines and agents',
|
|
162
|
+
detail: 'Organizations, workspaces, roles and administration.',
|
|
163
|
+
},
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const home = await ask(io, 'Where should Synomem store its data?', resolveHome(options.home));
|
|
167
|
+
|
|
168
|
+
if (backend === 'local') {
|
|
169
|
+
return { backend, home };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const serviceUrl = cloudApiUrl(env);
|
|
173
|
+
io.output.write(`\nConnecting to Synomem Cloud at ${serviceUrl}\n`);
|
|
174
|
+
|
|
175
|
+
const auth = await select<AuthChoice>(io, 'How would you like to sign in?', [
|
|
176
|
+
{
|
|
177
|
+
value: 'browser',
|
|
178
|
+
label: 'Sign in with your browser',
|
|
179
|
+
detail: 'Opens the authorization server and returns through a loopback callback.',
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
value: 'access-key',
|
|
183
|
+
label: 'Use an installation access key',
|
|
184
|
+
detail: 'Create one at https://portal.synomem.ai/installations',
|
|
185
|
+
},
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
const workspaceId = await ask(io, 'Workspace ID');
|
|
189
|
+
if (!workspaceId) {
|
|
190
|
+
throw new SynomemError('INVALID_INPUT', 'A workspace ID is required for the hosted backend.');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const credentialStore =
|
|
194
|
+
auth === 'access-key'
|
|
195
|
+
? await select<CredentialStoreChoice>(
|
|
196
|
+
io,
|
|
197
|
+
'Where should Synomem store this credential?',
|
|
198
|
+
credentialStoreChoices(),
|
|
199
|
+
)
|
|
200
|
+
: 'auto';
|
|
201
|
+
|
|
202
|
+
return { backend, home, serviceUrl, auth, workspaceId, credentialStore };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Reads an access key without ever accepting it as an argument. */
|
|
206
|
+
export async function readAccessToken(io: PromptIo): Promise<string> {
|
|
207
|
+
const token = await askSecret(io, 'Installation access key');
|
|
208
|
+
if (!token) throw new SynomemError('INVALID_INPUT', 'No access key was provided.');
|
|
209
|
+
return token;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function confirmPlan(io: PromptIo, plan: ConfigPlan): Promise<boolean> {
|
|
213
|
+
io.output.write(
|
|
214
|
+
[
|
|
215
|
+
'',
|
|
216
|
+
'Synomem will be configured as:',
|
|
217
|
+
'',
|
|
218
|
+
` Backend: ${plan.backend === 'local' ? 'Local SQLite' : 'Synomem Cloud'}`,
|
|
219
|
+
` Home: ${plan.home}`,
|
|
220
|
+
...(plan.serviceUrl ? [` Service: ${plan.serviceUrl}`] : []),
|
|
221
|
+
...(plan.workspaceId ? [` Workspace: ${plan.workspaceId}`] : []),
|
|
222
|
+
...(plan.credentialStore && plan.credentialStore !== 'auto'
|
|
223
|
+
? [` Credential: ${plan.credentialStore}`]
|
|
224
|
+
: []),
|
|
225
|
+
'',
|
|
226
|
+
].join('\n'),
|
|
227
|
+
);
|
|
228
|
+
return await confirm(io, 'Apply this?');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function homeExists(home: string): boolean {
|
|
232
|
+
return existsSync(home);
|
|
233
|
+
}
|
package/src/credentials.ts
CHANGED
|
@@ -6,6 +6,21 @@ import type { ActorIdentity } from './types.js';
|
|
|
6
6
|
const serviceName = 'ai.synomem.credentials';
|
|
7
7
|
const maximumOutputBytes = 128 * 1024;
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* An installation access key.
|
|
11
|
+
*
|
|
12
|
+
* Not an OAuth credential: it has no refresh, no token endpoint and no client,
|
|
13
|
+
* and it authorizes a MACHINE rather than a person. Keeping it a distinct shape
|
|
14
|
+
* stops code treating it as refreshable, which would mean silently failing to
|
|
15
|
+
* renew something that never expires that way.
|
|
16
|
+
*/
|
|
17
|
+
export interface StoredInstallationKey {
|
|
18
|
+
kind: 'installation-key';
|
|
19
|
+
accessToken: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type StoredCredential = StoredOAuthCredential | StoredInstallationKey;
|
|
23
|
+
|
|
9
24
|
export interface StoredOAuthCredential {
|
|
10
25
|
accessToken: string;
|
|
11
26
|
refreshToken?: string;
|
|
@@ -17,8 +32,8 @@ export interface StoredOAuthCredential {
|
|
|
17
32
|
}
|
|
18
33
|
|
|
19
34
|
export interface CredentialStore {
|
|
20
|
-
get(reference: string): Promise<
|
|
21
|
-
set(reference: string, credential:
|
|
35
|
+
get(reference: string): Promise<StoredCredential | undefined>;
|
|
36
|
+
set(reference: string, credential: StoredCredential): Promise<void>;
|
|
22
37
|
delete(reference: string): Promise<boolean>;
|
|
23
38
|
}
|
|
24
39
|
|
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,7 @@ 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';
|
|
38
44
|
export { asSynomemError, errorCodes, SynomemError } from './errors.js';
|
|
39
45
|
export {
|
|
40
46
|
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
|
}
|
|
@@ -326,6 +340,82 @@ export async function createSynomemMcpServer(
|
|
|
326
340
|
},
|
|
327
341
|
);
|
|
328
342
|
|
|
343
|
+
server.registerTool(
|
|
344
|
+
'synomem_post_create',
|
|
345
|
+
{
|
|
346
|
+
title: 'Publish a post',
|
|
347
|
+
description:
|
|
348
|
+
'Publish something the whole workspace can read. Use for an announcement, a decision, or context several agents need. A post has no recipient — if one named actor must act, send a memo or assign a task instead.',
|
|
349
|
+
inputSchema: z.object({
|
|
350
|
+
title: z.string().trim().min(1).max(200),
|
|
351
|
+
body: z.string().trim().min(1).max(32_000),
|
|
352
|
+
tags: z.array(z.string()).max(20).optional(),
|
|
353
|
+
replyTo: z.string().length(26).optional(),
|
|
354
|
+
idempotencyKey: z.string().max(200).optional(),
|
|
355
|
+
}),
|
|
356
|
+
outputSchema,
|
|
357
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
358
|
+
},
|
|
359
|
+
async (input) => {
|
|
360
|
+
try {
|
|
361
|
+
const result = await client.posts.create(input);
|
|
362
|
+
return success(actor, `Published post ${result.record.event.id}.`, {
|
|
363
|
+
post: result.record,
|
|
364
|
+
});
|
|
365
|
+
} catch (error) {
|
|
366
|
+
return failure(actor, error);
|
|
367
|
+
}
|
|
368
|
+
},
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
server.registerTool(
|
|
372
|
+
'synomem_post_acknowledge',
|
|
373
|
+
{
|
|
374
|
+
title: 'Acknowledge a post',
|
|
375
|
+
description:
|
|
376
|
+
'Record that YOU have seen a post. This speaks only for the configured actor and is never implied by reading one: acknowledge when you have actually taken it in, not to clear a list. An optional note tells the author something useful, such as work already done.',
|
|
377
|
+
inputSchema: z.object({
|
|
378
|
+
postId: z.string().length(26),
|
|
379
|
+
note: z.string().trim().min(1).max(2000).optional(),
|
|
380
|
+
idempotencyKey: z.string().max(200).optional(),
|
|
381
|
+
}),
|
|
382
|
+
outputSchema,
|
|
383
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
|
|
384
|
+
},
|
|
385
|
+
async (input) => {
|
|
386
|
+
try {
|
|
387
|
+
const record = await client.posts.acknowledge(input);
|
|
388
|
+
return success(actor, `Acknowledged post ${input.postId}.`, { post: record });
|
|
389
|
+
} catch (error) {
|
|
390
|
+
return failure(actor, error);
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
server.registerTool(
|
|
396
|
+
'synomem_post_roster',
|
|
397
|
+
{
|
|
398
|
+
title: 'See who has acknowledged a post',
|
|
399
|
+
description:
|
|
400
|
+
'Who has acknowledged a post and who has not. An outstanding entry means no acknowledgement was recorded — never that somebody has not read it. Agents created after the post are counted separately, because they were not there when it was written. This is read-only and does not acknowledge anything.',
|
|
401
|
+
inputSchema: z.object({ postId: z.string().length(26) }),
|
|
402
|
+
outputSchema,
|
|
403
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true },
|
|
404
|
+
},
|
|
405
|
+
async ({ postId }) => {
|
|
406
|
+
try {
|
|
407
|
+
const roster = await client.posts.roster(postId);
|
|
408
|
+
return success(
|
|
409
|
+
actor,
|
|
410
|
+
`${roster.acknowledged.length} acknowledged, ${roster.outstanding.length} with no acknowledgement recorded.`,
|
|
411
|
+
roster,
|
|
412
|
+
);
|
|
413
|
+
} catch (error) {
|
|
414
|
+
return failure(actor, error);
|
|
415
|
+
}
|
|
416
|
+
},
|
|
417
|
+
);
|
|
418
|
+
|
|
329
419
|
server.registerTool(
|
|
330
420
|
'synomem_agent_resolve',
|
|
331
421
|
{
|
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/ports/repository.ts
CHANGED
|
@@ -5,6 +5,8 @@ import type {
|
|
|
5
5
|
ChangePage,
|
|
6
6
|
ItemListInput,
|
|
7
7
|
JsonValue,
|
|
8
|
+
PostAcknowledgment,
|
|
9
|
+
PostRoster,
|
|
8
10
|
ItemSummary,
|
|
9
11
|
KudosListInput,
|
|
10
12
|
KudosSummary,
|
|
@@ -45,6 +47,9 @@ export interface SynomemRepository {
|
|
|
45
47
|
listAgents(): Awaitable<AgentProfile[]>;
|
|
46
48
|
/** Resolves a name case-insensitively, reporting ambiguity instead of guessing. */
|
|
47
49
|
resolveAgent(query: string): Awaitable<{ match?: AgentProfile; candidates: AgentProfile[] }>;
|
|
50
|
+
listPostAcknowledgments(postId: string): Awaitable<PostAcknowledgment[]>;
|
|
51
|
+
/** Who has acknowledged a post and who has not; see PostRoster. */
|
|
52
|
+
postRoster(postId: string): Awaitable<PostRoster | undefined>;
|
|
48
53
|
listRuntimeBindings(agentId: string): Awaitable<AgentRuntimeBinding[]>;
|
|
49
54
|
bindRuntime(binding: {
|
|
50
55
|
id: string;
|
package/src/projections.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type {
|
|
|
16
16
|
KudosRecord,
|
|
17
17
|
MemoRecord,
|
|
18
18
|
NoteRecord,
|
|
19
|
+
PostRecord,
|
|
19
20
|
TaskDue,
|
|
20
21
|
TaskRecord,
|
|
21
22
|
TodoRecord,
|
|
@@ -254,6 +255,75 @@ export function memoRecordsFromEvents(events: SynomemEvent[]): MemoRecord[] {
|
|
|
254
255
|
return [...records.values()].sort((a, b) => b.event.createdAt.localeCompare(a.event.createdAt));
|
|
255
256
|
}
|
|
256
257
|
|
|
258
|
+
/**
|
|
259
|
+
* Rebuilds posts from their events, acknowledgements included.
|
|
260
|
+
*
|
|
261
|
+
* Edits are kept as a list rather than collapsed into the current text. A reader
|
|
262
|
+
* has to be able to see that a post changed after somebody acknowledged it —
|
|
263
|
+
* silently rewriting what was acknowledged is how a record becomes a lie.
|
|
264
|
+
*/
|
|
265
|
+
export function postRecordsFromEvents(events: SynomemEvent[]): PostRecord[] {
|
|
266
|
+
const records = new Map<string, PostRecord>();
|
|
267
|
+
for (const event of events) {
|
|
268
|
+
if (event.type === 'post.created') {
|
|
269
|
+
records.set(event.id, {
|
|
270
|
+
event,
|
|
271
|
+
edits: [],
|
|
272
|
+
acknowledgments: [],
|
|
273
|
+
status: 'active',
|
|
274
|
+
title: event.title,
|
|
275
|
+
body: event.body,
|
|
276
|
+
...(event.tags ? { tags: event.tags } : {}),
|
|
277
|
+
// The text version, counting only changes to the text.
|
|
278
|
+
//
|
|
279
|
+
// Deliberately not the aggregate version, which also counts every
|
|
280
|
+
// acknowledgement. If they were the same number, somebody
|
|
281
|
+
// acknowledging a post would invalidate an edit the author was in the
|
|
282
|
+
// middle of making — a conflict with nothing to reconcile.
|
|
283
|
+
version: 1,
|
|
284
|
+
});
|
|
285
|
+
} else if (event.type === 'post.edited') {
|
|
286
|
+
const record = records.get(event.postId);
|
|
287
|
+
if (record) {
|
|
288
|
+
record.edits.push(event);
|
|
289
|
+
record.title = event.title;
|
|
290
|
+
record.body = event.body;
|
|
291
|
+
if (event.tags) record.tags = event.tags;
|
|
292
|
+
record.version += 1;
|
|
293
|
+
}
|
|
294
|
+
} else if (event.type === 'post.archived') {
|
|
295
|
+
const record = records.get(event.postId);
|
|
296
|
+
if (record) {
|
|
297
|
+
record.archived = event;
|
|
298
|
+
record.status = 'archived';
|
|
299
|
+
}
|
|
300
|
+
} else if (event.type === 'post.acknowledged') {
|
|
301
|
+
const record = records.get(event.postId);
|
|
302
|
+
if (record) {
|
|
303
|
+
// One statement per actor: a repeat replaces rather than accumulates.
|
|
304
|
+
const existing = record.acknowledgments.findIndex(
|
|
305
|
+
(entry) => entry.actor.id === event.actor.id && entry.actor.kind === event.actor.kind,
|
|
306
|
+
);
|
|
307
|
+
const entry = {
|
|
308
|
+
actor: event.actor,
|
|
309
|
+
acknowledgedAt: event.createdAt,
|
|
310
|
+
...(event.note ? { note: event.note } : {}),
|
|
311
|
+
};
|
|
312
|
+
if (existing >= 0) record.acknowledgments[existing] = entry;
|
|
313
|
+
else record.acknowledgments.push(entry);
|
|
314
|
+
}
|
|
315
|
+
} else if (event.type === 'post.acknowledgment.withdrawn') {
|
|
316
|
+
const record = records.get(event.postId);
|
|
317
|
+
if (record) {
|
|
318
|
+
record.acknowledgments = record.acknowledgments.filter(
|
|
319
|
+
(entry) => !(entry.actor.id === event.actor.id && entry.actor.kind === event.actor.kind),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return [...records.values()];
|
|
325
|
+
}
|
|
326
|
+
|
|
257
327
|
export function noteRecordsFromEvents(events: SynomemEvent[]): NoteRecord[] {
|
|
258
328
|
const records = new Map<string, NoteRecord>();
|
|
259
329
|
for (const event of events) {
|
|
@@ -480,7 +550,7 @@ export class ProjectionManager implements ProjectionWriter {
|
|
|
480
550
|
const generated: string[] = [];
|
|
481
551
|
|
|
482
552
|
for (const profile of profiles) {
|
|
483
|
-
const agentDirectory = join(this.storage.home, profile.
|
|
553
|
+
const agentDirectory = join(this.storage.home, profile.handle);
|
|
484
554
|
assertNoSymlinkEscape(this.storage.home, agentDirectory);
|
|
485
555
|
ensureDirectory(agentDirectory);
|
|
486
556
|
const profilePath = join(agentDirectory, 'profile.json');
|
|
@@ -586,7 +656,7 @@ export class ProjectionManager implements ProjectionWriter {
|
|
|
586
656
|
(record) => record.event.assigneeAgentId === profile.id,
|
|
587
657
|
);
|
|
588
658
|
const rebuiltAt = events.at(-1)?.createdAt ?? new Date(0).toISOString();
|
|
589
|
-
const agentDirectory = join(this.storage.home, profile.
|
|
659
|
+
const agentDirectory = join(this.storage.home, profile.handle);
|
|
590
660
|
const inboxDirectory = join(agentDirectory, 'inbox');
|
|
591
661
|
const kudosInboxDirectory = join(inboxDirectory, 'kudos');
|
|
592
662
|
const memoInboxDirectory = join(inboxDirectory, 'memos');
|
|
@@ -659,7 +729,7 @@ export class ProjectionManager implements ProjectionWriter {
|
|
|
659
729
|
}
|
|
660
730
|
|
|
661
731
|
const keep = new Set(generated);
|
|
662
|
-
const prefixes = [`${profile.
|
|
732
|
+
const prefixes = [`${profile.handle}/`, `${profile.handle}\\`];
|
|
663
733
|
const removed: string[] = [];
|
|
664
734
|
for (const stale of this.storage
|
|
665
735
|
.projectionManifest()
|
|
@@ -675,7 +745,7 @@ export class ProjectionManager implements ProjectionWriter {
|
|
|
675
745
|
unlinkSync(path);
|
|
676
746
|
removed.push(stale);
|
|
677
747
|
}
|
|
678
|
-
this.storage.replaceAgentProjectionManifest(profile.
|
|
748
|
+
this.storage.replaceAgentProjectionManifest(profile.handle, generated, rebuiltAt);
|
|
679
749
|
return { generated: generated.sort(), removed: removed.sort() };
|
|
680
750
|
}
|
|
681
751
|
|
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
|
+
}
|