synomem 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/README.md +30 -12
  3. package/dist/cli.d.ts +1 -1
  4. package/dist/cli.d.ts.map +1 -1
  5. package/dist/cli.js +205 -32
  6. package/dist/cli.js.map +1 -1
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +10 -0
  9. package/dist/client.js.map +1 -1
  10. package/dist/config.d.ts +1 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +4 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/import.d.ts +4 -4
  15. package/dist/index.d.ts +4 -0
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +2 -0
  18. package/dist/index.js.map +1 -1
  19. package/dist/mcp/index.d.ts.map +1 -1
  20. package/dist/mcp/index.js +49 -3
  21. package/dist/mcp/index.js.map +1 -1
  22. package/dist/mcp-server.js +26 -3
  23. package/dist/mcp-server.js.map +1 -1
  24. package/dist/ports/projections.d.ts +8 -0
  25. package/dist/ports/projections.d.ts.map +1 -1
  26. package/dist/project.d.ts +51 -0
  27. package/dist/project.d.ts.map +1 -0
  28. package/dist/project.js +143 -0
  29. package/dist/project.js.map +1 -0
  30. package/dist/projections.d.ts +14 -0
  31. package/dist/projections.d.ts.map +1 -1
  32. package/dist/projections.js +36 -1
  33. package/dist/projections.js.map +1 -1
  34. package/dist/schemas.d.ts +18 -4
  35. package/dist/schemas.d.ts.map +1 -1
  36. package/dist/schemas.js +29 -2
  37. package/dist/schemas.js.map +1 -1
  38. package/dist/service.d.ts +1 -0
  39. package/dist/service.d.ts.map +1 -1
  40. package/dist/storage.d.ts +13 -0
  41. package/dist/storage.d.ts.map +1 -1
  42. package/dist/storage.js +24 -0
  43. package/dist/storage.js.map +1 -1
  44. package/dist/types.d.ts +1 -0
  45. package/dist/types.d.ts.map +1 -1
  46. package/dist/workspaces.d.ts +41 -0
  47. package/dist/workspaces.d.ts.map +1 -0
  48. package/dist/workspaces.js +96 -0
  49. package/dist/workspaces.js.map +1 -0
  50. package/package.json +1 -1
  51. package/skills/synomem/SKILL.md +9 -3
  52. package/skills/synomem/references/examples.md +5 -3
  53. package/src/cli.ts +245 -32
  54. package/src/client.ts +10 -0
  55. package/src/config.ts +4 -0
  56. package/src/index.ts +17 -0
  57. package/src/mcp/index.ts +65 -3
  58. package/src/mcp-server.ts +32 -5
  59. package/src/ports/projections.ts +9 -0
  60. package/src/project.ts +168 -0
  61. package/src/projections.ts +38 -1
  62. package/src/schemas.ts +44 -12
  63. package/src/service.ts +1 -0
  64. package/src/storage.ts +28 -0
  65. package/src/types.ts +1 -0
  66. package/src/workspaces.ts +107 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,70 @@
3
3
  All notable changes will be documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and releases follow Semantic Versioning.
5
5
 
