synartesis 0.5.1 → 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.
- package/CHANGELOG.md +59 -0
- package/README.md +142 -594
- package/dist/cli.js +97 -18
- package/package.json +23 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
What changed, and why it mattered. Dates are release dates.
|
|
4
4
|
|
|
5
|
+
## 0.6.0 — 2026-09-12
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **A desktop window.** You talk to a model; every tool it calls goes through
|
|
10
|
+
the same proxy the CLI installs, so what it did is journalled and reversible
|
|
11
|
+
without the window implementing any of that itself. Each call gets a card
|
|
12
|
+
naming the server, the tool, the class it was given and whether the state it
|
|
13
|
+
replaced was captured; a call that cannot be undone stops and waits for a
|
|
14
|
+
person rather than happening behind one; and "put it back" is the CLI's own
|
|
15
|
+
two steps — the real plan, then the confirmation. Claude, Gemini, Mistral,
|
|
16
|
+
OpenAI, and anything speaking `/v1/chat/completions`, which includes Ollama,
|
|
17
|
+
LM Studio and vLLM on the same machine. Keys are pasted by you and kept in the
|
|
18
|
+
OS keychain; they are never written to the journal or a log.
|
|
19
|
+
|
|
20
|
+
It is a separate download rather than part of this package: a browser engine
|
|
21
|
+
inside a CLI would put 200 MB into every install of a command that is a few
|
|
22
|
+
hundred kilobytes. Both share one journal, so either can undo what the other
|
|
23
|
+
did.
|
|
24
|
+
|
|
25
|
+
- **`synartesis desktop`** opens that window if it is installed, and says where
|
|
26
|
+
to get it if it is not.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **Opening the window no longer leaves an empty session behind.** A run is
|
|
31
|
+
begun whether or not anybody says anything, and closing one is a chain of
|
|
32
|
+
waits — each server is asked to stop, and a child process takes its time
|
|
33
|
+
going. Quitting the application ended the process partway through, so the
|
|
34
|
+
empty row survived: thirteen of them in one afternoon of testing, thirteen
|
|
35
|
+
lines of `synartesis list` between somebody and the run they were looking for.
|
|
36
|
+
The tidy-up now happens before the servers are waited on — nothing can be
|
|
37
|
+
recorded once the model's client is shut, so the count is already final — and
|
|
38
|
+
quit is held until the engine has put itself away.
|
|
39
|
+
|
|
40
|
+
- **A held call explains itself in words.** An MCP server returns its errors as
|
|
41
|
+
a content envelope, and the most common reason of all — the file does not
|
|
42
|
+
exist yet, so there is nothing to keep — was reaching the screen as a wall of
|
|
43
|
+
`{"content":[{"type":"text"...` with the sentence that mattered inside it.
|
|
44
|
+
|
|
45
|
+
## 0.5.2 — 2026-09-10
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- **The README is a third of its former length** — 714 lines to 210. It had
|
|
50
|
+
grown a full walkthrough and a manifest-authoring guide, both of which already
|
|
51
|
+
exist in the user guide, so it was three documents pretending to be one and
|
|
52
|
+
the first screen was a long way from `npm install`. What is left is the
|
|
53
|
+
argument, the two real terminal shots, install, the four classes, the commands,
|
|
54
|
+
and the limits; the walkthrough and manifest authoring are one link away.
|
|
55
|
+
|
|
56
|
+
It also documents what has shipped since it was last written: `show --live`,
|
|
57
|
+
`show --full`, `undo --force [--yes]`, `undo --replan`, the `l` key and the
|
|
58
|
+
rest of the screen's keys, `SYNARTESIS_SYNC`, and which policies are actually
|
|
59
|
+
proven against a real server rather than merely checked for tool existence.
|
|
60
|
+
|
|
61
|
+
No code changed. npm cannot refresh a package page without a version, and this
|
|
62
|
+
also carries 0.5.1's latency fix to anyone installing from npm.
|
|
63
|
+
|
|
5
64
|
## 0.5.1 — 2026-09-10
|
|
6
65
|
|
|
7
66
|
### Changed
|
package/README.md
CHANGED
|
@@ -23,59 +23,23 @@ tells you `update_customer` ran forty times, not what the values were before.
|
|
|
23
23
|
|
|
24
24
|
## What it looks like
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
An agent overwrote a file and tried to move another. One command puts the
|
|
31
|
-
first back and reports that the second never happened:
|
|
26
|
+
Both shots are real output from [`./demo/filesystem-demo.sh`](demo/filesystem-demo.sh),
|
|
27
|
+
pasted rather than typeset. An agent overwrote a file and tried to move another.
|
|
28
|
+
One command puts the first back and reports that the second never happened:
|
|
32
29
|
|
|
33
30
|

|
|
34
31
|
|
|
35
|
-
`skip` is the interesting row. `move_file` is irreversible on that server, so
|
|
36
|
-
|
|
32
|
+
`skip` is the interesting row. `move_file` is irreversible on that server, so it
|
|
33
|
+
was never applied in the first place — there is nothing to undo.
|
|
37
34
|
|
|
38
35
|
Now the same damage, except a colleague edited the file before you got to the
|
|
39
36
|
undo. Writing the old contents back would destroy their work, so it does not:
|
|
40
37
|
|
|
41
38
|

