zames_pro 2.71.0 → 2.72.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/browser.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { chromium, } from 'playwright';
2
2
  import { extractAnswer, extractTokenUsage, dumpNetBody, isFinishedWithoutAnswer, } from './net-capture.js';
3
3
  import { rewriteFailedAttachments } from './commands.js';
4
- import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, STATUS_RE, RATE_LIMIT_RE, SERVER_BUSY_RE, GENERATION_ERR_RE, INCOMPLETE_STATUS_RE, ANSWER_URL_RE, } from './deepseek-ui.js';
4
+ import { parseHistoryMessages, extractChatMessages, scrapeChatList, scrapeChatMessages, } from './browser-chats.js';
5
+ import { CHAT_URL, CHAT_ID_RE, chatUrl, HISTORY_MESSAGES_URL, BUTTON_CLASS_RE, INPUT_SELECTORS, SEND_SELECTORS, STOP_SELECTORS, STOP_NAME_RE, CONTINUE_NAME_RE, ANSWER_SELECTORS, THINK_CLASS_RE, MESSAGE_CONTAINER_SELECTORS, RESTORE_ANSWER_SELECTORS, ASSISTANT_CLASS_RE, ASSISTANT_SELECTOR, ANSWER_MARKDOWN_SELECTOR, MARKDOWN_SELECTOR, CHAT_SIGNAL_NODES_SELECTOR, NEW_CHAT_SELECTOR, NEW_CHAT_TEXT_RE, PASSWORD_SELECTORS, LOGIN_SELECTORS, LOGIN_SUBMIT_SELECTORS, LOGIN_BUTTON_SELECTOR, LOGIN_NAME_RE, LOGIN_FORM_SUBMIT_RE, SIDEBAR_TOGGLE_SELECTORS, TOGGLE_SELECTORS, DEEP_THINKING_RE, WEB_SEARCH_RE, ATTACH_SELECTORS, STATUS_RE, ANSWER_URL_RE, } from './deepseek-ui.js';
5
6
  import { probeUiHealth, } from './ui-health.js';
6
7
  import path from 'path';
7
8
  import os from 'os';
@@ -13,234 +14,12 @@ import { translate, DEFAULT_LOCALE } from './i18n.js';
13
14
  // here preserves the existing public API (test/*.test.ts import them from
14
15
  // browser.ts).
15
16
  export { STOP_NAME_RE, CONTINUE_NAME_RE };
