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/CHANGELOG.md +79 -0
- package/README.md +111 -19
- package/dist/agent-loop.js +17 -5
- package/dist/browser-pure.js +6 -6
- package/dist/browser.js +5 -1
- package/dist/commands.js +7 -0
- package/dist/config.js +56 -1
- package/dist/i18n/common.js +2 -2
- package/dist/i18n/config.js +14 -0
- package/dist/i18n/deepseek.js +26 -26
- package/dist/i18n/help.js +8 -0
- package/dist/i18n/qwen.js +132 -0
- package/dist/i18n.js +2 -0
- package/dist/index.js +295 -28
- package/dist/input.js +23 -0
- package/dist/provider-handoff.js +73 -0
- package/dist/providers.js +50 -0
- package/dist/qwen-browser.js +1743 -0
- package/dist/qwen-chats.js +183 -0
- package/dist/qwen-net-capture.js +221 -0
- package/dist/qwen-ui.js +160 -0
- package/dist/run-task.js +2 -1
- package/dist/sessions.js +15 -4
- package/dist/ui-health.js +27 -21
- package/package.json +8 -3
- package/scripts/coverage-gate.mts +5 -1
- package/scripts/qwen-smoke.mts +189 -0
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
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
+
}
|