@synqa/cli 1.1.0 → 1.2.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/README.md +145 -57
- package/bin/synqa.js +0 -0
- package/dist/agent.js +19 -5
- package/dist/api.js +46 -4
- package/dist/commands.js +150 -30
- package/dist/compact.js +4 -1
- package/dist/config.js +52 -9
- package/dist/hooks.js +219 -0
- package/dist/main.js +275 -91
- package/dist/modes.js +20 -0
- package/dist/paths.js +157 -38
- package/dist/prompt.js +381 -0
- package/dist/providers.js +234 -0
- package/dist/sanitize.js +6 -0
- package/dist/select.js +131 -0
- package/dist/statusbar.js +16 -9
- package/dist/theme.js +47 -7
- package/dist/tiers.js +1 -1
- package/dist/tools.js +133 -44
- package/dist/ui.js +152 -72
- package/dist/update.js +71 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ Blink 5.5, Sonata 6.0 and Aeon 6.5.
|
|
|
12
12
|
- [Slash commands](#slash-commands)
|
|
13
13
|
- [Welcome panel and turn footer](#welcome-panel-and-turn-footer)
|
|
14
14
|
- [Diff preview](#diff-preview)
|
|
15
|
-
- [Status
|
|
15
|
+
- [Status line](#status-line)
|
|
16
16
|
- [AGENTS.md and /init](#agentsmd-and-init)
|
|
17
17
|
- [Undo](#undo)
|
|
18
18
|
- [Settings and environment](#settings-and-environment)
|
|
@@ -22,7 +22,7 @@ Blink 5.5, Sonata 6.0 and Aeon 6.5.
|
|
|
22
22
|
|
|
23
23
|
## Install
|
|
24
24
|
|
|
25
|
-
Needs Node.js 22.18 or newer (or 23.6 or newer) and npm. There are no other dependencies.
|
|
25
|
+
Needs Node.js 22.18 or newer (or 23.6 or newer) and npm. There are no other dependencies.
|
|
26
26
|
|
|
27
27
|
Try it without installing:
|
|
28
28
|
|
|
@@ -59,72 +59,146 @@ From a checkout of this repo (for development): `npm link ./cli`.
|
|
|
59
59
|
2. Paste your `synqa_live_...` key (typing is hidden). It is checked against the Synqa API and
|
|
60
60
|
saved to `~/.config/synqa/config.json` (file mode 0600).
|
|
61
61
|
|
|
62
|
-
Or
|
|
62
|
+
Or set `SYNQA_API_KEY` in your shell. A key saved by `synqa login` or `/login` wins over the variable,
|
|
63
|
+
so you never need to unset it; pass `--env-key` to make the variable win for one run.
|
|
64
|
+
|
|
65
|
+
Starting `synqa` in a terminal with no valid Synqa key shows the welcome screen and asks for the key
|
|
66
|
+
first (typing shows `••••`, the key is checked at `https://api.synqa.ca/v1/account`). Chat and every
|
|
67
|
+
other provider stay locked until you are signed in to Synqa Cloud.
|
|
68
|
+
|
|
69
|
+
## Providers
|
|
70
|
+
|
|
71
|
+
After you sign in, `/provider` (or `/login`) opens an arrow-key menu: Synqa Cloud (primary),
|
|
72
|
+
OpenCode Go, OpenCode Zen, OpenRouter (bring your own `sk-or-...` key) and a custom
|
|
73
|
+
OpenAI-compatible endpoint. Keys for the others are typed masked, checked with `GET /v1/models` and
|
|
74
|
+
saved in the same private config file (mode 0600 on macOS and Linux; Windows ignores the mode). The
|
|
75
|
+
active provider and model show in the header and the footer. `/logout` offers to leave the secondary
|
|
76
|
+
provider or to log out fully, which clears the Synqa key (and `SYNQA_API_KEY` in this process) and
|
|
77
|
+
returns to the sign-in prompt. Use `OPENCODE_API_KEY` or the router's key variable as a fallback when
|
|
78
|
+
nothing is saved.
|
|
79
|
+
|
|
80
|
+
## Keys and modes
|
|
81
|
+
|
|
82
|
+
- `/` opens the command list under the prompt: Up/Down move, Tab fills, Enter runs, Esc closes.
|
|
83
|
+
- `/model`, `/effort`, `/provider`, `/login`, `/logout` and `/hooks` with no argument open an inline
|
|
84
|
+
Up/Down list (Enter selects, Esc cancels).
|
|
85
|
+
- Shift+Tab cycles the permission mode: `ask before edits`, `auto-accept edits` (file edits in the
|
|
86
|
+
workspace apply without a question, like `--yes`; shell commands still ask) and `plan only` (no
|
|
87
|
+
file is written and no command is run). The mode shows in the footer.
|
|
88
|
+
|
|
89
|
+
## Hooks
|
|
90
|
+
|
|
91
|
+
`.synqa/hooks.json` in a project and `hooks.json` in the user config folder can run shell commands at
|
|
92
|
+
`SessionStart`, `PreToolUse`, `PostToolUse` and `Stop`:
|
|
93
|
+
|
|
94
|
+
```json
|
|
95
|
+
{ "hooks": { "PreToolUse": [ { "matcher": "write_file|edit_file", "command": "python check.py" } ] } }
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The hook gets the event as JSON on stdin. A `PreToolUse` hook blocks the tool by exiting with code 2
|
|
99
|
+
or printing `{"decision":"block","reason":"..."}`; other output is ignored. A command that starts
|
|
100
|
+
with `python` runs with `python3` (or `py` on Windows) when `python` is not on PATH. Project hooks
|
|
101
|
+
are repository content, so they start off: `/hooks` lists them and switches them on or off.
|
|
102
|
+
|
|
103
|
+
### Hooks security
|
|
104
|
+
|
|
105
|
+
- Project hooks start disabled. An approval is tied to the project path and the exact entry
|
|
106
|
+
(command, matcher and timeout): a different folder or any edit to the entry needs a new approval.
|
|
107
|
+
- The hook payload includes the tool input and result (up to 4000 characters each), so a hook sees
|
|
108
|
+
file contents and command output.
|
|
109
|
+
- Never point a hook at a file the agent can write: the agent could rewrite the script that runs next.
|
|
110
|
+
- In plan only mode the `PostToolUse` and `Stop` hooks do not run.
|
|
111
|
+
|
|
112
|
+
## Updates
|
|
113
|
+
|
|
114
|
+
On start `synqa` asks the npm registry once, with a 1.5 second limit and no error message, whether a
|
|
115
|
+
newer `@synqa/cli` exists, and shows a quiet hint in the footer. `/update` runs
|
|
116
|
+
`npm install -g @synqa/cli@latest`. Set `SYNQA_NO_UPDATE_CHECK=1` to skip the check.
|
|
63
117
|
|
|
64
118
|
## Use
|
|
65
119
|
|
|
66
120
|
- `synqa` : interactive session in the current folder.
|
|
67
121
|
- `synqa "fix the failing test"` : one task, then exit.
|
|
68
|
-
- `synqa --yes "..."` :
|
|
122
|
+
- `synqa --yes "..."` : approve ordinary file writes and edits inside the folder without asking. **Shell commands always ask, even with `--yes`** (any file the agent reads could carry an injected command), and `--yes` is not allowed in your home folder
|
|
69
123
|
(text in a README or a web page can steer the model). Some things still ask, and are denied when nobody can answer:
|
|
70
124
|
writes that can run code later (git hooks and other `.git` files, `package.json`, Makefiles, `.envrc`, `.vscode`
|
|
71
125
|
settings, CI workflows, shell startup files, dotfiles, executables), writes to secrets files, every write and
|
|
72
126
|
shell command while an `AGENTS.md` is loaded, and shell commands that name a secrets file or a glob that could.
|
|
73
127
|
Diffs are still shown.
|
|
74
128
|
- `synqa --model sonata` : start on another tier (`blink`, `sonata`, `aeon`).
|
|
75
|
-
- `synqa --effort low` : start with a reasoning effort (`default`, `low`, `medium`).
|
|
129
|
+
- `synqa --effort low` : start with a reasoning effort (`default`, `low`, `medium`, `high`).
|
|
76
130
|
|
|
77
131
|
## Slash commands
|
|
78
132
|
|
|
79
133
|
| Command | What it does |
|
|
80
134
|
|---|---|
|
|
81
|
-
| `/model blink\|sonata\|aeon` |
|
|
82
|
-
| `/effort default\|low\|medium` | Set the reasoning effort. `default` sends no effort setting. |
|
|
135
|
+
| `/model blink\|sonata\|aeon` | Choose the model. With no argument an arrow-key list opens: Blink 5.5, Sonata 6.0, Aeon 6.5 on Synqa Cloud, the provider's own model list otherwise. |
|
|
136
|
+
| `/effort default\|low\|medium\|high` | Set the reasoning effort (arrow-key list with no argument). `default` sends no effort setting. |
|
|
137
|
+
| `/provider`, `/login` | Pick Synqa Cloud, OpenCode Go, OpenCode Zen, OpenRouter or a custom endpoint. |
|
|
138
|
+
| `/logout` | Leave the secondary provider, or log out fully and lock the session. |
|
|
139
|
+
| `/hooks` | List hooks and switch them on or off. |
|
|
140
|
+
| `/update` | Install the latest `@synqa/cli`. |
|
|
83
141
|
| `/status` | Tier, routing, session tokens, context use of the last request, and your live credit and weekly pool balance. |
|
|
84
142
|
| `/init` | Scan the project and write a starter `AGENTS.md`. |
|
|
85
143
|
| `/compact` | Ask the model to summarize the conversation and continue from the summary, to save context. |
|
|
86
144
|
| `/undo` | Revert the last file the agent wrote. Run it again to go further back. |
|
|
87
145
|
| `/clear` | Start a fresh conversation (reloads `AGENTS.md`). |
|
|
88
|
-
| `/help` | List the commands. |
|
|
146
|
+
| `/help` | List the commands, grouped, with the keys of the command menu. |
|
|
89
147
|
| `/exit` | Leave (also `/quit`, or Ctrl-D). |
|
|
90
148
|
|
|
91
149
|
`/status` reads your balance from the Synqa account service with your key. If the service does not
|
|
92
150
|
answer, it says so and shows everything else; your balance is always on the
|
|
93
151
|
[dashboard](https://synqa.ca/client-dashboard).
|
|
94
152
|
|
|
95
|
-
##
|
|
153
|
+
## Start screen, prompt and turn footer
|
|
96
154
|
|
|
97
|
-
In an interactive terminal the session opens with
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
155
|
+
In an interactive terminal the session opens with the Synqa wordmark (on a window of 60 columns or
|
|
156
|
+
more; a one-line bold `Synqa.` below that), then dim lines with the CLI version, tier and effort, and
|
|
157
|
+
the workspace folder (your home folder shown as `~`), then short `⎿` notes: whether `AGENTS.md`
|
|
158
|
+
project notes were loaded, and your credit and weekly pool balance from the Synqa account service.
|
|
159
|
+
The balance lookup waits at most 2 seconds; if it fails the note says `balance unavailable`, and
|
|
160
|
+
`balance not available yet` when the service does not offer it to API keys yet. Your key is never shown.
|
|
102
161
|
|
|
103
162
|
```
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
163
|
+
▄▀▀▀
|
|
164
|
+
▀▀▄ █ █ █▀▀▄ ▄▀▀█ ▄▀▀█
|
|
165
|
+
▄▄▄▀ ▀▄▄█ █ █ ▀▄▄█ ▀▄▄█ ▄
|
|
166
|
+
▄▄▄▀ █
|
|
167
|
+
|
|
168
|
+
v1.2.0 · Blink 5.5 · default effort
|
|
169
|
+
~/synqa-test
|
|
170
|
+
⎿ no AGENTS.md yet: /init writes one
|
|
171
|
+
⎿ $9.98 credit · weekly pool $4.00 of $10.00
|
|
172
|
+
───────────────────────────────────────────────────────────────────────────────
|
|
173
|
+
❯ Try "explain this project", or type / for commands
|
|
174
|
+
───────────────────────────────────────────────────────────────────────────────
|
|
175
|
+
Blink 5.5 · synqa-test · 0 tokens $9.98 credit
|
|
176
|
+
/ commands · ↑↓ history · tab complete · ctrl+c quit
|
|
110
177
|
```
|
|
111
178
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
179
|
+
The letters are drawn in your terminal's own text colour, so they read as ink on a light theme and
|
|
180
|
+
light on a dark one; the full stop is terracotta. The input sits between two thin rules, with a dim
|
|
181
|
+
hint while it is empty. Under it the status line shows only what is known (see below) and a line of
|
|
182
|
+
key hints.
|
|
183
|
+
|
|
184
|
+
Type `/` and a menu of commands opens under the input, filtered as you type, with a short
|
|
185
|
+
description of each. Up and Down move the highlight, Tab or Enter completes it (Enter runs a command
|
|
186
|
+
that takes no argument), Esc closes the menu. An unknown command gets a one-line hint with the
|
|
187
|
+
nearest name. The menu is drawn with Node's own readline key events, no extra package.
|
|
188
|
+
|
|
189
|
+
On a terminal that is not UTF-8 (or `TERM=dumb`, or a very old Windows console) everything is plain
|
|
190
|
+
ASCII and there is no menu or frame: the status is printed above a plain `>` prompt.
|
|
191
|
+
|
|
192
|
+
Colours: terracotta for the full stop, spinner, `❯` prompt, tier name and the selected menu row;
|
|
193
|
+
the terminal's own text colour (bold) for names, or warm bone when the terminal reports a dark
|
|
194
|
+
background (`COLORFGBG`); warm stone for rules and labels; sage for the balance and added diff lines
|
|
195
|
+
(removed lines stay red). 24-bit colour is used only where it is known to work: `COLORTERM` is
|
|
196
|
+
`truecolor` or `24bit`, or `TERM_PROGRAM` is iTerm2, WezTerm, VS Code, Ghostty and similar. macOS
|
|
197
|
+
Terminal.app (`TERM_PROGRAM=Apple_Terminal`) and unknown terminals get the 256 standard colours, with
|
|
198
|
+
terracotta mapped to colour 166 so it stays burnt orange; Terminal.app gets them even when a
|
|
199
|
+
`COLORTERM` was copied into the shell profile, because it misdraws 24-bit escapes. Every line is
|
|
200
|
+
measured by its visible width (colour codes not counted, wide characters counted as two) and kept
|
|
201
|
+
one column short of the window, so nothing wraps.
|
|
128
202
|
|
|
129
203
|
While the agent waits for the first token a spinner with the elapsed seconds is shown (`⠋ waiting 1.2s`)
|
|
130
204
|
and is erased when output starts. Each tool call is drawn as a card: a header with the tool and its
|
|
@@ -143,10 +217,10 @@ file or command, the diff preview for edits inside it, and a one line result.
|
|
|
143
217
|
After every turn a dim footer line stays in the scrollback: time to first token, output speed,
|
|
144
218
|
routing (`local` or `hybrid`) and your credit left (only when the balance is known).
|
|
145
219
|
|
|
146
|
-
The
|
|
220
|
+
The start screen, spinner, cards and footer degrade on their own: with `NO_COLOR` they have no colour; when
|
|
147
221
|
output is not a terminal (a pipe, a file, a one-shot task) there is no spinner, no cursor control and
|
|
148
222
|
the old plain output; when the terminal is not UTF-8, is `TERM=dumb`, or is the old Windows console
|
|
149
|
-
(Windows Terminal is fine), the
|
|
223
|
+
(Windows Terminal is fine), the start screen, cards and spinner use plain ASCII (`+-`, `| / - \`). Long paths are cut with an
|
|
150
224
|
ellipsis to fit narrow windows. Text from the model or from tools never reaches the terminal as
|
|
151
225
|
control sequences.
|
|
152
226
|
|
|
@@ -161,24 +235,26 @@ shell startup files, other dotfiles): those are always shown in full.
|
|
|
161
235
|
Colour is off when output is not a terminal or when `NO_COLOR` is set; the diff is then plain `+`/`-`
|
|
162
236
|
text. Control characters in file content are shown as `\x1b`-style escapes, never sent to your terminal.
|
|
163
237
|
|
|
164
|
-
## Status
|
|
238
|
+
## Status line
|
|
165
239
|
|
|
166
|
-
In an interactive terminal the
|
|
240
|
+
In an interactive terminal the line under the input shows only values that are known:
|
|
167
241
|
|
|
168
242
|
```
|
|
169
|
-
Blink 5.5
|
|
243
|
+
Blink 5.5 · low effort · synqa-test · ctx 10% · hybrid · 1,234 tokens $9.98 credit
|
|
170
244
|
```
|
|
171
245
|
|
|
172
|
-
- Tier display name (`Blink 5.5`, `Sonata 6.0` or `Aeon 6.5`), plus `effort
|
|
246
|
+
- Tier display name (`Blink 5.5`, `Sonata 6.0` or `Aeon 6.5`), plus `low effort` or `medium effort` when set.
|
|
247
|
+
- The workspace folder name.
|
|
248
|
+
- `ctx`: how much of the context window the last request used (after the first request).
|
|
173
249
|
- Routing: `local` or `hybrid`, read from the `x-synqa-routing` response header of the last request
|
|
174
|
-
(`fallback` for a fallback route
|
|
175
|
-
- TTFT: seconds from sending the request to the first token. tok/s: output tokens per second of
|
|
176
|
-
generation over the last turn. A dash means the number was not reported.
|
|
250
|
+
(`fallback` for a fallback route); left out when the server did not say.
|
|
177
251
|
- Session tokens: input plus output so far.
|
|
252
|
+
- On the right, your credit when the balance is known.
|
|
178
253
|
|
|
179
|
-
On a narrow terminal the least important parts drop off first.
|
|
180
|
-
|
|
181
|
-
|
|
254
|
+
On a narrow terminal the least important parts drop off first. Time to first token and output speed
|
|
255
|
+
are in the footer after each turn. When the output is not a terminal (a pipe, a file, `TERM=dumb`)
|
|
256
|
+
a plain line (`Blink 5.5 | local | TTFT 0.42s | 87.3 tok/s | 1,234 tokens`, unknown parts left out)
|
|
257
|
+
is printed above each prompt, and after a one-shot task.
|
|
182
258
|
|
|
183
259
|
## AGENTS.md and /init
|
|
184
260
|
|
|
@@ -203,7 +279,7 @@ it. Use git for that.
|
|
|
203
279
|
|
|
204
280
|
| Name | Meaning |
|
|
205
281
|
|---|---|
|
|
206
|
-
| `SYNQA_API_KEY` | Your key
|
|
282
|
+
| `SYNQA_API_KEY` | Your key. A key saved by `synqa login` or `/login` wins over it unless you pass `--env-key`. Never passed to commands the agent runs. |
|
|
207
283
|
| `NO_COLOR` | Any non-empty value turns colour off. |
|
|
208
284
|
| `~/.config/synqa/config.json` | Saved key and default tier (mode 0600). |
|
|
209
285
|
|
|
@@ -211,7 +287,7 @@ it. Use git for that.
|
|
|
211
287
|
|
|
212
288
|
- File tools only touch files inside the folder you started in (`..`, absolute paths and symlinks
|
|
213
289
|
out of it are refused).
|
|
214
|
-
- Writes
|
|
290
|
+
- Writes and edits ask `y/N` first, also in one-shot mode, unless `--yes`; shell commands always ask. Every question shows the whole diff (only the display after a `--yes` approval is cut). Commands run without credential-looking environment variables (tokens, secrets, keys and passwords by name, `AWS_*` except region and profile, git askpass helpers, connection strings with a password in them, the API key, and `SSH_AUTH_SOCK`: run `git push` over SSH in your own terminal). `grep` stops a pattern that runs longer than 10 seconds. Without a
|
|
215
291
|
terminal the answer is no.
|
|
216
292
|
- Shell commands are not confined to the folder: read each one before you answer.
|
|
217
293
|
- Writes to `.git/hooks`, `package.json`, shell startup files and other dotfiles carry a warning, because
|
|
@@ -220,21 +296,33 @@ it. Use git for that.
|
|
|
220
296
|
delimited block as conventions only, you are told when it is loaded, and while it is loaded shell
|
|
221
297
|
commands always ask first, even with `--yes`.
|
|
222
298
|
- Files that hold secrets (`.env` and `.env.*` except examples, `.envrc`, `*.pem`, `*.key`, `*.tfvars`, `id_*`
|
|
223
|
-
private keys, `keys.json`, `credentials.json`, `secrets.*`, `.ssh/`, `.aws/`, `.gnupg/`, `.kube
|
|
224
|
-
`.docker/config.json`, `.netrc`, `.npmrc`, `.pypirc`, `.git-credentials`, `.dev.vars`,
|
|
225
|
-
|
|
299
|
+
private keys, `keys.json`, `credentials.json`, `secrets.*`, `.ssh/`, `.aws/`, `.gnupg/`, `.kube/`,
|
|
300
|
+
`.docker/config.json`, `.netrc`, `.npmrc`, `.pypirc`, `.git-credentials`, `.dev.vars`, `.config/gh`,
|
|
301
|
+
`.config/gcloud`, `.azure/`, `.password-store/`, macOS keychains (`Library/Keychains`, `*.keychain-db`),
|
|
302
|
+
browser profiles (Chrome, Chromium, Brave, Edge, Arc, Firefox, Safari, cookies and saved logins), this
|
|
303
|
+
CLI's own config) are never read, written or edited by the agent, with or without `--yes`, and are
|
|
304
|
+
skipped by `grep` and `glob`. A shell command that names one always asks first, even with `--yes`.
|
|
226
305
|
Names match without regard to case or full-width letters, as on a case-insensitive Mac.
|
|
227
|
-
- The CLI
|
|
228
|
-
|
|
306
|
+
- The CLI starts in any folder, your home folder included, like other coding CLIs. Inside your home
|
|
307
|
+
folder everything under a dot entry at its top (`~/.zsh_history`, `~/.config/...`, `~/.cargo/...`,
|
|
308
|
+
any tool's settings) and everything under `~/Library` is protected as a rule, whether or not it is
|
|
309
|
+
named above; your projects' own dot files (`~/proj/.github`) are not affected. `--yes` is refused
|
|
310
|
+
in the home folder itself. Writes to launchd agents, autostart entries, systemd units, fish and
|
|
311
|
+
other shell startup files and `~/.local/bin` always ask. Only the filesystem root (`/`) is refused
|
|
312
|
+
as a start folder. `--allow-home` is still accepted and does nothing.
|
|
229
313
|
- Your key is never printed, never put on a command line, and removed from the environment of every
|
|
230
314
|
command the agent runs.
|
|
231
315
|
|
|
232
316
|
## Troubleshooting
|
|
233
317
|
|
|
234
318
|
- `No API key`: run `synqa login` or set `SYNQA_API_KEY`.
|
|
235
|
-
- `
|
|
319
|
+
- `Your API key was rejected`: run `synqa login` again, or check on the dashboard that the key is
|
|
320
|
+
active. A key saved with `/login` is used before `SYNQA_API_KEY`; `/logout` then `Full logout` clears
|
|
321
|
+
both in the running session.
|
|
322
|
+
- `synqa login` checks the key against the same key check the chat uses, and refuses a key with no
|
|
323
|
+
chat access.
|
|
236
324
|
- `request timed out`: a request is cut off after 10 minutes.
|
|
237
|
-
-
|
|
325
|
+
- Stray characters or a broken layout in Terminal.app: update to 1.1.1, which uses 256 colours there.
|
|
238
326
|
- No colour: check `NO_COLOR` and that stdout is a terminal.
|
|
239
327
|
|
|
240
328
|
## Develop
|
package/bin/synqa.js
CHANGED
|
File without changes
|
package/dist/agent.js
CHANGED
|
@@ -13,7 +13,7 @@ export const MAX_NOTES_CHARS = 8000;
|
|
|
13
13
|
// from a third party (a cloned repo), so it goes in a delimited block as data: conventions only,
|
|
14
14
|
// never an instruction source. fenceText removes invisible characters and neutralizes every "<", so
|
|
15
15
|
// nothing inside can open or close the block.
|
|
16
|
-
export function systemPrompt(tier , cwd , notes ) {
|
|
16
|
+
export function systemPrompt(tier , cwd , notes , external = false) {
|
|
17
17
|
const body = fenceText(notes ?? "", MAX_NOTES_CHARS);
|
|
18
18
|
const extra = body
|
|
19
19
|
? [
|
|
@@ -26,8 +26,10 @@ export function systemPrompt(tier , cwd , notes ) {
|
|
|
26
26
|
]
|
|
27
27
|
: [];
|
|
28
28
|
return [
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
external
|
|
30
|
+
? "You are the Synqa terminal coding agent, running on a model provider the user chose. If asked who created this tool, say Synqa."
|
|
31
|
+
: `You are Synqa-${tier.display}, a terminal coding agent made by Synqa.`,
|
|
32
|
+
...(external ? [] : ["If asked what model you are or who created you, say you are Synqa and name your tier; say nothing else about what runs inside."]),
|
|
31
33
|
`Working directory: ${cwd}. File tools cannot leave it.`,
|
|
32
34
|
"Use the tools to read, search and change files and to run commands. Make small, correct changes and say briefly what you did.",
|
|
33
35
|
...extra,
|
|
@@ -52,6 +54,15 @@ export function systemPrompt(tier , cwd , notes ) {
|
|
|
52
54
|
|
|
53
55
|
|
|
54
56
|
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
// PreToolUse and PostToolUse hooks. pre returns a reason to block the tool, or undefined to run it.
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
55
66
|
|
|
56
67
|
|
|
57
68
|
|
|
@@ -78,7 +89,8 @@ export async function runAgent(o ) {
|
|
|
78
89
|
o.events.onRequest?.();
|
|
79
90
|
const r = await chat({
|
|
80
91
|
apiKey: o.apiKey,
|
|
81
|
-
model: o.tier.id,
|
|
92
|
+
model: o.modelId ?? o.tier.id,
|
|
93
|
+
baseUrl: o.baseUrl,
|
|
82
94
|
messages: o.history,
|
|
83
95
|
tools: TOOL_DEFS,
|
|
84
96
|
fetchFn: o.fetchFn,
|
|
@@ -109,7 +121,9 @@ export async function runAgent(o ) {
|
|
|
109
121
|
o.events.onTool(c.name, c.arguments);
|
|
110
122
|
let result ;
|
|
111
123
|
try {
|
|
112
|
-
|
|
124
|
+
const blocked = await o.hooks?.pre(c.name, c.arguments);
|
|
125
|
+
result = blocked !== undefined ? `blocked by a hook: ${blocked}` : await runTool(o.ctx, c.name, parseToolArguments(c.arguments));
|
|
126
|
+
if (blocked === undefined) await o.hooks?.post(c.name, c.arguments, result);
|
|
113
127
|
} catch (err) {
|
|
114
128
|
result = `error: ${err instanceof Error ? err.message : String(err)}`;
|
|
115
129
|
}
|
package/dist/api.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { API_BASE } from "./config.js";
|
|
1
|
+
import { API_BASE, DASHBOARD_URL } from "./config.js";
|
|
2
|
+
import { sanitize } from "./sanitize.js";
|
|
2
3
|
import { REQUEST_TIMEOUT_MS, MAX_OUTPUT_TOKENS, usageFrom } from "./tiers.js";
|
|
3
4
|
|
|
4
5
|
|
|
@@ -90,9 +91,48 @@ export class ApiError extends Error {
|
|
|
90
91
|
}
|
|
91
92
|
}
|
|
92
93
|
|
|
94
|
+
export const KEY_REJECTED = `Your API key was rejected. Run \`synqa login\` again, or check the key is active in your Synqa dashboard (${DASHBOARD_URL}).`;
|
|
95
|
+
|
|
96
|
+
// The gateway's short reason ({"detail": "..."}), already white-labelled by the server; sanitized
|
|
97
|
+
// and cut here because it is network text.
|
|
98
|
+
async function detailOf(res ) {
|
|
99
|
+
try {
|
|
100
|
+
const text = (await res.text()).slice(0, 4000);
|
|
101
|
+
const v = JSON.parse(text) ;
|
|
102
|
+
const d = typeof v.detail === "string" ? v.detail : typeof v.error?.message === "string" ? v.error.message : "";
|
|
103
|
+
return sanitize(d).replace(/\s+/g, " ").trim().slice(0, 200);
|
|
104
|
+
} catch {
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// A plain sentence for a failed request, by status. Never the raw body.
|
|
110
|
+
export function friendlyStatus(status , detail = "") {
|
|
111
|
+
const why = detail ? ` (${detail})` : "";
|
|
112
|
+
if (status === 401) return KEY_REJECTED;
|
|
113
|
+
if (status === 402) return `Your Synqa balance is used up or your plan is not active${why}. Top up in your Synqa dashboard (${DASHBOARD_URL}).`;
|
|
114
|
+
if (status === 403) return `This key is not allowed to do that${why}. Check the key's access in your Synqa dashboard (${DASHBOARD_URL}).`;
|
|
115
|
+
if (status === 404) return "The Synqa service did not recognise this request (404). Update the CLI: npm i -g @synqa/cli";
|
|
116
|
+
if (status === 413) return `The conversation is too large to send${why}. Run /compact or /clear and try again.`;
|
|
117
|
+
if (status === 429) return `Rate limit reached${why}. Wait a moment and try again.`;
|
|
118
|
+
if (status === 503) return `Synqa is busy or paused right now${why}. Try again in a minute.`;
|
|
119
|
+
if (status >= 500) return `The Synqa service had a problem (status ${status}). Try again in a minute.`;
|
|
120
|
+
return `The request failed with status ${status}${why}.`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Login check: GET /v1/models goes through the same key check as chat (the gateway's caller_key),
|
|
124
|
+
// and the list is empty for a key without the chat capability, so an empty list is refused too.
|
|
93
125
|
export async function checkKey(apiKey , fetchFn = fetch) {
|
|
94
126
|
const res = await fetchFn(`${API_BASE}/models`, { headers: { authorization: `Bearer ${apiKey}` } });
|
|
95
|
-
if (!res.ok) throw new ApiError(res.status, res.status === 401
|
|
127
|
+
if (!res.ok) throw new ApiError(res.status, res.status === 401 ? "the key was rejected" : friendlyStatus(res.status, await detailOf(res)));
|
|
128
|
+
let ids = [];
|
|
129
|
+
try {
|
|
130
|
+
const body = (await res.json()) ;
|
|
131
|
+
ids = Array.isArray(body.data) ? body.data : [];
|
|
132
|
+
} catch {
|
|
133
|
+
throw new ApiError(res.status, "the service answer was not understood");
|
|
134
|
+
}
|
|
135
|
+
if (ids.length === 0) throw new ApiError(403, "this key has no chat access; check it in your Synqa dashboard");
|
|
96
136
|
}
|
|
97
137
|
|
|
98
138
|
|
|
@@ -101,6 +141,7 @@ export async function checkKey(apiKey , fetchFn = fetch)
|
|
|
101
141
|
|
|
102
142
|
|
|
103
143
|
|
|
144
|
+
|
|
104
145
|
|
|
105
146
|
|
|
106
147
|
|
|
@@ -114,7 +155,7 @@ export async function chat(o ) {
|
|
|
114
155
|
const ac = new AbortController();
|
|
115
156
|
const timer = setTimeout(() => ac.abort(), o.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
|
116
157
|
try {
|
|
117
|
-
const res = await fetchFn(`${API_BASE}/chat/completions`, {
|
|
158
|
+
const res = await fetchFn(`${(o.baseUrl ?? API_BASE).replace(/\/+$/, "")}/chat/completions`, {
|
|
118
159
|
method: "POST",
|
|
119
160
|
headers: { authorization: `Bearer ${o.apiKey}`, "content-type": "application/json" },
|
|
120
161
|
body: JSON.stringify({
|
|
@@ -128,7 +169,8 @@ export async function chat(o ) {
|
|
|
128
169
|
}),
|
|
129
170
|
signal: ac.signal,
|
|
130
171
|
});
|
|
131
|
-
if (!res.ok
|
|
172
|
+
if (!res.ok) throw new ApiError(res.status, friendlyStatus(res.status, res.status === 401 ? "" : await detailOf(res)));
|
|
173
|
+
if (!res.body) throw new ApiError(res.status, "the service sent an empty answer");
|
|
132
174
|
const acc = new StreamAccumulator();
|
|
133
175
|
let firstAt ;
|
|
134
176
|
for await (const ev of sseEvents(res.body )) {
|