|
|
42
39
|
|
|
43
|
-
It stops at the record that moved and exits non-zero. Anything
|
|
44
|
-
put back
|
|
45
|
-
|
|
46
|
-
## What it can and cannot do
|
|
47
|
-
|
|
48
|
-
Every tool gets one of four classifications, which you write down in a manifest:
|
|
49
|
-
|
|
50
|
-
| Class | Meaning | Example | What happens |
|
|
51
|
-
|---|---|---|---|
|
|
52
|
-
| `readonly` | Changes nothing | `get_customer` | Recorded, forwarded |
|
|
53
|
-
| `reversible` | Prior state can be restored exactly | `update_customer` | State captured before the write; written back on undo |
|
|
54
|
-
| `compensable` | Cannot be reversed, but can be offset | `create_charge` | A different call neutralises it |
|
|
55
|
-
| `irreversible` | Neither | `send_email` | **Suspended until a human approves it** |
|
|
56
|
-
|
|
57
|
-
A tool your manifest does not mention is treated as `irreversible`. That is
|
|
58
|
-
deliberate: silently forwarding an unknown destructive call is the one failure
|
|
59
|
-
worth avoiding most.
|
|
60
|
-
|
|
61
|
-
## Requirements
|
|
62
|
-
|
|
63
|
-
| Tool | Version | Check with |
|
|
64
|
-
|---|---|---|
|
|
65
|
-
| Node | 22 or newer | `node --version` |
|
|
66
|
-
| pnpm | 9 or newer | `pnpm --version` |
|
|
67
|
-
| A C toolchain | any | `cc --version` |
|
|
68
|
-
|
|
69
|
-
`pnpm` comes with Node via corepack:
|
|
70
|
-
|
|
71
|
-
```bash
|
|
72
|
-
corepack enable pnpm
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
The C toolchain is only needed if you build from source: npm ships a prebuilt
|
|
76
|
-
SQLite binding for common platforms and installs without one. To build from a
|
|
77
|
-
clone on macOS run `xcode-select --install`; on Debian or Ubuntu,
|
|
78
|
-
`apt install build-essential`.
|
|
40
|
+
It stops at the record that moved and exits non-zero. Anything already put back
|
|
41
|
+
stays put back, and it prints the three ways on: leave it, restore the resource
|
|
42
|
+
and `--replan`, or `--force` to overwrite deliberately.
|
|
79
43
|
|
|
80
44
|
## Install
|
|
81
45
|
|
|
@@ -89,612 +53,196 @@ Then, from anywhere:
|
|
|
89
53
|
synartesis install
|
|
90
54
|
```
|
|
91
55
|
|
|
92
|
-
That finds what Claude Code, Claude Desktop, Cursor or Codex already list,
|
|
93
|
-
policy covering all of it, and points each entry at the proxy.
|
|
94
|
-
recognises get the policy that ships for them and work immediately;
|
|
95
|
-
are drafted with every tool held until you say how to undo it. Your
|
|
96
|
-
copied aside first, `synartesis uninstall` puts it back, and
|
|
97
|
-
status` says what is covered.
|
|
98
|
-
|
|
99
|
-
Each server keeps its own entry and its own proxy, selected with `--server`,
|
|
100
|
-
so **no tool is renamed** — the agent sees exactly the names it saw before.
|
|
101
|
-
|
|
102
|
-
That is for you: the screen, `undo`, `watch`, `approve`. **Your agent needs
|
|
103
|
-
nothing installed** — the config block below fetches the proxy on demand.
|
|
104
|
-
|
|
105
|
-
Or the script, which builds from source and links the same two commands:
|
|
106
|
-
|
|
107
|
-
```bash
|
|
108
|
-
curl -fsSL https://raw.githubusercontent.com/ArhaanDev24/Synartesis/main/install.sh | bash
|
|
109
|
-
```
|
|
110
|
-
|
|
111
|
-
Or from a clone, if you would rather read it first:
|
|
112
|
-
|
|
113
|
-
```bash
|
|
114
|
-
git clone https://github.com/ArhaanDev24/Synartesis.git && cd Synartesis && ./install.sh
|
|
115
|
-
```
|
|
116
|
-
|
|
117
|
-
The script checks your Node version, builds, and links `synartesis` and
|
|
118
|
-
`synartesis-proxy` into the first writable directory already on your PATH. It
|
|
119
|
-
edits no shell profile and needs no sudo. Pass `--no-link` to build only.
|
|
120
|
-
|
|
121
|
-
```bash
|
|
122
|
-
synartesis --help
|
|
123
|
-
```
|
|
124
|
-
|
|
125
|
-
If nothing could be linked, nothing breaks: every command Synartesis prints
|
|
126
|
-
spells itself out in whichever form actually runs on your machine.
|
|
127
|
-
|
|
128
|
-
## Walkthrough
|
|
129
|
-
|
|
130
|
-
This uses a toy CRM that ships with the repo, so you can see the whole loop
|
|
131
|
-
without pointing anything at real data. Run it from a scratch directory.
|
|
132
|
-
|
|
133
|
-
```bash
|
|
134
|
-
mkdir -p /tmp/synartesis-demo && cd /tmp/synartesis-demo
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
### 1. Write a policy
|
|
138
|
-
|
|
139
|
-
`init` starts a server, asks it what tools it has, and writes a manifest.
|
|
140
|
-
Replace `SYNARTESIS` with the path you cloned into.
|
|
141
|
-
|
|
142
|
-
```bash
|
|
143
|
-
node SYNARTESIS/dist/cli.js init crm -- node SYNARTESIS/dist/toy-crm.js --state ./crm.json
|
|
144
|
-
```
|
|
56
|
+
That finds what Claude Code, Claude Desktop, Cursor or Codex already list,
|
|
57
|
+
writes one policy covering all of it, and points each entry at the proxy.
|
|
58
|
+
Servers it recognises get the policy that ships for them and work immediately;
|
|
59
|
+
the rest are drafted with every tool held until you say how to undo it. Your
|
|
60
|
+
config is copied aside first, `synartesis uninstall` puts it back, and
|
|
61
|
+
`synartesis status` says what is covered.
|
|
145
62
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
snapshot or an inverse the server does not have, the whole policy is dropped
|
|
150
|
-
and you get the TODOs instead: a policy whose inverses cannot be called is
|
|
151
|
-
worse than none, because it looks done.
|
|
63
|
+
Each server keeps its own entry and its own proxy, so **no tool is renamed** —
|
|
64
|
+
the agent sees exactly the names it saw before. **Your agent needs nothing
|
|
65
|
+
installed.**
|
|
152
66
|
|
|
153
|
-
|
|
154
|
-
|
|
67
|
+
Needs Node 22 or newer. npm ships a prebuilt SQLite binding, so no toolchain is
|
|
68
|
+
required unless you build from a clone.
|
|
155
69
|
|
|
156
|
-
|
|
157
|
-
directory you are standing in, that is `~/.synartesis/synartesis.yaml`. Open it:
|
|
158
|
-
every tool that isn't a self-declared read starts as `irreversible` with a
|
|
159
|
-
`TODO`. **Working through those TODOs is the job.** A finished policy for this
|
|
160
|
-
fixture ships in the repo, so copy it into this directory rather than typing it
|
|
161
|
-
out — a policy here takes precedence over the one in your home:
|
|
70
|
+
Then just:
|
|
162
71
|
|
|
163
72
|
```bash
|
|
164
|
-
|
|
73
|
+
synartesis
|
|
165
74
|
```
|
|
166
75
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
the path to your clone and the `--state` file:
|
|
76
|
+
One screen: what agents have done, what is held for approval, every AI on the
|
|
77
|
+
machine, and undo — all on the arrow keys.
|
|
170
78
|
|
|
171
|
-
|
|
172
|
-
servers:
|
|
173
|
-
crm:
|
|
174
|
-
command: node
|
|
175
|
-
args: ["SYNARTESIS/dist/toy-crm.js", "--state", "./crm.json"]
|
|
176
|
-
```
|
|
79
|
+
## The desktop window
|
|
177
80
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
want covered with the proxy. For Claude Desktop or Claude Code that is a
|
|
182
|
-
`mcpServers` block:
|
|
183
|
-
|
|
184
|
-
```json
|
|
185
|
-
{
|
|
186
|
-
"mcpServers": {
|
|
187
|
-
"crm": {
|
|
188
|
-
"command": "node",
|
|
189
|
-
"args": ["SYNARTESIS/dist/proxy.js", "--manifest", "/tmp/synartesis-demo/synartesis.yaml"]
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
```
|
|
81
|
+
The same engine, with a conversation in front of it. You talk to a model — any
|
|
82
|
+
model — and every tool it calls goes through the proxy on its way out, so the
|
|
83
|
+
undo is not a feature the window implements. It is one it can already offer.
|
|
194
84
|
|
|
195
|
-
|
|
196
|
-
there is nothing to install first and nothing to point at:
|
|
197
|
-
|
|
198
|
-
```json
|
|
199
|
-
{
|
|
200
|
-
"mcpServers": {
|
|
201
|
-
"synartesis": {
|
|
202
|
-
"command": "npx",
|
|
203
|
-
"args": ["-y", "synartesis", "proxy", "--manifest", "/Users/you/.synartesis/synartesis.yaml"]
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
```
|
|
85
|
+

|
|
208
86
|
|
|
209
|
-
|
|
210
|
-
the
|
|
87
|
+
Every call gets a card: which server, which tool, the class Synartesis gave it,
|
|
88
|
+
and whether the state it replaced was captured. The ledger at the top counts the
|
|
89
|
+
same thing for the whole conversation. Nothing there is a promise about what
|
|
90
|
+
should have happened — it is read back out of the journal after the fact.
|
|
211
91
|
|
|
212
|
-
|
|
92
|
+
A call that cannot be undone does not happen behind your back. It stops, and
|
|
93
|
+
waits for you, with the reason it cannot be reversed written out:
|
|
213
94
|
|
|
214
|
-
|
|
215
|
-
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"demo-agent","version":"0"}}}' '{"jsonrpc":"2.0","method":"notifications/initialized"}' '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"update_customer","arguments":{"id":"c_001","plan":"free","notes":"wrong edit"}}}' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"delete_customer","arguments":{"id":"c_002"}}}' | node SYNARTESIS/dist/proxy.js --manifest ./synartesis.yaml --journal ./journal.db > /dev/null
|
|
216
|
-
```
|
|
95
|
+

|
|
217
96
|
|
|
218
|
-
|
|
97
|
+
And putting it back is the same two steps the CLI takes: the real plan first,
|
|
98
|
+
built from the journal, then the confirmation.
|
|
219
99
|
|
|
220
|
-
|
|
221
|
-
cat crm.json
|
|
222
|
-
```
|
|
223
|
-
|
|
224
|
-
Ada is on the wrong plan with the wrong notes, and Grace is gone.
|
|
225
|
-
|
|
226
|
-
### 3. See what it did
|
|
227
|
-
|
|
228
|
-
```bash
|
|
229
|
-
node SYNARTESIS/dist/cli.js list --journal ./journal.db
|
|
230
|
-
```
|
|
231
|
-
|
|
232
|
-
```bash
|
|
233
|
-
node SYNARTESIS/dist/cli.js show RUN_ID --journal ./journal.db
|
|
234
|
-
```
|
|
100
|
+

|
|
235
101
|
|
|
236
|
-
|
|
237
|
-
|
|
102
|
+
It talks to Claude, Gemini, Mistral, OpenAI, or anything speaking
|
|
103
|
+
`/v1/chat/completions` — including Ollama, LM Studio and vLLM on your own
|
|
104
|
+
machine, which cost nothing and send nothing anywhere. Keys are pasted by you,
|
|
105
|
+
kept in the OS keychain through Electron's `safeStorage`, and never written to
|
|
106
|
+
the journal or a log. There is a parchment and a dark setting:
|
|
238
107
|
|
|
239
|
-
|
|
108
|
+

|
|
240
109
|
|
|
241
|
-
|
|
110
|
+
**Getting it.** `synartesis desktop` opens it, and says where to get it if it is
|
|
111
|
+
not installed. It is a separate download on purpose: shipping a browser engine
|
|
112
|
+
inside a CLI would put 200 MB into every install of a command that is a few
|
|
113
|
+
hundred kilobytes. Until there is a signed build to download, it is built from a
|
|
114
|
+
clone:
|
|
242
115
|
|
|
243
116
|
```bash
|
|
244
|
-
|
|
117
|
+
pnpm install && pnpm app:dist
|
|
245
118
|
```
|
|
246
119
|
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
cat crm.json
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
Grace is back and Ada is on her original plan, with her original notes.
|
|
258
|
-
|
|
259
|
-
### 5. Watch it refuse
|
|
260
|
-
|
|
261
|
-
Undo is not a blunt instrument. If something else changed a record after the
|
|
262
|
-
agent touched it, writing the old value back would destroy that work, so
|
|
263
|
-
Synartesis stops and shows you both values.
|
|
264
|
-
|
|
265
|
-
Run the damage command from step 2 again. That creates a second run, so take
|
|
266
|
-
the run id from the top of `list`, which is ordered most recent first. Then
|
|
267
|
-
edit the record by hand:
|
|
268
|
-
|
|
269
|
-
```bash
|
|
270
|
-
node -e 'const f="./crm.json",s=JSON.parse(require("fs").readFileSync(f));s.customers.c_001.notes="a human wrote this";require("fs").writeFileSync(f,JSON.stringify(s,null,2))'
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
```bash
|
|
274
|
-
node SYNARTESIS/dist/cli.js undo RUN_ID --journal ./journal.db
|
|
275
|
-
```
|
|
276
|
-
|
|
277
|
-
It halts at the record that moved, prints the expected and actual state, and
|
|
278
|
-
exits non-zero. It is not all-or-nothing: undo works newest first, so anything
|
|
279
|
-
it had already put back before reaching the drifted record stays put back —
|
|
280
|
-
here `delete_customer` is reversed and `c_002` comes back, and then it stops.
|
|
281
|
-
`show` afterwards tells you which actions are still outstanding. Once you have
|
|
282
|
-
resolved the conflict yourself, `undo --replan` rebuilds the plan against the
|
|
283
|
-
world as it now is and carries on.
|
|
284
|
-
|
|
285
|
-
## Approving what cannot be undone
|
|
286
|
-
|
|
287
|
-
`send_email` is classified `irreversible`, so the agent cannot send one on its
|
|
288
|
-
own. The call is **refused immediately** with an action id and the command that
|
|
289
|
-
would approve it. The agent tells you, you decide, and it tries again.
|
|
290
|
-
|
|
291
|
-
It does not hold the call open while waiting. That was the first design and it
|
|
292
|
-
does not survive contact with a real client: every useful window for a person
|
|
293
|
-
to notice, open a terminal and decide is longer than a client will wait for a
|
|
294
|
-
tool, so the two cannot be reconciled by picking a better timeout.
|
|
120
|
+
That writes an installer for the machine it runs on to `app/release` — a `.dmg`
|
|
121
|
+
and a `.app` on macOS, an `.exe` on Windows, an AppImage and a `.deb` on Linux.
|
|
122
|
+
It is unsigned, so it runs where it was built and Gatekeeper refuses it
|
|
123
|
+
anywhere it has been downloaded to: signing and notarisation need an Apple
|
|
124
|
+
developer account, and [`app/README.md`](app/README.md) lists the two
|
|
125
|
+
environment variables that turn them on. Both the window and the terminal share
|
|
126
|
+
one journal, so either can undo what the other did.
|
|
295
127
|
|
|
296
|
-
|
|
297
|
-
talks MCP over stdin and stdout, so there is nothing there to prompt on, and a
|
|
298
|
-
desktop client has no terminal at all. The request goes to the journal, and you
|
|
299
|
-
answer it from anywhere:
|
|
300
|
-
|
|
301
|
-
```bash
|
|
302
|
-
node SYNARTESIS/dist/cli.js gates --journal ./journal.db
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
```bash
|
|
306
|
-
node SYNARTESIS/dist/cli.js approve ACTION_ID --by your-name --journal ./journal.db
|
|
307
|
-
```
|
|
308
|
-
|
|
309
|
-
```bash
|
|
310
|
-
node SYNARTESIS/dist/cli.js deny ACTION_ID --by your-name --reason "not this one" --journal ./journal.db
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
An approval is **single use** and expires after an hour, so it covers the retry
|
|
314
|
-
it was granted for and cannot quietly authorise the same call tomorrow. It is
|
|
315
|
-
not tied to one session, because people restart their client and an approval
|
|
316
|
-
stranded in a dead session would be no approval at all.
|
|
128
|
+
## What it can and cannot do
|
|
317
129
|
|
|
318
|
-
|
|
319
|
-
unanswered, visible in `synartesis gates` until someone decides.
|
|
130
|
+
Every tool gets one of four classifications, written down in a manifest:
|
|
320
131
|
|
|
321
|
-
|
|
322
|
-
|
|
132
|
+
| Class | Meaning | Example | What happens |
|
|
133
|
+
|---|---|---|---|
|
|
134
|
+
| `readonly` | Changes nothing | `get_customer` | Recorded, forwarded |
|
|
135
|
+
| `reversible` | Prior state can be restored exactly | `update_customer` | State captured before the write; written back on undo |
|
|
136
|
+
| `compensable` | Cannot be reversed, but can be offset | `create_charge` | A different call neutralises it |
|
|
137
|
+
| `irreversible` | Neither | `send_email` | **Suspended until a human approves it** |
|
|
323
138
|
|
|
324
|
-
|
|
139
|
+
A tool your manifest does not mention is treated as `irreversible`. That is
|
|
140
|
+
deliberate: silently forwarding an unknown destructive call is the one failure
|
|
141
|
+
worth avoiding most.
|
|
325
142
|
|
|
326
|
-
|
|
327
|
-
protocol, so its subject is whatever the servers you have connected can do:
|
|
328
|
-
your files, your repositories, your database, your tickets, your agent's own
|
|
329
|
-
memory. What it can undo depends entirely on what those servers expose, and
|
|
330
|
-
each manifest below says plainly where that runs out.
|
|
143
|
+
## Has anybody touched it since?
|
|
331
144
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
| [`git.yaml`](manifests/git.yaml) | `mcp-server-git` | a real repository's index and history |
|
|
337
|
-
| [`github.yaml`](manifests/github.yaml) | `github/github-mcp-server` | issues, pull requests, file contents |
|
|
338
|
-
| [`toy-crm.yaml`](manifests/toy-crm.yaml) | the fixture in this repo, not in the published package | the worked example of every class |
|
|
145
|
+
Synartesis records what an agent does, not what happens to a file. Nothing you
|
|
146
|
+
do by hand goes through the proxy — which is exactly what makes the drift check
|
|
147
|
+
work: when undo reads a file and finds bytes it never recorded, it knows
|
|
148
|
+
somebody else has been there.
|
|
339
149
|
|
|
340
|
-
|
|
341
|
-
running. Two demos run the whole loop for real:
|
|
150
|
+
To ask before you find out the hard way:
|
|
342
151
|
|
|
343
152
|
```bash
|
|
344
|
-
|
|
345
|
-
./demo/memory-demo.sh
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
The filesystem demo overwrites a file and moves another, restores both, then
|
|
349
|
-
shows undo refusing when a human edited the file in between, and the gate
|
|
350
|
-
refusing to create a directory this server has no way to remove.
|
|
351
|
-
|
|
352
|
-
The memory demo is the sharper one. The agent adds two people to the graph, one
|
|
353
|
-
of whom was already there, and the server quietly ignores the duplicate. Undo
|
|
354
|
-
therefore has to remove exactly one of them: the inverse is built from what the
|
|
355
|
-
server said it created, not from what the agent asked for, so the person who
|
|
356
|
-
was there first survives being undone. The same session then tries to delete an
|
|
357
|
-
entity and is held, because deleting an entity also deletes every relation
|
|
358
|
-
touching it and one inverse call cannot put back both.
|
|
359
|
-
|
|
360
|
-
### Where each one runs out
|
|
361
|
-
|
|
362
|
-
The limits are the interesting part, and they are properties of the servers
|
|
363
|
-
rather than of Synartesis.
|
|
364
|
-
|
|
365
|
-
- **filesystem**: `move_file` is reversible from its arguments alone, so no
|
|
366
|
-
pre-read is declared and drift cannot be checked for it. `create_directory`
|
|
367
|
-
is `irreversible` not because directories are precious but because this
|
|
368
|
-
server exposes no way to remove one.
|
|
369
|
-
- **memory**: `add_observations` and `delete_observations` are exact opposites
|
|
370
|
-
that disagree about what to call the same field. A path can read a field and
|
|
371
|
-
cannot rename one, so that inverse cannot be written at all and the call is
|
|
372
|
-
gated instead.
|
|
373
|
-
- **git**: nearly every read this server offers answers in prose meant for a
|
|
374
|
-
person, so almost nothing can be inverted from a captured state however
|
|
375
|
-
reversible the underlying git operation is. Commits are gated because this
|
|
376
|
-
server exposes no reset, no revert, and no way to move a branch.
|
|
377
|
-
|
|
378
|
-
Two things worth knowing if you write your own, both found by running these
|
|
379
|
-
against live servers rather than by reading documentation:
|
|
380
|
-
|
|
381
|
-
`$result` is the structured block, and it need not match the text one. The
|
|
382
|
-
memory server answers `create_entities` with a bare list in its text block and
|
|
383
|
-
`{"entities": [...]}` in `structuredContent`. Synartesis walks the structured
|
|
384
|
-
one, because that is the machine-readable contract.
|
|
385
|
-
|
|
386
|
-
And `synartesis check` proves a tool exists, not that a path resolves. It
|
|
387
|
-
cannot: no call has been made, so there is no result to walk. Run the thing
|
|
388
|
-
once and read `synartesis show` before you rely on an inverse.
|
|
389
|
-
|
|
390
|
-
## Writing a manifest
|
|
391
|
-
|
|
392
|
-
The manifest is the whole product. It should take fifteen minutes for an API
|
|
393
|
-
you know.
|
|
394
|
-
|
|
395
|
-
```yaml
|
|
396
|
-
version: 1
|
|
397
|
-
|
|
398
|
-
servers:
|
|
399
|
-
crm:
|
|
400
|
-
command: node
|
|
401
|
-
args: ["./crm-server.js"]
|
|
402
|
-
|
|
403
|
-
tools:
|
|
404
|
-
- match: "crm.get_customer"
|
|
405
|
-
class: readonly
|
|
406
|
-
|
|
407
|
-
# Read the record before overwriting it, then write that record back.
|
|
408
|
-
- match: "crm.update_customer"
|
|
409
|
-
class: reversible
|
|
410
|
-
snapshot:
|
|
411
|
-
tool: "crm.get_customer"
|
|
412
|
-
args:
|
|
413
|
-
id: "$.id"
|
|
414
|
-
inverse:
|
|
415
|
-
tool: "crm.update_customer"
|
|
416
|
-
args:
|
|
417
|
-
id: "$.id"
|
|
418
|
-
name: "$snapshot.name"
|
|
419
|
-
plan: "$snapshot.plan"
|
|
420
|
-
|
|
421
|
-
# Nothing to read beforehand; the id only exists once the call returns.
|
|
422
|
-
- match: "crm.create_customer"
|
|
423
|
-
class: compensable
|
|
424
|
-
inverse:
|
|
425
|
-
tool: "crm.delete_customer"
|
|
426
|
-
args:
|
|
427
|
-
id: "$result.id"
|
|
428
|
-
|
|
429
|
-
- match: "crm.send_*"
|
|
430
|
-
class: irreversible
|
|
431
|
-
gate: always
|
|
432
|
-
```
|
|
433
|
-
|
|
434
|
-
There are exactly three things a value can refer to:
|
|
435
|
-
|
|
436
|
-
| Prefix | Refers to | Available in |
|
|
437
|
-
|---|---|---|
|
|
438
|
-
| `$.` | the arguments the agent sent | `snapshot` and `inverse` |
|
|
439
|
-
| `$snapshot.` | what the pre-read captured | `inverse` |
|
|
440
|
-
| `$result.` | what the forward call returned | `inverse` |
|
|
441
|
-
|
|
442
|
-
Anything else is a literal. A reference can stand alone, in which case the
|
|
443
|
-
value keeps its type, or sit inside a sentence, in which case it is substituted
|
|
444
|
-
as text:
|
|
445
|
-
|
|
446
|
-
```yaml
|
|
447
|
-
sha: "$result.content.sha" # the value itself
|
|
448
|
-
message: "Revert agent change to $.path" # text with the path substituted
|
|
449
|
-
```
|
|
450
|
-
|
|
451
|
-
Write `$$` for a literal dollar sign. There are no expressions, conditionals or
|
|
452
|
-
functions, and there will not be: the moment this becomes a language it stops
|
|
453
|
-
being something you can write in fifteen minutes.
|
|
454
|
-
|
|
455
|
-
Paths can index a list with `[0]` and read one field from every element with
|
|
456
|
-
`[]`:
|
|
457
|
-
|
|
458
|
-
```yaml
|
|
459
|
-
labels: "$snapshot.labels[].name" # [{name: "bug"}, ...] becomes ["bug", ...]
|
|
460
|
-
```
|
|
461
|
-
|
|
462
|
-
That covers the common case where an API hands a field back richer than it
|
|
463
|
-
takes it, which is what GitHub does with issue labels. `[]` reads the same key
|
|
464
|
-
from each element and nothing else: it is still a path, not a transform. A
|
|
465
|
-
reference copies values, it cannot compute them, so an API needing a genuinely
|
|
466
|
-
different shape is one the inverse should leave that field out of, and say so.
|
|
467
|
-
|
|
468
|
-
Other things to know:
|
|
469
|
-
|
|
470
|
-
- `match` supports `*`, which matches within one segment: `crm.send_*` matches
|
|
471
|
-
`crm.send_email` but not `crm.a.b`. The most specific pattern wins regardless
|
|
472
|
-
of the order rules are written in.
|
|
473
|
-
- The inverse of a patch should restore **every** field, not re-apply a patch.
|
|
474
|
-
If the same record is edited twice in one run, a partial inverse leaves the
|
|
475
|
-
fields the second edit touched behind.
|
|
476
|
-
- `gate: on_write` is a heuristic for tools like a raw SQL runner, where
|
|
477
|
-
destructiveness cannot be read from the tool name. Anything it cannot
|
|
478
|
-
confidently read as a single read statement is gated. Use `gate: always`
|
|
479
|
-
wherever certainty matters.
|
|
480
|
-
- A malformed manifest stops the proxy from starting, with the file and line to
|
|
481
|
-
fix. It will never run with a policy it could not understand.
|
|
482
|
-
|
|
483
|
-
### Saying what absence looks like
|
|
484
|
-
|
|
485
|
-
A pre-read that fails tells you nothing about *why*. MCP has no code for "not
|
|
486
|
-
found", so a snapshot that comes back with an error could mean the resource was
|
|
487
|
-
never there — an agent creating a file — or that it exists and could not be
|
|
488
|
-
read. Those need opposite answers: the first is a call to ask a person about,
|
|
489
|
-
the second is a call to refuse outright.
|
|
490
|
-
|
|
491
|
-
Tell the policy what its server says when a thing is not there:
|
|
492
|
-
|
|
493
|
-
```yaml
|
|
494
|
-
- match: "fs.write_file"
|
|
495
|
-
class: reversible
|
|
496
|
-
snapshot:
|
|
497
|
-
tool: "fs.read_text_file"
|
|
498
|
-
args:
|
|
499
|
-
path: "$.path"
|
|
500
|
-
absent_when: ["ENOENT", "no such file"]
|
|
153
|
+
synartesis show <session> --live
|
|
501
154
|
```
|
|
502
155
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
156
|
+
It reads every resource the session touched as it is now and says which still
|
|
157
|
+
match. Nothing is written, no reversing call is sent, and unlike
|
|
158
|
+
`undo --dry-run` it does not stop at the first conflict — five writes get five
|
|
159
|
+
answers. `l` in the screen does the same.
|
|
507
160
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
Past a few megabytes a resource cannot be read back through a stdio MCP
|
|
511
|
-
connection — the reply is too large to carry, and the connection closes.
|
|
512
|
-
Synartesis reconnects and says so, and refuses the write rather than applying a
|
|
513
|
-
change it could not capture. That is the right answer, but it does mean
|
|
514
|
-
**writes to very large files are refused, not undone**. Nothing is lost; the
|
|
515
|
-
call simply does not go through.
|
|
516
|
-
|
|
517
|
-
Where the ceiling falls depends on the server and its transport, so no exact
|
|
518
|
-
figure here would be true of every one. Measured against the bundled
|
|
519
|
-
filesystem policy: a 4 MB file was captured and restored intact; 8 MB and above
|
|
520
|
-
was refused with the file untouched.
|
|
161
|
+
If you decide the recorded value is the one worth keeping, `undo --force` prints
|
|
162
|
+
every line it would write over and stops; `--force --yes` goes ahead.
|
|
521
163
|
|
|
522
164
|
## Commands
|
|
523
165
|
|
|
166
|
+
`synartesis desktop` opens [the window](#the-desktop-window), and says where to
|
|
167
|
+
get it if it is not installed.
|
|
168
|
+
|
|
524
169
|
| Command | Does |
|
|
525
170
|
|---|---|
|
|
171
|
+
| `synartesis` | The screen. Everything below can be done from it |
|
|
172
|
+
| `install` / `uninstall` / `status` | Cover the clients on this machine, put them back, say what is covered |
|
|
526
173
|
| `init <server> -- <cmd>` | Introspect a server and draft a manifest |
|
|
527
|
-
| `list` | Every recorded run |
|
|
528
|
-
| `show <runId>` | One run's timeline, with the undo for each step |
|
|
529
|
-
| `show <runId> --live` | The same, plus what has changed in the world since |
|
|
530
|
-
| `gates` | What is waiting for a decision |
|
|
531
|
-
| `approve <actionId>` | Allow a suspended call |
|
|
532
|
-
| `deny <actionId>` | Refuse one |
|
|
533
|
-
| `undo <runId>` | Reverse a run, newest action first |
|
|
534
|
-
| `undo <runId> --replan` | Same, but rebuild each undo from the current manifest |
|
|
535
|
-
| `undo <runId> --force` | Print every change it would write over; `--yes` goes ahead |
|
|
536
174
|
| `check` | Load a manifest and verify it against the servers it names |
|
|
537
|
-
| `
|
|
538
|
-
| `
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
`
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
not: they take a remote HTTPS endpoint and will not run a process on your
|
|
561
|
-
machine. For those, serve it:
|
|
562
|
-
|
|
563
|
-
```bash
|
|
564
|
-
synartesis proxy --manifest ~/.synartesis/synartesis.yaml --http 9123 --token "$SYNARTESIS_TOKEN"
|
|
565
|
-
```
|
|
566
|
-
|
|
567
|
-
The endpoint is `/mcp` and every request needs `Authorization: Bearer <token>`.
|
|
568
|
-
A token of at least 16 characters is **required** — it refuses to start without
|
|
569
|
-
one — and it binds to `127.0.0.1` unless `--http-host` says otherwise, which
|
|
570
|
-
warns when it is not loopback. A session that goes quiet for half an hour is
|
|
571
|
-
closed; `--http-idle <seconds>` changes that.
|
|
572
|
-
|
|
573
|
-
Reaching it from the internet means putting a tunnel in front of it. That is
|
|
574
|
-
deliberately your decision and not a flag: what is on the other end can write
|
|
575
|
-
through every server in your policy, so treat the token like a password and
|
|
576
|
-
prefer a tunnel that does its own authentication.
|
|
577
|
-
|
|
578
|
-
The proxy takes `--manifest`, `--journal` and `--log-level`. It also still
|
|
579
|
-
accepts `--gate-timeout <seconds>`, which does nothing: a held call is refused
|
|
580
|
-
immediately rather than holding the connection open, so there is no wait to cut
|
|
581
|
-
short. Passing it logs a warning saying as much. It logs structured JSON to stderr; stdout is reserved for
|
|
582
|
-
protocol traffic.
|
|
175
|
+
| `list` | Every recorded session |
|
|
176
|
+
| `show <id>` | One session's timeline, with the undo for each step |
|
|
177
|
+
| `show <id> --live` | The same, plus what has changed in the world since |
|
|
178
|
+
| `show <id> --full` | Every argument, snapshot and inverse, nothing elided |
|
|
179
|
+
| `gates` / `approve <id>` / `deny <id>` | What is waiting, and answering it |
|
|
180
|
+
| `undo <id>` | Reverse a session, newest action first |
|
|
181
|
+
| `undo <id> --dry-run` | Plan it and change nothing |
|
|
182
|
+
| `undo <id> --replan` | Rebuild each undo from the current manifest |
|
|
183
|
+
| `undo <id> --force [--yes]` | Print what it would write over; `--yes` goes ahead |
|
|
184
|
+
| `watch` | Live activity, with approvals answerable in place |
|
|
185
|
+
| `prune` | Delete sessions older than 30 days and reclaim the space |
|
|
186
|
+
| `close [id]` | End a session a killed proxy left open |
|
|
187
|
+
|
|
188
|
+
In the screen: `enter` opens, `u` undoes, `p` previews, `l` checks the world
|
|
189
|
+
now, `f` expands, `c` shows every AI on the machine, `g` shows what is held.
|
|
190
|
+
|
|
191
|
+
`--manifest` and `--journal` are found rather than typed, from the current
|
|
192
|
+
directory upwards the way a version control tool finds its root, then from
|
|
193
|
+
`~/.synartesis`. `SYNARTESIS_HOME` moves that. `--json` works on `list`, `show`
|
|
194
|
+
and `gates`. Exit codes: `0` succeeded, `1` halted or refused, `2` bad usage.
|
|
195
|
+
|
|
196
|
+
**Full walkthrough, writing a manifest, and serving over HTTP for clients that
|
|
197
|
+
cannot start a process:** see the [user guide](docs/synartesis-user-guide.md).
|
|
583
198
|
|
|
584
199
|
## What it does not do
|
|
585
200
|
|
|
586
201
|
- **It cannot un-send what has been seen.** An email that has been read, a
|
|
587
202
|
posted message, a file deleted with no backup. This is why the gate exists.
|
|
588
203
|
- **Compensable actions cannot be checked for drift.** They declare no pre-read,
|
|
589
|
-
so undo compensates them and marks them `[unverified]
|
|
204
|
+
so undo compensates them and marks them `[unverified]`.
|
|
590
205
|
- **Undo halts on uncertainty, and steps over the merely permanent.** Drift, an
|
|
591
|
-
unknown outcome, or a failed reversing call stop it
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
206
|
+
unknown outcome, or a failed reversing call stop it. An action that simply
|
|
207
|
+
cannot be undone is reported and left in place while everything else is
|
|
208
|
+
reverted. Either way the session is marked `partial`.
|
|
209
|
+
- **An error is not proof that nothing happened.** A timeout or a tool-level
|
|
210
|
+
error after a write leaves the outcome *unknown*, not failed, and undo will
|
|
211
|
+
not step past it. Where a pre-read exists it is consulted to settle the
|
|
212
|
+
question instead of guessing.
|
|
598
213
|
- **An undo is only as good as the policy that recorded it.** Inverses are
|
|
599
|
-
resolved when the call happens,
|
|
600
|
-
|
|
601
|
-
corrected manifest using the state already captured, which is the way out.
|
|
602
|
-
|
|
603
|
-
## Has anybody touched it since?
|
|
604
|
-
|
|
605
|
-
Synartesis records what an agent does, not what happens to a file. Nothing you
|
|
606
|
-
do by hand goes through the proxy, so an edit of your own is invisible to the
|
|
607
|
-
journal — and that is what makes the drift check work: when undo reads a file
|
|
608
|
-
and finds bytes it never recorded, it knows somebody else has been there.
|
|
609
|
-
|
|
610
|
-
The cost used to be that you found out by walking into it. You ran the undo,
|
|
611
|
-
and it refused. To ask first:
|
|
612
|
-
|
|
613
|
-
```bash
|
|
614
|
-
synartesis show <session> --live
|
|
615
|
-
```
|
|
616
|
-
|
|
617
|
-
It reads every resource the session touched, as it is now, and says which of
|
|
618
|
-
them still match what the run left:
|
|
619
|
-
|
|
620
|
-
```
|
|
621
|
-
1 ← reversible applied filesystem.write_file changed since
|
|
622
|
-
at line 13:
|
|
623
|
-
+ A HUMAN WAS HERE
|
|
624
|
-
0 removed, 1 added.
|
|
625
|
-
|
|
626
|
-
1 changed since this ran; undoing would write over it
|
|
627
|
-
```
|
|
628
|
-
|
|
629
|
-
Nothing is written, no reversing call is sent, and no row changes status.
|
|
630
|
-
Unlike `undo --dry-run`, it does not stop at the first conflict — a session
|
|
631
|
-
with five writes reports on all five. `l` in the screen does the same thing.
|
|
632
|
-
|
|
633
|
-
If you decide the recorded value is the one worth keeping, `undo --force`
|
|
634
|
-
prints every line it would write over and stops; `--force --yes` goes ahead.
|
|
635
|
-
|
|
636
|
-
## Watching it work
|
|
637
|
-
|
|
638
|
-
Synartesis is not a daemon and cannot be one. An MCP client spawns a stdio
|
|
639
|
-
server itself and owns its lifetime, so nothing long-running could sit in
|
|
640
|
-
between and see those calls. What a person wants from a daemon is usually the
|
|
641
|
-
reassurance that it is there and doing something, and that needs somewhere to
|
|
642
|
-
look rather than a background process:
|
|
643
|
-
|
|
644
|
-
```bash
|
|
645
|
-
synartesis watch
|
|
646
|
-
```
|
|
214
|
+
resolved when the call happens, so a mistake in a manifest is baked into every
|
|
215
|
+
run made under it. `undo --replan` rebuilds them from a corrected one.
|
|
647
216
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
217
|
+
The bundled **filesystem** policy is tested against the real server: exact
|
|
218
|
+
byte-for-byte restoration, drift refusal, and absence told apart from a read
|
|
219
|
+
that failed. The **memory, git and github** policies are checked only for tool
|
|
220
|
+
existence — their recovery guarantees are not yet proven.
|
|
652
221
|
|
|
653
222
|
## Trust
|
|
654
223
|
|
|
655
224
|
A manifest names commands and Synartesis runs them. Treat one you did not write
|
|
656
225
|
the way you would treat a shell script from the same source: read it first.
|
|
657
|
-
There is no sandbox here, and there is not meant to be.
|
|
658
|
-
|
|
659
|
-
### What the journal holds
|
|
660
|
-
|
|
661
|
-
Putting a file back means having kept what was in it, so the journal is not a
|
|
662
|
-
log of what happened. It is a copy of the data: the contents of every resource
|
|
663
|
-
before it was written, the arguments of every call, and what each server sent
|
|
664
|
-
back. A key that was sitting in a file your agent touched is in there in plain
|
|
665
|
-
text. That is not a leak to be closed — it is the thing that makes undo work.
|
|
666
|
-
|
|
667
|
-
So it is treated as private data. The journal is created `0600` and the
|
|
668
|
-
directory Synartesis makes for it `0700`, and `0600` is re-applied every time a
|
|
669
|
-
journal is opened, which closes one written by an older version the first time
|
|
670
|
-
a newer one touches it. A directory that already existed is left alone, because
|
|
671
|
-
a journal can sit beside a policy inside a project and silently making your
|
|
672
|
-
project directory `0700` would be the worse surprise. If your `~/.synartesis`
|
|
673
|
-
predates 0.3.0, `chmod 700 ~/.synartesis` is worth doing once by hand.
|
|
674
|
-
|
|
675
|
-
Nothing is encrypted. Full-disk encryption answers a stolen laptop; file
|
|
676
|
-
permissions answer another account on a machine you share.
|
|
677
|
-
|
|
678
|
-
### How fast it grows
|
|
679
|
-
|
|
680
|
-
Every write keeps the resource as it was, the resource as it became, and the
|
|
681
|
-
call that did it, so the journal grows at roughly **four times the bytes your
|
|
682
|
-
agent writes** and never shrinks on its own. Thirty edits of one 200 kB file
|
|
683
|
-
came to 24 MB.
|
|
684
|
-
|
|
685
|
-
```bash
|
|
686
|
-
synartesis prune # runs finished more than 30 days ago
|
|
687
|
-
synartesis prune --older-than 7 --dry-run
|
|
688
|
-
```
|
|
689
|
-
|
|
690
|
-
It deletes whole runs and then `VACUUM`s, since deleting rows leaves a SQLite
|
|
691
|
-
file exactly the size it was. It will not touch a run that is still active, or
|
|
692
|
-
one holding a call that is waiting on a person or whose outcome is unknown —
|
|
693
|
-
age is not an answer to a question nobody answered. A pruned run cannot be
|
|
694
|
-
undone afterwards, which is the whole of the trade.
|
|
695
226
|
|
|
696
|
-
|
|
697
|
-
|
|
227
|
+
**The journal is a copy of your data, not a log.** Putting a file back means
|
|
228
|
+
having kept what was in it, so the contents of every resource before it was
|
|
229
|
+
written are in there in plain text — including any key that was sitting in a
|
|
230
|
+
file your agent touched. That is not a leak to be closed; it is the thing that
|
|
231
|
+
makes undo work. It is created `0600` in a `0700` directory, and nothing is
|
|
232
|
+
encrypted: full-disk encryption answers a stolen laptop, permissions answer
|
|
233
|
+
another account on a machine you share.
|
|
234
|
+
|
|
235
|
+
**It grows at roughly four times the bytes your agent writes** and never shrinks
|
|
236
|
+
on its own — thirty edits of one 200 kB file came to 24 MB. `synartesis prune`
|
|
237
|
+
deletes whole sessions and `VACUUM`s. It will not touch one still active, or one
|
|
238
|
+
holding a call waiting on a person, or one whose undo halted on a conflict. A
|
|
239
|
+
pruned session cannot be undone afterwards, which is the whole of the trade.
|
|
240
|
+
Nothing prunes on a timer.
|
|
241
|
+
|
|
242
|
+
**Durability.** The journal runs `synchronous = NORMAL`. A crash of the process
|
|
243
|
+
or of the CLI mid-undo loses nothing; only the machine losing power can cost the
|
|
244
|
+
tail of the write-ahead log. `SYNARTESIS_SYNC=full` asks for an fsync per commit
|
|
245
|
+
instead — worth it where fsync is cheap, and measurably not where it is not.
|
|
698
246
|
|
|
699
247
|
## Development
|
|
700
248
|
|
package/dist/cli.js
CHANGED
|
@@ -39,8 +39,9 @@ import {
|
|
|
39
39
|
} from "./chunk-YVOO3PTV.js";
|
|
40
40
|
|
|
41
41
|
// src/cli.ts
|
|
42
|
-
import {
|
|
43
|
-
import {
|
|
42
|
+
import { spawn } from "child_process";
|
|
43
|
+
import { existsSync as existsSync8, mkdirSync as mkdirSync2, readFileSync as readFileSync4, statSync, writeFileSync as writeFileSync3 } from "fs";
|
|
44
|
+
import { dirname as dirname3, join as join3, resolve as resolve3 } from "path";
|
|
44
45
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
45
46
|
|
|
46
47
|
// src/init/draft.ts
|
|
@@ -389,6 +390,10 @@ async function rollback(options) {
|
|
|
389
390
|
const projected = /* @__PURE__ */ new Map();
|
|
390
391
|
for (const action of inScope) {
|
|
391
392
|
let forcedOver;
|
|
393
|
+
if (action.class === "readonly") {
|
|
394
|
+
steps.push({ ...describeStep(action), kind: "skip", reason: "readonly", verified: true });
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
392
397
|
const early = classify(action, policies !== void 0, force || dryRun);
|
|
393
398
|
if (early?.kind === "halt") {
|
|
394
399
|
const seen = action.error ?? "";
|
|
@@ -403,10 +408,6 @@ ${seen}` : seen;
|
|
|
403
408
|
steps.push({ ...describeStep(action), ...early });
|
|
404
409
|
continue;
|
|
405
410
|
}
|
|
406
|
-
if (action.class === "readonly") {
|
|
407
|
-
steps.push({ ...describeStep(action), kind: "skip", reason: "readonly", verified: true });
|
|
408
|
-
continue;
|
|
409
|
-
}
|
|
410
411
|
const rebuilt = replan(action);
|
|
411
412
|
const parsedPlan = inversePlan.safeParse(rebuilt.inverse ?? action.inverse);
|
|
412
413
|
if (!parsedPlan.success) {
|
|
@@ -2465,6 +2466,61 @@ async function openConsole(options) {
|
|
|
2465
2466
|
}
|
|
2466
2467
|
}
|
|
2467
2468
|
|
|
2469
|
+
// src/desktop.ts
|
|
2470
|
+
import { existsSync as existsSync7 } from "fs";
|
|
2471
|
+
import { homedir as homedir2 } from "os";
|
|
2472
|
+
import { join as join2 } from "path";
|
|
2473
|
+
var RELEASES = "https://github.com/ArhaanDev24/Synartesis/releases";
|
|
2474
|
+
function candidates(platform2 = process.platform) {
|
|
2475
|
+
const home = homedir2();
|
|
2476
|
+
if (platform2 === "darwin") {
|
|
2477
|
+
return [
|
|
2478
|
+
"/Applications/Synartesis.app",
|
|
2479
|
+
join2(home, "Applications/Synartesis.app")
|
|
2480
|
+
];
|
|
2481
|
+
}
|
|
2482
|
+
if (platform2 === "win32") {
|
|
2483
|
+
const local = process.env["LOCALAPPDATA"] ?? join2(home, "AppData/Local");
|
|
2484
|
+
return [
|
|
2485
|
+
join2(local, "Programs/Synartesis/Synartesis.exe"),
|
|
2486
|
+
join2(process.env["PROGRAMFILES"] ?? "C:/Program Files", "Synartesis/Synartesis.exe")
|
|
2487
|
+
];
|
|
2488
|
+
}
|
|
2489
|
+
return [
|
|
2490
|
+
"/opt/Synartesis/synartesis-desktop",
|
|
2491
|
+
"/usr/bin/synartesis-desktop",
|
|
2492
|
+
join2(home, ".local/bin/synartesis-desktop"),
|
|
2493
|
+
join2(home, "Applications/Synartesis.AppImage")
|
|
2494
|
+
];
|
|
2495
|
+
}
|
|
2496
|
+
function findDesktop(platform2 = process.platform, here = existsSync7) {
|
|
2497
|
+
for (const path of candidates(platform2)) {
|
|
2498
|
+
if (!here(path)) {
|
|
2499
|
+
continue;
|
|
2500
|
+
}
|
|
2501
|
+
if (platform2 === "darwin") {
|
|
2502
|
+
return { path, open: { command: "open", args: ["-a", path] } };
|
|
2503
|
+
}
|
|
2504
|
+
return { path, open: { command: path, args: [] } };
|
|
2505
|
+
}
|
|
2506
|
+
return void 0;
|
|
2507
|
+
}
|
|
2508
|
+
function whereToGetIt(platform2 = process.platform) {
|
|
2509
|
+
const asset = platform2 === "darwin" ? "the .dmg for your chip (Apple silicon or Intel)" : platform2 === "win32" ? "the Windows installer" : "the AppImage or .deb";
|
|
2510
|
+
return [
|
|
2511
|
+
"The Synartesis desktop application is not installed.",
|
|
2512
|
+
"",
|
|
2513
|
+
`Download ${asset} from:`,
|
|
2514
|
+
` ${RELEASES}`,
|
|
2515
|
+
"",
|
|
2516
|
+
"It is a separate download on purpose. Shipping it through npm would mean",
|
|
2517
|
+
"every install of this command line tool pulled a browser engine with it.",
|
|
2518
|
+
"",
|
|
2519
|
+
"The journal is shared either way: something the window does is undoable",
|
|
2520
|
+
"here with `synartesis undo`, and the other way round."
|
|
2521
|
+
].join("\n");
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2468
2524
|
// src/cli.ts
|
|
2469
2525
|
var NODE_MAJOR = Number(process.versions.node.split(".")[0]);
|
|
2470
2526
|
if (NODE_MAJOR < 22) {
|
|
@@ -2494,6 +2550,7 @@ var COMMANDS = `
|
|
|
2494
2550
|
[--journal <path>]
|
|
2495
2551
|
[--http <port> --token <secret>] for a client that
|
|
2496
2552
|
cannot start one
|
|
2553
|
+
synartesis desktop
|
|
2497
2554
|
synartesis watch [--by <name>] [--journal <path>]
|
|
2498
2555
|
synartesis approve [actionId|--all] [--by <name>] [--journal <path>]
|
|
2499
2556
|
synartesis deny [actionId|--all] [--by <name>] [--reason <text>] [--journal <path>]
|
|
@@ -2505,6 +2562,10 @@ Cursor or Codex already list, writes a policy covering all of it -- using the on
|
|
|
2505
2562
|
ship where they fit -- and points each entry at the proxy. The original config
|
|
2506
2563
|
is copied aside first, and uninstall puts it back. status says what is covered.
|
|
2507
2564
|
|
|
2565
|
+
desktop opens the window, if it is installed. It is a separate download --
|
|
2566
|
+
shipping it through npm would put a browser engine inside every install of
|
|
2567
|
+
this command. Both share one journal, so either can undo what the other did.
|
|
2568
|
+
|
|
2508
2569
|
close ends a run left active by a proxy that was killed; nothing guesses at
|
|
2509
2570
|
that, since several proxies can share one journal.
|
|
2510
2571
|
|
|
@@ -2736,7 +2797,7 @@ async function runUninstall(argv) {
|
|
|
2736
2797
|
}
|
|
2737
2798
|
function openIfPresent(journalPath) {
|
|
2738
2799
|
try {
|
|
2739
|
-
return
|
|
2800
|
+
return existsSync8(journalPath) ? openJournal(journalPath, { mustExist: true }) : void 0;
|
|
2740
2801
|
} catch {
|
|
2741
2802
|
return void 0;
|
|
2742
2803
|
}
|
|
@@ -2769,7 +2830,7 @@ function runStatus(argv) {
|
|
|
2769
2830
|
const journalPath = findJournal(flag(argv, "--journal"), manifestPath);
|
|
2770
2831
|
out("");
|
|
2771
2832
|
out(
|
|
2772
|
-
` ${style.label("policy")} ${
|
|
2833
|
+
` ${style.label("policy")} ${existsSync8(manifestPath) ? style.strong(manifestPath) : style.quiet(`${manifestPath} (none yet)`)}`
|
|
2773
2834
|
);
|
|
2774
2835
|
out(
|
|
2775
2836
|
` ${style.label("journal")} ${bytesOf(journalPath) === void 0 ? style.quiet(`${journalPath} (none yet)`) : `${style.strong(journalPath)} ${style.quiet(sizeOf(journalPath))}`}`
|
|
@@ -2822,7 +2883,7 @@ async function runInit(argv) {
|
|
|
2822
2883
|
}
|
|
2823
2884
|
const path = findManifest(flag(argv, "--manifest"));
|
|
2824
2885
|
const force = argv.includes("--force");
|
|
2825
|
-
const present =
|
|
2886
|
+
const present = existsSync8(path);
|
|
2826
2887
|
if (present && force) {
|
|
2827
2888
|
throw new UsageError(
|
|
2828
2889
|
`--force would discard ${path}. Delete it yourself if that is what you want; init will otherwise add to it.`
|
|
@@ -2855,17 +2916,17 @@ async function runInit(argv) {
|
|
|
2855
2916
|
out("");
|
|
2856
2917
|
return 0;
|
|
2857
2918
|
}
|
|
2858
|
-
function pick(
|
|
2919
|
+
function pick(candidates2, given, noun, newest = false) {
|
|
2859
2920
|
const listed = (items) => items.map((item) => ` ${item.id}`).join("\n");
|
|
2860
2921
|
if (given === void 0) {
|
|
2861
|
-
const [only, ...rest2] =
|
|
2922
|
+
const [only, ...rest2] = candidates2;
|
|
2862
2923
|
if (only === void 0) {
|
|
2863
2924
|
throw new UsageError(`there is no ${noun.one} to act on`);
|
|
2864
2925
|
}
|
|
2865
2926
|
if (rest2.length > 0 && !newest) {
|
|
2866
2927
|
throw new UsageError(
|
|
2867
|
-
`there are ${String(
|
|
2868
|
-
${listed(
|
|
2928
|
+
`there are ${String(candidates2.length)} ${noun.many}; name one, or use --all:
|
|
2929
|
+
${listed(candidates2)}`
|
|
2869
2930
|
);
|
|
2870
2931
|
}
|
|
2871
2932
|
return only;
|
|
@@ -2873,11 +2934,11 @@ ${listed(candidates)}`
|
|
|
2873
2934
|
if (given === "") {
|
|
2874
2935
|
throw new UsageError(`no ${noun.one} was named; an empty id is usually an unset variable`);
|
|
2875
2936
|
}
|
|
2876
|
-
const exact =
|
|
2937
|
+
const exact = candidates2.find((item) => item.id === given);
|
|
2877
2938
|
if (exact !== void 0) {
|
|
2878
2939
|
return exact;
|
|
2879
2940
|
}
|
|
2880
|
-
const matches =
|
|
2941
|
+
const matches = candidates2.filter((item) => item.id.startsWith(given));
|
|
2881
2942
|
const [first, ...rest] = matches;
|
|
2882
2943
|
if (first === void 0) {
|
|
2883
2944
|
throw new UsageError(`no ${noun.one} matches ${given}`);
|
|
@@ -3533,7 +3594,7 @@ var FLAGS = /* @__PURE__ */ new Set([
|
|
|
3533
3594
|
function version() {
|
|
3534
3595
|
try {
|
|
3535
3596
|
const root = dirname3(fileURLToPath2(import.meta.url));
|
|
3536
|
-
const parsed = JSON.parse(readFileSync4(
|
|
3597
|
+
const parsed = JSON.parse(readFileSync4(join3(root, "..", "package.json"), "utf8"));
|
|
3537
3598
|
const found = typeof parsed === "object" && parsed !== null ? parsed.version : void 0;
|
|
3538
3599
|
return typeof found === "string" ? found : "unknown";
|
|
3539
3600
|
} catch {
|
|
@@ -3551,7 +3612,7 @@ function rejectUnknownFlags(argv) {
|
|
|
3551
3612
|
}
|
|
3552
3613
|
}
|
|
3553
3614
|
function openJournalOrExplain(journalPath) {
|
|
3554
|
-
if (!
|
|
3615
|
+
if (!existsSync8(journalPath)) {
|
|
3555
3616
|
throw new UsageError(
|
|
3556
3617
|
`nothing has been recorded yet: there is no journal at ${journalPath}. One appears the first time an agent calls a tool through synartesis proxy.`
|
|
3557
3618
|
);
|
|
@@ -3641,7 +3702,7 @@ ${COMMANDS}`);
|
|
|
3641
3702
|
});
|
|
3642
3703
|
}
|
|
3643
3704
|
journalArg = given === void 0 ? "" : ` --journal ${resolve3(given)}`;
|
|
3644
|
-
if (!
|
|
3705
|
+
if (!existsSync8(journalPath) && (command === "list" || command === "show" || command === "gates")) {
|
|
3645
3706
|
if (asJson) {
|
|
3646
3707
|
out(JSON.stringify(command === "show" ? { run: null, actions: [] } : []));
|
|
3647
3708
|
return 0;
|
|
@@ -3655,6 +3716,9 @@ ${COMMANDS}`);
|
|
|
3655
3716
|
out("");
|
|
3656
3717
|
return 0;
|
|
3657
3718
|
}
|
|
3719
|
+
if (command === "desktop") {
|
|
3720
|
+
return runDesktop();
|
|
3721
|
+
}
|
|
3658
3722
|
const journal = openJournalOrExplain(journalPath);
|
|
3659
3723
|
try {
|
|
3660
3724
|
switch (command) {
|
|
@@ -3681,6 +3745,21 @@ ${COMMANDS}`);
|
|
|
3681
3745
|
journal.close();
|
|
3682
3746
|
}
|
|
3683
3747
|
}
|
|
3748
|
+
function runDesktop() {
|
|
3749
|
+
const found = findDesktop();
|
|
3750
|
+
if (found === void 0) {
|
|
3751
|
+
process.stderr.write(`${whereToGetIt()}
|
|
3752
|
+
`);
|
|
3753
|
+
return 2;
|
|
3754
|
+
}
|
|
3755
|
+
const child = spawn(found.open.command, [...found.open.args], {
|
|
3756
|
+
detached: true,
|
|
3757
|
+
stdio: "ignore"
|
|
3758
|
+
});
|
|
3759
|
+
child.unref();
|
|
3760
|
+
out(`opening ${found.path}`);
|
|
3761
|
+
return 0;
|
|
3762
|
+
}
|
|
3684
3763
|
try {
|
|
3685
3764
|
process.exitCode = await main(process.argv.slice(2));
|
|
3686
3765
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "synartesis",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "An undo layer for AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -43,11 +43,18 @@
|
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"build": "tsup",
|
|
46
|
-
"typecheck": "tsc --noEmit",
|
|
46
|
+
"typecheck": "tsc --noEmit && tsc -p app/renderer --noEmit",
|
|
47
47
|
"lint": "eslint .",
|
|
48
48
|
"test": "vitest run",
|
|
49
49
|
"test:watch": "vitest",
|
|
50
50
|
"pretest": "tsup --silent",
|
|
51
|
+
"app": "pnpm app:build && electron app/dist/main/index.js",
|
|
52
|
+
"app:build": "tsup --config app/tsup.config.ts && vite build --config app/vite.config.ts",
|
|
53
|
+
"app:icon": "electron app/build/make-icon.mjs",
|
|
54
|
+
"app:logo": "python3 app/build/make-logo.py && pnpm app:icon",
|
|
55
|
+
"app:sandbox": "pnpm app:build && node app/dev/sandbox.mjs",
|
|
56
|
+
"app:pack": "pnpm app:build && node app/build/pack.mjs --dir",
|
|
57
|
+
"app:dist": "pnpm app:build && node app/build/pack.mjs",
|
|
51
58
|
"demo": "tsup --silent && ./demo/filesystem-demo.sh",
|
|
52
59
|
"demo:memory": "tsup --silent && ./demo/memory-demo.sh",
|
|
53
60
|
"prepublishOnly": "tsup"
|
|
@@ -60,13 +67,27 @@
|
|
|
60
67
|
"zod": "^4.4.3"
|
|
61
68
|
},
|
|
62
69
|
"devDependencies": {
|
|
70
|
+
"@anthropic-ai/sdk": "^0.125.0",
|
|
71
|
+
"@electron/notarize": "^3.1.1",
|
|
72
|
+
"@fontsource/cormorant-garamond": "^5.3.0",
|
|
73
|
+
"@fontsource/ibm-plex-mono": "^5.3.0",
|
|
74
|
+
"@fontsource/ibm-plex-sans": "^5.3.0",
|
|
75
|
+
"@google/genai": "^2.22.0",
|
|
63
76
|
"@modelcontextprotocol/server-filesystem": "^2026.7.10",
|
|
64
77
|
"@types/better-sqlite3": "^9.6.0",
|
|
65
78
|
"@types/node": "^26.2.0",
|
|
79
|
+
"@types/react": "^19.3.0",
|
|
80
|
+
"@types/react-dom": "^19.3.0",
|
|
81
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
82
|
+
"electron": "^44.3.0",
|
|
83
|
+
"electron-builder": "^26.15.3",
|
|
66
84
|
"eslint": "^10.8.1",
|
|
85
|
+
"react": "^19.3.0",
|
|
86
|
+
"react-dom": "^19.3.0",
|
|
67
87
|
"tsup": "^8.5.1",
|
|
68
88
|
"typescript": "^5.9.3",
|
|
69
89
|
"typescript-eslint": "^8.67.0",
|
|
90
|
+
"vite": "^8.3.0",
|
|
70
91
|
"vitest": "^4.1.11"
|
|
71
92
|
},
|
|
72
93
|
"packageManager": "pnpm@9.15.9+sha512.68046141893c66fad01c079231128e9afb89ef87e2691d69e4d40eee228988295fd4682181bae55b58418c3a253bde65a505ec7c5f9403ece5cc3cd37dcf2531"
|