mslxdff 0.1.60 → 0.1.62

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.60",
3
+ "version": "0.1.62",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -52,7 +52,7 @@ ${mini}
52
52
  - 永远输出精确的命令与模型 id,大小写敏感。
53
53
  - 需要执行命令时调用 run_command,需要看文件时调用 read_file,需要检查网络/服务可用性时调用 curl。
54
54
  - curl 简写:upstream(=上游 https://opencode.ai/zen/v1/models)、local/health(=本机 /health)、local/models(=本机 /v1/models),也支持完整 http(s) URL;会自动补上游头、本机 token 与已配置供应商 key(直连 https://api.b.ai/v1/models 会自动带 bai 的 key,无需手动加头)。
55
- - 查“某供应商有哪些模型”:优先直接用上方“可用模型”按前缀过滤回答(如 bai/ 开头的即 bai 供应商),无需调工具;如需实时刷新,调 curl local/models(GET,自动带本机 token)看网关聚合列表,或 curl https://api.b.ai/v1/models(自动带 key)看上游全量。禁止为此调用 -showtoken(本机 token 已自动注入)。
55
+ - 查“某供应商有哪些模型”**禁止调用 run_command**:**直接用上方“可用模型”按前缀过滤回答**(如 workbuddy/ 开头的即 workbuddy 供应商,clinebot/ 开头即 clinebot),无需调工具;如需实时刷新,调 curl local/models(GET,自动带本机 token)看网关聚合列表。**错误示例**:用户问 workbuddy有哪些模型 → 调用 -provider workbuddy list(这是查配置,不是查模型!)→ 错。**正确**:直接列 workbuddy/ 前缀的可用模型。禁止为此调用 -showtoken(本机 token 已自动注入)。
56
56
  - 严禁幻觉命令:mslxdff "hi" --model X / mslxdff --model X "hi" / mslxdff -chat --model X 都不存在,输出只会是 status 页。探活任意模型(含 clinebot/*、workbuddy/*、bai/*)必须用 curl POST http://localhost:8989/v1/chat/completions,body 为 {"model":"<前缀/模型>","messages":[{"role":"user","content":"hi"}],"stream":false},成功 200 + x-mslxdff-via:local 即通;401 代表本机 token 陈旧需提示 mslxdff -stop && mslxdff;403 + x-mslxdff-allowlist:1 代表白名单未放行需 allowlist add。
57
57
  - **禁止重复调用(最高优先级)**:同一 run_command/curl/read_file 在本轮只执行一次,重复会被工具侧 SKIPPED_DUP 拦截;查询类(-showtoken/-status/-provider list/-providers list/-model list/-group list/-log 等)**调用一次即答案**,拿到 OK 结果后必须**立即用中文直接回答用户**,禁止再发起任何工具调用。收到 SKIPPED_DUP 或“请直接回答/禁止再调用”提示时,必须 0 工具直接回答。
58
58
  - 禁止调用 -uninstall,包含即拒绝;-showtoken 仅在用户明确要求查看/调试本机 token 时才用,查模型/查供应商严禁调用。
package/src/chat/repl.js CHANGED
@@ -30,6 +30,36 @@ function trace(line) {
30
30
  console.log(`\x1b[90m· ${line}\x1b[0m`);
31
31
  }
32
32
 
33
+ function extractProvFromQuery(text) {
34
+ const low = String(text || "").toLowerCase();
35
+ const known = ["workbuddy", "clinebot", "opencode", "bai", "openrouter", "poolside", "z-ai", "deepseek"];
36
+ for (const k of known) if (low.includes(k)) return k;
37
+ return null;
38
+ }
39
+ function isModelListQuery(text) {
40
+ const low = String(text || "").toLowerCase();
41
+ if (!low.includes("模型")) return false;
42
+ return low.includes("哪些") || low.includes("可用") || low.includes("支持") || low.includes("列表") || low.includes("有啥") || low.includes("都有") || low.includes("可以") || low.includes("用");
43
+ }
44
+ function formatModelAnswer(prov, models) {
45
+ const byProv = {};
46
+ for (const id of models) {
47
+ const slash = id.indexOf("/");
48
+ const p = slash > 0 ? id.slice(0, slash) : "opencode";
49
+ if (!byProv[p]) byProv[p] = [];
50
+ byProv[p].push(id);
51
+ }
52
+ if (prov) {
53
+ const list = byProv[prov] || models.filter((m) => m.toLowerCase().startsWith(prov.toLowerCase() + "/"));
54
+ if (!list.length) return `**${prov}** 暂无可用模型(可能未配置或网关未聚合)。可用总量 ${models.length},按供应商:${Object.entries(byProv).map(([k, v]) => `${k}(${v.length})`).join(" | ")}`;
55
+ // 更友好:直接列 id 和调用方式
56
+ return `**${prov}** 可用模型(共 ${list.length} 个,网关已聚合):\n\n| 模型 id | 调用方式 |\n|:---|:---|\n${list.map((m) => `| \`${m}\` | \`${m}\` |`).join("\n")}\n\n> 提示:直接用 \`${prov}/<模型>\` 调用,例如 \`${list[0]}\``;
57
+ }
58
+ // 无指定供应商:按分组汇总
59
+ const summary = Object.entries(byProv).map(([p, arr]) => `**${p}**(${arr.length}):${arr.slice(0, 8).join(", ")}${arr.length > 8 ? " …" : ""}`).join("\n");
60
+ return `可用模型总计 ${models.length} 个,按供应商分组:\n\n${summary}\n\n> 查某供应商请说“workbuddy有哪些模型”`;
61
+ }
62
+
33
63
  async function maybeCompress(messages) {
34
64
  if (!needsCompress(messages)) return [...messages];
35
65
  const sys = messages[0];
@@ -55,6 +85,19 @@ async function maybeCompress(messages) {
55
85
  async function runAgentTurn(userText, messages) {
56
86
  const tools = getToolDefs();
57
87
  messages.push({ role: "user", content: userText });
88
+ // Fast-path:模型列表类问题本地直答,不走 LLM,避免 “provider list” 幻觉和 6 轮重复
89
+ if (isModelListQuery(userText)) {
90
+ const prov = extractProvFromQuery(userText);
91
+ try {
92
+ const models = getModelsForPrompt();
93
+ const answer = formatModelAnswer(prov, models);
94
+ if (answer) {
95
+ messages.push({ role: "assistant", content: answer });
96
+ trace(`[fast] 模型列表直答 prov=${prov || "all"} 共 ${models.length} 个`);
97
+ return { text: answer, model: "local", latency: 0, usage: null, fallback: false, ok: true, totalMs: 0 };
98
+ }
99
+ } catch {}
100
+ }
58
101
  let loops = 0;
59
102
  let lastModel = null;
60
103
  let lastUsage = null;
@@ -64,6 +107,8 @@ async function runAgentTurn(userText, messages) {
64
107
  const turnStart = performance.now();
65
108
  // 同轮去重:同一工具+参数只真正执行一次,重复直接复用并提示 LLM
66
109
  const seenCalls = new Map(); // key -> { count, firstResult }
110
+ let duplicateStrikes = 0;
111
+ let forceNoTools = false;
67
112
  trace(`[turn] 开始 "${userText.slice(0, 60)}${userText.length > 60 ? "…" : ""}" · 历史 ${messages.length}条 约 ${estimateChars(messages)}字`);
68
113
  while (loops < CHAT_MAX_TOOL_LOOPS) {
69
114
  const tLoop = performance.now();
@@ -74,12 +119,19 @@ async function runAgentTurn(userText, messages) {
74
119
  messages.length = 0;
75
120
  for (const m of cur) messages.push(m);
76
121
  const tCall = performance.now();
77
- const spinnerLabel = loops === 0 ? "已发送给 AI,等待回复中" : "AI 正在整理回复中";
122
+ const spinnerLabel = loops === 0 ? "已发送给 AI,等待回复中" : forceNoTools ? "AI 整理回答中(已禁工具)" : "AI 正在整理回复中";
78
123
  const spinner = createSpinner(spinnerLabel);
79
124
  spinner.start();
80
125
  let res;
81
126
  try {
82
- res = await chatWithFallback({ messages, tools });
127
+ const activeTools = forceNoTools ? [] : tools;
128
+ res = await chatWithFallback({ messages, tools: activeTools });
129
+ if (forceNoTools && res.ok && res.message?.tool_calls?.length) {
130
+ // LLM 在禁工具模式下仍尝试调工具,视为违规,直接转文本
131
+ trace(`[guard] 禁工具模式下仍收到 tool_calls,已拦截`);
132
+ res.message.tool_calls = [];
133
+ if (!res.message.content) res.message.content = "(已拦截违规工具调用,请基于已有结果直接回答)";
134
+ }
83
135
  } finally {
84
136
  const ms = Math.round(performance.now() - tCall);
85
137
  spinner.stop(`\x1b[90m✓ AI 已回复 · ${ms}ms\x1b[0m`);
@@ -149,13 +201,18 @@ async function runAgentTurn(userText, messages) {
149
201
  console.log(`\x1b[90m→ 执行: mslxdff ${cmd}\x1b[0m`);
150
202
  const r = await execCommand(cmd);
151
203
  result = `${r.ok ? "OK" : "FAIL"}: ${r.output}`;
152
- // 查询类命令直接在结果里植入“立即回答”锚点,降低 LLM 再发一次的概率
204
+ // 查询类命令直接在结果里植入“立即回答”锚点,降低 LLM 再发一次的概率;若用户问模型,则 provider list 不算答案
153
205
  const lowCmd = cmd.toLowerCase().replace(/\s+/g, " ").trim();
206
+ const asksModel = String(userText || "").toLowerCase().includes("模型");
154
207
  const isOnceAndDone =
155
208
  /^-+(showtoken|status|s|providers?\b|model\b|group\b|log\b|workbuddy\b|free\b|autostart\b|plugins\b)/.test(lowCmd) ||
156
209
  lowCmd === "-provider list" || lowCmd === "-providers list";
157
210
  if (isOnceAndDone && r.ok) {
158
- result += `\n\n[系统提示:此查询已完成,结果即答案,请直接用中文回答用户,禁止再调用相同或同类查询工具]`;
211
+ if (asksModel && lowCmd.includes("-provider")) {
212
+ result += `\n\n[提示:此命令仅显示供应商配置,不包含模型列表。用户问的是“有哪些模型”,请用系统提示中的“可用模型”按前缀过滤回答,或调 curl local/models,不要再调 provider list]`;
213
+ } else {
214
+ result += `\n\n[系统提示:此查询已完成,结果即答案,请直接用中文回答用户,禁止再调用相同或同类查询工具]`;
215
+ }
159
216
  }
160
217
  const dt = Math.round(performance.now() - t1);
161
218
  trace(`[tool] run_command "${cmd.slice(0, 40)}" · ${dt}ms · ${r.ok ? "OK" : "FAIL"} ${r.output.length}字`);
@@ -185,10 +242,20 @@ async function runAgentTurn(userText, messages) {
185
242
  return { id: c.id, content: result };
186
243
  }));
187
244
  for (const tr of toolResults) messages.push({ role: "tool", tool_call_id: tr.id, content: tr.content });
188
- // 若本轮有 SKIPPED_DUP,额外追加一条系统提示,强制 LLM 基于已有结果回答而不是再调工具
245
+ // 若本轮有 SKIPPED_DUP,额外追加系统提示并禁用后续工具,强制直接回答
189
246
  if (toolResults.some((tr) => String(tr.content).startsWith("SKIPPED_DUP"))) {
190
- messages.push({ role: "system", content: "系统提示:你已重复调用相同工具,工具侧已复用首次结果并跳过执行。请直接基于以上工具结果用中文回答用户,不要再发起任何工具调用。" });
191
- trace(`[dup] 检测到重复调用,已注入系统提示要求直接回答`);
247
+ duplicateStrikes++;
248
+ forceNoTools = true;
249
+ messages.push({ role: "system", content: "系统提示:你已重复调用相同工具,工具侧已复用首次结果并跳过执行。你已被禁止再调用任何工具,必须立即基于以上工具结果用中文直接回答用户,0 工具调用。" });
250
+ trace(`[dup] 检测到重复调用 ${duplicateStrikes} 次,已禁用后续工具调用`);
251
+ if (duplicateStrikes >= 2) {
252
+ const seen = [...seenCalls.values()].map((v) => v.firstResult).join("\n---\n").slice(0, 6000);
253
+ const synth = `检测到重复调用已达 ${duplicateStrikes} 次,为避免空转,直接基于已有结果回答:\n\n${seen}`;
254
+ messages.push({ role: "assistant", content: synth });
255
+ const totalMs = Math.round(performance.now() - t0);
256
+ trace(`[turn] 提前结束(重复阈值) 总计 ${totalMs}ms`);
257
+ return { text: synth, model: lastModel || "local", latency: lastLatency, usage: lastUsage, fallback: lastFallback, ok: true, totalMs };
258
+ }
192
259
  }
193
260
  const toolsMs = Math.round(performance.now() - tTools);
194
261
  const loopMs = Math.round(performance.now() - tLoop);
@@ -3,6 +3,7 @@ import { createKeyRing } from "./keyring.js";
3
3
  import { loadProviderKeys, loadProviderAuths, loadProviderBaseUrl, loadProviderShareKeys, saveProviderConfig, WORKBUDDY_DEFAULT_BASE_URL } from "../state.js";
4
4
  import { writeFileSync, existsSync, mkdirSync, readdirSync, readFileSync, appendFileSync, statSync } from "node:fs";
5
5
  import { join, dirname } from "node:path";
6
+ import { tmpdir } from "node:os";
6
7
  import { getCachedBalance, setCachedBalance } from "./workbuddy-balance.js";
7
8
 
8
9
  let UndiciAgent = null;
@@ -17,6 +18,14 @@ function envInt(name, fallback) {
17
18
  const v = Number(process.env[name]);
18
19
  return Number.isInteger(v) && v > 0 ? v : fallback;
19
20
  }
21
+ function isTestEnv() {
22
+ if (process.env.NODE_ENV === "test") return true;
23
+ if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
24
+ if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
25
+ if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
26
+ if (process.env.NODE_TEST_CONTEXT) return true;
27
+ return false;
28
+ }
20
29
 
21
30
  function resolveBaseUrl(baseUrl) {
22
31
  if (baseUrl) return String(baseUrl).trim().replace(/\/+$/, "");
@@ -143,7 +152,7 @@ export function createWorkbuddyProvider({
143
152
  // fallback scan auths/workbuddy-*.json if still empty
144
153
  if (!authList.length && !keys.length) {
145
154
  try {
146
- const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
155
+ const authDir = process.env.WORKBUDDY_AUTH_DIR || (isTestEnv() ? join(tmpdir(), "mslxdff-test-auths") : (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths")));
147
156
  if (existsSync(authDir)) {
148
157
  const files = readdirSync(authDir).filter((f) => f.startsWith("workbuddy-") && f.endsWith(".json"));
149
158
  for (const f of files) {
@@ -223,7 +232,7 @@ export function createWorkbuddyProvider({
223
232
  saveProviderConfig(id, { baseUrl: resolvedBase, keys: [...keys], auths: [...authList] }, file ? { file } : {});
224
233
  } catch {}
225
234
  try {
226
- const authDir = process.env.WORKBUDDY_AUTH_DIR || (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths"));
235
+ const authDir = process.env.WORKBUDDY_AUTH_DIR || (isTestEnv() ? join(tmpdir(), "mslxdff-test-auths") : (file && String(file).includes("mslxdff-") ? join(dirname(String(file)), "auths") : join(process.cwd(), "auths")));
227
236
  mkdirSync(authDir, { recursive: true });
228
237
  const expAt = (() => { try { return JSON.parse(Buffer.from(newAt.split(".")[1], "base64").toString()).exp; } catch { return Math.floor(Date.now()/1000)+5184000; } })();
229
238
  const doc = { account: { uid, enterpriseId: auth.enterpriseId || "", nickname: "" }, auth: { accessToken: newAt, refreshToken: newRt, expiresAt: expAt, domain: auth.domain || "www.codebuddy.cn" } };
package/src/state.js CHANGED
@@ -6,9 +6,19 @@ import os from "node:os";
6
6
 
7
7
  export const DEFAULT_PORT = 8989;
8
8
 
9
+ function isTestEnv() {
10
+ if (process.env.NODE_ENV === "test") return true;
11
+ if (process.env.MSLXDFF_STATE_FILE && String(process.env.MSLXDFF_STATE_FILE).includes("mslxdff-test")) return true;
12
+ if (process.argv.some((a) => String(a).includes("--test") || String(a).endsWith(".test.js"))) return true;
13
+ if (Array.isArray(process.execArgv) && process.execArgv.some((a) => String(a).includes("--test"))) return true;
14
+ // Node test runner sets this
15
+ if (process.env.NODE_TEST_CONTEXT) return true;
16
+ return false;
17
+ }
18
+
9
19
  export function defaultStateFile() {
10
20
  if (process.env.MSLXDFF_STATE_FILE) return process.env.MSLXDFF_STATE_FILE;
11
- if (process.argv.includes("--test") || process.env.NODE_ENV === "test") {
21
+ if (isTestEnv()) {
12
22
  return join(os.tmpdir(), "mslxdff-test-state.json");
13
23
  }
14
24
  return join(os.homedir(), ".config", "mslxdff", "state.json");
@@ -19,7 +29,7 @@ export function tokenFile(file) {
19
29
  const sf = file || defaultStateFile();
20
30
  const realDefault = join(os.homedir(), ".config", "mslxdff", "state.json");
21
31
  if (sf !== realDefault) return join(dirname(sf), "token");
22
- if (process.argv.includes("--test") || process.env.NODE_ENV === "test") {
32
+ if (isTestEnv()) {
23
33
  return join(os.tmpdir(), "mslxdff-test-token");
24
34
  }
25
35
  return join(os.homedir(), ".config", "mslxdff", "token");