zames_pro 2.17.0 → 2.19.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 +13 -2
- package/dist/agent-loop.js +17 -5
- package/dist/browser.js +362 -21
- package/dist/commands.js +125 -2
- package/dist/i18n.js +95 -0
- package/dist/index.js +168 -2
- package/dist/input.js +73 -2
- package/dist/net-capture.js +47 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -138,8 +138,8 @@ config commands (/new, /chats, /resume, /cd, /status, /config,
|
|
|
138
138
|
Codex CLI:
|
|
139
139
|
|
|
140
140
|
- /diff [--staged] — show the working-tree git diff (--staged for the index).
|
|
141
|
-
- /cost (alias /usage) — session stats: tasks, tool calls, duration
|
|
142
|
-
|
|
141
|
+
- /cost (alias /usage) — session stats: tasks, tool calls, duration, and the
|
|
142
|
+
context size in tokens (DeepSeek's `accumulated_token_usage`).
|
|
143
143
|
- /export [file] — write the session transcript to a Markdown file
|
|
144
144
|
(zames-export-<stamp>.md by default).
|
|
145
145
|
- /doctor — diagnose node, git, config, browser, clipboard and MCP.
|
|
@@ -147,8 +147,19 @@ Codex CLI:
|
|
|
147
147
|
alwaysConfirm regex list).
|
|
148
148
|
- /add-dir <path> — validate an extra directory (the sandbox is fixed at
|
|
149
149
|
startup; relaunch with --dir to write there).
|
|
150
|
+
- /resume <n> (after /chats) and /resume-id <id> — open a chat and PRINT its
|
|
151
|
+
dialogue into the terminal, so the restored context is visible. Only the
|
|
152
|
+
last 20 messages are shown (`RESTORED_HISTORY_LIMIT`).
|
|
150
153
|
- /review [focus] [--staged] — ask the agent to review uncommitted changes
|
|
151
154
|
and report findings (no code changes).
|
|
155
|
+
- /compact — ask DeepSeek to compress the current chat into a handover
|
|
156
|
+
summary, then open a NEW chat, resend the system prompt and post the summary
|
|
157
|
+
as the carried-over context. Use it when the context gets long.
|
|
158
|
+
|
|
159
|
+
The token context is also shown live: the status line above the input has the
|
|
160
|
+
spinner/text on the left and the context on the right (e.g. `125k · 13%`,
|
|
161
|
+
percent of a 1M context). It comes from DeepSeek's `accumulated_token_usage`
|
|
162
|
+
and is hidden until the first answer delivers it.
|
|
152
163
|
|
|
153
164
|
|
|
154
165
|
## Project context, skills and memory
|
package/dist/agent-loop.js
CHANGED
|
@@ -129,6 +129,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
129
129
|
let unparsedRetries = 0;
|
|
130
130
|
const MAX_UNPARSED_RETRIES = 4;
|
|
131
131
|
let finalRespondAsked = false;
|
|
132
|
+
// The operator is told ONCE that the agent is re-asking the model for a
|
|
133
|
+
// proper tool call (not on every retry — that would spam the terminal).
|
|
134
|
+
let toolRetryWarned = false;
|
|
132
135
|
// Guard against "the agent stalled": DeepSeek sometimes sends a final text
|
|
133
136
|
// that merely DESCRIBES the next tool call (or cuts the answer off
|
|
134
137
|
// mid-word), and the agent silently finishes the task even though the work
|
|
@@ -207,9 +210,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
207
210
|
attempt: afterToolRetries,
|
|
208
211
|
error: e.message,
|
|
209
212
|
});
|
|
210
|
-
safeWarning(
|
|
211
|
-
Math.round(askDeadlineMs / 1000)
|
|
212
|
-
|
|
213
|
+
safeWarning(translate(locale)('ds.answer_timeout', {
|
|
214
|
+
sec: Math.round(askDeadlineMs / 1000),
|
|
215
|
+
}));
|
|
213
216
|
if (afterToolRetries < MAX_AFTER_TOOL_RETRIES) {
|
|
214
217
|
afterToolRetries++;
|
|
215
218
|
await new Promise((r) => setTimeout(r, 1500));
|
|
@@ -218,8 +221,7 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
218
221
|
transcript?.log('ask_timeout_exhausted', {
|
|
219
222
|
message: 'ask() did not return an answer and the retry limit is exhausted',
|
|
220
223
|
});
|
|
221
|
-
safeWarning('
|
|
222
|
-
'stopping. No model answer — check the DeepSeek chat manually.');
|
|
224
|
+
safeWarning(translate(locale)('ds.answer_timeout_give_up'));
|
|
223
225
|
return 'ask() watchdog: no model answer received';
|
|
224
226
|
}
|
|
225
227
|
await reportChat();
|
|
@@ -402,6 +404,16 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
402
404
|
attempt: unparsedRetries,
|
|
403
405
|
response: rawResponse.slice(0, 500),
|
|
404
406
|
});
|
|
407
|
+
// Tell the operator WHY nothing is happening: the model wrote text
|
|
408
|
+
// instead of a tool call and the agent is asking it to continue. Only
|
|
409
|
+
// once per task, otherwise the retry budget spams the terminal.
|
|
410
|
+
if (!toolRetryWarned) {
|
|
411
|
+
toolRetryWarned = true;
|
|
412
|
+
safeWarning(translate(locale)('ds.tool_retry', {
|
|
413
|
+
attempt: unparsedRetries,
|
|
414
|
+
max: MAX_UNPARSED_RETRIES,
|
|
415
|
+
}));
|
|
416
|
+
}
|
|
405
417
|
message =
|
|
406
418
|
(justRanTool
|
|
407
419
|
? 'You stopped after a tool call and wrote plain text. '
|
package/dist/browser.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { chromium, } from 'playwright';
|
|
2
|
-
import { extractAnswer, dumpNetBody } from './net-capture.js';
|
|
2
|
+
import { extractAnswer, extractTokenUsage, dumpNetBody } from './net-capture.js';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
5
|
import fs from 'fs/promises';
|
|
@@ -277,6 +277,18 @@ export class DeepSeekBrowser {
|
|
|
277
277
|
_netCapture;
|
|
278
278
|
_netCaptureAt;
|
|
279
279
|
_netChatId;
|
|
280
|
+
// The latest CONTEXT size (in tokens) DeepSeek reported in the current
|
|
281
|
+
// chat. It is the `accumulated_token_usage` counter taken from the SSE
|
|
282
|
+
// completion stream and from /api/v0/chat/history_messages. Null until the
|
|
283
|
+
// first answer (or history fetch) delivers it. Shown by /cost and /status.
|
|
284
|
+
_lastTokenUsage;
|
|
285
|
+
// Authorization / PoW headers sniffed from DeepSeek's own API requests, so
|
|
286
|
+
// fetchChatMessages can replay them (a bare fetch does not get them).
|
|
287
|
+
_apiAuth;
|
|
288
|
+
_apiPow;
|
|
289
|
+
// Last error from fetchChatMessages ('' on success). Surfaced under --debug
|
|
290
|
+
// so a missing restored history can be diagnosed.
|
|
291
|
+
_lastHistoryError;
|
|
280
292
|
_netSniff;
|
|
281
293
|
_netSniffLimit;
|
|
282
294
|
_netHookInstalled;
|
|
@@ -321,6 +333,10 @@ export class DeepSeekBrowser {
|
|
|
321
333
|
this._netCapture = '';
|
|
322
334
|
this._netCaptureAt = 0;
|
|
323
335
|
this._netChatId = null;
|
|
336
|
+
this._lastTokenUsage = null;
|
|
337
|
+
this._apiAuth = '';
|
|
338
|
+
this._apiPow = '';
|
|
339
|
+
this._lastHistoryError = '';
|
|
324
340
|
this._netSniff = [];
|
|
325
341
|
this._netSniffLimit = 5;
|
|
326
342
|
this._netHookInstalled = false;
|
|
@@ -424,6 +440,23 @@ export class DeepSeekBrowser {
|
|
|
424
440
|
pg.on('response', (resp) => {
|
|
425
441
|
void this._onResponse(resp).catch(() => { });
|
|
426
442
|
});
|
|
443
|
+
// DeepSeek's own API calls carry an Authorization header (the app reads a
|
|
444
|
+
// token from its storage and sets it explicitly). A bare fetch from our
|
|
445
|
+
// code does NOT get it, so history_messages would answer 401/empty.
|
|
446
|
+
// Sniff the header off the real requests and replay it later.
|
|
447
|
+
pg.on('request', (req) => {
|
|
448
|
+
try {
|
|
449
|
+
const url = req.url();
|
|
450
|
+
if (!/deepseek\.com\/api\//i.test(url))
|
|
451
|
+
return;
|
|
452
|
+
const h = req.headers();
|
|
453
|
+
if (h['authorization'])
|
|
454
|
+
this._apiAuth = h['authorization'];
|
|
455
|
+
if (h['x-ds-pow-response'])
|
|
456
|
+
this._apiPow = h['x-ds-pow-response'];
|
|
457
|
+
}
|
|
458
|
+
catch { }
|
|
459
|
+
});
|
|
427
460
|
}
|
|
428
461
|
async _onResponse(resp) {
|
|
429
462
|
try {
|
|
@@ -445,6 +478,12 @@ export class DeepSeekBrowser {
|
|
|
445
478
|
if (this._netSniff.length > this._netSniffLimit)
|
|
446
479
|
this._netSniff.shift();
|
|
447
480
|
void dumpNetBody(url, body);
|
|
481
|
+
// Context size (tokens) reported by DeepSeek for this answer. Kept
|
|
482
|
+
// even when extractAnswer() returns nothing (a history_messages
|
|
483
|
+
// response carries the counter but no answer text).
|
|
484
|
+
const usage = extractTokenUsage(body);
|
|
485
|
+
if (usage !== null)
|
|
486
|
+
this._lastTokenUsage = usage;
|
|
448
487
|
const extracted = extractAnswer(body);
|
|
449
488
|
if (extracted) {
|
|
450
489
|
this._netCapture = extracted;
|
|
@@ -829,6 +868,20 @@ export class DeepSeekBrowser {
|
|
|
829
868
|
if (this._netCapture && this._netCaptureAt >= this._lastSentAt) {
|
|
830
869
|
return this._netCapture;
|
|
831
870
|
}
|
|
871
|
+
return await this._readLastAnswerTextDom();
|
|
872
|
+
}
|
|
873
|
+
// The answer as rendered on the page, ALWAYS from the DOM (never from the
|
|
874
|
+
// network capture). This is what the "has a new answer started?" checks
|
|
875
|
+
// must compare against `beforeText`.
|
|
876
|
+
//
|
|
877
|
+
// Why a separate reader: `_readLastAnswerText()` prefers `_netCapture` when
|
|
878
|
+
// it is fresh. `beforeText` was taken through it, so after a send the
|
|
879
|
+
// network capture (the SAME text) made `cur` equal to `beforeText` — the
|
|
880
|
+
// "changed" signal never fired and ask() ended with "no new answer" and
|
|
881
|
+
// retried for minutes, while the operator saw the agent "stop after a tool
|
|
882
|
+
// call". The DOM reader has no such self-comparison problem: before the
|
|
883
|
+
// send the DOM shows the OLD answer, after it the NEW one.
|
|
884
|
+
async _readLastAnswerTextDom() {
|
|
832
885
|
return await this.page.evaluate((sels) => {
|
|
833
886
|
// DeepSeek stores the model's reasoning in .ds-think-content blocks.
|
|
834
887
|
// They are NOT the answer and must never be picked up as the answer
|
|
@@ -895,6 +948,19 @@ export class DeepSeekBrowser {
|
|
|
895
948
|
}
|
|
896
949
|
async _readLastAnswerTextClean() {
|
|
897
950
|
const raw = await this._readLastAnswerText().catch(() => '');
|
|
951
|
+
return this._cleanAnswer(raw);
|
|
952
|
+
}
|
|
953
|
+
// The same "clean" filter as _readLastAnswerTextClean, but read from the
|
|
954
|
+
// DOM only. Used by _askOnce for the before/after comparison: the network
|
|
955
|
+
// capture must not be compared against itself (see _readLastAnswerTextDom).
|
|
956
|
+
async _readLastAnswerTextCleanDom() {
|
|
957
|
+
const raw = await this._readLastAnswerTextDom().catch(() => '');
|
|
958
|
+
return this._cleanAnswer(raw);
|
|
959
|
+
}
|
|
960
|
+
// Drop service placeholders ("Reading…", "Thinking…") so they are not
|
|
961
|
+
// mistaken for an answer; keep everything else as-is (including whitespace
|
|
962
|
+
// the tool-call relies on).
|
|
963
|
+
_cleanAnswer(raw) {
|
|
898
964
|
const t = (raw || '').trim();
|
|
899
965
|
if (!t)
|
|
900
966
|
return '';
|
|
@@ -1017,10 +1083,17 @@ export class DeepSeekBrowser {
|
|
|
1017
1083
|
if (e instanceof RateLimitError) {
|
|
1018
1084
|
rateLimitRetries++;
|
|
1019
1085
|
if (rateLimitRetries > this.maxRateLimitRetries) {
|
|
1020
|
-
console.error(theme.error(
|
|
1086
|
+
console.error(theme.error(this._t('ds.rate_limit_give_up', {
|
|
1087
|
+
attempt: rateLimitRetries,
|
|
1088
|
+
min: Math.ceil(this.rateLimitWaitMs / 60000),
|
|
1089
|
+
})));
|
|
1021
1090
|
throw e;
|
|
1022
1091
|
}
|
|
1023
|
-
console.error(theme.warn(
|
|
1092
|
+
console.error(theme.warn(this._t('ds.rate_limit_wait', {
|
|
1093
|
+
min: Math.ceil(this.rateLimitWaitMs / 60000),
|
|
1094
|
+
attempt: rateLimitRetries,
|
|
1095
|
+
max: this.maxRateLimitRetries,
|
|
1096
|
+
})));
|
|
1024
1097
|
// Interruptible: Esc/Ctrl+C must cancel this long wait too,
|
|
1025
1098
|
// otherwise "stop" stays frozen for up to 5 minutes.
|
|
1026
1099
|
const aborted = await this._sleepInterruptible(this.rateLimitWaitMs);
|
|
@@ -1028,15 +1101,44 @@ export class DeepSeekBrowser {
|
|
|
1028
1101
|
return '(прервано пользователем)';
|
|
1029
1102
|
continue;
|
|
1030
1103
|
}
|
|
1031
|
-
|
|
1104
|
+
// Server busy / overloaded: clear in seconds, so we retry quickly
|
|
1105
|
+
// (unlike the rate limit). Without this branch the error fell into the
|
|
1106
|
+
// generic ask() retry and the operator saw a bare message with no
|
|
1107
|
+
// explanation of what DeepSeek is doing.
|
|
1108
|
+
if (e instanceof ServerBusyError) {
|
|
1109
|
+
serverBusyRetries++;
|
|
1110
|
+
if (serverBusyRetries > this.maxServerBusyRetries) {
|
|
1111
|
+
console.error(theme.error(this._t('ds.server_busy_give_up', {
|
|
1112
|
+
attempt: serverBusyRetries,
|
|
1113
|
+
})));
|
|
1114
|
+
throw e;
|
|
1115
|
+
}
|
|
1116
|
+
console.error(theme.warn(this._t('ds.server_busy_wait', {
|
|
1117
|
+
sec: Math.ceil(this.serverBusyWaitMs / 1000),
|
|
1118
|
+
attempt: serverBusyRetries,
|
|
1119
|
+
max: this.maxServerBusyRetries,
|
|
1120
|
+
})));
|
|
1121
|
+
const aborted = await this._sleepInterruptible(this.serverBusyWaitMs);
|
|
1122
|
+
if (aborted)
|
|
1123
|
+
return '(прервано пользователем)';
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
console.error('\n' +
|
|
1127
|
+
theme.warn(this._t('ds.ask_retry', {
|
|
1128
|
+
attempt,
|
|
1129
|
+
max: this.askRetries,
|
|
1130
|
+
error: e.message,
|
|
1131
|
+
})));
|
|
1032
1132
|
if (/closed|crash|Target page|browser/i.test(e.message)) {
|
|
1033
|
-
console.error('
|
|
1133
|
+
console.error(theme.warn(this._t('ds.ask_restart_browser')));
|
|
1034
1134
|
try {
|
|
1035
1135
|
await this.restart();
|
|
1036
1136
|
await this.waitForLogin();
|
|
1037
1137
|
}
|
|
1038
1138
|
catch (re) {
|
|
1039
|
-
console.error(
|
|
1139
|
+
console.error(theme.warn(this._t('ds.ask_restart_failed', {
|
|
1140
|
+
error: re.message,
|
|
1141
|
+
})));
|
|
1040
1142
|
}
|
|
1041
1143
|
}
|
|
1042
1144
|
if (attempt < this.askRetries) {
|
|
@@ -1044,7 +1146,10 @@ export class DeepSeekBrowser {
|
|
|
1044
1146
|
}
|
|
1045
1147
|
}
|
|
1046
1148
|
}
|
|
1047
|
-
throw new Error(
|
|
1149
|
+
throw new Error(this._t('ds.ask_failed', {
|
|
1150
|
+
max: this.askRetries,
|
|
1151
|
+
error: lastErr?.message || '',
|
|
1152
|
+
}));
|
|
1048
1153
|
}
|
|
1049
1154
|
async _setInputText(input, text) {
|
|
1050
1155
|
const tag = await input.evaluate((el) => el.tagName.toLowerCase());
|
|
@@ -1100,7 +1205,10 @@ export class DeepSeekBrowser {
|
|
|
1100
1205
|
return el.innerText || el.textContent || "";
|
|
1101
1206
|
});
|
|
1102
1207
|
if (norm(got2) !== norm(text)) {
|
|
1103
|
-
throw new Error(
|
|
1208
|
+
throw new Error(this._t('ds.input_partial', {
|
|
1209
|
+
got: norm(got2).length,
|
|
1210
|
+
want: norm(text).length,
|
|
1211
|
+
}));
|
|
1104
1212
|
}
|
|
1105
1213
|
}
|
|
1106
1214
|
}
|
|
@@ -1250,9 +1358,14 @@ export class DeepSeekBrowser {
|
|
|
1250
1358
|
return '(прервано пользователем)';
|
|
1251
1359
|
const input = await this._findVisible(INPUT_SELECTORS, 10_000);
|
|
1252
1360
|
if (!input) {
|
|
1253
|
-
throw new Error('
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1361
|
+
throw new Error(this._t('ds.input_missing'));
|
|
1362
|
+
}
|
|
1363
|
+
// `beforeText` MUST come from the DOM, not from _readLastAnswerText():
|
|
1364
|
+
// that reader prefers the network capture, so comparing `cur` (also the
|
|
1365
|
+
// capture) against `beforeText` compared the capture against itself and
|
|
1366
|
+
// the "new answer" check never fired — the source of the
|
|
1367
|
+
// ds.send_no_new_answer flapping.
|
|
1368
|
+
const beforeText = await this._readLastAnswerTextCleanDom().catch(() => '');
|
|
1256
1369
|
this._askDebug('SEND agent=' + agent + ' len=' + prompt.length + ' beforeLen=' + beforeText.length + ' beforeHead=' + JSON.stringify(beforeText.slice(0, 60)));
|
|
1257
1370
|
await this._waitForSendSlot(agent);
|
|
1258
1371
|
// Esc/Ctrl+C pressed during the pause — do not send anything.
|
|
@@ -1326,7 +1439,7 @@ export class DeepSeekBrowser {
|
|
|
1326
1439
|
if (isServerBusyText(pageText)) {
|
|
1327
1440
|
throw new ServerBusyError(pageText.slice(0, 300));
|
|
1328
1441
|
}
|
|
1329
|
-
const cur = await this.
|
|
1442
|
+
const cur = await this._readLastAnswerTextCleanDom().catch(() => '');
|
|
1330
1443
|
const bodyLen = await this.page
|
|
1331
1444
|
.evaluate(() => document.body.innerText.length)
|
|
1332
1445
|
.catch(() => 0);
|
|
@@ -1375,7 +1488,7 @@ export class DeepSeekBrowser {
|
|
|
1375
1488
|
// (otherwise it is the old answer on screen) or a fresh network capture
|
|
1376
1489
|
// proves a new answer. Returning an equal text made the loop re-run the
|
|
1377
1490
|
// previous tool call.
|
|
1378
|
-
const cur = await this.
|
|
1491
|
+
const cur = await this._readLastAnswerTextCleanDom().catch(() => '');
|
|
1379
1492
|
const fresh = !!this._netCapture && this._netCaptureAt >= this._lastSentAt;
|
|
1380
1493
|
if (cur && cur.trim() && normText(cur) !== normText(beforeText) && !(await this._isGenerating())) {
|
|
1381
1494
|
return cur;
|
|
@@ -1395,7 +1508,7 @@ export class DeepSeekBrowser {
|
|
|
1395
1508
|
while (Date.now() < retryDeadline) {
|
|
1396
1509
|
if (this._abort)
|
|
1397
1510
|
return '(прервано пользователем)';
|
|
1398
|
-
const cur2 = await this.
|
|
1511
|
+
const cur2 = await this._readLastAnswerTextCleanDom().catch(() => '');
|
|
1399
1512
|
const net2 = !!this._netCapture && this._netCaptureAt >= this._lastSentAt;
|
|
1400
1513
|
const grew2 = (await this.page
|
|
1401
1514
|
.evaluate(() => document.body.innerText.length)
|
|
@@ -1411,8 +1524,7 @@ export class DeepSeekBrowser {
|
|
|
1411
1524
|
}
|
|
1412
1525
|
}
|
|
1413
1526
|
if (!started) {
|
|
1414
|
-
throw new Error('
|
|
1415
|
-
'Проверьте чат DeepSeek вручную.');
|
|
1527
|
+
throw new Error(this._t('ds.send_no_start'));
|
|
1416
1528
|
}
|
|
1417
1529
|
// Wait until the answer stops changing. We check the "not generating"
|
|
1418
1530
|
// condition via text growth, NOT via _isGenerating().
|
|
@@ -1438,7 +1550,7 @@ export class DeepSeekBrowser {
|
|
|
1438
1550
|
}
|
|
1439
1551
|
}
|
|
1440
1552
|
const netFresh = !!this._netCapture && this._netCaptureAt >= this._lastSentAt;
|
|
1441
|
-
const cur = await this.
|
|
1553
|
+
const cur = await this._readLastAnswerTextCleanDom().catch(() => '');
|
|
1442
1554
|
// Ignore an "answer" that is identical to what was on the page BEFORE we
|
|
1443
1555
|
// sent the message: that is the previous answer, not a new one. Returning
|
|
1444
1556
|
// it would make the agent re-process the old tool call (or silently
|
|
@@ -1482,12 +1594,11 @@ export class DeepSeekBrowser {
|
|
|
1482
1594
|
return this._netCapture;
|
|
1483
1595
|
}
|
|
1484
1596
|
this._askDebug('THROW no-new-answer lastLen=' + last.length + ' beforeLen=' + beforeText.length);
|
|
1485
|
-
throw new Error('
|
|
1486
|
-
'Возможно, сообщение не отправилось.');
|
|
1597
|
+
throw new Error(this._t('ds.send_no_new_answer'));
|
|
1487
1598
|
}
|
|
1488
1599
|
async dumpDom(filePath) {
|
|
1489
1600
|
if (!this.page)
|
|
1490
|
-
throw new Error('
|
|
1601
|
+
throw new Error(this._t('ds.not_launched'));
|
|
1491
1602
|
const html = await this.page.content();
|
|
1492
1603
|
await fs.writeFile(filePath, html, 'utf-8');
|
|
1493
1604
|
const report = await this.page.evaluate((sels) => {
|
|
@@ -1581,9 +1692,234 @@ export class DeepSeekBrowser {
|
|
|
1581
1692
|
return true;
|
|
1582
1693
|
}
|
|
1583
1694
|
catch (e) {
|
|
1584
|
-
throw new Error(
|
|
1695
|
+
throw new Error(this._t('ds.open_chat_failed', { id, error: e.message }));
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
// Fetch the WHOLE dialogue from DeepSeek's own history endpoint instead of
|
|
1699
|
+
// scraping the DOM. The rendered page only shows a part of the history (the
|
|
1700
|
+
// list is virtualized), while /api/v0/chat/history_messages returns every
|
|
1701
|
+
// message of the chat as JSON. The request is made INSIDE the page, so the
|
|
1702
|
+
// auth cookie and the CSRF/PoW handling are the browser's.
|
|
1703
|
+
//
|
|
1704
|
+
// Shape of data.biz_data.chat_messages[]: { role: 'USER'|'ASSISTANT',
|
|
1705
|
+
// fragments: [{ type: 'REQUEST'|'RESPONSE'|'THINK'|'FILE', content }] }.
|
|
1706
|
+
// We keep REQUEST (user) and RESPONSE (assistant); THINK (reasoning) is
|
|
1707
|
+
// skipped and FILE fragments carry no text.
|
|
1708
|
+
async fetchChatMessages(id) {
|
|
1709
|
+
this._lastHistoryError = '';
|
|
1710
|
+
if (!this.page || !id)
|
|
1711
|
+
return [];
|
|
1712
|
+
// The Authorization header is sniffed off DeepSeek's own API requests. On
|
|
1713
|
+
// a freshly opened chat that request may not have fired yet — wait a bit
|
|
1714
|
+
// (up to 3s) so the replay is authorized instead of answering 401/empty.
|
|
1715
|
+
for (let i = 0; i < 15 && !this._apiAuth; i++) {
|
|
1716
|
+
await this.page.waitForTimeout(200);
|
|
1717
|
+
}
|
|
1718
|
+
const auth = this._apiAuth;
|
|
1719
|
+
const pow = this._apiPow;
|
|
1720
|
+
const res = await this.page
|
|
1721
|
+
.evaluate(async (opts) => {
|
|
1722
|
+
try {
|
|
1723
|
+
const url = 'https://chat.deepseek.com/api/v0/chat/history_messages?chat_session_id=' +
|
|
1724
|
+
encodeURIComponent(opts.chatId);
|
|
1725
|
+
const headers = {
|
|
1726
|
+
accept: 'application/json',
|
|
1727
|
+
};
|
|
1728
|
+
if (opts.auth)
|
|
1729
|
+
headers['authorization'] = opts.auth;
|
|
1730
|
+
if (opts.pow)
|
|
1731
|
+
headers['x-ds-pow-response'] = opts.pow;
|
|
1732
|
+
const resp = await fetch(url, {
|
|
1733
|
+
credentials: 'include',
|
|
1734
|
+
headers,
|
|
1735
|
+
});
|
|
1736
|
+
if (!resp.ok)
|
|
1737
|
+
return { error: 'HTTP ' + resp.status, list: [] };
|
|
1738
|
+
const json = await resp.json();
|
|
1739
|
+
const messages = json && json.data && json.data.biz_data
|
|
1740
|
+
? json.data.biz_data.chat_messages
|
|
1741
|
+
: null;
|
|
1742
|
+
if (!Array.isArray(messages)) {
|
|
1743
|
+
return { error: 'no chat_messages in response', list: [] };
|
|
1744
|
+
}
|
|
1745
|
+
const NL = String.fromCharCode(10);
|
|
1746
|
+
const out = [];
|
|
1747
|
+
// The context size: the LATEST accumulated_token_usage in the chat
|
|
1748
|
+
// (each message carries the running counter).
|
|
1749
|
+
let usage = null;
|
|
1750
|
+
for (const m of messages) {
|
|
1751
|
+
if (m && typeof m.accumulated_token_usage === 'number') {
|
|
1752
|
+
usage = m.accumulated_token_usage;
|
|
1753
|
+
}
|
|
1754
|
+
const role = m && m.role === 'ASSISTANT' ? 'assistant' : 'user';
|
|
1755
|
+
const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
|
|
1756
|
+
let text = '';
|
|
1757
|
+
for (const fr of (m && m.fragments) || []) {
|
|
1758
|
+
if (!fr || fr.type !== want)
|
|
1759
|
+
continue;
|
|
1760
|
+
if (typeof fr.content === 'string')
|
|
1761
|
+
text += fr.content;
|
|
1762
|
+
}
|
|
1763
|
+
text = text.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
|
|
1764
|
+
if (text)
|
|
1765
|
+
out.push({ role, text });
|
|
1766
|
+
}
|
|
1767
|
+
return { error: '', list: out, usage };
|
|
1768
|
+
}
|
|
1769
|
+
catch (e) {
|
|
1770
|
+
return { error: 'fetch failed: ' + e.message, list: [] };
|
|
1771
|
+
}
|
|
1772
|
+
}, { chatId: id, auth, pow })
|
|
1773
|
+
.catch((e) => ({
|
|
1774
|
+
error: 'evaluate failed: ' + e.message,
|
|
1775
|
+
list: [],
|
|
1776
|
+
}));
|
|
1777
|
+
this._lastHistoryError = res?.error || '';
|
|
1778
|
+
// Pick up the context size the history carries, so /resume (and /cost
|
|
1779
|
+
// right after it) shows a real number even before the first answer.
|
|
1780
|
+
const usage = res?.usage;
|
|
1781
|
+
if (typeof usage === 'number')
|
|
1782
|
+
this._lastTokenUsage = usage;
|
|
1783
|
+
const list = (res?.list || []);
|
|
1784
|
+
if (list.length || !res?.error)
|
|
1785
|
+
return list;
|
|
1786
|
+
// Fallback: Playwright's own request context (shares the browser cookies)
|
|
1787
|
+
// when the in-page fetch was blocked (CSP, CORS, a page error).
|
|
1788
|
+
this._lastHistoryError = res.error;
|
|
1789
|
+
try {
|
|
1790
|
+
const resp = await this.page.request.get('https://chat.deepseek.com/api/v0/chat/history_messages?chat_session_id=' +
|
|
1791
|
+
encodeURIComponent(id), { headers: { accept: 'application/json' } });
|
|
1792
|
+
if (!resp.ok())
|
|
1793
|
+
return list;
|
|
1794
|
+
const json = (await resp.json());
|
|
1795
|
+
const messages = json?.data?.biz_data?.chat_messages;
|
|
1796
|
+
if (!Array.isArray(messages))
|
|
1797
|
+
return list;
|
|
1798
|
+
const NL = String.fromCharCode(10);
|
|
1799
|
+
const out = [];
|
|
1800
|
+
for (const m of messages) {
|
|
1801
|
+
if (m && typeof m.accumulated_token_usage === 'number') {
|
|
1802
|
+
this._lastTokenUsage = m.accumulated_token_usage;
|
|
1803
|
+
}
|
|
1804
|
+
const role = m && m.role === 'ASSISTANT' ? 'assistant' : 'user';
|
|
1805
|
+
const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
|
|
1806
|
+
let text = '';
|
|
1807
|
+
for (const fr of (m && m.fragments) || []) {
|
|
1808
|
+
if (!fr || fr.type !== want)
|
|
1809
|
+
continue;
|
|
1810
|
+
if (typeof fr.content === 'string')
|
|
1811
|
+
text += fr.content;
|
|
1812
|
+
}
|
|
1813
|
+
text = text.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
|
|
1814
|
+
if (text)
|
|
1815
|
+
out.push({ role, text });
|
|
1816
|
+
}
|
|
1817
|
+
this._lastHistoryError = '';
|
|
1818
|
+
return out;
|
|
1819
|
+
}
|
|
1820
|
+
catch (e) {
|
|
1821
|
+
this._lastHistoryError = 'request failed: ' + e.message;
|
|
1822
|
+
return list;
|
|
1585
1823
|
}
|
|
1586
1824
|
}
|
|
1825
|
+
// Read the WHOLE visible dialogue of the currently open chat, top to
|
|
1826
|
+
// bottom. Used by /resume and /resume-id so the operator sees the restored
|
|
1827
|
+
// context in the terminal instead of just "Chat opened.".
|
|
1828
|
+
//
|
|
1829
|
+
// Best-effort DOM scraping: DeepSeek renders every message as a markdown
|
|
1830
|
+
// block (assistant) or a plain bubble (user). Role detection relies on the
|
|
1831
|
+
// ds-assistant-message-* class; anything that is not an assistant block is
|
|
1832
|
+
// treated as a user message. The model's reasoning (.ds-think-content) is
|
|
1833
|
+
// skipped, exactly like in _readLastAnswerText.
|
|
1834
|
+
async readChatMessages() {
|
|
1835
|
+
if (!this.page)
|
|
1836
|
+
return [];
|
|
1837
|
+
const raw = await this.page
|
|
1838
|
+
.evaluate(() => {
|
|
1839
|
+
const inThink = (e) => {
|
|
1840
|
+
let n = e;
|
|
1841
|
+
while (n) {
|
|
1842
|
+
const cls = (n.className || '').toString();
|
|
1843
|
+
if (/ds-think-content|thinking-content/i.test(cls))
|
|
1844
|
+
return true;
|
|
1845
|
+
n = n.parentElement;
|
|
1846
|
+
}
|
|
1847
|
+
return false;
|
|
1848
|
+
};
|
|
1849
|
+
const textOf = (e) => {
|
|
1850
|
+
const h = e;
|
|
1851
|
+
const t = h.innerText || h.textContent || '';
|
|
1852
|
+
const NL = String.fromCharCode(10);
|
|
1853
|
+
return t.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
|
|
1854
|
+
};
|
|
1855
|
+
// A message block is "assistant" when it contains an answer markdown
|
|
1856
|
+
// wrapper (ds-markdown / ds-assistant-message), otherwise it is a
|
|
1857
|
+
// user bubble. DeepSeek's class names drift between builds, so the
|
|
1858
|
+
// detection is content-based, not class-prefix-based.
|
|
1859
|
+
const looksAssistant = (e) => {
|
|
1860
|
+
const cls = (e.className || '').toString();
|
|
1861
|
+
if (/ds-assistant-message|assistant-message/i.test(cls))
|
|
1862
|
+
return true;
|
|
1863
|
+
if (e.querySelector('[class*="ds-assistant-message"]'))
|
|
1864
|
+
return true;
|
|
1865
|
+
// A user bubble has no rendered markdown; an answer does.
|
|
1866
|
+
if (e.querySelector('[class*="ds-markdown"]'))
|
|
1867
|
+
return true;
|
|
1868
|
+
return false;
|
|
1869
|
+
};
|
|
1870
|
+
// Message-level containers first (broad), then the answer wrappers.
|
|
1871
|
+
const containerSels = [
|
|
1872
|
+
'[data-message-id]',
|
|
1873
|
+
'[class*="ds-message"]',
|
|
1874
|
+
'[class*="chat-message"]',
|
|
1875
|
+
'[class*="message-item"]',
|
|
1876
|
+
'[class*="_message"]',
|
|
1877
|
+
];
|
|
1878
|
+
let blocks = [];
|
|
1879
|
+
for (const s of containerSels) {
|
|
1880
|
+
const found = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e) && textOf(e).length > 0);
|
|
1881
|
+
if (found.length) {
|
|
1882
|
+
blocks = found;
|
|
1883
|
+
break;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
const out = [];
|
|
1887
|
+
if (blocks.length) {
|
|
1888
|
+
for (const b of blocks) {
|
|
1889
|
+
const t = textOf(b);
|
|
1890
|
+
if (!t)
|
|
1891
|
+
continue;
|
|
1892
|
+
out.push({
|
|
1893
|
+
role: looksAssistant(b) ? 'assistant' : 'user',
|
|
1894
|
+
text: t,
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
if (out.length)
|
|
1898
|
+
return out;
|
|
1899
|
+
}
|
|
1900
|
+
// Last resort: assistant answers only (no user turns) — better than
|
|
1901
|
+
// nothing when no message container matched.
|
|
1902
|
+
const sels = [
|
|
1903
|
+
'div.ds-assistant-message-main-content',
|
|
1904
|
+
'div[class*="ds-assistant-message-main-content"]',
|
|
1905
|
+
'div[class*="ds-markdown"]',
|
|
1906
|
+
];
|
|
1907
|
+
for (const s of sels) {
|
|
1908
|
+
const list = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e));
|
|
1909
|
+
if (!list.length)
|
|
1910
|
+
continue;
|
|
1911
|
+
for (const el of list) {
|
|
1912
|
+
const t = textOf(el);
|
|
1913
|
+
if (t)
|
|
1914
|
+
out.push({ role: 'assistant', text: t });
|
|
1915
|
+
}
|
|
1916
|
+
break;
|
|
1917
|
+
}
|
|
1918
|
+
return out;
|
|
1919
|
+
})
|
|
1920
|
+
.catch(() => []);
|
|
1921
|
+
return raw;
|
|
1922
|
+
}
|
|
1587
1923
|
async getCurrentChatId() {
|
|
1588
1924
|
try {
|
|
1589
1925
|
const url = this.page.url();
|
|
@@ -1596,6 +1932,11 @@ export class DeepSeekBrowser {
|
|
|
1596
1932
|
return this._netChatId;
|
|
1597
1933
|
}
|
|
1598
1934
|
}
|
|
1935
|
+
// The latest context size (in tokens) DeepSeek reported for the current
|
|
1936
|
+
// chat, or null when nothing has been seen yet. Used by /cost and /status.
|
|
1937
|
+
getLastTokenUsage() {
|
|
1938
|
+
return this._lastTokenUsage;
|
|
1939
|
+
}
|
|
1599
1940
|
async close() {
|
|
1600
1941
|
try {
|
|
1601
1942
|
if (this.context)
|
package/dist/commands.js
CHANGED
|
@@ -77,9 +77,15 @@ export function formatDuration(ms) {
|
|
|
77
77
|
return m + 'm ' + pad(s) + 's';
|
|
78
78
|
return s + 's';
|
|
79
79
|
}
|
|
80
|
-
export function renderCost(stats, transcriptFile) {
|
|
80
|
+
export function renderCost(stats, transcriptFile, tokenUsage = null) {
|
|
81
81
|
const lines = [];
|
|
82
|
-
lines.push('Session stats
|
|
82
|
+
lines.push('Session stats:');
|
|
83
|
+
if (typeof tokenUsage === 'number') {
|
|
84
|
+
lines.push(' context: ~' + tokenUsage + ' tokens (DeepSeek accumulated_token_usage)');
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
lines.push(' context: unknown (DeepSeek reports it after the first answer in a chat)');
|
|
88
|
+
}
|
|
83
89
|
lines.push(' tasks: ' + stats.turns);
|
|
84
90
|
lines.push(' tool calls: ' + stats.toolCalls);
|
|
85
91
|
const top = Object.entries(stats.toolCounts).sort((a, b) => b[1] - a[1]);
|
|
@@ -186,6 +192,123 @@ export function resolveExtraDir(input, workdir) {
|
|
|
186
192
|
}
|
|
187
193
|
return { path: abs };
|
|
188
194
|
}
|
|
195
|
+
/** How many characters of the dialogue are printed by default. */
|
|
196
|
+
export const RESTORED_HISTORY_LIMIT = 20;
|
|
197
|
+
/**
|
|
198
|
+
* Is this message worth showing to the operator as part of the dialogue?
|
|
199
|
+
*
|
|
200
|
+
* The chat history contains a lot of protocol noise that is meaningless in
|
|
201
|
+
* the terminal: the system-prompt (a huge REQUEST), tool results
|
|
202
|
+
* ("Tool result for ..."), the agent's raw tool-calls (JSON / DSML), the
|
|
203
|
+
* "You stopped after a tool result..." nudges, and the agent's <system> notes.
|
|
204
|
+
* We keep only real user turns and real assistant answers.
|
|
205
|
+
*/
|
|
206
|
+
export function isDisplayableMessage(m) {
|
|
207
|
+
const text = String((m && m.text) || '').trim();
|
|
208
|
+
if (!text)
|
|
209
|
+
return false;
|
|
210
|
+
if (text.length > 100_000)
|
|
211
|
+
return false; // the system-prompt / a giant blob
|
|
212
|
+
if (m.role === 'user') {
|
|
213
|
+
// Tool output, the system-prompt and the corrective nudges are not the
|
|
214
|
+
// operator's words.
|
|
215
|
+
if (/^Tool result for /.test(text))
|
|
216
|
+
return false;
|
|
217
|
+
if (/^You are a coding agent running in a terminal/.test(text))
|
|
218
|
+
return false;
|
|
219
|
+
if (/^You stopped after a tool result/.test(text))
|
|
220
|
+
return false;
|
|
221
|
+
if (/^\[system\]/.test(text))
|
|
222
|
+
return false;
|
|
223
|
+
if (/^The user ran \//.test(text))
|
|
224
|
+
return false;
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
// Assistant: a tool-call (JSON or DSML) is not an answer to show. The
|
|
228
|
+
// DSML markers use FULL-WIDTH vertical bars (||DSML), so the regex must
|
|
229
|
+
// match them, not the ASCII pipe.
|
|
230
|
+
if (/DSML/i.test(text))
|
|
231
|
+
return false;
|
|
232
|
+
if (/^<system>/i.test(text))
|
|
233
|
+
return false;
|
|
234
|
+
if (/"tool"\s*:/.test(text.slice(0, 600))) {
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
if (/^\s*[\[{]/.test(text) && /"args"\s*:/.test(text.slice(0, 600))) {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Keep only the last `limit` messages and drop empties/service noise. The
|
|
244
|
+
* source (network history or DOM) may contain protocol messages; they are
|
|
245
|
+
* filtered out here so the terminal output stays readable.
|
|
246
|
+
*/
|
|
247
|
+
export function trimRestoredMessages(messages, limit = RESTORED_HISTORY_LIMIT) {
|
|
248
|
+
const clean = (messages || []).filter((m) => m && typeof m.text === 'string' && isDisplayableMessage(m));
|
|
249
|
+
if (limit > 0 && clean.length > limit)
|
|
250
|
+
return clean.slice(clean.length - limit);
|
|
251
|
+
return clean;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Render the restored dialogue as plain text lines: "❯ ..." for the operator,
|
|
255
|
+
* "● ..." for the agent. Markdown rendering is done by the caller (it needs
|
|
256
|
+
* the terminal width); this helper is pure and unit-tested.
|
|
257
|
+
*/
|
|
258
|
+
export function formatRestoredHistory(messages, opts = {}) {
|
|
259
|
+
const list = trimRestoredMessages(messages, opts.limit ?? RESTORED_HISTORY_LIMIT);
|
|
260
|
+
const out = [];
|
|
261
|
+
for (const m of list) {
|
|
262
|
+
const marker = m.role === 'user' ? '❯ ' : '● ';
|
|
263
|
+
out.push(marker + m.text.trim());
|
|
264
|
+
}
|
|
265
|
+
return out.join(NL + NL);
|
|
266
|
+
}
|
|
267
|
+
// ---------- /compact ----------
|
|
268
|
+
/**
|
|
269
|
+
* The prompt that asks the model to compress the current chat into a handover
|
|
270
|
+
* summary. It is sent to the OLD chat before a new one is opened; the answer
|
|
271
|
+
* (the summary) is then carried over as the context of the new chat.
|
|
272
|
+
*
|
|
273
|
+
* The summary must be self-sufficient: the new chat sees ONLY this text (plus
|
|
274
|
+
* the system prompt), so the model is told to keep facts, decisions, file
|
|
275
|
+
* paths, commands and the exact current state of the work.
|
|
276
|
+
*/
|
|
277
|
+
export function buildCompactPrompt(locale = 'ru') {
|
|
278
|
+
if (locale === 'en') {
|
|
279
|
+
return ('Compact the conversation so far into a handover summary for a NEW chat. ' +
|
|
280
|
+
'This summary is the ONLY context the new chat will start with, so it must be self-sufficient. ' +
|
|
281
|
+
'Include: (1) the user goal and constraints; (2) what has been done so far; ' +
|
|
282
|
+
'(3) the exact current state (files changed, commands run, their results); ' +
|
|
283
|
+
'(4) open questions and the next concrete steps. ' +
|
|
284
|
+
'Keep file paths, function/identifier names, commands and error texts verbatim. ' +
|
|
285
|
+
'Be concise but complete — no small talk, no code dumps beyond short essential snippets.');
|
|
286
|
+
}
|
|
287
|
+
return ('Сожми историю диалога в краткое резюме для НОВОГО чата. ' +
|
|
288
|
+
'Это резюме будет ЕДИНСТВЕННЫМ контекстом, с которым новый чат начнёт работу, поэтому оно должно быть самодостаточным. ' +
|
|
289
|
+
'Включи: (1) цель пользователя и ограничения; (2) что уже сделано; ' +
|
|
290
|
+
'(3) точное текущее состояние (изменённые файлы, выполненные команды и их результаты); ' +
|
|
291
|
+
'(4) открытые вопросы и следующие конкретные шаги. ' +
|
|
292
|
+
'Пути к файлам, имена функций/идентификаторов, команды и тексты ошибок сохраняй дословно. ' +
|
|
293
|
+
'Пиши кратко, но полно — без воды и без больших дампов кода (только короткие важные фрагменты).');
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Wrap the model's summary into the text posted as the first message of the
|
|
297
|
+
* NEW chat. The system prompt is sent separately (sendSystemPrompt), so here
|
|
298
|
+
* we only mark the block as a carried-over context and add the operator's
|
|
299
|
+
* original goal so the model does not lose it.
|
|
300
|
+
*/
|
|
301
|
+
export function buildCompactCarryover(summary, task) {
|
|
302
|
+
const body = String(summary ?? '').trim();
|
|
303
|
+
const goal = String(task ?? '').trim();
|
|
304
|
+
let out = 'Context carried over from a previous chat (compacted). ' +
|
|
305
|
+
'Treat it as the history of our work so far and continue from the current state.';
|
|
306
|
+
out += NL + NL + body;
|
|
307
|
+
if (goal) {
|
|
308
|
+
out += NL + NL + 'Original task: ' + goal;
|
|
309
|
+
}
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
189
312
|
// ---------- /review ----------
|
|
190
313
|
export function buildReviewPrompt(focus, hasStaged = false) {
|
|
191
314
|
const scope = hasStaged ? 'staged' : 'uncommitted';
|
package/dist/i18n.js
CHANGED
|
@@ -86,6 +86,13 @@ const CATALOG = {
|
|
|
86
86
|
'help.cmd.permissions': { ru: '/permissions настройки подтверждений', en: '/permissions confirmation settings' },
|
|
87
87
|
'help.cmd.add_dir': { ru: '/add-dir <path> проверить директорию', en: '/add-dir <path> validate a directory' },
|
|
88
88
|
'help.cmd.review': { ru: '/review [focus] ревью незакоммиченных изменений', en: '/review [focus] review uncommitted changes' },
|
|
89
|
+
'help.cmd.compact': { ru: '/compact сжать историю и открыть новый чат с резюме', en: '/compact compact the history and open a new chat with the summary' },
|
|
90
|
+
'compact.start': { ru: '🗜️ Сжимаю историю чата (DeepSeek)...', en: '🗜️ Compacting the chat history (DeepSeek)...' },
|
|
91
|
+
'compact.empty': { ru: 'Нечего сжимать: в чате ещё нет ответов.', en: 'Nothing to compact: the chat has no answers yet.' },
|
|
92
|
+
'compact.summary_failed': { ru: 'Не удалось получить резюме от модели: {v}', en: 'Could not get the summary from the model: {v}' },
|
|
93
|
+
'compact.done': { ru: '✅ История сжата, открыт новый чат с резюме.', en: '✅ History compacted, a new chat with the summary is open.' },
|
|
94
|
+
'compact.report': { ru: 'Резюме перенесено в новый чат ({chars} символов, токенов было ~{tokens}).', en: 'The summary was carried into the new chat ({chars} chars, ~{tokens} tokens before).' },
|
|
95
|
+
'compact.no_chat': { ru: 'Чат ещё не создан — сжимать нечего.', en: 'No chat created yet — nothing to compact.' },
|
|
89
96
|
'diff.not_repo': { ru: 'Не git-репозиторий.', en: 'Not a git repository.' },
|
|
90
97
|
'export.done': { ru: 'Сессия выгружена: {v}', en: 'Session exported: {v}' },
|
|
91
98
|
'export.outside': { ru: 'Путь вне рабочей директории.', en: 'Path is outside the working directory.' },
|
|
@@ -209,6 +216,83 @@ const CATALOG = {
|
|
|
209
216
|
'msg.reload_error': { ru: 'Ошибка reload:', en: 'Reload error:' },
|
|
210
217
|
'msg.abort_gen_short': { ru: '⏹ Esc — прерываю генерацию...', en: '⏹ Esc — aborting generation...' },
|
|
211
218
|
'msg.abort_ctrlc_short': { ru: '⏹ Ctrl+C — прерываю генерацию...', en: '⏹ Ctrl+C — aborting generation...' },
|
|
219
|
+
// ---------- DeepSeek-side problems (what is happening right now) ----------
|
|
220
|
+
// The operator must understand WHY the agent is waiting or stopped: a rate
|
|
221
|
+
// limit, a server hiccup, a refused send, a login problem. These strings are
|
|
222
|
+
// printed around the moment they happen (browser.ts / agent-loop.ts), not as
|
|
223
|
+
// a bare error at the end of the run.
|
|
224
|
+
'ds.rate_limit_wait': {
|
|
225
|
+
ru: '⏳ DeepSeek: «слишком часто». Жду {min} мин ({attempt}/{max}) и повторю отправку...',
|
|
226
|
+
en: '⏳ DeepSeek: "too frequent". Waiting {min} min ({attempt}/{max}), then resending...',
|
|
227
|
+
},
|
|
228
|
+
'ds.rate_limit_give_up': {
|
|
229
|
+
ru: '✖ DeepSeek не принял сообщение после {attempt} пауз по {min} мин. Подожди и попробуй снова.',
|
|
230
|
+
en: '✖ DeepSeek refused the message after {attempt} waits of {min} min. Wait and try again.',
|
|
231
|
+
},
|
|
232
|
+
'ds.server_busy_wait': {
|
|
233
|
+
ru: '⏳ DeepSeek: сервер занят. Жду {sec}с ({attempt}/{max}) и повторю...',
|
|
234
|
+
en: '⏳ DeepSeek: server busy. Waiting {sec}s ({attempt}/{max}), then retrying...',
|
|
235
|
+
},
|
|
236
|
+
'ds.server_busy_give_up': {
|
|
237
|
+
ru: '✖ DeepSeek: сервер не ответил после {attempt} повторов. Попробуй позже.',
|
|
238
|
+
en: '✖ DeepSeek: the server did not respond after {attempt} retries. Try again later.',
|
|
239
|
+
},
|
|
240
|
+
'ds.ask_retry': {
|
|
241
|
+
ru: '⚠ Ответ не получен ({attempt}/{max}): {error} — повторяю...',
|
|
242
|
+
en: '⚠ No answer yet ({attempt}/{max}): {error} — retrying...',
|
|
243
|
+
},
|
|
244
|
+
'ds.ask_restart_browser': {
|
|
245
|
+
ru: '⚠ Браузер потерял страницу — перезапускаю и вхожу заново...',
|
|
246
|
+
en: '⚠ The browser lost the page — restarting and signing in again...',
|
|
247
|
+
},
|
|
248
|
+
'ds.ask_restart_failed': {
|
|
249
|
+
ru: '⚠ Не удалось перезапустить браузер: {error}',
|
|
250
|
+
en: '⚠ Could not restart the browser: {error}',
|
|
251
|
+
},
|
|
252
|
+
'ds.ask_failed': {
|
|
253
|
+
ru: '✖ Не удалось получить ответ от DeepSeek после {max} попыток: {error}',
|
|
254
|
+
en: '✖ Could not get an answer from DeepSeek after {max} attempts: {error}',
|
|
255
|
+
},
|
|
256
|
+
'ds.input_missing': {
|
|
257
|
+
ru: '✖ Не найдено поле ввода на странице DeepSeek. Запусти /debug-dom и поправь INPUT_SELECTORS.',
|
|
258
|
+
en: '✖ The DeepSeek input field was not found. Run /debug-dom and fix INPUT_SELECTORS.',
|
|
259
|
+
},
|
|
260
|
+
'ds.input_partial': {
|
|
261
|
+
ru: '✖ Не удалось вставить текст в поле ввода целиком ({got} из {want} символов). Сообщение не отправлено, чтобы не отправить обрезанный текст. Попробуй ещё раз или разбей сообщение.',
|
|
262
|
+
en: '✖ Could not paste the whole text into the input ({got} of {want} chars). The message was not sent to avoid sending a truncated text. Retry or split the message.',
|
|
263
|
+
},
|
|
264
|
+
'ds.send_no_start': {
|
|
265
|
+
ru: '✖ Ответ не начал генерироваться за 35с даже после повторной отправки. Проверь чат DeepSeek вручную (возможно, кнопка отправки не нажимается или сессия разлогинилась).',
|
|
266
|
+
en: '✖ The answer did not start generating within 35s even after resending. Check the DeepSeek chat manually (the send button may not be clickable or the session may have expired).',
|
|
267
|
+
},
|
|
268
|
+
'ds.send_no_new_answer': {
|
|
269
|
+
ru: '✖ Новый ответ не получен — на странице остался прежний текст. Возможно, сообщение не отправилось. Проверь чат DeepSeek вручную.',
|
|
270
|
+
en: '✖ No new answer received — the page still shows the previous text. The message may not have been sent. Check the DeepSeek chat manually.',
|
|
271
|
+
},
|
|
272
|
+
'ds.answer_timeout': {
|
|
273
|
+
ru: '⏳ DeepSeek не ответил за {sec}с — повторяю запрос...',
|
|
274
|
+
en: '⏳ DeepSeek did not answer within {sec}s — retrying the request...',
|
|
275
|
+
},
|
|
276
|
+
'ds.answer_timeout_give_up': {
|
|
277
|
+
ru: '✖ DeepSeek перестал отвечать, лимит повторов исчерпан. Модель не дала ответа — проверь чат DeepSeek вручную.',
|
|
278
|
+
en: '✖ DeepSeek stopped responding, retry limit exhausted. The model gave no answer — check the DeepSeek chat manually.',
|
|
279
|
+
},
|
|
280
|
+
'ds.tool_retry': {
|
|
281
|
+
ru: '⏳ Агент не распознал ответ модели — прошу продолжить ({attempt}/{max})...',
|
|
282
|
+
en: '⏳ The agent did not recognize the model answer — asking it to continue ({attempt}/{max})...',
|
|
283
|
+
},
|
|
284
|
+
'ds.stalled': {
|
|
285
|
+
ru: '⚠ Агент остановился, не завершив задачу (модель перестала вызывать инструменты). Проверь чат DeepSeek — задача может быть не выполнена.',
|
|
286
|
+
en: '⚠ The agent stopped before finishing (the model stopped calling tools). Check the DeepSeek chat — the task may be incomplete.',
|
|
287
|
+
},
|
|
288
|
+
'ds.not_launched': {
|
|
289
|
+
ru: '✖ Браузер не запущен.',
|
|
290
|
+
en: '✖ The browser is not launched.',
|
|
291
|
+
},
|
|
292
|
+
'ds.open_chat_failed': {
|
|
293
|
+
ru: '✖ Не удалось открыть чат {id}: {error}',
|
|
294
|
+
en: '✖ Could not open chat {id}: {error}',
|
|
295
|
+
},
|
|
212
296
|
// ---------- self-review ----------
|
|
213
297
|
'self.review_failed': { ru: 'Самообзор провалился:', en: 'Self-review failed:' },
|
|
214
298
|
'self.fix_usage': { ru: 'Использование: /self-fix <name> [фокус]', en: 'Usage: /self-fix <name> [focus]' },
|
|
@@ -238,6 +322,13 @@ const CATALOG = {
|
|
|
238
322
|
'chats.prompt_will_resend': { ru: ' Системный промпт будет переслан на следующей задаче.\n', en: ' System prompt will be resent on the next task.\n' },
|
|
239
323
|
'chats.current_id': { ru: 'Текущий chat id: {v}', en: 'Current chat id: {v}' },
|
|
240
324
|
'chats.not_created': { ru: 'Чат ещё не создан.', en: 'No chat created yet.' },
|
|
325
|
+
'chats.history_title': { ru: 'Диалог чата:', en: 'Chat dialogue:' },
|
|
326
|
+
'chats.history_tokens': { ru: 'Контекст чата: ~{v} токенов', en: 'Chat context: ~{v} tokens' },
|
|
327
|
+
'chats.history_empty': { ru: 'Диалог пуст или не удалось прочитать сообщения.', en: 'The dialogue is empty or the messages could not be read.' },
|
|
328
|
+
'chats.history_service_only': { ru: 'В этом чате нет пользовательских реплик — только служебные сообщения агента (вызовы инструментов).', en: 'This chat has no user turns — only the agent service messages (tool calls).' },
|
|
329
|
+
'chats.history_truncated': { ru: '… показаны последние {n} сообщений.', en: '… showing the last {n} messages.' },
|
|
330
|
+
'chats.history_you': { ru: 'Вы', en: 'You' },
|
|
331
|
+
'chats.history_agent': { ru: 'Агент', en: 'Agent' },
|
|
241
332
|
'sessions.dir': { ru: 'Папка сессий: {v}', en: 'Sessions dir: {v}' },
|
|
242
333
|
'sessions.none': { ru: 'Сохранённых сессий нет. Они появятся после первой задачи/чата.', en: 'No saved sessions. They appear after the first task/chat.' },
|
|
243
334
|
'sessions.restore_hint': { ru: 'Восстановить: /resume-id <id> (полный id) или /resume <n> после /chats.', en: 'Restore: /resume-id <id> (full id) or /resume <n> after /chats.' },
|
|
@@ -272,6 +363,10 @@ const CATALOG = {
|
|
|
272
363
|
'mcp.title': { ru: 'MCP-серверы (инструментов: {n}):', en: 'MCP servers ({n} tools):' },
|
|
273
364
|
'mcp.status_error': { ru: '(ошибка: {v})', en: '(error: {v})' },
|
|
274
365
|
'status.mcp': { ru: 'MCP-инструменты: {v}', en: 'MCP tools: {v}' },
|
|
366
|
+
'status.tokens': {
|
|
367
|
+
ru: 'Контекст (токенов): {v}',
|
|
368
|
+
en: 'Context (tokens): {v}',
|
|
369
|
+
},
|
|
275
370
|
// ---------- config menu ----------
|
|
276
371
|
'cfg.group.ui': { ru: 'Интерфейс', en: 'Interface' },
|
|
277
372
|
'cfg.group.agent': { ru: 'Агент', en: 'Agent' },
|
package/dist/index.js
CHANGED
|
@@ -16,10 +16,12 @@ import { translate, normalizeLocale, localeDisplayName, isLocale, } from './i18n
|
|
|
16
16
|
import { Transcript } from './transcript.js';
|
|
17
17
|
import { UndoStore } from './undo.js';
|
|
18
18
|
import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
|
|
19
|
-
import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, renderPermissions, resolveExtraDir, buildReviewPrompt, } from './commands.js';
|
|
19
|
+
import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, renderPermissions, resolveExtraDir, buildReviewPrompt, trimRestoredMessages, RESTORED_HISTORY_LIMIT, buildCompactPrompt, buildCompactCarryover, } from './commands.js';
|
|
20
|
+
import { renderMarkdown } from './markdown.js';
|
|
20
21
|
import { closeWeb } from './web.js';
|
|
21
22
|
import { saveSession, loadLastSession, listSessions, sessionsDir, } from './sessions.js';
|
|
22
23
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const NL = String.fromCharCode(10);
|
|
23
25
|
// True when a DeepSeek session/credentials were stored earlier (auth.json).
|
|
24
26
|
// Used by /doctor to report that auto re-login is ready.
|
|
25
27
|
function authMarkerExists() {
|
|
@@ -242,6 +244,7 @@ ${theme.bold(t('help.commands'))}
|
|
|
242
244
|
${t('help.cmd.permissions')}
|
|
243
245
|
${t('help.cmd.add_dir')}
|
|
244
246
|
${t('help.cmd.review')}
|
|
247
|
+
${t('help.cmd.compact')}
|
|
245
248
|
${t('help.cmd.config')}
|
|
246
249
|
${t('help.cmd.lang')}
|
|
247
250
|
${t('help.cmd.debug_dom')}
|
|
@@ -297,6 +300,7 @@ const SLASH_COMMANDS = [
|
|
|
297
300
|
{ name: '/permissions', key: 'help.cmd.permissions' },
|
|
298
301
|
{ name: '/add-dir', key: 'help.cmd.add_dir' },
|
|
299
302
|
{ name: '/review', key: 'help.cmd.review' },
|
|
303
|
+
{ name: '/compact', key: 'help.cmd.compact' },
|
|
300
304
|
{ name: '/config', key: 'help.cmd.config' },
|
|
301
305
|
{ name: '/skills', key: 'help.cmd.skills' },
|
|
302
306
|
{ name: '/memory', key: 'help.cmd.memory' },
|
|
@@ -788,6 +792,72 @@ async function resolveWorkdir() {
|
|
|
788
792
|
}
|
|
789
793
|
return dir;
|
|
790
794
|
}
|
|
795
|
+
// ---------- restored dialogue ----------
|
|
796
|
+
/**
|
|
797
|
+
* Print the dialogue of the just-opened chat into the terminal. Called by
|
|
798
|
+
* /resume, /resume-id and on startup when a previous session is restored, so
|
|
799
|
+
* the operator sees the context instead of a bare "Chat opened.".
|
|
800
|
+
*
|
|
801
|
+
* Best-effort: a DOM-scraping failure must never break the restore.
|
|
802
|
+
*/
|
|
803
|
+
async function printRestoredHistory(browser, ui, chatId = null) {
|
|
804
|
+
// Prefer DeepSeek's own history endpoint (complete, unvirtualized); fall
|
|
805
|
+
// back to scraping the rendered DOM when the request fails.
|
|
806
|
+
let all = [];
|
|
807
|
+
let fetchCount = -1;
|
|
808
|
+
let domCount = -1;
|
|
809
|
+
if (chatId && browser.fetchChatMessages) {
|
|
810
|
+
all = await browser.fetchChatMessages(chatId).catch(() => []);
|
|
811
|
+
fetchCount = all.length;
|
|
812
|
+
}
|
|
813
|
+
if (!all.length && browser.readChatMessages) {
|
|
814
|
+
all = await browser.readChatMessages().catch(() => []);
|
|
815
|
+
domCount = all.length;
|
|
816
|
+
}
|
|
817
|
+
const messages = trimRestoredMessages(all);
|
|
818
|
+
// Always surface the diagnostic line when the history could not be turned
|
|
819
|
+
// into anything printable — otherwise "the dialogue is empty" is a dead end
|
|
820
|
+
// (was the fetch blocked? did the chat really have no user turns?).
|
|
821
|
+
if (!all.length || (debug && !messages.length)) {
|
|
822
|
+
console.log(theme.dim(`[history] chat=${chatId || '-'} fetch=${fetchCount} dom=${domCount} raw=${all.length} shown=${messages.length} auth=${browser._apiAuth ? 'yes' : 'no'} err=${browser._lastHistoryError || '-'}`));
|
|
823
|
+
}
|
|
824
|
+
if (!all.length) {
|
|
825
|
+
console.log(theme.system(t('chats.history_empty')));
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (!messages.length) {
|
|
829
|
+
// The history exists but contains only service/protocol messages (e.g.
|
|
830
|
+
// a chat where only the system-prompt and tool-calls were stored).
|
|
831
|
+
console.log(theme.system(t('chats.history_service_only', { n: String(all.length) })));
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
const out = (text) => {
|
|
835
|
+
if (ui)
|
|
836
|
+
ui.printAbove(text);
|
|
837
|
+
else
|
|
838
|
+
console.log(text);
|
|
839
|
+
};
|
|
840
|
+
out(theme.system(t('chats.history_title')));
|
|
841
|
+
const restoredTokens = browser.getLastTokenUsage();
|
|
842
|
+
if (typeof restoredTokens === 'number') {
|
|
843
|
+
out(theme.dim(t('chats.history_tokens', { v: String(restoredTokens) })));
|
|
844
|
+
}
|
|
845
|
+
for (const m of messages) {
|
|
846
|
+
if (m.role === 'user') {
|
|
847
|
+
out(theme.user('❯ ' + t('chats.history_you') + ': ') + m.text.trim());
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
out(NL +
|
|
851
|
+
theme.assistant('● ' + t('chats.history_agent') + ':') +
|
|
852
|
+
NL +
|
|
853
|
+
renderMarkdown(m.text.trim()) +
|
|
854
|
+
NL);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (all.length > messages.length) {
|
|
858
|
+
out(theme.dim(t('chats.history_truncated', { n: String(RESTORED_HISTORY_LIMIT) })));
|
|
859
|
+
}
|
|
860
|
+
}
|
|
791
861
|
// ---------- task runner ----------
|
|
792
862
|
async function runTask(browser, tools, taskText, workdir, opts, attachments = []) {
|
|
793
863
|
const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, } = opts;
|
|
@@ -1091,6 +1161,7 @@ async function main() {
|
|
|
1091
1161
|
sendSystemPromptNext = resendPrompt;
|
|
1092
1162
|
saveLastChat(resumeId, currentWorkdir);
|
|
1093
1163
|
console.log(theme.system(t('msg.chat_opened') + ' ' + resumeId + String.fromCharCode(10)));
|
|
1164
|
+
await printRestoredHistory(browser, null, resumeId);
|
|
1094
1165
|
}
|
|
1095
1166
|
catch (e) {
|
|
1096
1167
|
console.error(theme.error(t('msg.open_chat_error', { v: e.message })));
|
|
@@ -1130,6 +1201,10 @@ async function main() {
|
|
|
1130
1201
|
});
|
|
1131
1202
|
editor = ed;
|
|
1132
1203
|
ed.setTmpDir(TMP_DIR);
|
|
1204
|
+
// The token context right-aligned on the status line (above the input).
|
|
1205
|
+
// The editor pulls the number on every render, so it follows the live
|
|
1206
|
+
// DeepSeek counter (accumulated_token_usage) without a polling timer.
|
|
1207
|
+
ed.onContextQuery = () => browser.getLastTokenUsage();
|
|
1133
1208
|
ed.onAttach = async (raw) => {
|
|
1134
1209
|
// Case 1: the paste is the image data itself (data URL / base64 blob).
|
|
1135
1210
|
const image = parseImagePaste(raw);
|
|
@@ -1774,6 +1849,7 @@ async function main() {
|
|
|
1774
1849
|
theme.system(resendPrompt
|
|
1775
1850
|
? ' Системный промпт будет переслан на следующей задаче.\n'
|
|
1776
1851
|
: ' Контекст чата сохранён. Системный промпт не пересылается (--resend-prompt чтобы дослать).\n'));
|
|
1852
|
+
await printRestoredHistory(browser, editor, pick.id);
|
|
1777
1853
|
}
|
|
1778
1854
|
catch (e) {
|
|
1779
1855
|
console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
|
|
@@ -1831,6 +1907,7 @@ async function main() {
|
|
|
1831
1907
|
transcript.log('resume_chat', { id });
|
|
1832
1908
|
console.log(theme.assistant('Чат открыт.') +
|
|
1833
1909
|
theme.system(String.fromCharCode(10)));
|
|
1910
|
+
await printRestoredHistory(browser, editor, id);
|
|
1834
1911
|
}
|
|
1835
1912
|
catch (e) {
|
|
1836
1913
|
console.error(theme.error(t('msg.open_chat_error', { v: '' })), e.message);
|
|
@@ -2040,6 +2117,10 @@ async function main() {
|
|
|
2040
2117
|
console.log(theme.system(t('status.mcp', {
|
|
2041
2118
|
v: mcpPool ? String(mcpPool.status().toolCount) : t('common.none'),
|
|
2042
2119
|
})));
|
|
2120
|
+
const tokens = browser.getLastTokenUsage();
|
|
2121
|
+
console.log(theme.system(t('status.tokens', {
|
|
2122
|
+
v: tokens === null ? t('common.unknown') : String(tokens),
|
|
2123
|
+
})));
|
|
2043
2124
|
continue;
|
|
2044
2125
|
}
|
|
2045
2126
|
if (lower === '/config' || lower.startsWith('/config ')) {
|
|
@@ -2073,7 +2154,7 @@ async function main() {
|
|
|
2073
2154
|
// best-effort
|
|
2074
2155
|
}
|
|
2075
2156
|
}
|
|
2076
|
-
console.log(theme.system(renderCost(stats, transcript.file)));
|
|
2157
|
+
console.log(theme.system(renderCost(stats, transcript.file, browser.getLastTokenUsage())));
|
|
2077
2158
|
continue;
|
|
2078
2159
|
}
|
|
2079
2160
|
if (lower === '/export' || lower.startsWith('/export ')) {
|
|
@@ -2167,6 +2248,91 @@ async function main() {
|
|
|
2167
2248
|
console.log(theme.system(t('adddir.note', { v: res.path })));
|
|
2168
2249
|
continue;
|
|
2169
2250
|
}
|
|
2251
|
+
if (lower === '/compact') {
|
|
2252
|
+
// Compaction: ask DeepSeek (in the CURRENT chat) to compress the
|
|
2253
|
+
// history into a handover summary, then start a NEW chat, resend the
|
|
2254
|
+
// system prompt and post the summary as the carried-over context.
|
|
2255
|
+
// This keeps the model working with a small context while nothing is
|
|
2256
|
+
// lost: the summary plus the system prompt are all the new chat needs.
|
|
2257
|
+
if (!currentChatId) {
|
|
2258
|
+
currentChatId = await browser.getCurrentChatId();
|
|
2259
|
+
}
|
|
2260
|
+
if (!currentChatId) {
|
|
2261
|
+
console.error(theme.warn(t('compact.no_chat')));
|
|
2262
|
+
continue;
|
|
2263
|
+
}
|
|
2264
|
+
const beforeTokens = browser.getLastTokenUsage();
|
|
2265
|
+
if (editor)
|
|
2266
|
+
editor.lock(t('msg.input_locked'));
|
|
2267
|
+
try {
|
|
2268
|
+
console.log(theme.system(t('compact.start')));
|
|
2269
|
+
// 1) Ask the OLD chat to summarize itself. agent: true - this is a
|
|
2270
|
+
// real back-and-forth, so the send throttle applies.
|
|
2271
|
+
let summary = '';
|
|
2272
|
+
try {
|
|
2273
|
+
summary = await browser.ask(buildCompactPrompt(currentLocale), {
|
|
2274
|
+
agent: true,
|
|
2275
|
+
timeout: Math.max(60_000, config.browser.answerTimeoutMs),
|
|
2276
|
+
});
|
|
2277
|
+
}
|
|
2278
|
+
catch (e) {
|
|
2279
|
+
console.error(theme.error(t('compact.summary_failed', { v: e.message })));
|
|
2280
|
+
continue;
|
|
2281
|
+
}
|
|
2282
|
+
summary = String(summary || '').trim();
|
|
2283
|
+
// A model "answer" that is actually an error/abort sentinel is not a
|
|
2284
|
+
// summary - do not carry it over.
|
|
2285
|
+
if (!summary || /^\(прервано пользователем\)$/.test(summary)) {
|
|
2286
|
+
console.error(theme.error(t('compact.summary_failed', { v: summary || t('common.unknown') })));
|
|
2287
|
+
continue;
|
|
2288
|
+
}
|
|
2289
|
+
transcript.log('compact_summary', {
|
|
2290
|
+
chars: summary.length,
|
|
2291
|
+
beforeTokens,
|
|
2292
|
+
});
|
|
2293
|
+
// 2) New chat + system prompt + the summary as the first message.
|
|
2294
|
+
await browser.newChat();
|
|
2295
|
+
await browser.ask(mod.buildSystemPrompt({
|
|
2296
|
+
workdir: currentWorkdir,
|
|
2297
|
+
tools: mod.createTools(currentWorkdir, { undo }),
|
|
2298
|
+
locale: currentLocale,
|
|
2299
|
+
}), { timeout: 60_000, agent: false });
|
|
2300
|
+
await browser.ask(buildCompactCarryover(summary, task ?? undefined), {
|
|
2301
|
+
timeout: 60_000,
|
|
2302
|
+
agent: false,
|
|
2303
|
+
});
|
|
2304
|
+
currentChatId = await browser.getCurrentChatId();
|
|
2305
|
+
saveLastChat(currentChatId, currentWorkdir);
|
|
2306
|
+
freshChatNext = false;
|
|
2307
|
+
// The new chat already carries the system prompt and the context.
|
|
2308
|
+
sendSystemPromptNext = false;
|
|
2309
|
+
console.log(theme.assistant(t('compact.done') +
|
|
2310
|
+
String.fromCharCode(10) +
|
|
2311
|
+
t('compact.report', {
|
|
2312
|
+
chars: summary.length,
|
|
2313
|
+
tokens: beforeTokens === null
|
|
2314
|
+
? t('common.unknown')
|
|
2315
|
+
: String(beforeTokens),
|
|
2316
|
+
})));
|
|
2317
|
+
if (editor) {
|
|
2318
|
+
editor.printAbove(theme.dim(String.fromCharCode(10) +
|
|
2319
|
+
'--- compacted context ---' +
|
|
2320
|
+
String.fromCharCode(10) +
|
|
2321
|
+
summary +
|
|
2322
|
+
String.fromCharCode(10) +
|
|
2323
|
+
'--- end ---' +
|
|
2324
|
+
String.fromCharCode(10)));
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
catch (e) {
|
|
2328
|
+
console.error(theme.error(e.message));
|
|
2329
|
+
}
|
|
2330
|
+
finally {
|
|
2331
|
+
if (editor)
|
|
2332
|
+
editor.unlock();
|
|
2333
|
+
}
|
|
2334
|
+
continue;
|
|
2335
|
+
}
|
|
2170
2336
|
if (lower === '/review' || lower.startsWith('/review ')) {
|
|
2171
2337
|
const rest = trimmed.slice('/review'.length).trim();
|
|
2172
2338
|
const staged = rest.indexOf("--staged") !== -1;
|
package/dist/input.js
CHANGED
|
@@ -170,6 +170,31 @@ export function layoutInput(promptStr, buf, cursor, cols) {
|
|
|
170
170
|
}
|
|
171
171
|
return { rows, cursorRow, cursorCol };
|
|
172
172
|
}
|
|
173
|
+
// Format a token count for the status line: compact (10k, 125k) plus the
|
|
174
|
+
// percentage of CONTEXT_LIMIT. Exported so it is unit-tested without a live
|
|
175
|
+
// editor. A null/undefined/NaN count renders an empty string (no status).
|
|
176
|
+
export const CONTEXT_LIMIT = 1_000_000;
|
|
177
|
+
export function formatTokenStatus(tokens, limit = CONTEXT_LIMIT) {
|
|
178
|
+
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
|
|
179
|
+
return '';
|
|
180
|
+
}
|
|
181
|
+
const n = Math.round(tokens);
|
|
182
|
+
let compact;
|
|
183
|
+
if (n >= 1_000_000) {
|
|
184
|
+
const m = n / 1_000_000;
|
|
185
|
+
compact = (Number.isInteger(m) ? String(m) : m.toFixed(1)) + 'M';
|
|
186
|
+
}
|
|
187
|
+
else if (n >= 1_000) {
|
|
188
|
+
const k = n / 1_000;
|
|
189
|
+
compact = (k >= 100 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, '')) + 'k';
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
compact = String(n);
|
|
193
|
+
}
|
|
194
|
+
const pct = Math.max(0, (n / limit) * 100);
|
|
195
|
+
const pctStr = pct >= 10 ? String(Math.round(pct)) : pct.toFixed(1);
|
|
196
|
+
return compact + ' · ' + pctStr + '%';
|
|
197
|
+
}
|
|
173
198
|
export class LineEditor {
|
|
174
199
|
promptStr;
|
|
175
200
|
buf;
|
|
@@ -218,6 +243,13 @@ export class LineEditor {
|
|
|
218
243
|
// Interface language for the editor's own labels (hint, answer marker,
|
|
219
244
|
// pause status). Everything the OPERATOR sees must be localized.
|
|
220
245
|
locale;
|
|
246
|
+
// Token context for the status line: the compact count (10k/125k) plus the
|
|
247
|
+
// percentage of the context limit, right-aligned above the input line. Null
|
|
248
|
+
// hides it. Updated by the caller from browser.getLastTokenUsage().
|
|
249
|
+
contextStatus;
|
|
250
|
+
// A callback the editor calls to fetch the CURRENT token count before every
|
|
251
|
+
// status render, so the status line stays fresh without the caller polling.
|
|
252
|
+
onContextQuery;
|
|
221
253
|
constructor({ prompt = '> ', commands = [], locale = 'ru' } = {}) {
|
|
222
254
|
this.locale = locale;
|
|
223
255
|
this.promptStr = prompt;
|
|
@@ -250,6 +282,22 @@ export class LineEditor {
|
|
|
250
282
|
this.onAttach = null;
|
|
251
283
|
this.onClipboard = null;
|
|
252
284
|
this.locked = false;
|
|
285
|
+
this.contextStatus = null;
|
|
286
|
+
this.onContextQuery = null;
|
|
287
|
+
}
|
|
288
|
+
// The token status for the CURRENT render: refreshed from onContextQuery
|
|
289
|
+
// when wired, otherwise the last value passed to setContextStatus().
|
|
290
|
+
_contextForRender() {
|
|
291
|
+
if (this.onContextQuery) {
|
|
292
|
+
try {
|
|
293
|
+
const n = this.onContextQuery();
|
|
294
|
+
this.contextStatus = n === null || n === undefined ? null : formatTokenStatus(n) || null;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
// A broken callback must never break the render.
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return this.contextStatus;
|
|
253
301
|
}
|
|
254
302
|
// Read the OS clipboard for an image and insert its marker. Used when the
|
|
255
303
|
// terminal sends no usable paste data (Ctrl+V / right-click / empty paste).
|
|
@@ -370,6 +418,11 @@ export class LineEditor {
|
|
|
370
418
|
this.promptStr = str;
|
|
371
419
|
this._render();
|
|
372
420
|
}
|
|
421
|
+
// Update the token-context text shown at the right of the status line.
|
|
422
|
+
setContextStatus(tokens) {
|
|
423
|
+
this.contextStatus = formatTokenStatus(tokens) || null;
|
|
424
|
+
this._render();
|
|
425
|
+
}
|
|
373
426
|
// Update the interface language (labels: hint, answer marker, pause).
|
|
374
427
|
setLocale(locale) {
|
|
375
428
|
this.locale = locale;
|
|
@@ -402,11 +455,29 @@ export class LineEditor {
|
|
|
402
455
|
const cols = process.stdout.columns || 80;
|
|
403
456
|
let out = '';
|
|
404
457
|
let top = 0;
|
|
458
|
+
// Status line above the input: the spinner/answer text on the left and the
|
|
459
|
+
// token context right-aligned on the SAME row (10k · 12%). The context is
|
|
460
|
+
// refreshed from onContextQuery() on every render, so it follows the live
|
|
461
|
+
// DeepSeek counter without a polling timer of its own.
|
|
462
|
+
const ctx = this._contextForRender();
|
|
463
|
+
const ctxText = ctx ? theme.dim(ctx) : '';
|
|
405
464
|
if (this.statusText) {
|
|
406
|
-
|
|
465
|
+
if (ctxText) {
|
|
466
|
+
const pad = Math.max(1, cols - visLen(this.statusText) - visLen(ctxText));
|
|
467
|
+
out += this.statusText + ' '.repeat(pad) + ctxText + NL;
|
|
468
|
+
}
|
|
469
|
+
else {
|
|
470
|
+
out += this.statusText + NL;
|
|
471
|
+
}
|
|
407
472
|
// The status may wrap onto several lines — we account for this,
|
|
408
473
|
// otherwise the block erase misses and statuses pile up.
|
|
409
|
-
top = visRows(this.statusText, cols);
|
|
474
|
+
top = visRows(this.statusText + (ctxText ? ' '.repeat(2) + ctxText : ''), cols);
|
|
475
|
+
}
|
|
476
|
+
else if (ctxText) {
|
|
477
|
+
// Idle: no spinner, but the context still belongs on its own line just
|
|
478
|
+
// above the input, right-aligned.
|
|
479
|
+
out += ' '.repeat(Math.max(0, cols - visLen(ctxText))) + ctxText + NL;
|
|
480
|
+
top = visRows(ctxText, cols);
|
|
410
481
|
}
|
|
411
482
|
const lay = layoutInput(this.promptStr, this.buf, this.cursor, cols);
|
|
412
483
|
out += lay.rows.map((r) => r.prefix + r.text).join(NL);
|
package/dist/net-capture.js
CHANGED
|
@@ -159,6 +159,53 @@ export function extractAnswer(body) {
|
|
|
159
159
|
return sse;
|
|
160
160
|
return extractFromJson(body);
|
|
161
161
|
}
|
|
162
|
+
// The CONTEXT size (in tokens) DeepSeek reports for the current answer.
|
|
163
|
+
//
|
|
164
|
+
// chat.deepseek.com does not expose prompt_tokens/completion_tokens the way
|
|
165
|
+
// the API does. What it sends instead is `accumulated_token_usage` — a
|
|
166
|
+
// CUMULATIVE counter of the whole chat so far, present both in the SSE
|
|
167
|
+
// completion stream and (per message) in /api/v0/chat/history_messages. It
|
|
168
|
+
// is the number the operator wants for "how much context is used": the
|
|
169
|
+
// latest value is the current size of the chat context in tokens.
|
|
170
|
+
//
|
|
171
|
+
// SSE placement:
|
|
172
|
+
// * the initial fragment: v.response.accumulated_token_usage
|
|
173
|
+
// * an update chunk: {"p":"response","o":"BATCH",
|
|
174
|
+
// "v":[{"p":"accumulated_token_usage","v":N}, ...]}
|
|
175
|
+
// We take the LAST value seen (the freshest).
|
|
176
|
+
//
|
|
177
|
+
// Returns null when the body carries no counter (e.g. an OpenAI-shaped
|
|
178
|
+
// response or a non-answer endpoint) so the caller can keep the old value.
|
|
179
|
+
export function extractTokenUsage(body) {
|
|
180
|
+
let found = null;
|
|
181
|
+
const consider = (n) => {
|
|
182
|
+
if (typeof n === 'number' && Number.isFinite(n) && n >= 0)
|
|
183
|
+
found = n;
|
|
184
|
+
};
|
|
185
|
+
for (const obj of parseDataLines(body)) {
|
|
186
|
+
if (obj == null || typeof obj !== 'object')
|
|
187
|
+
continue;
|
|
188
|
+
const o = obj;
|
|
189
|
+
// The initial fragment: v.response.accumulated_token_usage.
|
|
190
|
+
const v = o.v;
|
|
191
|
+
const resp = v && typeof v === 'object'
|
|
192
|
+
? v.response
|
|
193
|
+
: undefined;
|
|
194
|
+
if (resp)
|
|
195
|
+
consider(resp.accumulated_token_usage);
|
|
196
|
+
// A BATCH update: v is an array of {"p":"accumulated_token_usage","v":N}.
|
|
197
|
+
if (Array.isArray(o.v)) {
|
|
198
|
+
for (const item of o.v) {
|
|
199
|
+
if (item && typeof item === 'object') {
|
|
200
|
+
const it = item;
|
|
201
|
+
if (it.p === 'accumulated_token_usage')
|
|
202
|
+
consider(it.v);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return found;
|
|
208
|
+
}
|
|
162
209
|
// Saves the DeepSeek network response body to disk for post-mortem analysis.
|
|
163
210
|
// The files live in ~/.zames/net-log — from them the real answer format is visible.
|
|
164
211
|
// DEBUG ONLY: disabled unless ZAMES_NET_DEBUG=1. It writes a file per network
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zames_pro",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.19.0",
|
|
4
4
|
"description": "Terminal coding agent over chat.deepseek.com via Playwright",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
],
|
|
16
16
|
"scripts": {
|
|
17
17
|
"start": "node ./dist/index.js",
|
|
18
|
-
"dev": "tsx ./src/index.ts --dev",
|
|
18
|
+
"dev": "ZAMES_NET_DEBUG=1 tsx ./src/index.ts --dev",
|
|
19
19
|
"build": "tsc -p tsconfig.build.json",
|
|
20
20
|
"typecheck": "tsc --noEmit",
|
|
21
21
|
"prepublishOnly": "npm run build",
|