zcode-pro 0.19.0 → 0.20.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.
@@ -4,19 +4,19 @@
4
4
 
5
5
  import { createServer } from 'node:http';
6
6
  import { execFile, spawn, spawnSync } from 'node:child_process';
7
- import { chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
7
+ import { appendFileSync, chmodSync, copyFileSync, createWriteStream, existsSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
8
8
  import { mkdtemp } from 'node:fs/promises'; // 注意:fs 的 mkdtemp 是回调版,await 它会把 undefined 当回调
9
9
  import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from 'node:path';
10
10
  import { homedir, tmpdir } from 'node:os';
11
11
  import { fileURLToPath, pathToFileURL } from 'node:url';
12
12
  import { readSettings, writeSettingsAtomic, remapSettingsPaths, isProjectOpenInTabs } from './settings.mjs';
13
- import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, getTaskProviderModel } from './taskIndex.mjs';
13
+ import { taskIndexPath, probeTaskIndexWritable, remapTaskIndexPaths, taskIndexDriverAvailable, listOverriddenTitles, listTaskWorkspaceMap, setTaskTitleIfNotOverridden, setCustomSessionTitle, isTaskTitleOverridden, reconcileCustomTitles, getTaskProviderModel } from './taskIndex.mjs';
14
14
  import { pickFolderSystem } from './pickFolder.mjs';
15
15
  import { reorderWorkspaceTasks, reorderGroupMembers } from './taskOrder.mjs';
16
16
  import { webdavRequest, webdavPutWithMkcol, webdavHttpText, parseWebdavListing, findCurl } from './webdav.mjs';
17
17
  import { resolveZcodeExecutable } from './paths.mjs';
18
18
 
19
- const VERSION = '0.19.0';
19
+ const VERSION = '0.20.0';
20
20
 
21
21
  // 全局提示词固定在用户主目录:官方加载器按 HOME/USERPROFILE 拼 .zcode/AGENTS.md,
22
22
  // 不读 ZCODE_DATA_BASE_DIR(数据根迁走时全局指令仍在原位)。
@@ -104,6 +104,7 @@ export function defaultConfig() {
104
104
  //(用会话当前模型);prompt 留空 = 内置默认提示词
105
105
  titleGen: {
106
106
  enabled: false,
107
+ customWritten: {},
107
108
  provider: 'session',
108
109
  model: 'glm-5.3-flash',
109
110
  prompt: '',
@@ -118,14 +119,16 @@ export function defaultConfig() {
118
119
  // 软件(headroom 与 rtk)的安装、检查更新与升级;sync = WebDAV 同步。同步默认
119
120
  // 关闭——坚果云等国内网盘通常直连即可,代理停了同步会跟着失败
120
121
  proxyScopes: { plugins: true, deps: true, sync: false },
121
- // 开发模式(设置弹窗“调试”标签页):开启后出现「重载」按钮,可在不重启
122
+ // 开发模式(设置弹窗“调试”标签页):开启后出现“重载”按钮,可在不重启
122
123
  // ZCode 的情况下加载新代码(构建 → 换新启动器进程 → 各窗口自动刷新重注入)
123
124
  devMode: false,
124
125
  // 设置同步(设置弹窗“同步”标签页,交互参考 Tampermonkey 的手动同步):
125
126
  // 仅 WebDAV、仅在点击按钮时传输(type 字段预留多后端扩展,当前恒为 webdav)。
126
127
  // url 为 WebDAV 服务器地址;dir 为其下的上传目录(默认 zcode-pro,避免直接
127
128
  // 写进网盘根目录);include 选择同步内容;凭据只存本机、不同步出去;
128
- // lastPushAt/lastPullAt 只记录本机操作时间。
129
+ // lastPushAt/lastPullAt 只记录本机操作时间;historyLimit 为云端历史版本保留
130
+ // 个数(同一次上传的各组文件算一个,上传成功后自动删除更早的;0 = 不留历史,
131
+ // 只有固定名最新版)。
129
132
  sync: {
130
133
  type: 'webdav',
131
134
  url: '',
@@ -133,6 +136,7 @@ export function defaultConfig() {
133
136
  login: '',
134
137
  password: '',
135
138
  include: { zcodepro: true, model: true },
139
+ historyLimit: 10,
136
140
  lastPushAt: null,
137
141
  lastPullAt: null,
138
142
  },
@@ -246,6 +250,20 @@ function proxyUrlFor(scope) {
246
250
  return activeProxyScopes[scope] ? activeProxyUrl : '';
247
251
  }
248
252
 
253
+ // 智能起名诊断日志:追加到 <dataRoot>/zcodepro.log(与启动诊断同文件),超 512KB
254
+ // 轮转 .old。起名过程注入侧静默(失败不打扰用户),出问题时靠这里回查
255
+ let titleGenLogRotated = false;
256
+ function titleGenLog(dataRoot, line) {
257
+ try {
258
+ const file = join(dataRoot, 'zcodepro.log');
259
+ if (!titleGenLogRotated && existsSync(file) && statSync(file).size > 512 * 1024) {
260
+ renameSync(file, file + '.old');
261
+ titleGenLogRotated = true;
262
+ }
263
+ appendFileSync(file, `[${new Date().toISOString()}] title-gen ${line}\n`);
264
+ } catch { /* 日志失败不影响功能 */ }
265
+ }
266
+
249
267
  export function startHelper({ port, token, dataRoot, state, agentsFile = defaultAgentsFile() }) {
250
268
  const configFile = join(dataRoot, 'zcodepro.json');
251
269
  const settingsFile = join(dataRoot, 'v2', 'setting.json');
@@ -418,6 +436,13 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
418
436
  // 注入侧据此把被自动标题覆盖的侧栏行钉回重命名值;响应带开关,
419
437
  // 注入侧按需拉取这一个端点即可(无轮询)
420
438
  if (req.method === 'GET' && url.pathname === '/pinned-titles') {
439
+ // 先自动校正:桌面端打开会话时会把运行时缓存的旧标题写回任务索引(实测),
440
+ // 对我们写过且 CLI custom 仍是所写标题的会话,把索引改回来再下发
441
+ const config0 = loadConfig(configFile);
442
+ if (config0.titleGen.customWritten && Object.keys(config0.titleGen.customWritten).length) {
443
+ const repaired = await reconcileCustomTitles(dataRoot, config0.titleGen.customWritten);
444
+ if (repaired.length) titleGenLog(dataRoot, `reconciled sess=${repaired.map((s) => s.slice(5, 13)).join(',')}`);
445
+ }
421
446
  const r = await listOverriddenTitles(dataRoot);
422
447
  if (r.error) {
423
448
  json(res, 500, { ok: false, code: r.code, error: r.error });
@@ -488,9 +513,43 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
488
513
  return;
489
514
  }
490
515
  const sessId = typeof body?.sessId === 'string' && /^sess_[A-Za-z0-9._-]+$/.test(body.sessId) ? body.sessId : '';
491
- const title = await generateTitleWith(cfg, text, dataRoot, sessId);
516
+ let title;
517
+ try {
518
+ title = await generateTitleWith(cfg, text, dataRoot, sessId);
519
+ } catch (err) {
520
+ titleGenLog(dataRoot, `generate-failed sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
521
+ throw err; // 交给外层统一 500
522
+ }
492
523
  let written = false;
524
+ let store = ''; // CLI 会话库写入结果:'' 成功 | skipped 原因 | 'error'
493
525
  if (sessId && body.test !== true) {
526
+ // 已被手动重命名(或前次起名已写过)就整体跳过——手动重命名永远优先,
527
+ // 也避免起名晚到的 CLI 写入盖掉用户的 custom 标题
528
+ if (await isTaskTitleOverridden(dataRoot, sessId)) {
529
+ json(res, 200, { ok: true, title, written: false, store: 'skipped: overridden' });
530
+ return;
531
+ }
532
+ // 双写,顺序不能反:先写 CLI 会话库(title_source=custom,权威层——
533
+ // 官方自动起名看到 custom 会放弃,桌面同步器会把 custom 标题带着
534
+ // overridden=1 送回任务索引),再写任务索引(展示层,立即上屏)。
535
+ // 只写任务索引时,官方运行时排队的自动标题会在几秒后把它覆盖(实测)。
536
+ // CLI 库不可写(旧版无 title_source 列 / 无该会话行)不阻断:退回
537
+ // 单写任务索引的旧行为;任务索引随后失败也不回滚 CLI——同步器会补
538
+ for (let attempt = 0; ; attempt++) {
539
+ try {
540
+ const r = await setCustomSessionTitle(dataRoot, sessId, title);
541
+ store = r.written ? '' : (r.skipped || 'skipped');
542
+ break;
543
+ } catch (err) {
544
+ if (err.code === 'index-busy' && attempt < 2) {
545
+ await new Promise((r) => setTimeout(r, 1200));
546
+ continue;
547
+ }
548
+ store = 'error: ' + (err?.message || err);
549
+ titleGenLog(dataRoot, `cli-store-error sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
550
+ break;
551
+ }
552
+ }
494
553
  // 写库撞上宿主写入时稍候重试(起名不急这几秒)
495
554
  for (let attempt = 0; ; attempt++) {
496
555
  try {
@@ -502,12 +561,19 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
502
561
  await new Promise((r) => setTimeout(r, 1200));
503
562
  continue;
504
563
  }
564
+ titleGenLog(dataRoot, `index-error sess=${sessId.slice(0, 13)} err=${err?.message || err}`);
505
565
  json(res, 500, { ok: false, code: err.code || 'index-error', error: err.message });
506
566
  return;
507
567
  }
508
568
  }
509
569
  }
510
- json(res, 200, { ok: true, title, written });
570
+ // CLI 库写成功了才登记回查记录(reconcileCustomTitles 只认这些记录校正)
571
+ if (sessId && store === '') {
572
+ const c = loadConfig(configFile);
573
+ c.titleGen.customWritten = { ...(c.titleGen.customWritten || {}), [sessId]: { title, at: Date.now() } };
574
+ saveConfig(configFile, c);
575
+ }
576
+ json(res, 200, { ok: true, title, written, store });
511
577
  return;
512
578
  }
513
579
  // —— zcode-vision 插件(图片视觉代理):设置弹窗“视觉代理”标签页读写 ————
@@ -910,7 +976,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
910
976
  json(res, r.status || 500, { ok: false, code: r.code || 'sync-run', error: r.error });
911
977
  return;
912
978
  }
913
- json(res, 200, { ok: true, direction, applied: r.applied, sync: loadConfig(configFile).sync });
979
+ json(res, 200, { ok: true, direction, applied: r.applied, ...(typeof r.pruned === 'number' ? { pruned: r.pruned } : {}), sync: loadConfig(configFile).sync });
914
980
  return;
915
981
  }
916
982
  // —— 同步连通性测试(“测试”按钮):用表单当前值逐步验证(可先测再存),不写入配置文件 ——
@@ -1076,7 +1142,7 @@ export function startHelper({ port, token, dataRoot, state, agentsFile = default
1076
1142
  if (r.ok) updated += 1;
1077
1143
  } else if (!p.installed && only) {
1078
1144
  // 安装只走单插件入口(name 指定=各面板的安装卡):
1079
- // 全量「检查并更新」/启动自动更新只升级已装插件——卸载必须被尊重,
1145
+ // 全量“检查并更新”/启动自动更新只升级已装插件——卸载必须被尊重,
1080
1146
  // 未装插件一律由用户在各 tab 显式点安装
1081
1147
  const r = await runZcodeCli(['plugins', 'install', `${p.name}@${ZCODE_PLUGINS_MARKETPLACE}`], 120000);
1082
1148
  steps.push({ step: `install ${p.name}`, ok: r.ok, output: r.output });
@@ -1143,6 +1209,18 @@ function mergeTitleGenConfig(saved) {
1143
1209
  // 旧版配置的 'cli'(登录账号单独成模式)统一迁移为 'session':
1144
1210
  // 登录账号的套餐已在供应商列表里,跟随会话即覆盖原语义
1145
1211
  const provider = typeof s.provider === 'string' ? s.provider.trim().slice(0, 200) : d.provider;
1212
+ // customWritten:起名功能写过的 { sessId → { title, at } },自动校正只认这些
1213
+ // 记录(见 reconcileCustomTitles)。只收合法会话 id 与短字符串,超 7 天/500 条的丢弃
1214
+ const written = {};
1215
+ const cutoff = Date.now() - 7 * 24 * 3600 * 1000;
1216
+ let kept = 0;
1217
+ for (const [k, v] of Object.entries((s.customWritten && typeof s.customWritten === 'object' && !Array.isArray(s.customWritten)) ? s.customWritten : {})) {
1218
+ if (kept >= 500 || !/^sess_[A-Za-z0-9._-]+$/.test(k)) continue;
1219
+ if (!v || typeof v !== 'object' || typeof v.title !== 'string' || v.title.length > 200) continue;
1220
+ if (!Number.isFinite(v.at) || v.at < cutoff) continue;
1221
+ written[k] = { title: v.title, at: v.at };
1222
+ kept++;
1223
+ }
1146
1224
  return {
1147
1225
  enabled: s.enabled === true,
1148
1226
  provider: provider === 'cli' ? 'session' : provider,
@@ -1151,6 +1229,7 @@ function mergeTitleGenConfig(saved) {
1151
1229
  baseUrl: typeof s.baseUrl === 'string' ? s.baseUrl.trim().slice(0, 500) : d.baseUrl,
1152
1230
  apiKey: typeof s.apiKey === 'string' ? s.apiKey.trim().slice(0, 500) : d.apiKey,
1153
1231
  format: s.format === 'openai' ? 'openai' : s.format === 'anthropic' ? 'anthropic' : '',
1232
+ customWritten: written,
1154
1233
  };
1155
1234
  }
1156
1235
 
@@ -1179,13 +1258,16 @@ function mergeSyncConfig(saved) {
1179
1258
  login: typeof saved.login === 'string' ? saved.login.slice(0, 300) : '',
1180
1259
  password: typeof saved.password === 'string' ? saved.password.slice(0, 300) : '',
1181
1260
  include: { zcodepro: inc.zcodepro !== false, model: inc.model !== false },
1261
+ historyLimit: Number.isInteger(saved.historyLimit) && saved.historyLimit >= 0 && saved.historyLimit <= 100
1262
+ ? saved.historyLimit : d.historyLimit,
1182
1263
  lastPushAt: Number.isFinite(saved.lastPushAt) ? saved.lastPushAt : null,
1183
1264
  lastPullAt: Number.isFinite(saved.lastPullAt) ? saved.lastPullAt : null,
1184
1265
  };
1185
1266
  }
1186
1267
 
1187
1268
  // POST /sync 表单校验:url/login/password/dir 全量替换(dir 缺省时不动已有值)。
1188
- // URL 必填,指向 WebDAV 服务器(可含路径前缀);dir 为其下的上传目录,可多级
1269
+ // URL 必填,指向 WebDAV 服务器(可含路径前缀);dir 为其下的上传目录,可多级;
1270
+ // historyLimit 可选(缺省时不动已有值),0–100 的整数
1189
1271
  function validateSyncForm(raw) {
1190
1272
  const url = typeof raw.url === 'string' ? raw.url.trim() : '';
1191
1273
  const login = typeof raw.login === 'string' ? raw.login.trim() : '';
@@ -1206,6 +1288,12 @@ function validateSyncForm(raw) {
1206
1288
  }
1207
1289
  out.dir = dir;
1208
1290
  }
1291
+ if (raw.historyLimit !== undefined && raw.historyLimit !== null) {
1292
+ if (!Number.isInteger(raw.historyLimit) || raw.historyLimit < 0 || raw.historyLimit > 100) {
1293
+ return { error: '历史版本保留个数需为 0–100 的整数' };
1294
+ }
1295
+ out.historyLimit = raw.historyLimit;
1296
+ }
1209
1297
  return { sync: out };
1210
1298
  }
1211
1299
 
@@ -1299,8 +1387,10 @@ async function runSettingsSync({ direction, dataRoot, configFile, config, proxyU
1299
1387
  // 上传:每组独立一个云端文件(zcodepro.json / model.json,始终最新一版),勾选几组
1300
1388
  // 写几个;写前预读云端内容,别名与本机的合并后再写(合并规则见 mergeAliases),
1301
1389
  // 其余内容整份覆盖。同一份内容再写一个带时间戳的历史版本文件,供下载时任选版本
1302
- // 恢复(旧版 zcode-pro 仍读固定名)
1390
+ // 恢复(旧版 zcode-pro 仍读固定名);历史版本最多保留 historyLimit 个(同一时间戳
1391
+ // 的各组文件算一个),上传成功后自动删除更早的
1303
1392
  async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1393
+ const keep = Number.isInteger(sync.historyLimit) ? sync.historyLimit : defaultConfig().sync.historyLimit;
1304
1394
  const docs = {};
1305
1395
  if (sync.include.zcodepro) {
1306
1396
  // sync(本机的同步配置与凭据)与 version(本机 helper 版本)不外发
@@ -1345,10 +1435,13 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1345
1435
  if (put.httpCode < 200 || put.httpCode >= 300) {
1346
1436
  return { code: 'sync-request', status: 502, error: webdavHttpText(put.httpCode) };
1347
1437
  }
1348
- const putVersion = await putUrl(syncVersionName(kind, stamp));
1349
- if (!putVersion.ok) return { code: 'sync-request', status: 502, error: putVersion.error };
1350
- if (putVersion.httpCode < 200 || putVersion.httpCode >= 300) {
1351
- return { code: 'sync-request', status: 502, error: webdavHttpText(putVersion.httpCode) };
1438
+ // 历史版本只在保留个数 > 0 时写(0 = 只留固定名最新版,由上传后清理收尾)
1439
+ if (keep > 0) {
1440
+ const putVersion = await putUrl(syncVersionName(kind, stamp));
1441
+ if (!putVersion.ok) return { code: 'sync-request', status: 502, error: putVersion.error };
1442
+ if (putVersion.httpCode < 200 || putVersion.httpCode >= 300) {
1443
+ return { code: 'sync-request', status: 502, error: webdavHttpText(putVersion.httpCode) };
1444
+ }
1352
1445
  }
1353
1446
  pushed.push(kind);
1354
1447
  }
@@ -1357,7 +1450,9 @@ async function runSyncPush({ dataRoot, configFile, config, sync, proxyUrl }) {
1357
1450
  }
1358
1451
  sync.lastPushAt = Date.now();
1359
1452
  saveConfig(configFile, config);
1360
- return { applied: pushed };
1453
+ // 上传成功后清理超出保留个数的旧历史版本(best-effort,失败不影响上传结果)
1454
+ const pruned = await pruneSyncHistory(sync, proxyUrl, keep);
1455
+ return { applied: pushed, pruned };
1361
1456
  }
1362
1457
 
1363
1458
  // 下载:把云端所选组应用到本机(各组独立文件,勾选几组读几组,可只覆盖模型设置)。
@@ -1500,6 +1595,39 @@ async function listSyncVersions(sync, proxyUrl) {
1500
1595
  return { files };
1501
1596
  }
1502
1597
 
1598
+ // 从云端文件名列表选出待删除的历史版本(导出以便测试脚本直接驱动):带时间戳的
1599
+ // 文件按时间戳段分组——同一次上传的各组文件共用一个时间戳,算“一个”;时间戳
1600
+ // 段定宽零填充,字典序即时间序。保留最近 keep 个,返回更早的各组文件名;
1601
+ // 固定名(zcodepro.json / model.json)和不相干的文件名不会出现在结果里
1602
+ export function syncHistoryStaleNames(names, keep) {
1603
+ const groups = new Map();
1604
+ for (const name of names) {
1605
+ const ts = (String(name).match(/^(?:zcodepro|model)(-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.json$/) || [])[1];
1606
+ if (!ts) continue;
1607
+ if (!groups.has(ts)) groups.set(ts, []);
1608
+ groups.get(ts).push(name);
1609
+ }
1610
+ // 时间戳段按新 → 旧排,slice(keep) 剩下的即超出保留个数的更早各组
1611
+ return [...groups.keys()].sort((a, b) => b.localeCompare(a)).slice(Math.max(0, keep)).flatMap((ts) => groups.get(ts));
1612
+ }
1613
+
1614
+ // 上传后清理云端目录里超出保留个数的历史版本:列出文件,删掉 syncHistoryStaleNames
1615
+ // 归出的旧文件。best-effort——列目录或单个删除失败只跳过(下次上传再清),不抛错;
1616
+ // 返回本次删掉的文件数
1617
+ async function pruneSyncHistory(sync, proxyUrl, keep) {
1618
+ const list = await listSyncVersions(sync, proxyUrl);
1619
+ if (list.error) return 0;
1620
+ const stale = syncHistoryStaleNames(list.files.map((f) => f.name), keep);
1621
+ let removed = 0;
1622
+ for (const name of stale) {
1623
+ const r = await webdavRequest({ method: 'DELETE', url: syncFileUrlByName(sync, name), login: sync.login, password: sync.password, proxyUrl, timeoutMs: 30000 });
1624
+ if (!r.ok) continue; // 网络失败:跳过该文件
1625
+ if (r.httpCode !== 404 && (r.httpCode < 200 || r.httpCode >= 300)) continue; // 404 = 已不在,视为已清
1626
+ removed++;
1627
+ }
1628
+ return removed;
1629
+ }
1630
+
1503
1631
  // 别名表清洗(同步下载用):只收 路径字符串 → 短字符串 的条目,数量限幅
1504
1632
  function sanitizeAliases(raw) {
1505
1633
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
@@ -1607,7 +1735,7 @@ export async function scanIdleBusy(paths, dataRoot) {
1607
1735
  return SCAN_OFF;
1608
1736
  }
1609
1737
 
1610
- // Linux:/proc 直读(comm 定位 CLI、cwd 定位项目、task/<pid>/children 拿子进程)
1738
+ // Linux:直接读取 /proc(comm 定位 CLI、cwd 定位项目、task/<pid>/children 拿子进程)
1611
1739
  function scanIdleBusyLinux(paths) {
1612
1740
  let pids;
1613
1741
  try { pids = readdirSync('/proc').filter((d) => /^\d+$/.test(d)); }
@@ -1794,7 +1922,7 @@ function sanitizeFontStack(v) {
1794
1922
  return s;
1795
1923
  }
1796
1924
 
1797
- // 视觉代理「跟随供应商」下拉取数:与智能起名共用同一套供应商列表和过滤规则
1925
+ // 视觉代理“跟随供应商”下拉取数:与智能起名共用同一套供应商列表和过滤规则
1798
1926
  //(见 listTitleGenProviders),只回 id/名称/别名,不含密钥。
1799
1927
  // 导出以便测试脚本直接驱动。
1800
1928
  export function listVisionProviders() {
@@ -1853,7 +1981,7 @@ const TITLE_GEN_TIMEOUT_MS = 60000; // 单次模型调用超时(与官方起
1853
1981
  //(coding-plan 与 start-plan 都叫 "Coding Plan"),界面里按套餐类型显示。
1854
1982
  // coding-plan 的个人/团队是动态的:从桌面设置(v2/setting.json 的
1855
1983
  // providerFamilyConnectionSelections)读当前登录的连接方式(kind 含
1856
- // individual → 个人、team → 团队),读不到时退回「个人/团队」
1984
+ // individual → 个人、team → 团队),读不到时退回“个人/团队”
1857
1985
  function codingPlanKindByFamily() {
1858
1986
  const s = readJsonFile(join(homedir(), '.zcode', 'v2', 'setting.json'));
1859
1987
  const sel = (s && s.providerFamilyConnectionSelections && typeof s.providerFamilyConnectionSelections === 'object')
@@ -1962,7 +2090,7 @@ async function callAnthropicText(baseUrl, apiKey, model, content) {
1962
2090
  body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
1963
2091
  });
1964
2092
  const body = await resp.text();
1965
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${body.slice(0, 300)}`);
2093
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} [model=${model}]: ${body.slice(0, 300)}`);
1966
2094
  const doc = JSON.parse(body);
1967
2095
  const text = Array.isArray(doc.content)
1968
2096
  ? doc.content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('') : '';
@@ -1988,7 +2116,7 @@ async function callOpenAiText(baseUrl, apiKey, model, content) {
1988
2116
  body: JSON.stringify({ model, max_tokens: 2048, messages: [{ role: 'user', content }] }),
1989
2117
  });
1990
2118
  const body = await resp.text();
1991
- if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${body.slice(0, 300)}`);
2119
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} [model=${model}]: ${body.slice(0, 300)}`);
1992
2120
  const doc = JSON.parse(body);
1993
2121
  const text = doc.choices?.[0]?.message?.content || '';
1994
2122
  if (typeof text !== 'string' || !text.trim()) throw new Error('响应中没有文本内容');
@@ -2008,7 +2136,12 @@ async function callOpenAiText(baseUrl, apiKey, model, content) {
2008
2136
  export async function generateTitleWith(cfg, rawText, dataRoot = '', sessId = '') {
2009
2137
  const text = String(rawText || '').trim().replace(/\s+/g, ' ').slice(0, TITLE_GEN_MAX_TEXT);
2010
2138
  const prompt = (cfg.prompt && cfg.prompt.trim()) || titleGenDefaultPrompt();
2011
- const userContent = `${prompt}\n\n会话的用户消息:\n${text}`;
2139
+ // 注入当前日期(上海时区):起名模型是无状态的,自定义提示词里要求
2140
+ // “MMDD 取对话创建日期”时它无从得知,只能留字面量 MMDD
2141
+ const now = new Date(Date.now() + 8 * 3600 * 1000);
2142
+ const pad = (n) => String(n).padStart(2, '0');
2143
+ const dateLine = `当前日期(Asia/Shanghai):${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())}(MMDD 即 ${pad(now.getUTCMonth() + 1)}${pad(now.getUTCDate())})`;
2144
+ const userContent = `${prompt}\n\n${dateLine}\n\n会话的用户消息:\n${text}`;
2012
2145
  const providerId = (cfg.provider || '').trim();
2013
2146
  const model = (cfg.model || '').trim();
2014
2147
  const conn = await resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessId);
@@ -2056,12 +2189,19 @@ async function resolveTitleGenConnection(cfg, providerId, model, dataRoot, sessI
2056
2189
  }
2057
2190
 
2058
2191
  // 清洗模型输出:去思考块/代码围栏/首尾引号/序号/末尾标点,取首个非空行并限长。
2059
- // 模型可能沿用官方起名的习惯回 JSON 对象,此时取其 title 字段。导出以便测试
2192
+ // 模型可能沿用官方起名的习惯回 JSON 对象,此时取其 title 字段。自定义提示词常让
2193
+ // 模型“先说明再给标题”(如“无法直接修改会话标题……建议手动设置:”后另起一行
2194
+ // 【MMDD|类型|主题】),此时首个非空行是说明而非标题——优先取独立成行的
2195
+ // 【】/[] 包裹标题行(允许行首 markdown 标题符)。导出以便测试
2060
2196
  export function cleanGeneratedTitle(text) {
2061
2197
  let t = String(text || '').replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
2062
- // 整段包在代码围栏里时先去掉围栏标记(先取行会把「```json」当成标题内容)
2198
+ // 整段包在代码围栏里时先去掉围栏标记(先取行会把“```json”当成标题内容)
2063
2199
  t = t.replace(/^```[a-zA-Z]*\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
2064
- let line = t.split(/\r?\n/).map((s) => s.trim()).find(Boolean) || '';
2200
+ const lines = t.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
2201
+ // 标题行判定前剥掉 markdown 外壳(## 标题符、**加粗**、*斜体*、_下划线_、~~删除线~~)
2202
+ const unwrap = (s) => s.replace(/^#{1,4}\s*/, '').replace(/^[*_~]+/, '').replace(/[*_~]+$/, '').trim();
2203
+ const bracketed = lines.find((s) => /^(?:【[^】]{1,60}】|\[[^\]\n]{1,60}\])$/.test(unwrap(s)));
2204
+ let line = bracketed ? unwrap(bracketed) : (lines[0] || '');
2065
2205
  line = line.replace(/^```[a-zA-Z]*\s*/, '').replace(/```\s*$/, '');
2066
2206
  try {
2067
2207
  const doc = JSON.parse(line);
@@ -2411,7 +2551,7 @@ async function ensureUserPathEntry(dir) {
2411
2551
  }
2412
2552
 
2413
2553
  // 在插件缓存里找市场插件的钩子脚本:只认 installed_plugins.json 登记的 installPath。
2414
- // 不能按目录扫描:卸载只删「当前指向」的版本目录,缓存里的历史版本目录会残留
2554
+ // 不能按目录扫描:卸载只删“当前指向”的版本目录,缓存里的历史版本目录会残留
2415
2555
  // (升级留旧版、GitHub/本地源先后装过等),扫目录会把已卸载的插件误判为已装,
2416
2556
  // 面板就不显示安装卡了。本地开发流(install-plugin-local.sh)同样会更新安装记录,兼容。
2417
2557
  // 注意只认正式市场 duanluan-zcode-plugins:本地临时市场(如 duanluan-local)装的副本不在查找范围,
@@ -2430,7 +2570,7 @@ function findVisionHook() {
2430
2570
  return findMarketplaceHook('zcode-vision', join('hooks', 'vision-hook.mjs'));
2431
2571
  }
2432
2572
 
2433
- // 读市场插件在 installed_plugins.json 里登记的版本(面板信息卡显示「插件 x.y.z」用)
2573
+ // 读市场插件在 installed_plugins.json 里登记的版本(面板信息卡显示“插件 x.y.z”用)
2434
2574
  function installedPluginVersion(pluginName) {
2435
2575
  const installedRaw = readJsonFile(join(cliPluginsDir(), 'installed_plugins.json'));
2436
2576
  const rec = (installedRaw?.plugins || []).find((p) => p?.name === pluginName && p?.marketplace === ZCODE_PLUGINS_MARKETPLACE);
package/src/host/main.mjs CHANGED
@@ -17,11 +17,15 @@ import { runSetup } from './setup.mjs';
17
17
  const here = dirname(fileURLToPath(import.meta.url));
18
18
  const INJECT_BUNDLE = resolve(here, '..', '..', 'dist', 'inject.js');
19
19
 
20
- // Windows 控制台中文乱码适配:输出为 UTF-8 字节,旧版 conhost/PowerShell 的默认
21
- // 代码页(936/GBK)按 GBK 解码会显示乱码。检测到真实控制台且非 UTF-8 代码页时
22
- // 切到 65001——只影响当前控制台会话(重开终端即恢复),重定向/非交互不干预。
20
+ // Windows 控制台中文乱码适配:输出为 UTF-8 字节,旧版 conhost/cmd 的默认代码页
21
+ //(936/GBK)按 GBK 解码会显示乱码。检测到非 UTF-8 代码页时切到 65001——只影响
22
+ // 当前控制台会话(重开终端即恢复)。不能用 stdout.isTTY 单独作前提:npm 全局
23
+ // 入口(bin/zcode-pro.mjs)spawn ZCode 运行时跑 cli.mjs,子进程 stdout 经
24
+ // stdio:'inherit' 是管道(isTTY=false),但控制台乱码照样发生(0.18/0.19 用户
25
+ // 实拍);此时 stdin 仍连着终端,故用 stdout 或 stdin 任一 isTTY 判定。真正的
26
+ // 重定向场景(stdout 与 stdin 都不是 TTY)不干预。
23
27
  // 放在模块顶层:cli.mjs 经 import main.mjs 进入,setup 等子命令的输出同样受益。
24
- if (process.platform === 'win32' && process.stdout.isTTY) {
28
+ if (process.platform === 'win32' && (process.stdout.isTTY || process.stdin.isTTY)) {
25
29
  try {
26
30
  const cp = execSync('chcp.com', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true }).match(/(\d+)/)?.[1];
27
31
  if (cp && cp !== '65001') execSync('chcp.com 65001', { stdio: 'ignore', windowsHide: true });