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.
- package/dist/context/token-calibration.js +23 -2
- package/dist/i18n/index.js +2 -0
- package/dist/repl/status-bar.js +16 -2
- package/dist/session/compact.js +32 -12
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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];
|
package/dist/i18n/index.js
CHANGED
|
@@ -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}',
|
package/dist/repl/status-bar.js
CHANGED
|
@@ -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
|
* "还剩多少空间",两条职责分开。 */
|
package/dist/session/compact.js
CHANGED
|
@@ -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 =
|
|
570
|
-
state.lastEstimate =
|
|
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 =
|
|
692
|
-
state.lastEstimate =
|
|
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(
|
|
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 =
|
|
784
|
-
state.lastEstimate =
|
|
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(
|
|
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 =
|
|
815
|
-
state.lastEstimate =
|
|
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(
|
|
840
|
+
layout.contentWrite(compactionLogLine('微压缩旧工具结果', estimateBefore, estimateAfter, opts.window));
|
|
821
841
|
return {
|
|
822
842
|
compacted: true,
|
|
823
843
|
summarized: false,
|