zames_pro 2.61.0 → 2.63.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 +63 -2
- package/README.md +62 -13
- package/dist/agent-loop.js +54 -2
- package/dist/checkpoint.js +197 -0
- package/dist/commands.js +135 -0
- package/dist/config.js +20 -0
- package/dist/context.js +88 -1
- package/dist/i18n.js +89 -0
- package/dist/index.js +232 -12
- package/dist/input.js +3 -2
- package/dist/path-token.js +75 -0
- package/dist/permissions.js +98 -0
- package/dist/system-prompt.js +15 -0
- package/logo-small.jpg +0 -0
- package/package.json +3 -2
- package/scripts/coverage-gate.mjs +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,67 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [2.63.0] - 2026-10-06
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Реальные подтверждения действий (BACKLOG C1): политика approval в
|
|
13
|
+
`.zames/permissions.json` (`default` + `rules` с regexp по `tool`/`command`/
|
|
14
|
+
`path` и действием `allow`|`deny`|`ask`). Вызывается в `agent-loop.ts` ПЕРЕД
|
|
15
|
+
каждым инструментом: `deny` блокирует вызов, `ask` спрашивает оператора через
|
|
16
|
+
`onAskPermission` (в TTY — интерактивный промпт над инпут-линией, в non-TTY —
|
|
17
|
+
запрет). Новый чистый модуль `src/permissions.ts` + тесты
|
|
18
|
+
(test/permissions.test.ts, test/permissions-loop.test.ts).
|
|
19
|
+
|
|
20
|
+
- Кастомные команды: аргументы и подсказки (BACKLOG B7). Frontmatter
|
|
21
|
+
`argument-hint:` показывается в списке «/» и в `/help` (не вставляется в
|
|
22
|
+
строку ввода), а `arguments:` объявляет обязательные позиционные аргументы.
|
|
23
|
+
В теле команды подставляются `$1 $2`, именованные `$name`, а также прежние
|
|
24
|
+
`{{args}}`/`$ARGUMENTS`. Если обязательные аргументы не переданы — команда
|
|
25
|
+
не уходит в чат, печатается подсказка. Новые чистые хелперы
|
|
26
|
+
`splitCommandArgs()` / `expandCommandArgs()` / `missingCommandArgs()`
|
|
27
|
+
(src/commands.ts, покрыты тестами).
|
|
28
|
+
|
|
29
|
+
- Path-scoped правила (BACKLOG B6): вложенные `AGENTS.md`/`MEMORY.md` из
|
|
30
|
+
подпапок, которых касается задача, подтягиваются автоматически. Текст задачи
|
|
31
|
+
сканируется на path-токены, для найденных директорий (и их предков ниже
|
|
32
|
+
рабочей) читаются ближайшие инструкции и рендерятся отдельной секцией
|
|
33
|
+
`## Scoped instructions (...)` — явно помечены как действующие только для
|
|
34
|
+
этих файлов. `loadProjectContext(workdir, touchPaths?)` и
|
|
35
|
+
`renderContextSection()` (src/context.ts, src/system-prompt.ts; покрыто
|
|
36
|
+
тестами).
|
|
37
|
+
|
|
38
|
+
- `@file`-ссылки в задаче (BACKLOG B5): `реши задачу @src/browser.ts`
|
|
39
|
+
подставляет содержимое указанного файла прямо в задачу, экономя отдельный
|
|
40
|
+
ход агента на чтение. Распознаётся `@path` на границе слова (в начале строки
|
|
41
|
+
или после пробела/скобки/кавычки), с расширением файла; `user@host` и
|
|
42
|
+
декораторы (`@Component`) не трогаются. Существующие файлы инлайнятся
|
|
43
|
+
(лимит 60 КБ на файл, 200 КБ суммарно — сверх этого усечение с пометкой),
|
|
44
|
+
несуществующие остаются как есть. Хелпер `extractAtFileRefs()` в
|
|
45
|
+
`src/path-token.ts` (чистый, покрыт тестами).
|
|
46
|
+
|
|
47
|
+
### Changed
|
|
48
|
+
|
|
49
|
+
- `AGENTS.md` уменьшен (BACKLOG C2): глубокие root-cause разборы («агент
|
|
50
|
+
остановился», чтение ответа из DOM, send-хуки, `LineEditor`, вложения) и
|
|
51
|
+
терминальная механика вынесены в `docs/DESIGN-NOTES.md` (progressive
|
|
52
|
+
disclosure — не грузится в каждую задачу). В AGENTS.md остались действующие
|
|
53
|
+
правила и краткая выжимка со ссылкой.
|
|
54
|
+
|
|
55
|
+
## [2.62.0]
|
|
56
|
+
|
|
57
|
+
### Added
|
|
58
|
+
|
|
59
|
+
- Checkpoints / rewind (BACKLOG B3): at the start of every task the working
|
|
60
|
+
tree is snapshotted into a tarball under `~/.zames/checkpoints/`, and
|
|
61
|
+
`/rewind [n]` rolls it back in one step (with a confirmation prompt and an
|
|
62
|
+
automatic `pre-rewind` backup of the current state, so the rewind itself is
|
|
63
|
+
reversible). `/rewind-list` shows the recent checkpoints. `node_modules`,
|
|
64
|
+
`.git`, `dist` and `tmp` are never snapshotted or deleted. Unlike a manual
|
|
65
|
+
git stash, this works in a non-git directory and never touches the
|
|
66
|
+
operator's index. New module `src/checkpoint.ts` (pure helpers + `CheckpointStore`,
|
|
67
|
+
unit-tested); configurable via `checkpoint.enabled` / `checkpoint.maxBackups`
|
|
68
|
+
(default 50) in `/config`.
|
|
9
69
|
|
|
10
70
|
## [2.61.0]
|
|
11
71
|
|
|
@@ -249,7 +309,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
249
309
|
- Session banner warns when no saved DeepSeek session exists.
|
|
250
310
|
- `--no-color` flag (explicit `NO_COLOR`).
|
|
251
311
|
|
|
252
|
-
[Unreleased]: https://github.com/Viqto0r/zames_pro/compare/v2.
|
|
312
|
+
[Unreleased]: https://github.com/Viqto0r/zames_pro/compare/v2.63.0...HEAD
|
|
313
|
+
[2.63.0]: https://github.com/Viqto0r/zames_pro/compare/v2.61.0...v2.63.0
|
|
253
314
|
[2.61.0]: https://github.com/Viqto0r/zames_pro/compare/v2.60.0...v2.61.0
|
|
254
315
|
[2.60.0]: https://github.com/Viqto0r/zames_pro/compare/v2.59.0...v2.60.0
|
|
255
316
|
[2.59.0]: https://github.com/Viqto0r/zames_pro/compare/v2.58.0...v2.59.0
|
package/README.md
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/Viqto0r/zames_pro/master/logo-small.jpg" alt="zames logo" width="180">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
[](https://www.npmjs.com/package/zames_pro)
|
|
5
|
-
[](https://github.com/Viqto0r/zames_pro/actions/workflows/test.yml)
|
|
6
|
-
[](LICENSE)
|
|
7
|
-
[](package.json)
|
|
5
|
+
<h1 align="center">zames_pro</h1>
|
|
8
6
|
|
|
9
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>A terminal coding agent that drives chat.deepseek.com through Playwright — no API key required.</strong>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://www.npmjs.com/package/zames_pro"><img src="https://img.shields.io/npm/v/zames_pro.svg" alt="npm version"></a>
|
|
13
|
+
<a href="https://www.npmjs.com/package/zames_pro"><img src="https://img.shields.io/npm/dm/zames_pro.svg" alt="npm downloads"></a>
|
|
14
|
+
<a href="https://github.com/Viqto0r/zames_pro/actions/workflows/test.yml"><img src="https://github.com/Viqto0r/zames_pro/actions/workflows/test.yml/badge.svg" alt="tests"></a>
|
|
15
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a>
|
|
16
|
+
<a href="package.json"><img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg" alt="Node.js"></a>
|
|
17
|
+
</p>
|
|
10
18
|
|
|
11
19
|
A terminal coding agent that works on top of [chat.deepseek.com](https://chat.deepseek.com/) through Playwright.
|
|
12
20
|
In spirit it is similar to Claude Code / Codex CLI: it starts in the current
|
|
@@ -15,6 +23,44 @@ directory, reads and edits files, runs commands, and commits to git.
|
|
|
15
23
|
> No API key required — it drives the DeepSeek web chat like a regular user
|
|
16
24
|
> through a real (headless) browser.
|
|
17
25
|
|
|
26
|
+
## How it works
|
|
27
|
+
|
|
28
|
+
zames does not call the model API. It launches a headless Chromium with a
|
|
29
|
+
persistent profile, signs in to `chat.deepseek.com` like a human, types the task
|
|
30
|
+
into the chat box, and reads the answer back.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
terminal (you) zames chat.deepseek.com
|
|
34
|
+
─────────────── ───── ─────────────────
|
|
35
|
+
task ──────────────────────▶ system prompt + task ────────▶ browser types it
|
|
36
|
+
▲ │
|
|
37
|
+
│ model answers
|
|
38
|
+
│ │
|
|
39
|
+
answer ◀──── render ◀───── parse tool-call ◀──── raw answer ◀────┘
|
|
40
|
+
│
|
|
41
|
+
▼
|
|
42
|
+
run tool (Read/Edit/Bash/…) → feed result back
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Key pieces:
|
|
46
|
+
|
|
47
|
+
- **The answer is read from the raw network stream** (SSE), not the rendered
|
|
48
|
+
DOM, so tool-call JSON with template strings and escapes survives intact.
|
|
49
|
+
- **A persistent profile** (`~/.zames/profile`) keeps you signed in; the
|
|
50
|
+
headless User-Agent is patched so DeepSeek's CDN does not 403 the login.
|
|
51
|
+
- **A send throttle** (15 s by default) keeps the web chat's rate limit happy
|
|
52
|
+
during long tool-heavy runs.
|
|
53
|
+
- **The agent is sandboxed** to the directory it was started in — no tool can
|
|
54
|
+
read or write above it.
|
|
55
|
+
|
|
56
|
+
## Table of contents
|
|
57
|
+
|
|
58
|
+
- [Features](#features) · [Why zames?](#why-zames) · [Requirements](#requirements)
|
|
59
|
+
- [How it works](#how-it-works) · [Installation](#installation) · [Signing in](#signing-in) · [Usage](#usage)
|
|
60
|
+
- [Tools](#tools) · [Slash commands](#slash-commands)
|
|
61
|
+
- [Project context, skills and memory](#project-context-skills-and-memory) · [MCP (external tools)](#mcp-external-tools) · [Configuration](#configuration)
|
|
62
|
+
- [FAQ](#faq) · [Links](#links) · [License](#license)
|
|
63
|
+
|
|
18
64
|
## Features
|
|
19
65
|
|
|
20
66
|
- **Tools like Claude Code / Codex** — `Read`, `Write`, `Edit`, `Bash`,
|
|
@@ -82,12 +128,6 @@ version). You do not need `--headed` just to log in.
|
|
|
82
128
|
Credentials and toggles can also be edited from `/config`
|
|
83
129
|
(`browser.auth.username`, `browser.auth.password`, `browser.auth.saveSession`).
|
|
84
130
|
|
|
85
|
-
## Links
|
|
86
|
-
|
|
87
|
-
- npm: <https://www.npmjs.com/package/zames_pro>
|
|
88
|
-
- Changelog: [`CHANGELOG.md`](CHANGELOG.md)
|
|
89
|
-
- Security policy: [`SECURITY.md`](SECURITY.md)
|
|
90
|
-
|
|
91
131
|
## Installation
|
|
92
132
|
|
|
93
133
|
```bash
|
|
@@ -406,6 +446,15 @@ Same shape (tools, `AGENTS.md`, skills, MCP, slash commands) but it runs on your
|
|
|
406
446
|
DeepSeek account instead of an API, as a browser automation rather than a
|
|
407
447
|
first-party API client.
|
|
408
448
|
|
|
449
|
+
## Links
|
|
450
|
+
|
|
451
|
+
- **npm:** <https://www.npmjs.com/package/zames_pro>
|
|
452
|
+
- **GitHub:** <https://github.com/Viqto0r/zames_pro>
|
|
453
|
+
- **Changelog:** [`CHANGELOG.md`](CHANGELOG.md)
|
|
454
|
+
- **Contributing:** [`CONTRIBUTING.md`](CONTRIBUTING.md)
|
|
455
|
+
- **Security policy:** [`SECURITY.md`](SECURITY.md)
|
|
456
|
+
- **Code of conduct:** [`CODE_OF_CONDUCT.md`](CODE_OF_CONDUCT.md)
|
|
457
|
+
|
|
409
458
|
## License
|
|
410
459
|
|
|
411
460
|
MIT
|
package/dist/agent-loop.js
CHANGED
|
@@ -6,12 +6,15 @@ import { translate } from './i18n.js';
|
|
|
6
6
|
import { substituteAttachmentMarkers } from './commands.js';
|
|
7
7
|
import { normText } from './browser.js';
|
|
8
8
|
import { loadHooks, runPreToolUse, runPostToolUse, } from './hooks.js';
|
|
9
|
-
|
|
9
|
+
import { loadPermissions, decidePermission, } from './permissions.js';
|
|
10
|
+
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, hooks = undefined, permissions = undefined, onAskPermission = undefined, selfImprovement = false, }) {
|
|
10
11
|
// Resolve the hook config ONCE per task: a read per tool call would be
|
|
11
12
|
// wasteful, and a mid-task edit of hooks.json is not something to chase.
|
|
12
13
|
// `undefined` means "read .zames/hooks.json"; an explicit null disables
|
|
13
14
|
// hooks entirely.
|
|
14
15
|
const hookConfig = hooks === undefined ? loadHooks(workdir) : (hooks ?? {});
|
|
16
|
+
// Same for the approval policy (C1): read `.zames/permissions.json` once.
|
|
17
|
+
const permissionPolicy = permissions === undefined ? loadPermissions(workdir) : permissions;
|
|
15
18
|
// UI callbacks must NEVER break the agent loop. A rendering error (a huge
|
|
16
19
|
// tool result, a broken markdown frame, a closed terminal) used to throw
|
|
17
20
|
// out of the loop right after a tool call — the session looked "stopped
|
|
@@ -91,7 +94,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
91
94
|
}
|
|
92
95
|
let context;
|
|
93
96
|
try {
|
|
94
|
-
|
|
97
|
+
// Pass the task text so nested AGENTS.md/MEMORY.md for the directories
|
|
98
|
+
// this task actually touches are pulled in (B6, path-scoped rules).
|
|
99
|
+
context = await loadProjectContext(workdir, [task]);
|
|
95
100
|
}
|
|
96
101
|
catch {
|
|
97
102
|
context = null;
|
|
@@ -108,6 +113,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
108
113
|
length: systemPrompt.length,
|
|
109
114
|
gitContext: gitText,
|
|
110
115
|
agents: context?.agents.map((f) => f.path) ?? [],
|
|
116
|
+
scopedAgents: context?.scopedAgents?.map((f) => f.path) ?? [],
|
|
111
117
|
memory: context?.memory.map((f) => f.path) ?? [],
|
|
112
118
|
skills: context?.skills.map((s) => s.name) ?? [],
|
|
113
119
|
commands: context?.commands.map((c) => c.name) ?? [],
|
|
@@ -727,6 +733,47 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
727
733
|
results.push({ tool: call.tool, result: blocked });
|
|
728
734
|
continue;
|
|
729
735
|
}
|
|
736
|
+
// Approval policy (C1): a `deny` rule blocks the call (like a PreToolUse
|
|
737
|
+
// denial); an `ask` rule prompts the operator. Hooks stay authoritative
|
|
738
|
+
// for programmatic guards — this is the human-in-the-loop layer.
|
|
739
|
+
const decision = decidePermission(permissionPolicy, call.tool, call.args);
|
|
740
|
+
if (decision.action === 'deny') {
|
|
741
|
+
const blocked = `Blocked by permission policy: ${decision.reason}`;
|
|
742
|
+
transcript?.log('permission_deny', {
|
|
743
|
+
tool: call.tool,
|
|
744
|
+
reason: decision.reason,
|
|
745
|
+
});
|
|
746
|
+
safeToolResult(blocked);
|
|
747
|
+
transcript?.log('tool_result', {
|
|
748
|
+
tool: call.tool,
|
|
749
|
+
result: blocked,
|
|
750
|
+
});
|
|
751
|
+
results.push({ tool: call.tool, result: blocked });
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
if (decision.action === 'ask' && onAskPermission) {
|
|
755
|
+
let allowed;
|
|
756
|
+
try {
|
|
757
|
+
allowed = await onAskPermission({ ...decision, tool: call.tool });
|
|
758
|
+
}
|
|
759
|
+
catch {
|
|
760
|
+
allowed = false;
|
|
761
|
+
}
|
|
762
|
+
if (!allowed) {
|
|
763
|
+
const blocked = `Denied by operator: ${decision.reason}`;
|
|
764
|
+
transcript?.log('permission_denied', {
|
|
765
|
+
tool: call.tool,
|
|
766
|
+
reason: decision.reason,
|
|
767
|
+
});
|
|
768
|
+
safeToolResult(blocked);
|
|
769
|
+
transcript?.log('tool_result', {
|
|
770
|
+
tool: call.tool,
|
|
771
|
+
result: blocked,
|
|
772
|
+
});
|
|
773
|
+
results.push({ tool: call.tool, result: blocked });
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
730
777
|
let result;
|
|
731
778
|
// While the tool runs, poll for an Esc/Ctrl+C: the abort flag is a plain
|
|
732
779
|
// boolean set by stopGeneration(), so the only way to turn it into a
|
|
@@ -735,6 +782,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
735
782
|
const poll = setInterval(syncToolAbort, 100);
|
|
736
783
|
if (typeof poll.unref === 'function')
|
|
737
784
|
poll.unref();
|
|
785
|
+
// Time the tool (T-D3): the transcript carries durationMs so /cost can
|
|
786
|
+
// show where the time goes (frequent Read→Edit cycles vs slow Bash).
|
|
787
|
+
const toolStart = Date.now();
|
|
738
788
|
try {
|
|
739
789
|
result = await tool.fn(call.args, { signal: toolAbort.signal });
|
|
740
790
|
}
|
|
@@ -744,6 +794,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
744
794
|
finally {
|
|
745
795
|
clearInterval(poll);
|
|
746
796
|
}
|
|
797
|
+
const toolMs = Date.now() - toolStart;
|
|
747
798
|
// PostToolUse hooks run AFTER the tool; their stdout is appended to the
|
|
748
799
|
// result (e.g. `prettier` output) before it is fed back to the model.
|
|
749
800
|
// Best-effort: a hook failure is ignored, the tool result still stands.
|
|
@@ -759,6 +810,7 @@ ${post}`;
|
|
|
759
810
|
transcript?.log('tool_result', {
|
|
760
811
|
tool: call.tool,
|
|
761
812
|
result: String(result),
|
|
813
|
+
durationMs: toolMs,
|
|
762
814
|
});
|
|
763
815
|
results.push({ tool: call.tool, result });
|
|
764
816
|
// The operator pressed Esc/Ctrl+C while the tool was running. The tool
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import fs from 'fs/promises';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { execFile } from 'child_process';
|
|
5
|
+
import { writeJsonAtomic } from './fsutil.js';
|
|
6
|
+
// Checkpoints / rewind (BACKLOG B3).
|
|
7
|
+
//
|
|
8
|
+
// Undo (src/undo.ts) reverts ONE file write. A checkpoint snapshots the WHOLE
|
|
9
|
+
// working tree at the start of a task, so a risky task can be rolled back with
|
|
10
|
+
// a single `/rewind`. A tarball — not a git stash — is used on purpose: it
|
|
11
|
+
// works in a non-git directory and never touches the operator's index/stash.
|
|
12
|
+
//
|
|
13
|
+
// Machine codes only ('not_found', 'archive_missing', ...): the caller
|
|
14
|
+
// (src/index.ts) localizes them through the i18n catalog, exactly like
|
|
15
|
+
// undo.ts. Keeping prose out of this module is also why no Russian leaks here
|
|
16
|
+
// (see test/agent-facing-english.test.ts).
|
|
17
|
+
const CHECKPOINT_DIR = path.join(os.homedir(), '.zames', 'checkpoints');
|
|
18
|
+
// Never snapshotted and never deleted on restore: dependencies, the git object
|
|
19
|
+
// store (the operator's own safety net), build output and the agent's scratch
|
|
20
|
+
// dir. Everything else is part of the snapshot. Patterns are anchored to the
|
|
21
|
+
// archive root ('./x') so a nested `src/tmp` is NOT accidentally excluded.
|
|
22
|
+
export const CHECKPOINT_EXCLUDES = ['node_modules', '.git', 'dist', 'tmp'];
|
|
23
|
+
// ---------- pure helpers (unit-tested without tar) ----------
|
|
24
|
+
export function tarCreateArgs(archive, _dir) {
|
|
25
|
+
const args = ['-czf', archive];
|
|
26
|
+
for (const e of CHECKPOINT_EXCLUDES)
|
|
27
|
+
args.push('--exclude=./' + e);
|
|
28
|
+
args.push('-C', _dir, '.');
|
|
29
|
+
return args;
|
|
30
|
+
}
|
|
31
|
+
export function tarExtractArgs(archive, dir) {
|
|
32
|
+
return ['-xzf', archive, '-C', dir];
|
|
33
|
+
}
|
|
34
|
+
/** Keep only the newest `max` records (returns a new array; order preserved). */
|
|
35
|
+
export function rotateRecords(records, max) {
|
|
36
|
+
if (max <= 0)
|
|
37
|
+
return [];
|
|
38
|
+
if (records.length <= max)
|
|
39
|
+
return records.slice();
|
|
40
|
+
return records.slice(records.length - max);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a `/rewind` selector against records sorted NEWEST FIRST.
|
|
44
|
+
* `1` = most recent; a non-numeric selector is matched as a stamp prefix.
|
|
45
|
+
*/
|
|
46
|
+
export function pickRecord(records, selector) {
|
|
47
|
+
const sel = String(selector ?? '').trim();
|
|
48
|
+
if (!sel)
|
|
49
|
+
return null;
|
|
50
|
+
if (/^\d+$/.test(sel)) {
|
|
51
|
+
const n = Number(sel);
|
|
52
|
+
// A small number is an index (1 = newest). An out-of-range number is
|
|
53
|
+
// still tried as a stamp prefix below, so `/rewind 1730000` works.
|
|
54
|
+
if (n >= 1 && n <= records.length)
|
|
55
|
+
return records[n - 1];
|
|
56
|
+
}
|
|
57
|
+
return records.find((r) => String(r.stamp).startsWith(sel)) || null;
|
|
58
|
+
}
|
|
59
|
+
// ---------- tar / fs plumbing ----------
|
|
60
|
+
function runTar(args, cwd, timeout = 60_000) {
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
execFile('tar', args, { cwd, timeout, maxBuffer: 1024 * 1024 * 8, windowsHide: true }, (err, _stdout, stderr) => {
|
|
63
|
+
if (!err)
|
|
64
|
+
return resolve({ code: 0, stderr: '' });
|
|
65
|
+
const raw = err.code;
|
|
66
|
+
const code = typeof raw === 'number' ? raw : 127;
|
|
67
|
+
resolve({ code, stderr: String(stderr || err.message || '') });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async function copyTree(from, to) {
|
|
72
|
+
await fs.mkdir(to, { recursive: true });
|
|
73
|
+
const entries = await fs.readdir(from, { withFileTypes: true });
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
const src = path.join(from, e.name);
|
|
76
|
+
const dst = path.join(to, e.name);
|
|
77
|
+
if (e.isDirectory())
|
|
78
|
+
await copyTree(src, dst);
|
|
79
|
+
else if (e.isSymbolicLink()) {
|
|
80
|
+
const link = await fs.readlink(src).catch(() => null);
|
|
81
|
+
if (link !== null)
|
|
82
|
+
await fs.symlink(link, dst).catch(() => { });
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
await fs.copyFile(src, dst);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// ---------- store ----------
|
|
89
|
+
export class CheckpointStore {
|
|
90
|
+
enabled;
|
|
91
|
+
maxBackups;
|
|
92
|
+
dir;
|
|
93
|
+
constructor({ enabled = true, maxBackups = 50, dir = CHECKPOINT_DIR, } = {}) {
|
|
94
|
+
this.enabled = enabled;
|
|
95
|
+
this.maxBackups = maxBackups;
|
|
96
|
+
this.dir = dir;
|
|
97
|
+
}
|
|
98
|
+
get indexFile() {
|
|
99
|
+
return path.join(this.dir, 'index.json');
|
|
100
|
+
}
|
|
101
|
+
async _ensure() {
|
|
102
|
+
await fs.mkdir(this.dir, { recursive: true });
|
|
103
|
+
}
|
|
104
|
+
async _readIndex() {
|
|
105
|
+
try {
|
|
106
|
+
const data = JSON.parse(await fs.readFile(this.indexFile, 'utf-8'));
|
|
107
|
+
return Array.isArray(data) ? data : [];
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Snapshot `workdir` into a tarball. Best-effort: a missing tar, an
|
|
115
|
+
* unreadable dir or a timeout yields null, never a thrown error — a failed
|
|
116
|
+
* checkpoint must not abort the task it was meant to protect.
|
|
117
|
+
*/
|
|
118
|
+
async create(workdir, label) {
|
|
119
|
+
if (!this.enabled)
|
|
120
|
+
return null;
|
|
121
|
+
await this._ensure();
|
|
122
|
+
const stamp = Date.now();
|
|
123
|
+
const file = path.join(this.dir, `${stamp}.tgz`);
|
|
124
|
+
const res = await runTar(tarCreateArgs(file, workdir), workdir);
|
|
125
|
+
if (res.code !== 0) {
|
|
126
|
+
await fs.rm(file, { force: true }).catch(() => { });
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const record = { stamp, file, workdir };
|
|
130
|
+
if (label)
|
|
131
|
+
record.label = label;
|
|
132
|
+
const history = await this._readIndex();
|
|
133
|
+
history.push(record);
|
|
134
|
+
const kept = rotateRecords(history, this.maxBackups);
|
|
135
|
+
// Drop the archives that fell out of the retention window.
|
|
136
|
+
const keptStamps = new Set(kept.map((r) => r.stamp));
|
|
137
|
+
for (const r of history) {
|
|
138
|
+
if (!keptStamps.has(r.stamp))
|
|
139
|
+
await fs.rm(r.file, { force: true }).catch(() => { });
|
|
140
|
+
}
|
|
141
|
+
writeJsonAtomic(this.indexFile, kept);
|
|
142
|
+
return record;
|
|
143
|
+
}
|
|
144
|
+
/** Newest-first list, capped at `count`. */
|
|
145
|
+
async list(count = 10) {
|
|
146
|
+
const history = await this._readIndex();
|
|
147
|
+
return history.slice().reverse().slice(0, count);
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Restore the working tree to a checkpoint. Backs up the CURRENT tree first
|
|
151
|
+
* (a 'pre-rewind' checkpoint), so the rewind itself is reversible. Returns
|
|
152
|
+
* machine codes; the caller localizes them.
|
|
153
|
+
*/
|
|
154
|
+
async restore(selector) {
|
|
155
|
+
if (!this.enabled)
|
|
156
|
+
return { ok: false, reason: 'disabled' };
|
|
157
|
+
const history = await this._readIndex();
|
|
158
|
+
const newestFirst = history.slice().reverse();
|
|
159
|
+
const record = pickRecord(newestFirst, selector);
|
|
160
|
+
if (!record)
|
|
161
|
+
return { ok: false, reason: 'not_found' };
|
|
162
|
+
const exists = await fs
|
|
163
|
+
.stat(record.file)
|
|
164
|
+
.then(() => true)
|
|
165
|
+
.catch(() => false);
|
|
166
|
+
if (!exists)
|
|
167
|
+
return { ok: false, reason: 'archive_missing' };
|
|
168
|
+
// Backup the CURRENT tree before touching it.
|
|
169
|
+
const backup = await this.create(record.workdir, 'pre-rewind');
|
|
170
|
+
// Extract to a temp dir FIRST; only a successful extract may touch workdir
|
|
171
|
+
// (otherwise a bad archive would wipe the tree).
|
|
172
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'zames-rewind-'));
|
|
173
|
+
try {
|
|
174
|
+
const res = await runTar(tarExtractArgs(record.file, tmp), tmp);
|
|
175
|
+
if (res.code !== 0)
|
|
176
|
+
return { ok: false, reason: 'extract_failed' };
|
|
177
|
+
// Full restore: drop everything in workdir except the excludes, then
|
|
178
|
+
// copy the snapshot in. A partial extract would leave behind files the
|
|
179
|
+
// task created after the checkpoint.
|
|
180
|
+
const entries = await fs
|
|
181
|
+
.readdir(record.workdir)
|
|
182
|
+
.catch(() => []);
|
|
183
|
+
for (const e of entries) {
|
|
184
|
+
if (CHECKPOINT_EXCLUDES.includes(e))
|
|
185
|
+
continue;
|
|
186
|
+
await fs
|
|
187
|
+
.rm(path.join(record.workdir, e), { recursive: true, force: true })
|
|
188
|
+
.catch(() => { });
|
|
189
|
+
}
|
|
190
|
+
await copyTree(tmp, record.workdir);
|
|
191
|
+
return { ok: true, record, backup };
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
await fs.rm(tmp, { recursive: true, force: true }).catch(() => { });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -82,6 +82,8 @@ export function summarizeTranscript(entries) {
|
|
|
82
82
|
turns: 0,
|
|
83
83
|
toolCalls: 0,
|
|
84
84
|
toolCounts: {},
|
|
85
|
+
toolMs: 0,
|
|
86
|
+
toolTime: {},
|
|
85
87
|
durationMs: 0,
|
|
86
88
|
startedAt: null,
|
|
87
89
|
autoCompacts: 0,
|
|
@@ -98,6 +100,14 @@ export function summarizeTranscript(entries) {
|
|
|
98
100
|
const tool = String(e.tool ?? '?');
|
|
99
101
|
stats.toolCounts[tool] = (stats.toolCounts[tool] || 0) + 1;
|
|
100
102
|
}
|
|
103
|
+
if (e.type === 'tool_result') {
|
|
104
|
+
const ms = Number(e.durationMs);
|
|
105
|
+
if (Number.isFinite(ms) && ms > 0) {
|
|
106
|
+
stats.toolMs += ms;
|
|
107
|
+
const tool = String(e.tool ?? '?');
|
|
108
|
+
stats.toolTime[tool] = (stats.toolTime[tool] || 0) + ms;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
101
111
|
if (typeof e.elapsed === 'number' && e.elapsed > stats.durationMs) {
|
|
102
112
|
stats.durationMs = e.elapsed;
|
|
103
113
|
}
|
|
@@ -179,6 +189,19 @@ export function renderCost(stats, transcriptFile, tokenUsage = null, t = transla
|
|
|
179
189
|
for (const [name, n] of top)
|
|
180
190
|
lines.push(' ' + name + ': ' + n);
|
|
181
191
|
}
|
|
192
|
+
// T-D3: where the time went. Sort by total ms, show the top tools with the
|
|
193
|
+
// count and a human duration, so Read→Edit cycles and slow Bash calls are
|
|
194
|
+
// visible at a glance.
|
|
195
|
+
const byTime = Object.entries(stats.toolTime || {})
|
|
196
|
+
.filter(([, ms]) => ms > 0)
|
|
197
|
+
.sort((a, b) => b[1] - a[1]);
|
|
198
|
+
if (byTime.length) {
|
|
199
|
+
lines.push(t('cost.by_time', { dur: formatDuration(stats.toolMs) }));
|
|
200
|
+
for (const [name, ms] of byTime.slice(0, 8)) {
|
|
201
|
+
const count = stats.toolCounts[name] ?? 0;
|
|
202
|
+
lines.push(' ' + name + ': ' + formatDuration(ms) + ' ×' + count);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
182
205
|
lines.push(t('cost.duration', { dur: formatDuration(stats.durationMs) }));
|
|
183
206
|
if (stats.startedAt)
|
|
184
207
|
lines.push(t('cost.started', { v: stats.startedAt }));
|
|
@@ -615,6 +638,118 @@ export function rewriteFailedAttachments(text, failed) {
|
|
|
615
638
|
}
|
|
616
639
|
return out;
|
|
617
640
|
}
|
|
641
|
+
// Split a custom-command argument string into positional tokens, honoring
|
|
642
|
+
// single/double quotes (so two quoted words stay one token). Pure, so it is
|
|
643
|
+
// unit-tested.
|
|
644
|
+
export function splitCommandArgs(rest) {
|
|
645
|
+
const out = [];
|
|
646
|
+
const s = String(rest ?? '');
|
|
647
|
+
const SQ = String.fromCharCode(39);
|
|
648
|
+
const DQ = String.fromCharCode(34);
|
|
649
|
+
let cur = '';
|
|
650
|
+
let quote = '';
|
|
651
|
+
for (let i = 0; i < s.length; i++) {
|
|
652
|
+
const ch = s[i];
|
|
653
|
+
if (quote) {
|
|
654
|
+
if (ch === quote) {
|
|
655
|
+
quote = '';
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
cur += ch;
|
|
659
|
+
continue;
|
|
660
|
+
}
|
|
661
|
+
if (ch === SQ || ch === DQ) {
|
|
662
|
+
quote = ch;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
if (ch.charCodeAt(0) <= 32) {
|
|
666
|
+
if (cur) {
|
|
667
|
+
out.push(cur);
|
|
668
|
+
cur = '';
|
|
669
|
+
}
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
cur += ch;
|
|
673
|
+
}
|
|
674
|
+
if (cur)
|
|
675
|
+
out.push(cur);
|
|
676
|
+
return out;
|
|
677
|
+
}
|
|
678
|
+
function isAllDigits(tok) {
|
|
679
|
+
if (!tok)
|
|
680
|
+
return false;
|
|
681
|
+
for (let i = 0; i < tok.length; i++) {
|
|
682
|
+
const c = tok.charCodeAt(i);
|
|
683
|
+
if (c < 48 || c > 57)
|
|
684
|
+
return false;
|
|
685
|
+
}
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
function isWordChar(ch) {
|
|
689
|
+
const c = ch.charCodeAt(0);
|
|
690
|
+
return ((c >= 48 && c <= 57) ||
|
|
691
|
+
(c >= 65 && c <= 90) ||
|
|
692
|
+
(c >= 97 && c <= 122) ||
|
|
693
|
+
ch === '_');
|
|
694
|
+
}
|
|
695
|
+
// Replace $1..$N and $name tokens in an already-expanded body. A whole word is
|
|
696
|
+
// read after $ so $foobar never matches the name foo. Pure.
|
|
697
|
+
function replaceDollarTokens(s, parts, names) {
|
|
698
|
+
let res = '';
|
|
699
|
+
let i = 0;
|
|
700
|
+
while (i < s.length) {
|
|
701
|
+
const ch = s[i];
|
|
702
|
+
if (ch !== '$') {
|
|
703
|
+
res += ch;
|
|
704
|
+
i++;
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
let j = i + 1;
|
|
708
|
+
let tok = '';
|
|
709
|
+
while (j < s.length && isWordChar(s[j])) {
|
|
710
|
+
tok += s[j];
|
|
711
|
+
j++;
|
|
712
|
+
}
|
|
713
|
+
if (!tok) {
|
|
714
|
+
res += ch;
|
|
715
|
+
i++;
|
|
716
|
+
continue;
|
|
717
|
+
}
|
|
718
|
+
if (isAllDigits(tok)) {
|
|
719
|
+
res += parts[Number(tok) - 1] ?? '';
|
|
720
|
+
}
|
|
721
|
+
else {
|
|
722
|
+
const idx = names.indexOf(tok);
|
|
723
|
+
res += idx >= 0 ? (parts[idx] ?? '') : '$' + tok;
|
|
724
|
+
}
|
|
725
|
+
i = j;
|
|
726
|
+
}
|
|
727
|
+
return res;
|
|
728
|
+
}
|
|
729
|
+
// Expand a custom-command body: {{args}} / $ARGUMENTS become the whole argument
|
|
730
|
+
// string, $1..$N the positional tokens, $name the declared positions. Pure, so
|
|
731
|
+
// it is unit-tested.
|
|
732
|
+
export function expandCommandArgs(body, rest, names = []) {
|
|
733
|
+
const parts = splitCommandArgs(rest);
|
|
734
|
+
let out = String(body ?? '');
|
|
735
|
+
out = out.split('{{args}}').join(rest);
|
|
736
|
+
out = out.split('$ARGUMENTS').join(rest);
|
|
737
|
+
out = replaceDollarTokens(out, parts, names);
|
|
738
|
+
return out.trim();
|
|
739
|
+
}
|
|
740
|
+
// Names declared in `arguments:` but not supplied on the command line (by
|
|
741
|
+
// position). Pure, so the caller can refuse an incomplete invocation.
|
|
742
|
+
export function missingCommandArgs(names, rest) {
|
|
743
|
+
if (!names || !names.length)
|
|
744
|
+
return [];
|
|
745
|
+
const parts = splitCommandArgs(rest);
|
|
746
|
+
const missing = [];
|
|
747
|
+
for (let i = 0; i < names.length; i++) {
|
|
748
|
+
if (!parts[i])
|
|
749
|
+
missing.push(names[i]);
|
|
750
|
+
}
|
|
751
|
+
return missing;
|
|
752
|
+
}
|
|
618
753
|
// True when a `/config ...` line can be handled LIVE (while the agent is busy):
|
|
619
754
|
// the text subcommands only touch config values, never the chat. The bare
|
|
620
755
|
// `/config` and `/config menu` (which pause the editor and read keys) are NOT
|