synomem 0.7.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/README.md +47 -68
  3. package/dist/backend.d.ts +18 -6
  4. package/dist/backend.d.ts.map +1 -1
  5. package/dist/backend.js +55 -41
  6. package/dist/backend.js.map +1 -1
  7. package/dist/cli.d.ts +20 -25
  8. package/dist/cli.d.ts.map +1 -1
  9. package/dist/cli.js +1394 -1281
  10. package/dist/cli.js.map +1 -1
  11. package/dist/configure.d.ts +12 -46
  12. package/dist/configure.d.ts.map +1 -1
  13. package/dist/configure.js +51 -192
  14. package/dist/configure.js.map +1 -1
  15. package/dist/credentials.d.ts +73 -33
  16. package/dist/credentials.d.ts.map +1 -1
  17. package/dist/credentials.js +167 -43
  18. package/dist/credentials.js.map +1 -1
  19. package/dist/discover.d.ts +10 -13
  20. package/dist/discover.d.ts.map +1 -1
  21. package/dist/discover.js +45 -30
  22. package/dist/discover.js.map +1 -1
  23. package/dist/errors.d.ts +1 -1
  24. package/dist/errors.d.ts.map +1 -1
  25. package/dist/errors.js +5 -0
  26. package/dist/errors.js.map +1 -1
  27. package/dist/import.d.ts +3 -0
  28. package/dist/import.d.ts.map +1 -1
  29. package/dist/import.js +3 -0
  30. package/dist/import.js.map +1 -1
  31. package/dist/index.d.ts +9 -7
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +6 -5
  34. package/dist/index.js.map +1 -1
  35. package/dist/mcp/index.d.ts +18 -7
  36. package/dist/mcp/index.d.ts.map +1 -1
  37. package/dist/mcp/index.js +402 -183
  38. package/dist/mcp/index.js.map +1 -1
  39. package/dist/mcp-server.d.ts +5 -1
  40. package/dist/mcp-server.d.ts.map +1 -1
  41. package/dist/mcp-server.js +27 -105
  42. package/dist/mcp-server.js.map +1 -1
  43. package/dist/oauth.d.ts +31 -33
  44. package/dist/oauth.d.ts.map +1 -1
  45. package/dist/oauth.js +178 -125
  46. package/dist/oauth.js.map +1 -1
  47. package/dist/profiles.d.ts +243 -0
  48. package/dist/profiles.d.ts.map +1 -0
  49. package/dist/profiles.js +465 -0
  50. package/dist/profiles.js.map +1 -0
  51. package/dist/project.d.ts +8 -39
  52. package/dist/project.d.ts.map +1 -1
  53. package/dist/project.js +36 -94
  54. package/dist/project.js.map +1 -1
  55. package/dist/remote.d.ts +23 -15
  56. package/dist/remote.d.ts.map +1 -1
  57. package/dist/remote.js +54 -49
  58. package/dist/remote.js.map +1 -1
  59. package/dist/resolvers.d.ts +47 -0
  60. package/dist/resolvers.d.ts.map +1 -0
  61. package/dist/resolvers.js +255 -0
  62. package/dist/resolvers.js.map +1 -0
  63. package/dist/service.d.ts +2 -0
  64. package/dist/service.d.ts.map +1 -1
  65. package/dist/skill-install.d.ts +4 -6
  66. package/dist/skill-install.d.ts.map +1 -1
  67. package/dist/skill-install.js +13 -12
  68. package/dist/skill-install.js.map +1 -1
  69. package/dist/types.d.ts +51 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/docs/cli.md +173 -196
  72. package/docs/mcp.md +69 -65
  73. package/package.json +1 -1
  74. package/skills/synomem/SKILL.md +30 -4
  75. package/skills/synomem/references/examples.md +14 -0
  76. package/src/backend.ts +66 -64
  77. package/src/cli.ts +2137 -2163
  78. package/src/configure.ts +62 -241
  79. package/src/credentials.ts +208 -84
  80. package/src/discover.ts +60 -36
  81. package/src/errors.ts +5 -0
  82. package/src/import.ts +5 -0
  83. package/src/index.ts +14 -12
  84. package/src/mcp/index.ts +473 -194
  85. package/src/mcp-server.ts +32 -114
  86. package/src/oauth.ts +229 -130
  87. package/src/profiles.ts +644 -0
  88. package/src/project.ts +42 -108
  89. package/src/remote.ts +69 -58
  90. package/src/resolvers.ts +299 -0
  91. package/src/service.ts +2 -0
  92. package/src/skill-install.ts +17 -18
  93. package/src/types.ts +46 -0
