mocode-ai 1.5.3 → 1.5.4

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.
@@ -2,11 +2,27 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { createHash } from 'node:crypto';
5
- const CACHE_VERSION = 1;
5
+ // v2:引入 SUSPECT_* 可信区间。旧缓存里被口径不可比的 provider 砸到 MIN_CORRECTION
6
+ // 下限、又被 clamp 住的条目(correction=0.5 且再不会有新样本去修正它)会永久打对折
7
+ // 所有显示数字,只能整表作废——丢掉的是几十个样本,重学只要几步。
8
+ const CACHE_VERSION = 2;
6
9
  const EWMA_ALPHA = 0.2;
7
10
  const MIN_CORRECTION = 0.5;
8
11
  const MAX_CORRECTION = 2;
9
12
  const MAX_ENTRIES = 64;
13
+ /**
14
+ * 可信样本区间(actual / estimated)。超出即**不并入 EWMA**。
15
+ *
16
+ * 为什么需要这道闸:估算器的任务只是「别让请求溢出窗口」,它允许偏高;而 usage 是
17
+ * provider 报的账,两者本该同量级(本机 40+ 会话实测 est/actual 落在 0.33–1.66)。
18
+ * 一旦某个 gateway/model 的 usage 口径不可比(实测踩过:localhost 网关的 thinking 模型
19
+ * 报 20.8 chars/token,同机其它 provider 全是 1.3–3.3),ratio 会直接砸到 MIN_CORRECTION
20
+ * 下限并被 clamp 住——之后 correction 恒为 0.5,**每个乘以它的显示数字都被无谓地打对折**
21
+ * (压缩行 40% vs 底栏 80%,用户看到的两个数都不是真值)。这种样本学不出有用信息,
22
+ * 只会污染 UI;丢掉它,correction 保持上一次可用值(或 1)。
23
+ */
24
+ const SUSPECT_MIN_RATIO = 0.3;
25
+ const SUSPECT_MAX_RATIO = 3.5;
10
26
  let cache;
11
27
  const toolFingerprints = new WeakMap();
