compressed-context 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/package.json +64 -6
- package/src/compression/active-summary-cache.ts +50 -0
- package/src/compression/adopt.ts +57 -0
- package/src/compression/auto.ts +372 -0
- package/src/compression/babel.ts +85 -0
- package/src/compression/block.ts +25 -0
- package/src/compression/cache-history.ts +75 -0
- package/src/compression/compaction.ts +189 -0
- package/src/compression/decompress-tool.ts +74 -0
- package/src/compression/decompress.ts +292 -0
- package/src/compression/deleted-sessions.ts +24 -0
- package/src/compression/distill.ts +220 -0
- package/src/compression/events.ts +275 -0
- package/src/compression/markdown.ts +53 -0
- package/src/compression/normalize.ts +114 -0
- package/src/compression/render.ts +70 -0
- package/src/compression/schema.ts +84 -0
- package/src/compression/select.ts +123 -0
- package/src/compression/session-message.ts +112 -0
- package/src/compression/settings-rpc.ts +111 -0
- package/src/compression/settings.ts +175 -0
- package/src/compression/state.ts +248 -0
- package/src/compression/summarize.ts +595 -0
- package/src/compression/summary.ts +73 -0
- package/src/compression/tool.ts +142 -0
- package/src/compression/transcript-store.ts +258 -0
- package/src/compression/transcript.ts +63 -0
- package/src/compression/validate.ts +81 -0
- package/src/i18n/en.ts +93 -0
- package/src/i18n/index.ts +28 -0
- package/src/i18n/types.ts +89 -0
- package/src/i18n/zh.ts +93 -0
- package/src/index.ts +108 -0
- package/src/shared/blocks.ts +17 -0
- package/src/shared/cache.ts +199 -0
- package/src/shared/format.ts +23 -0
- package/src/shared/media.ts +184 -0
- package/src/shared/parse.ts +25 -0
- package/src/shared/parts.ts +72 -0
- package/src/shared/rpc.ts +145 -0
- package/src/shared/scan.ts +32 -0
- package/src/shared/settings.ts +471 -0
- package/src/shared/stats.ts +142 -0
- package/src/shared/tokens.ts +259 -0
- package/src/tui/chart/cache-chart.ts +237 -0
- package/src/tui/chart/color.ts +58 -0
- package/src/tui/index.tsx +111 -0
- package/src/tui/panel/cache.tsx +203 -0
- package/src/tui/panel/data.ts +105 -0
- package/src/tui/panel/index.tsx +176 -0
- package/src/tui/panel/summaries.tsx +192 -0
- package/src/tui/settings/dialogs.tsx +215 -0
- package/src/tui/settings/options.ts +238 -0
- package/src/tui/settings.tsx +418 -0
- package/src/tui/types.ts +4 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { CategoryKey } from "../shared/stats.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 面板文案。
|
|
5
|
+
*
|
|
6
|
+
* 源码里只引用键名,所有用户可见文字都来自语言文件。
|
|
7
|
+
*/
|
|
8
|
+
export interface Messages {
|
|
9
|
+
readonly title: string
|
|
10
|
+
readonly labels: Readonly<Record<CategoryKey, string>>
|
|
11
|
+
readonly approximate: string
|
|
12
|
+
readonly terminal: string
|
|
13
|
+
readonly cacheTitle: string
|
|
14
|
+
readonly cacheChart: string
|
|
15
|
+
readonly cacheLog: string
|
|
16
|
+
readonly cacheInput: string
|
|
17
|
+
readonly cacheOutput: string
|
|
18
|
+
readonly cacheCached: string
|
|
19
|
+
readonly cacheHit: string
|
|
20
|
+
readonly summariesTitle: string
|
|
21
|
+
readonly noData: string
|
|
22
|
+
readonly command: {
|
|
23
|
+
readonly title: string
|
|
24
|
+
readonly description: string
|
|
25
|
+
readonly group: string
|
|
26
|
+
}
|
|
27
|
+
readonly settings: {
|
|
28
|
+
readonly title: string
|
|
29
|
+
readonly show: string
|
|
30
|
+
readonly limit: string
|
|
31
|
+
readonly limitAuto: string
|
|
32
|
+
readonly limitCustom: string
|
|
33
|
+
readonly limitHint: string
|
|
34
|
+
readonly limitInvalid: string
|
|
35
|
+
readonly limitPlaceholder: string
|
|
36
|
+
readonly limitPreview: (value: string) => string
|
|
37
|
+
readonly limitCapped: (requested: string, actual: string) => string
|
|
38
|
+
readonly threshold: string
|
|
39
|
+
readonly thresholdHint: string
|
|
40
|
+
readonly thresholdInvalid: string
|
|
41
|
+
readonly thresholdPreview: (value: string) => string
|
|
42
|
+
readonly nudgeGrowth: string
|
|
43
|
+
readonly nudgeGrowthHint: string
|
|
44
|
+
readonly nudgeGrowthInvalid: string
|
|
45
|
+
readonly summaryBlockPercent: string
|
|
46
|
+
readonly summaryBlockPercentHint: string
|
|
47
|
+
readonly summaryTotalPercent: string
|
|
48
|
+
readonly summaryTotalPercentHint: string
|
|
49
|
+
readonly summaryPercentInvalid: string
|
|
50
|
+
readonly windowKnown: (value: string) => string
|
|
51
|
+
readonly windowUnknown: string
|
|
52
|
+
readonly summaryLanguage: string
|
|
53
|
+
readonly summaryFormat: string
|
|
54
|
+
readonly summaryFormatJson: string
|
|
55
|
+
readonly summaryFormatMarkdown: string
|
|
56
|
+
readonly summaryFormatBabel: string
|
|
57
|
+
readonly autoCompress: string
|
|
58
|
+
readonly enableCompressTool: string
|
|
59
|
+
readonly enableDecompressTool: string
|
|
60
|
+
readonly showTimeline: string
|
|
61
|
+
readonly showBlockTokens: string
|
|
62
|
+
readonly showCacheChart: string
|
|
63
|
+
readonly showCacheLog: string
|
|
64
|
+
readonly showSummaries: string
|
|
65
|
+
readonly categories: {
|
|
66
|
+
readonly compress: string
|
|
67
|
+
readonly summary: string
|
|
68
|
+
readonly panel: string
|
|
69
|
+
readonly decompress: string
|
|
70
|
+
readonly interface: string
|
|
71
|
+
}
|
|
72
|
+
readonly decompressInlineMax: string
|
|
73
|
+
readonly decompressInlineMaxHint: string
|
|
74
|
+
readonly decompressInlineMaxInvalid: string
|
|
75
|
+
readonly decompressTempCap: string
|
|
76
|
+
readonly decompressTempCapHint: string
|
|
77
|
+
readonly decompressTempCapInvalid: string
|
|
78
|
+
readonly decompressShowTokens: string
|
|
79
|
+
readonly submit: string
|
|
80
|
+
readonly language: string
|
|
81
|
+
readonly auto: string
|
|
82
|
+
readonly on: string
|
|
83
|
+
readonly off: string
|
|
84
|
+
}
|
|
85
|
+
readonly languages: {
|
|
86
|
+
readonly zh: string
|
|
87
|
+
readonly en: string
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/i18n/zh.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Messages } from "./types.ts"
|
|
2
|
+
|
|
3
|
+
/** 中文文案。标签控制在 4 个字以内,侧栏窄也能放下。 */
|
|
4
|
+
export const zh: Messages = {
|
|
5
|
+
title: "上下文构成",
|
|
6
|
+
labels: {
|
|
7
|
+
system: "系统",
|
|
8
|
+
tools: "工具定义",
|
|
9
|
+
tool: "工具结果",
|
|
10
|
+
text: "文本",
|
|
11
|
+
recent: "最近保留",
|
|
12
|
+
summaries: "摘要",
|
|
13
|
+
media: "媒体",
|
|
14
|
+
},
|
|
15
|
+
approximate: "≈",
|
|
16
|
+
terminal: "上下文无法继续压缩,请开新会话或使用原生压缩",
|
|
17
|
+
cacheTitle: "缓存记录",
|
|
18
|
+
cacheChart: "折线图",
|
|
19
|
+
cacheLog: "日志",
|
|
20
|
+
cacheInput: "输入",
|
|
21
|
+
cacheOutput: "输出",
|
|
22
|
+
cacheCached: "缓存",
|
|
23
|
+
cacheHit: "命中",
|
|
24
|
+
summariesTitle: "摘要块",
|
|
25
|
+
noData: "—",
|
|
26
|
+
command: {
|
|
27
|
+
title: "压缩上下文设置",
|
|
28
|
+
description: "打开插件设置",
|
|
29
|
+
group: "插件",
|
|
30
|
+
},
|
|
31
|
+
settings: {
|
|
32
|
+
title: "压缩上下文插件设置",
|
|
33
|
+
show: "显示面板",
|
|
34
|
+
limit: "上下文上限",
|
|
35
|
+
limitAuto: "模型原生",
|
|
36
|
+
limitCustom: "自定义上限…",
|
|
37
|
+
limitHint: "填的是上限;实际窗口取「设置值」和「模型原生窗口」中较小的那个。纯数字或带 k/m 后缀",
|
|
38
|
+
limitInvalid: "请输入有效的 token 数",
|
|
39
|
+
limitPlaceholder: "200k",
|
|
40
|
+
limitPreview: (value) => `= ${value} tokens`,
|
|
41
|
+
limitCapped: (requested, actual) => `${requested}(模型上限 ${actual})`,
|
|
42
|
+
threshold: "压缩阈值",
|
|
43
|
+
thresholdHint: "输入 60 ~ 95 之间的整数(百分比)",
|
|
44
|
+
thresholdInvalid: "阈值必须在 60 ~ 95 之间",
|
|
45
|
+
thresholdPreview: (value) => `= ${value}%`,
|
|
46
|
+
nudgeGrowth: "压缩提醒间隔",
|
|
47
|
+
nudgeGrowthHint: "上下文每增长这么多 token 提醒模型可主动压缩;0 关闭",
|
|
48
|
+
nudgeGrowthInvalid: "请输入 0,或 1k ~ 200k",
|
|
49
|
+
summaryBlockPercent: "单条摘要上限",
|
|
50
|
+
summaryBlockPercentHint: "单条摘要最多占窗口的百分比;可输入 10% 或 20k",
|
|
51
|
+
summaryTotalPercent: "全部摘要上限",
|
|
52
|
+
summaryTotalPercentHint: "所有摘要加起来最多占窗口的百分比;可输入 50% 或 100k",
|
|
53
|
+
summaryPercentInvalid: "请输入 1% ~ 90%,或一个能换算成百分比的 token 数",
|
|
54
|
+
windowKnown: (value) => `当前窗口:${value}`,
|
|
55
|
+
windowUnknown: "当前窗口未知(先跑一次模型请求)",
|
|
56
|
+
summaryLanguage: "摘要语言",
|
|
57
|
+
summaryFormat: "摘要格式",
|
|
58
|
+
summaryFormatJson: "JSON(结构化)",
|
|
59
|
+
summaryFormatMarkdown: "Markdown(自然语言)",
|
|
60
|
+
summaryFormatBabel: "Babel(模型间高密度)",
|
|
61
|
+
autoCompress: "自动压缩",
|
|
62
|
+
enableCompressTool: "compress 工具",
|
|
63
|
+
enableDecompressTool: "decompress 工具",
|
|
64
|
+
showTimeline: "块头时间线",
|
|
65
|
+
showBlockTokens: "块头 token 估算",
|
|
66
|
+
showCacheChart: "显示缓存折线图",
|
|
67
|
+
showCacheLog: "显示缓存日志",
|
|
68
|
+
showSummaries: "显示摘要块",
|
|
69
|
+
categories: {
|
|
70
|
+
compress: "压缩",
|
|
71
|
+
summary: "摘要",
|
|
72
|
+
panel: "面板",
|
|
73
|
+
decompress: "解压",
|
|
74
|
+
interface: "界面",
|
|
75
|
+
},
|
|
76
|
+
decompressInlineMax: "解压内联上限",
|
|
77
|
+
decompressInlineMaxHint: "超过这个字符数就写成临时文件;输入纯数字或带 k/m 后缀",
|
|
78
|
+
decompressInlineMaxInvalid: "请输入 1k ~ 1m 之间的字符数",
|
|
79
|
+
decompressTempCap: "解压临时文件上限",
|
|
80
|
+
decompressTempCapHint: "最多保留多少个解压临时文件",
|
|
81
|
+
decompressTempCapInvalid: "请输入 1 ~ 1000 之间的整数",
|
|
82
|
+
decompressShowTokens: "解压返回 token 估算",
|
|
83
|
+
submit: "提交",
|
|
84
|
+
language: "语言",
|
|
85
|
+
auto: "自动",
|
|
86
|
+
on: "开",
|
|
87
|
+
off: "关",
|
|
88
|
+
},
|
|
89
|
+
languages: {
|
|
90
|
+
zh: "中文",
|
|
91
|
+
en: "English",
|
|
92
|
+
},
|
|
93
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
|
|
3
|
+
import { maybeAutoCompress, maybeAutoCompressStep, maybeNudge } from "./compression/auto.ts"
|
|
4
|
+
import { recordCacheHistoryStep } from "./compression/cache-history.ts"
|
|
5
|
+
import { adoptNativeCheckpoint } from "./compression/adopt.ts"
|
|
6
|
+
import { handleCompaction } from "./compression/compaction.ts"
|
|
7
|
+
import { reconcileAll, startSessionEvents } from "./compression/events.ts"
|
|
8
|
+
import { registerSettingsRpc, type Disposable } from "./compression/settings-rpc.ts"
|
|
9
|
+
import { loadState } from "./compression/state.ts"
|
|
10
|
+
import { registerCompressTool } from "./compression/tool.ts"
|
|
11
|
+
import { registerDecompressTool } from "./compression/decompress-tool.ts"
|
|
12
|
+
import { createStats, mergeMetadata, shouldPublish, type ContextStats } from "./shared/stats.ts"
|
|
13
|
+
import { estimateCategories } from "./shared/tokens.ts"
|
|
14
|
+
|
|
15
|
+
export default Plugin.define({
|
|
16
|
+
id: "compressed-context",
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 输入:OpenCode 注入的插件上下文 ctx。
|
|
20
|
+
* 输出:注册 compress 工具、设置 RPC、context / compaction 钩子与事件订阅。
|
|
21
|
+
* 预期:块链通过 compaction 落盘;失败不破坏历史。
|
|
22
|
+
*/
|
|
23
|
+
async setup(ctx) {
|
|
24
|
+
const published = new Map<string, ContextStats>()
|
|
25
|
+
const disposables: Disposable[] = []
|
|
26
|
+
|
|
27
|
+
/** 输入:sessionID;输出:无;预期:会话空闲/删除时清掉进程内统计缓存。 */
|
|
28
|
+
const forgetMemory = (sessionID: string): void => {
|
|
29
|
+
published.delete(sessionID)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const publishStats = async (sessionID: string, stats: ContextStats): Promise<void> => {
|
|
33
|
+
const session = await ctx.session.get({ sessionID })
|
|
34
|
+
await ctx.session.update({
|
|
35
|
+
sessionID,
|
|
36
|
+
metadata: mergeMetadata(session.metadata, stats),
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
disposables.push(await registerCompressTool(ctx))
|
|
41
|
+
disposables.push(await registerDecompressTool(ctx))
|
|
42
|
+
disposables.push(await registerSettingsRpc(ctx))
|
|
43
|
+
const stopEvents = startSessionEvents(ctx, {
|
|
44
|
+
onSessionIdle: forgetMemory,
|
|
45
|
+
onSessionDeleted: forgetMemory,
|
|
46
|
+
onStepEnded: ({ sessionID, messageID, tokens }) =>
|
|
47
|
+
void (async () => {
|
|
48
|
+
await recordCacheHistoryStep(ctx, sessionID, { messageID, tokens })
|
|
49
|
+
await maybeAutoCompressStep({ ctx, sessionID, tokens })
|
|
50
|
+
})(),
|
|
51
|
+
})
|
|
52
|
+
void reconcileAll(ctx)
|
|
53
|
+
|
|
54
|
+
disposables.push(
|
|
55
|
+
await ctx.session.hook("context", async (event) => {
|
|
56
|
+
await adoptNativeCheckpoint(ctx, event.sessionID, event.messages)
|
|
57
|
+
|
|
58
|
+
await maybeNudge({
|
|
59
|
+
ctx,
|
|
60
|
+
sessionID: event.sessionID,
|
|
61
|
+
messages: event.messages,
|
|
62
|
+
system: event.system,
|
|
63
|
+
tools: event.tools,
|
|
64
|
+
model: event.model,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const stats = createStats(
|
|
68
|
+
estimateCategories({
|
|
69
|
+
system: event.system,
|
|
70
|
+
tools: event.tools,
|
|
71
|
+
messages: event.messages,
|
|
72
|
+
}),
|
|
73
|
+
Date.now(),
|
|
74
|
+
)
|
|
75
|
+
if (shouldPublish(published.get(event.sessionID), stats)) {
|
|
76
|
+
published.set(event.sessionID, stats)
|
|
77
|
+
void publishStats(event.sessionID, stats).catch(() => undefined)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await maybeAutoCompress({
|
|
81
|
+
ctx,
|
|
82
|
+
sessionID: event.sessionID,
|
|
83
|
+
messages: event.messages,
|
|
84
|
+
system: event.system,
|
|
85
|
+
tools: event.tools,
|
|
86
|
+
model: event.model,
|
|
87
|
+
})
|
|
88
|
+
}),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
disposables.push(
|
|
92
|
+
await ctx.session.hook("compaction", (event) => handleCompaction(ctx, event)),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
// 终止后拒绝一切新输入:把真实原因直接抛给宿主,TUI 会显示这条错误。
|
|
96
|
+
disposables.push(
|
|
97
|
+
await ctx.session.hook("prompt", async (event) => {
|
|
98
|
+
const state = await loadState(ctx.storage, event.sessionID)
|
|
99
|
+
if (state.terminal !== undefined) throw new Error(state.terminal)
|
|
100
|
+
}),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return () => {
|
|
104
|
+
stopEvents()
|
|
105
|
+
for (const disposable of disposables) void disposable.dispose()
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 摘要块的跨进程 DTO。
|
|
3
|
+
*
|
|
4
|
+
* 服务端把 active 链渲染成 body,TUI 只负责展示。
|
|
5
|
+
*/
|
|
6
|
+
export interface SummaryBlock {
|
|
7
|
+
readonly id: string
|
|
8
|
+
readonly tier: 1 | 2 | 3
|
|
9
|
+
/** 摘要格式;Babel 正文不能按 Markdown 渲染。 */
|
|
10
|
+
readonly format: "json" | "markdown" | "babel"
|
|
11
|
+
readonly title: string
|
|
12
|
+
readonly at: number
|
|
13
|
+
readonly tokensBefore: number
|
|
14
|
+
readonly tokensAfter: number
|
|
15
|
+
readonly sourceBlockIds?: readonly string[]
|
|
16
|
+
readonly body: string
|
|
17
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 缓存命中历史的跨进程载体。
|
|
3
|
+
*
|
|
4
|
+
* 服务端插件在 session.idle 时把最近 10 条 assistant 消息的 usage 追加到
|
|
5
|
+
* storage(`cache:history:<sessionID>`);TUI 通过 RPC 读取。
|
|
6
|
+
* 本模块只放纯函数:采样、追加、校验、排序,方便单测。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** 每个会话一份,key 里带 sessionID,父/子会话互不影响。 */
|
|
10
|
+
export const CACHE_HISTORY_PREFIX = "cache:history:"
|
|
11
|
+
|
|
12
|
+
/** 只保留最近 10 次请求。 */
|
|
13
|
+
export const CACHE_HISTORY_LIMIT = 10
|
|
14
|
+
|
|
15
|
+
/** 一条缓存命中样本;hitRate 是 0..100,向下截断到两位小数。 */
|
|
16
|
+
export interface CacheSample {
|
|
17
|
+
readonly messageID: string
|
|
18
|
+
readonly at: number
|
|
19
|
+
readonly input: number
|
|
20
|
+
readonly output: number
|
|
21
|
+
readonly cached: number
|
|
22
|
+
readonly hitRate: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** storage 里的持久化结构。 */
|
|
26
|
+
export interface CacheHistory {
|
|
27
|
+
readonly v: 1
|
|
28
|
+
readonly samples: readonly CacheSample[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const EMPTY_CACHE_HISTORY: CacheHistory = { v: 1, samples: [] }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 输入:总输入 token + 命中 token。
|
|
35
|
+
* 输出:0..100 的百分比,向下截断到两位小数。
|
|
36
|
+
* 预期:不四舍五入;99.999% 显示 99.99,不会变成 100.00。
|
|
37
|
+
*/
|
|
38
|
+
export function hitRatePercent(input: number, cached: number): number {
|
|
39
|
+
if (input <= 0) return 0
|
|
40
|
+
return Math.floor(Math.min(1, cached / input) * 10_000) / 100
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 输入:命中率;输出:固定两位小数的百分比文本(不含 `%`)。 */
|
|
44
|
+
export function formatHitRate(hitRate: number): string {
|
|
45
|
+
return hitRate.toFixed(2)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 输入:sessionID;输出:该会话的 storage key。 */
|
|
49
|
+
export function cacheHistoryKey(sessionID: string): string {
|
|
50
|
+
return CACHE_HISTORY_PREFIX + sessionID
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 输入:消息(结构最小化,便于测试替身)。
|
|
55
|
+
* 输出:assistant 且带 tokens 时返回样本,否则 undefined。
|
|
56
|
+
*/
|
|
57
|
+
export interface CacheMessage {
|
|
58
|
+
readonly id: string
|
|
59
|
+
readonly type: string
|
|
60
|
+
readonly time?: { readonly created?: number }
|
|
61
|
+
readonly tokens?: {
|
|
62
|
+
readonly input: number
|
|
63
|
+
readonly output: number
|
|
64
|
+
readonly reasoning: number
|
|
65
|
+
readonly cache: { readonly read: number; readonly write: number }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 输入:消息 + 缺时间时的回退值;输出:样本或 undefined。 */
|
|
70
|
+
export function sampleFromMessage(message: CacheMessage, fallbackAt: number): CacheSample | undefined {
|
|
71
|
+
if (message.type !== "assistant") return undefined
|
|
72
|
+
const tokens = message.tokens
|
|
73
|
+
if (tokens === undefined) return undefined
|
|
74
|
+
const input = tokens.input + tokens.cache.read + tokens.cache.write
|
|
75
|
+
const output = tokens.output + tokens.reasoning
|
|
76
|
+
const cached = tokens.cache.read
|
|
77
|
+
return {
|
|
78
|
+
messageID: message.id,
|
|
79
|
+
at: message.time?.created ?? fallbackAt,
|
|
80
|
+
input,
|
|
81
|
+
output,
|
|
82
|
+
cached,
|
|
83
|
+
hitRate: hitRatePercent(input, cached),
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 输入:样本列表;输出:按时间升序、去重、只留最新 10 条。 */
|
|
88
|
+
function normalizeSamples(samples: readonly CacheSample[]): readonly CacheSample[] {
|
|
89
|
+
const unique = new Map<string, CacheSample>()
|
|
90
|
+
for (const sample of samples) {
|
|
91
|
+
// 同一条消息重复出现时保留第一次记录,避免重试把同一条刷成多条。
|
|
92
|
+
if (!unique.has(sample.messageID)) unique.set(sample.messageID, sample)
|
|
93
|
+
}
|
|
94
|
+
return [...unique.values()]
|
|
95
|
+
.sort((left, right) => left.at - right.at || left.messageID.localeCompare(right.messageID))
|
|
96
|
+
.slice(-CACHE_HISTORY_LIMIT)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 输入:已有历史 + 当前可见消息。
|
|
101
|
+
* 输出:追加未记录过的 assistant 样本后的新历史。
|
|
102
|
+
* 预期:按 messageID 去重;按时间排序;只保留最新 10 条。
|
|
103
|
+
*/
|
|
104
|
+
export function appendCacheSamples(
|
|
105
|
+
existing: readonly CacheSample[],
|
|
106
|
+
messages: readonly CacheMessage[],
|
|
107
|
+
now: number = Date.now(),
|
|
108
|
+
): readonly CacheSample[] {
|
|
109
|
+
const known = new Set(existing.map((sample) => sample.messageID))
|
|
110
|
+
const added: CacheSample[] = []
|
|
111
|
+
for (const message of messages) {
|
|
112
|
+
if (known.has(message.id)) continue
|
|
113
|
+
const sample = sampleFromMessage(message, now)
|
|
114
|
+
if (sample === undefined) continue
|
|
115
|
+
known.add(sample.messageID)
|
|
116
|
+
added.push(sample)
|
|
117
|
+
}
|
|
118
|
+
return normalizeSamples([...existing, ...added])
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 输入:已有历史 + 单个 step 的 assistant usage。
|
|
123
|
+
* 输出:追加该 step 样本后的新历史。
|
|
124
|
+
* 预期:tool loop 里 context/usage.updated 拿不到最新 step usage,
|
|
125
|
+
* `session.step.ended` 事件直接携带 tokens,这里按 messageID 去重落一条。
|
|
126
|
+
*/
|
|
127
|
+
export function appendCacheSampleFromStep(
|
|
128
|
+
existing: readonly CacheSample[],
|
|
129
|
+
messageID: string,
|
|
130
|
+
tokens: {
|
|
131
|
+
readonly input: number
|
|
132
|
+
readonly output: number
|
|
133
|
+
readonly reasoning: number
|
|
134
|
+
readonly cache: { readonly read: number; readonly write: number }
|
|
135
|
+
},
|
|
136
|
+
fallbackAt: number = Date.now(),
|
|
137
|
+
): readonly CacheSample[] {
|
|
138
|
+
return appendCacheSamples(existing, [{ id: messageID, type: "assistant", tokens }], fallbackAt)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 输入:任意 storage 值;输出:合法历史;损坏时返回空历史,绝不抛错。hitRate 按 input/cached 重算。 */
|
|
142
|
+
export function parseCacheHistory(value: unknown): CacheHistory {
|
|
143
|
+
if (typeof value !== "object" || value === null) return EMPTY_CACHE_HISTORY
|
|
144
|
+
const record = value as Record<string, unknown>
|
|
145
|
+
if (record["v"] !== 1 || !Array.isArray(record["samples"])) return EMPTY_CACHE_HISTORY
|
|
146
|
+
const samples: CacheSample[] = []
|
|
147
|
+
for (const item of record["samples"]) {
|
|
148
|
+
if (typeof item !== "object" || item === null) continue
|
|
149
|
+
const sample = item as Record<string, unknown>
|
|
150
|
+
const messageID = sample["messageID"]
|
|
151
|
+
const at = sample["at"]
|
|
152
|
+
const input = sample["input"]
|
|
153
|
+
const output = sample["output"]
|
|
154
|
+
const cached = sample["cached"]
|
|
155
|
+
if (typeof messageID !== "string" || messageID.length === 0) continue
|
|
156
|
+
if (
|
|
157
|
+
typeof at !== "number" ||
|
|
158
|
+
!Number.isFinite(at) ||
|
|
159
|
+
at < 0 ||
|
|
160
|
+
typeof input !== "number" ||
|
|
161
|
+
!Number.isFinite(input) ||
|
|
162
|
+
input < 0 ||
|
|
163
|
+
typeof output !== "number" ||
|
|
164
|
+
!Number.isFinite(output) ||
|
|
165
|
+
output < 0 ||
|
|
166
|
+
typeof cached !== "number" ||
|
|
167
|
+
!Number.isFinite(cached) ||
|
|
168
|
+
cached < 0
|
|
169
|
+
) {
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
samples.push({
|
|
173
|
+
messageID,
|
|
174
|
+
at,
|
|
175
|
+
input,
|
|
176
|
+
output,
|
|
177
|
+
cached,
|
|
178
|
+
hitRate: hitRatePercent(input, cached),
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
return { v: 1, samples: normalizeSamples(samples) }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** 输入:两条历史;输出:样本是否完全一致;用于避免无变化的重复写盘。 */
|
|
185
|
+
export function cacheHistoryEquals(left: readonly CacheSample[], right: readonly CacheSample[]): boolean {
|
|
186
|
+
if (left.length !== right.length) return false
|
|
187
|
+
return left.every((sample, index) => {
|
|
188
|
+
const other = right[index]
|
|
189
|
+
if (other === undefined) return false
|
|
190
|
+
return (
|
|
191
|
+
sample.messageID === other.messageID &&
|
|
192
|
+
sample.at === other.at &&
|
|
193
|
+
sample.input === other.input &&
|
|
194
|
+
sample.output === other.output &&
|
|
195
|
+
sample.cached === other.cached &&
|
|
196
|
+
sample.hitRate === other.hitRate
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板数字格式化。
|
|
3
|
+
*
|
|
4
|
+
* 输入:token 数量(非负整数)。
|
|
5
|
+
* 输出:紧凑字符串:<1000 原样、<10000 一位小数 k、<1e6 取整 k、
|
|
6
|
+
* <1e7 一位小数 M、更大取整 M。
|
|
7
|
+
* 预期:侧栏一行内放得下。
|
|
8
|
+
*/
|
|
9
|
+
export function formatTokens(count: number): string {
|
|
10
|
+
if (!Number.isFinite(count) || count <= 0) return "0"
|
|
11
|
+
if (count < 1_000) return count.toString()
|
|
12
|
+
if (count < 10_000) return `${(count / 1_000).toFixed(1)}k`
|
|
13
|
+
if (count < 1_000_000) return `${Math.round(count / 1_000).toString()}k`
|
|
14
|
+
if (count < 10_000_000) return `${(count / 1_000_000).toFixed(1)}M`
|
|
15
|
+
return `${Math.round(count / 1_000_000).toString()}M`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 输入:毫秒时间戳;输出:本地时间 `YYYY/MM/DD HH:mm`;预期:固定格式,不依赖 locale。 */
|
|
19
|
+
export function formatTimestamp(at: number): string {
|
|
20
|
+
const date = new Date(at)
|
|
21
|
+
const pad = (value: number): string => value.toString().padStart(2, "0")
|
|
22
|
+
return `${date.getFullYear().toString()}/${pad(date.getMonth() + 1)}/${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
23
|
+
}
|