package/src/project.ts CHANGED
@@ -1,60 +1,56 @@
1
1
  /**
2
- * Per-project workspace selection.
2
+ * Per-project profile selection.
3
3
  *
4
- * The goal is that opening an agent in a repository is enough: every note,
5
- * memo, task, todo and post it writes lands in that repository's workspace,
6
- * with nobody naming the workspace again for the rest of the session.
4
+ * Opening an agent in a repository should be enough to act as the right
5
+ * identity: a `.synomem/project.json` found by walking up from the working
6
+ * directory may name a profile (or an MCP preset) from the user's own
7
+ * `profiles.json`.
7
8
  *
8
- * That cannot be a command typed into a running session. The stdio MCP server
9
- * is bound to a home and an actor when the harness launches it, so a later
10
- * instruction has nothing to retarget. It also should not be one global
11
- * "current workspace": two agents open in two repositories would fight over
12
- * it, which is exactly the case this exists to serve.
13
- *
14
- * So it is a file in the project, found by walking up from the working
15
- * directory — the same shape as `.git`, `.nvmrc` or `.npmrc`, and for the same
16
- * reason: the directory somebody is working in is the thing that knows which
17
- * project this is.
18
- *
19
- * `.synomem/config.json` holds a POINTER, never a store:
20
- *
21
- * { "workspace": "lumina", "actor": "claude" }
22
- *
23
- * The database stays under the Synomem home. Putting one in the repository
24
- * would mean an append-only event log inside somebody's git history, committed
25
- * by accident the first time they ran `git add -A`.
9
+ * It may name ONLY that. A cloned repository is untrusted input, so the file
10
+ * cannot carry a credential, an API origin, a context id, or an actor — it can
11
+ * point at a profile the user already created, and nothing else. An unknown
12
+ * key is refused rather than ignored, so an old `{ "workspace", "actor" }`
13
+ * pointer fails loudly instead of silently doing nothing.
26
14
  */
27
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
28
16
  import { dirname, join, parse, resolve } from 'node:path';
29
- import { resolveHome } from './config.js';
30
17
  import { z } from 'zod';
31
18
  import { SynomemError } from './errors.js';
32
- import { localWorkspaceHome, workspaceNameSchema } from './workspaces.js';
33
19
 
34
20
  export const PROJECT_DIRECTORY = '.synomem';
35
- export const PROJECT_CONFIG_FILE = 'config.json';
21
+ /*
22
+ * `project.json`, never `config.json`: a Synomem home (a local store) keeps its
23
+ * own policy in `.synomem/config.json`, and the walk up from a project directory
24
+ * passes through the user's home — so a shared name would read the store's
25
+ * config as a project pointer and break every command run beneath it.
26
+ */
27
+ export const PROJECT_CONFIG_FILE = 'project.json';
28
+
29
+ const nameSchema = z
30
+ .string()
31
+ .trim()
32
+ .regex(/^[a-z0-9][a-z0-9._-]{0,62}$/i, 'must be a profile or preset name');
36
33
 
37
- export const projectConfigSchema = z.object({
38
- /** The local workspace this project's records belong in. */
39
- workspace: workspaceNameSchema.optional(),
40
- /** The agent this project's records are written by, when it is always one. */
41
- actor: z.string().trim().min(1).max(63).optional(),
42
- });
34
+ export const projectConfigSchema = z
35
+ .object({
36
+ profile: nameSchema.optional(),
37
+ preset: nameSchema.optional(),
38
+ })
39
+ .strict()
40
+ .refine((value) => !(value.profile && value.preset), {
41
+ message: 'name a profile or a preset, not both',
42
+ });
43
43
 
