zames_pro 2.32.0 → 2.35.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/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
- let compact;
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
@@ -298,6 +310,13 @@ export class LineEditor {
298
310
  contextStatus;
299
311
  // The raw token count behind contextStatus (for the color level).
300
312
  contextTokens;
313
+ // Number of messages waiting in the queue, shown as a small "⧗N" badge in
314
+ // the status line so the operator sees that typed-ahead messages are still
315
+ // pending. 0 hides it. Updated by the caller.
316
+ queueLength;
317
+ // A callback the editor calls before every render to read the CURRENT queue
318
+ // length, so the badge stays live without the caller polling.
319
+ onQueueQuery;
301
320
  // Whether the DeepSeek "Deep thinking" / "Smart search" toggles are ON.
302
321
  // Shown as two colored icons before the context counter: a dim gray icon
303
322
  // means off, a teal-green icon means on.
@@ -333,6 +352,7 @@ export class LineEditor {
333
352
  this._showHint = true;
334
353
  this._dotPhase = 0;
335
354
  this._thinkBase = '';
355
+ this._sendState = '';
336
356
  this._lastStatusBlock = '';
337
357
  this._wasRaw = false;
338
358
  this._onData = (b) => this._handle(b);
@@ -375,6 +395,8 @@ export class LineEditor {
375
395
  this.contextLimit = CONTEXT_LIMIT;
376
396
  this.onToggleQuery = null;
377
397
  this.onContextQuery = null;
398
+ this.queueLength = 0;
399
+ this.onQueueQuery = null;
378
400
  }
379
401
  // Refresh the toggle icon states from onToggleQuery (if wired) before a
380
402
  // render. A broken callback must never break the render.
@@ -423,6 +445,23 @@ export class LineEditor {
423
445
  return theme.warn(ctx);
424
446
  return theme.success(ctx);
425
447
  }
448
+ // The queue badge ("⧗N") shown before the context counter when messages are
449
+ // waiting. Returns '' when the queue is empty, so it never clutters an idle
450
+ // status line. Refreshed from onQueueQuery before every render when wired.
451
+ _queueBadge() {
452
+ if (this.onQueueQuery) {
453
+ try {
454
+ const n = this.onQueueQuery();
455
+ this.queueLength = typeof n === 'number' && n > 0 ? n : 0;
456
+ }
457
+ catch {
458
+ // A broken callback must never break the render.
459
+ }
460
+ }
461
+ if (this.queueLength <= 0)
462
+ return '';
463
+ return theme.warn('⧗' + this.queueLength) + ' ';
464
+ }
426
465
  // Icons for the DeepSeek toggles: 🧠 "Deep thinking" and 🌐 "Smart search",
427
466
  // shown before the context counter. Only the ENABLED toggles are shown, so
428
467
  // the state is unambiguous WITHOUT relying on the terminal coloring the
@@ -658,7 +697,7 @@ export class LineEditor {
658
697
  // but the icons are still meaningful.
659
698
  const ctxRaw = this._contextText();
660
699
  this._refreshToggles();
661
- const ctxText = this._toggleIcons() + ctxRaw;
700
+ const ctxText = this._queueBadge() + this._toggleIcons() + ctxRaw;
662
701
  let statusOut = '';
663
702
  let top = 0;
664
703
  // Never fill a row to the FULL terminal width: on terminals with
@@ -668,31 +707,38 @@ export class LineEditor {
668
707
  // free (effective width = cols - 1) for the right-aligned context and for
669
708
  // the padded status row.
670
709
  const usable = Math.max(1, cols - 1);
671
- if (this.statusText) {
710
+ // An explicit lifecycle phase (generating/settled) is prefixed to the
711
+ // animated status, so the operator can tell a real generation from a
712
+ // quiet spinner. Empty when no phase is active (tool running, idle).
713
+ const stateLabel = this._stateLabel();
714
+ const statusText = stateLabel
715
+ ? stateLabel + ' ' + this.statusText
716
+ : this.statusText;
717
+ if (statusText) {
672
718
  if (ctxText) {
673
719
  // Right-align the context on the SAME row as the status. When the
674
720
  // status is too long to leave room, do NOT cram them together (that
675
721
  // pushed the trailing `%` past the right edge and it got truncated):
676
722
  // put the context on its own line instead.
677
- const space = usable - visLen(this.statusText) - visLen(ctxText);
723
+ const space = usable - visLen(statusText) - visLen(ctxText);
678
724
  if (space >= 2) {
679
- const row = this.statusText + ' '.repeat(space) + ctxText;
725
+ const row = statusText + ' '.repeat(space) + ctxText;
680
726
  statusOut = row + NL;
681
727
  top = visRows(row, cols);
682
728
  }
683
729
  else {
684
- statusOut = this.statusText + NL;
685
- top = visRows(this.statusText, cols);
730
+ statusOut = statusText + NL;
731
+ top = visRows(statusText, cols);
686
732
  const ctxRow = ' '.repeat(Math.max(0, usable - visLen(ctxText))) + ctxText;
687
733
  statusOut += ctxRow + NL;
688
734
  top += visRows(ctxRow, cols);
689
735
  }
690
736
  }
691
737
  else {
692
- statusOut = this.statusText + NL;
738
+ statusOut = statusText + NL;
693
739
  // The status may wrap onto several lines — we account for this,
694
740
  // otherwise the block erase misses and statuses pile up.
695
- top = visRows(this.statusText, cols);
741
+ top = visRows(statusText, cols);
696
742
  }
697
743
  }
698
744
  else if (ctxText) {
@@ -885,6 +931,28 @@ export class LineEditor {
885
931
  return '';
886
932
  return theme.dim(' · ' + translate(this.locale)('spinner.hint'));
887
933
  }
934
+ // The lifecycle phase prefix for the status line, colored by meaning:
935
+ // generating (brown). There is NO prefix for 'settled' (the answer block
936
+ // already marks completion) and none for 'paused' (the base status itself
937
+ // reads "пауза Ns"). Returns '' when no phase is active, so an idle status
938
+ // is not prefixed.
939
+ _stateLabel() {
940
+ if (this._sendState === 'generating')
941
+ return (theme.brown(translate(this.locale)('state.generating')) + theme.dim(':'));
942
+ return '';
943
+ }
944
+ // Called by the browser layer at real lifecycle points: right before a
945
+ // send (generating), during a throttle/Continue pause (paused), and after
946
+ // the answer settled (settled). Any other value clears the phase.
947
+ setSendState(state) {
948
+ const next = state === 'generating' || state === 'paused' || state === 'settled'
949
+ ? state
950
+ : '';
951
+ if (next === this._sendState)
952
+ return;
953
+ this._sendState = next;
954
+ this._render();
955
+ }
888
956
  _startThinking() {
889
957
  if (this.pendingText) {
890
958
  this.setStatus(theme.prompt('✎ ') + this.pendingText + this._hint());
@@ -945,6 +1013,9 @@ export class LineEditor {
945
1013
  }
946
1014
  stop() {
947
1015
  this._stopDots();
1016
+ // A finished send must not keep prefixing the NEXT status (e.g. a tool
1017
+ // that starts right after the answer) with a stale "settled"/"generating".
1018
+ this._sendState = '';
948
1019
  this.setStatus('');
949
1020
  }
950
1021
  toolCall(name, args) {
@@ -968,30 +1039,16 @@ export class LineEditor {
968
1039
  assistant(msg) {
969
1040
  this.stop();
970
1041
  const rendered = renderMarkdown(msg);
971
- // Show the used context size at the END of the answer too. During
972
- // generation it lives in the status line, but ui.stop() clears that right
973
- // before this call, so the operator lost the number exactly when the
974
- // answer arrived. Read it fresh via onContextQuery and append it dimmed
975
- // under the answer divider.
976
- let ctxLine = '';
977
- if (this.onContextQuery) {
978
- try {
979
- const n = this.onContextQuery();
980
- const txt = formatTokenStatus(n, this.contextLimit);
981
- if (txt)
982
- ctxLine = NL + theme.dim(txt);
983
- }
984
- catch {
985
- // never break the render
986
- }
987
- }
1042
+ // The used context is NOT repeated under the answer: `_writeBlock` keeps
1043
+ // showing it right-aligned in the status line even after `stop()` clears
1044
+ // the spinner text (the `else if (ctxText)` branch), so printing it here
1045
+ // again was a duplicate of the same `accumulated_token_usage` number.
988
1046
  this.printAbove(NL +
989
1047
  theme.assistant(translate(this.locale)('editor.answer')) +
990
1048
  NL +
991
1049
  rendered +
992
1050
  NL +
993
- theme.dim('─'.repeat(60)) +
994
- ctxLine);
1051
+ theme.dim('─'.repeat(60)));
995
1052
  }
996
1053
  // Warning to the operator (e.g. the agent stopped suspiciously).
997
1054
  // 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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zames_pro",
3
- "version": "2.32.0",
3
+ "version": "2.35.0",
4
4
  "description": "Terminal coding agent over chat.deepseek.com via Playwright",
5
5
  "type": "module",
6
6
  "bin": {