6
+ ## [0.6.0] - 2026-09-07
7
+
8
+ ### Added
9
+
10
+ - Local workspace isolation is now real. A local workspace used to be a
11
+ `--workspace` label with nothing behind it — only `events` carried
12
+ `workspace_id`, reads never filtered on it, and several tables had no such
13
+ column at all. A local workspace is now a separate SQLite database in its
14
+ own home, the safer design given SQLite has no row-level security: on
15
+ Postgres a query that forgets to filter by workspace returns nothing
16
+ instead of another workspace's rows, and separate files make the local
17
+ equivalent of that mistake unwritable rather than merely unlikely.
18
+ - A directory can now be bound to a local workspace: `synomem workspace use
19
+ <name> [--as <actor>]` writes `.synomem/config.json`, found by walking up
20
+ from the working directory the same way `.git` or `.nvmrc` is. Every command
21
+ and every stdio MCP server started in that directory afterward resolves the
22
+ workspace — and, unless overridden per launch, the default actor — with no
23
+ flag repeated. This is what makes several harnesses opened in the same
24
+ repository share one workspace while each still writes as its own actor:
25
+ `--workspace`, `SYNOMEM_WORKSPACE`, and `--home` all still win outright, in
26
+ that order, over the project file, and each harness's own `--agent-id` or
27
+ `--actor-id` always wins over the file's default actor.
28
+ - `synomem_agent_archive` and `synomem_agent_restore` MCP tools, closing the
29
+ gap where the CLI could archive or restore an agent identity but MCP could
30
+ not. Gated by a new `allowAgentArchiveViaMcp` capability
31
+ (`SYNOMEM_ALLOW_AGENT_ARCHIVE_VIA_MCP`), off by default and mirroring
32
+ `agentCreationViaMcp` exactly, so runtime agents cannot silently disable
33
+ each other unless an operator opts in.
34
+
35
+ ### Fixed
36
+
37
+ - An agent created before handles existed (schema 7) made every write in its
38
+ workspace fail with `UNSUPPORTED_EVENT`: its stored `agent.created` event
39
+ carries an id and no handle, and the compatibility check that runs before
40
+ each write refused the whole event stream on that missing field. The reader
41
+ now widens to accept it — the id is the correct handle for those records —
42
+ rather than the append-only log being rewritten.
43
+ - Renaming an agent's handle left its old projection directory behind,
44
+ stranding `NOTES.md`, the one file in there that belongs to the reader
45
+ rather than to Synomem and that a rebuild will never delete. The directory
46
+ is now moved to the new handle before projections are regenerated, so
47
+ hand-written notes arrive intact instead of being stranded.
48
+
49
+ ## [0.5.3] - 2026-09-07
50
+
51
+ ### Documentation
52
+
53
+ - 0.5.2 said the inbox "does not include posts or todos", which implied notes
54
+ were in it. They are not. The inbox holds kudos, memos and tasks and nothing
55
+ else, because it answers one question -- what is another actor waiting on
56
+ this agent for -- and nobody waits on an agent's own knowledge, its own
57
+ reminders, or an announcement addressed to everyone. The Skill, the examples
58
+ and the `synomem_inbox` description now say what it contains rather than
59
+ listing exclusions and getting the list wrong.
60
+ - `synomem_list` and `synomem_get` still described four record kinds out of
61
+ six. `synomem_list` now also says that posts and todos are reachable only
62
+ through it, and that an empty inbox is not the same as nothing to look at.
63
+
64
+ ### Tests
65
+
66
+ - The inbox contract is pinned by a test, since the Skill and the MCP tool
67
+ descriptions both state it and neither can check it. Confirmed to fail when a
68
+ kind is added to the `pending` filter.
69
+
6
70
  ## [0.5.2] - 2026-09-07
7
71
 
8
72
  ### Documentation
package/README.md CHANGED
@@ -38,9 +38,9 @@ Synomem gives humans and AI agents durable ways to coordinate beyond a disappear
38
38
  Traditional AI memory layers resemble an isolated file cabinet for one model. Synomem turns memory
39
39
  into a shared, transactional canvas: independently operating agents can retain private knowledge,
40
40
  deliver durable context, delegate work with consent, track commitments, and recognize good
41
- collaboration through one auditable protocol. V1 provides that substrate locally; its interfaces are
42
- designed so the same agent identities and semantics can later cross machines through an explicitly
43
- configured service.
41
+ collaboration through one auditable protocol. The same agent identities and semantics work on one
42
+ machine or across many: the local backend keeps everything on disk, and Synomem Cloud keeps it in a
43
+ hosted workspace reached over HTTPS.
44
44
 
45
45
  One append-only event store powers the TypeScript library, `synomem` CLI, actor-bound stdio MCP
46
46
  server, compact change feeds, and readable Markdown projections. On the local backend that store is
@@ -85,7 +85,7 @@ Everything below works the same on both backends.
85
85
 
86
86
  ```bash
87
87
  export SYNOMEM_HOME="$(mktemp -d)/.synomem"
88
- synomem init
88
+ synomem config init --backend local --yes
89
89
  synomem agent create codex --name "Codex"
90
90
  synomem agent create gracie --name "Gracie"
91
91
 
@@ -243,23 +243,41 @@ are `claude`, `codex`, `hermes`, `openclaw`, `cursor`, and `grok`; `grokbot` ali
243
243
 
244
244
  ## Storage
245
245
 
246
+ This is the local backend. On Synomem Cloud the canonical store is a hosted Postgres workspace and
247
+ nothing below is written to this machine.
248
+
246
249
  ```text
247
250
  ~/.synomem/
248
- ├── synomem/
249
- ├── config.json
250
- │ └── synomem.sqlite3
251
- └── <agent-id>/
251
+ ├── config.json
252
+ ├── synomem.sqlite3
253
+ ├── credentials/
254
+ └── installation.json # only when an access key is stored in a file
255
+ └── <handle>/ # one directory per agent, named by handle
252
256
  ├── profile.json
253
257
  ├── WINS.md
254
258
  ├── MEMORY.md
255
259
  ├── TASKS.md