44
44
  export type ProjectConfig = z.infer<typeof projectConfigSchema>;
45
45
 
46
46
  export interface ProjectSelection extends ProjectConfig {
47
- /** The directory whose `.synomem` was used, so tools can say where it came from. */
48
- directory: string;
47
+ /** The file that supplied the selection, so errors can say where it came from. */
48
+ path: string;
49
49
  }
50
50
 
51
51
  /**
52
- * The nearest project configuration at or above `from`.
53
- *
54
- * Walking up stops at the filesystem root, and never treats the Synomem home
55
- * itself as a project: `~/.synomem/config.json` is a real Synomem config with a
56
- * different shape, and reading it as a project pointer would silently apply a
57
- * home's settings to every command run anywhere under the home directory.
52
+ * The nearest project file at or above `from`. The Synomem home itself is
53
+ * never treated as a project directory.
58
54
  */
59
55
  export function findProjectSelection(
60
56
  from: string = process.cwd(),
@@ -63,12 +59,11 @@ export function findProjectSelection(
63
59
  const stopAt = parse(resolve(from)).root;
64
60
  let directory = resolve(from);
65
61
  for (;;) {
66
- const candidate = join(directory, PROJECT_DIRECTORY, PROJECT_CONFIG_FILE);
67
- // `<home>/config.json` is the home's own config, not a project pointer, and
68
- // the home is never a project directory.
69
- const isHome = home !== undefined && resolve(home) === join(directory, PROJECT_DIRECTORY);
62
+ const folder = join(directory, PROJECT_DIRECTORY);
63
+ const candidate = join(folder, PROJECT_CONFIG_FILE);
64
+ const isHome = home !== undefined && resolve(home) === folder;
70
65
  if (!isHome && existsSync(candidate)) {
71
- return { ...readProjectConfig(candidate), directory };
66
+ return { ...readProjectConfig(candidate), path: candidate };
72
67
  }
73
68
  if (directory === stopAt) return undefined;
74
69
  const parent = dirname(directory);
@@ -88,7 +83,9 @@ function readProjectConfig(path: string): ProjectConfig {
88
83
  if (!parsed.success) {
89
84
  throw new SynomemError(
90
85
  'CONFIG_INVALID',
91
- `${path} is not a valid Synomem project file: ${parsed.error.issues[0]?.message ?? 'unknown problem'}`,
86
+ `${path} is not a valid Synomem project file (it may name only a "profile" or a "preset"): ${
87
+ parsed.error.issues[0]?.message ?? 'unknown problem'
88
+ }`,
92
89
  );
93
90
  }
94
91
  return parsed.data;
@@ -103,66 +100,3 @@ export function writeProjectSelection(directory: string, config: ProjectConfig):
103
100
  writeFileSync(path, `${JSON.stringify(parsed, null, 2)}\n`);
104
101
  return path;
105
102
  }
106
-
107
- /**
108
- * Which workspace to act in, and as whom.
109
- *
110
- * One resolver, used by both the CLI and the stdio MCP server, so the two
111
- * cannot disagree about precedence. Order, most specific first:
112
- *
113
- * 1. `--workspace` / `--actor` — said on this invocation
114
- * 2. `SYNOMEM_WORKSPACE` / `SYNOMEM_ACTOR_ID` — set for this process
115
- * 3. `.synomem/config.json` in the project, found by walking up from the
116
- * working directory
117
- * 4. the root home, which is the default workspace
118
- *
119
- * The project file is third rather than first because a flag someone typed
120
- * should always beat a file they may have forgotten is there.
121
- */
122
- export function resolveWorkspaceSelection(input: {
123
- flag?: string;
124
- actorFlag?: string;
125
- env?: NodeJS.ProcessEnv;
126
- cwd?: string;
127
- explicitRoot?: string;
128
- }): { home: string; workspace?: string; actor?: string; source: string } {
129
- const env = input.env ?? process.env;
130
- const root = resolveHome(input.explicitRoot);
131
-
132
- if (input.flag) {
133
- return {
134
- home: localWorkspaceHome(input.flag, input.explicitRoot),
135
- workspace: input.flag,
136
- ...(input.actorFlag ? { actor: input.actorFlag } : {}),
137
- source: 'the --workspace option',
138
- };
139
- }
140
-
141
- const fromEnv = env.SYNOMEM_WORKSPACE?.trim();
142
- if (fromEnv) {
143
- return {
144
- home: localWorkspaceHome(fromEnv, input.explicitRoot),
145
- workspace: fromEnv,
146
- ...((input.actorFlag ?? env.SYNOMEM_ACTOR_ID?.trim())
147
- ? { actor: input.actorFlag ?? env.SYNOMEM_ACTOR_ID?.trim() }
148
- : {}),
149
- source: 'SYNOMEM_WORKSPACE',
150
- };
151
- }
152
-
153
- const project = findProjectSelection(input.cwd ?? process.cwd(), root);
154
- if (project?.workspace) {
155
- return {
156
- home: localWorkspaceHome(project.workspace, input.explicitRoot),
157
- workspace: project.workspace,
158
- ...((input.actorFlag ?? project.actor) ? { actor: input.actorFlag ?? project.actor } : {}),
159
- source: join(project.directory, PROJECT_DIRECTORY, PROJECT_CONFIG_FILE),
160
- };
161
- }
162
-
163
- return {
164
- home: root,
165
- ...((input.actorFlag ?? project?.actor) ? { actor: input.actorFlag ?? project?.actor } : {}),
166
- source: 'the default workspace',
167
- };
168
- }
package/src/remote.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { actorSchema } from './schemas.js';
2
- import { asSynomemError, errorCodes, SynomemError, type SynomemErrorCode } from './errors.js';
2
+ import { errorCodes, SynomemError, type SynomemErrorCode } from './errors.js';
3
3
  import type { SynomemService, SynomemServiceCapabilities, SynomemServiceInfo } from './service.js';
4
4
  import type {
5
5
  ActorIdentity,
@@ -31,26 +31,40 @@ export interface SynomemCredentialProvider {
31
31
  getAccessToken(signal?: AbortSignal): Promise<string | undefined>;
32
32
  }
33
33
 
34
+ /**
35
+ * Where a remote service gets its bearer: an OAuth access token or a `syn_` access key
36
+ * (identity contract §6.1). It may refresh; it must never be logged.
37
+ */
38
+ export interface RemoteCredentialSource {
39
+ bearer(): Promise<string>;
40
+ }
41
+
42
+ export type RemoteCredential =
43
+ RemoteCredentialSource | SynomemCredentialProvider | (() => Promise<string | undefined>);
44
+
34
45
  export interface RemoteSynomemOptions {
35
46
  baseUrl: string;
47
+ /** The workspace in the route. Must equal the selected context's workspace. */
36
48
  workspaceId: string;
37
- expectedActor: ActorIdentity;
38
- credentialProvider: SynomemCredentialProvider;
49
+ /**
50
+ * The stable target this service acts as. Sent as `Synomem-Context-Id` on every request.
51
+ * Omit only for a fixed credential, whose single pinned context the API selects itself.
52
+ */
53
+ contextId?: string;
54
+ credential: RemoteCredential;
39
55
  fetch?: typeof fetch;
40
56
  signal?: AbortSignal;
41
57
  timeoutMs?: number;
42
58
  maximumResponseBytes?: number;
43
- /**
44
- * False when `expectedActor` is only a historical CLI fallback guess (a
45
- * caller named no real identity via `--actor`, `SYNOMEM_ACTOR_ID`, or a
46
- * project binding), not an identity the caller actually asserted. Defaults
47
- * to true, so every existing caller keeps its current strict behavior.
48
- * `kind: 'system'` already bypasses the match unconditionally (its own
49
- * historical bootstrap placeholder); this is the same bypass for a
50
- * fallback of any other kind, without pretending that fallback IS a real
51
- * identity.
52
- */
53
- assertActor?: boolean;
59
+ }
60
+
61
+ /** Normalizes any accepted credential shape into one bearer lookup. */
62
+ export function bearerFrom(
63
+ credential: RemoteCredential,
64
+ ): (signal?: AbortSignal) => Promise<string | undefined> {
65
+ if (typeof credential === 'function') return () => credential();
66
+ if ('bearer' in credential) return () => credential.bearer();
67
+ return (signal) => credential.getAccessToken(signal);
54
68
  }
55
69
 
56
70
  interface ApiErrorEnvelope {
@@ -110,6 +124,14 @@ function queryString(input: object): string {
110
124
  return encoded ? `?${encoded}` : '';
111
125
  }
112
126
 
127
+ /** Codes a caller must be able to branch on, never collapsed into AUTH_* by status. */
128
+ const contextCodes = new Set<SynomemErrorCode>([
129
+ 'CONTEXT_REQUIRED',
130
+ 'CONTEXT_FORBIDDEN',
131
+ 'CONTEXT_AMBIGUOUS',
132
+ 'REAUTHORIZATION_REQUIRED',
133
+ ]);
134
+
113
135
  function knownErrorCode(value: string): SynomemErrorCode {
114
136
  return errorCodes.some((code) => code === value)
115
137
  ? (value as SynomemErrorCode)
@@ -157,17 +179,19 @@ export function environmentCredentialProvider(
157
179
  }
158
180
 
159
181
  export class RemoteSynomemService implements SynomemService {
160
- // Not `readonly`: `init()` resolves it from the server's response when the
161
- // caller didn't assert a real identity up front (see `assertedRealActor`).
162
- actor: ActorIdentity;
182
+ /**
183
+ * The actor this service acts as, learned from the server during `init()` — never asserted
184
+ * by the caller. Before `init()` it is a placeholder that no request uses.
185
+ */
186
+ actor: ActorIdentity = { kind: 'system', id: 'unbound' };
187
+ readonly contextId?: string;
163
188
  private readonly baseUrl: URL;
164
189
  private readonly workspaceId: string;
165
- private readonly credentialProvider: SynomemCredentialProvider;
190
+ private readonly bearer: (signal?: AbortSignal) => Promise<string | undefined>;
166
191
  private readonly fetchImplementation: typeof fetch;
167
192
  private readonly signal?: AbortSignal;
168
193
  private readonly timeoutMs: number;
169
194
  private readonly maximumResponseBytes: number;
170
- private readonly assertActor: boolean;
171
195
  private initialized = false;
172
196
  private cachedCapabilities?: SynomemServiceCapabilities;
173
197
 
@@ -540,13 +564,13 @@ export class RemoteSynomemService implements SynomemService {
540
564
  throw new SynomemError('CONFIG_INVALID', 'Remote Synomem workspaceId is required.');
541
565
  }
542
566
  this.workspaceId = options.workspaceId;
543
- try {
544
- this.actor = actorSchema.parse(options.expectedActor);
545
- } catch (error) {
546
- throw asSynomemError(error);
567
+ if (options.contextId !== undefined) {
568
+ if (!/^[A-Za-z0-9_-]{1,100}$/.test(options.contextId)) {
569
+ throw new SynomemError('CONFIG_INVALID', 'Remote Synomem contextId is malformed.');
570
+ }
571
+ this.contextId = options.contextId;
547
572
  }
548
- this.credentialProvider = options.credentialProvider;
549
- this.assertActor = options.assertActor ?? true;
573
+ this.bearer = bearerFrom(options.credential);
550
574
  this.fetchImplementation = options.fetch ?? fetch;
551
575
  this.signal = options.signal;
552
576
  this.timeoutMs = options.timeoutMs ?? defaultTimeoutMs;
@@ -572,30 +596,21 @@ export class RemoteSynomemService implements SynomemService {
572
596
  throw new SynomemError('REMOTE_PROTOCOL', 'The configured server is not a remote backend.');
573
597
  }
574
598
  const binding = this.cachedCapabilities.binding;
575
- // `kind: 'system'` is this client's own placeholder for "no actor
576
- // asserted" — historically the CLI default for commands that run before
577
- // any identity exists (`doctor`, `agent list`, `agent create`, …). No
578
- // credential the server authenticates ever reports that kind back, so
579
- // asserting it here would make every one of those commands fail against
580
- // a remote backend no matter who is actually calling — exactly the
581
- // bootstrap deadlock the server-side half of this already fixed.
582
- // `assertActor: false` is the same bypass for a fallback of any other
583
- // kind: a caller whose "actor" is only a historical guess (never a real
584
- // --actor, SYNOMEM_ACTOR_ID, or project binding) that should adopt
585
- // whatever the credential actually names rather than fail outright.
586
- // Either way, only a caller that named a real identity gets it enforced.
587
- const assertedRealActor = this.assertActor && this.actor.kind !== 'system';
588
- if (
589
- binding.workspaceId !== this.workspaceId ||
590
- (assertedRealActor &&
591
- (binding.actor.kind !== this.actor.kind || binding.actor.id !== this.actor.id))
592
- ) {
599
+ if (binding.workspaceId !== this.workspaceId) {
593
600
  throw new SynomemError(
594
- 'AUTH_FORBIDDEN',
595
- 'The authenticated Synomem actor does not match the configured actor.',
601
+ 'CONTEXT_FORBIDDEN',
602
+ 'The selected Synomem context does not belong to the workspace this service addresses.',
596
603
  );
597
604
  }
598
- if (!assertedRealActor) this.actor = binding.actor;
605
+ if (this.contextId && binding.contextId && binding.contextId !== this.contextId) {
606
+ throw new SynomemError('CONTEXT_FORBIDDEN', 'The server bound a different context.');
607
+ }
608
+ const actor = actorSchema.safeParse(binding.actor);
609
+ if (!actor.success) {
610
+ throw new SynomemError('REMOTE_PROTOCOL', 'The server reported an invalid bound actor.');
611
+ }
612
+ // The server decides who this is; the client only learns it (plan §5 rule 7).
613
+ this.actor = actor.data;
599
614
  this.initialized = true;
600
615
  }
601
616
 
@@ -698,7 +713,7 @@ export class RemoteSynomemService implements SynomemService {
698
713
  body?: object,
699
714
  idempotencyKey?: string,
700
715
  ): Promise<T> {
701
- const accessToken = await this.credentialProvider.getAccessToken(this.signal);
716
+ const accessToken = await this.bearer(this.signal);
702
717
  if (!accessToken) {
703
718
  throw new SynomemError('AUTH_REQUIRED', 'Remote Synomem authentication is required.');
704
719
  }
@@ -721,15 +736,9 @@ export class RemoteSynomemService implements SynomemService {
721
736
  headers: {
722
737
  accept: 'application/json',
723
738
  authorization: `Bearer ${accessToken}`,
724
- /*
725
- * Harmless for an OAuth actor token (its workspace and identity are
726
- * already bound into the token itself), and required for an access
727
- * key: an access key authenticates the member who owns it, not a
728
- * machine or a workspace, so which workspace and which agent are
729
- * meant have to be named on every request.
730
- */
731
- 'synomem-workspace-id': this.workspaceId,
732
- ...(this.actor.kind === 'agent' ? { 'synomem-agent-id': this.actor.id } : {}),
739
+ // The ONLY selector (identity contract §3). The server authorizes it against the
740
+ // credential's grant on every request; knowing an id grants nothing.
741
+ ...(this.contextId ? { 'synomem-context-id': this.contextId } : {}),
733
742
  ...(body ? { 'content-type': 'application/json' } : {}),
734
743
  ...(idempotencyKey ? { 'idempotency-key': idempotencyKey } : {}),
735
744
  },
@@ -760,8 +769,10 @@ export class RemoteSynomemService implements SynomemService {
760
769
  if (envelope.ok !== false || !envelope.error || typeof envelope.error.message !== 'string') {
761
770
  throw new SynomemError('REMOTE_PROTOCOL', 'Remote Synomem returned an invalid error.');
762
771
  }
763
- const code =
764
- response.status === 401
772
+ const reported = knownErrorCode(envelope.error.code);
773
+ const code = contextCodes.has(reported)
774
+ ? reported
775
+ : response.status === 401
765
776
  ? 'AUTH_REQUIRED'
766
777
  : response.status === 403
767
778
  ? 'AUTH_FORBIDDEN'