zames_pro 2.73.2 → 2.73.4
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 +12 -0
- package/README.md +69 -58
- package/dist/attachments.js +14 -10
- package/dist/checkpoint.js +19 -2
- package/dist/config.js +6 -0
- package/dist/context.js +18 -2
- package/dist/gitTools.js +20 -4
- package/dist/index.js +7 -1
- package/dist/input.js +8 -19
- package/dist/mcp.js +23 -7
- package/dist/sessions.js +9 -4
- package/dist/shell.js +9 -0
- package/dist/transcript.js +3 -3
- package/dist/undo.js +7 -1
- package/dist/web.js +35 -2
- package/package.json +1 -1
- package/scripts/render-how-it-works.mts +162 -99
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.73.4] - 2026-10-11
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- docs(readme): restore mermaid-style diagram with Qwen, finish provider-neutral prose
|
|
15
|
+
|
|
16
|
+
## [2.73.3] - 2026-10-11
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- harden SSRF guard, sandbox, git args, mcp lifecycle and editor
|
|
21
|
+
|
|
10
22
|
## [2.73.2] - 2026-10-11
|
|
11
23
|
|
|
12
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -28,8 +28,8 @@ through Playwright.
|
|
|
28
28
|
In spirit it is similar to Claude Code / Codex CLI: it starts in the current
|
|
29
29
|
directory, reads and edits files, runs commands, and commits to git.
|
|
30
30
|
|
|
31
|
-
> No API key required — it drives
|
|
32
|
-
> through a real (headless) browser.
|
|
31
|
+
> No API key required — it drives a DeepSeek or Qwen web chat like a regular
|
|
32
|
+
> user through a real (headless) browser.
|
|
33
33
|
|
|
34
34
|
<p align="center">
|
|
35
35
|
<img src="https://raw.githubusercontent.com/Viqto0r/zames_pro/master/docs/demo.png" alt="zames session in the terminal" width="860">
|
|
@@ -44,10 +44,12 @@ zames # sign in once, then describe your task
|
|
|
44
44
|
```
|
|
45
45
|
|
|
46
46
|
There is **no API key and no per-token bill**: zames signs in to **your own
|
|
47
|
-
[chat.deepseek.com](https://chat.deepseek.com/)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
[chat.deepseek.com](https://chat.deepseek.com/) or
|
|
48
|
+
[chat.qwen.ai](https://chat.qwen.ai/) account** in a real (headless) Chromium
|
|
49
|
+
and drives the web chat like a regular user. The first launch asks for your
|
|
50
|
+
login once (the session is stored in `~/.zames/profile` for DeepSeek or
|
|
51
|
+
`~/.zames/qwen-profile` for Qwen); after that just run `zames` and type a task
|
|
52
|
+
in plain language:
|
|
51
53
|
|
|
52
54
|
```text
|
|
53
55
|
❯ refactor the config loader and add a test for the new default
|
|
@@ -122,8 +124,8 @@ Key pieces:
|
|
|
122
124
|
|
|
123
125
|
## Why zames?
|
|
124
126
|
|
|
125
|
-
- **No API key, no per-token bill** — it uses your own DeepSeek chat
|
|
126
|
-
not the paid API. Good for long, tool-heavy tasks.
|
|
127
|
+
- **No API key, no per-token bill** — it uses your own DeepSeek or Qwen chat
|
|
128
|
+
account, not the paid API. Good for long, tool-heavy tasks.
|
|
127
129
|
- **Same workflow as Claude Code / Codex** — tools, `AGENTS.md`, skills, MCP
|
|
128
130
|
and slash commands, so it feels familiar from day one.
|
|
129
131
|
- **Runs unattended** — headless by default, resumable sessions, and `/loop`
|
|
@@ -143,11 +145,11 @@ Key pieces:
|
|
|
143
145
|
|
|
144
146
|
## Signing in
|
|
145
147
|
|
|
146
|
-
The browser runs **headless by default**. When
|
|
147
|
-
zames:
|
|
148
|
+
The browser runs **headless by default**. When the active backend requires a
|
|
149
|
+
sign-in, zames:
|
|
148
150
|
|
|
149
|
-
1. reuses the session stored in the persistent profile (`~/.zames/profile`
|
|
150
|
-
if it is still valid;
|
|
151
|
+
1. reuses the session stored in the persistent profile (`~/.zames/profile` for
|
|
152
|
+
DeepSeek, `~/.zames/qwen-profile` for Qwen) if it is still valid;
|
|
151
153
|
2. otherwise signs in automatically with the saved credentials
|
|
152
154
|
(`browser.auth.username` / `browser.auth.password`);
|
|
153
155
|
3. otherwise asks you for the login/password in the terminal (in a TTY) and,
|
|
@@ -165,7 +167,7 @@ The `--headless` flag (the default) and `headless: true` in the config keep
|
|
|
165
167
|
the browser without a window; `--headed` / `headless: false` show it.
|
|
166
168
|
|
|
167
169
|
Headless works out of the box: a headless Chrome normally advertises a
|
|
168
|
-
`HeadlessChrome/...` User-Agent that
|
|
170
|
+
`HeadlessChrome/...` User-Agent that the site's CDN blocks with a 403, so
|
|
169
171
|
zames strips that marker before loading the page (keeping the real engine
|
|
170
172
|
version). You do not need `--headed` just to log in.
|
|
171
173
|
|
|
@@ -233,7 +235,7 @@ The agent works inside the directory it was started in and cannot leave it (sand
|
|
|
233
235
|
While the agent is working you can keep typing: press Enter to queue a message
|
|
234
236
|
(it is sent right after the current task, in the same chat), or Esc / Ctrl+C to
|
|
235
237
|
abort the current generation. This mirrors typing during generation on the
|
|
236
|
-
|
|
238
|
+
web chat.
|
|
237
239
|
|
|
238
240
|
### Input line
|
|
239
241
|
|
|
@@ -261,8 +263,8 @@ You can paste an image or a file into the input line (Ctrl+Shift+V / Shift+Inser
|
|
|
261
263
|
or the terminal's own paste). zames saves it under `<project>/tmp` and shows a
|
|
262
264
|
marker in the line - `[image#1]` for images, `[file#1]` for other files - then
|
|
263
265
|
uploads the real file to the chat together with your message. These are the
|
|
264
|
-
same formats the DeepSeek web chat accepts (PNG, JPEG, GIF, WEBP, BMP,
|
|
265
|
-
the usual document types).
|
|
266
|
+
same formats the DeepSeek / Qwen web chat accepts (PNG, JPEG, GIF, WEBP, BMP,
|
|
267
|
+
SVG and the usual document types).
|
|
266
268
|
|
|
267
269
|
Pasting an image works when the terminal sends it as a `data:` URL or as a
|
|
268
270
|
base64 blob with a recognizable image signature. You can also paste a path to a
|
|
@@ -369,7 +371,7 @@ Codex CLI:
|
|
|
369
371
|
(Write/Edit/MultiEdit/ApplyPatch/Bash, GitAdd/GitCommit/GitPush) are removed
|
|
370
372
|
from the tool set, so the agent can investigate without touching the tree.
|
|
371
373
|
Start in it with `--plan`.
|
|
372
|
-
- /compact — ask
|
|
374
|
+
- /compact — ask the active backend to compress the current chat into a handover
|
|
373
375
|
summary, then open a NEW chat, resend the system prompt and post the summary
|
|
374
376
|
as the carried-over context. Use it when the context gets long.
|
|
375
377
|
- /goal [text|clear] — set a long-lived session goal. It is prepended to every
|
|
@@ -505,31 +507,34 @@ Agent data is stored in `~/.zames`: browser profile, logs, undo history, session
|
|
|
505
507
|
|
|
506
508
|
## FAQ
|
|
507
509
|
|
|
508
|
-
**Is this an official DeepSeek product?**
|
|
509
|
-
No. zames drives the public chat.deepseek.com web
|
|
510
|
-
like a regular user. It is not affiliated with DeepSeek
|
|
510
|
+
**Is this an official DeepSeek or Qwen product?**
|
|
511
|
+
No. zames drives the public `chat.deepseek.com` / `chat.qwen.ai` web UIs through
|
|
512
|
+
a real browser, like a regular user. It is not affiliated with DeepSeek or
|
|
513
|
+
Alibaba.
|
|
511
514
|
|
|
512
515
|
**Do I need an API key?**
|
|
513
|
-
No. You sign in with your own
|
|
514
|
-
`~/.zames/profile` and
|
|
516
|
+
No. You sign in with your own account on the chat you use — DeepSeek (stored in
|
|
517
|
+
`~/.zames/profile`) or Qwen (stored in `~/.zames/qwen-profile`) — and the session
|
|
518
|
+
is reused. Qwen also works in guest mode for a few messages a day.
|
|
515
519
|
|
|
516
520
|
**Which model does it use?**
|
|
517
|
-
Whichever the
|
|
518
|
-
"Deep thinking" on).
|
|
521
|
+
Whichever the active web chat uses. On DeepSeek that is DeepSeek-V3 (or the
|
|
522
|
+
reasoning model with "Deep thinking" on). On Qwen you can pick a model with
|
|
523
|
+
`/model` (e.g. `qwen3.7-plus`). zames never calls the API directly.
|
|
519
524
|
|
|
520
525
|
**Does it work headless / on a server?**
|
|
521
526
|
Yes — headless is the default, and `--output-format jsonl` makes it scriptable.
|
|
522
527
|
`--headed` is only needed for a manual sign-in or selector debugging.
|
|
523
528
|
|
|
524
529
|
**Is automating the web UI allowed?**
|
|
525
|
-
That depends on
|
|
526
|
-
your own risk and keep the send throttle (
|
|
527
|
-
the rate limit.
|
|
530
|
+
That depends on the site's terms; automating a website may violate them. Use at
|
|
531
|
+
your own risk and keep the send throttle (15 s on DeepSeek by default) so you do
|
|
532
|
+
not hammer the rate limit.
|
|
528
533
|
|
|
529
534
|
**How is it different from Claude Code / Codex?**
|
|
530
535
|
Same shape (tools, `AGENTS.md`, skills, MCP, slash commands) but it runs on your
|
|
531
|
-
DeepSeek account instead of an API, as a browser automation rather than
|
|
532
|
-
first-party API client.
|
|
536
|
+
DeepSeek or Qwen account instead of an API, as a browser automation rather than
|
|
537
|
+
a first-party API client.
|
|
533
538
|
|
|
534
539
|
## Links
|
|
535
540
|
|
|
@@ -554,7 +559,7 @@ MIT
|
|
|
554
559
|
По духу он похож на Claude Code / Codex CLI: запускается в текущей
|
|
555
560
|
директории, читает и правит файлы, выполняет команды и коммитит в git.
|
|
556
561
|
|
|
557
|
-
> API-ключ не нужен — агент управляет веб-чатом DeepSeek как обычный
|
|
562
|
+
> API-ключ не нужен — агент управляет веб-чатом DeepSeek или Qwen как обычный
|
|
558
563
|
> пользователь через настоящий (headless) браузер.
|
|
559
564
|
|
|
560
565
|
<p align="center">
|
|
@@ -570,11 +575,12 @@ zames # один раз войти, дальше прост
|
|
|
570
575
|
```
|
|
571
576
|
|
|
572
577
|
Здесь **нет API-ключа и нет оплаты за токены**: zames входит в **ваш
|
|
573
|
-
собственный аккаунт [chat.deepseek.com](https://chat.deepseek.com/)
|
|
574
|
-
настоящем (headless) Chromium и
|
|
575
|
-
При первом запуске он один раз
|
|
576
|
-
|
|
577
|
-
|
|
578
|
+
собственный аккаунт [chat.deepseek.com](https://chat.deepseek.com/) или
|
|
579
|
+
[chat.qwen.ai](https://chat.qwen.ai/)** в настоящем (headless) Chromium и
|
|
580
|
+
управляет веб-чатом как обычный пользователь. При первом запуске он один раз
|
|
581
|
+
спросит логин (сессия сохраняется в `~/.zames/profile` для DeepSeek или
|
|
582
|
+
`~/.zames/qwen-profile` для Qwen); после этого достаточно запустить `zames` и
|
|
583
|
+
написать задачу обычным языком:
|
|
578
584
|
|
|
579
585
|
```text
|
|
580
586
|
❯ отрефактори загрузчик конфига и добавь тест на новый дефолт
|
|
@@ -654,7 +660,8 @@ zames не вызывает API модели. Он запускает headless C
|
|
|
654
660
|
## Почему zames?
|
|
655
661
|
|
|
656
662
|
- **Нет API-ключа и счёта за токены** — используется ваш собственный аккаунт
|
|
657
|
-
чата DeepSeek, а не платный API. Удобно для долгих задач с
|
|
663
|
+
чата DeepSeek или Qwen, а не платный API. Удобно для долгих задач с
|
|
664
|
+
инструментами.
|
|
658
665
|
- **Тот же рабочий процесс, что в Claude Code / Codex** — инструменты,
|
|
659
666
|
`AGENTS.md`, навыки, MCP и слэш-команды, так что всё знакомо с первого дня.
|
|
660
667
|
- **Работает без присмотра** — headless по умолчанию, возобновляемые сессии,
|
|
@@ -674,11 +681,11 @@ zames не вызывает API модели. Он запускает headless C
|
|
|
674
681
|
|
|
675
682
|
## Вход в аккаунт
|
|
676
683
|
|
|
677
|
-
Браузер по умолчанию работает **headless**. Когда
|
|
678
|
-
zames:
|
|
684
|
+
Браузер по умолчанию работает **headless**. Когда активный бэкенд требует
|
|
685
|
+
входа, zames:
|
|
679
686
|
|
|
680
|
-
1. переиспользует сессию из постоянного профиля (`~/.zames/profile`
|
|
681
|
-
ещё валидна;
|
|
687
|
+
1. переиспользует сессию из постоянного профиля (`~/.zames/profile` для
|
|
688
|
+
DeepSeek, `~/.zames/qwen-profile` для Qwen), если она ещё валидна;
|
|
682
689
|
2. иначе входит автоматически по сохранённым учётным данным
|
|
683
690
|
(`browser.auth.username` / `browser.auth.password`);
|
|
684
691
|
3. иначе спрашивает логин/пароль в терминале (в TTY) и после успешного входа
|
|
@@ -696,7 +703,7 @@ zames --headed
|
|
|
696
703
|
браузер без окна; `--headed` / `headless: false` показывают его.
|
|
697
704
|
|
|
698
705
|
Headless работает из коробки: headless Chrome обычно представляется как
|
|
699
|
-
`HeadlessChrome/...`, и CDN
|
|
706
|
+
`HeadlessChrome/...`, и CDN сайта блокирует такой User-Agent ответом 403,
|
|
700
707
|
поэтому zames срезает этот маркер перед загрузкой страницы (сохраняя реальную
|
|
701
708
|
версию движка). `--headed` нужен не только ради входа.
|
|
702
709
|
|
|
@@ -762,7 +769,7 @@ zames
|
|
|
762
769
|
Пока агент работает, можно продолжать печатать: Enter ставит сообщение в
|
|
763
770
|
очередь (оно уйдёт сразу после текущей задачи, в тот же чат), а Esc / Ctrl+C
|
|
764
771
|
прерывают текущую генерацию. Это повторяет набор текста во время генерации на
|
|
765
|
-
сайте
|
|
772
|
+
сайте чата.
|
|
766
773
|
|
|
767
774
|
### Строка ввода
|
|
768
775
|
|
|
@@ -794,8 +801,8 @@ zames
|
|
|
794
801
|
или собственной вставкой терминала). zames сохраняет его в `<project>/tmp` и
|
|
795
802
|
показывает маркер в строке — `[image#1]` для картинок, `[file#1]` для прочих
|
|
796
803
|
файлов — затем загружает реальный файл в чат вместе с вашим сообщением. Это те
|
|
797
|
-
же форматы, что принимает веб-чат DeepSeek (PNG, JPEG, GIF, WEBP, BMP,
|
|
798
|
-
обычные типы документов).
|
|
804
|
+
же форматы, что принимает веб-чат DeepSeek / Qwen (PNG, JPEG, GIF, WEBP, BMP,
|
|
805
|
+
SVG и обычные типы документов).
|
|
799
806
|
|
|
800
807
|
Вставка картинки работает, когда терминал передаёт её как `data:` URL или как
|
|
801
808
|
base64-блок с узнаваемой сигнатурой изображения. Можно также вставить путь к
|
|
@@ -904,8 +911,8 @@ zames --task "..." --output-format jsonl \
|
|
|
904
911
|
инструменты (Write/Edit/MultiEdit/ApplyPatch/Bash, GitAdd/GitCommit/GitPush)
|
|
905
912
|
убраны из набора, чтобы агент изучал код, не трогая дерево. Стартовать в нём
|
|
906
913
|
можно с `--plan`.
|
|
907
|
-
- /compact — попросить
|
|
908
|
-
открыть НОВЫЙ чат, заново отправить системный промпт и положить резюме как
|
|
914
|
+
- /compact — попросить активный бэкенд сжать текущий чат в передаточное резюме,
|
|
915
|
+
затем открыть НОВЫЙ чат, заново отправить системный промпт и положить резюме как
|
|
909
916
|
перенесённый контекст. Используйте, когда контекст разрастается.
|
|
910
917
|
- /goal [text|clear] — задать долгоживущую цель сессии. Она добавляется в
|
|
911
918
|
начало каждого сообщения задачи, поэтому модель держит общую картину через
|
|
@@ -1044,31 +1051,35 @@ enum переключаются на месте; числа и строки от
|
|
|
1044
1051
|
|
|
1045
1052
|
## FAQ
|
|
1046
1053
|
|
|
1047
|
-
**Это официальный продукт DeepSeek?**
|
|
1048
|
-
Нет. zames управляет
|
|
1049
|
-
настоящий браузер, как обычный пользователь. Он не связан
|
|
1054
|
+
**Это официальный продукт DeepSeek или Qwen?**
|
|
1055
|
+
Нет. zames управляет публичными веб-интерфейсами `chat.deepseek.com` /
|
|
1056
|
+
`chat.qwen.ai` через настоящий браузер, как обычный пользователь. Он не связан
|
|
1057
|
+
ни с DeepSeek, ни с Alibaba.
|
|
1050
1058
|
|
|
1051
1059
|
**Нужен ли API-ключ?**
|
|
1052
|
-
Нет. Вы один раз входите в свой аккаунт DeepSeek
|
|
1053
|
-
`~/.zames/profile` и
|
|
1060
|
+
Нет. Вы один раз входите в свой аккаунт на нужном чате — DeepSeek (хранится в
|
|
1061
|
+
`~/.zames/profile`) или Qwen (хранится в `~/.zames/qwen-profile`) — и сессия
|
|
1062
|
+
переиспользуется. Qwen также работает в гостевом режиме на несколько сообщений.
|
|
1054
1063
|
|
|
1055
1064
|
**Какую модель он использует?**
|
|
1056
|
-
Ту, которую использует
|
|
1057
|
-
включённом «Deep thinking»).
|
|
1065
|
+
Ту, которую использует активный веб-чат. В DeepSeek это DeepSeek-V3 (или
|
|
1066
|
+
reasoning-модель при включённом «Deep thinking»). В Qwen модель можно выбрать
|
|
1067
|
+
командой `/model` (например, `qwen3.7-plus`). zames никогда не вызывает API
|
|
1068
|
+
напрямую.
|
|
1058
1069
|
|
|
1059
1070
|
**Работает ли headless / на сервере?**
|
|
1060
1071
|
Да — headless по умолчанию, а `--output-format jsonl` делает его скриптуемым.
|
|
1061
1072
|
`--headed` нужен только для ручного входа или отладки селекторов.
|
|
1062
1073
|
|
|
1063
1074
|
**Разрешена ли автоматизация веб-интерфейса?**
|
|
1064
|
-
Это зависит от условий
|
|
1065
|
-
Используйте на свой риск и держите троттлинг отправки (15 с по
|
|
1066
|
-
чтобы не бить по рейт-лимиту.
|
|
1075
|
+
Это зависит от условий сайта; автоматизация сайта может их нарушать.
|
|
1076
|
+
Используйте на свой риск и держите троттлинг отправки (15 с в DeepSeek по
|
|
1077
|
+
умолчанию), чтобы не бить по рейт-лимиту.
|
|
1067
1078
|
|
|
1068
1079
|
**Чем отличается от Claude Code / Codex?**
|
|
1069
1080
|
Та же форма (инструменты, `AGENTS.md`, навыки, MCP, слэш-команды), но работает
|
|
1070
|
-
на вашем аккаунте DeepSeek вместо API, как автоматизация браузера, а
|
|
1071
|
-
first-party API-клиент.
|
|
1081
|
+
на вашем аккаунте DeepSeek или Qwen вместо API, как автоматизация браузера, а
|
|
1082
|
+
не first-party API-клиент.
|
|
1072
1083
|
|
|
1073
1084
|
## Ссылки
|
|
1074
1085
|
|
package/dist/attachments.js
CHANGED
|
@@ -197,18 +197,22 @@ export function looksLikeFileName(name) {
|
|
|
197
197
|
export async function saveToTemp(tmpDir, name, data) {
|
|
198
198
|
await fs.mkdir(tmpDir, { recursive: true });
|
|
199
199
|
const base = sanitize(name);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
200
|
+
const ext = path.extname(base);
|
|
201
|
+
const stem = base.slice(0, base.length - ext.length);
|
|
202
|
+
// O_EXCL ('wx'): pick the first free name and CREATE it atomically. A
|
|
203
|
+
// stat-then-write raced with another attach and silently overwrote a file.
|
|
204
|
+
for (let i = 0; i < 10_000; i++) {
|
|
205
|
+
const target = path.join(tmpDir, i === 0 ? base : stem + '-' + i + ext);
|
|
206
|
+
try {
|
|
207
|
+
await fs.writeFile(target, data, { flag: 'wx' });
|
|
208
|
+
return target;
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
if (e.code !== 'EEXIST')
|
|
212
|
+
throw e;
|
|
208
213
|
}
|
|
209
214
|
}
|
|
210
|
-
|
|
211
|
-
return target;
|
|
215
|
+
throw new Error('could not find a free temp name for ' + base);
|
|
212
216
|
}
|
|
213
217
|
// True if any OS clipboard tool is available on this machine.
|
|
214
218
|
export function hasClipboardTool() {
|
package/dist/checkpoint.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'fs/promises';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import { execFile } from 'child_process';
|
|
5
|
+
import { randomBytes } from 'crypto';
|
|
5
6
|
import { writeJsonAtomic } from './fsutil.js';
|
|
6
7
|
// Checkpoints / rewind (BACKLOG B3).
|
|
7
8
|
//
|
|
@@ -120,7 +121,11 @@ export class CheckpointStore {
|
|
|
120
121
|
return null;
|
|
121
122
|
await this._ensure();
|
|
122
123
|
const stamp = Date.now();
|
|
123
|
-
|
|
124
|
+
// Two checkpoints in the SAME millisecond would share `${stamp}.tgz` and
|
|
125
|
+
// the second would overwrite the first (losing a snapshot). Add a random
|
|
126
|
+
// suffix so an archive name is unique.
|
|
127
|
+
const uniq = randomBytes(3).toString('hex');
|
|
128
|
+
const file = path.join(this.dir, `${stamp}-${uniq}.tgz`);
|
|
124
129
|
const res = await runTar(tarCreateArgs(file, workdir), workdir);
|
|
125
130
|
if (res.code !== 0) {
|
|
126
131
|
await fs.rm(file, { force: true }).catch(() => { });
|
|
@@ -187,7 +192,19 @@ export class CheckpointStore {
|
|
|
187
192
|
.rm(path.join(record.workdir, e), { recursive: true, force: true })
|
|
188
193
|
.catch(() => { });
|
|
189
194
|
}
|
|
190
|
-
|
|
195
|
+
// A copy failure here must NOT surface as an uncaught throw that leaves
|
|
196
|
+
// the (already wiped) tree half-restored: report it as a failure so the
|
|
197
|
+
// caller can tell the operator to /rewind from the pre-rewind backup.
|
|
198
|
+
try {
|
|
199
|
+
await copyTree(tmp, record.workdir);
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
reason: 'copy_failed',
|
|
205
|
+
detail: e.message,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
191
208
|
return { ok: true, record, backup };
|
|
192
209
|
}
|
|
193
210
|
finally {
|
package/dist/config.js
CHANGED
|
@@ -119,6 +119,12 @@ export function providerAskDeadlineMs(cfg) {
|
|
|
119
119
|
}
|
|
120
120
|
function deepMerge(target, source) {
|
|
121
121
|
for (const key of Object.keys(source)) {
|
|
122
|
+
// A `.zamesrc.json` (possibly shared/copied from elsewhere) could carry a
|
|
123
|
+
// `__proto__` / `constructor` / `prototype` key; assigning those to a
|
|
124
|
+
// plain object mutates Object.prototype (prototype pollution). Skip them.
|
|
125
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
122
128
|
if (source[key] &&
|
|
123
129
|
typeof source[key] === 'object' &&
|
|
124
130
|
!Array.isArray(source[key])) {
|
package/dist/context.js
CHANGED
|
@@ -211,7 +211,17 @@ export function skillBody(raw) {
|
|
|
211
211
|
const end = norm.indexOf(NL + '---', 3);
|
|
212
212
|
if (end === -1)
|
|
213
213
|
return norm.trim();
|
|
214
|
-
|
|
214
|
+
// The closing `---` may be the LAST line with no trailing newline; then
|
|
215
|
+
// indexOf(NL, …) is -1 and slice(0) returned the WHOLE file (frontmatter
|
|
216
|
+
// included). Guard the -1 -> the body is everything after the closing ---.
|
|
217
|
+
const after = norm.indexOf(NL, end + 1);
|
|
218
|
+
if (after === -1) {
|
|
219
|
+
// No newline after the closing delimiter: the body is empty (or just the
|
|
220
|
+
// rest of that final line, which IS the `---`).
|
|
221
|
+
const rest = norm.slice(end + 1);
|
|
222
|
+
return rest.replace(/^-*$/, '').trim();
|
|
223
|
+
}
|
|
224
|
+
return norm.slice(after + 1).trim();
|
|
215
225
|
}
|
|
216
226
|
async function findSkillFiles(root, depth = 3) {
|
|
217
227
|
const out = [];
|
|
@@ -500,11 +510,17 @@ async function loadScopedAgents(workdir, touchPaths) {
|
|
|
500
510
|
const out = [];
|
|
501
511
|
const seen = new Set();
|
|
502
512
|
for (const dir of dirs) {
|
|
513
|
+
// AGENTS.md is head-clipped; MEMORY.md is append-only so it uses the
|
|
514
|
+
// memory budget and keeps its TAIL (same rule as the main memory chain) —
|
|
515
|
+
// otherwise the newest notes in a large nested MEMORY.md were lost.
|
|
503
516
|
for (const names of [AGENT_NAMES, MEMORY_NAMES]) {
|
|
517
|
+
const isMemory = names === MEMORY_NAMES;
|
|
504
518
|
const p = await findNamedFile(dir, names);
|
|
505
519
|
if (!p || seen.has(p))
|
|
506
520
|
continue;
|
|
507
|
-
const f =
|
|
521
|
+
const f = isMemory
|
|
522
|
+
? await readIfFile(p, MAX_MEMORY_FILE, true)
|
|
523
|
+
: await readIfFile(p, MAX_FILE_BASE);
|
|
508
524
|
if (!f)
|
|
509
525
|
continue;
|
|
510
526
|
seen.add(p);
|
package/dist/gitTools.js
CHANGED
|
@@ -150,7 +150,11 @@ export function createGitTools(workdir) {
|
|
|
150
150
|
const err = await ensureRepo();
|
|
151
151
|
if (err)
|
|
152
152
|
return err;
|
|
153
|
-
|
|
153
|
+
// Guard the count: `Number(count)||10` let -3 and Infinity through as
|
|
154
|
+
// literal `-n -3` / `-n Infinity`. Keep a positive integer only.
|
|
155
|
+
const n = Math.trunc(Number(count));
|
|
156
|
+
const lim = Number.isFinite(n) && n > 0 ? n : 10;
|
|
157
|
+
return runGitArgs(['log', '--oneline', '--decorate', '-n', String(lim)], workdir, 10_000);
|
|
154
158
|
},
|
|
155
159
|
},
|
|
156
160
|
{
|
|
@@ -238,14 +242,17 @@ export function createGitTools(workdir) {
|
|
|
238
242
|
const fs = await import('fs/promises');
|
|
239
243
|
const path = await import('path');
|
|
240
244
|
const os = await import('os');
|
|
241
|
-
|
|
242
|
-
|
|
245
|
+
// O_EXCL ('wx') so a pre-created symlink at this predictable path
|
|
246
|
+
// cannot redirect the write to another file (TOCTOU on a shared /tmp).
|
|
247
|
+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'zames-commit-'));
|
|
248
|
+
const tmp = path.join(tmpDir, 'msg.txt');
|
|
249
|
+
await fs.writeFile(tmp, msg, { encoding: 'utf-8', flag: 'wx' });
|
|
243
250
|
try {
|
|
244
251
|
const result = await runGitArgs(['commit', '-F', tmp], workdir, 30_000);
|
|
245
252
|
return `add: ${addResult}\ncommit: ${result}`;
|
|
246
253
|
}
|
|
247
254
|
finally {
|
|
248
|
-
await fs.
|
|
255
|
+
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => { });
|
|
249
256
|
}
|
|
250
257
|
},
|
|
251
258
|
},
|
|
@@ -269,6 +276,15 @@ export function createGitTools(workdir) {
|
|
|
269
276
|
if (!/^[A-Za-z0-9._/-]+$/.test(b) || b.startsWith('-')) {
|
|
270
277
|
return `Error: invalid branch name: ${b}`;
|
|
271
278
|
}
|
|
279
|
+
// The regex above allows `..` and `@{`, which git parses as a REFSPEC
|
|
280
|
+
// RANGE (`git push origin a..b`), not a branch. Ask git itself so the
|
|
281
|
+
// name is valid by construction.
|
|
282
|
+
const refCheck = await runGitArgs(['check-ref-format', '--branch', b], workdir, 5000);
|
|
283
|
+
// On success git echoes the branch name; on failure it prints an
|
|
284
|
+
// `Exit code: <n>` / `fatal:` block instead.
|
|
285
|
+
if (refCheck.trim() !== b) {
|
|
286
|
+
return `Error: invalid branch name: ${b}`;
|
|
287
|
+
}
|
|
272
288
|
const args = ['push'];
|
|
273
289
|
if (setUpstream)
|
|
274
290
|
args.push('-u');
|
package/dist/index.js
CHANGED
|
@@ -85,7 +85,13 @@ const headless = hasFlag('--headed')
|
|
|
85
85
|
: hasFlag('--headless') || config.headless;
|
|
86
86
|
const debug = hasFlag('--debug') || config.debug;
|
|
87
87
|
const maxIterArg = getArg('--max-iter', null);
|
|
88
|
-
|
|
88
|
+
// A non-numeric `--max-iter abc` yields NaN, which the loop treats as
|
|
89
|
+
// "unlimited" (NaN > 0 is false). Fall back to the configured value instead
|
|
90
|
+
// of silently disabling the cap.
|
|
91
|
+
const maxIterParsed = maxIterArg !== null ? Number(maxIterArg) : NaN;
|
|
92
|
+
const maxIter = Number.isFinite(maxIterParsed)
|
|
93
|
+
? maxIterParsed
|
|
94
|
+
: config.maxIterations;
|
|
89
95
|
const positional = getPositional();
|
|
90
96
|
const task = getArg('--task', positional.join(' ').trim() || null);
|
|
91
97
|
const chatIdArg = getArg('--chat', null);
|
package/dist/input.js
CHANGED
|
@@ -892,28 +892,17 @@ export class LineEditor {
|
|
|
892
892
|
}
|
|
893
893
|
const cols = process.stdout.columns || 80;
|
|
894
894
|
const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols);
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
895
|
+
// Reuse the SAME windowed+highlighted suggestion lines as _writeBlock.
|
|
896
|
+
// Before, this used a hardcoded `sugg.slice(0, 8)`, so navigating past
|
|
897
|
+
// page 1 (Ctrl+N/P) left the repaint showing the first page without the
|
|
898
|
+
// highlight, and linesBelow drifted from the real row count.
|
|
899
|
+
const sug = this._suggestionLines(this._suggestOffset);
|
|
900
|
+
const linesBelow = sug.rows;
|
|
901
901
|
// Move up to the INPUT top using the OLD cursor row (on screen right now).
|
|
902
902
|
this._eraseInputOnly(this.cursorRowFromTop - this._statusTop);
|
|
903
903
|
let out = lay.rows.map((r) => r.prefix + r.text).join(NL);
|
|
904
|
-
if (
|
|
905
|
-
|
|
906
|
-
const lines = shown.map((c) => {
|
|
907
|
-
const name = theme.prompt(c.name.padEnd(maxName));
|
|
908
|
-
const desc = theme.dim(' ' + c.description);
|
|
909
|
-
return ' ' + name + desc;
|
|
910
|
-
});
|
|
911
|
-
out += NL + lines.join(NL);
|
|
912
|
-
const hidden = sugg.length - shown.length;
|
|
913
|
-
if (hidden > 0)
|
|
914
|
-
out +=
|
|
915
|
-
NL +
|
|
916
|
-
theme.dim(' ' + translate(this.locale)('editor.more', { n: hidden }));
|
|
904
|
+
if (sug.lines.length) {
|
|
905
|
+
out += NL + sug.lines.join(NL);
|
|
917
906
|
}
|
|
918
907
|
// A buffer change can UNWRAP the input (backspace on a wrapped line), so
|
|
919
908
|
// the block can get shorter here too — push it back to the bottom BEFORE
|
package/dist/mcp.js
CHANGED
|
@@ -44,9 +44,9 @@ export function hardenPlaywrightArgs(command, args) {
|
|
|
44
44
|
const hay = (String(command || '') + ' ' + args.join(' ')).toLowerCase();
|
|
45
45
|
if (!hay.includes('playwright') || !hay.includes('mcp'))
|
|
46
46
|
return args;
|
|
47
|
-
if (args.some((a) => a
|
|
47
|
+
if (args.some((a) => a.startsWith('--isolated') ||
|
|
48
48
|
a.startsWith('--user-data-dir') ||
|
|
49
|
-
a
|
|
49
|
+
a.startsWith('--extension') ||
|
|
50
50
|
a.startsWith('--cdp-endpoint'))) {
|
|
51
51
|
return args;
|
|
52
52
|
}
|
|
@@ -292,10 +292,15 @@ export function dedupeToolNames(tools) {
|
|
|
292
292
|
}
|
|
293
293
|
// Connect to ONE server and return a ready connection (or an error).
|
|
294
294
|
async function connectServer(name, cfg, debug) {
|
|
295
|
+
// Declared OUTSIDE the try so the catch can close a half-open server: a
|
|
296
|
+
// stdio transport SPAWNS a child process on connect, and if connect() or
|
|
297
|
+
// listTools() throws we would otherwise leak an orphan process on every run.
|
|
298
|
+
let client = null;
|
|
299
|
+
let transport = null;
|
|
295
300
|
try {
|
|
296
301
|
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
297
|
-
const
|
|
298
|
-
|
|
302
|
+
const c = new Client({ name: 'zames', version: packageVersion() }, { capabilities: {} });
|
|
303
|
+
client = c;
|
|
299
304
|
if (cfg.url) {
|
|
300
305
|
if (cfg.transport === 'sse') {
|
|
301
306
|
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
|
|
@@ -320,8 +325,8 @@ async function connectServer(name, cfg, debug) {
|
|
|
320
325
|
stderr: 'pipe',
|
|
321
326
|
});
|
|
322
327
|
}
|
|
323
|
-
await
|
|
324
|
-
const listed = await
|
|
328
|
+
await c.connect(transport);
|
|
329
|
+
const listed = await c.listTools();
|
|
325
330
|
const tools = (listed.tools || []).map((t) => {
|
|
326
331
|
const tool = t;
|
|
327
332
|
return {
|
|
@@ -331,11 +336,22 @@ async function connectServer(name, cfg, debug) {
|
|
|
331
336
|
};
|
|
332
337
|
});
|
|
333
338
|
debug?.(`MCP: connected to ${name} (${tools.length} tools)`);
|
|
334
|
-
return { name, client, tools };
|
|
339
|
+
return { name, client: c, tools };
|
|
335
340
|
}
|
|
336
341
|
catch (e) {
|
|
337
342
|
const msg = e.message || String(e);
|
|
338
343
|
debug?.(`MCP: failed to connect to ${name}: ${msg}`);
|
|
344
|
+
// Close a half-open connection so a spawned stdio child is not orphaned.
|
|
345
|
+
try {
|
|
346
|
+
if (client)
|
|
347
|
+
await client.close().catch(() => { });
|
|
348
|
+
}
|
|
349
|
+
catch { }
|
|
350
|
+
try {
|
|
351
|
+
if (transport?.close)
|
|
352
|
+
await Promise.resolve(transport.close()).catch(() => { });
|
|
353
|
+
}
|
|
354
|
+
catch { }
|
|
339
355
|
return { error: msg };
|
|
340
356
|
}
|
|
341
357
|
}
|
package/dist/sessions.js
CHANGED
|
@@ -98,11 +98,16 @@ function writeIndex({ id, workdir }) {
|
|
|
98
98
|
if (!index.byWorkdir || typeof index.byWorkdir !== 'object') {
|
|
99
99
|
index.byWorkdir = {};
|
|
100
100
|
}
|
|
101
|
+
// An EMPTY workdir must not become a byWorkdir key: it would make every
|
|
102
|
+
// project fall back to `index.last` and restore a chat from ANOTHER
|
|
103
|
+
// project on the next launch. Only track a real directory.
|
|
101
104
|
const key = workdir || '';
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
if (key) {
|
|
106
|
+
index.byWorkdir[key] = id;
|
|
107
|
+
index.last = id;
|
|
108
|
+
index.updatedAt = new Date().toISOString();
|
|
109
|
+
writeJsonAtomic(INDEX_FILE, index);
|
|
110
|
+
}
|
|
106
111
|
}
|
|
107
112
|
catch { }
|
|
108
113
|
}
|
package/dist/shell.js
CHANGED
|
@@ -23,6 +23,15 @@ export function assertCommandInsideRoot(root, command) {
|
|
|
23
23
|
const raw = m[1].trim().replace(/^["']|["']$/g, '');
|
|
24
24
|
if (!raw || raw === '-')
|
|
25
25
|
continue;
|
|
26
|
+
// A `cd` target we cannot RESOLVE statically is rejected: the shell would
|
|
27
|
+
// expand it AFTER this scan, so `d=/; cd $d` would otherwise slip past as
|
|
28
|
+
// root/$d. `$HOME`/`${HOME}` is the one variable we CAN expand (below), so
|
|
29
|
+
// it is allowed; any OTHER `$var`, a backtick substitution or `~user` is
|
|
30
|
+
// refused outright.
|
|
31
|
+
const withoutHome = raw.replace(/\$\{?HOME\}?/g, '');
|
|
32
|
+
if (/[$`]/.test(withoutHome) || /^~[^/\\]/.test(raw)) {
|
|
33
|
+
throw new Error('Sandbox: cannot verify cd target ' + raw + ' (variable/substitution)');
|
|
34
|
+
}
|
|
26
35
|
// sh expands ~ and $HOME AFTER this check, so expand them here as well —
|
|
27
36
|
// otherwise `cd ~` passes the guard and then leaves the root.
|
|
28
37
|
const expanded = raw
|
package/dist/transcript.js
CHANGED
|
@@ -64,10 +64,10 @@ export class Transcript {
|
|
|
64
64
|
if (!stream)
|
|
65
65
|
return Promise.resolve();
|
|
66
66
|
return new Promise((resolve) => {
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
// in the constructor.
|
|
67
|
+
// Attach the error handler BEFORE end(): an error emitted synchronously
|
|
68
|
+
// by end() would otherwise reach no listener on this tick.
|
|
70
69
|
stream.on('error', () => resolve());
|
|
70
|
+
stream.end(() => resolve());
|
|
71
71
|
});
|
|
72
72
|
}
|
|
73
73
|
}
|
package/dist/undo.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import { randomBytes } from 'crypto';
|
|
4
5
|
import { writeJsonAtomic } from './fsutil.js';
|
|
5
6
|
const UNDO_DIR = path.join(os.homedir(), '.zames', 'undo');
|
|
6
7
|
const INDEX = path.join(UNDO_DIR, 'index.json');
|
|
@@ -44,7 +45,12 @@ export class UndoStore {
|
|
|
44
45
|
const hash = Buffer.from(filePath).toString('hex').slice(0, 16);
|
|
45
46
|
let backupFile = null;
|
|
46
47
|
if (existed) {
|
|
47
|
-
|
|
48
|
+
// The stamp is only ms-precision and the hash is the first 8 bytes of
|
|
49
|
+
// the path, so two SIBLING files edited in the same millisecond would
|
|
50
|
+
// collide and the second write would clobber the first (losing the
|
|
51
|
+
// backup). Add a random suffix so a backup name is unique.
|
|
52
|
+
const uniq = randomBytes(4).toString('hex');
|
|
53
|
+
backupFile = path.join(UNDO_DIR, `${stamp}-${hash}-${uniq}.bak`);
|
|
48
54
|
await fs.writeFile(backupFile, content);
|
|
49
55
|
}
|
|
50
56
|
const history = await this._readIndex();
|
package/dist/web.js
CHANGED
|
@@ -71,12 +71,34 @@ export function isPrivateIp(ip) {
|
|
|
71
71
|
const low = ip.toLowerCase();
|
|
72
72
|
if (low === '::1' || low === '::')
|
|
73
73
|
return true;
|
|
74
|
-
|
|
74
|
+
// IPv6 link-local is fe80::/10, i.e. fe80..febf (the old `startsWith('fe80')`
|
|
75
|
+
// missed fe90::, fea0:: and febf::). ULA is fc00::/7 (fc..fd). fec0::/10
|
|
76
|
+
// (deprecated site-local) is also treated as non-public.
|
|
77
|
+
if (/^fe[89abcde]/.test(low) ||
|
|
78
|
+
low.startsWith('fc') ||
|
|
79
|
+
low.startsWith('fd')) {
|
|
75
80
|
return true;
|
|
76
81
|
}
|
|
82
|
+
// IPv4-mapped in DOTTED form (::ffff:127.0.0.1).
|
|
77
83
|
const mapped = low.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
|
|
78
84
|
if (mapped)
|
|
79
85
|
return isPrivateIp(mapped[1]);
|
|
86
|
+
// IPv4-mapped in HEX form (::ffff:7f00:1 == 127.0.0.1) and the NAT64
|
|
87
|
+
// well-known prefix 64:ff9b::/96 (which can encode 127.0.0.1 too).
|
|
88
|
+
const mappedHex = low.match(/^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
89
|
+
if (mappedHex) {
|
|
90
|
+
const hi = parseInt(mappedHex[1], 16);
|
|
91
|
+
const lo = parseInt(mappedHex[2], 16);
|
|
92
|
+
return isPrivateIp(((hi >> 8) & 0xff) +
|
|
93
|
+
'.' +
|
|
94
|
+
(hi & 0xff) +
|
|
95
|
+
'.' +
|
|
96
|
+
((lo >> 8) & 0xff) +
|
|
97
|
+
'.' +
|
|
98
|
+
(lo & 0xff));
|
|
99
|
+
}
|
|
100
|
+
if (low.startsWith('64:ff9b:'))
|
|
101
|
+
return true;
|
|
80
102
|
return false;
|
|
81
103
|
}
|
|
82
104
|
export function isPrivateHostname(host) {
|
|
@@ -87,6 +109,13 @@ export function isPrivateHostname(host) {
|
|
|
87
109
|
return true;
|
|
88
110
|
if (h === 'metadata.google.internal')
|
|
89
111
|
return true;
|
|
112
|
+
// Internal/mDNS/service names that never belong to the public internet.
|
|
113
|
+
if (h.endsWith('.internal') || h === 'internal')
|
|
114
|
+
return true;
|
|
115
|
+
if (h.endsWith('.local') || h === 'local')
|
|
116
|
+
return true;
|
|
117
|
+
if (h.endsWith('.home.arpa'))
|
|
118
|
+
return true;
|
|
90
119
|
return false;
|
|
91
120
|
}
|
|
92
121
|
export async function assertPublicUrl(rawUrl) {
|
|
@@ -191,6 +220,10 @@ export async function httpFetch(url, { timeout = DEFAULT_TIMEOUT, headers = {},
|
|
|
191
220
|
const next = new URL(loc, current).toString();
|
|
192
221
|
// Validate the TARGET before requesting it.
|
|
193
222
|
await assertPublicUrl(next);
|
|
223
|
+
// Drain/close the redirect body: otherwise each hop leaks a socket
|
|
224
|
+
// (up to MAX_REDIRECTS per fetch), which exhausts the pool on a long
|
|
225
|
+
// series of fetches.
|
|
226
|
+
await res.body?.cancel().catch(() => { });
|
|
194
227
|
current = next;
|
|
195
228
|
continue;
|
|
196
229
|
}
|
|
@@ -310,7 +343,7 @@ export function createWebTools() {
|
|
|
310
343
|
if (!/^https?:\/\//i.test(String(url))) {
|
|
311
344
|
return `Error: URL must start with http:// or https://`;
|
|
312
345
|
}
|
|
313
|
-
const limit = Math.min(Number(maxChars) || MAX_TEXT, 60_000);
|
|
346
|
+
const limit = Math.max(1, Math.min(Number(maxChars) || MAX_TEXT, 60_000));
|
|
314
347
|
try {
|
|
315
348
|
await assertPublicUrl(String(url));
|
|
316
349
|
if (render) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zames_pro",
|
|
3
|
-
"version": "2.73.
|
|
3
|
+
"version": "2.73.4",
|
|
4
4
|
"description": "Terminal coding agent that drives a real browser to chat with DeepSeek, Qwen and other web LLMs — no API key required. Reads and edits files, runs commands, commits to git, like Claude Code / Codex CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// Render docs/how-it-works.png — the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
1
|
+
// Render docs/how-it-works.png — the "How it works" flowchart used in
|
|
2
|
+
// README.md. Reproduces the ORIGINAL mermaid `flowchart LR` look (rounded
|
|
3
|
+
// lavender nodes, gray arrows, white background, a simple left-to-right
|
|
4
|
+
// chain) so the diagram stays visually the same as before. The ONLY change:
|
|
5
|
+
// the model node now shows BOTH supported backends (chat.deepseek.com /
|
|
6
|
+
// chat.qwen.ai) instead of only chat.deepseek.com.
|
|
7
7
|
//
|
|
8
8
|
// Run: npm run render-how (tsx scripts/render-how-it-works.mts)
|
|
9
9
|
//
|
|
10
|
-
// Isolation: chromium.launch() uses a THROWAWAY
|
|
10
|
+
// Isolation: chromium.launch() uses a THROWAWAY context, never
|
|
11
11
|
// ~/.zames/profile (see AGENTS.md — never touch the live agent's browser).
|
|
12
12
|
|
|
13
13
|
import { chromium } from 'playwright'
|
|
@@ -17,103 +17,165 @@ import { fileURLToPath } from 'node:url'
|
|
|
17
17
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
18
18
|
const out = path.join(root, 'docs', 'how-it-works.png')
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
20
|
+
// Mermaid default-theme palette (matches the previous diagram).
|
|
21
|
+
const FILL = '#ECECFF'
|
|
22
|
+
const STROKE = '#9370DB'
|
|
23
|
+
const TEXT = '#333333'
|
|
24
|
+
const FONT = 'trebuchet ms, verdana, arial, sans-serif'
|
|
25
|
+
|
|
26
|
+
const W = 1120
|
|
27
|
+
const H = 150
|
|
28
|
+
const NODE_W = 250
|
|
29
|
+
const NODE_H = 70
|
|
30
|
+
const CY = H / 2
|
|
31
|
+
const PAD = 30
|
|
32
|
+
|
|
33
|
+
// A left-to-right chain with the SAME three steps as the original diagram;
|
|
34
|
+
// only the last node gained a second line (the two supported backends).
|
|
35
|
+
interface Node {
|
|
36
|
+
x: number
|
|
37
|
+
w: number
|
|
38
|
+
lines: string[]
|
|
31
39
|
}
|
|
32
40
|
|
|
33
|
-
const
|
|
34
|
-
|
|
41
|
+
const nodes: Node[] = [
|
|
42
|
+
{ x: PAD, w: NODE_W, lines: ['terminal (you)'] },
|
|
43
|
+
{
|
|
44
|
+
x: PAD + NODE_W + 150,
|
|
45
|
+
w: 260,
|
|
46
|
+
lines: ['zames', 'parse tool-call · run tool'],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
x: PAD + NODE_W + 150 + 260 + 150,
|
|
50
|
+
w: 260,
|
|
51
|
+
lines: ['chat.deepseek.com', 'chat.qwen.ai'],
|
|
52
|
+
},
|
|
53
|
+
]
|
|
35
54
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
sub: string,
|
|
39
|
-
color: string,
|
|
40
|
-
): string =>
|
|
41
|
-
'<div class="box" style="border-color:' +
|
|
42
|
-
color +
|
|
43
|
-
'">' +
|
|
44
|
-
'<div class="box-t" style="color:' +
|
|
45
|
-
color +
|
|
46
|
-
'">' +
|
|
47
|
-
title +
|
|
48
|
-
'</div>' +
|
|
49
|
-
'<div class="box-s">' +
|
|
50
|
-
sub +
|
|
51
|
-
'</div></div>'
|
|
55
|
+
const esc = (s: string): string =>
|
|
56
|
+
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
52
57
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
const nodeSvg = (n: Node): string => {
|
|
59
|
+
const y = CY - NODE_H / 2
|
|
60
|
+
const rx = 12
|
|
61
|
+
const lh = 20
|
|
62
|
+
const startY = CY - ((n.lines.length - 1) * lh) / 2 + 5
|
|
63
|
+
const cx = n.x + n.w / 2
|
|
64
|
+
const texts = n.lines
|
|
65
|
+
.map(
|
|
66
|
+
(t, i) =>
|
|
67
|
+
'<text x="' +
|
|
68
|
+
cx +
|
|
69
|
+
'" y="' +
|
|
70
|
+
(startY + i * lh) +
|
|
71
|
+
'" text-anchor="middle" font-family="' +
|
|
72
|
+
FONT +
|
|
73
|
+
'" font-size="15" fill="' +
|
|
74
|
+
TEXT +
|
|
75
|
+
'">' +
|
|
76
|
+
esc(t) +
|
|
77
|
+
'</text>',
|
|
78
|
+
)
|
|
79
|
+
.join('')
|
|
80
|
+
return (
|
|
81
|
+
'<rect x="' +
|
|
82
|
+
n.x +
|
|
83
|
+
'" y="' +
|
|
84
|
+
y +
|
|
85
|
+
'" width="' +
|
|
86
|
+
n.w +
|
|
87
|
+
'" height="' +
|
|
88
|
+
NODE_H +
|
|
89
|
+
'" rx="' +
|
|
90
|
+
rx +
|
|
91
|
+
'" ry="' +
|
|
92
|
+
rx +
|
|
93
|
+
'" fill="' +
|
|
94
|
+
FILL +
|
|
95
|
+
'" stroke="' +
|
|
96
|
+
STROKE +
|
|
97
|
+
'" stroke-width="2"/>' +
|
|
98
|
+
texts
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A horizontal edge from x1 to x2 at height y, with a centered label above it.
|
|
103
|
+
const edge = (
|
|
104
|
+
x1: number,
|
|
105
|
+
x2: number,
|
|
106
|
+
y: number,
|
|
107
|
+
label: string,
|
|
108
|
+
dir: 'r' | 'l',
|
|
109
|
+
): string => {
|
|
110
|
+
const mid = (x1 + x2) / 2
|
|
111
|
+
const marker = dir === 'r' ? 'ar' : 'al'
|
|
112
|
+
return (
|
|
113
|
+
'<path d="M' +
|
|
114
|
+
x1 +
|
|
115
|
+
' ' +
|
|
116
|
+
y +
|
|
117
|
+
' L' +
|
|
118
|
+
x2 +
|
|
119
|
+
' ' +
|
|
120
|
+
y +
|
|
121
|
+
'" fill="none" stroke="' +
|
|
122
|
+
TEXT +
|
|
123
|
+
'" stroke-width="1.6" marker-end="url(#' +
|
|
124
|
+
marker +
|
|
125
|
+
')"/>' +
|
|
126
|
+
'<text x="' +
|
|
127
|
+
mid +
|
|
128
|
+
'" y="' +
|
|
129
|
+
(y - 8) +
|
|
130
|
+
'" text-anchor="middle" font-family="' +
|
|
131
|
+
FONT +
|
|
132
|
+
'" font-size="12" fill="' +
|
|
133
|
+
TEXT +
|
|
134
|
+
'">' +
|
|
135
|
+
esc(label) +
|
|
136
|
+
'</text>'
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const a = nodes[0]
|
|
141
|
+
const z = nodes[1]
|
|
142
|
+
const m = nodes[2]
|
|
143
|
+
|
|
144
|
+
const reqY = CY - 16
|
|
145
|
+
const ansY = CY + 20
|
|
146
|
+
|
|
147
|
+
const svg =
|
|
148
|
+
'<svg xmlns="http://www.w3.org/2000/svg" width="' +
|
|
149
|
+
W +
|
|
150
|
+
'" height="' +
|
|
151
|
+
H +
|
|
152
|
+
'" viewBox="0 0 ' +
|
|
63
153
|
W +
|
|
64
|
-
'
|
|
154
|
+
' ' +
|
|
65
155
|
H +
|
|
66
|
-
'
|
|
67
|
-
'
|
|
68
|
-
'
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
'
|
|
72
|
-
|
|
73
|
-
'
|
|
74
|
-
|
|
75
|
-
'
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
'
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
'
|
|
82
|
-
|
|
83
|
-
'
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
'
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
'}' +
|
|
90
|
-
'</style></head><body><div class="wrap">' +
|
|
91
|
-
'<div class="row">' +
|
|
92
|
-
// left: the operator
|
|
93
|
-
'<div class="col">' +
|
|
94
|
-
box('Operator', 'terminal task<br/>+ tool approval', C.user) +
|
|
95
|
-
'<div class="flow-label">types a task</div>' +
|
|
96
|
-
'</div>' +
|
|
97
|
-
'<div class="arrow">→</div>' +
|
|
98
|
-
// middle: zames
|
|
99
|
-
'<div class="col">' +
|
|
100
|
-
box(
|
|
101
|
-
'zames',
|
|
102
|
-
'sandboxed tools<br/>parses tool-calls<br/>runs them locally',
|
|
103
|
-
C.accent,
|
|
104
|
-
) +
|
|
105
|
-
'<div class="flow-label">system prompt + task</div>' +
|
|
106
|
-
'</div>' +
|
|
107
|
-
'<div class="arrow">⇄</div>' +
|
|
108
|
-
// right: the two backends
|
|
109
|
-
'<div class="providers">' +
|
|
110
|
-
box('DeepSeek', 'chat.deepseek.com<br/>~/.zames/profile', C.ds) +
|
|
111
|
-
box('Qwen', 'chat.qwen.ai<br/>~/.zames/qwen-profile', C.qwen) +
|
|
112
|
-
'</div>' +
|
|
113
|
-
'</div>' +
|
|
114
|
-
'<div class="legend">zames drives a <b>real browser</b> over a persistent per-provider profile — no API key. ' +
|
|
115
|
-
'Switch live with <b>/provider <deepseek|qwen></b> (each chat id belongs to one backend).</div>' +
|
|
116
|
-
'</div></body></html>'
|
|
156
|
+
'">' +
|
|
157
|
+
'<defs>' +
|
|
158
|
+
'<marker id="ar" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M0,0 L9,3 L0,6 z" fill="' +
|
|
159
|
+
TEXT +
|
|
160
|
+
'"/></marker>' +
|
|
161
|
+
'<marker id="al" markerWidth="10" markerHeight="10" refX="1" refY="3" orient="auto" markerUnits="strokeWidth"><path d="M9,0 L0,3 L9,6 z" fill="' +
|
|
162
|
+
TEXT +
|
|
163
|
+
'"/></marker>' +
|
|
164
|
+
'</defs>' +
|
|
165
|
+
'<rect width="100%" height="100%" fill="#ffffff"/>' +
|
|
166
|
+
// request edges (top, left -> right)
|
|
167
|
+
edge(a.x + a.w, z.x, reqY, 'task', 'r') +
|
|
168
|
+
edge(z.x + z.w, m.x, reqY, 'system prompt + task', 'r') +
|
|
169
|
+
// answer edges (bottom, right -> left)
|
|
170
|
+
edge(m.x, z.x + z.w, ansY, 'model answers (raw SSE)', 'l') +
|
|
171
|
+
edge(z.x, a.x + a.w, ansY, 'answer', 'l') +
|
|
172
|
+
nodes.map(nodeSvg).join('') +
|
|
173
|
+
'</svg>'
|
|
174
|
+
|
|
175
|
+
const html =
|
|
176
|
+
'<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;padding:0;background:#fff}svg{display:block}</style></head><body>' +
|
|
177
|
+
svg +
|
|
178
|
+
'</body></html>'
|
|
117
179
|
|
|
118
180
|
const browser = await chromium.launch({ headless: true })
|
|
119
181
|
try {
|
|
@@ -122,7 +184,8 @@ try {
|
|
|
122
184
|
deviceScaleFactor: 2,
|
|
123
185
|
})
|
|
124
186
|
await page.setContent(html, { waitUntil: 'load' })
|
|
125
|
-
await page
|
|
187
|
+
const el = await page.$('svg')
|
|
188
|
+
await (el || page).screenshot({ path: out })
|
|
126
189
|
console.log('wrote', path.relative(root, out))
|
|
127
190
|
} finally {
|
|
128
191
|
await browser.close()
|