256
- ├── inbox/{kudos,memos,tasks}/
257
- └── NOTES.md
260
+ ├── NOTES.md
261
+ └── inbox/{kudos,memos,tasks}/<record-id>.md
258
262
  ```
259
263
 
264
+ The home IS the storage directory: `config.json` and the database sit directly in it, with no
265
+ nested `synomem/` level.
266
+
267
+ Agent directories are named by HANDLE, because they exist to be read. The canonical agent ID is
268
+ what stored events reference, so renaming an agent leaves its history untouched.
269
+
270
+ Renaming moves the whole directory, `NOTES.md` included. That file is yours rather than Synomem's,
271
+ so a rebuild will never delete it — which is exactly why the rename moves the directory instead of
272
+ regenerating it somewhere new and leaving your notes behind.
273
+
260
274
  SQLite events are canonical and append-only. Markdown and current-state tables are rebuildable
261
- projections. `NOTES.md` is human-owned and is never overwritten; canonical agent notes project to
262
- `MEMORY.md`.
275
+ projections run `synomem rebuild` to regenerate them, and `synomem projection status` to see
276
+ whether they currently match the events. Posts and todos project no files: a post belongs to the
277
+ whole workspace rather than to one agent's directory, and a todo is private to its owner.
278
+
279
+ `NOTES.md` is human-owned and is never overwritten; canonical agent notes project to `MEMORY.md`.
280
+ Each projection can be turned off individually, in which case its file is not written at all.
263
281
 
264
282
  Override the root with `SYNOMEM_HOME`, `--home`, or the library's `home` option. Use
265
283
  `synomem backup` for a consistent snapshot and JSON or JSONL export for recovery. Never synchronize
package/dist/cli.d.ts CHANGED
@@ -38,6 +38,6 @@ export interface CliDependencies {
38
38
  planId?: string;
39
39
  }) => Promise<ImportPreview | ImportResult>;
40
40
  }
41
- export declare function createCli(io?: CliIo, serviceFactory?: SynomemServiceFactory, dependencies?: CliDependencies): Command;
41
+ export declare function createCli(io?: CliIo, serviceFactory?: SynomemServiceFactory, dependencies?: CliDependencies, argv?: string[]): Command;
42
42
  export declare function runCli(argv?: string[], io?: CliIo, serviceFactory?: SynomemServiceFactory, dependencies?: CliDependencies): Promise<number>;
43
43
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAE,OAAO,EAA0B,MAAM,WAAW,CAAC;AAmB5D,OAAO,EAAmB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAA0C,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAIhG,OAAO,EAA4C,KAAK,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE9F,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AASrB,OAAO,KAAK,EACV,aAAa,EASd,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAkB,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAE1E,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,sBAAsB,CAAC,EAAE,CAAC,OAAO,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,eAAe,CAAC;KAClC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAKpB,sBAAsB,CAAC,EAAE,CAAC,OAAO,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;KACrB,KAAK,OAAO,CAAC;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvC,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;IAC7D,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,MAAM,EAAE,YAAY,CAAC;QACrB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,KAAK,OAAO,CAAC,aAAa,GAAG,YAAY,CAAC,CAAC;CAC7C;AAkND,wBAAgB,SAAS,CACvB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,GACjC,OAAO,CA62ET;AAED,wBAAsB,MAAM,CAC1B,IAAI,WAAe,EACnB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,GACjC,OAAO,CAAC,MAAM,CAAC,CAqBjB"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAKA,OAAO,EAAE,OAAO,EAA0B,MAAM,WAAW,CAAC;AAyB5D,OAAO,EAAmB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EAA0C,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAIhG,OAAO,EAA4C,KAAK,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAE9F,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,YAAY,EAClB,MAAM,aAAa,CAAC;AASrB,OAAO,KAAK,EACV,aAAa,EASd,MAAM,YAAY,CAAC;AAEpB,OAAO,KAAK,EAAkB,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAE1E,MAAM,WAAW,KAAK;IACpB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAChC;AAED,MAAM,WAAW,eAAe;IAC9B,yEAAyE;IACzE,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,eAAe,CAAC,EAAE,eAAe,CAAC;IAClC,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,iBAAiB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,sBAAsB,CAAC,EAAE,CAAC,OAAO,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,SAAS,EAAE,MAAM,CAAC;QAClB,eAAe,EAAE,eAAe,CAAC;KAClC,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAKpB,sBAAsB,CAAC,EAAE,CAAC,OAAO,EAAE;QACjC,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;KACrB,KAAK,OAAO,CAAC;QAAE,WAAW,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACvC,kBAAkB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC,YAAY,CAAC,CAAC;IAC7D,YAAY,CAAC,EAAE,CAAC,OAAO,EAAE;QACvB,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,MAAM,CAAC;QACpB,KAAK,EAAE,aAAa,CAAC;QACrB,MAAM,EAAE,YAAY,CAAC;QACrB,MAAM,CAAC,EAAE,MAAM,CAAC;KACjB,KAAK,OAAO,CAAC,aAAa,GAAG,YAAY,CAAC,CAAC;CAC7C;AA+QD,wBAAgB,SAAS,CACvB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,EAClC,IAAI,GAAE,MAAM,EAAiB,GAC5B,OAAO,CA8/ET;AAED,wBAAsB,MAAM,CAC1B,IAAI,WAAe,EACnB,EAAE,GAAE,KAAiB,EACrB,cAAc,GAAE,qBAAgD,EAChE,YAAY,GAAE,eAAoB,GACjC,OAAO,CAAC,MAAM,CAAC,CAqBjB"}
package/dist/cli.js CHANGED
@@ -9,6 +9,8 @@ import { cloudApiUrl } from './cloud.js';
9
9
  import { resolveHome } from './config.js';
10
10
  import { assertInteractive, confirmPlan, credentialFingerprint, credentialStoreChoices, environmentInstructions, readAccessToken, runConfigWizard, writeCredentialFile, } from './configure.js';
11
11
  import { discoverBoundWorkspace, discoverOrganizations, workspaceChoices } from './discover.js';
12
+ import { DEFAULT_WORKSPACE, listLocalWorkspaces, localWorkspaceHome } from './workspaces.js';
13
+ import { findProjectSelection, resolveWorkspaceSelection, writeProjectSelection, } from './project.js';
12
14
  import { defaultPromptIo } from './prompt.js';
13
15
  import { credentialReference, OsCredentialStore } from './credentials.js';
14
16
  import { asSynomemError, SynomemError } from './errors.js';
@@ -61,11 +63,19 @@ function actor(kind, id, displayName) {
61
63
  * backend binds the credential to one actor, so SYNOMEM_ACTOR_ID/KIND/NAME must be able to
62
64
  * override them or those commands cannot authenticate.
63
65
  */
64
- function defaultActor(env, fallbackKind, fallbackId) {
65
- const id = env.SYNOMEM_ACTOR_ID?.trim();
66
+ function defaultActor(env, fallbackKind, fallbackId,
67
+ /** `--actor`, which outranks the environment: it is said on this invocation. */
68
+ override) {
69
+ const id = override?.trim() || env.SYNOMEM_ACTOR_ID?.trim();
66
70
  if (!id)
67
71
  return actor(fallbackKind, fallbackId);
68
- return actor(env.SYNOMEM_ACTOR_KIND?.trim() || fallbackKind, id, env.SYNOMEM_ACTOR_NAME?.trim());
72
+ const kind = override?.trim()
73
+ ? // An explicit --actor names an agent unless told otherwise; the historical
74
+ // fallbacks here are `system`/`cli`, which is not what somebody means when
75
+ // they name one.
76
+ env.SYNOMEM_ACTOR_KIND?.trim() || 'agent'
77
+ : env.SYNOMEM_ACTOR_KIND?.trim() || fallbackKind;
78
+ return actor(kind, id, env.SYNOMEM_ACTOR_NAME?.trim());
69
79
  }
70
80
  function taskDue(options) {
71
81
  if (options.dueDate && options.dueAt)
@@ -148,9 +158,28 @@ function showRecord(record) {
148
158
  function output(io, json, value, human) {
149
159
  io.stdout(json ? `${JSON.stringify(value, null, 2)}\n` : `${human}\n`);
150
160
  }
151
- function globals(command) {
152
- return command.optsWithGlobals();
161
+ /**
162
+ * The resolved global options for a command.
163
+ *
164
+ * `--workspace` is turned into a home HERE, before any service exists, which is
165
+ * the whole reason it costs nothing downstream: a local workspace is a separate
166
+ * database in its own home, and choosing one is choosing a home. Nothing in the
167
+ * domain, the commands, or the MCP tools learns that a workspace was selected.
168
+ *
169
+ * On a remote backend the name means a hosted workspace instead, which
170
+ * `backend use remote --workspace` already handles; passing both here would be
171
+ * two different answers to the same question, so it is refused.
172
+ */
173
+ /** `parent child`, so a subcommand name cannot be confused with another's. */
174
+ function commandPath(command) {
175
+ const parent = command.parent?.name();
176
+ return parent && parent !== 'synomem' ? `${parent} ${command.name()}` : command.name();
153
177
  }
178
+ /**
179
+ * Commands where `--workspace` names a HOSTED workspace being configured,
180
+ * rather than a local one to act in.
181
+ */
182
+ const CONFIGURES_BACKEND = new Set(['config init', 'backend use', 'remote import']);
154
183
  async function withService(serviceFactory, home, configuredActor, operation) {
155
184
  const client = serviceFactory({ ...(home ? { home } : {}), actor: configuredActor });
156
185
  await client.init();
@@ -195,8 +224,77 @@ function listInput(options) {
195
224
  offset: Number(options.offset),
196
225
  };
197
226
  }
198
- export function createCli(io = defaultIo, serviceFactory = configuredServiceFactory, dependencies = {}) {
227
+ /**
228
+ * The value `--actor` should supply to commands that name an actor.
229
+ *
230
+ * Read from argv directly, before the commands are built, because Commander
231
+ * evaluates option defaults at DECLARATION time: a `--as` declared without one
232
+ * is required, and a `--as` declared with one is already satisfied. Supplying
233
+ * it here is a single change point instead of a fallback threaded through
234
+ * twenty action bodies, and `--as` still wins when both are given because an
235
+ * explicitly passed option overrides its default.
236
+ */
237
+ function actorDefault(argv, env) {
238
+ for (let index = 0; index < argv.length; index += 1) {
239
+ const argument = argv[index];
240
+ if (argument === '--actor')
241
+ return argv[index + 1]?.trim() || undefined;
242
+ if (argument.startsWith('--actor='))
243
+ return argument.slice('--actor='.length).trim() || undefined;
244
+ }
245
+ if (env.SYNOMEM_ACTOR_ID?.trim())
246
+ return env.SYNOMEM_ACTOR_ID.trim();
247
+ /*
248
+ * Last, the project's own binding. `workspace use --as` exists so a
249
+ * repository can settle both questions once — which workspace, and as whom —
250
+ * and a command run there needs neither flag afterwards.
251
+ */
252
+ try {
253
+ return findProjectSelection()?.actor;
254
+ }
255
+ catch {
256
+ // A malformed project file is reported by the resolver when the command
257
+ // actually runs, with the path in the message. Failing here would turn it
258
+ // into an error before any command had been parsed.
259
+ return undefined;
260
+ }
261
+ }
262
+ export function createCli(io = defaultIo, serviceFactory = configuredServiceFactory, dependencies = {}, argv = process.argv) {
199
263
  const env = dependencies.env ?? process.env;
264
+ const actingDefault = actorDefault(argv, env);
265
+ const globals = (command) => {
266
+ const options = command.optsWithGlobals();
267
+ if (CONFIGURES_BACKEND.has(commandPath(command)))
268
+ return options;
269
+ /*
270
+ * Resolution runs even with no `--workspace`, because a project's
271
+ * `.synomem/config.json` selects one without anybody passing a flag — that is
272
+ * the whole point of it. `--home` still wins outright: it names a home
273
+ * directly rather than a workspace within one.
274
+ */
275
+ if (!options.home) {
276
+ const selection = resolveWorkspaceSelection({
277
+ ...(options.workspace ? { flag: options.workspace } : {}),
278
+ ...(options.actor ? { actorFlag: options.actor } : {}),
279
+ env,
280
+ });
281
+ return { ...options, home: selection.home, actor: selection.actor ?? options.actor };
282
+ }
283
+ if (!options.workspace)
284
+ return options;
285
+ /*
286
+ * One flag, one meaning — "which workspace" — resolved differently by the
287
+ * handful of commands that CONFIGURE a backend rather than act inside one.
288
+ * For those, the value is a hosted workspace ID to be written to the config,
289
+ * so it is passed through raw and they read `workspace`. Everywhere else it
290
+ * names a local workspace, which is a home.
291
+ *
292
+ * These commands used to declare their own `--workspace`, which does not
293
+ * work: Commander gives a duplicated long flag to the parent, so the
294
+ * subcommand never received it at all.
295
+ */
296
+ return { ...options, home: localWorkspaceHome(options.workspace, options.home) };
297
+ };
200
298
  const credentialStore = dependencies.credentialStore ?? new OsCredentialStore();
201
299
  const oauthLogin = dependencies.oauthLogin ?? loginWithOAuth;
202
300
  const discoverWorkspace = dependencies.discoverBoundWorkspace ?? discoverBoundWorkspace;
@@ -224,9 +322,86 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
224
322
  .description('Local-first communication, memory, recognition, and task infrastructure for agents')
225
323
  .version(packageVersion())
226
324
  .option('--home <path>', 'storage root (defaults to SYNOMEM_HOME or ~/.synomem)')
325
+ // A local workspace is its own database under the root, so this selects a
326
+ // home. On a remote backend the hosted workspace is chosen by
327
+ // `backend use remote --workspace` instead.
328
+ .option('--workspace <name>', 'local workspace to act in (see `synomem workspace list`)')
329
+ .option('--actor <id>', 'act as this agent (overrides SYNOMEM_ACTOR_ID)')
227
330
  .option('--json', 'emit stable machine-readable JSON', false)
228
331
  .showSuggestionAfterError()
229
332
  .configureOutput({ writeOut: io.stdout, writeErr: io.stderr });
333
+ /*
334
+ * Local workspaces.
335
+ *
336
+ * Each is a separate database in its own home, which is what makes the
337
+ * isolation real: SQLite has no row-level security, so a shared file would
338
+ * rest on every query remembering to filter, with nothing to catch a miss.
339
+ * Separate files mean cross-workspace leakage is not something anybody can
340
+ * write by accident.
341
+ */
342
+ const workspaceCommand = program
343
+ .command('workspace')
344
+ .description('Work in a separate local store, isolated from the others');
345
+ workspaceCommand
346
+ .command('list')
347
+ .description('List the local workspaces on this machine')
348
+ .action((_options, command) => {
349
+ const global = globals(command);
350
+ // Read from disk, so nothing is listed that does not exist.
351
+ const workspaces = listLocalWorkspaces(global.home);
352
+ // Which one is in effect here, and what decided it — a flag, the
353
+ // environment, a project file, or nothing.
354
+ const selection = resolveWorkspaceSelection({ env });
355
+ const human = workspaces
356
+ .map((workspace) => `${workspace.name === DEFAULT_WORKSPACE ? '*' : ' '} ${workspace.name.padEnd(24)} ${workspace.initialized ? workspace.home : `${workspace.home} (not initialized)`}`)
357
+ .join('\n');
358
+ output(io, global.json, { workspaces, active: selection.workspace ?? DEFAULT_WORKSPACE, source: selection.source }, [
359
+ human,
360
+ '',
361
+ `Acting in: ${selection.workspace ?? DEFAULT_WORKSPACE} (from ${selection.source})`,
362
+ '',
363
+ 'Bind a directory with `synomem workspace use <name>`, or pass --workspace once.',
364
+ 'A local workspace is a separate store on this machine; a hosted workspace is shared,',
365
+ 'and is selected with `backend use remote --workspace`.',
366
+ ].join('\n'));
367
+ });
368
+ workspaceCommand
369
+ .command('use <name>')
370
+ .description('Bind this directory to a workspace, for every session opened here')
371
+ .option('--as <actor-id>', 'also always write as this agent', actingDefault)
372
+ .action((name, options, command) => {
373
+ const global = globals(command);
374
+ // Validated by resolving it, so a name that could never work is refused
375
+ // before a file claiming it is written.
376
+ const home = localWorkspaceHome(name, undefined);
377
+ const path = writeProjectSelection(process.cwd(), {
378
+ workspace: name,
379
+ ...(options.as ? { actor: options.as } : {}),
380
+ });
381
+ output(io, global.json, { path, workspace: name, home, ...(options.as ? { actor: options.as } : {}) }, [
382
+ `Wrote ${path}`,
383
+ '',
384
+ `Every Synomem command and MCP server started in this directory now acts in ${name}${options.as ? ` as ${options.as}` : ''}, with no flag.`,
385
+ `Records live in ${home} — nothing is stored in this directory.`,
386
+ '',
387
+ 'Commit it to share the choice with the repository, or ignore it to keep it yours.',
388
+ ].join('\n'));
389
+ });
390
+ workspaceCommand
391
+ .command('create <name>')
392
+ .description('Create a local workspace and initialize its store')
393
+ .action(async (name, _options, command) => {
394
+ const global = globals(command);
395
+ const home = localWorkspaceHome(name, global.home);
396
+ if (readSynomemConfig(home)) {
397
+ throw new SynomemError('INVALID_INPUT', `Workspace already exists: ${name}`);
398
+ }
399
+ writeSynomemBackend({ kind: 'local' }, home);
400
+ // Opening it once creates the database, so `list` does not report a
401
+ // workspace that exists in name only.
402
+ await withClient(home, defaultActor(env, 'system', 'cli'), async () => undefined);
403
+ output(io, global.json, { name, home }, `Created workspace ${name} at ${home}.\nAct in it with --workspace ${name}.`);
404
+ });
230
405
  const remoteCommand = program.command('remote').description('Administer a remote workspace');
231
406
  /*
232
407
  * The browser counterpart to an access key naming its own workspace.
@@ -449,7 +624,6 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
449
624
  .description('Configure Synomem without prompting')
450
625
  .option('--backend <kind>', 'local or remote')
451
626
  .option('--auth <method>', 'browser or access-key')
452
- .option('--workspace <id>', 'remote workspace ID')
453
627
  .option('--credential-store <where>', 'auto, keychain, file, or environment', 'auto')
454
628
  // The token is read from stdin, never taken as an argument: an argument is
455
629
  // kept by the shell history and visible in the process list.
@@ -463,7 +637,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
463
637
  const backend = options.backend;
464
638
  // An access key names its own workspace, so --workspace is only
465
639
  // required when there is no key to ask.
466
- if (backend === 'remote' && !options.workspace && !options.accessTokenStdin) {
640
+ if (backend === 'remote' && !global.workspace && !options.accessTokenStdin) {
467
641
  throw new SynomemError('INVALID_INPUT', 'Remote setup requires --workspace, or --access-token-stdin so the key can name its own.');
468
642
  }
469
643
  const token = options.accessTokenStdin ? await readAccessToken(promptIo) : undefined;
@@ -477,7 +651,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
477
651
  ? {
478
652
  serviceUrl: cloudApiUrl(env),
479
653
  auth: options.auth ?? 'access-key',
480
- workspaceId: options.workspace,
654
+ workspaceId: global.workspace,
481
655
  credentialStore: options.credentialStore,
482
656
  }
483
657
  : {}),
@@ -594,13 +768,12 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
594
768
  // never ask for a service address, because a person has no way to tell a
595
769
  // real one from a phished one.
596
770
  .option('--url <url>', 'internal: alternate HTTPS origin')
597
- .option('--workspace <id>', 'remote workspace ID')
598
771
  .action((kind, options, command) => {
599
772
  const global = globals(command);
600
773
  if (kind !== 'local' && kind !== 'remote') {
601
774
  throw new SynomemError('INVALID_INPUT', 'Backend kind must be local or remote.');
602
775
  }
603
- if (kind === 'remote' && !options.workspace) {
776
+ if (kind === 'remote' && !global.workspace) {
604
777
  throw new SynomemError('INVALID_INPUT', 'Remote backend selection requires --workspace.');
605
778
  }
606
779
  const config = writeSynomemBackend(kind === 'local'
@@ -608,7 +781,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
608
781
  : {
609
782
  kind: 'remote',
610
783
  baseUrl: options.url ?? cloudApiUrl(env),
611
- workspaceId: options.workspace,
784
+ workspaceId: global.workspace,
612
785
  }, global.home);
613
786
  output(io, global.json, { backend: config.backend }, `Selected ${config.backend.kind} Synomem backend.`);
614
787
  });
@@ -1066,7 +1239,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1066
1239
  postCommand
1067
1240
  .command('create')
1068
1241
  .description('Publish a post the whole workspace can read')
1069
- .requiredOption('--as <actor-id>')
1242
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1070
1243
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1071
1244
  .requiredOption('--title <title>')
1072
1245
  .requiredOption('--body <body>')
@@ -1085,7 +1258,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1085
1258
  postCommand
1086
1259
  .command('list')
1087
1260
  .description('List posts in this workspace')
1088
- .requiredOption('--as <actor-id>')
1261
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1089
1262
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1090
1263
  .option('--limit <n>', 'default 10, maximum 50')
1091
1264
  .action(async (options, command) => {
@@ -1099,7 +1272,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1099
1272
  postCommand
1100
1273
  .command('show <post-id>')
1101
1274
  .description('Show one post with its acknowledgements')
1102
- .requiredOption('--as <actor-id>')
1275
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1103
1276
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1104
1277
  .action(async (postId, options, command) => {
1105
1278
  const global = globals(command);
@@ -1114,7 +1287,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1114
1287
  postCommand
1115
1288
  .command('acknowledge <post-id>')
1116
1289
  .description('Say you have seen a post')
1117
- .requiredOption('--as <actor-id>')
1290
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1118
1291
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1119
1292
  .option('--note <text>', 'optional context for the author')
1120
1293
  .action(async (postId, options, command) => {
@@ -1128,7 +1301,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1128
1301
  postCommand
1129
1302
  .command('roster <post-id>')
1130
1303
  .description('Who has acknowledged a post, and who has not')
1131
- .requiredOption('--as <actor-id>')
1304
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1132
1305
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1133
1306
  .action(async (postId, options, command) => {
1134
1307
  const global = globals(command);
@@ -1153,7 +1326,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1153
1326
  postCommand
1154
1327
  .command('archive <post-id>')
1155
1328
  .description('Archive a post you wrote')
1156
- .requiredOption('--as <actor-id>')
1329
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1157
1330
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1158
1331
  .option('--reason <text>')
1159
1332
  .action(async (postId, options, command) => {
@@ -1165,7 +1338,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1165
1338
  kudosCommand
1166
1339
  .command('give <recipient>')
1167
1340
  .description('Give specific, evidence-based kudos to an agent')
1168
- .requiredOption('--from <actor-id>', 'stable ID of the giver')
1341
+ .requiredOption('--from <actor-id>', 'stable ID of the giver (defaults to --actor)', actingDefault)
1169
1342
  .requiredOption('--actor-kind <kind>', 'human, agent, or system')
1170
1343
  .option('--actor-name <display-name>')
1171
1344
  .requiredOption('--title <title>')
@@ -1258,7 +1431,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1258
1431
  const memoCommand = program.command('memo').description('Send and manage durable messages');
1259
1432
  memoCommand
1260
1433
  .command('send <recipient>')
1261
- .requiredOption('--from <actor-id>')
1434
+ .requiredOption('--from <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1262
1435
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1263
1436
  .option('--actor-name <name>')
1264
1437
  .requiredOption('--subject <subject>')
@@ -1319,7 +1492,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1319
1492
  .description('Retain and revise agent-owned knowledge');
1320
1493
  noteCommand
1321
1494
  .command('create')
1322
- .requiredOption('--as <actor-id>')
1495
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1323
1496
  .option('--actor-kind <kind>', 'agent or human', 'agent')
1324
1497
  .option('--owner <agent-id>')
1325
1498
  .requiredOption('--title <title>')
@@ -1358,7 +1531,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1358
1531
  });
1359
1532
  noteCommand
1360
1533
  .command('revise <note-id>')
1361
- .requiredOption('--as <actor-id>')
1534
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1362
1535
  .option('--actor-kind <kind>', 'agent or human', 'agent')
1363
1536
  .requiredOption('--expected-version <number>')
1364
1537
  .option('--title <title>')
@@ -1379,7 +1552,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1379
1552
  });
1380
1553
  noteCommand
1381
1554
  .command('archive <note-id>')
1382
- .requiredOption('--as <actor-id>')
1555
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1383
1556
  .option('--actor-kind <kind>', 'agent or human', 'agent')
1384
1557
  .option('--idempotency-key <key>')
1385
1558
  .action(async (id, options, command) => {
@@ -1395,7 +1568,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1395
1568
  .description('Create and manage your own private reminders');
1396
1569
  todoCommand
1397
1570
  .command('create')
1398
- .requiredOption('--as <actor-id>')
1571
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1399
1572
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1400
1573
  .requiredOption('--title <title>')
1401
1574
  .option('--details <text>', 'private working detail')
@@ -1419,7 +1592,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1419
1592
  });
1420
1593
  todoCommand
1421
1594
  .command('list')
1422
- .requiredOption('--as <actor-id>')
1595
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1423
1596
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1424
1597
  .option('--status <status>')
1425
1598
  .option('--limit <number>', 'maximum results', '10')
@@ -1437,7 +1610,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1437
1610
  });
1438
1611
  todoCommand
1439
1612
  .command('show <todo-id>')
1440
- .requiredOption('--as <actor-id>')
1613
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1441
1614
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1442
1615
  .action(async (id, options, command) => {
1443
1616
  const global = globals(command);
@@ -1447,7 +1620,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1447
1620
  for (const operation of ['complete', 'reopen', 'cancel', 'archive']) {
1448
1621
  todoCommand
1449
1622
  .command(`${operation} <todo-id>`)
1450
- .requiredOption('--as <actor-id>')
1623
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1451
1624
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1452
1625
  .option('--note <text>')
1453
1626
  .option('--reason <text>')
@@ -1485,7 +1658,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1485
1658
  const taskCommand = program.command('task').description('Create and manage agent tasks');
1486
1659
  taskCommand
1487
1660
  .command('create <assignee>')
1488
- .requiredOption('--from <actor-id>')
1661
+ .requiredOption('--from <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1489
1662
  .option('--actor-kind <kind>', 'human, agent, or system', 'agent')
1490
1663
  .requiredOption('--title <title>')
1491
1664
  .option('--description <text>')
@@ -1531,7 +1704,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1531
1704
  });
1532
1705
  taskCommand
1533
1706
  .command('update <task-id>')
1534
- .requiredOption('--as <actor-id>')
1707
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1535
1708
  .option('--actor-kind <kind>', 'agent or human', 'agent')
1536
1709
  .requiredOption('--expected-version <number>')
1537
1710
  .option('--title <title>')
@@ -1561,7 +1734,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1561
1734
  for (const operation of ['accept', 'reject', 'complete', 'reopen', 'cancel']) {
1562
1735
  const command_ = taskCommand
1563
1736
  .command(`${operation} <task-id>`)
1564
- .requiredOption('--as <actor-id>')
1737
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1565
1738
  .option('--actor-kind <kind>', 'agent or human', 'agent')
1566
1739
  .option('--note <text>')
1567
1740
  .option('--reason <text>')
@@ -1637,7 +1810,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1637
1810
  kudosCommand
1638
1811
  .command('revoke <kudos-id>')
1639
1812
  .description('Record a revocation while preserving history')
1640
- .requiredOption('--as <actor-id>')
1813
+ .requiredOption('--as <actor-id>', 'actor to act as (defaults to --actor)', actingDefault)
1641
1814
  .option('--actor-kind <kind>', 'human, agent, or system', 'human')
1642
1815
  .requiredOption('--reason <reason>')
1643
1816
  .option('--administrative', 'mark as an administrative revocation', false)
@@ -1779,7 +1952,7 @@ export function createCli(io = defaultIo, serviceFactory = configuredServiceFact
1779
1952
  return program;
1780
1953
  }
1781
1954
  export async function runCli(argv = process.argv, io = defaultIo, serviceFactory = configuredServiceFactory, dependencies = {}) {
1782
- const program = createCli(io, serviceFactory, dependencies);
1955
+ const program = createCli(io, serviceFactory, dependencies, argv);
1783
1956
  program.exitOverride();
1784
1957
  try {
1785
1958
  await program.parseAsync(argv);