12
28
  function cachePath() {
@@ -75,7 +91,8 @@ export function getTokenCalibration(baseURL, model, tools) {
75
91
  const entry = readCache().entries[calibrationKey(baseURL, model, tools)];
76
92
  return validEntry(entry) ? { correction: entry.correction, samples: entry.samples } : { correction: 1, samples: 0 };
77
93
  }
78
- /** 用一次真实 prompt usage 更新 EWMA;只落比例和样本数,不保存任何消息内容。 */
94
+ /** 用一次真实 prompt usage 更新 EWMA;只落比例和样本数,不保存任何消息内容。
95
+ * 样本与估算器差到 SUSPECT_* 区间之外时判为 provider 口径异常,直接丢弃(不改 correction)。 */
79
96
  export function updateTokenCalibration(baseURL, model, tools, estimatedTokens, actualTokens) {
80
97
  if (estimatedTokens <= 100 ||
81
98
  actualTokens <= 100 ||
@@ -83,6 +100,10 @@ export function updateTokenCalibration(baseURL, model, tools, estimatedTokens, a
83
100
  !Number.isFinite(actualTokens)) {
84
101
  return getTokenCalibration(baseURL, model, tools);
85
102
  }
103
+ const ratio = actualTokens / estimatedTokens;
104
+ if (ratio < SUSPECT_MIN_RATIO || ratio > SUSPECT_MAX_RATIO) {
105
+ return getTokenCalibration(baseURL, model, tools);
106
+ }
86
107
  const key = calibrationKey(baseURL, model, tools);
87
108
  const store = readCache();
88
109
  const previous = store.entries[key];
@@ -186,6 +186,7 @@ const zhCN = {
186
186
  'status.measured': '实测',
187
187
  'status.estimated': '估算',
188
188
  'status.messages': '{count} 条消息',
189
+ 'status.providerMeasured': 'provider 上一步实测 {tokens}:{pct}%',
189
190
  'agent.sending': '发送中… (任意键 / Esc / Ctrl+C 撤回)',
190
191
  'agent.thinking': '思考中',
191
192
  'agent.generating': '生成 {tool}',
@@ -516,6 +517,7 @@ const en = {
516
517
  'status.measured': 'measured',
517
518
  'status.estimated': 'estimated',
518
519
  'status.messages': '{count} messages',
520
+ 'status.providerMeasured': 'provider measured {tokens} last step:{pct}%',
519
521
  'agent.sending': 'Sending… (any key / Esc / Ctrl+C to recall)',
520
522
  'agent.thinking': 'Thinking',
521
523
  'agent.generating': 'Generating {tool}',
@@ -23,8 +23,20 @@ export function renderContextBar(history) {
23
23
  const W = 10;
24
24
  const filled = Math.round(pct * W);
25
25
  const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
26
- const src = contextState.lastUsage ? t('status.measured') : t('status.estimated');
27
26
  const k = (n) => `${Math.round(n / 1000)}k`;
27
+ // 标签必须描述**这个数字**是什么:本条 bar 恒为「对话内容」估算(dialog-only,不含
28
+ // system prompt / 工具 schema),与底栏用量条、80% 压力线都不是同一个数。
29
+ // 旧实现只要 lastUsage 存在就打「实测」,而显示的仍是估算值 —— 标签与数字对不上,
30
+ // 用户拿它跟底栏对账只会更困惑。改为:数字照旧标「估算」,把 provider 真正测到的
31
+ // prompt 并列出来,两者差多少一眼可见(差值本身就是 provider 口径是否可信的证据)。
32
+ const src = t('status.estimated');
33
+ const measured = contextState.lastUsage?.promptTokens;
34
+ const measuredNote = measured && measured > 0
35
+ ? ` · ${t('status.providerMeasured', {
36
+ tokens: k(measured),
37
+ pct: Math.round(Math.min(1, measured / win) * 100),
38
+ })}`
39
+ : '';
28
40
  const pctCol = pct >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio ? ui.yellow : ui.accent;
29
41
  const lifecycle = contextState.lifecycleStats;
30
42
  const archived = computePruneStats(history);
@@ -36,7 +48,7 @@ export function renderContextBar(history) {
36
48
  ? `\n lifecycle · live ${lifecycle.live} · referenced ${lifecycle.referenced} · digested ${lifecycle.digested} · stubbed ${lifecycle.stubbed}`
37
49
  : '\n lifecycle · no active snapshot (run a tool-enabled turn first)';
38
50
  const archiveLine = `\n archived tool results · ${archived.stubbed}`;
39
- return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${ui.reset}${artifactLine}${lifecycleLine}${archiveLine}`;
51
+ return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${measuredNote}${ui.reset}${artifactLine}${lifecycleLine}${archiveLine}`;
40
52
  }
41
53
  /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
42
54
  * 必须**用全 prompt 估算**(消息 + 工具 schema + 尾部 ephemeral 注入),与压缩触发器
@@ -45,6 +57,8 @@ export function renderContextBar(history) {
45
57
  * 触发器用 `Math.max(rawTotal, total) >= 0.8 * window`,bar 也照搬:校正后和校正前
46
58
  * 哪个大取哪个,确保不会因 correction<1 而低估。ephemeral 文本由 agent/core 每步写入
47
59
  * contextState.ephemeralText(避免在 bar 里再读一次 notes.md)。
60
+ * 压缩行(compact.ts 的 compactionLogLine)已统一到同一口径:数字取裸估算、后缀带窗口
61
+ * 百分比,两者可直接对账——「底栏 80% 而压缩行 40%」这类口径分裂不再出现。
48
62
  *
49
63
  * /context 命令仍是 dialog-only(见 renderContextBar):它的设计意图是"我说了多少"而非
50
64
  * "还剩多少空间",两条职责分开。 */
@@ -559,6 +559,26 @@ async function defaultSummarize(older, focus, signal, runtime = defaultCompactio
559
559
  }
560
560
  }
561
561
  // ── 对外:compactHistory / maybeCompact ───────────────────────────────────
562
+ /**
563
+ * 用户可见的上下文占用口径:**裸估算**(不乘 correction)。
564
+ *
565
+ * 为什么必须裸估算:触发判定用的就是它——session/scheduler.ts 的 80% 压力线比
566
+ * `Math.max(report.rawTotal, report.total)`(context/budget.ts scheduleActions),
567
+ * repl 底栏用量条也取 `max(raw, corrected)`(repl/status-bar.ts)。压缩行若打印校正后的值,
568
+ * 同一时刻 TUI 上就同时存在三个都叫 token 的数字(provider 实测 / 校正后 / 触发用裸估),
569
+ * 用户无从判断哪条线会触发 —— 实测踩过:底栏 80%、压缩行 40%,看起来「没到线却压了」。
570
+ * correction 只服务内部保留区尺寸估算(见 correctTokenEstimate 调用点),不再外泄成占用读数。
571
+ */
572
+ function rawPromptTokens(history, activeTools) {
573
+ return estimatePromptTokens(history, activeTools, 1);
574
+ }
575
+ /** 压缩行统一格式:`● 标签 205214 → 28614 tokens (80% → 11%)`。
576
+ * 百分比与数字同源(裸估算 / 窗口),让「凭什么这时压」在行内自证,不必回头翻代码。 */
577
+ function compactionLogLine(label, before, after, window) {
578
+ const pct = (n) => `${Math.round((n / Math.max(1, window)) * 100)}%`;
579
+ return (` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}${label}${ui.reset} ` +
580
+ `${ui.dim}${before} → ${after} tokens (${pct(before)} → ${pct(after)})${ui.reset}\n`);
581
+ }
562
582
  /**
563
583
  * 压缩 history(原地)。手动 /compact 与自动 maybeCompact 都走这里。
564
584
  * 不检查阈值——调用方(maybeCompact)决定是否调;/compact 直接调以强制压缩。
@@ -566,8 +586,8 @@ async function defaultSummarize(older, focus, signal, runtime = defaultCompactio
566
586
  export async function compactHistory(history, opts) {
567
587
  const state = opts.contextState ?? contextState;
568
588
  const activeTools = opts.tools ?? chatTools;
569
- const estimateBefore = estimatePromptTokens(history, activeTools, state.correction);
570
- state.lastEstimate = estimateBefore;
589
+ const estimateBefore = rawPromptTokens(history, activeTools);
590
+ state.lastEstimate = estimatePromptTokens(history, activeTools, state.correction);
571
591
  // 调用前就已中断(用户在上一步末尾按的 Ctrl+C):一步都别做,直接冒泡。
572
592
  // 不做完再抛是为了保证 history 完全未被触碰——abortRestore 才还原得干净。
573
593
  if (opts.signal?.aborted) {
@@ -688,12 +708,12 @@ export async function compactHistory(history, opts) {
688
708
  const single = groups[0];
689
709
  const userOnly = single.tools.length === 0 && single.assistant?.role === 'user';
690
710
  if (!userOnly && microcompactGroup(single)) {
691
- const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
692
- state.lastEstimate = estimateAfter;
711
+ const estimateAfter = rawPromptTokens(history, activeTools);
712
+ state.lastEstimate = estimatePromptTokens(history, activeTools, state.correction);
693
713
  state.lastUsage = undefined;
694
714
  if (!layout.isLastContentRowBlank())
695
715
  layout.contentWrite('\n');
696
- layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}强制微压缩(单组)${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
716
+ layout.contentWrite(compactionLogLine('强制微压缩(单组)', estimateBefore, estimateAfter, opts.window));
697
717
  return {
698
718
  compacted: true,
699
719
  summarized: false,
@@ -780,14 +800,14 @@ export async function compactHistory(history, opts) {
780
800
  history.length = 0;
781
801
  history.push(...rebuilt);
782
802
  (opts.runtime?.rollbackStore ?? defaultRollbackStore).pruneAfterCompaction(history);
783
- const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
784
- state.lastEstimate = estimateAfter;
785
- state.lastUsage = undefined; // 压缩后旧 usage 失效,/context 改用校正估算
803
+ const estimateAfter = rawPromptTokens(history, activeTools);
804
+ state.lastEstimate = estimatePromptTokens(history, activeTools, state.correction);
805
+ state.lastUsage = undefined; // 压缩后旧 usage 失效,/context 改用估算
786
806
  // 压缩行与上一个工具批次摘要行之间补空行分隔(compact 在 core step 循环顶部触发,
787
807
  // 上一步的 batch 可能尚未 flush,缓冲末行仍是 ● 工具摘要行 → 两行黏在一起)。
788
808
  if (!layout.isLastContentRowBlank())
789
809
  layout.contentWrite('\n');
790
- layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}压缩上下文${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
810
+ layout.contentWrite(compactionLogLine('压缩上下文', estimateBefore, estimateAfter, opts.window));
791
811
  // 抖动保护:压缩后仍超阈 → 提示 /clear,不死循环
792
812
  if (estimateAfter >= opts.threshold * opts.window) {
793
813
  layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}压缩后仍超阈,可能存在超大单条;建议 /clear。${ui.reset}\n`);
@@ -811,13 +831,13 @@ export async function compactHistory(history, opts) {
811
831
  if (microcompactGroup(g))
812
832
  microcompactDone = true;
813
833
  }
814
- const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
815
- state.lastEstimate = estimateAfter;
834
+ const estimateAfter = rawPromptTokens(history, activeTools);
835
+ state.lastEstimate = estimatePromptTokens(history, activeTools, state.correction);
816
836
  state.lastUsage = undefined; // token 数已变,旧 usage 失效
817
837
  if (microcompactDone) {
818
838
  if (!layout.isLastContentRowBlank())
819
839
  layout.contentWrite('\n');
820
- layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}微压缩旧工具结果${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
840
+ layout.contentWrite(compactionLogLine('微压缩旧工具结果', estimateBefore, estimateAfter, opts.window));
821
841
  return {
822
842
  compacted: true,
823
843
  summarized: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.5.3",
3
+ "version": "1.5.4",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 25 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {