zames_pro 2.72.9 → 2.73.1

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/index.js CHANGED
@@ -6,6 +6,8 @@ import { existsSync, statSync } from 'fs';
6
6
  import { fileURLToPath } from 'url';
7
7
  import { theme } from './theme.js';
8
8
  import { DeepSeekBrowser, headlessUACacheReady } from './browser.js';
9
+ import { QwenBrowser } from './qwen-browser.js';
10
+ import { isProviderId, asProviderId, providerChatUrlPrefix, providerLabel, } from './providers.js';
9
11
  import { createTools, mergeTools, filterToolsForReadOnly, filterToolsByAllowList, } from './tools.js';
10
12
  import { createTodoStore, getTodos, setTodos, resetTodos, renderTodos, } from './extraTools.js';
11
13
  import { runAgentLoop } from './agent-loop.js';
@@ -14,7 +16,7 @@ import { createSpinner } from './spinner.js';
14
16
  import { LineEditor } from './input.js';
15
17
  import { promptOnce } from './prompt.js';
16
18
  import { parseImagePaste, extForMime, saveToTemp, guessMime, formatSize, readClipboardImageDetailed, sniffMime, readWindowsClipboardFiles, hasClipboardTool, writeClipboardText, contentHash, } from './attachments.js';
17
- import { loadConfig, DEFAULTS, CONFIG_PATHS, ZAMES_HOME, CONFIG_SCHEMA, getByPath, validateConfigValue, writeConfigValue, resetConfigValue, } from './config.js';
19
+ import { loadConfig, DEFAULTS, CONFIG_PATHS, ZAMES_HOME, CONFIG_SCHEMA, getByPath, validateConfigValue, writeConfigValue, resetConfigValue, providerAskDeadlineMs, } from './config.js';
18
20
  import { runConfigMenu } from './config-menu.js';
19
21
  import { formatConfigList } from './config-commands.js';
20
22
  import { handleConfigCommand as runConfigCommand } from './config-command.js';
@@ -24,6 +26,7 @@ import { UndoStore } from './undo.js';
24
26
  import { CheckpointStore, pickRecord as pickCheckpoint } from './checkpoint.js';
25
27
  import { selfReview, selfDiff, selfApply, selfList } from './self-review.js';
26
28
  import { performCompact } from './compact.js';
29
+ import { buildHandoffText, saveProviderHandoff, readProviderHandoff, clearProviderHandoff, } from './provider-handoff.js';
27
30
  import { Scheduler, parseInterval, formatInterval, formatJobLine, parseCron, } from './scheduler.js';
28
31
  import { createCliArgs } from './cli-args.js';
29
32
  import { renderMarkdown, setAnswerWidth } from './markdown.js';
