zames_pro 2.31.0 → 2.34.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/dist/agent-loop.js +4 -1
- package/dist/browser.js +32 -1
- package/dist/commands.js +96 -4
- package/dist/i18n.js +96 -5
- package/dist/index.js +339 -30
- package/dist/input.js +89 -40
- package/dist/scheduler.js +185 -0
- package/dist/theme.js +1 -0
- package/package.json +1 -1
package/dist/agent-loop.js
CHANGED
|
@@ -4,7 +4,7 @@ import { getGitContext, formatGitContext } from './gitTools.js';
|
|
|
4
4
|
import { parseXmlToolCalls } from './xml-toolcall.js';
|
|
5
5
|
import { translate } from './i18n.js';
|
|
6
6
|
import { normText } from './browser.js';
|
|
7
|
-
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, }) {
|
|
7
|
+
export async function runAgentLoop({ browser, tools, task, workdir, maxIterations = 0, freshChat = false, sendSystemPrompt = false, transcript = null, attachments = [], onThinking = () => { }, onSendPause = () => { }, onSendState = () => { }, onNotice = () => { }, onAssistantThought = () => { }, onToolCall = () => { }, onToolResult = () => { }, onAssistantMessage = () => { }, onChatReady = () => { }, onWarning = () => { }, debugLog = false, locale = 'ru', askDeadlineMs = 240_000, maxAfterToolRetries = 6, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, }) {
|
|
8
8
|
// UI callbacks must NEVER break the agent loop. A rendering error (a huge
|
|
9
9
|
// tool result, a broken markdown frame, a closed terminal) used to throw
|
|
10
10
|
// out of the loop right after a tool call — the session looked "stopped
|
|
@@ -30,6 +30,9 @@ export async function runAgentLoop({ browser, tools, task, workdir, maxIteration
|
|
|
30
30
|
// seconds, the UI shows an animated status instead of a frozen line.
|
|
31
31
|
const safeSendPause = safe(onSendPause);
|
|
32
32
|
browser.onSendPause = safeSendPause;
|
|
33
|
+
// Coarse lifecycle of the send (generating/paused/settled) for the status
|
|
34
|
+
// line. Same best-effort wiring as the other hooks.
|
|
35
|
+
browser.onSendState = safe(onSendState);
|
|
33
36
|
// Service notices (rate limit, server busy, resend) must reach the OPERATOR.
|
|
34
37
|
// While a LineEditor is active a raw console.error is overwritten by the
|
|
35
38
|
// editor's repaint, so the operator only saw the spinner. `onNotice` prints
|
package/dist/browser.js
CHANGED
|
@@ -400,6 +400,12 @@ export class DeepSeekBrowser {
|
|
|
400
400
|
// remaining seconds. Lets the UI animate the pause status instead of
|
|
401
401
|
// printing a static line (the dots used to be frozen during the pause).
|
|
402
402
|
onSendPause;
|
|
403
|
+
// Coarse lifecycle of one send: 'generating' right before the message is
|
|
404
|
+
// typed, 'paused' while waiting out a throttle/Continue gap, 'settled'
|
|
405
|
+
// once the answer stopped changing. Lets the UI show an explicit phase
|
|
406
|
+
// instead of only an on/off spinner (a long reasoning turn and a rate-limit
|
|
407
|
+
// wait looked identical). Firing this hook does not alter any send timing.
|
|
408
|
+
onSendState;
|
|
403
409
|
// A service notice for the OPERATOR (rate limit, server busy, resend, …).
|
|
404
410
|
// IMPORTANT: these must NOT go through `console.error` directly — while a
|
|
405
411
|
// LineEditor is active it repaints its own status line and the raw stderr
|
|
@@ -458,6 +464,7 @@ export class DeepSeekBrowser {
|
|
|
458
464
|
this._netHookInstalled = false;
|
|
459
465
|
this.onSendStart = null;
|
|
460
466
|
this.onSendPause = null;
|
|
467
|
+
this.onSendState = null;
|
|
461
468
|
this.onNotice = null;
|
|
462
469
|
// An explicit UA (config/options) is respected as-is. Otherwise it stays
|
|
463
470
|
// empty here and is derived from the real engine UA after launch — see
|
|
@@ -482,6 +489,16 @@ export class DeepSeekBrowser {
|
|
|
482
489
|
}
|
|
483
490
|
console.error(text);
|
|
484
491
|
}
|
|
492
|
+
// Report the coarse lifecycle of one send to the UI hook (if wired).
|
|
493
|
+
// Best-effort: a broken UI callback must never break the send loop.
|
|
494
|
+
_emitSendState(state) {
|
|
495
|
+
if (this.onSendState) {
|
|
496
|
+
try {
|
|
497
|
+
this.onSendState(state);
|
|
498
|
+
}
|
|
499
|
+
catch { }
|
|
500
|
+
}
|
|
501
|
+
}
|
|
485
502
|
async launch() {
|
|
486
503
|
// NOTE: we deliberately do NOT kill stray chrome processes here. Per the
|
|
487
504
|
// project rules, an agent must never kill a browser it did not start —
|
|
@@ -1606,7 +1623,18 @@ export class DeepSeekBrowser {
|
|
|
1606
1623
|
// at the start burns the whole askRetries budget and ask() gives up early.
|
|
1607
1624
|
while (attempt < this.askRetries) {
|
|
1608
1625
|
try {
|
|
1609
|
-
|
|
1626
|
+
const answer = await this._askOnce(prompt, {
|
|
1627
|
+
timeout,
|
|
1628
|
+
agent,
|
|
1629
|
+
attachments,
|
|
1630
|
+
});
|
|
1631
|
+
// The answer has settled (the finish loop returned it): mark the
|
|
1632
|
+
// lifecycle state so the status line shows "settled" instead of
|
|
1633
|
+
// still looking like generation.
|
|
1634
|
+
if (answer !== '(прервано пользователем)') {
|
|
1635
|
+
this._emitSendState('settled');
|
|
1636
|
+
}
|
|
1637
|
+
return answer;
|
|
1610
1638
|
}
|
|
1611
1639
|
catch (e) {
|
|
1612
1640
|
lastErr = e;
|
|
@@ -1892,6 +1920,7 @@ export class DeepSeekBrowser {
|
|
|
1892
1920
|
catch { }
|
|
1893
1921
|
}
|
|
1894
1922
|
};
|
|
1923
|
+
this._emitSendState('paused');
|
|
1895
1924
|
tick(totalSec * 1000);
|
|
1896
1925
|
return await this._sleepInterruptible(totalSec * 1000, tick);
|
|
1897
1926
|
}
|
|
@@ -1937,6 +1966,7 @@ export class DeepSeekBrowser {
|
|
|
1937
1966
|
}
|
|
1938
1967
|
};
|
|
1939
1968
|
report(gap);
|
|
1969
|
+
this._emitSendState('paused');
|
|
1940
1970
|
await this._sleepInterruptible(gap, report);
|
|
1941
1971
|
}
|
|
1942
1972
|
// Sleep in small slices so Esc/Ctrl+C can cancel the wait promptly.
|
|
@@ -2022,6 +2052,7 @@ export class DeepSeekBrowser {
|
|
|
2022
2052
|
}
|
|
2023
2053
|
catch { }
|
|
2024
2054
|
}
|
|
2055
|
+
this._emitSendState('generating');
|
|
2025
2056
|
this._netCapture = '';
|
|
2026
2057
|
this._netCaptureAt = 0;
|
|
2027
2058
|
this._netNoAnswer = false;
|
package/dist/commands.js
CHANGED
|
@@ -71,17 +71,20 @@ export function parseTranscript(body) {
|
|
|
71
71
|
}
|
|
72
72
|
return out;
|
|
73
73
|
}
|
|
74
|
-
export
|
|
74
|
+
export const EN_DURATION_UNITS = { h: 'h', m: 'm', s: 's' };
|
|
75
|
+
// Human-readable duration: "8s", "5m 12s", "1h 01m 01s". Seconds are padded
|
|
76
|
+
// only when a larger unit is present, so a lone "45s" is not "45s" padded.
|
|
77
|
+
export function formatDuration(ms, units = EN_DURATION_UNITS) {
|
|
75
78
|
const total = Math.max(0, Math.round(ms / 1000));
|
|
76
79
|
const h = Math.floor(total / 3600);
|
|
77
80
|
const m = Math.floor((total % 3600) / 60);
|
|
78
81
|
const s = total % 60;
|
|
79
82
|
const pad = (n) => String(n).padStart(2, '0');
|
|
80
83
|
if (h > 0)
|
|
81
|
-
return h +
|
|
84
|
+
return h + units.h + ' ' + pad(m) + units.m + ' ' + pad(s) + units.s;
|
|
82
85
|
if (m > 0)
|
|
83
|
-
return m +
|
|
84
|
-
return s +
|
|
86
|
+
return m + units.m + ' ' + pad(s) + units.s;
|
|
87
|
+
return s + units.s;
|
|
85
88
|
}
|
|
86
89
|
export function renderCost(stats, transcriptFile, tokenUsage = null) {
|
|
87
90
|
const lines = [];
|
|
@@ -169,6 +172,15 @@ export function renderDoctor(d) {
|
|
|
169
172
|
row(!!d.clipboardTool, 'clipboard', d.clipboardTool || 'no tool found (image paste disabled)');
|
|
170
173
|
row(true, 'mcp', d.mcpServers + ' server(s), ' + d.mcpTools + ' tool(s)');
|
|
171
174
|
row(d.transcriptOk, 'transcript', d.transcriptOk ? 'on' : 'off');
|
|
175
|
+
if (typeof d.contextLimit === 'number' && d.contextLimit > 0) {
|
|
176
|
+
row(true, 'context', d.contextLimit.toLocaleString('en-US') + ' tokens');
|
|
177
|
+
}
|
|
178
|
+
if (d.gitOk && typeof d.hasOrigin === 'boolean') {
|
|
179
|
+
row(true, 'git remote', d.hasOrigin ? 'origin configured' : 'no origin');
|
|
180
|
+
}
|
|
181
|
+
if (typeof d.minSendIntervalMs === 'number') {
|
|
182
|
+
row(d.minSendIntervalMs > 0, 'send pause', Math.round(d.minSendIntervalMs / 1000) + 's between agent sends');
|
|
183
|
+
}
|
|
172
184
|
return 'Doctor:' + NL + rows.join(NL);
|
|
173
185
|
}
|
|
174
186
|
export function renderPermissions(p) {
|
|
@@ -421,6 +433,86 @@ export function isSlashCommand(text) {
|
|
|
421
433
|
.trim()
|
|
422
434
|
.startsWith('/');
|
|
423
435
|
}
|
|
436
|
+
export function parseLiveToggle(text) {
|
|
437
|
+
const m = /^\/(thinking|web|websearch|search)(?:\s+(\S+))?\s*$/i.exec(String(text ?? '').trim());
|
|
438
|
+
if (!m)
|
|
439
|
+
return null;
|
|
440
|
+
const name = m[1].toLowerCase();
|
|
441
|
+
const kind = name === 'thinking' ? 'thinking' : 'search';
|
|
442
|
+
const arg = (m[2] || '').toLowerCase();
|
|
443
|
+
let mode = 'toggle';
|
|
444
|
+
if (arg === 'on' || arg === '1' || arg === 'true' || arg === 'вкл')
|
|
445
|
+
mode = 'on';
|
|
446
|
+
else if (arg === 'off' || arg === '0' || arg === 'false' || arg === 'выкл')
|
|
447
|
+
mode = 'off';
|
|
448
|
+
else if (arg !== '')
|
|
449
|
+
return null;
|
|
450
|
+
return { kind, mode };
|
|
451
|
+
}
|
|
452
|
+
// Prepend a long-lived session goal to a task message, so the model keeps the
|
|
453
|
+
// big picture even when the goal was set many turns ago. The task is appended
|
|
454
|
+
// AFTER the goal; when there is no goal the task is returned unchanged. The
|
|
455
|
+
// text is agent-facing (English), like the system prompt.
|
|
456
|
+
export function withGoal(goal, task) {
|
|
457
|
+
const g = String(goal ?? '').trim();
|
|
458
|
+
if (!g)
|
|
459
|
+
return task;
|
|
460
|
+
const NL = String.fromCharCode(10);
|
|
461
|
+
return ('Session goal (long-lived — keep it in mind for this and every following task):' +
|
|
462
|
+
NL +
|
|
463
|
+
g +
|
|
464
|
+
NL +
|
|
465
|
+
NL +
|
|
466
|
+
'Current task:' +
|
|
467
|
+
NL +
|
|
468
|
+
task);
|
|
469
|
+
}
|
|
470
|
+
// Parse a `/goal` invocation. Subcommands: 'set' (with text), 'show', 'clear'.
|
|
471
|
+
// Returns null when the text is not a /goal command. Kept pure so the main loop
|
|
472
|
+
// and any future interception share one parser.
|
|
473
|
+
export function parseGoalCommand(text) {
|
|
474
|
+
const t = String(text ?? '').trim();
|
|
475
|
+
const m = /^\/goal(?:\s+([\s\S]*))?$/.exec(t);
|
|
476
|
+
if (!m)
|
|
477
|
+
return null;
|
|
478
|
+
const rest = (m[1] ?? '').trim();
|
|
479
|
+
if (rest === '')
|
|
480
|
+
return { sub: 'show' };
|
|
481
|
+
if (rest.toLowerCase() === 'clear' || rest.toLowerCase() === 'off')
|
|
482
|
+
return { sub: 'clear' };
|
|
483
|
+
return { sub: 'set', goal: rest };
|
|
484
|
+
}
|
|
485
|
+
// null when the text is not a /queue command at all. This runs WHILE the agent
|
|
486
|
+
// is busy (intercepted in the input handler), because the main command loop is
|
|
487
|
+
// blocked on runTask() and a queued /queue would only run after the task —
|
|
488
|
+
// when inspecting/clearing the queue is pointless.
|
|
489
|
+
export function parseQueueCommand(text) {
|
|
490
|
+
const t = String(text ?? '').trim();
|
|
491
|
+
const m = /^\/queue(?:\s+(\S+))?\s*$/.exec(t);
|
|
492
|
+
if (!m)
|
|
493
|
+
return null;
|
|
494
|
+
const arg = (m[1] || '').toLowerCase();
|
|
495
|
+
if (arg === '' || arg === 'list' || arg === 'ls')
|
|
496
|
+
return { sub: 'list' };
|
|
497
|
+
if (arg === 'clear' || arg === 'c' || arg === 'clean')
|
|
498
|
+
return { sub: 'clear' };
|
|
499
|
+
// An unrecognized subcommand falls through to the normal main-loop handler,
|
|
500
|
+
// which prints the usage hint.
|
|
501
|
+
return null;
|
|
502
|
+
}
|
|
503
|
+
// Render the queued messages for the operator (one line per message, text
|
|
504
|
+
// truncated). Pure, so the /queue interception and the main-loop handler share
|
|
505
|
+
// the EXACT same output instead of duplicating the formatting.
|
|
506
|
+
export function formatQueueList(msgs) {
|
|
507
|
+
return (msgs || []).map((m, i) => {
|
|
508
|
+
const one = String(m.text || '')
|
|
509
|
+
.replace(/\s+/g, ' ')
|
|
510
|
+
.trim();
|
|
511
|
+
const shown = one.length > 80 ? one.slice(0, 80) + ' …' : one;
|
|
512
|
+
const att = m.attachments?.length ? ' [+' + m.attachments.length + ']' : '';
|
|
513
|
+
return ' ' + (i + 1) + '. ' + shown + att;
|
|
514
|
+
});
|
|
515
|
+
}
|
|
424
516
|
function queuedIsImage(a) {
|
|
425
517
|
return (String(a.mime || '').startsWith('image/') ||
|
|
426
518
|
isImageName(String(a.name || '')));
|
package/dist/i18n.js
CHANGED
|
@@ -205,6 +205,78 @@ const CATALOG = {
|
|
|
205
205
|
ru: '/queue [clear] показать/очистить очередь сообщений',
|
|
206
206
|
en: '/queue [clear] list/clear the pending message queue',
|
|
207
207
|
},
|
|
208
|
+
'help.cmd.thinking': {
|
|
209
|
+
ru: '/thinking [on|off] включить/выключить режим размышления (можно во время работы)',
|
|
210
|
+
en: '/thinking [on|off] toggle Deep thinking (works while the agent is busy)',
|
|
211
|
+
},
|
|
212
|
+
'help.cmd.web': {
|
|
213
|
+
ru: '/web [on|off] включить/выключить поиск в интернете (можно во время работы)',
|
|
214
|
+
en: '/web [on|off] toggle Smart search (works while the agent is busy)',
|
|
215
|
+
},
|
|
216
|
+
'help.cmd.goal': {
|
|
217
|
+
ru: '/goal [текст|clear] задать долгоживущую цель сессии',
|
|
218
|
+
en: '/goal [text|clear] set a long-lived session goal',
|
|
219
|
+
},
|
|
220
|
+
'help.cmd.loop': {
|
|
221
|
+
ru: '/loop <интервал> <задача> периодически повторять задачу (напр. 10m)',
|
|
222
|
+
en: '/loop <interval> <task> repeat a task periodically (e.g. 10m)',
|
|
223
|
+
},
|
|
224
|
+
'help.cmd.cron': {
|
|
225
|
+
ru: '/cron <выражение> <задача> запускать по расписанию (5 полей)',
|
|
226
|
+
en: '/cron <expr> <task> schedule a task (5-field cron)',
|
|
227
|
+
},
|
|
228
|
+
'help.cmd.jobs': {
|
|
229
|
+
ru: '/jobs [rm <id>|clear] список/удаление запланированных задач',
|
|
230
|
+
en: '/jobs [rm <id>|clear] list/remove scheduled tasks',
|
|
231
|
+
},
|
|
232
|
+
'sched.loop_usage': {
|
|
233
|
+
ru: 'Использование: /loop <интервал> <задача>, например /loop 10m проверь тесты',
|
|
234
|
+
en: 'Usage: /loop <interval> <task>, e.g. /loop 10m check the tests',
|
|
235
|
+
},
|
|
236
|
+
'sched.cron_usage': {
|
|
237
|
+
ru: 'Использование: /cron "<мин> <час> <день> <мес> <день недели>" <задача>',
|
|
238
|
+
en: 'Usage: /cron "<min> <hour> <dom> <month> <dow>" <task>',
|
|
239
|
+
},
|
|
240
|
+
'sched.added': {
|
|
241
|
+
ru: '⏰ Задача #{id} добавлена ({when}): {task}',
|
|
242
|
+
en: '⏰ Job #{id} added ({when}): {task}',
|
|
243
|
+
},
|
|
244
|
+
'sched.title': { ru: 'Запланированные задачи:', en: 'Scheduled jobs:' },
|
|
245
|
+
'sched.none': { ru: 'Запланированных задач нет.', en: 'No scheduled jobs.' },
|
|
246
|
+
'sched.fired': {
|
|
247
|
+
ru: '⏰ Задача #{id} сработала: {task}',
|
|
248
|
+
en: '⏰ Job #{id} fired: {task}',
|
|
249
|
+
},
|
|
250
|
+
'sched.cleared': { ru: 'Задачи очищены: {n}', en: 'Jobs cleared: {n}' },
|
|
251
|
+
'sched.removed': { ru: 'Задача #{id} удалена.', en: 'Job #{id} removed.' },
|
|
252
|
+
'sched.rm_usage': {
|
|
253
|
+
ru: 'Использование: /jobs rm <id>',
|
|
254
|
+
en: 'Usage: /jobs rm <id>',
|
|
255
|
+
},
|
|
256
|
+
'sched.remove_hint': {
|
|
257
|
+
ru: 'Удалить: /jobs rm <id> · очистить всё: /jobs clear',
|
|
258
|
+
en: 'Remove: /jobs rm <id> · clear all: /jobs clear',
|
|
259
|
+
},
|
|
260
|
+
'goal.set': { ru: '🎯 Цель сессии: {goal}', en: '🎯 Session goal: {goal}' },
|
|
261
|
+
'goal.loaded': {
|
|
262
|
+
ru: '🎯 Восстановлена цель сессии: {goal}',
|
|
263
|
+
en: '🎯 Restored session goal: {goal}',
|
|
264
|
+
},
|
|
265
|
+
'goal.current': {
|
|
266
|
+
ru: '🎯 Цель сессии: {goal}',
|
|
267
|
+
en: '🎯 Session goal: {goal}',
|
|
268
|
+
},
|
|
269
|
+
'goal.none': {
|
|
270
|
+
ru: 'Цель сессии не задана. /goal <текст>, /goal clear.',
|
|
271
|
+
en: 'No session goal. /goal <text>, /goal clear.',
|
|
272
|
+
},
|
|
273
|
+
'goal.cleared': {
|
|
274
|
+
ru: '🎯 Цель сессии очищена.',
|
|
275
|
+
en: '🎯 Session goal cleared.',
|
|
276
|
+
},
|
|
277
|
+
'toggle.thinking': { ru: 'Размышление', en: 'Deep thinking' },
|
|
278
|
+
'toggle.search': { ru: 'Поиск в интернете', en: 'Smart search' },
|
|
279
|
+
'toggle.set': { ru: '{name}: {state}', en: '{name}: {state}' },
|
|
208
280
|
'compact.auto_trigger': {
|
|
209
281
|
ru: '🗜️ Контекст заполнен на {pct}% ({tokens} токенов) — сжимаю историю и открываю новый чат...',
|
|
210
282
|
en: '🗜️ Context at {pct}% ({tokens} tokens) — compacting the history and opening a fresh chat...',
|
|
@@ -374,6 +446,10 @@ const CATALOG = {
|
|
|
374
446
|
ru: '⚙ выполняю {name}',
|
|
375
447
|
en: '⚙ running {name}',
|
|
376
448
|
},
|
|
449
|
+
// Send/generation lifecycle indicator in the status line. Unlike the
|
|
450
|
+
// animated thinking phrase, it says explicitly that a generation is in
|
|
451
|
+
// flight. 'paused'/'settled' have no label (see LineEditor._stateLabel).
|
|
452
|
+
'state.generating': { ru: '▶ генерация', en: '▶ generating' },
|
|
377
453
|
'editor.more': { ru: '…ещё {n}', en: '…{n} more' },
|
|
378
454
|
'editor.answer': { ru: '● Ответ', en: '● Answer' },
|
|
379
455
|
// ---------- status ----------
|
|
@@ -462,6 +538,13 @@ const CATALOG = {
|
|
|
462
538
|
en: '⏹ Ctrl+C — aborting generation...',
|
|
463
539
|
},
|
|
464
540
|
'msg.queued': { ru: '📨 В очередь ({n}): ', en: '📨 Queued ({n}): ' },
|
|
541
|
+
// Shown ONCE per session, the first time a message is queued while the
|
|
542
|
+
// agent is working. It explains what happens (sent after the current task)
|
|
543
|
+
// and how to inspect/clear the queue, so the operator knows nothing is lost.
|
|
544
|
+
'msg.queued_hint': {
|
|
545
|
+
ru: 'Сообщение в очереди — уйдёт после текущей задачи; /queue покажет и очистит.',
|
|
546
|
+
en: 'Message queued — it will be sent after the current task; /queue lists or clears it.',
|
|
547
|
+
},
|
|
465
548
|
'msg.attached_image': {
|
|
466
549
|
ru: '🖼 Вложено изображение {marker} ({size}) — {path}',
|
|
467
550
|
en: '🖼 Attached image {marker} ({size}) — {path}',
|
|
@@ -482,10 +565,15 @@ const CATALOG = {
|
|
|
482
565
|
ru: ' Похоже, вы в контейнере/Dev Container: клипборд Windows отсюда недоступен. Вставьте путь к файлу (например tmp/pic.png) — он приложится к сообщению.',
|
|
483
566
|
en: ' Looks like a container/Dev Container: the Windows clipboard is not reachable from here. Paste a path to a file (e.g. tmp/pic.png) — it will be attached to the message.',
|
|
484
567
|
},
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
},
|
|
568
|
+
// Per-task summary parts (joined with "·" by index.ts). Split into separate
|
|
569
|
+
// keys so the tokens part can be omitted when the counter is unknown.
|
|
570
|
+
'task_sum.dur': { ru: 'длительность: {dur}', en: 'duration: {dur}' },
|
|
571
|
+
'task_sum.tools': { ru: 'инструментов: {n}', en: 'tools: {n}' },
|
|
572
|
+
'task_sum.tokens': { ru: 'токенов: {n}', en: 'tokens: {n}' },
|
|
573
|
+
// Unit labels for the localized task duration (see formatDuration).
|
|
574
|
+
'dur.h': { ru: 'ч', en: 'h' },
|
|
575
|
+
'dur.m': { ru: 'м', en: 'm' },
|
|
576
|
+
'dur.s': { ru: 'с', en: 's' },
|
|
489
577
|
'msg.from_queue': { ru: '▶ Из очереди: ', en: '▶ From queue: ' },
|
|
490
578
|
'msg.from_queue_batch': {
|
|
491
579
|
ru: '▶ Из очереди ({n} сообщ.): ',
|
|
@@ -498,7 +586,6 @@ const CATALOG = {
|
|
|
498
586
|
'msg.queue_title': { ru: 'Очередь сообщений:', en: 'Pending messages:' },
|
|
499
587
|
'msg.queue_empty': { ru: 'Очередь пуста.', en: 'The queue is empty.' },
|
|
500
588
|
'msg.queue_cleared': { ru: 'Очередь очищена.', en: 'Queue cleared.' },
|
|
501
|
-
'msg.queue_item': { ru: '{n}. {text}', en: '{n}. {text}' },
|
|
502
589
|
'msg.queue_usage': {
|
|
503
590
|
ru: 'Использование: /queue [clear]',
|
|
504
591
|
en: 'Usage: /queue [clear]',
|
|
@@ -876,6 +963,10 @@ const CATALOG = {
|
|
|
876
963
|
},
|
|
877
964
|
'mcp.status_error': { ru: '(ошибка: {v})', en: '(error: {v})' },
|
|
878
965
|
'status.mcp': { ru: 'MCP-инструменты: {v}', en: 'MCP tools: {v}' },
|
|
966
|
+
'status.toggles': {
|
|
967
|
+
ru: 'Размышление: {think} · Поиск: {search}',
|
|
968
|
+
en: 'Deep thinking: {think} · Search: {search}',
|
|
969
|
+
},
|
|
879
970
|
'status.tokens': {
|
|
880
971
|
ru: 'Контекст (токенов): {v}',
|
|
881
972
|
en: 'Context (tokens): {v}',
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import { DeepSeekBrowser } from './browser.js';
|
|
|
8
8
|
import { createTools } from './tools.js';
|
|
9
9
|
import { runAgentLoop } from './agent-loop.js';
|
|
10
10
|
import { createSpinner } from './spinner.js';
|
|
11
|
-
import { LineEditor, expandPastes, pasteReplacement, } from './input.js';
|
|
11
|
+
import { LineEditor, expandPastes, pasteReplacement, formatCompactTokens, } from './input.js';
|
|
12
12
|
import { parseImagePaste, extForMime, saveToTemp, guessMime, isImageName, formatSize, looksLikeFilePath, readClipboardImageDetailed, sniffMime, readWindowsClipboardFiles, hasClipboardTool, } from './attachments.js';
|
|
13
13
|
import { loadConfig, DEFAULTS, CONFIG_PATHS, ZAMES_HOME, CONFIG_SCHEMA, getByPath, validateConfigValue, writeConfigValue, resetConfigValue, } from './config.js';
|
|
14
14
|
import { runConfigMenu } from './config-menu.js';
|
|
@@ -16,8 +16,9 @@ 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, trimRestoredMessages, RESTORED_HISTORY_LIMIT, mergeMessages, isSlashCommand, ctrlCEscalation, } from './commands.js';
|
|
19
|
+
import { formatDiff, diffGitArgs, parseTranscript, summarizeTranscript, renderCost, formatExport, defaultExportPath, renderDoctor, renderPermissions, resolveExtraDir, buildReviewPrompt, formatDuration, trimRestoredMessages, RESTORED_HISTORY_LIMIT, mergeMessages, isSlashCommand, parseQueueCommand, parseLiveToggle, parseGoalCommand, withGoal, formatQueueList, ctrlCEscalation, } from './commands.js';
|
|
20
20
|
import { performCompact } from './compact.js';
|
|
21
|
+
import { Scheduler, parseInterval, formatInterval, parseCron, } from './scheduler.js';
|
|
21
22
|
import { renderMarkdown } from './markdown.js';
|
|
22
23
|
import { closeWeb } from './web.js';
|
|
23
24
|
import { saveSession, loadLastSession, listSessions, sessionsDir, } from './sessions.js';
|
|
@@ -108,6 +109,36 @@ function loadLastChat(workdir = '') {
|
|
|
108
109
|
const s = loadLastSession(workdir);
|
|
109
110
|
return s ? s.id : null;
|
|
110
111
|
}
|
|
112
|
+
// Persistent per-project session goal (set by /goal). Kept OUT of the session
|
|
113
|
+
// JSON (a session == a DeepSeek chat) because the goal is a property of the
|
|
114
|
+
// PROJECT, not of one chat: it must survive /new and /compact. Best-effort —
|
|
115
|
+
// a missing/unreadable file simply means "no goal".
|
|
116
|
+
function goalFilePath(workdir) {
|
|
117
|
+
return path.join(workdir, '.zames-goal');
|
|
118
|
+
}
|
|
119
|
+
async function loadGoal(workdir) {
|
|
120
|
+
try {
|
|
121
|
+
const txt = await fs.readFile(goalFilePath(workdir), 'utf-8');
|
|
122
|
+
const trimmed = txt.trim();
|
|
123
|
+
return trimmed || null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
async function saveGoal(workdir, goal) {
|
|
130
|
+
try {
|
|
131
|
+
if (goal && goal.trim()) {
|
|
132
|
+
await fs.writeFile(goalFilePath(workdir), goal.trim() + '\n', 'utf-8');
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
await fs.rm(goalFilePath(workdir), { force: true });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// best-effort: a failed write must not break the command
|
|
140
|
+
}
|
|
141
|
+
}
|
|
111
142
|
// ---------- hot reload ----------
|
|
112
143
|
// We keep references to the logic modules in the mod object. The /reload
|
|
113
144
|
// command re-reads them via a dynamic import with a timestamp query — Node
|
|
@@ -287,6 +318,12 @@ ${theme.bold(t('help.commands'))}
|
|
|
287
318
|
${t('help.cmd.permissions')}
|
|
288
319
|
${t('help.cmd.add_dir')}
|
|
289
320
|
${t('help.cmd.review')}
|
|
321
|
+
${t('help.cmd.goal')}
|
|
322
|
+
${t('help.cmd.loop')}
|
|
323
|
+
${t('help.cmd.cron')}
|
|
324
|
+
${t('help.cmd.jobs')}
|
|
325
|
+
${t('help.cmd.thinking')}
|
|
326
|
+
${t('help.cmd.web')}
|
|
290
327
|
${t('help.cmd.compact')}
|
|
291
328
|
${t('help.cmd.queue')}
|
|
292
329
|
${t('help.cmd.config')}
|
|
@@ -344,6 +381,12 @@ const SLASH_COMMANDS = [
|
|
|
344
381
|
{ name: '/permissions', key: 'help.cmd.permissions' },
|
|
345
382
|
{ name: '/add-dir', key: 'help.cmd.add_dir' },
|
|
346
383
|
{ name: '/review', key: 'help.cmd.review' },
|
|
384
|
+
{ name: '/goal', key: 'help.cmd.goal' },
|
|
385
|
+
{ name: '/loop', key: 'help.cmd.loop' },
|
|
386
|
+
{ name: '/cron', key: 'help.cmd.cron' },
|
|
387
|
+
{ name: '/jobs', key: 'help.cmd.jobs' },
|
|
388
|
+
{ name: '/thinking', key: 'help.cmd.thinking' },
|
|
389
|
+
{ name: '/web', key: 'help.cmd.web' },
|
|
347
390
|
{ name: '/compact', key: 'help.cmd.compact' },
|
|
348
391
|
{ name: '/queue', key: 'help.cmd.queue' },
|
|
349
392
|
{ name: '/config', key: 'help.cmd.config' },
|
|
@@ -935,7 +978,7 @@ async function printRestoredHistory(browser, ui, chatId = null) {
|
|
|
935
978
|
}
|
|
936
979
|
// ---------- task runner ----------
|
|
937
980
|
async function runTask(browser, tools, taskText, workdir, opts, attachments = []) {
|
|
938
|
-
const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, } = opts;
|
|
981
|
+
const { transcript, freshChat, sendSystemPrompt, queue = [], ui: editor, onChatReady, onAutoCompact = null, autoCompactPct = 95, contextLimit = 1_000_000, getTokenUsage = null, goal = null, } = opts;
|
|
939
982
|
// In TTY mode the UI is a LineEditor: it owns the input (queue, Esc,
|
|
940
983
|
// Ctrl+C) and draws the status ABOVE the permanent input line. In non-TTY
|
|
941
984
|
// mode (pipes) — a regular spinner + watchInput.
|
|
@@ -984,15 +1027,20 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
|
|
|
984
1027
|
// the whole pre-send phase (chat creation, throttle wait) with no
|
|
985
1028
|
// generation in flight.
|
|
986
1029
|
while (true) {
|
|
987
|
-
// T7: per-task summary (duration + number of tool calls).
|
|
988
|
-
// via the UI callback and reports ONE
|
|
989
|
-
// operator sees how long it took and how much work happened.
|
|
1030
|
+
// T7: per-task summary (duration + number of tool calls + tokens).
|
|
1031
|
+
// Counts tools via the UI callback and reports ONE line after the task,
|
|
1032
|
+
// so the operator sees how long it took and how much work happened.
|
|
990
1033
|
const taskStart = Date.now();
|
|
1034
|
+
// Token delta for THIS task: accumulated_token_usage is cumulative for
|
|
1035
|
+
// the whole chat, so the per-task spend is after - before. Null when the
|
|
1036
|
+
// counter is unknown (fresh chat / no answer yet); the token part is then
|
|
1037
|
+
// omitted from the summary.
|
|
1038
|
+
const tokensBefore = getTokenUsage ? getTokenUsage() : null;
|
|
991
1039
|
let taskTools = 0;
|
|
992
1040
|
const outcome = await mod.runAgentLoop({
|
|
993
1041
|
browser,
|
|
994
1042
|
tools,
|
|
995
|
-
task: next.text,
|
|
1043
|
+
task: withGoal(goal, next.text),
|
|
996
1044
|
workdir,
|
|
997
1045
|
maxIterations: maxIter,
|
|
998
1046
|
freshChat: next.freshChat,
|
|
@@ -1001,6 +1049,10 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
|
|
|
1001
1049
|
transcript,
|
|
1002
1050
|
onThinking: () => ui.thinking(),
|
|
1003
1051
|
onSendPause: (seconds) => ui.sendPause(seconds),
|
|
1052
|
+
onSendState: (state) => {
|
|
1053
|
+
if (editor)
|
|
1054
|
+
editor.setSendState(state);
|
|
1055
|
+
},
|
|
1004
1056
|
onNotice: (msg) => ui.warning(msg),
|
|
1005
1057
|
onToolCall: (name, toolArgs) => {
|
|
1006
1058
|
taskTools++;
|
|
@@ -1034,12 +1086,30 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
|
|
|
1034
1086
|
ui.warning(outcome);
|
|
1035
1087
|
transcript?.log('agent_no_answer', { outcome });
|
|
1036
1088
|
}
|
|
1037
|
-
// T7: one
|
|
1038
|
-
// even on an abort, so the operator sees what happened.
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1089
|
+
// T7: one summary line per task (duration + tool calls + tokens spent).
|
|
1090
|
+
// Printed even on an abort, so the operator sees what happened. Duration
|
|
1091
|
+
// is human-readable ("45s", "1m 20s", "1h 12m 45s") with LOCALIZED unit
|
|
1092
|
+
// labels, instead of a raw seconds count like "3129.2s". The token part
|
|
1093
|
+
// is the DELTA of the cumulative counter for THIS task; it is omitted
|
|
1094
|
+
// when the counter is unknown.
|
|
1095
|
+
const tokensAfter = getTokenUsage ? getTokenUsage() : null;
|
|
1096
|
+
const tokenDelta = typeof tokensBefore === 'number' && typeof tokensAfter === 'number'
|
|
1097
|
+
? Math.max(0, tokensAfter - tokensBefore)
|
|
1098
|
+
: null;
|
|
1099
|
+
const summaryParts = [
|
|
1100
|
+
t('task_sum.dur', {
|
|
1101
|
+
dur: formatDuration(Date.now() - taskStart, {
|
|
1102
|
+
h: t('dur.h'),
|
|
1103
|
+
m: t('dur.m'),
|
|
1104
|
+
s: t('dur.s'),
|
|
1105
|
+
}),
|
|
1106
|
+
}),
|
|
1107
|
+
t('task_sum.tools', { n: String(taskTools) }),
|
|
1108
|
+
];
|
|
1109
|
+
if (tokenDelta !== null) {
|
|
1110
|
+
summaryParts.push(t('task_sum.tokens', { n: formatCompactTokens(tokenDelta) }));
|
|
1111
|
+
}
|
|
1112
|
+
const summary = theme.taskSummary('· ' + summaryParts.join(' · '));
|
|
1043
1113
|
if (editor)
|
|
1044
1114
|
editor.printAbove(summary);
|
|
1045
1115
|
else
|
|
@@ -1101,6 +1171,7 @@ async function runTask(browser, tools, taskText, workdir, opts, attachments = []
|
|
|
1101
1171
|
// start a stale spinner.
|
|
1102
1172
|
browser.onSendStart = null;
|
|
1103
1173
|
browser.onSendPause = null;
|
|
1174
|
+
browser.onSendState = null;
|
|
1104
1175
|
browser.onNotice = null;
|
|
1105
1176
|
}
|
|
1106
1177
|
}
|
|
@@ -1253,11 +1324,53 @@ async function main() {
|
|
|
1253
1324
|
let lastChats = [];
|
|
1254
1325
|
let currentChatId = null;
|
|
1255
1326
|
let running = true;
|
|
1327
|
+
// Long-lived session goal (set via /goal). Prepended to every task message so
|
|
1328
|
+
// the model keeps the big picture across many turns. Persisted per working
|
|
1329
|
+
// directory in <project>/.zames-goal (best-effort); restored right away.
|
|
1330
|
+
let sessionGoal = await loadGoal(currentWorkdir);
|
|
1331
|
+
if (sessionGoal) {
|
|
1332
|
+
console.log(theme.system(t('goal.loaded', { goal: sessionGoal })));
|
|
1333
|
+
}
|
|
1334
|
+
// Scheduled tasks (/loop, /cron). A pure Scheduler holds the jobs; a
|
|
1335
|
+
// 1-second ticker enqueues a due job's task into the SAME message queue the
|
|
1336
|
+
// operator uses, so it runs when the agent is free (never mid-generation,
|
|
1337
|
+
// never bypassing the send throttle). Started lazily on the first job.
|
|
1338
|
+
const scheduler = new Scheduler();
|
|
1339
|
+
let scheduleTimer = null;
|
|
1340
|
+
const startScheduleTicker = () => {
|
|
1341
|
+
if (scheduleTimer)
|
|
1342
|
+
return;
|
|
1343
|
+
scheduleTimer = setInterval(() => {
|
|
1344
|
+
const due = scheduler.due(Date.now());
|
|
1345
|
+
for (const job of due) {
|
|
1346
|
+
pendingQueue.push({ text: job.task });
|
|
1347
|
+
transcript.log('scheduled_fire', { id: job.id, task: job.task });
|
|
1348
|
+
if (editor) {
|
|
1349
|
+
editor.printAbove(theme.system(t('sched.fired', { id: String(job.id), task: job.task })));
|
|
1350
|
+
}
|
|
1351
|
+
else {
|
|
1352
|
+
console.log(theme.system(t('sched.fired', { id: String(job.id), task: job.task })));
|
|
1353
|
+
}
|
|
1354
|
+
// Wake a waiting main loop so a queued job runs even when idle.
|
|
1355
|
+
if (waiter) {
|
|
1356
|
+
const r = waiter;
|
|
1357
|
+
waiter = null;
|
|
1358
|
+
r(pendingQueue.shift() ?? null);
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}, 1000);
|
|
1362
|
+
if (scheduleTimer.unref)
|
|
1363
|
+
scheduleTimer.unref();
|
|
1364
|
+
};
|
|
1256
1365
|
// Messages the user typed while the agent worked. runTask takes them one
|
|
1257
1366
|
// by one after the current task finishes.
|
|
1258
1367
|
const pendingQueue = [];
|
|
1259
1368
|
// Show the "no clipboard image" hint only once per session.
|
|
1260
1369
|
let clipboardWarned = false;
|
|
1370
|
+
// Show the "message is queued" hint once per session (the first time the
|
|
1371
|
+
// operator sends while a task runs), so the queue is understood without
|
|
1372
|
+
// spamming the hint on every queued message.
|
|
1373
|
+
let queueHintShown = false;
|
|
1261
1374
|
// Timestamp of the last Ctrl+C while the agent was busy. Two presses
|
|
1262
1375
|
// within 2s escalate from "abort the running tool" to "stop the whole run".
|
|
1263
1376
|
let lastCtrlCAt = 0;
|
|
@@ -1451,6 +1564,39 @@ async function main() {
|
|
|
1451
1564
|
return att;
|
|
1452
1565
|
};
|
|
1453
1566
|
ed.onSubmit = (text, items) => {
|
|
1567
|
+
// While the agent is busy the main command loop is blocked on
|
|
1568
|
+
// `await runTask()`, so ONLY commands that make sense without the main
|
|
1569
|
+
// loop are handled here (a queued slash-command would run too late).
|
|
1570
|
+
if (ed.busy) {
|
|
1571
|
+
const q = parseQueueCommand(text);
|
|
1572
|
+
if (q) {
|
|
1573
|
+
if (q.sub === 'clear') {
|
|
1574
|
+
const n = pendingQueue.length;
|
|
1575
|
+
pendingQueue.length = 0;
|
|
1576
|
+
ed.printAbove(theme.system(t('msg.queue_cleared') + ' (' + n + ')'));
|
|
1577
|
+
}
|
|
1578
|
+
else if (!pendingQueue.length) {
|
|
1579
|
+
ed.printAbove(theme.dim(t('msg.queue_empty')));
|
|
1580
|
+
}
|
|
1581
|
+
else {
|
|
1582
|
+
ed.printAbove(theme.system(t('msg.queue_title')));
|
|
1583
|
+
for (const line of formatQueueList(pendingQueue)) {
|
|
1584
|
+
ed.printAbove(theme.assistant(line));
|
|
1585
|
+
}
|
|
1586
|
+
ed.printAbove(theme.dim(t('msg.queue_cleared_hint')));
|
|
1587
|
+
}
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
// Toggle Deep thinking / Smart search LIVE. This only flips the
|
|
1591
|
+
// browser's DESIRED state and the status icons; it does not touch the
|
|
1592
|
+
// in-flight generation, so it is safe mid-run. The actual DeepSeek
|
|
1593
|
+
// toggles are applied on the next send by _applyToggles().
|
|
1594
|
+
const tg = parseLiveToggle(text);
|
|
1595
|
+
if (tg) {
|
|
1596
|
+
applyLiveToggle(tg);
|
|
1597
|
+
return;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1454
1600
|
const attachments = (items || []).map((a) => ({
|
|
1455
1601
|
path: a.path,
|
|
1456
1602
|
name: a.name,
|
|
@@ -1458,6 +1604,14 @@ async function main() {
|
|
|
1458
1604
|
}));
|
|
1459
1605
|
const msg = { text, attachments };
|
|
1460
1606
|
pendingQueue.push(msg);
|
|
1607
|
+
// The operator typed this WHILE the agent was working, so the message is
|
|
1608
|
+
// queued and only sent after the current task. Explain that once per
|
|
1609
|
+
// session (via the editor's own status area) so it is clear nothing was
|
|
1610
|
+
// lost; later queued messages stay as the compact msg.queued banner only.
|
|
1611
|
+
if (ed.busy && !queueHintShown) {
|
|
1612
|
+
queueHintShown = true;
|
|
1613
|
+
ed.printAbove(theme.dim(t('msg.queued_hint')));
|
|
1614
|
+
}
|
|
1461
1615
|
if (waiter) {
|
|
1462
1616
|
const r = waiter;
|
|
1463
1617
|
waiter = null;
|
|
@@ -1577,6 +1731,40 @@ async function main() {
|
|
|
1577
1731
|
}
|
|
1578
1732
|
}
|
|
1579
1733
|
}
|
|
1734
|
+
// Apply `/thinking` / `/web` immediately: flip the browser's DESIRED state
|
|
1735
|
+
// and the status icons, and persist to config (so it survives a restart).
|
|
1736
|
+
// The DeepSeek toggles themselves are clicked on the next send by
|
|
1737
|
+
// _applyToggles(), so this is safe even while a generation is in flight.
|
|
1738
|
+
function applyLiveToggle(cmd) {
|
|
1739
|
+
const b = browser;
|
|
1740
|
+
const cur = cmd.kind === 'thinking' ? b.deepThinking : b.webSearch;
|
|
1741
|
+
const next = cmd.mode === 'toggle' ? !cur : cmd.mode === 'on';
|
|
1742
|
+
if (cmd.kind === 'thinking')
|
|
1743
|
+
b.deepThinking = next;
|
|
1744
|
+
else
|
|
1745
|
+
b.webSearch = next;
|
|
1746
|
+
b.setToggleState({
|
|
1747
|
+
deepThinking: b.deepThinking,
|
|
1748
|
+
webSearch: b.webSearch,
|
|
1749
|
+
});
|
|
1750
|
+
const cfgPath = cmd.kind === 'thinking' ? 'browser.deepThinking' : 'browser.webSearch';
|
|
1751
|
+
try {
|
|
1752
|
+
writeConfigValue('home', cfgPath, String(next));
|
|
1753
|
+
setConfigRuntime(cfgPath, next);
|
|
1754
|
+
}
|
|
1755
|
+
catch {
|
|
1756
|
+
// A failed persist must not break the toggle itself.
|
|
1757
|
+
}
|
|
1758
|
+
if (editor)
|
|
1759
|
+
editor.refreshStatus();
|
|
1760
|
+
const label = cmd.kind === 'thinking' ? t('toggle.thinking') : t('toggle.search');
|
|
1761
|
+
const stateText = next ? t('common.on') : t('common.off');
|
|
1762
|
+
const line = t('toggle.set', { name: label, state: stateText });
|
|
1763
|
+
if (editor)
|
|
1764
|
+
editor.printAbove(theme.system(line));
|
|
1765
|
+
else
|
|
1766
|
+
console.log(theme.system(line));
|
|
1767
|
+
}
|
|
1580
1768
|
function configSetRaw(field, raw) {
|
|
1581
1769
|
const value = validateConfigValue(field, raw);
|
|
1582
1770
|
writeConfigValue('home', field.path, raw);
|
|
@@ -2126,37 +2314,145 @@ async function main() {
|
|
|
2126
2314
|
console.log(theme.system(currentWorkdir));
|
|
2127
2315
|
continue;
|
|
2128
2316
|
}
|
|
2317
|
+
if (lower === '/goal' || lower.startsWith('/goal ')) {
|
|
2318
|
+
const g = parseGoalCommand(trimmed);
|
|
2319
|
+
if (g) {
|
|
2320
|
+
if (g.sub === 'clear') {
|
|
2321
|
+
sessionGoal = null;
|
|
2322
|
+
await saveGoal(currentWorkdir, null);
|
|
2323
|
+
console.log(theme.system(t('goal.cleared')));
|
|
2324
|
+
}
|
|
2325
|
+
else if (g.sub === 'show') {
|
|
2326
|
+
console.log(sessionGoal
|
|
2327
|
+
? theme.system(t('goal.current', { goal: sessionGoal }))
|
|
2328
|
+
: theme.dim(t('goal.none')));
|
|
2329
|
+
}
|
|
2330
|
+
else {
|
|
2331
|
+
sessionGoal = g.goal;
|
|
2332
|
+
await saveGoal(currentWorkdir, g.goal);
|
|
2333
|
+
console.log(theme.system(t('goal.set', { goal: g.goal })));
|
|
2334
|
+
}
|
|
2335
|
+
continue;
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
if (lower === '/loop' || lower.startsWith('/loop ')) {
|
|
2339
|
+
const rest = trimmed.slice('/loop'.length).trim();
|
|
2340
|
+
const sp = rest.indexOf(' ');
|
|
2341
|
+
const intervalStr = sp === -1 ? rest : rest.slice(0, sp);
|
|
2342
|
+
const taskStr = sp === -1 ? '' : rest.slice(sp + 1).trim();
|
|
2343
|
+
const ms = parseInterval(intervalStr);
|
|
2344
|
+
if (!ms || !taskStr) {
|
|
2345
|
+
console.error(theme.error(t('sched.loop_usage')));
|
|
2346
|
+
continue;
|
|
2347
|
+
}
|
|
2348
|
+
const job = scheduler.addLoop(ms, taskStr, Date.now());
|
|
2349
|
+
startScheduleTicker();
|
|
2350
|
+
console.log(theme.system(t('sched.added', {
|
|
2351
|
+
id: String(job.id),
|
|
2352
|
+
when: formatInterval(ms),
|
|
2353
|
+
task: taskStr,
|
|
2354
|
+
})));
|
|
2355
|
+
continue;
|
|
2356
|
+
}
|
|
2357
|
+
if (lower === '/cron' || lower.startsWith('/cron ')) {
|
|
2358
|
+
const rest = trimmed.slice('/cron'.length).trim();
|
|
2359
|
+
// A cron expression is exactly 5 whitespace-separated fields; the task
|
|
2360
|
+
// is everything after them.
|
|
2361
|
+
const parts = rest.split(/\s+/);
|
|
2362
|
+
if (parts.length < 6) {
|
|
2363
|
+
console.error(theme.error(t('sched.cron_usage')));
|
|
2364
|
+
continue;
|
|
2365
|
+
}
|
|
2366
|
+
const cronExpr = parts.slice(0, 5).join(' ');
|
|
2367
|
+
const taskStr = parts.slice(5).join(' ').trim();
|
|
2368
|
+
if (!parseCron(cronExpr) || !taskStr) {
|
|
2369
|
+
console.error(theme.error(t('sched.cron_usage')));
|
|
2370
|
+
continue;
|
|
2371
|
+
}
|
|
2372
|
+
const job = scheduler.addCron(cronExpr, taskStr, Date.now());
|
|
2373
|
+
if (!job) {
|
|
2374
|
+
console.error(theme.error(t('sched.cron_usage')));
|
|
2375
|
+
continue;
|
|
2376
|
+
}
|
|
2377
|
+
startScheduleTicker();
|
|
2378
|
+
const nextIn = formatInterval(Math.max(0, job.nextAt - Date.now()));
|
|
2379
|
+
console.log(theme.system(t('sched.added', {
|
|
2380
|
+
id: String(job.id),
|
|
2381
|
+
when: cronExpr + ' (in ~' + nextIn + ')',
|
|
2382
|
+
task: taskStr,
|
|
2383
|
+
})));
|
|
2384
|
+
continue;
|
|
2385
|
+
}
|
|
2386
|
+
if (lower === '/jobs' || lower.startsWith('/jobs ')) {
|
|
2387
|
+
const sub = trimmed.slice('/jobs'.length).trim().toLowerCase();
|
|
2388
|
+
if (sub === 'clear') {
|
|
2389
|
+
const n = scheduler.clear();
|
|
2390
|
+
if (scheduleTimer) {
|
|
2391
|
+
clearInterval(scheduleTimer);
|
|
2392
|
+
scheduleTimer = null;
|
|
2393
|
+
}
|
|
2394
|
+
console.log(theme.system(t('sched.cleared', { n: String(n) })));
|
|
2395
|
+
continue;
|
|
2396
|
+
}
|
|
2397
|
+
if (sub.startsWith('rm ') || sub.startsWith('remove ')) {
|
|
2398
|
+
const id = Number(sub.replace(/^(rm|remove)\s+/, ''));
|
|
2399
|
+
if (!Number.isInteger(id) || !scheduler.remove(id)) {
|
|
2400
|
+
console.error(theme.error(t('sched.rm_usage')));
|
|
2401
|
+
continue;
|
|
2402
|
+
}
|
|
2403
|
+
if (!scheduler.count() && scheduleTimer) {
|
|
2404
|
+
clearInterval(scheduleTimer);
|
|
2405
|
+
scheduleTimer = null;
|
|
2406
|
+
}
|
|
2407
|
+
console.log(theme.system(t('sched.removed', { id: String(id) })));
|
|
2408
|
+
continue;
|
|
2409
|
+
}
|
|
2410
|
+
const jobs = scheduler.list();
|
|
2411
|
+
if (!jobs.length) {
|
|
2412
|
+
console.log(theme.dim(t('sched.none')));
|
|
2413
|
+
continue;
|
|
2414
|
+
}
|
|
2415
|
+
console.log(theme.system(t('sched.title')));
|
|
2416
|
+
for (const j of jobs) {
|
|
2417
|
+
const when = j.kind === 'loop'
|
|
2418
|
+
? 'every ' + formatInterval(j.intervalMs || 0)
|
|
2419
|
+
: j.cron || '';
|
|
2420
|
+
const nextIn = formatInterval(Math.max(0, j.nextAt - Date.now()));
|
|
2421
|
+
console.log(theme.assistant(' #' + j.id + ' [' + when + ', next ~' + nextIn + '] ' + j.task));
|
|
2422
|
+
}
|
|
2423
|
+
console.log(theme.dim(t('sched.remove_hint')));
|
|
2424
|
+
continue;
|
|
2425
|
+
}
|
|
2129
2426
|
if (lower === '/queue' || lower.startsWith('/queue ')) {
|
|
2130
|
-
const
|
|
2131
|
-
if (
|
|
2427
|
+
const q = parseQueueCommand(trimmed);
|
|
2428
|
+
if (!q) {
|
|
2429
|
+
console.error(theme.error(t('msg.queue_usage')));
|
|
2430
|
+
continue;
|
|
2431
|
+
}
|
|
2432
|
+
if (q.sub === 'clear') {
|
|
2132
2433
|
const n = pendingQueue.length;
|
|
2133
2434
|
pendingQueue.length = 0;
|
|
2134
2435
|
console.log(theme.system(t('msg.queue_cleared') + ' (' + n + ')'));
|
|
2135
2436
|
continue;
|
|
2136
2437
|
}
|
|
2137
|
-
if (sub && sub !== 'list' && sub !== 'ls') {
|
|
2138
|
-
console.error(theme.error(t('msg.queue_usage')));
|
|
2139
|
-
continue;
|
|
2140
|
-
}
|
|
2141
2438
|
if (!pendingQueue.length) {
|
|
2142
2439
|
console.log(theme.dim(t('msg.queue_empty')));
|
|
2143
2440
|
continue;
|
|
2144
2441
|
}
|
|
2145
2442
|
console.log(theme.system(t('msg.queue_title')));
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
.trim();
|
|
2150
|
-
const shown = one.length > 80 ? one.slice(0, 80) + ' …' : one;
|
|
2151
|
-
const att = m.attachments?.length
|
|
2152
|
-
? ' [+' + m.attachments.length + ']'
|
|
2153
|
-
: '';
|
|
2154
|
-
console.log(theme.system(' ') +
|
|
2155
|
-
theme.assistant(t('msg.queue_item', { n: i + 1, text: shown + att })));
|
|
2156
|
-
});
|
|
2443
|
+
for (const line of formatQueueList(pendingQueue)) {
|
|
2444
|
+
console.log(theme.assistant(line));
|
|
2445
|
+
}
|
|
2157
2446
|
console.log(theme.dim(t('msg.queue_cleared_hint')));
|
|
2158
2447
|
continue;
|
|
2159
2448
|
}
|
|
2449
|
+
if (lower.startsWith('/thinking') || lower.startsWith('/web')) {
|
|
2450
|
+
const tg = parseLiveToggle(trimmed);
|
|
2451
|
+
if (tg) {
|
|
2452
|
+
applyLiveToggle(tg);
|
|
2453
|
+
continue;
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2160
2456
|
if (lower === '/mcp') {
|
|
2161
2457
|
if (!mcpPool) {
|
|
2162
2458
|
console.log(theme.dim(t('mcp.none')));
|
|
@@ -2347,6 +2643,11 @@ async function main() {
|
|
|
2347
2643
|
console.log(theme.system(t('status.max_iter', { v: maxIter })));
|
|
2348
2644
|
console.log(theme.system(t('status.headless', { v: headless ? yes : no })));
|
|
2349
2645
|
console.log(theme.system(t('status.debug', { v: debug ? yes : no })));
|
|
2646
|
+
const statusToggles = browser.getToggleStatesSync();
|
|
2647
|
+
console.log(theme.system(t('status.toggles', {
|
|
2648
|
+
think: statusToggles.deepThinking ? yes : no,
|
|
2649
|
+
search: statusToggles.webSearch ? yes : no,
|
|
2650
|
+
})));
|
|
2350
2651
|
console.log(theme.system(t('status.undo', {
|
|
2351
2652
|
v: config.undo.enabled ? t('common.on') : t('common.off'),
|
|
2352
2653
|
})));
|
|
@@ -2461,6 +2762,11 @@ async function main() {
|
|
|
2461
2762
|
mcpTools: mcpStatus.toolCount,
|
|
2462
2763
|
transcriptOk: !!transcript.file,
|
|
2463
2764
|
authSaved: authMarkerExists(),
|
|
2765
|
+
contextLimit: config.ui.contextLimit,
|
|
2766
|
+
hasOrigin: gitOk
|
|
2767
|
+
? (await runGit('git remote', currentWorkdir, 5000)).includes('origin')
|
|
2768
|
+
: undefined,
|
|
2769
|
+
minSendIntervalMs: config.browser.minSendIntervalMs,
|
|
2464
2770
|
})));
|
|
2465
2771
|
continue;
|
|
2466
2772
|
}
|
|
@@ -2690,6 +2996,7 @@ async function main() {
|
|
|
2690
2996
|
autoCompactPct: config.browser.autoCompactPct,
|
|
2691
2997
|
contextLimit: config.ui.contextLimit,
|
|
2692
2998
|
getTokenUsage: () => browser.getLastTokenUsage(),
|
|
2999
|
+
goal: sessionGoal,
|
|
2693
3000
|
}, inputAttachments);
|
|
2694
3001
|
}
|
|
2695
3002
|
finally {
|
|
@@ -2705,6 +3012,8 @@ async function main() {
|
|
|
2705
3012
|
}
|
|
2706
3013
|
if (editor)
|
|
2707
3014
|
editor.dispose();
|
|
3015
|
+
if (scheduleTimer)
|
|
3016
|
+
clearInterval(scheduleTimer);
|
|
2708
3017
|
await browser.close().catch(() => { });
|
|
2709
3018
|
await mod.closeWeb().catch(() => { });
|
|
2710
3019
|
if (mcpPool)
|
package/dist/input.js
CHANGED
|
@@ -205,26 +205,32 @@ export function formatTokenStatus(tokens, limit = CONTEXT_LIMIT) {
|
|
|
205
205
|
return '';
|
|
206
206
|
}
|
|
207
207
|
const n = Math.round(tokens);
|
|
208
|
-
|
|
209
|
-
if (n >= 1_000_000) {
|
|
210
|
-
const m = n / 1_000_000;
|
|
211
|
-
compact = (Number.isInteger(m) ? String(m) : m.toFixed(1)) + 'M';
|
|
212
|
-
}
|
|
213
|
-
else if (n >= 1_000) {
|
|
214
|
-
const k = n / 1_000;
|
|
215
|
-
compact =
|
|
216
|
-
(k >= 100 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, '')) +
|
|
217
|
-
'k';
|
|
218
|
-
}
|
|
219
|
-
else {
|
|
220
|
-
compact = String(n);
|
|
221
|
-
}
|
|
208
|
+
const compact = formatCompactTokens(n);
|
|
222
209
|
const pct = Math.max(0, (n / limit) * 100);
|
|
223
210
|
const pctStr = pct >= 10 ? String(Math.round(pct)) : pct.toFixed(1);
|
|
224
211
|
// "ctx:" marks this as the USED context size, so the operator does not have
|
|
225
212
|
// to guess what "302k · 30%" means.
|
|
226
213
|
return 'ctx: ' + compact + ' · ' + pctStr + '%';
|
|
227
214
|
}
|
|
215
|
+
// Compact token count (10k, 125k, 1.5M). Exported so the per-task summary
|
|
216
|
+
// reuses the EXACT same "k/M" formatting as the context counter. Invalid
|
|
217
|
+
// input (null/NaN/negative) renders an empty string.
|
|
218
|
+
export function formatCompactTokens(tokens) {
|
|
219
|
+
if (typeof tokens !== 'number' || !Number.isFinite(tokens) || tokens < 0) {
|
|
220
|
+
return '';
|
|
221
|
+
}
|
|
222
|
+
const n = Math.round(tokens);
|
|
223
|
+
if (n >= 1_000_000) {
|
|
224
|
+
const m = n / 1_000_000;
|
|
225
|
+
return (Number.isInteger(m) ? String(m) : m.toFixed(1)) + 'M';
|
|
226
|
+
}
|
|
227
|
+
if (n >= 1_000) {
|
|
228
|
+
const k = n / 1_000;
|
|
229
|
+
return ((k >= 100 ? String(Math.round(k)) : k.toFixed(1).replace(/\.0$/, '')) +
|
|
230
|
+
'k');
|
|
231
|
+
}
|
|
232
|
+
return String(n);
|
|
233
|
+
}
|
|
228
234
|
export class LineEditor {
|
|
229
235
|
promptStr;
|
|
230
236
|
buf;
|
|
@@ -251,6 +257,12 @@ export class LineEditor {
|
|
|
251
257
|
_showHint;
|
|
252
258
|
_dotPhase;
|
|
253
259
|
_thinkBase;
|
|
260
|
+
// Explicit lifecycle state of the send ('generating' | 'paused' |
|
|
261
|
+
// 'settled' | ''). The thinking spinner already shows ACTIVITY, but it does
|
|
262
|
+
// not tell a throttle pause from a real generation — a long reasoning turn
|
|
263
|
+
// and a wait for the rate-limit slot look identical. Only 'generating' shows
|
|
264
|
+
// a prefix (see _stateLabel); the field also gates the clean-up in stop().
|
|
265
|
+
_sendState;
|
|
254
266
|
// The last rendered status row(s) (status + right-aligned context). Cached so
|
|
255
267
|
// a re-render driven by TYPING (the buffer changed, the status did not) can
|
|
256
268
|
// skip erasing and re-printing the status line entirely. On terminals like
|
|
@@ -333,6 +345,7 @@ export class LineEditor {
|
|
|
333
345
|
this._showHint = true;
|
|
334
346
|
this._dotPhase = 0;
|
|
335
347
|
this._thinkBase = '';
|
|
348
|
+
this._sendState = '';
|
|
336
349
|
this._lastStatusBlock = '';
|
|
337
350
|
this._wasRaw = false;
|
|
338
351
|
this._onData = (b) => this._handle(b);
|
|
@@ -501,9 +514,25 @@ export class LineEditor {
|
|
|
501
514
|
// Redraw on terminal resize so the layout follows the new width instead
|
|
502
515
|
// of leaving artifacts from the old one.
|
|
503
516
|
process.stdout.on('resize', this._onResize);
|
|
517
|
+
// Pin the input block to the BOTTOM of the terminal. A fresh terminal
|
|
518
|
+
// starts the cursor at the top, so without this the input line sits at
|
|
519
|
+
// the top and only "descends" as output accumulates. Filling the viewport
|
|
520
|
+
// with newlines first makes the block start at the bottom, and every
|
|
521
|
+
// later printAbove() pushes content UP while the input stays put.
|
|
522
|
+
this._padToBottom();
|
|
504
523
|
this._writeBlock();
|
|
505
524
|
return true;
|
|
506
525
|
}
|
|
526
|
+
// Fill the current viewport with empty lines so the next _writeBlock()
|
|
527
|
+
// lands at the BOTTOM of the terminal. Used at start and after clearScreen.
|
|
528
|
+
// We deliberately emit real newlines (not a cursor-position escape) because
|
|
529
|
+
// the block height is not known yet and scrolling keeps the block's own
|
|
530
|
+
// cursor math (`cursorRowFromTop`) correct.
|
|
531
|
+
_padToBottom() {
|
|
532
|
+
const rows = process.stdout.rows || 24;
|
|
533
|
+
if (rows > 1)
|
|
534
|
+
process.stdout.write(NL.repeat(rows - 1));
|
|
535
|
+
}
|
|
507
536
|
dispose() {
|
|
508
537
|
const stdin = process.stdin;
|
|
509
538
|
stdin.removeListener('data', this._onData);
|
|
@@ -652,31 +681,38 @@ export class LineEditor {
|
|
|
652
681
|
// free (effective width = cols - 1) for the right-aligned context and for
|
|
653
682
|
// the padded status row.
|
|
654
683
|
const usable = Math.max(1, cols - 1);
|
|
655
|
-
|
|
684
|
+
// An explicit lifecycle phase (generating/settled) is prefixed to the
|
|
685
|
+
// animated status, so the operator can tell a real generation from a
|
|
686
|
+
// quiet spinner. Empty when no phase is active (tool running, idle).
|
|
687
|
+
const stateLabel = this._stateLabel();
|
|
688
|
+
const statusText = stateLabel
|
|
689
|
+
? stateLabel + ' ' + this.statusText
|
|
690
|
+
: this.statusText;
|
|
691
|
+
if (statusText) {
|
|
656
692
|
if (ctxText) {
|
|
657
693
|
// Right-align the context on the SAME row as the status. When the
|
|
658
694
|
// status is too long to leave room, do NOT cram them together (that
|
|
659
695
|
// pushed the trailing `%` past the right edge and it got truncated):
|
|
660
696
|
// put the context on its own line instead.
|
|
661
|
-
const space = usable - visLen(
|
|
697
|
+
const space = usable - visLen(statusText) - visLen(ctxText);
|
|
662
698
|
if (space >= 2) {
|
|
663
|
-
const row =
|
|
699
|
+
const row = statusText + ' '.repeat(space) + ctxText;
|
|
664
700
|
statusOut = row + NL;
|
|
665
701
|
top = visRows(row, cols);
|
|
666
702
|
}
|
|
667
703
|
else {
|
|
668
|
-
statusOut =
|
|
669
|
-
top = visRows(
|
|
704
|
+
statusOut = statusText + NL;
|
|
705
|
+
top = visRows(statusText, cols);
|
|
670
706
|
const ctxRow = ' '.repeat(Math.max(0, usable - visLen(ctxText))) + ctxText;
|
|
671
707
|
statusOut += ctxRow + NL;
|
|
672
708
|
top += visRows(ctxRow, cols);
|
|
673
709
|
}
|
|
674
710
|
}
|
|
675
711
|
else {
|
|
676
|
-
statusOut =
|
|
712
|
+
statusOut = statusText + NL;
|
|
677
713
|
// The status may wrap onto several lines — we account for this,
|
|
678
714
|
// otherwise the block erase misses and statuses pile up.
|
|
679
|
-
top = visRows(
|
|
715
|
+
top = visRows(statusText, cols);
|
|
680
716
|
}
|
|
681
717
|
}
|
|
682
718
|
else if (ctxText) {
|
|
@@ -789,6 +825,8 @@ export class LineEditor {
|
|
|
789
825
|
process.stdout.write(ESC + '[2J' + ESC + '[H');
|
|
790
826
|
this.rendered = false;
|
|
791
827
|
this._lastStatusBlock = '';
|
|
828
|
+
// Re-pin the block to the bottom after the screen was cleared.
|
|
829
|
+
this._padToBottom();
|
|
792
830
|
this._writeBlock();
|
|
793
831
|
}
|
|
794
832
|
// Repaint ONLY the input rows, leaving the status/spinner block untouched.
|
|
@@ -867,6 +905,28 @@ export class LineEditor {
|
|
|
867
905
|
return '';
|
|
868
906
|
return theme.dim(' · ' + translate(this.locale)('spinner.hint'));
|
|
869
907
|
}
|
|
908
|
+
// The lifecycle phase prefix for the status line, colored by meaning:
|
|
909
|
+
// generating (brown). There is NO prefix for 'settled' (the answer block
|
|
910
|
+
// already marks completion) and none for 'paused' (the base status itself
|
|
911
|
+
// reads "пауза Ns"). Returns '' when no phase is active, so an idle status
|
|
912
|
+
// is not prefixed.
|
|
913
|
+
_stateLabel() {
|
|
914
|
+
if (this._sendState === 'generating')
|
|
915
|
+
return (theme.brown(translate(this.locale)('state.generating')) + theme.dim(':'));
|
|
916
|
+
return '';
|
|
917
|
+
}
|
|
918
|
+
// Called by the browser layer at real lifecycle points: right before a
|
|
919
|
+
// send (generating), during a throttle/Continue pause (paused), and after
|
|
920
|
+
// the answer settled (settled). Any other value clears the phase.
|
|
921
|
+
setSendState(state) {
|
|
922
|
+
const next = state === 'generating' || state === 'paused' || state === 'settled'
|
|
923
|
+
? state
|
|
924
|
+
: '';
|
|
925
|
+
if (next === this._sendState)
|
|
926
|
+
return;
|
|
927
|
+
this._sendState = next;
|
|
928
|
+
this._render();
|
|
929
|
+
}
|
|
870
930
|
_startThinking() {
|
|
871
931
|
if (this.pendingText) {
|
|
872
932
|
this.setStatus(theme.prompt('✎ ') + this.pendingText + this._hint());
|
|
@@ -927,6 +987,9 @@ export class LineEditor {
|
|
|
927
987
|
}
|
|
928
988
|
stop() {
|
|
929
989
|
this._stopDots();
|
|
990
|
+
// A finished send must not keep prefixing the NEXT status (e.g. a tool
|
|
991
|
+
// that starts right after the answer) with a stale "settled"/"generating".
|
|
992
|
+
this._sendState = '';
|
|
930
993
|
this.setStatus('');
|
|
931
994
|
}
|
|
932
995
|
toolCall(name, args) {
|
|
@@ -950,30 +1013,16 @@ export class LineEditor {
|
|
|
950
1013
|
assistant(msg) {
|
|
951
1014
|
this.stop();
|
|
952
1015
|
const rendered = renderMarkdown(msg);
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
// under the answer divider.
|
|
958
|
-
let ctxLine = '';
|
|
959
|
-
if (this.onContextQuery) {
|
|
960
|
-
try {
|
|
961
|
-
const n = this.onContextQuery();
|
|
962
|
-
const txt = formatTokenStatus(n, this.contextLimit);
|
|
963
|
-
if (txt)
|
|
964
|
-
ctxLine = NL + theme.dim(txt);
|
|
965
|
-
}
|
|
966
|
-
catch {
|
|
967
|
-
// never break the render
|
|
968
|
-
}
|
|
969
|
-
}
|
|
1016
|
+
// The used context is NOT repeated under the answer: `_writeBlock` keeps
|
|
1017
|
+
// showing it right-aligned in the status line even after `stop()` clears
|
|
1018
|
+
// the spinner text (the `else if (ctxText)` branch), so printing it here
|
|
1019
|
+
// again was a duplicate of the same `accumulated_token_usage` number.
|
|
970
1020
|
this.printAbove(NL +
|
|
971
1021
|
theme.assistant(translate(this.locale)('editor.answer')) +
|
|
972
1022
|
NL +
|
|
973
1023
|
rendered +
|
|
974
1024
|
NL +
|
|
975
|
-
theme.dim('─'.repeat(60))
|
|
976
|
-
ctxLine);
|
|
1025
|
+
theme.dim('─'.repeat(60)));
|
|
977
1026
|
}
|
|
978
1027
|
// Warning to the operator (e.g. the agent stopped suspiciously).
|
|
979
1028
|
// Printed above the input line without overwriting it.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Pure scheduling logic for the /loop and /cron commands. Kept free of any
|
|
2
|
+
// browser/terminal dependency so it can be unit-tested; index.ts owns the
|
|
3
|
+
// 1-second ticker and the actual enqueue into the message queue.
|
|
4
|
+
// Parse a human interval like "30s", "5m", "2h" or a bare number of seconds.
|
|
5
|
+
// Returns the period in ms, or null when the text is not a valid interval.
|
|
6
|
+
export function parseInterval(text) {
|
|
7
|
+
const m = /^(\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hour|hours)?$/i.exec(String(text ?? '').trim());
|
|
8
|
+
if (!m)
|
|
9
|
+
return null;
|
|
10
|
+
const n = Number(m[1]);
|
|
11
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
12
|
+
return null;
|
|
13
|
+
const unit = (m[2] || 's').toLowerCase();
|
|
14
|
+
const mult = unit.startsWith('h')
|
|
15
|
+
? 3_600_000
|
|
16
|
+
: unit.startsWith('m')
|
|
17
|
+
? 60_000
|
|
18
|
+
: 1000;
|
|
19
|
+
return n * mult;
|
|
20
|
+
}
|
|
21
|
+
// Render an interval back to the shortest human form (used in listings).
|
|
22
|
+
export function formatInterval(ms) {
|
|
23
|
+
if (ms % 3_600_000 === 0)
|
|
24
|
+
return ms / 3_600_000 + 'h';
|
|
25
|
+
if (ms % 60_000 === 0)
|
|
26
|
+
return ms / 60_000 + 'm';
|
|
27
|
+
if (ms % 1000 === 0)
|
|
28
|
+
return ms / 1000 + 's';
|
|
29
|
+
return ms + 'ms';
|
|
30
|
+
}
|
|
31
|
+
// Parse one cron field ("*", "a-b", "*/n", "a,b", "a-b/n") into a value set.
|
|
32
|
+
function parseCronField(field, min, max) {
|
|
33
|
+
const out = new Set();
|
|
34
|
+
for (const part of field.split(',')) {
|
|
35
|
+
const [range, stepStr] = part.split('/');
|
|
36
|
+
const step = stepStr === undefined ? 1 : Number(stepStr);
|
|
37
|
+
if (!Number.isInteger(step) || step <= 0)
|
|
38
|
+
return null;
|
|
39
|
+
let lo;
|
|
40
|
+
let hi;
|
|
41
|
+
if (range === '*') {
|
|
42
|
+
lo = min;
|
|
43
|
+
hi = max;
|
|
44
|
+
}
|
|
45
|
+
else if (range.includes('-')) {
|
|
46
|
+
const [a, b] = range.split('-');
|
|
47
|
+
lo = Number(a);
|
|
48
|
+
hi = Number(b);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
lo = Number(range);
|
|
52
|
+
hi = lo;
|
|
53
|
+
}
|
|
54
|
+
if (!Number.isInteger(lo) || !Number.isInteger(hi))
|
|
55
|
+
return null;
|
|
56
|
+
if (lo < min || hi > max || lo > hi)
|
|
57
|
+
return null;
|
|
58
|
+
for (let v = lo; v <= hi; v += step)
|
|
59
|
+
out.add(v);
|
|
60
|
+
}
|
|
61
|
+
return out.size ? out : null;
|
|
62
|
+
}
|
|
63
|
+
// Parse a 5-field cron expression: minute hour day-of-month month day-of-week.
|
|
64
|
+
// Day-of-week accepts 0-7 (both 0 and 7 mean Sunday). Returns null on a bad
|
|
65
|
+
// expression so the command can report it instead of scheduling silently.
|
|
66
|
+
export function parseCron(expr) {
|
|
67
|
+
const parts = String(expr ?? '')
|
|
68
|
+
.trim()
|
|
69
|
+
.split(/\s+/);
|
|
70
|
+
if (parts.length !== 5)
|
|
71
|
+
return null;
|
|
72
|
+
const minute = parseCronField(parts[0], 0, 59);
|
|
73
|
+
const hour = parseCronField(parts[1], 0, 23);
|
|
74
|
+
const dom = parseCronField(parts[2], 1, 31);
|
|
75
|
+
const month = parseCronField(parts[3], 1, 12);
|
|
76
|
+
const dowRaw = parseCronField(parts[4], 0, 7);
|
|
77
|
+
if (!minute || !hour || !dom || !month || !dowRaw)
|
|
78
|
+
return null;
|
|
79
|
+
const dow = new Set();
|
|
80
|
+
for (const d of dowRaw)
|
|
81
|
+
dow.add(d === 7 ? 0 : d);
|
|
82
|
+
return { minute, hour, dom, month, dow };
|
|
83
|
+
}
|
|
84
|
+
// Does the given Date match the cron expression? dom and dow are ANDed (the
|
|
85
|
+
// common, predictable behavior); to schedule "every day" use `*` for one.
|
|
86
|
+
export function cronMatches(expr, date) {
|
|
87
|
+
const c = parseCron(expr);
|
|
88
|
+
if (!c)
|
|
89
|
+
return false;
|
|
90
|
+
return (c.minute.has(date.getMinutes()) &&
|
|
91
|
+
c.hour.has(date.getHours()) &&
|
|
92
|
+
c.dom.has(date.getDate()) &&
|
|
93
|
+
c.month.has(date.getMonth() + 1) &&
|
|
94
|
+
c.dow.has(date.getDay()));
|
|
95
|
+
}
|
|
96
|
+
// Next epoch ms at which the expression fires, strictly after `fromMs`. Scans
|
|
97
|
+
// minute by minute (bounded to ~4 years); a one-time cost when a job is
|
|
98
|
+
// created/rescheduled, and the field checks reject almost every minute fast.
|
|
99
|
+
export function nextCronTime(expr, fromMs) {
|
|
100
|
+
const c = parseCron(expr);
|
|
101
|
+
if (!c)
|
|
102
|
+
return null;
|
|
103
|
+
const d = new Date(fromMs);
|
|
104
|
+
d.setSeconds(0, 0);
|
|
105
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
106
|
+
const limit = fromMs + 4 * 366 * 24 * 60 * 60 * 1000;
|
|
107
|
+
while (d.getTime() <= limit) {
|
|
108
|
+
if (cronMatches(expr, d))
|
|
109
|
+
return d.getTime();
|
|
110
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
// Holds the scheduled jobs. Pure: no timers here. index.ts drives it with a
|
|
115
|
+
// 1-second tick and enqueues the tasks that `due()` returns.
|
|
116
|
+
export class Scheduler {
|
|
117
|
+
jobs = [];
|
|
118
|
+
nextId = 1;
|
|
119
|
+
addLoop(intervalMs, task, now) {
|
|
120
|
+
const job = {
|
|
121
|
+
id: this.nextId++,
|
|
122
|
+
kind: 'loop',
|
|
123
|
+
task,
|
|
124
|
+
intervalMs,
|
|
125
|
+
nextAt: now + intervalMs,
|
|
126
|
+
createdAt: now,
|
|
127
|
+
};
|
|
128
|
+
this.jobs.push(job);
|
|
129
|
+
return job;
|
|
130
|
+
}
|
|
131
|
+
addCron(cron, task, now) {
|
|
132
|
+
const nextAt = nextCronTime(cron, now);
|
|
133
|
+
if (nextAt === null)
|
|
134
|
+
return null;
|
|
135
|
+
const job = {
|
|
136
|
+
id: this.nextId++,
|
|
137
|
+
kind: 'cron',
|
|
138
|
+
task,
|
|
139
|
+
cron,
|
|
140
|
+
nextAt,
|
|
141
|
+
createdAt: now,
|
|
142
|
+
};
|
|
143
|
+
this.jobs.push(job);
|
|
144
|
+
return job;
|
|
145
|
+
}
|
|
146
|
+
remove(id) {
|
|
147
|
+
const i = this.jobs.findIndex((j) => j.id === id);
|
|
148
|
+
if (i === -1)
|
|
149
|
+
return false;
|
|
150
|
+
this.jobs.splice(i, 1);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
clear() {
|
|
154
|
+
const n = this.jobs.length;
|
|
155
|
+
this.jobs = [];
|
|
156
|
+
return n;
|
|
157
|
+
}
|
|
158
|
+
list() {
|
|
159
|
+
return this.jobs.slice();
|
|
160
|
+
}
|
|
161
|
+
count() {
|
|
162
|
+
return this.jobs.length;
|
|
163
|
+
}
|
|
164
|
+
// Jobs whose nextAt is due at `now`, rescheduled for their NEXT fire so a
|
|
165
|
+
// single tick never fires the same job twice. A loop that fell behind is
|
|
166
|
+
// advanced past `now` in one step (missed fires are coalesced, not queued
|
|
167
|
+
// many times).
|
|
168
|
+
due(now) {
|
|
169
|
+
const fired = [];
|
|
170
|
+
for (const job of this.jobs) {
|
|
171
|
+
if (job.nextAt > now)
|
|
172
|
+
continue;
|
|
173
|
+
fired.push({ ...job });
|
|
174
|
+
if (job.kind === 'loop' && job.intervalMs && job.intervalMs > 0) {
|
|
175
|
+
const missed = Math.floor((now - job.nextAt) / job.intervalMs);
|
|
176
|
+
job.nextAt = job.nextAt + (missed + 1) * job.intervalMs;
|
|
177
|
+
}
|
|
178
|
+
else if (job.cron) {
|
|
179
|
+
const nxt = nextCronTime(job.cron, now);
|
|
180
|
+
job.nextAt = nxt === null ? Number.POSITIVE_INFINITY : nxt;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return fired;
|
|
184
|
+
}
|
|
185
|
+
}
|
package/dist/theme.js
CHANGED
|
@@ -23,6 +23,7 @@ export const theme = {
|
|
|
23
23
|
toolResult: chalk.hex('#8a9bb5'), // gray-blue
|
|
24
24
|
system: chalk.hex('#808896'), // neutral gray
|
|
25
25
|
dim: chalk.hex('#5b616e'), // dark gray
|
|
26
|
+
taskSummary: chalk.hex('#8fa3c8'), // per-task summary — noticeable but calm, distinct from spinner brown and warn ochre
|
|
26
27
|
warn: chalk.hex('#c9a86a'), // calm ochre
|
|
27
28
|
error: chalk.hex('#c98a80'), // muted terracotta
|
|
28
29
|
success: chalk.hex('#a9c08c'), // soft sage
|