synomem 0.3.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 +40 -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 +280 -16
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +32 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +125 -10
- 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 +58 -43
- 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 +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.js +4 -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 +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 +4 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/storage.d.ts +14 -1
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +55 -14
- package/dist/storage.js.map +1 -1
- package/dist/types.d.ts +24 -2
- 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 +377 -19
- package/src/client.ts +135 -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 +19 -5
- package/src/oauth.ts +8 -1
- package/src/projections.ts +4 -4
- package/src/prompt.ts +88 -0
- package/src/remote.ts +24 -0
- package/src/schemas.ts +60 -5
- package/src/service.ts +4 -0
- package/src/storage.ts +70 -13
- package/src/types.ts +24 -2
package/src/client.ts
CHANGED
|
@@ -106,7 +106,11 @@ export interface SynomemCoreOptions {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
export class SynomemCore implements SynomemDomainService {
|
|
109
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Mutable because an agent actor is resolved to its canonical identity on
|
|
111
|
+
* init: callers name a handle, events record the opaque ID.
|
|
112
|
+
*/
|
|
113
|
+
actor: ActorIdentity;
|
|
110
114
|
private readonly repository: SynomemRepository;
|
|
111
115
|
private readonly projectionWriter: ProjectionWriter;
|
|
112
116
|
private readonly clock: () => Date;
|
|
@@ -121,6 +125,11 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
121
125
|
get: (idOrAlias: string) => this.getAgent(idOrAlias),
|
|
122
126
|
list: () => this.listAgents(),
|
|
123
127
|
resolve: (query: string) => this.resolveAgent(query),
|
|
128
|
+
archive: (idOrAlias: string) => this.setAgentStatus(idOrAlias, 'archived'),
|
|
129
|
+
restore: (idOrAlias: string) => this.setAgentStatus(idOrAlias, 'active'),
|
|
130
|
+
addAliases: (idOrAlias: string, aliases: string[]) => this.addAgentAliases(idOrAlias, aliases),
|
|
131
|
+
removeAliases: (idOrAlias: string, aliases: string[]) =>
|
|
132
|
+
this.removeAgentAliases(idOrAlias, aliases),
|
|
124
133
|
directory: () => this.agentDirectory(),
|
|
125
134
|
bindings: (idOrAlias: string) => this.listRuntimeBindings(idOrAlias),
|
|
126
135
|
bindRuntime: (input: BindRuntimeInput) => this.bindRuntime(input),
|
|
@@ -271,6 +280,30 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
271
280
|
if (this.initialized) return;
|
|
272
281
|
await this.repository.init();
|
|
273
282
|
this.initialized = true;
|
|
283
|
+
|
|
284
|
+
/*
|
|
285
|
+
* An agent actor is resolved to its canonical identity here.
|
|
286
|
+
*
|
|
287
|
+
* Callers name a handle because that is what people and harnesses know,
|
|
288
|
+
* but every event must record the opaque ID — otherwise renaming a handle
|
|
289
|
+
* would orphan the history written under the old one. The display name
|
|
290
|
+
* comes from the profile for the same reason a harness cannot assert it on
|
|
291
|
+
* the command line: the stored record is the authority, not the argument.
|
|
292
|
+
*
|
|
293
|
+
* An unresolvable name is left as given rather than rejected, so a system
|
|
294
|
+
* actor can still create the agent that does not exist yet. Writing as an
|
|
295
|
+
* unknown agent is refused later by the checks that already exist.
|
|
296
|
+
*/
|
|
297
|
+
if (this.actor.kind === 'agent') {
|
|
298
|
+
const resolved = await this.repository.resolveAgent(this.actor.id);
|
|
299
|
+
if (resolved.match) {
|
|
300
|
+
this.actor = {
|
|
301
|
+
kind: 'agent',
|
|
302
|
+
id: resolved.match.id,
|
|
303
|
+
...(resolved.match.displayName ? { displayName: resolved.match.displayName } : {}),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
}
|
|
274
307
|
}
|
|
275
308
|
|
|
276
309
|
async close(): Promise<void> {
|
|
@@ -314,20 +347,29 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
314
347
|
this.checkAbort();
|
|
315
348
|
await this.repository.assertEventCompatibility();
|
|
316
349
|
const parsed = this.validate(() => createAgentSchema.parse(input));
|
|
317
|
-
if (await this.repository.getAgent(parsed.
|
|
318
|
-
throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.
|
|
350
|
+
if (await this.repository.getAgent(parsed.handle)) {
|
|
351
|
+
throw new SynomemError('AGENT_EXISTS', `Agent or alias already exists: ${parsed.handle}`);
|
|
319
352
|
}
|
|
320
353
|
const aliases = [...new Set(parsed.aliases ?? [])].sort();
|
|
321
|
-
if (aliases.includes(parsed.
|
|
322
|
-
throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own
|
|
354
|
+
if (aliases.includes(parsed.handle)) {
|
|
355
|
+
throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
|
|
323
356
|
}
|
|
324
357
|
for (const alias of aliases) {
|
|
325
358
|
if (await this.repository.getAgent(alias)) {
|
|
326
359
|
throw new SynomemError('ALIAS_CONFLICT', `Alias already belongs to an agent: ${alias}`);
|
|
327
360
|
}
|
|
328
361
|
}
|
|
362
|
+
/*
|
|
363
|
+
* The canonical ID is generated here and never supplied by the caller.
|
|
364
|
+
* Every event references it permanently, so it has to be free of meaning:
|
|
365
|
+
* a caller that could choose it could choose one that collides with an
|
|
366
|
+
* archived agent's history, and a meaningful ID becomes a handle nobody can
|
|
367
|
+
* rename.
|
|
368
|
+
*/
|
|
329
369
|
const profile: AgentProfile = {
|
|
330
|
-
id:
|
|
370
|
+
id: this.nextId(),
|
|
371
|
+
handle: parsed.handle,
|
|
372
|
+
status: 'active',
|
|
331
373
|
displayName: parsed.displayName,
|
|
332
374
|
...(aliases.length ? { aliases } : {}),
|
|
333
375
|
...(parsed.description !== undefined ? { description: parsed.description } : {}),
|
|
@@ -355,8 +397,20 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
355
397
|
const existing = await this.repository.getAgent(idOrAlias);
|
|
356
398
|
if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
357
399
|
const aliases = parsed.aliases ? [...new Set(parsed.aliases)].sort() : existing.aliases;
|
|
358
|
-
|
|
359
|
-
|
|
400
|
+
const handle = parsed.handle ?? existing.handle;
|
|
401
|
+
if (aliases?.includes(handle)) {
|
|
402
|
+
throw new SynomemError('ALIAS_CONFLICT', 'An agent cannot use its own handle as an alias.');
|
|
403
|
+
}
|
|
404
|
+
// Renaming the handle is allowed and is why the canonical ID exists, but a
|
|
405
|
+
// handle another agent already answers to is still refused.
|
|
406
|
+
if (parsed.handle && parsed.handle !== existing.handle) {
|
|
407
|
+
const owner = await this.repository.getAgent(parsed.handle);
|
|
408
|
+
if (owner && owner.id !== existing.id) {
|
|
409
|
+
throw new SynomemError(
|
|
410
|
+
'ALIAS_CONFLICT',
|
|
411
|
+
`Handle already belongs to ${owner.id}: ${parsed.handle}`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
360
414
|
}
|
|
361
415
|
for (const alias of aliases ?? []) {
|
|
362
416
|
const owner = await this.repository.getAgent(alias);
|
|
@@ -384,6 +438,52 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
384
438
|
return updated;
|
|
385
439
|
}
|
|
386
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Archiving stops an agent acting without erasing it.
|
|
443
|
+
*
|
|
444
|
+
* Events reference the actor permanently, so deleting an agent would leave
|
|
445
|
+
* history pointing at nothing. Archived agents keep their records and their
|
|
446
|
+
* handle, and can be restored.
|
|
447
|
+
*/
|
|
448
|
+
private async setAgentStatus(
|
|
449
|
+
idOrAlias: string,
|
|
450
|
+
status: 'active' | 'archived',
|
|
451
|
+
): Promise<AgentProfile> {
|
|
452
|
+
this.checkAbort();
|
|
453
|
+
await this.repository.assertEventCompatibility();
|
|
454
|
+
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
455
|
+
const existing = await this.repository.getAgent(idOrAlias);
|
|
456
|
+
if (!existing) throw new SynomemError('AGENT_NOT_FOUND', `Unknown agent: ${idOrAlias}`);
|
|
457
|
+
if (existing.status === status) return existing;
|
|
458
|
+
const updated: AgentProfile = { ...existing, status };
|
|
459
|
+
await this.repository.transaction(async () => {
|
|
460
|
+
const event: SynomemEvent = {
|
|
461
|
+
...this.eventBase(existing.id, await this.repository.nextAggregateVersion(existing.id)),
|
|
462
|
+
type: 'agent.updated',
|
|
463
|
+
agentId: existing.id,
|
|
464
|
+
changes: { status },
|
|
465
|
+
};
|
|
466
|
+
await this.repository.updateAgent(updated, event.createdAt);
|
|
467
|
+
await this.repository.insertEvent(event);
|
|
468
|
+
});
|
|
469
|
+
await this.projectionWriter.syncAgent(updated.id);
|
|
470
|
+
return updated;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/** Adds aliases without disturbing the ones already there. */
|
|
474
|
+
private async addAgentAliases(idOrAlias: string, add: string[]): Promise<AgentProfile> {
|
|
475
|
+
const existing = await this.getAgent(idOrAlias);
|
|
476
|
+
const merged = [...new Set([...(existing.aliases ?? []), ...add])].sort();
|
|
477
|
+
return await this.updateAgent(existing.id, { aliases: merged });
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
private async removeAgentAliases(idOrAlias: string, remove: string[]): Promise<AgentProfile> {
|
|
481
|
+
const existing = await this.getAgent(idOrAlias);
|
|
482
|
+
const drop = new Set(remove.map((alias) => alias.trim().toLowerCase()));
|
|
483
|
+
const kept = (existing.aliases ?? []).filter((alias) => !drop.has(alias));
|
|
484
|
+
return await this.updateAgent(existing.id, { aliases: kept });
|
|
485
|
+
}
|
|
486
|
+
|
|
387
487
|
private async getAgent(idOrAlias: string): Promise<AgentProfile> {
|
|
388
488
|
this.checkAbort();
|
|
389
489
|
this.validate(() => agentLookupSchema.parse(idOrAlias));
|
|
@@ -1441,10 +1541,34 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1441
1541
|
return await this.getTodoRecord(input.todoId);
|
|
1442
1542
|
}
|
|
1443
1543
|
|
|
1544
|
+
/**
|
|
1545
|
+
* Turns an agent name in a filter into the canonical ID the records hold.
|
|
1546
|
+
*
|
|
1547
|
+
* Callers filter by the name they know — a handle or an alias — while every
|
|
1548
|
+
* record stores the opaque ID. Without this the filter silently matches
|
|
1549
|
+
* nothing, which reads as "there is nothing here" rather than "that name
|
|
1550
|
+
* means something else now".
|
|
1551
|
+
*
|
|
1552
|
+
* An unresolvable name is passed through unchanged so it can match a legacy
|
|
1553
|
+
* name-shaped ID rather than being swallowed.
|
|
1554
|
+
*/
|
|
1555
|
+
private async canonicalAgentId(name: string | undefined): Promise<string | undefined> {
|
|
1556
|
+
if (!name) return name;
|
|
1557
|
+
const resolved = await this.repository.resolveAgent(name);
|
|
1558
|
+
return resolved.match?.id ?? name;
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1444
1561
|
private async listItems(input: ItemListInput): Promise<Page<ItemSummary>> {
|
|
1445
1562
|
this.checkAbort();
|
|
1446
1563
|
const parsed = this.validate(() => itemListInputSchema.parse(input));
|
|
1447
|
-
|
|
1564
|
+
const resolved = {
|
|
1565
|
+
...parsed,
|
|
1566
|
+
...(parsed.participantAgentId
|
|
1567
|
+
? { participantAgentId: await this.canonicalAgentId(parsed.participantAgentId) }
|
|
1568
|
+
: {}),
|
|
1569
|
+
...(parsed.actorId ? { actorId: await this.canonicalAgentId(parsed.actorId) } : {}),
|
|
1570
|
+
};
|
|
1571
|
+
return await this.repository.listItemSummaries(resolved, this.actor);
|
|
1448
1572
|
}
|
|
1449
1573
|
private async listItemChanges(input: ChangesInput): Promise<ChangePage> {
|
|
1450
1574
|
this.checkAbort();
|
|
@@ -1539,8 +1663,8 @@ export class SynomemCore implements SynomemDomainService {
|
|
|
1539
1663
|
* check that silently lags the migration runner reports a healthy database as
|
|
1540
1664
|
* broken.
|
|
1541
1665
|
*/
|
|
1542
|
-
const CURRENT_SCHEMA_VERSION =
|
|
1543
|
-
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6];
|
|
1666
|
+
const CURRENT_SCHEMA_VERSION = 7;
|
|
1667
|
+
const EXPECTED_APPLIED_MIGRATIONS = [1, 2, 3, 4, 5, 6, 7];
|
|
1544
1668
|
|
|
1545
1669
|
export class SynomemClient extends SynomemCore implements SynomemService {
|
|
1546
1670
|
readonly home: string;
|
package/src/cloud.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synomem Cloud, as a constant rather than a question.
|
|
3
|
+
*
|
|
4
|
+
* Public onboarding must never ask for a service URL. A person setting up
|
|
5
|
+
* Synomem has no way to know whether an address they were given is the real
|
|
6
|
+
* one, and a prompt that accepts any origin is a prompt that can be phished.
|
|
7
|
+
* The hosted service therefore has one address, compiled in.
|
|
8
|
+
*
|
|
9
|
+
* `SYNOMEM_API_URL` remains for development and private deployments. It is
|
|
10
|
+
* deliberately undocumented in the README, the public docs, the packaged skill
|
|
11
|
+
* and ordinary help output — a private deployment is configured by whoever runs
|
|
12
|
+
* it, not discovered by an ordinary user.
|
|
13
|
+
*/
|
|
14
|
+
export const SYNOMEM_CLOUD_API_URL = 'https://api.synomem.ai';
|
|
15
|
+
|
|
16
|
+
export function cloudApiUrl(env: NodeJS.ProcessEnv = process.env): string {
|
|
17
|
+
const override = env.SYNOMEM_API_URL?.trim();
|
|
18
|
+
return override || SYNOMEM_CLOUD_API_URL;
|
|
19
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -73,7 +73,14 @@ export const configSchema = policySchema.extend({
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
export function resolveHome(explicitHome?: string): string {
|
|
76
|
-
|
|
76
|
+
/*
|
|
77
|
+
* `~/.synomem`, resolved as an exact directory.
|
|
78
|
+
*
|
|
79
|
+
* No detection of or migration from `~/.agents`: the project is greenfield,
|
|
80
|
+
* and code that quietly moves somebody's database is worse than a clear
|
|
81
|
+
* message telling them where the new home is.
|
|
82
|
+
*/
|
|
83
|
+
const candidate = explicitHome ?? process.env.SYNOMEM_HOME ?? resolve(homedir(), '.synomem');
|
|
77
84
|
if (candidate.includes('\0')) throw new SynomemError('UNSAFE_PATH', 'Storage home contains NUL.');
|
|
78
85
|
return resolve(candidate);
|
|
79
86
|
}
|
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
|
}
|
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,7 +745,7 @@ 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
|
|