@@ -121,10 +124,14 @@ if (jsonMode) {
121
124
  function saveLastChat(id, workdir = '', title = '') {
122
125
  if (!id)
123
126
  return;
124
- saveSession({ id, workdir, title });
127
+ // Tag the session with the ACTIVE provider: a chat id is only meaningful to
128
+ // the backend that created it, so a later resume must open it in the same
129
+ // one (or refuse). Without this a /provider switch made /resume try to open a
130
+ // qwen id in DeepSeek (and vice versa).
131
+ saveSession({ id, workdir, title, provider: config.provider });
125
132
  }
126
133
  function loadLastChat(workdir = '') {
127
- const s = loadLastSession(workdir);
134
+ const s = loadLastSession(workdir, config.provider);
128
135
  return s ? s.id : null;
129
136
  }
130
137
  // Persistent per-project session goal (set by /goal). Kept OUT of the session
@@ -591,6 +598,61 @@ async function resolveWorkdir() {
591
598
  return dir;
592
599
  }
593
600
  // ---------- restored dialogue ----------
601
+ /**
602
+ * The answer timeout for a one-off provider call (compact summary, etc.).
603
+ * Qwen's cold guest turns are far slower than DeepSeek's, so using
604
+ * config.browser.answerTimeoutMs there cut them off; pick the active
605
+ * provider's own value.
606
+ */
607
+ function providerAnswerTimeoutMs() {
608
+ return config.provider === 'qwen'
609
+ ? config.qwen.answerTimeoutMs
610
+ : config.browser.answerTimeoutMs;
611
+ }
612
+ /** The provider's base chat URL, used by /chat to build the right link. */
613
+ function browserProviderUrl() {
614
+ return providerChatUrlPrefix(config.provider);
615
+ }
616
+ /**
617
+ * Build the browser for a provider. Called at startup AND on a live
618
+ * `/provider` switch, so both paths construct the class the same way. The
619
+ * two backends differ in every layer (selectors, SSE format, chat-id scheme),
620
+ * so the choice is a class, not a flag; the shared timings live in
621
+ * config.browser and only Qwen-only knobs are overridden.
622
+ */
623
+ function createProviderBrowser(provider) {
624
+ const persistAuth = (username, password) => {
625
+ config.browser.auth.username = username;
626
+ config.browser.auth.password = password;
627
+ try {
628
+ writeConfigValue('home', 'browser.auth.username', username);
629
+ writeConfigValue('home', 'browser.auth.password', password);
630
+ }
631
+ catch { }
632
+ };
633
+ return provider === 'qwen'
634
+ ? new QwenBrowser({
635
+ headless,
636
+ debug,
637
+ channel: config.browserChannel,
638
+ ...config.browser,
639
+ // Qwen-specific overrides (see config.qwen): no 15s DeepSeek
640
+ // throttle, a longer cold-start answer timeout.
641
+ minSendIntervalMs: config.qwen.minSendIntervalMs,
642
+ answerTimeoutMs: config.qwen.answerTimeoutMs,
643
+ model: config.qwen.model,
644
+ locale: currentLocale,
645
+ onAuthSave: persistAuth,
646
+ })
647
+ : new DeepSeekBrowser({
648
+ headless,
649
+ debug,
650
+ channel: config.browserChannel,
651
+ ...config.browser,
652
+ locale: currentLocale,
653
+ onAuthSave: persistAuth,
654
+ });
655
+ }
594
656
  /**
595
657
  * Print the dialogue of the just-opened chat into the terminal. Called by
596
658
  * /resume, /resume-id and on startup when a previous session is restored, so
@@ -757,24 +819,15 @@ async function main() {
757
819
  catch (e) {
758
820
  console.error(theme.warn(t('mcp.load_failed', { v: e.message })));
759
821
  }
760
- const browser = new DeepSeekBrowser({
761
- headless,
762
- debug,
763
- channel: config.browserChannel,
764
- ...config.browser,
765
- locale: currentLocale,
766
- // Persist credentials the operator typed in the terminal so the next
767
- // launch can re-login silently after a logout.
768
- onAuthSave: (username, password) => {
769
- config.browser.auth.username = username;
770
- config.browser.auth.password = password;
771
- try {
772
- writeConfigValue('home', 'browser.auth.username', username);
773
- writeConfigValue('home', 'browser.auth.password', password);
774
- }
775
- catch { }
776
- },
777
- });
822
+ // Build the browser for the ACTIVE provider. The two backends differ in
823
+ // every layer (selectors, SSE format, chat-id scheme), so the switch is a
824
+ // class choice, not a flag. The shared timings live in config.browser; only
825
+ // the model is provider-specific. A provider switch takes effect on the
826
+ // next launch (the operator is told to restart).
827
+ // `let`, not `const`: a live /provider switch disposes this instance and
828
+ // builds the other one IN PLACE (see the /provider block in the main loop),
829
+ // so the URL changes without restarting zames.
830
+ let browser = createProviderBrowser(asProviderId(config.provider));
778
831
  const bootSpinner = mod.createSpinner(currentLocale);
779
832
  bootSpinner.thinking();
780
833
  try {
@@ -821,7 +874,7 @@ async function main() {
821
874
  // explicitly: --chat <id> or --resume-last.
822
875
  let resumeId = chatIdArg;
823
876
  if (!resumeId && resumeLastFlag && !newChatFlag) {
824
- const last = loadLastSession(currentWorkdir);
877
+ const last = loadLastSession(currentWorkdir, config.provider);
825
878
  if (last && last.id) {
826
879
  resumeId = last.id;
827
880
  console.log(theme.system(t('msg.resuming', { id: resumeId })));
@@ -937,7 +990,7 @@ async function main() {
937
990
  },
938
991
  transcript,
939
992
  maxSubagents: config.browser.maxSubagents,
940
- askDeadlineMs: config.browser.askDeadlineMs,
993
+ askDeadlineMs: providerAskDeadlineMs(config),
941
994
  maxAfterToolRetries: config.browser.maxAfterToolRetries,
942
995
  // An `ask` permission rule must also gate the subagent: otherwise a
943
996
  // guarded tool (e.g. a dangerous Bash command) would run inside the
@@ -1075,7 +1128,7 @@ async function main() {
1075
1128
  // --new-chat kept for compatibility (this is the default behavior)
1076
1129
  let resumeId = chatIdArg;
1077
1130
  if (!resumeId && resumeLastFlag && !newChatFlag) {
1078
- const last = loadLastSession(currentWorkdir);
1131
+ const last = loadLastSession(currentWorkdir, config.provider);
1079
1132
  if (last && last.id) {
1080
1133
  resumeId = last.id;
1081
1134
  console.log(theme.system(t('msg.resuming', {
@@ -1137,6 +1190,8 @@ async function main() {
1137
1190
  });
1138
1191
  editor = ed;
1139
1192
  ed.setTmpDir(TMP_DIR);
1193
+ // Provider badge: show which backend is answering ([Qwen]/[DeepSeek]).
1194
+ ed.setProvider(providerLabel(config.provider));
1140
1195
  // Restore the input history from the previous runs and persist every new
1141
1196
  // entry, so Ctrl+R / arrows can recall older messages after a restart.
1142
1197
  ed.history = loadHistory();
@@ -1983,10 +2038,199 @@ async function main() {
1983
2038
  }
1984
2039
  continue;
1985
2040
  }
2041
+ if (lower === '/provider' || lower.startsWith('/provider ')) {
2042
+ const arg = trimmed.slice('/provider'.length).trim().toLowerCase();
2043
+ if (!arg || arg === 'status') {
2044
+ const model = config.provider === 'qwen' ? config.qwen.model || '(default)' : 'n/a';
2045
+ console.log(theme.system(t('provider.status', {
2046
+ v: config.provider,
2047
+ m: model,
2048
+ auth: authMarkerExists() ? 'yes' : 'no',
2049
+ })));
2050
+ continue;
2051
+ }
2052
+ if (arg === 'login') {
2053
+ try {
2054
+ await browser.waitForLogin();
2055
+ console.log(theme.system(t('provider.login_done')));
2056
+ }
2057
+ catch (e) {
2058
+ console.error(theme.warn(e.message));
2059
+ }
2060
+ continue;
2061
+ }
2062
+ if (isProviderId(arg)) {
2063
+ const from = config.provider;
2064
+ if (arg === from) {
2065
+ console.log(theme.system(t('provider.switched', { v: arg })));
2066
+ continue;
2067
+ }
2068
+ // Is there anything worth carrying over? A FRESH/EMPTY chat has no
2069
+ // context to summarize, so switching it is a pure URL/backend change:
2070
+ // summarizing an empty chat used to post a useless "please compact"
2071
+ // request into a brand-new chat (a real complaint). We only build a
2072
+ // handoff when the chat actually has messages.
2073
+ let chatHasMessages = false;
2074
+ if (browser.fetchChatMessages) {
2075
+ const cid = await browser.getCurrentChatId().catch(() => null);
2076
+ if (cid) {
2077
+ const all = await browser.fetchChatMessages(cid).catch(() => []);
2078
+ chatHasMessages =
2079
+ mod.commands.trimRestoredMessages(all, 0).length > 0;
2080
+ }
2081
+ }
2082
+ if (chatHasMessages) {
2083
+ // CARRY-OVER: a chat id only exists on its own backend, so switching
2084
+ // providers loses the conversation. Summarize the CURRENT chat (still
2085
+ // on the OLD browser object) and persist a handoff for the target; it
2086
+ // is posted as the first message once the new provider is live.
2087
+ if (editor)
2088
+ editor.lock(t('msg.input_locked'));
2089
+ try {
2090
+ const spin = editor || mod.createSpinner(currentLocale);
2091
+ spin.thinking();
2092
+ let summary = '';
2093
+ try {
2094
+ const answer = await browser.ask(mod.commands.buildCompactPrompt(currentLocale), {
2095
+ agent: true,
2096
+ timeout: providerAnswerTimeoutMs(),
2097
+ });
2098
+ const trimmed = String(answer || '').trim();
2099
+ if (mod.commands.isUsableCompactSummary(trimmed))
2100
+ summary = trimmed;
2101
+ }
2102
+ catch (e) {
2103
+ if (debug)
2104
+ console.error('handoff summary failed:', e.message);
2105
+ }
2106
+ // Fall back to the LOCAL history when the model summary is unusable.
2107
+ if (!summary && browser.fetchChatMessages) {
2108
+ const cid = await browser.getCurrentChatId().catch(() => null);
2109
+ const all = cid
2110
+ ? await browser.fetchChatMessages(cid).catch(() => [])
2111
+ : [];
2112
+ const msgs = mod.commands.trimRestoredMessages(all, 0);
2113
+ if (msgs.length) {
2114
+ summary = mod.commands.formatRestoredHistory(msgs, {
2115
+ limit: 40,
2116
+ });
2117
+ }
2118
+ }
2119
+ spin.stop();
2120
+ if (summary) {
2121
+ saveProviderHandoff({
2122
+ provider: arg,
2123
+ workdir: currentWorkdir,
2124
+ summary,
2125
+ task: lastTaskText || undefined,
2126
+ createdAt: new Date().toISOString(),
2127
+ });
2128
+ console.log(theme.system(t('provider.handoff_saved', { v: arg })));
2129
+ }
2130
+ else {
2131
+ console.log(theme.warn(t('provider.handoff_empty')));
2132
+ }
2133
+ }
2134
+ finally {
2135
+ if (editor)
2136
+ editor.unlock();
2137
+ }
2138
+ }
2139
+ // Tag the current session with the OLD provider before switching.
2140
+ if (currentChatId)
2141
+ saveLastChat(currentChatId, currentWorkdir);
2142
+ // Persist the choice FIRST so a crash mid-switch still remembers it.
2143
+ config.provider = arg;
2144
+ config.qwen.enabled = arg === 'qwen';
2145
+ try {
2146
+ writeConfigValue('home', 'provider', arg);
2147
+ }
2148
+ catch { }
2149
+ // LIVE SWITCH: dispose the old backend and open the new one in place,
2150
+ // so the operator does not have to restart zames. The chat is NOT
2151
+ // resumable across providers (different chat ids), so the new backend
2152
+ // starts a FRESH chat and the handoff (if any) is posted with the next
2153
+ // task.
2154
+ if (editor)
2155
+ editor.lock(t('msg.input_locked'));
2156
+ try {
2157
+ console.log(theme.system(t('provider.switching', { v: arg })));
2158
+ // Launch the NEW backend FIRST; only close the old one once it is up.
2159
+ // If we closed the old browser before the new one launched and the
2160
+ // launch failed, `browser` would point at a dead instance and every
2161
+ // later command would break.
2162
+ const nextBrowser = createProviderBrowser(arg);
2163
+ await nextBrowser.launch();
2164
+ await nextBrowser.waitForLogin();
2165
+ const oldBrowser = browser;
2166
+ browser = nextBrowser;
2167
+ await oldBrowser.close().catch(() => { });
2168
+ // The new backend has no chat yet.
2169
+ currentChatId = await browser.getCurrentChatId().catch(() => null);
2170
+ if (editor) {
2171
+ editor.setProvider(providerLabel(arg));
2172
+ }
2173
+ try {
2174
+ warnUiHealth(await browser.probeUiHealth());
2175
+ }
2176
+ catch { }
2177
+ console.log(theme.system(t('provider.switched', { v: arg })));
2178
+ }
2179
+ catch (e) {
2180
+ // The new backend did not come up: keep the OLD one active so the
2181
+ // session keeps working, and roll the persisted choice back.
2182
+ config.provider = from;
2183
+ config.qwen.enabled = from === 'qwen';
2184
+ try {
2185
+ writeConfigValue('home', 'provider', from);
2186
+ }
2187
+ catch { }
2188
+ console.error(theme.warn(t('provider.switch_failed', {
2189
+ v: arg,
2190
+ error: e.message,
2191
+ })));
2192
+ }
2193
+ finally {
2194
+ if (editor)
2195
+ editor.unlock();
2196
+ }
2197
+ continue;
2198
+ }
2199
+ console.error(theme.warn(t('provider.usage')));
2200
+ continue;
2201
+ }
2202
+ if (lower === '/model' || lower.startsWith('/model ')) {
2203
+ const arg = trimmed.slice('/model'.length).trim();
2204
+ if (config.provider !== 'qwen') {
2205
+ console.log(theme.system(t('provider.usage')));
2206
+ continue;
2207
+ }
2208
+ const qb = browser;
2209
+ if (!arg) {
2210
+ const models = qb.listModels ? await qb.listModels() : [];
2211
+ if (!models.length) {
2212
+ console.log(theme.system(t('qwen.model_list', { v: '(none)' })));
2213
+ }
2214
+ else {
2215
+ const lines = models.map((m) => ` ${m.id} — ${m.name}`).join(NL);
2216
+ console.log(theme.system(t('qwen.model_list', { v: lines })));
2217
+ }
2218
+ continue;
2219
+ }
2220
+ config.qwen.model = arg;
2221
+ if (typeof qb.model === 'string')
2222
+ qb.model = arg;
2223
+ try {
2224
+ writeConfigValue('home', 'qwen.model', arg);
2225
+ }
2226
+ catch { }
2227
+ console.log(theme.system(t('qwen.model_set', { v: arg })));
2228
+ continue;
2229
+ }
1986
2230
  if (lower === '/chat') {
1987
2231
  if (currentChatId) {
1988
2232
  console.log(theme.system(t('chats.current_id', { v: currentChatId })));
1989
- console.log(theme.system(`URL: https://chat.deepseek.com/a/chat/s/${currentChatId}`));
2233
+ console.log(theme.system(`URL: ${browserProviderUrl()}/${currentChatId}`));
1990
2234
  }
1991
2235
  else {
1992
2236
  const id = await browser.getCurrentChatId();
@@ -2028,7 +2272,7 @@ async function main() {
2028
2272
  if (lower === '/last') {
2029
2273
  // Quick resume of the last chat for THIS working directory — otherwise
2030
2274
  // finding it meant /chats (a network fetch) or copying the id.
2031
- const last = loadLastSession(currentWorkdir);
2275
+ const last = loadLastSession(currentWorkdir, config.provider);
2032
2276
  if (!last || !last.id) {
2033
2277
  console.error(theme.error(t('sessions.none')));
2034
2278
  continue;
@@ -2721,6 +2965,8 @@ async function main() {
2721
2965
  uiPresent: uiHealth?.present,
2722
2966
  uiMissing: uiHealth?.missing,
2723
2967
  uiMissingCritical: uiHealth?.missingCritical,
2968
+ provider: config.provider,
2969
+ providerModel: config.provider === 'qwen' ? config.qwen.model : undefined,
2724
2970
  }, t)));
2725
2971
  continue;
2726
2972
  }
@@ -2755,7 +3001,7 @@ async function main() {
2755
3001
  ui: editor,
2756
3002
  buildSystemPrompt: mod.buildSystemPrompt,
2757
3003
  tools: mod.createTools(currentWorkdir, { undo, todos: todoStore }),
2758
- answerTimeoutMs: config.browser.answerTimeoutMs,
3004
+ answerTimeoutMs: providerAnswerTimeoutMs(),
2759
3005
  fallbackLimit: COMPACT_FALLBACK_LIMIT,
2760
3006
  selfImprovement: devMode,
2761
3007
  });
@@ -3112,6 +3358,27 @@ async function main() {
3112
3358
  }
3113
3359
  }
3114
3360
  lastTaskText = finalTaskText;
3361
+ // Provider handoff: if the operator switched providers, the new backend
3362
+ // has NO chat history. Post the carried-over summary as a preface so the
3363
+ // model continues instead of starting cold. Consumed once.
3364
+ {
3365
+ const handoff = readProviderHandoff(config.provider, currentWorkdir);
3366
+ if (handoff) {
3367
+ finalTaskText =
3368
+ buildHandoffText({
3369
+ from: handoff.provider === 'qwen' ? 'DeepSeek' : 'Qwen',
3370
+ to: providerLabel(config.provider),
3371
+ summary: handoff.summary,
3372
+ task: handoff.task,
3373
+ }) +
3374
+ String.fromCharCode(10, 10) +
3375
+ 'Now continue with this task:' +
3376
+ String.fromCharCode(10) +
3377
+ finalTaskText;
3378
+ clearProviderHandoff();
3379
+ console.log(theme.system(t('provider.handoff_used')));
3380
+ }
3381
+ }
3115
3382
  // Dev mode: pick up fresh logic modules before the task.
3116
3383
  await autoReload();
3117
3384
  // Re-scan skills/custom commands before every task: a SKILL.md or a
@@ -3185,7 +3452,7 @@ async function main() {
3185
3452
  undo,
3186
3453
  todos: todoStore,
3187
3454
  }),
3188
- answerTimeoutMs: config.browser.answerTimeoutMs,
3455
+ answerTimeoutMs: providerAnswerTimeoutMs(),
3189
3456
  quiet: true,
3190
3457
  skipUiLock: true,
3191
3458
  selfImprovement: devMode,
package/dist/input.js CHANGED
@@ -171,6 +171,10 @@ export class LineEditor {
171
171
  // plain field because the CALLER runs the command on its own timer (a
172
172
  // per-render spawn would block typing).
173
173
  statusLine;
174
+ // Active chat provider label (e.g. "Qwen", "DeepSeek") shown as a small
175
+ // badge in the status line, so the operator always knows which backend is
176
+ // answering. Empty hides it. Set via setProvider().
177
+ providerBadge;
174
178
  // Whether the DeepSeek "Deep thinking" / "Smart search" toggles are ON.
175
179
  // Shown as two colored icons before the context counter: a dim gray icon
176
180
  // means off, a teal-green icon means on.
@@ -265,6 +269,7 @@ export class LineEditor {
265
269
  this.onContextQuery = null;
266
270
  this.queueLength = 0;
267
271
  this.statusLine = '';
272
+ this.providerBadge = '';
268
273
  this.onQueueQuery = null;
269
274
  this.onTasksQuery = null;
270
275
  }
@@ -353,6 +358,14 @@ export class LineEditor {
353
358
  _statusLineBadge() {
354
359
  return this.statusLine ? theme.system(this.statusLine) + ' ' : '';
355
360
  }
361
+ // A small badge with the ACTIVE provider (e.g. "Qwen"), shown before the
362
+ // toggles so the operator always knows which backend is answering (a
363
+ // multi-provider session can otherwise be ambiguous). Empty hides it.
364
+ _providerBadge() {
365
+ return this.providerBadge
366
+ ? theme.system('[' + this.providerBadge + ']') + ' '
367
+ : '';
368
+ }
356
369
  // Icons for the DeepSeek toggles: 🧠 "Deep thinking" and 🌐 "Smart search",
357
370
  // shown before the context counter. Only the ENABLED toggles are shown, so
358
371
  // the state is unambiguous WITHOUT relying on the terminal coloring the
@@ -593,6 +606,15 @@ export class LineEditor {
593
606
  this.statusLine = t;
594
607
  this._render();
595
608
  }
609
+ // Set the provider badge (e.g. "Qwen"). An empty string hides it. Repaints
610
+ // only when the value actually changes.
611
+ setProvider(text) {
612
+ const t = String(text || '').trim();
613
+ if (t === this.providerBadge)
614
+ return;
615
+ this.providerBadge = t;
616
+ this._render();
617
+ }
596
618
  setContextLimit(limit) {
597
619
  if (typeof limit === 'number' && Number.isFinite(limit) && limit > 0) {
598
620
  this.contextLimit = limit;
@@ -692,6 +714,7 @@ export class LineEditor {
692
714
  const ctxRaw = this._contextText();
693
715
  this._refreshToggles();
694
716
  const ctxText = this._statusLineBadge() +
717
+ this._providerBadge() +
695
718
  this._queueBadge() +
696
719
  this._tasksBadge() +
697
720
  this._toggleIcons() +
@@ -0,0 +1,73 @@
1
+ // Provider-switch HANDOFF: when the operator changes `/provider` in the middle
2
+ // of a session, the current chat does NOT exist on the new backend (a chat id
3
+ // is meaningful only to the provider that created it), so the conversation
4
+ // would be lost. Instead we summarize the current chat on the OLD provider and
5
+ // persist a HANDOFF for the target provider; on the next launch the new
6
+ // provider opens a fresh chat and posts the summary as its first message.
7
+ //
8
+ // This mirrors /compact's carryover, but the summary crosses a provider
9
+ // boundary and survives a restart, so it is written to disk.
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import os from 'os';
13
+ import { writeJsonAtomic } from './fsutil.js';
14
+ const ZAMES_HOME = path.join(os.homedir(), '.zames');
15
+ const HANDOFF_FILE = path.join(ZAMES_HOME, 'provider-handoff.json');
16
+ const NL = String.fromCharCode(10);
17
+ /**
18
+ * Wrap a cross-provider summary into the first message of the new provider's
19
+ * chat. PURE (unit-tested): the wording must make clear this is carried-over
20
+ * context from ANOTHER assistant, so the model treats it as history, not as a
21
+ * fresh instruction to redo the work.
22
+ */
23
+ export function buildHandoffText(h) {
24
+ const body = String(h.summary ?? '').trim();
25
+ const goal = String(h.task ?? '').trim();
26
+ let out = `Context carried over from a previous session with another assistant ` +
27
+ `(${h.from} -> ${h.to}). Treat it as the history of the work so far and ` +
28
+ `continue from the current state. Do NOT redo what is already done.`;
29
+ out += NL + NL + body;
30
+ if (goal)
31
+ out += NL + NL + 'Original task: ' + goal;
32
+ return out;
33
+ }
34
+ /** Persist a handoff for a provider. Best-effort (never throws to the caller). */
35
+ export function saveProviderHandoff(h) {
36
+ try {
37
+ fs.mkdirSync(ZAMES_HOME, { recursive: true });
38
+ writeJsonAtomic(HANDOFF_FILE, h);
39
+ }
40
+ catch { }
41
+ }
42
+ /**
43
+ * Read the pending handoff, but ONLY when it targets `provider` and `workdir`:
44
+ * a handoff for a different provider or project must not be posted into the
45
+ * wrong chat. Returns null when there is none / it does not match.
46
+ */
47
+ export function readProviderHandoff(provider, workdir) {
48
+ try {
49
+ const raw = JSON.parse(fs.readFileSync(HANDOFF_FILE, 'utf-8'));
50
+ if (!raw || typeof raw.summary !== 'string' || !raw.summary.trim()) {
51
+ return null;
52
+ }
53
+ if (raw.provider !== provider)
54
+ return null;
55
+ if ((raw.workdir || '') !== (workdir || ''))
56
+ return null;
57
+ return raw;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /** Remove the handoff once it has been posted (or is stale). Best-effort. */
64
+ export function clearProviderHandoff() {
65
+ try {
66
+ fs.unlinkSync(HANDOFF_FILE);
67
+ }
68
+ catch { }
69
+ }
70
+ /** For tests: the file the handoff lives in. */
71
+ export function providerHandoffFile() {
72
+ return HANDOFF_FILE;
73
+ }
@@ -0,0 +1,50 @@
1
+ // Provider registry — the SINGLE source of truth for the chat backends zames
2
+ // can drive. Adding a new backend (ChatGPT, Gemini, …) means:
3
+ // 1. add an entry here (id, label, chat URL prefix, extra config section);
4
+ // 2. add the provider's `<name>-ui.ts` + `<name>-net-capture.ts` +
5
+ // `<name>-browser.ts` (implementing ChatProvider, see types.ts);
6
+ // 3. register the class in `createProviderBrowser()` in index.ts.
7
+ // Everything else (config enum, /provider validation, sessions tagging, the
8
+ // /doctor row, the {provider} label in operator messages, /chat URL) reads
9
+ // from HERE, so nothing else has to be touched.
10
+ //
11
+ // This module is PURE (no imports, no side effects) so it can be imported from
12
+ // anywhere without pulling in Playwright.
13
+ export const PROVIDERS = {
14
+ deepseek: {
15
+ id: 'deepseek',
16
+ label: 'DeepSeek',
17
+ chatUrlPrefix: 'https://chat.deepseek.com/a/chat/s',
18
+ isDefault: true,
19
+ },
20
+ qwen: {
21
+ id: 'qwen',
22
+ label: 'Qwen',
23
+ chatUrlPrefix: 'https://chat.qwen.ai/c',
24
+ isDefault: false,
25
+ },
26
+ };
27
+ /** All provider ids, in a stable order (default first). */
28
+ export const PROVIDER_IDS = Object.keys(PROVIDERS).sort((a, b) => PROVIDERS[a].isDefault === PROVIDERS[b].isDefault
29
+ ? a.localeCompare(b)
30
+ : PROVIDERS[a].isDefault
31
+ ? -1
32
+ : 1);
33
+ /** True when `v` is a known provider id. Narrows the type for callers. */
34
+ export function isProviderId(v) {
35
+ return (typeof v === 'string' && Object.prototype.hasOwnProperty.call(PROVIDERS, v));
36
+ }
37
+ /** Resolve an unknown value to a provider id, falling back to the default. */
38
+ export function asProviderId(v) {
39
+ if (isProviderId(v))
40
+ return v;
41
+ return PROVIDER_IDS.find((id) => PROVIDERS[id].isDefault) || PROVIDER_IDS[0];
42
+ }
43
+ /** The human label of a provider ('Qwen', 'DeepSeek'). */
44
+ export function providerLabel(id) {
45
+ return isProviderId(id) ? PROVIDERS[id].label : String(id);
46
+ }
47
+ /** The base chat URL of a provider, used by /chat. */
48
+ export function providerChatUrlPrefix(id) {
49
+ return isProviderId(id) ? PROVIDERS[id].chatUrlPrefix : '';
50
+ }