16
- // Muted terminal input for passwords. We do NOT use readline here: readline
17
- // echoes the typed text through its own internal _writeToOutput, which cannot
18
- // be reliably overridden from outside (a real bug: the login prompt swallowed
19
- // the password step). Instead we read raw keystrokes from stdin and echo one
20
- // `*` per typed character. In a non-TTY (pipe/redirect) we fall back to a
21
- // plain line read.
22
- export function askPassword(question) {
23
- return new Promise((resolve) => {
24
- process.stdout.write(question);
25
- // Non-interactive: read a single line from stdin as-is.
26
- if (!process.stdin.isTTY) {
27
- void import('readline').then(({ createInterface }) => {
28
- const rl = createInterface({
29
- input: process.stdin,
30
- output: process.stdout,
31
- });
32
- rl.question('', (answer) => {
33
- rl.close();
34
- resolve(answer);
35
- });
36
- });
37
- return;
38
- }
39
- const stdin = process.stdin;
40
- const wasRaw = stdin.isRaw;
41
- stdin.setRawMode(true);
42
- stdin.resume();
43
- let value = '';
44
- const onData = (buf) => {
45
- const s = buf.toString('utf8');
46
- for (const ch of s) {
47
- const code = ch.charCodeAt(0);
48
- // Enter / Ctrl+J -> done.
49
- if (ch === '\r' || ch === '\n') {
50
- done();
51
- return;
52
- }
53
- // Ctrl+C -> abort the whole process (same as elsewhere in the CLI).
54
- if (code === 3) {
55
- cleanup();
56
- process.stdout.write('\n');
57
- process.exit(130);
58
- }
59
- // Ctrl+D -> finish with whatever we have.
60
- if (code === 4) {
61
- done();
62
- return;
63
- }
64
- // Backspace (0x7f or 0x08): remove the last char and one star.
65
- if (code === 127 || code === 8) {
66
- if (value.length > 0) {
67
- value = value.slice(0, -1);
68
- process.stdout.write('\b \b');
69
- }
70
- continue;
71
- }
72
- // Ignore other control characters (arrows, etc.).
73
- if (code < 32)
74
- continue;
75
- value += ch;
76
- process.stdout.write('*');
77
- }
78
- };
79
- const cleanup = () => {
80
- stdin.removeListener('data', onData);
81
- try {
82
- stdin.setRawMode(wasRaw || false);
83
- }
84
- catch { }
85
- stdin.pause();
86
- };
87
- const done = () => {
88
- cleanup();
89
- process.stdout.write('\n');
90
- resolve(value);
91
- };
92
- stdin.on('data', onData);
93
- });
94
- }
95
- const USER_DATA_DIR = path.join(os.homedir(), '.zames', 'profile');
96
- // Cache of the sanitized (non-Headless) User-Agent, keyed by the browser
97
- // engine path. On the FIRST headless run we detect the real UA, strip the
98
- // "Headless" marker and store it here; later runs pass it straight to
99
- // _launchOnce, so the browser starts with a good UA and the "launch → read UA →
100
- // relaunch" dance happens only once (it otherwise doubled every headless
101
- // start). Keying by the executable path means a Playwright update (new engine
102
- // path) invalidates the cache and we re-derive the UA once for the new build.
103
- const HEADLESS_UA_CACHE = path.join(os.homedir(), '.zames', 'headless-ua.json');
104
- // Pure: pick a reusable UA from the cache file's text for the given engine, or
105
- // null. Exported so the policy is unit-tested without touching ~/.zames.
106
- export function parseHeadlessUACache(raw, engine) {
107
- try {
108
- const j = JSON.parse(raw);
109
- if (j &&
110
- j.engine === engine &&
111
- typeof j.ua === 'string' &&
112
- j.ua &&
113
- !/Headless/i.test(j.ua)) {
114
- return j.ua;
115
- }
116
- }
117
- catch { }
118
- return null;
119
- }
120
- function headlessEngineKey() {
121
- try {
122
- return chromium.executablePath();
123
- }
124
- catch {
125
- return '';
126
- }
127
- }
128
- async function readCachedHeadlessUA(engine) {
129
- if (!engine)
130
- return null;
131
- try {
132
- const raw = await fs.readFile(HEADLESS_UA_CACHE, 'utf-8');
133
- return parseHeadlessUACache(raw, engine);
134
- }
135
- catch {
136
- return null;
137
- }
138
- }
139
- async function writeCachedHeadlessUA(engine, ua) {
140
- if (!engine || !ua)
141
- return;
142
- try {
143
- await fs.mkdir(path.dirname(HEADLESS_UA_CACHE), { recursive: true });
144
- await fs.writeFile(HEADLESS_UA_CACHE, JSON.stringify({ engine, ua }, null, 2), 'utf-8');
145
- }
146
- catch { }
147
- }
148
- // For /doctor: is a sanitized UA cached for the CURRENT engine build? A missing
149
- // cache only means the next headless start relaunches once (not an error), so
150
- // this is informational. Best-effort.
151
- export async function headlessUACacheReady() {
152
- const ua = await readCachedHeadlessUA(headlessEngineKey());
153
- return !!ua;
154
- }
155
- // A headless Chrome advertises "HeadlessChrome/..." in its User-Agent, and
156
- // DeepSeek's CDN (CloudFront/WAF) rejects that UA with a plain "403 ERROR"
157
- // page before the app is even served — so a headless login looked broken while
158
- // a headed one worked (headed Chrome sends "Chrome/..."). We keep the real
159
- // engine version and only drop the "Headless" marker. When the operator
160
- // explicitly set a UA in the config, that one wins (see _launchOnce).
161
- export function sanitizeHeadlessUA(ua) {
162
- return String(ua || '').replace(/HeadlessChrome/g, 'Chrome');
163
- }
164
- // Written after a successful sign-in so the next launch knows a session was
165
- // stored in the persistent profile (used by /doctor and diagnostics).
166
- const AUTH_MARKER_FILE = path.join(os.homedir(), '.zames', 'auth.json');
167
- export function isGenerationIncompleteText(text) {
168
- const t = String(text || '');
169
- return GENERATION_ERR_RE.test(t) || INCOMPLETE_STATUS_RE.test(t);
170
- }
171
- export function isRateLimitText(text) {
172
- return RATE_LIMIT_RE.test(String(text || ''));
173
- }
174
- export function isServerBusyText(text) {
175
- const t = String(text || '');
176
- // Rate limit takes priority (it needs the long wait).
177
- if (RATE_LIMIT_RE.test(t))
178
- return false;
179
- return SERVER_BUSY_RE.test(t);
180
- }
181
- // Normalize text for comparison: collapse whitespace so that tiny DOM
182
- // differences (nbsp, trailing spaces) don't count as "a new answer".
183
- export function normText(s) {
184
- return String(s || '')
185
- .replace(/\u00a0/g, ' ')
186
- .replace(/\s+/g, ' ')
187
- .trim();
188
- }
189
- // The "too frequent" error: distinct from others so ask() waits a long time
190
- // (DeepSeek limits reset over minutes) and retries the send itself.
191
- export class RateLimitError extends Error {
192
- constructor(detail) {
193
- super('Messages too frequent. Try again later. ' + detail);
194
- this.name = 'RateLimitError';
195
- }
196
- }
197
- // DeepSeek transient server error (Server busy).
198
- export class ServerBusyError extends Error {
199
- constructor(detail) {
200
- super('Server busy. Try again later. ' + detail);
201
- this.name = 'ServerBusyError';
202
- }
203
- }
204
- // The generation was truncated by the server (`generation_err` / INCOMPLETE).
205
- // Distinct from ServerBusyError: the DeepSeek backend answered, but the turn
206
- // did not finish, and the UI offers a "Continue" button. The agent retries the
207
- // same send (a new message into the chat) instead of waiting for an answer
208
- // that will never arrive. This is what the operator hit with the reasoning
209
- // (deep thinking) mode on: the long THINK phase makes the truncation far more
210
- // likely, so with thinking off everything worked and with it on the turn kept
211
- // stopping and showing "Continue".
212
- export class GenerationIncompleteError extends Error {
213
- constructor(detail) {
214
- super('Generation incomplete (server truncated the answer). ' + detail);
215
- this.name = 'GenerationIncompleteError';
216
- }
217
- }
218
- // ---------- profile cleanup ----------
219
- async function cleanSingletonFiles() {
220
- const names = ['SingletonLock', 'SingletonCookie', 'SingletonSocket'];
221
- for (const name of names) {
222
- const p = path.join(USER_DATA_DIR, name);
223
- try {
224
- await fs.unlink(p);
225
- }
226
- catch { }
227
- }
228
- }
229
- async function profileLooksLocked() {
230
- try {
231
- await fs.access(path.join(USER_DATA_DIR, 'SingletonLock'));
232
- return true;
233
- }
234
- catch {
235
- return false;
236
- }
237
- }
238
- async function nukeProfile() {
239
- try {
240
- await fs.rm(USER_DATA_DIR, { recursive: true, force: true });
241
- }
242
- catch { }
243
- }
17
+ // The pure, state-free helpers live in browser-pure.ts (BACKLOG C3): they are
18
+ // imported for the class below and RE-EXPORTED here, so the public API of this
19
+ // module is unchanged (tests and other modules import these names from
20
+ // './browser.js').
21
+ import { askPassword, USER_DATA_DIR, parseHeadlessUACache, headlessEngineKey, readCachedHeadlessUA, writeCachedHeadlessUA, headlessUACacheReady, sanitizeHeadlessUA, AUTH_MARKER_FILE, isGenerationIncompleteText, isRateLimitText, isServerBusyText, normText, RateLimitError, ServerBusyError, GenerationIncompleteError, cleanSingletonFiles, profileLooksLocked, nukeProfile, } from './browser-pure.js';
22
+ export { askPassword, parseHeadlessUACache, headlessUACacheReady, sanitizeHeadlessUA, isGenerationIncompleteText, isRateLimitText, isServerBusyText, normText, RateLimitError, ServerBusyError, GenerationIncompleteError, };
244
23
  export class DeepSeekBrowser {
245
24
  headless;
246
25
  debug;
@@ -2605,31 +2384,9 @@ export class DeepSeekBrowser {
2605
2384
  }
2606
2385
  async listChats(limit = 30) {
2607
2386
  await this._ensureSidebarOpen();
2608
- return await this.page.evaluate((lim) => {
2609
- const out = [];
2610
- const seen = new Set();
2611
- const anchors = document.querySelectorAll('a[href*="/chat/"]');
2612
- for (const a of anchors) {
2613
- const href = a.getAttribute('href') || '';
2614
- const m = href.match(/\/chat\/s\/([a-zA-Z0-9_-]+)/) ||
2615
- href.match(/\/a\/chat\/s\/([a-zA-Z0-9_-]+)/);
2616
- if (!m)
2617
- continue;
2618
- const id = m[1];
2619
- if (seen.has(id))
2620
- continue;
2621
- seen.add(id);
2622
- const titleEl = a.querySelector('[class*="title"], [class*="text"]');
2623
- let title = (titleEl ? titleEl.textContent : a.textContent) || '';
2624
- title = title.trim().replace(/\s+/g, ' ');
2625
- if (!title)
2626
- title = '(без названия)';
2627
- out.push({ id, title, href });
2628
- if (out.length >= lim)
2629
- break;
2630
- }
2631
- return out;
2632
- }, limit);
2387
+ // The scraper is a named export (browser-chats.ts) so the class stays thin
2388
+ // and the scraping policy has one home; it runs inside the page.
2389
+ return await this.page.evaluate(scrapeChatList, limit);
2633
2390
  }
2634
2391
  async openChat(id) {
2635
2392
  // Click the chat link and VERIFY the URL actually points at the
@@ -2696,6 +2453,11 @@ export class DeepSeekBrowser {
2696
2453
  }
2697
2454
  const auth = this._apiAuth;
2698
2455
  const pow = this._apiPow;
2456
+ // The in-page fetch only RETURNS the raw chat_messages; the parsing happens
2457
+ // OUTSIDE, in the pure parseHistoryMessages() (browser-chats.ts), so the
2458
+ // in-page and the Playwright-request fallback paths share ONE parser and
2459
+ // cannot drift. The in-page callback cannot call our helpers (it runs in
2460
+ // the page context), so it must stay a plain data fetch.
2699
2461
  const res = await this.page
2700
2462
  .evaluate(async (opts) => {
2701
2463
  try {
@@ -2714,53 +2476,35 @@ export class DeepSeekBrowser {
2714
2476
  headers,
2715
2477
  });
2716
2478
  if (!resp.ok)
2717
- return { error: 'HTTP ' + resp.status, list: [] };
2479
+ return { error: 'HTTP ' + resp.status, messages: null };
2718
2480
  const json = await resp.json();
2719
2481
  const messages = json && json.data && json.data.biz_data
2720
2482
  ? json.data.biz_data.chat_messages
2721
2483
  : null;
2722
2484
  if (!Array.isArray(messages)) {
2723
- return { error: 'no chat_messages in response', list: [] };
2485
+ return { error: 'no chat_messages in response', messages: null };
2724
2486
  }
2725
- const NL = String.fromCharCode(10);
2726
- const out = [];
2727
- // The context size: the LATEST accumulated_token_usage in the chat
2728
- // (each message carries the running counter).
2729
- let usage = null;
2730
- for (const m of messages) {
2731
- if (m && typeof m.accumulated_token_usage === 'number') {
2732
- usage = m.accumulated_token_usage;
2733
- }
2734
- const role = m && m.role === 'ASSISTANT' ? 'assistant' : 'user';
2735
- const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
2736
- let text = '';
2737
- for (const fr of (m && m.fragments) || []) {
2738
- if (!fr || fr.type !== want)
2739
- continue;
2740
- if (typeof fr.content === 'string')
2741
- text += fr.content;
2742
- }
2743
- text = text.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
2744
- if (text)
2745
- out.push({ role, text });
2746
- }
2747
- return { error: '', list: out, usage };
2487
+ return { error: '', messages };
2748
2488
  }
2749
2489
  catch (e) {
2750
- return { error: 'fetch failed: ' + e.message, list: [] };
2490
+ return {
2491
+ error: 'fetch failed: ' + e.message,
2492
+ messages: null,
2493
+ };
2751
2494
  }
2752
2495
  }, { chatId: id, auth, pow, historyUrl: HISTORY_MESSAGES_URL })
2753
2496
  .catch((e) => ({
2754
2497
  error: 'evaluate failed: ' + e.message,
2755
- list: [],
2498
+ messages: null,
2756
2499
  }));
2757
2500
  this._lastHistoryError = res?.error || '';
2501
+ // Parse the raw messages with the SAME pure parser the fallback uses.
2502
+ const parsed = parseHistoryMessages(res?.messages);
2758
2503
  // Pick up the context size the history carries, so /resume (and /cost
2759
2504
  // right after it) shows a real number even before the first answer.
2760
- const usage = res?.usage;
2761
- if (typeof usage === 'number')
2762
- this._lastTokenUsage = usage;
2763
- const list = (res?.list || []);
2505
+ if (typeof parsed.usage === 'number')
2506
+ this._lastTokenUsage = parsed.usage;
2507
+ const list = parsed.list;
2764
2508
  if (list.length || !res?.error)
2765
2509
  return list;
2766
2510
  // Fallback: Playwright's own request context (shares the browser cookies)
@@ -2770,31 +2514,17 @@ export class DeepSeekBrowser {
2770
2514
  const resp = await this.page.request.get(HISTORY_MESSAGES_URL + '?chat_session_id=' + encodeURIComponent(id), { headers: { accept: 'application/json' } });
2771
2515
  if (!resp.ok())
2772
2516
  return list;
2773
- const json = (await resp.json());
2774
- const messages = json?.data?.biz_data?.chat_messages;
2775
- if (!Array.isArray(messages))
2517
+ const json = await resp.json();
2518
+ const messages = extractChatMessages(json);
2519
+ if (!messages)
2776
2520
  return list;
2777
- const NL = String.fromCharCode(10);
2778
- const out = [];
2779
- for (const m of messages) {
2780
- if (m && typeof m.accumulated_token_usage === 'number') {
2781
- this._lastTokenUsage = m.accumulated_token_usage;
2782
- }
2783
- const role = m && m.role === 'ASSISTANT' ? 'assistant' : 'user';
2784
- const want = role === 'assistant' ? 'RESPONSE' : 'REQUEST';
2785
- let text = '';
2786
- for (const fr of (m && m.fragments) || []) {
2787
- if (!fr || fr.type !== want)
2788
- continue;
2789
- if (typeof fr.content === 'string')
2790
- text += fr.content;
2791
- }
2792
- text = text.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
2793
- if (text)
2794
- out.push({ role, text });
2795
- }
2521
+ // Same pure parser as the in-page path (browser-chats.ts), so the two
2522
+ // cannot drift.
2523
+ const parsed = parseHistoryMessages(messages);
2524
+ if (typeof parsed.usage === 'number')
2525
+ this._lastTokenUsage = parsed.usage;
2796
2526
  this._lastHistoryError = '';
2797
- return out;
2527
+ return parsed.list;
2798
2528
  }
2799
2529
  catch (e) {
2800
2530
  this._lastHistoryError = 'request failed: ' + e.message;
@@ -2810,92 +2540,17 @@ export class DeepSeekBrowser {
2810
2540
  // ds-assistant-message-* class; anything that is not an assistant block is
2811
2541
  // treated as a user message. The model's reasoning (.ds-think-content) is
2812
2542
  // skipped, exactly like in _readLastAnswerText.
2543
+ // Read the WHOLE visible dialogue of the currently open chat, top to
2544
+ // bottom. Used by /resume and /resume-id so the operator sees the restored
2545
+ // context in the terminal instead of just "Chat opened.".
2546
+ //
2547
+ // The in-page scraper (scrapeChatMessages, browser-chats.ts) is a named
2548
+ // export so the class body stays thin; it runs inside the page.
2813
2549
  async readChatMessages() {
2814
2550
  if (!this.page)
2815
2551
  return [];
2816
2552
  const raw = await this.page
2817
- .evaluate((opts) => {
2818
- const think = new RegExp(opts.thinkRe, 'i');
2819
- const assistantRe = new RegExp(opts.assistantRe, 'i');
2820
- const inThink = (e) => {
2821
- let n = e;
2822
- while (n) {
2823
- const cls = (n.className || '').toString();
2824
- if (think.test(cls))
2825
- return true;
2826
- n = n.parentElement;
2827
- }
2828
- return false;
2829
- };
2830
- const textOf = (e) => {
2831
- const h = e;
2832
- const t = h.innerText || h.textContent || '';
2833
- const NL = String.fromCharCode(10);
2834
- return t.replace(new RegExp(NL + '{3,}', 'g'), NL + NL).trim();
2835
- };
2836
- // A message block is "assistant" when it contains an answer markdown
2837
- // wrapper (ds-markdown / ds-assistant-message), otherwise it is a
2838
- // user bubble. DeepSeek's class names drift between builds, so the
2839
- // detection is content-based, not class-prefix-based.
2840
- const looksAssistant = (e) => {
2841
- const cls = (e.className || '').toString();
2842
- if (assistantRe.test(cls))
2843
- return true;
2844
- if (e.querySelector(opts.assistantSel))
2845
- return true;
2846
- // A user bubble has no rendered markdown; an answer does.
2847
- if (e.querySelector(opts.markdownSel))
2848
- return true;
2849
- return false;
2850
- };
2851
- // Message-level containers first (broad), then the answer wrappers.
2852
- // See MESSAGE_CONTAINER_SELECTORS in deepseek-ui.ts.
2853
- let blocks = [];
2854
- for (const s of opts.containerSels) {
2855
- const found = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e) && textOf(e).length > 0);
2856
- if (found.length) {
2857
- blocks = found;
2858
- break;
2859
- }
2860
- }
2861
- // De-duplicate by node identity and drop a block that is NESTED
2862
- // inside another block. Some builds match both an outer message
2863
- // container and an inner one; without this the restored dialogue
2864
- // printed each turn twice. We keep only the OUTERMOST blocks.
2865
- if (blocks.length > 1) {
2866
- const unique = Array.from(new Set(blocks));
2867
- blocks = unique.filter((b) => !unique.some((other) => other !== b && other.contains(b)));
2868
- }
2869
- const out = [];
2870
- if (blocks.length) {
2871
- for (const b of blocks) {
2872
- const t = textOf(b);
2873
- if (!t)
2874
- continue;
2875
- out.push({
2876
- role: looksAssistant(b) ? 'assistant' : 'user',
2877
- text: t,
2878
- });
2879
- }
2880
- if (out.length)
2881
- return out;
2882
- }
2883
- // Last resort: assistant answers only (no user turns) — better than
2884
- // nothing when no message container matched.
2885
- // See RESTORE_ANSWER_SELECTORS in deepseek-ui.ts.
2886
- for (const s of opts.answerSels) {
2887
- const list = Array.from(document.querySelectorAll(s)).filter((e) => !inThink(e));
2888
- if (!list.length)
2889
- continue;
2890
- for (const el of list) {
2891
- const t = textOf(el);
2892
- if (t)
2893
- out.push({ role: 'assistant', text: t });
2894
- }
2895
- break;
2896
- }
2897
- return out;
2898
- }, {
2553
+ .evaluate(scrapeChatMessages, {
2899
2554
  containerSels: MESSAGE_CONTAINER_SELECTORS,
2900
2555
  answerSels: RESTORE_ANSWER_SELECTORS,
2901
2556
  thinkRe: THINK_CLASS_RE.source,
package/dist/changelog.js CHANGED
@@ -78,3 +78,32 @@ export function renderChangelogDraft(groups) {
78
78
  }
79
79
  return out.join('\n').trimEnd();
80
80
  }
81
+ /**
82
+ * Replace the body between `## [Unreleased]` and the next `## [` heading with
83
+ * a freshly drafted body (D2). PURE so a test can pin the exact behavior.
84
+ *
85
+ * When `body` is empty the existing Unreleased section is CLEARED to just the
86
+ * heading (no stray blank content). A changelog without an Unreleased heading
87
+ * is returned UNCHANGED (we never guess where to insert one).
88
+ */
89
+ export function syncUnreleased(changelog, body) {
90
+ const nl = '\n';
91
+ const anchor = '## [Unreleased]';
92
+ const idx = changelog.indexOf(anchor);
93
+ if (idx < 0)
94
+ return changelog;
95
+ const afterAnchor = idx + anchor.length;
96
+ // The section ends at the next top-level `## [` (the next version).
97
+ const rest = changelog.slice(afterAnchor);
98
+ const next = rest.search(/\n## \[/);
99
+ const tail = next < 0 ? '' : rest.slice(next);
100
+ const cleanBody = String(body || '')
101
+ .replace(/^## \[Unreleased\]\s*/m, '')
102
+ .trim()
103
+ // The draft renders groups as `## Added` (top-level), but INSIDE the file
104
+ // a version's groups are `### Added` (a subsection of the version heading).
105
+ // `## [` (the next version) is left alone.
106
+ .replace(/^## (?!\[)/gm, '### ');
107
+ const section = cleanBody ? nl + nl + cleanBody + nl : nl;
108
+ return changelog.slice(0, afterAnchor) + section + tail;
109
+ }
@@ -0,0 +1,48 @@
1
+ // PURE CLI argument parsing (BACKLOG C3). index.ts used to hold `args` as a
2
+ // module-level constant plus three helpers closing over it, which made the
3
+ // parsing impossible to unit-test in isolation (index.ts is the entry point and
4
+ // is not covered). `createCliArgs(argv)` binds the argv ONCE and returns the
5
+ // same three helpers, so every call site in index.ts keeps its shape
6
+ // (`getArg('--x')` / `hasFlag('--x')`) while the LOGIC is now pure and tested.
7
+ //
8
+ // Behavior is intentionally identical to the old inline helpers, including the
9
+ // quirk that a value-flag appearing WITHOUT a value returns the fallback (never
10
+ // the flag itself or the next flag).
11
+ // Flags that consume the NEXT argv entry as their value, so the positional
12
+ // parser must skip it. Kept in sync with the getArg call sites by
13
+ // test/cli-flags.test.ts.
14
+ export const SKIP_VALUE_FLAGS = [
15
+ '--dir',
16
+ '--task',
17
+ '--max-iter',
18
+ '--chat',
19
+ '--output-format',
20
+ ];
21
+ export function createCliArgs(argv) {
22
+ const args = Array.isArray(argv) ? argv : [];
23
+ const getArg = (flag, fallback = null) => {
24
+ const i = args.indexOf(flag);
25
+ // `args[i + 1]` truthiness means an empty string or a missing value falls
26
+ // back — the historical behavior (an empty `--task ''` is not a task).
27
+ // NOTE: this does NOT special-case a following FLAG, so `--task --debug`
28
+ // returns `--debug` (the original inline helper did the same); we preserve
29
+ // it rather than "fix" a long-standing quirk in a refactor.
30
+ return i !== -1 && args[i + 1] ? args[i + 1] : fallback;
31
+ };
32
+ const hasFlag = (flag) => args.includes(flag);
33
+ const getPositional = () => {
34
+ const positional = [];
35
+ for (let i = 0; i < args.length; i++) {
36
+ const a = args[i];
37
+ if (SKIP_VALUE_FLAGS.includes(a)) {
38
+ i++;
39
+ continue;
40
+ }
41
+ if (a.startsWith('--'))
42
+ continue;
43
+ positional.push(a);
44
+ }
45
+ return positional;
46
+ };
47
+ return { getArg, hasFlag, getPositional };
48
+ }
package/dist/compact.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { translate } from './i18n.js';
2
2
  import { buildCompactPrompt, buildCompactCarryover, isUsableCompactSummary, trimRestoredMessages, formatRestoredHistory, } from './commands.js';
3
+ import { loadHooks, runLifecycleHooks } from './hooks.js';
3
4
  const NL = String.fromCharCode(10);
4
5
  /** How many times the summary call is retried before the local fallback. */
5
6
  export const COMPACT_SUMMARY_ATTEMPTS = 3;
@@ -42,6 +43,14 @@ export async function performCompact(opts) {
42
43
  try {
43
44
  if (!quiet)
44
45
  log(t('compact.start'));
46
+ // PreCompact hook (N32): runs BEFORE the chat is summarized/rewritten, so
47
+ // a hook can persist state or notify. Best-effort.
48
+ try {
49
+ const out = await runLifecycleHooks(loadHooks(workdir), 'PreCompact', workdir, { chatId: currentChatId });
50
+ if (out)
51
+ transcript?.log('hook_pre_compact', { output: out });
52
+ }
53
+ catch { }
45
54
  // 1) Ask the OLD chat to summarize itself.
46
55
  let summary = '';
47
56
  let summaryErr = '';
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
+ import { DEFAULT_DIAGNOSTICS } from './diagnostics.js';
4
5
  import { DEFAULT_LOCALE } from './i18n.js';
5
6
  import { writeJsonAtomic } from './fsutil.js';
6
7
  const ZAMES_HOME = path.join(os.homedir(), '.zames');
@@ -67,7 +68,12 @@ export const DEFAULTS = {
67
68
  contextLimit: 1_000_000,
68
69
  // 0 = auto: terminal width, capped at 100 columns (the historical width).
69
70
  answerWidth: 0,
71
+ // N37 statusLine hook. Off by default; edit config.json to set a command.
72
+ statusLineCommand: '',
73
+ statusLineIntervalMs: 10_000,
70
74
  },
75
+ // Off by default: a full tsc/eslint run after every edit is slow; opt in.
76
+ diagnostics: { ...DEFAULT_DIAGNOSTICS },
71
77
  };
72
78
  export function loadConfig() {
73
79
  const merged = JSON.parse(JSON.stringify(DEFAULTS));