zames_pro 2.10.8 → 2.11.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 CHANGED
@@ -76,6 +76,35 @@ zames --version
76
76
  zames --help
77
77
  ```
78
78
 
79
+ ## Project context, skills and memory
80
+
81
+ Like Codex / Claude Code, zames reads project instructions and reusable
82
+ workflows from your repository and from `~/.zames`.
83
+
84
+ - **AGENTS.md** — project instructions. Put one in the repo root (or in any
85
+ parent folder of the working directory). Global instructions live in
86
+ `~/.zames/AGENTS.md` (and `~/.claude/CLAUDE.md`). Create a starter file with
87
+ `/init`.
88
+ - **MEMORY.md** — durable notes that persist between sessions. The agent
89
+ appends useful facts here; you can edit it by hand.
90
+ - **Skills** — a folder with a `SKILL.md` file (YAML frontmatter: `name`,
91
+ `description`, optional `allowed-tools`, `user-invokable`) plus any helper
92
+ files. Discovered under `.zames/skills/`, `.claude/skills/`,
93
+ `.agents/skills/`, `skills/`, and `~/.zames/skills/`. The agent reads the
94
+ body only when a task matches the description. List them with `/skills`;
95
+ invoke one with `/<skill-name>`.
96
+ - **Custom commands** — `.md` files under `.zames/commands/` (or
97
+ `.claude/commands/`). They support `$ARGUMENTS` / `{{args}}` placeholders and
98
+ are invoked with `/<command-name>`.
99
+
100
+ Skills and custom commands show up in the «/» completion list and in `/help`.
101
+
102
+ ```
103
+ /skills list discovered skills
104
+ /memory show AGENTS.md / MEMORY.md in effect
105
+ /init create a starter AGENTS.md
106
+ ```
107
+
79
108
  ## Configuration
80
109
 
81
110
  Global config: `~/.zames/config.json`
@@ -1,9 +1,10 @@
1
1
  import { buildSystemPrompt } from './system-prompt.js';
2
+ import { loadProjectContext } from './context.js';
2
3
  import { getGitContext, formatGitContext } from './gitTools.js';
3
4
  import { parseXmlToolCalls } from './xml-toolcall.js';
4
5
  import { translate } from './i18n.js';
5
6
  import { normText } from './browser.js';
6
- export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 40, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
7
+ export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 200, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', }) {
7
8
  // UI callbacks must NEVER break the agent loop. A rendering error (a huge
8
9
  // tool result, a broken markdown frame, a closed terminal) used to throw
9
10
  // out of the loop right after a tool call — the session looked "stopped
@@ -20,6 +21,11 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
20
21
  };
21
22
  };
22
23
  const safeThinking = safe(onThinking);
24
+ // Start the "working" indicator ONLY when a message is really sent (after
25
+ // the send-pause), not while the loop is still preparing the request.
26
+ // Previously safeThinking() fired before browser.ask(), so the spinner ran
27
+ // through the whole throttle pause / chat-open phase with nothing in flight.
28
+ browser.onSendStart = safeThinking;
23
29
  const safeAssistantThought = safe(onAssistantThought);
24
30
  const safeToolCall = safe(onToolCall);
25
31
  const safeToolResult = safe(onToolResult);
@@ -54,15 +60,27 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
54
60
  catch (e) {
55
61
  gitText = `(git context error: ${e.message})`;
56
62
  }
63
+ let context = null;
64
+ try {
65
+ context = await loadProjectContext(workdir);
66
+ }
67
+ catch {
68
+ context = null;
69
+ }
57
70
  const systemPrompt = buildSystemPrompt({
58
71
  workdir,
59
72
  tools,
60
73
  gitContext: gitText,
61
74
  locale,
75
+ context,
62
76
  });
63
77
  transcript?.log('system_prompt', {
64
78
  length: systemPrompt.length,
65
79
  gitContext: gitText,
80
+ agents: context?.agents.map((f) => f.path) ?? [],
81
+ memory: context?.memory.map((f) => f.path) ?? [],
82
+ skills: context?.skills.map((s) => s.name) ?? [],
83
+ commands: context?.commands.map((c) => c.name) ?? [],
66
84
  });
67
85
  safeThinking();
68
86
  // system-prompt is an agent send: throttled (agent: true).
@@ -134,7 +152,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
134
152
  let afterToolRetries = 0;
135
153
  const MAX_AFTER_TOOL_RETRIES = 6;
136
154
  for (let i = 0; i < maxIterations; i++) {
137
- safeThinking();
155
+ // NOTE: the spinner is NOT started here. browser.onSendStart fires it
156
+ // right when the message is actually typed/sent (after the send-pause),
157
+ // so no spinner runs during the pre-send phase.
138
158
  // The first message (task) is user input: no throttle.
139
159
  // Subsequent ones (tool-result and resend requests) are agent sends:
140
160
  // throttled so we don't hit the rate limit.
package/dist/browser.js CHANGED
@@ -139,6 +139,11 @@ export class DeepSeekBrowser {
139
139
  _netSniff;
140
140
  _netSniffLimit;
141
141
  _netHookInstalled;
142
+ // Fired right when a message is actually typed/sent (AFTER the send-pause
143
+ // and attachments). Used to start the "agent is working" spinner only when
144
+ // a generation really begins, not during the pre-send phase (chat open,
145
+ // throttle wait), which used to show a spinner with no work in flight.
146
+ onSendStart;
142
147
  constructor({ headless = false, debug = false, channel = 'chrome', answerTimeoutMs = 180000, askRetries = 3, stabilityChecks = 3, stabilityDelayMs = 1000, minSendIntervalMs = 15000, rateLimitWaitMs = 300000, maxRateLimitRetries = 6, maxServerBusyRetries = 5, serverBusyWaitMs = 3000, } = {}) {
143
148
  this.headless = headless;
144
149
  this.debug = debug;
@@ -161,6 +166,7 @@ export class DeepSeekBrowser {
161
166
  this._netSniff = [];
162
167
  this._netSniffLimit = 5;
163
168
  this._netHookInstalled = false;
169
+ this.onSendStart = null;
164
170
  }
165
171
  async launch() {
166
172
  await killStaleChrome();
@@ -670,6 +676,16 @@ export class DeepSeekBrowser {
670
676
  // Esc/Ctrl+C pressed during the pause — do not send anything.
671
677
  if (this._abort)
672
678
  return '(прервано пользователем)';
679
+ // The pause is over and we are really about to type/send: only NOW start
680
+ // the "working" indicator. The spinner used to be started by the caller
681
+ // BEFORE ask(), so it ran for the whole throttle pause and chat-opening
682
+ // phase with no generation in flight.
683
+ if (this.onSendStart) {
684
+ try {
685
+ this.onSendStart();
686
+ }
687
+ catch { }
688
+ }
673
689
  this._netCapture = '';
674
690
  this._netCaptureAt = 0;
675
691
  // Attach files/images FIRST (before the text): the DeepSeek upload widget
package/dist/config.js CHANGED
@@ -6,7 +6,7 @@ const ZAMES_HOME = path.join(os.homedir(), '.zames');
6
6
  const HOME_CONFIG = path.join(ZAMES_HOME, 'config.json');
7
7
  const PROJECT_CONFIG = path.join(process.cwd(), '.zamesrc.json');
8
8
  export const DEFAULTS = {
9
- maxIterations: 40,
9
+ maxIterations: 200,
10
10
  headless: false,
11
11
  debug: false,
12
12
  browserChannel: null,
@@ -77,7 +77,7 @@ function deepMerge(target, source) {
77
77
  }
78
78
  export const CONFIG_SCHEMA = [
79
79
  { path: 'ui.locale', type: 'enum', values: ['ru', 'en'], labelKey: 'cfg.f.ui_locale', groupKey: 'cfg.group.ui' },
80
- { path: 'maxIterations', type: 'number', min: 1, max: 1000, labelKey: 'cfg.f.maxIterations', groupKey: 'cfg.group.agent' },
80
+ { path: 'maxIterations', type: 'number', min: 1, max: 100000, labelKey: 'cfg.f.maxIterations', groupKey: 'cfg.group.agent' },
81
81
  { path: 'headless', type: 'boolean', labelKey: 'cfg.f.headless', groupKey: 'cfg.group.agent' },
82
82
  { path: 'debug', type: 'boolean', labelKey: 'cfg.f.debug', groupKey: 'cfg.group.agent' },
83
83
  { path: 'hotReload', type: 'boolean', labelKey: 'cfg.f.hotReload', groupKey: 'cfg.group.agent' },
@@ -0,0 +1,270 @@
1
+ import fs from 'fs/promises';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ const MAX_FILE_BASE = 60000;
5
+ const MAX_TOTAL = 240000;
6
+ const CRLF_RE = /\r\n/g;
7
+ const NL = String.fromCharCode(10);
8
+ function clipIf(name, content, budget) {
9
+ const cleaned = content.replace(CRLF_RE, NL).trim();
10
+ if (cleaned.length <= budget)
11
+ return cleaned;
12
+ const keep = Math.max(0, budget - 120);
13
+ return cleaned.slice(0, keep) + NL + NL + '[... ' + name + ' truncated (' + (cleaned.length - keep) + ' chars omitted) ...]';
14
+ }
15
+ async function readIfFile(p, budget) {
16
+ try {
17
+ const st = await fs.stat(p);
18
+ if (!st.isFile())
19
+ return null;
20
+ const raw = await fs.readFile(p, 'utf-8');
21
+ const content = clipIf(path.basename(p), raw, budget);
22
+ if (!content)
23
+ return null;
24
+ return { path: p, content };
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ const AGENT_NAMES = ['AGENTS.md', 'agents.md', 'Agents.md'];
31
+ const MEMORY_NAMES = ['MEMORY.md', 'memory.md', 'Memory.md'];
32
+ async function findNamedFile(dir, names) {
33
+ for (const n of names) {
34
+ const p = path.join(dir, n);
35
+ const st = await fs.stat(p).catch(() => null);
36
+ if (st && st.isFile())
37
+ return p;
38
+ }
39
+ return null;
40
+ }
41
+ function dirChainFromRoot(from) {
42
+ const out = [];
43
+ let cur = path.resolve(from);
44
+ const root = path.parse(cur).root;
45
+ while (true) {
46
+ out.unshift(cur);
47
+ if (cur === root)
48
+ break;
49
+ const parent = path.dirname(cur);
50
+ if (parent === cur)
51
+ break;
52
+ cur = parent;
53
+ }
54
+ return out;
55
+ }
56
+ const PROJECT_SKILL_DIRS = ['.zames/skills', '.claude/skills', '.agents/skills', 'skills'];
57
+ function parseFrontmatter(raw) {
58
+ const norm = raw.replace(CRLF_RE, NL);
59
+ if (!norm.startsWith('---'))
60
+ return {};
61
+ const end = norm.indexOf(NL + '---', 3);
62
+ if (end === -1)
63
+ return {};
64
+ const block = norm.slice(3, end);
65
+ const out = {};
66
+ let lastKey = null;
67
+ for (const rawLine of block.split(NL)) {
68
+ const line = rawLine.replace(/\s+$/, '');
69
+ if (!line || /^\s*#/.test(line))
70
+ continue;
71
+ const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
72
+ if (m && !/^\s/.test(line)) {
73
+ const key = m[1];
74
+ let val = (m[2] || '').trim();
75
+ if (val === '|' || val === '>') {
76
+ out[key] = '';
77
+ lastKey = key;
78
+ continue;
79
+ }
80
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
81
+ val = val.slice(1, -1);
82
+ }
83
+ out[key] = val;
84
+ lastKey = key;
85
+ }
86
+ else if (lastKey && /^\s+/.test(line)) {
87
+ out[lastKey] = ((out[lastKey] || '') + ' ' + line.trim()).trim();
88
+ }
89
+ }
90
+ const allowedTools = typeof out['allowed-tools'] === 'string'
91
+ ? out['allowed-tools'].split(/,/).map((s) => s.trim()).filter(Boolean)
92
+ : undefined;
93
+ const userInvokable = typeof out['user-invokable'] === 'string'
94
+ ? out['user-invokable'].toLowerCase() !== 'false'
95
+ : undefined;
96
+ return { name: out.name, description: out.description, allowedTools, userInvokable };
97
+ }
98
+ export function skillBody(raw) {
99
+ const norm = raw.replace(CRLF_RE, NL);
100
+ if (!norm.startsWith('---'))
101
+ return norm.trim();
102
+ const end = norm.indexOf(NL + '---', 3);
103
+ if (end === -1)
104
+ return norm.trim();
105
+ return norm.slice(norm.indexOf(NL, end + 1) + 1).trim();
106
+ }
107
+ async function findSkillFiles(root, depth = 3) {
108
+ const out = [];
109
+ const walk = async (dir, level) => {
110
+ let entries;
111
+ try {
112
+ entries = await fs.readdir(dir, { withFileTypes: true });
113
+ }
114
+ catch {
115
+ return;
116
+ }
117
+ for (const e of entries) {
118
+ const full = path.join(dir, e.name);
119
+ if (e.isFile() && e.name === 'SKILL.md')
120
+ out.push(full);
121
+ else if (e.isDirectory() && level < depth)
122
+ await walk(full, level + 1);
123
+ }
124
+ };
125
+ await walk(root, 1);
126
+ return out;
127
+ }
128
+ export async function loadSkills(workdir) {
129
+ const found = [];
130
+ const seen = new Set();
131
+ const roots = [];
132
+ for (const rel of PROJECT_SKILL_DIRS) {
133
+ roots.push({ dir: path.join(workdir, rel), source: 'project' });
134
+ }
135
+ roots.push({ dir: path.join(os.homedir(), '.zames', 'skills'), source: 'global' });
136
+ roots.push({ dir: path.join(os.homedir(), '.claude', 'skills'), source: 'global' });
137
+ for (const root of roots) {
138
+ const files = await findSkillFiles(root.dir);
139
+ for (const f of files) {
140
+ const raw = await fs.readFile(f, 'utf-8').catch(() => null);
141
+ if (raw === null)
142
+ continue;
143
+ const fm = parseFrontmatter(raw);
144
+ const dir = path.dirname(f);
145
+ const name = (fm.name || path.basename(dir)).trim();
146
+ if (!name)
147
+ continue;
148
+ const key = name.toLowerCase();
149
+ if (seen.has(key))
150
+ continue;
151
+ seen.add(key);
152
+ const description = (fm.description || '').replace(/\s+/g, ' ').trim();
153
+ found.push({ name, description, path: f, dir, allowedTools: fm.allowedTools, source: root.source, userInvokable: fm.userInvokable !== false });
154
+ }
155
+ }
156
+ found.sort((a, b) => a.name.localeCompare(b.name));
157
+ return found;
158
+ }
159
+ const PROJECT_COMMAND_DIRS = ['.zames/commands', '.claude/commands', '.agents/commands'];
160
+ async function loadCommandsFromDir(dir) {
161
+ const out = [];
162
+ let entries;
163
+ try {
164
+ entries = await fs.readdir(dir);
165
+ }
166
+ catch {
167
+ return out;
168
+ }
169
+ for (const e of entries) {
170
+ if (!/\.md$/i.test(e))
171
+ continue;
172
+ const full = path.join(dir, e);
173
+ const st = await fs.stat(full).catch(() => null);
174
+ if (!st || !st.isFile())
175
+ continue;
176
+ const raw = await fs.readFile(full, 'utf-8').catch(() => null);
177
+ if (raw === null)
178
+ continue;
179
+ const fm = parseFrontmatter(raw);
180
+ const name = path.basename(e, '.md').trim();
181
+ if (!name)
182
+ continue;
183
+ out.push({ name, description: (fm.description || '').replace(/\s+/g, ' ').trim(), path: full, body: skillBody(raw) });
184
+ }
185
+ return out;
186
+ }
187
+ export async function loadCommands(workdir) {
188
+ const out = [];
189
+ const seen = new Set();
190
+ const dirs = PROJECT_COMMAND_DIRS.map((d) => path.join(workdir, d));
191
+ dirs.push(path.join(os.homedir(), '.zames', 'commands'));
192
+ dirs.push(path.join(os.homedir(), '.claude', 'commands'));
193
+ for (const dir of dirs) {
194
+ for (const c of await loadCommandsFromDir(dir)) {
195
+ const key = c.name.toLowerCase();
196
+ if (seen.has(key))
197
+ continue;
198
+ seen.add(key);
199
+ out.push(c);
200
+ }
201
+ }
202
+ out.sort((a, b) => a.name.localeCompare(b.name));
203
+ return out;
204
+ }
205
+ async function loadAgentsChain(workdir) {
206
+ const out = [];
207
+ const seen = new Set();
208
+ for (const p of [path.join(os.homedir(), '.zames', 'AGENTS.md'), path.join(os.homedir(), '.claude', 'CLAUDE.md')]) {
209
+ const f = await readIfFile(p, MAX_FILE_BASE);
210
+ if (f && !seen.has(f.path)) {
211
+ seen.add(f.path);
212
+ out.push(f);
213
+ }
214
+ }
215
+ const chain = dirChainFromRoot(workdir);
216
+ for (const dir of chain) {
217
+ const p = await findNamedFile(dir, AGENT_NAMES);
218
+ if (!p || seen.has(p))
219
+ continue;
220
+ const f = await readIfFile(p, MAX_FILE_BASE);
221
+ if (f) {
222
+ seen.add(p);
223
+ out.push(f);
224
+ }
225
+ }
226
+ return out;
227
+ }
228
+ async function loadMemoryChain(workdir) {
229
+ const out = [];
230
+ const seen = new Set();
231
+ for (const p of [path.join(os.homedir(), '.zames', 'MEMORY.md'), path.join(os.homedir(), '.claude', 'MEMORY.md')]) {
232
+ const f = await readIfFile(p, MAX_FILE_BASE);
233
+ if (f && !seen.has(f.path)) {
234
+ seen.add(f.path);
235
+ out.push(f);
236
+ }
237
+ }
238
+ const chain = dirChainFromRoot(workdir);
239
+ for (const dir of chain) {
240
+ const p = await findNamedFile(dir, MEMORY_NAMES);
241
+ if (!p || seen.has(p))
242
+ continue;
243
+ const f = await readIfFile(p, MAX_FILE_BASE);
244
+ if (f) {
245
+ seen.add(p);
246
+ out.push(f);
247
+ }
248
+ }
249
+ return out;
250
+ }
251
+ export async function loadProjectContext(workdir) {
252
+ const [agents, memory, skills, commands] = await Promise.all([
253
+ loadAgentsChain(workdir).catch(() => []),
254
+ loadMemoryChain(workdir).catch(() => []),
255
+ loadSkills(workdir).catch(() => []),
256
+ loadCommands(workdir).catch(() => []),
257
+ ]);
258
+ let used = 0;
259
+ const withinBudget = (files) => {
260
+ const kept = [];
261
+ for (const f of files) {
262
+ if (used + f.content.length > MAX_TOTAL)
263
+ break;
264
+ used += f.content.length;
265
+ kept.push(f);
266
+ }
267
+ return kept;
268
+ };
269
+ return { agents: withinBudget(agents), memory: withinBudget(memory), skills, commands };
270
+ }
package/dist/i18n.js CHANGED
@@ -83,6 +83,18 @@ const CATALOG = {
83
83
  'help.cmd.debug_dom': { ru: '/debug-dom сохранить HTML страницы (для отладки)', en: '/debug-dom dump page HTML (debug)' },
84
84
  'help.cmd.help': { ru: '/help, help справка', en: '/help, help this help' },
85
85
  'help.cmd.exit': { ru: '/exit, /quit, exit выход', en: '/exit, /quit, exit exit' },
86
+ 'skills.none': { ru: 'Навыки не найдены. Добавьте SKILL.md в .zames/skills/<имя>/ или ~/.zames/skills/.', en: 'No skills found. Add SKILL.md under .zames/skills/<name>/ or ~/.zames/skills/.' },
87
+ 'skills.title': { ru: 'Найдено навыков: {n}', en: 'Skills found: {n}' },
88
+ 'memory.agents': { ru: 'Инструкции (AGENTS.md):', en: 'Instructions (AGENTS.md):' },
89
+ 'memory.memory': { ru: 'Память (MEMORY.md):', en: 'Memory (MEMORY.md):' },
90
+ 'init.exists': { ru: 'AGENTS.md уже существует: {v}', en: 'AGENTS.md already exists: {v}' },
91
+ 'init.created': { ru: 'Создан {v}', en: 'Created {v}' },
92
+ 'help.cmd.skill': { ru: 'навык (SKILL.md)', en: 'skill (SKILL.md)' },
93
+ 'help.cmd.custom': { ru: 'пользовательская команда', en: 'custom command' },
94
+ 'help.cmd.skills': { ru: '/skills список найденных навыков', en: '/skills list discovered skills' },
95
+ 'help.cmd.memory': { ru: '/memory показать MEMORY.md и AGENTS.md', en: '/memory show MEMORY.md and AGENTS.md' },
96
+ 'help.cmd.init': { ru: '/init создать AGENTS.md для проекта', en: '/init create AGENTS.md for the project' },
97
+ 'help.skills': { ru: 'Навыки и свои команды:', en: 'Skills and custom commands:' },
86
98
  'help.self_review': { ru: 'Самообзор (отладка агента):', en: 'Self-review (agent debugging):' },
87
99
  'help.self.review': { ru: '/self-review [фокус] снять снапшот src/ и запустить ревью', en: '/self-review [focus] snapshot src/ and run review' },
88
100
  'help.self.fix': { ru: '/self-fix <name> [фокус] вернуться в существующий снапшот и продолжить', en: '/self-fix <name> [focus] enter an existing snapshot and continue' },
@@ -128,6 +140,7 @@ const CATALOG = {
128
140
  'msg.new_chat': { ru: 'Создаю новый чат...', en: 'Creating a new chat...' },
129
141
  'msg.new_chat_ok': { ru: 'Новый чат.', en: 'New chat.' },
130
142
  'msg.resuming': { ru: 'Восстанавливаю сессию {id}...', en: 'Restoring session {id}...' },
143
+ 'msg.input_locked': { ru: 'Идёт операция, ввод временно заблокирован…', en: 'Operation in progress, input is temporarily locked…' },
131
144
  'msg.opening_chat': { ru: 'Открываю чат {id}...', en: 'Opening chat {id}...' },
132
145
  'msg.chat_opened': { ru: 'Чат открыт.', en: 'Chat opened.' },
133
146
  'msg.interactive': { ru: 'Интерактивный режим. Введите задачу. Команды — /help. Выход — /exit.', en: 'Interactive mode. Enter a task. Commands — /help. Exit — /exit.' },
package/dist/index.js CHANGED
@@ -226,6 +226,7 @@ ${theme.bold(t('help.self_review'))}
226
226
  ${t('help.self.list')}
227
227
  ${t('help.self.diff')}
228
228
  ${t('help.self.apply')}
229
+ ${dynamicCommands.length ? '\n' + theme.bold(t('help.skills')) + '\n' + dynamicCommands.map((d) => ' ' + d.name.padEnd(24) + ' ' + d.description).join('\n') : ''}
229
230
 
230
231
  ${theme.bold(t('help.files'))}
231
232
  ${t('help.files.logs')} ${config.transcript.dir}
@@ -261,6 +262,9 @@ const SLASH_COMMANDS = [
261
262
  { name: '/undo-list', key: 'help.cmd.undo_list' },
262
263
  { name: '/transcript', key: 'help.cmd.transcript' },
263
264
  { name: '/config', key: 'help.cmd.config' },
265
+ { name: '/skills', key: 'help.cmd.skills' },
266
+ { name: '/memory', key: 'help.cmd.memory' },
267
+ { name: '/init', key: 'help.cmd.init' },
264
268
  { name: '/debug-dom', key: 'help.cmd.debug_dom' },
265
269
  { name: '/self-review', key: 'help.self.review' },
266
270
  { name: '/self-fix', key: 'help.self.fix' },
@@ -271,12 +275,82 @@ const SLASH_COMMANDS = [
271
275
  { name: '/exit', key: 'help.cmd.exit' },
272
276
  { name: '/quit', key: 'help.cmd.exit' },
273
277
  ];
278
+ // Dynamically discovered skills and custom commands for the current workdir.
279
+ // Filled in by refreshDynamicCommands() and shown in the «/» hint list and
280
+ // help. Skills are surfaced as /skill-name entries (user-invokable only).
281
+ let dynamicCommands = [];
282
+ async function refreshDynamicCommands(workdir) {
283
+ const out = [];
284
+ try {
285
+ const { loadSkills, loadCommands } = await import('./context.js');
286
+ const skills = await loadSkills(workdir);
287
+ for (const s of skills) {
288
+ if (!s.userInvokable)
289
+ continue;
290
+ out.push({
291
+ name: '/' + s.name,
292
+ description: s.description || t('help.cmd.skill'),
293
+ });
294
+ }
295
+ const cmds = await loadCommands(workdir);
296
+ for (const c of cmds) {
297
+ out.push({
298
+ name: '/' + c.name,
299
+ description: c.description || t('help.cmd.custom'),
300
+ });
301
+ }
302
+ }
303
+ catch {
304
+ // best-effort: a broken skill/command must not break the CLI
305
+ }
306
+ dynamicCommands = out;
307
+ }
308
+ // Expand a slash target (skill or custom command) into a task string.
309
+ // Returns null when the name is neither a skill nor a custom command.
310
+ async function expandSlashTarget(workdir, name, rest) {
311
+ try {
312
+ const { loadSkills, loadCommands, skillBody } = await import('./context.js');
313
+ const commands = await loadCommands(workdir);
314
+ const cmd = commands.find((c) => c.name.toLowerCase() === name.toLowerCase());
315
+ if (cmd) {
316
+ let body = cmd.body.replace(/\{\{args\}\}/g, rest);
317
+ body = body.replace(/\$ARGUMENTS/g, rest);
318
+ return body.trim() || rest;
319
+ }
320
+ const skills = await loadSkills(workdir);
321
+ const skill = skills.find((s) => s.name.toLowerCase() === name.toLowerCase() && s.userInvokable);
322
+ if (skill) {
323
+ let body = '';
324
+ try {
325
+ const raw = await fs.readFile(skill.path, 'utf-8');
326
+ body = skillBody(raw);
327
+ }
328
+ catch {
329
+ body = '';
330
+ }
331
+ const header = 'Follow the skill "' + skill.name + '" (from ' + skill.path + ').';
332
+ const extra = rest ? '\n\nAdditional instructions from the operator: ' + rest : '';
333
+ return header + '\n\n' + body + extra;
334
+ }
335
+ }
336
+ catch {
337
+ // fall through: treated as an unknown command
338
+ }
339
+ return null;
340
+ }
274
341
  // Slash-command descriptions in the current language (for LineEditor hints).
275
342
  function buildSlashCommands() {
276
- return SLASH_COMMANDS.map((c) => ({
343
+ const base = SLASH_COMMANDS.map((c) => ({
277
344
  name: c.name,
278
345
  description: t(c.key),
279
346
  }));
347
+ const known = new Set(base.map((c) => c.name.toLowerCase()));
348
+ for (const d of dynamicCommands) {
349
+ if (known.has(d.name.toLowerCase()))
350
+ continue;
351
+ base.push(d);
352
+ }
353
+ return base;
280
354
  }
281
355
  // We put the agent's temporary files (one-off scripts, etc.) in
282
356
  // <project>/tmp — this folder is in .gitignore and is cleaned on every launch.
@@ -714,9 +788,15 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
714
788
  };
715
789
  // Execute the task, then everything the user managed to type while it
716
790
  // ran. The queue may be replenished right during draining.
791
+ //
792
+ // NOTE: we do NOT call ui.thinking() here. runAgentLoop() fires
793
+ // onThinking() right before the actual browser send (after the send
794
+ // pause, system-prompt, etc.), so the spinner only appears when a
795
+ // generation really starts. Calling it here made the spinner run for
796
+ // the whole pre-send phase (chat creation, throttle wait) with no
797
+ // generation in flight.
717
798
  while (true) {
718
- ui.thinking();
719
- await mod.runAgentLoop({
799
+ const outcome = await mod.runAgentLoop({
720
800
  browser,
721
801
  tools,
722
802
  task: next.text,
@@ -735,6 +815,16 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
735
815
  debugLog: debug,
736
816
  locale: currentLocale,
737
817
  });
818
+ // The loop may end WITHOUT a model answer: an exhausted iteration
819
+ // limit or an ask() watchdog (the model stopped responding). In that
820
+ // case no assistant message was shown, and the operator saw the run
821
+ // just "stop" after a tool call with no explanation. Surface it.
822
+ if (outcome &&
823
+ (outcome.startsWith('Достигнут лимит итераций') ||
824
+ outcome.startsWith('ask() watchdog'))) {
825
+ ui.warning(outcome);
826
+ transcript?.log('agent_no_answer', { outcome });
827
+ }
738
828
  // Aborted (Esc/Ctrl+C) — we don't start the next tasks from the queue
739
829
  // and clear it, so "stop" really stops everything.
740
830
  if (browser._stopped) {
@@ -770,6 +860,9 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
770
860
  finally {
771
861
  stopWatching();
772
862
  ui.stop();
863
+ // Detach the send hook so a later browser.ask() outside this task cannot
864
+ // start a stale spinner.
865
+ browser.onSendStart = null;
773
866
  }
774
867
  }
775
868
  // ---------- main ----------
@@ -954,6 +1047,7 @@ async function main() {
954
1047
  }
955
1048
  return theme.prompt('❯ ') + tail + theme.dim(' › ');
956
1049
  };
1050
+ await refreshDynamicCommands(currentWorkdir);
957
1051
  if (process.stdin.isTTY && process.stdout.isTTY) {
958
1052
  const ed = new LineEditor({
959
1053
  prompt: buildPrompt(),
@@ -1339,6 +1433,8 @@ async function main() {
1339
1433
  }
1340
1434
  if (['/new', '/clear', 'new'].includes(lower)) {
1341
1435
  console.log(theme.system(t('msg.new_chat')));
1436
+ if (editor)
1437
+ editor.lock(t('msg.input_locked'));
1342
1438
  try {
1343
1439
  await browser.newChat();
1344
1440
  freshChatNext = false;
@@ -1351,6 +1447,10 @@ async function main() {
1351
1447
  catch (e) {
1352
1448
  console.error(theme.error(t('msg.new_chat_error', { v: e.message })), e.message);
1353
1449
  }
1450
+ finally {
1451
+ if (editor)
1452
+ editor.unlock();
1453
+ }
1354
1454
  continue;
1355
1455
  }
1356
1456
  // ---------- Self-review ----------
@@ -1360,6 +1460,8 @@ async function main() {
1360
1460
  const originalWorkdir = reviewMode
1361
1461
  ? reviewMode.originalWorkdir
1362
1462
  : currentWorkdir;
1463
+ if (editor)
1464
+ editor.lock(t('msg.input_locked'));
1363
1465
  try {
1364
1466
  const result = await mod.selfReview({
1365
1467
  browser,
@@ -1388,6 +1490,10 @@ async function main() {
1388
1490
  if (debug)
1389
1491
  console.error(e.stack);
1390
1492
  }
1493
+ finally {
1494
+ if (editor)
1495
+ editor.unlock();
1496
+ }
1391
1497
  continue;
1392
1498
  }
1393
1499
  if (lower === '/self-fix' || lower.startsWith('/self-fix ')) {
@@ -1513,6 +1619,8 @@ async function main() {
1513
1619
  // ---------- Regular commands ----------
1514
1620
  if (lower === '/chats') {
1515
1621
  const spin = editor || mod.createSpinner(currentLocale);
1622
+ if (editor)
1623
+ editor.lock(t('msg.input_locked'));
1516
1624
  spin.thinking();
1517
1625
  try {
1518
1626
  lastChats = await browser.listChats(30);
@@ -1533,6 +1641,10 @@ async function main() {
1533
1641
  spin.stop();
1534
1642
  console.error(theme.error(t('chats.fetch_error')), e.message);
1535
1643
  }
1644
+ finally {
1645
+ if (editor)
1646
+ editor.unlock();
1647
+ }
1536
1648
  continue;
1537
1649
  }
1538
1650
  if (lower === '/resume' || lower.startsWith('/resume ')) {
@@ -1556,6 +1668,8 @@ async function main() {
1556
1668
  continue;
1557
1669
  }
1558
1670
  console.log(theme.system(t('chats.opening', { v: pick.title })));
1671
+ if (editor)
1672
+ editor.lock(t('msg.input_locked'));
1559
1673
  try {
1560
1674
  await browser.openChat(pick.id);
1561
1675
  currentChatId = pick.id;
@@ -1571,6 +1685,10 @@ async function main() {
1571
1685
  catch (e) {
1572
1686
  console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
1573
1687
  }
1688
+ finally {
1689
+ if (editor)
1690
+ editor.unlock();
1691
+ }
1574
1692
  continue;
1575
1693
  }
1576
1694
  if (lower === '/chat') {
@@ -1608,6 +1726,8 @@ async function main() {
1608
1726
  console.error(theme.error(t('sessions.resume_id_usage')));
1609
1727
  continue;
1610
1728
  }
1729
+ if (editor)
1730
+ editor.lock(t('msg.input_locked'));
1611
1731
  try {
1612
1732
  console.log(theme.system(t('msg.opening_chat', { id })));
1613
1733
  await browser.openChat(id);
@@ -1622,12 +1742,70 @@ async function main() {
1622
1742
  catch (e) {
1623
1743
  console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
1624
1744
  }
1745
+ finally {
1746
+ if (editor)
1747
+ editor.unlock();
1748
+ }
1625
1749
  continue;
1626
1750
  }
1627
1751
  if (lower === '/pwd') {
1628
1752
  console.log(theme.system(currentWorkdir));
1629
1753
  continue;
1630
1754
  }
1755
+ if (lower === '/skills') {
1756
+ const { loadSkills } = await import('./context.js');
1757
+ const skills = await loadSkills(currentWorkdir);
1758
+ if (!skills.length) {
1759
+ console.log(theme.dim(t('skills.none')));
1760
+ }
1761
+ else {
1762
+ console.log(theme.system(t('skills.title', { n: String(skills.length) })));
1763
+ for (const s of skills) {
1764
+ console.log(' ' +
1765
+ theme.user('/' + s.name) +
1766
+ theme.dim(' [' + s.source + '] ') +
1767
+ (s.description || theme.dim(t('common.none'))));
1768
+ console.log(theme.dim(' ' + s.path));
1769
+ }
1770
+ }
1771
+ continue;
1772
+ }
1773
+ if (lower === '/memory') {
1774
+ const { loadProjectContext } = await import('./context.js');
1775
+ const ctx = await loadProjectContext(currentWorkdir);
1776
+ const show = (title, files) => {
1777
+ console.log(theme.system(title));
1778
+ if (!files.length) {
1779
+ console.log(theme.dim(' ' + t('common.none')));
1780
+ return;
1781
+ }
1782
+ for (const f of files)
1783
+ console.log(' ' + f.path);
1784
+ };
1785
+ show(t('memory.agents'), ctx.agents);
1786
+ show(t('memory.memory'), ctx.memory);
1787
+ continue;
1788
+ }
1789
+ if (lower === '/init') {
1790
+ const target = path.join(currentWorkdir, 'AGENTS.md');
1791
+ const exists = await fs.stat(target).catch(() => null);
1792
+ if (exists) {
1793
+ console.log(theme.warn(t('init.exists', { v: target })));
1794
+ continue;
1795
+ }
1796
+ const stub = '# AGENTS.md\n\n' +
1797
+ 'Project instructions for the coding agent. Describe the build/test commands, ' +
1798
+ 'conventions, and any rules the agent must follow in this repository.\n\n' +
1799
+ '## Commands\n\n' +
1800
+ '- build: `...`\n' +
1801
+ '- test: `...`\n' +
1802
+ '- lint: `...`\n\n' +
1803
+ '## Conventions\n\n' +
1804
+ '- ...\n';
1805
+ await fs.writeFile(target, stub, 'utf-8');
1806
+ console.log(theme.assistant(t('init.created', { v: target })));
1807
+ continue;
1808
+ }
1631
1809
  if (lower === '/reload') {
1632
1810
  console.log(theme.system(t('msg.reload_start')));
1633
1811
  try {
@@ -1644,6 +1822,10 @@ async function main() {
1644
1822
  catch (e) {
1645
1823
  console.error(theme.error(t('msg.reload_error')), e.message);
1646
1824
  }
1825
+ await autoReload();
1826
+ await refreshDynamicCommands(currentWorkdir);
1827
+ if (editor)
1828
+ editor.setCommands(buildSlashCommands());
1647
1829
  continue;
1648
1830
  }
1649
1831
  if (lower === '/status') {
@@ -1754,6 +1936,9 @@ async function main() {
1754
1936
  currentWorkdir = newDir;
1755
1937
  freshChatNext = true;
1756
1938
  sendSystemPromptNext = true;
1939
+ await refreshDynamicCommands(currentWorkdir);
1940
+ if (editor)
1941
+ editor.setCommands(buildSlashCommands());
1757
1942
  console.log(theme.system(t('cd.changed', { v: newDir })));
1758
1943
  }
1759
1944
  catch (e) {
@@ -1761,19 +1946,29 @@ async function main() {
1761
1946
  }
1762
1947
  continue;
1763
1948
  }
1949
+ // Custom command or skill invoked as a slash command? Expand it into a
1950
+ // task. Skills are instructions the agent follows; custom commands are
1951
+ // prompt templates with $ARGUMENTS / {{args}} placeholders.
1952
+ let expandedTask = null;
1764
1953
  if (lower.startsWith('/')) {
1954
+ const name = trimmed.slice(1).split(/\s+/)[0];
1955
+ const rest = trimmed.slice(1 + name.length).trim();
1956
+ expandedTask = await expandSlashTarget(currentWorkdir, name, rest);
1957
+ }
1958
+ if (lower.startsWith('/') && expandedTask === null) {
1765
1959
  console.error(theme.error(t('msg.unknown_cmd', { v: trimmed })));
1766
1960
  continue;
1767
1961
  }
1768
1962
  // ---- Regular task (including in review mode) ----
1963
+ const taskText = expandedTask !== null ? expandedTask : trimmed;
1769
1964
  // Dev mode: pick up fresh logic modules before the task.
1770
1965
  await autoReload();
1771
- transcript.log('user_task', { task: trimmed, workdir: currentWorkdir });
1966
+ transcript.log('user_task', { task: taskText, workdir: currentWorkdir });
1772
1967
  const tools = mod.createTools(currentWorkdir, { undo });
1773
1968
  if (editor)
1774
1969
  editor.busy = true;
1775
1970
  try {
1776
- await runTask(browser, tools, trimmed, currentWorkdir, {
1971
+ await runTask(browser, tools, taskText, currentWorkdir, {
1777
1972
  transcript,
1778
1973
  freshChat: freshChatNext,
1779
1974
  sendSystemPrompt: sendSystemPromptNext,
package/dist/input.js CHANGED
@@ -207,6 +207,11 @@ export class LineEditor {
207
207
  // Clipboard callback: tries to read an image from the OS clipboard when the
208
208
  // terminal itself sends no usable data (Ctrl+V, right-click, empty paste).
209
209
  onClipboard;
210
+ // When locked, the editor ignores text input and Enter (submit). Used while
211
+ // a long operation runs (chat resume/open, /new, self-review): otherwise the
212
+ // user could type and send a message mid-operation, and it would be queued
213
+ // and sent right after the operation, breaking the restored session.
214
+ locked;
210
215
  constructor({ prompt = '> ', commands = [] } = {}) {
211
216
  this.promptStr = prompt;
212
217
  this.buf = '';
@@ -236,6 +241,7 @@ export class LineEditor {
236
241
  this._tmpDir = '';
237
242
  this.onAttach = null;
238
243
  this.onClipboard = null;
244
+ this.locked = false;
239
245
  }
240
246
  // Read the OS clipboard for an image and insert its marker. Used when the
241
247
  // terminal sends no usable paste data (Ctrl+V / right-click / empty paste).
@@ -333,6 +339,19 @@ export class LineEditor {
333
339
  this._writeBlock();
334
340
  this._render();
335
341
  }
342
+ // Lock input while a long operation runs (chat resume/open, /new,
343
+ // self-review). The line stays visible but text input and Enter are ignored,
344
+ // so a message typed mid-operation is not queued and sent afterwards,
345
+ // breaking the restored session. Ctrl+C/Ctrl+D still work so the user can
346
+ // abort.
347
+ lock(status) {
348
+ this.locked = true;
349
+ if (status)
350
+ this.setStatus(theme.dim(status));
351
+ }
352
+ unlock() {
353
+ this.locked = false;
354
+ }
336
355
  setPrompt(str) {
337
356
  this.promptStr = str;
338
357
  this._render();
@@ -481,6 +500,10 @@ export class LineEditor {
481
500
  }
482
501
  // ---------- input ----------
483
502
  _submit() {
503
+ if (this.locked) {
504
+ this._render();
505
+ return;
506
+ }
484
507
  const display = this.buf;
485
508
  if (!display.trim()) {
486
509
  this._render();
@@ -716,6 +739,25 @@ export class LineEditor {
716
739
  }
717
740
  _handle(data) {
718
741
  let s = data.toString('utf-8');
742
+ // While locked (a long operation is running), swallow all input except
743
+ // Ctrl+C / Ctrl+D — so the user can still abort, but cannot type a message
744
+ // that would be queued and sent after the operation.
745
+ if (this.locked) {
746
+ if (s.length === 1) {
747
+ const c = s.charCodeAt(0);
748
+ if (c === 3) {
749
+ if (this.onCtrlC)
750
+ this.onCtrlC();
751
+ return;
752
+ }
753
+ if (c === 4) {
754
+ if (this.onCtrlC)
755
+ this.onCtrlC();
756
+ return;
757
+ }
758
+ }
759
+ return;
760
+ }
719
761
  if (!this._inPaste && s === ESC) {
720
762
  if (this.onEscape)
721
763
  this.onEscape();
@@ -1,5 +1,53 @@
1
1
  import { translate } from './i18n.js';
2
- export function buildSystemPrompt({ workdir, tools, gitContext = null, locale = 'ru', attachments = [], }) {
2
+ /** Render the AGENTS.md / MEMORY / skills / commands blocks of the prompt. */
3
+ export function renderContextSection(context) {
4
+ if (!context)
5
+ return '';
6
+ let out = '';
7
+ for (const f of context.agents) {
8
+ out +=
9
+ '\n## Project instructions (' + f.path + ')\n\n' +
10
+ 'The following come from ' + f.path + '. Follow them for this project unless the operator explicitly overrides them.\n\n' +
11
+ f.content +
12
+ '\n';
13
+ }
14
+ for (const f of context.memory) {
15
+ out +=
16
+ '\n## Memory (' + f.path + ')\n\n' +
17
+ 'Durable notes accumulated across sessions. Treat as background knowledge; update this file when you learn something worth remembering.\n\n' +
18
+ f.content +
19
+ '\n';
20
+ }
21
+ if (context.memory.length) {
22
+ out +=
23
+ '\nWhen you learn a durable fact about this project or the operator preferences (build quirks, ' +
24
+ 'conventions, gotchas), append a short bullet to ' + context.memory[0].path + ' via Edit/Write so it survives ' +
25
+ 'into future sessions. Keep entries concise.\n';
26
+ }
27
+ if (context.skills.length) {
28
+ const lines = context.skills.map((s) => {
29
+ const tools = s.allowedTools && s.allowedTools.length
30
+ ? ' [tools: ' + s.allowedTools.join(', ') + ']'
31
+ : '';
32
+ return '- ' + s.name + tools + ': ' + (s.description || '(no description)') + '\n file: ' + s.path;
33
+ });
34
+ out +=
35
+ '\n## Skills\n\n' +
36
+ 'Specialized workflows available as SKILL.md files. When a task matches a skill description, read its file and follow its instructions. Skills are progressive disclosure: only read the body when the skill applies.\n\n' +
37
+ lines.join('\n') +
38
+ '\n';
39
+ }
40
+ if (context.commands.length) {
41
+ const lines = context.commands.map((c) => '- /' + c.name + ': ' + (c.description || '(no description)'));
42
+ out +=
43
+ '\n## Custom commands\n\n' +
44
+ 'Reusable prompts the operator can invoke with a slash command. If the operator runs one, its body arrives as the task text.\n\n' +
45
+ lines.join('\n') +
46
+ '\n';
47
+ }
48
+ return out;
49
+ }
50
+ export function buildSystemPrompt({ workdir, tools, gitContext = null, locale = 'ru', attachments = [], context = null, }) {
3
51
  const t = translate(locale);
4
52
  const toolDescriptions = tools
5
53
  .map((t2) => `### ${t2.name}\n${t2.description}\nПараметры: ${JSON.stringify(t2.parameters)}`)
@@ -11,6 +59,7 @@ export function buildSystemPrompt({ workdir, tools, gitContext = null, locale =
11
59
  ? `\n## Attachments\n\nThe user attached files to this task: ${attachments.join(', ')}.\n` +
12
60
  `Each [image#N] / [file#N] marker corresponds to a file the user pasted into the terminal; the file was uploaded to the chat and also saved under <project>/tmp. Look at the images in the chat; for files, read the copy in tmp if you need the contents.\n`
13
61
  : '';
62
+ const contextSection = renderContextSection(context);
14
63
  return `You are a coding agent running in a terminal. You help the user with software engineering tasks by reading files, writing code, running commands, and iterating until the task is done.
15
64
 
16
65
  You work in the directory: ${workdir}
@@ -63,7 +112,7 @@ task is done (or when you must ask the operator), not between tool calls.
63
112
  You have access to the following tools:
64
113
 
65
114
  ${toolDescriptions}
66
- ${gitSection}${attachSection}
115
+ ${gitSection}${attachSection}${contextSection}
67
116
  ## How to use tools
68
117
 
69
118
  To call ONE tool, respond with ONLY a JSON object (no markdown fences, no extra text):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.10.8",
3
+ "version": "2.11.0",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {