dsh-plugin-lrc 0.2.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/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "dsh-plugin-lrc",
3
+ "version": "0.2.0",
4
+ "description": "把 LRC 的结构化长期记忆接进 DeepSeek Harness:边聊边沉淀、按需召回、可纠错,让上下文不再需要频繁压缩",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "files": [
8
+ "dist/index.js",
9
+ "src",
10
+ "cordis.patch.yml",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/zhibaiYingChuan/dsh-plugin-lrc.git"
17
+ },
18
+ "homepage": "https://github.com/zhibaiYingChuan/dsh-plugin-lrc#readme",
19
+ "bugs": {
20
+ "url": "https://github.com/zhibaiYingChuan/dsh-plugin-lrc/issues"
21
+ },
22
+ "scripts": {
23
+ "build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node20 --charset=utf8 --external:@deepseek-ai/* --outfile=dist/index.js",
24
+ "smoke": "node scripts/smoke.ts",
25
+ "e2e": "node scripts/e2e-offload.ts",
26
+ "e2e:runtime": "node scripts/e2e-runtime.ts",
27
+ "e2e:host": "node scripts/e2e-host-payload.ts",
28
+ "e2e:filter": "node scripts/e2e-filter.ts",
29
+ "e2e:trim": "node scripts/e2e-trim.ts",
30
+ "e2e:handoff": "node scripts/e2e-handoff.ts",
31
+ "e2e:apply": "node --import ./scripts/register-loader.mjs scripts/e2e-apply.ts",
32
+ "e2e:governor": "node scripts/e2e-governor.ts",
33
+ "e2e:budget": "node scripts/e2e-budget.ts",
34
+ "e2e:turn": "node --import ./scripts/register-loader.mjs scripts/e2e-turn.ts",
35
+ "e2e:invariant": "node scripts/e2e-invariant.ts",
36
+ "e2e:profile": "node scripts/e2e-profile.ts",
37
+ "test": "node scripts/smoke.ts && node scripts/e2e-offload.ts && node scripts/e2e-runtime.ts && node scripts/e2e-host-payload.ts && node scripts/e2e-filter.ts && node scripts/e2e-trim.ts && node scripts/e2e-host-surface.ts && node scripts/e2e-handoff.ts && node scripts/e2e-governor.ts && node scripts/e2e-budget.ts && node scripts/e2e-profile.ts && node scripts/e2e-invariant.ts && node --import ./scripts/register-loader.mjs scripts/e2e-apply.ts && node --import ./scripts/register-loader.mjs scripts/e2e-turn.ts"
38
+ },
39
+ "keywords": [
40
+ "dsh-plugin",
41
+ "deepseek-harness",
42
+ "memory",
43
+ "context",
44
+ "compaction"
45
+ ],
46
+ "dsh": {
47
+ "bundle": {
48
+ "patch": "./cordis.patch.yml"
49
+ }
50
+ },
51
+ "peerDependencies": {
52
+ "@deepseek-ai/cordis": "*",
53
+ "@deepseek-ai/dsh-tools": "*",
54
+ "@deepseek-ai/schemastery": "*"
55
+ },
56
+ "engines": {
57
+ "node": ">=20",
58
+ "dsh": ">=0.2.0-rc.1 <0.3.0-0"
59
+ },
60
+ "license": "MIT",
61
+ "devDependencies": {
62
+ "esbuild": "^0.28.2"
63
+ }
64
+ }
package/src/budget.ts ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * 统一预算:把散落的魔法数字收敛成「按模型窗口比例推导」。
3
+ *
4
+ * ## 为什么需要
5
+ *
6
+ * 之前 `trimMinTokens=60000` 是按某个默认模型拍的,换到 1M 窗口就变成 6%,
7
+ * 33K 的会话永远不触发;`pruneResultThreshold=8192` 同理。
8
+ * 阈值应该跟着模型窗口走,而不是写死。
9
+ *
10
+ * ## 推导规则
11
+ *
12
+ * ```
13
+ * trimMinTokens = clamp(window * trimRatio, 8000, 200000)
14
+ * pruneResultThreshold = clamp(window * pruneRatio, 2048, 8192)
15
+ * ```
16
+ *
17
+ * 窗口未知时退回配置里的绝对值,绝不因为探测失败而停摆。
18
+ */
19
+
20
+ /** 预算配置(来自插件配置)。 */
21
+ export interface BudgetConfig {
22
+ /** 显式指定模型窗口;0 表示自动探测 */
23
+ contextWindowTokens: number;
24
+ /** 裁剪阈值占窗口的比例 */
25
+ trimRatio: number;
26
+ /** 工具输出剪枝阈值占窗口的比例 */
27
+ pruneRatio: number;
28
+ /** 窗口未知时的裁剪阈值(绝对值) */
29
+ trimMinTokens: number;
30
+ /** 窗口未知时的剪枝阈值(绝对值) */
31
+ pruneResultThreshold: number;
32
+ }
33
+
34
+ /** 解析后的预算。 */
35
+ export interface ResolvedBudget {
36
+ /** 模型窗口;0 表示未知 */
37
+ windowTokens: number;
38
+ trimMinTokens: number;
39
+ pruneResultThreshold: number;
40
+ /** auto = 从模型探测;configured = 显式配置;fallback = 探测失败用绝对值 */
41
+ source: 'auto' | 'configured' | 'fallback';
42
+ /** 探测到的模型名(诊断用;fallback 时为空) */
43
+ model?: string;
44
+ /** 降级原因(诊断用) */
45
+ note?: string;
46
+ }
47
+
48
+ /** 窗口的合理下限:低于 8K 视为探测异常。 */
49
+ const MIN_SANE_WINDOW = 8_192;
50
+ /**
51
+ * 可信窗口上限(不含)。
52
+ *
53
+ * 探测值达到这个数就**不再当作比例基线**:实测宿主对未知模型会返回
54
+ * 1,000,000 这个哨兵值,而真实会话只有 80K —— 按 1M 推导出的
55
+ * `trim=30000`(3%)看似能用,但分母是错的,任何比例缩放都失去意义。
56
+ *
57
+ * 降级后的绝对值(30000/4096)是实测可用的保守档,方向安全:宁可早裁,不可溢出。
58
+ *
59
+ * ⚠️ 2026-11-08 实测修正:`deepseek-v4.1-flash` 的 contextWindow 确实是
60
+ * 1,048,576,探测到的 1,000,000 是**真实窗口**而非哨兵值。也就是说这条规则
61
+ * 会把「真实 1M 窗口」误判为「未知模型」。当前不造成危害——降级值 30000
62
+ * 恰好落在成本 U 形曲线的低成本区(最优点约 53K,仅差 11%),且方向偏保守。
63
+ * 若将来要按真实窗口推导,应显式配置 `contextWindowTokens`,而不是依赖探测。
64
+ */
65
+ const MAX_TRUSTED_WINDOW = 1_000_000;
66
+
67
+ function clamp(value: number, min: number, max: number): number {
68
+ return Math.min(max, Math.max(min, value));
69
+ }
70
+
71
+ function safeGet(ctx: unknown, name: string): unknown {
72
+ try {
73
+ return (ctx as { get?: (n: string) => unknown })?.get?.(name);
74
+ } catch {
75
+ return undefined;
76
+ }
77
+ }
78
+
79
+ /** 从会话的请求头里取当前路由的 provider/model。 */
80
+ function routedTarget(session: unknown): { provider: string; model: string } | undefined {
81
+ const config = (session as { requestHeader?: () => { config?: { provider?: unknown; model?: unknown } } })
82
+ ?.requestHeader?.()?.config;
83
+ const provider = config?.provider;
84
+ const model = config?.model;
85
+ if (typeof provider !== 'string' || typeof model !== 'string') return undefined;
86
+ if (provider.length === 0 || model.length === 0) return undefined;
87
+ return { provider, model };
88
+ }
89
+
90
+ /**
91
+ * 解析预算。
92
+ *
93
+ * 永不抛错:探测失败一律退回配置绝对值。
94
+ *
95
+ * @param deps - 宿主 ctx、agent、预算配置
96
+ * @returns 解析后的预算
97
+ */
98
+ export async function resolveBudget(deps: {
99
+ ctx: unknown;
100
+ agent: unknown;
101
+ config: BudgetConfig;
102
+ }): Promise<ResolvedBudget> {
103
+ const { ctx, agent, config } = deps;
104
+
105
+ // 显式配置优先
106
+ if (config.contextWindowTokens > 0) {
107
+ return derive(config.contextWindowTokens, config, 'configured');
108
+ }
109
+
110
+ // 自动探测:llm.resolveModelInfo(provider, model)
111
+ const session = (agent as { session?: unknown })?.session;
112
+ const target = routedTarget(session);
113
+ if (target === undefined) {
114
+ return {
115
+ windowTokens: 0,
116
+ trimMinTokens: config.trimMinTokens,
117
+ pruneResultThreshold: config.pruneResultThreshold,
118
+ source: 'fallback',
119
+ };
120
+ }
121
+
122
+ const llm = safeGet(ctx, 'llm') as
123
+ | {
124
+ resolveModelInfo?: (
125
+ provider: string,
126
+ model: string,
127
+ ) => Promise<{ context?: { contextWindow?: unknown } }>;
128
+ }
129
+ | undefined;
130
+ if (typeof llm?.resolveModelInfo !== 'function') {
131
+ return {
132
+ windowTokens: 0,
133
+ trimMinTokens: config.trimMinTokens,
134
+ pruneResultThreshold: config.pruneResultThreshold,
135
+ source: 'fallback',
136
+ };
137
+ }
138
+
139
+ try {
140
+ const info = await llm.resolveModelInfo(target.provider, target.model);
141
+ const window = info?.context?.contextWindow;
142
+ if (typeof window === 'number' && Number.isFinite(window) && window > 0) {
143
+ // 探测值达到上限 → 视为「未知模型的哨兵值」,退回绝对值。
144
+ // 不能钳到 1M 继续用:分母错了,所有按比例推导的阈值都失去意义。
145
+ if (window >= MAX_TRUSTED_WINDOW) {
146
+ return {
147
+ windowTokens: 0,
148
+ trimMinTokens: config.trimMinTokens,
149
+ pruneResultThreshold: config.pruneResultThreshold,
150
+ source: 'fallback',
151
+ model: target.model,
152
+ // 提示可显式配置:哨兵值无法区分「真实 1M 窗口」与「未知模型」,
153
+ // 若确知模型窗口,配置 contextWindowTokens 即可按真实窗口推导阈值。
154
+ note: `probe=${window} 达到可信上限,视为未知模型;如确知窗口请显式配置 contextWindowTokens`,
155
+ };
156
+ }
157
+ // 过小的探测值钳到下限,避免 trim 阈值等于整个窗口
158
+ const sane = Math.max(MIN_SANE_WINDOW, window);
159
+ return { ...derive(sane, config, 'auto'), model: target.model };
160
+ }
161
+ } catch {
162
+ // 探测失败:退回绝对值
163
+ }
164
+
165
+ return {
166
+ windowTokens: 0,
167
+ trimMinTokens: config.trimMinTokens,
168
+ pruneResultThreshold: config.pruneResultThreshold,
169
+ source: 'fallback',
170
+ };
171
+ }
172
+
173
+ function derive(
174
+ windowTokens: number,
175
+ config: BudgetConfig,
176
+ source: 'auto' | 'configured',
177
+ ): ResolvedBudget {
178
+ return {
179
+ windowTokens,
180
+ trimMinTokens: clamp(Math.floor(windowTokens * config.trimRatio), 8000, 200000),
181
+ pruneResultThreshold: clamp(Math.floor(windowTokens * config.pruneRatio), 2048, 8192),
182
+ source,
183
+ };
184
+ }
185
+
186
+ /** 一行可读摘要(启动日志用)。 */
187
+ export function formatBudget(budget: ResolvedBudget): string {
188
+ const window = budget.windowTokens > 0 ? `${budget.windowTokens} tokens` : '未知';
189
+ const model = budget.model === undefined ? '' : ` model=${budget.model}`;
190
+ const note = budget.note === undefined ? '' : ` note=${budget.note}`;
191
+ return (
192
+ `[lrc][budget] 窗口=${window}(${budget.source})${model}${note}` +
193
+ ` trim=${budget.trimMinTokens} tokens prune=${budget.pruneResultThreshold} 字符`
194
+ );
195
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * 能力矩阵:启动时探测一次,之后所有路径按矩阵决定「做不做」。
3
+ *
4
+ * ## 为什么需要
5
+ *
6
+ * 前几轮故障的共同形态是「调用时才发现不可用」:
7
+ * - `session.events` 在某些版本不存在 → trim 静默失效
8
+ * - `surfaceOp` 字段名 `start/end` vs `startSeq/endSeq` → 剪枝 100% 失败
9
+ * - `engine.ctx.tokenMeter` 直接属性访问 → inject 崩溃
10
+ *
11
+ * 把探测集中到一处,好处是:失败只发生一次、日志只打一次、
12
+ * 调用方拿到的是明确的「可用/不可用」而不是运行时异常。
13
+ *
14
+ * ## 探测原则
15
+ *
16
+ * 只读、不写、不抛错。任何一项探测失败都记为「不可用 + 原因」,
17
+ * 绝不让探测本身掀翻插件。
18
+ */
19
+ import { resolveSessionEvents } from './context-trim.ts';
20
+
21
+ /** 单项能力。 */
22
+ export interface Capability {
23
+ available: boolean;
24
+ /** 诊断细节:不可用时说明原因,可用时说明来源 */
25
+ detail: string;
26
+ }
27
+
28
+ /** surfaceOp 的字段名形状(不同 DSH 版本不一致)。 */
29
+ export type SurfaceOpShape = 'start-end' | 'startSeq-endSeq' | 'unknown';
30
+
31
+ /** 一次探测得到的完整能力矩阵。 */
32
+ export interface CapabilityMatrix {
33
+ /** 压缩引擎(路径 1 依赖) */
34
+ compaction: Capability;
35
+ /** token 计量(路径 1 的阈值判断依赖) */
36
+ tokenMeter: Capability;
37
+ /** 官方工具输出剪枝器(路径 2 依赖) */
38
+ toolResultPruner: Capability;
39
+ /** 会话存储(路径 6 兜底依赖) */
40
+ sessions: Capability;
41
+ /** agent 工厂(路径 6 首选依赖) */
42
+ agents: Capability;
43
+ /** 系统提示注册(快照注入依赖) */
44
+ systemPrompt: Capability;
45
+ /** replace 事件的字段名形状 */
46
+ surfaceOpShape: SurfaceOpShape;
47
+ /** 探测时间 */
48
+ probedAt: number;
49
+ }
50
+
51
+ function safeGet(ctx: unknown, name: string): unknown {
52
+ try {
53
+ return (ctx as { get?: (n: string) => unknown })?.get?.(name);
54
+ } catch {
55
+ return undefined;
56
+ }
57
+ }
58
+
59
+ function hasFn(value: unknown, name: string): boolean {
60
+ return typeof (value as Record<string, unknown> | undefined)?.[name] === 'function';
61
+ }
62
+
63
+ /** 从引擎的 ctx 取服务(引擎 ctx 在 preset 隔离组内,宿主层可能拿不到)。 */
64
+ function fromEngine(engine: unknown, name: string): unknown {
65
+ try {
66
+ return (engine as { ctx?: { get?: (n: string) => unknown } } | undefined)?.ctx?.get?.(name);
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * 探测 surfaceOp 的字段名形状。
74
+ *
75
+ * 扫描会话里已有的 replace 事件:有 `start` 就是 start-end,
76
+ * 有 `startSeq` 就是 startSeq-endSeq。都没有则 unknown(调用方两种都试)。
77
+ */
78
+ export function probeSurfaceOpShape(session: unknown): SurfaceOpShape {
79
+ const events = resolveSessionEvents(session);
80
+ if (events === undefined) return 'unknown';
81
+ for (const raw of events) {
82
+ const op = (raw as { surfaceOp?: unknown })?.surfaceOp;
83
+ if (op === null || typeof op !== 'object') continue;
84
+ const record = op as Record<string, unknown>;
85
+ if (record['op'] !== 'replace') continue;
86
+ if (Object.hasOwn(record, 'start') && Object.hasOwn(record, 'end')) return 'start-end';
87
+ if (Object.hasOwn(record, 'startSeq') && Object.hasOwn(record, 'endSeq')) return 'startSeq-endSeq';
88
+ }
89
+ return 'unknown';
90
+ }
91
+
92
+ /**
93
+ * 探测一次完整能力矩阵。
94
+ *
95
+ * @param ctx - 宿主上下文
96
+ * @param agent - 当前 agent(用于穿透 preset 隔离)
97
+ * @returns 能力矩阵(永不抛错)
98
+ */
99
+ export function probeCapabilities(ctx: unknown, agent: unknown): CapabilityMatrix {
100
+ const presets = safeGet(ctx, 'agentPresets') as
101
+ | { serviceFor?: (a: unknown, n: string) => unknown }
102
+ | undefined;
103
+
104
+ // 压缩引擎
105
+ let engine: unknown;
106
+ let engineError = '';
107
+ try {
108
+ engine = presets?.serviceFor?.(agent, 'compaction');
109
+ } catch (error) {
110
+ engineError = error instanceof Error ? error.message : String(error);
111
+ }
112
+ const compaction: Capability = {
113
+ available: hasFn(engine, 'compactRegion'),
114
+ detail:
115
+ engineError !== ''
116
+ ? `serviceFor 抛错:${engineError}`
117
+ : engine === undefined
118
+ ? 'serviceFor 返回 undefined'
119
+ : `引擎=${(engine as { constructor?: { name?: string } })?.constructor?.name ?? 'unknown'}`,
120
+ };
121
+
122
+ // token 计量:引擎 ctx 优先,宿主层兜底
123
+ const meter = fromEngine(engine, 'tokenMeter') ?? safeGet(ctx, 'tokenMeter');
124
+ const tokenMeter: Capability = {
125
+ available: hasFn(meter, 'measure'),
126
+ detail: meter === undefined ? 'tokenMeter 不可达' : 'measure 可用',
127
+ };
128
+
129
+ // 官方剪枝器:三条路
130
+ const pruner =
131
+ safeGet(ctx, 'toolResultPruner') ??
132
+ (() => {
133
+ try {
134
+ return presets?.serviceFor?.(agent, 'toolResultPruner');
135
+ } catch {
136
+ return undefined;
137
+ }
138
+ })() ??
139
+ fromEngine(engine, 'toolResultPruner');
140
+ const toolResultPruner: Capability = {
141
+ available: hasFn(pruner, 'pruneSession'),
142
+ detail: pruner === undefined ? 'toolResultPruner 不可达' : 'pruneSession 可用',
143
+ };
144
+
145
+ const sessions = safeGet(ctx, 'sessions');
146
+ const agents = safeGet(ctx, 'agents');
147
+ const systemPrompt = safeGet(ctx, 'systemPrompt');
148
+
149
+ return {
150
+ compaction,
151
+ tokenMeter,
152
+ toolResultPruner,
153
+ sessions: {
154
+ available: hasFn(sessions, 'create'),
155
+ detail: sessions === undefined ? 'sessions 不可达' : 'create 可用',
156
+ },
157
+ agents: {
158
+ available: hasFn(agents, 'create'),
159
+ detail: agents === undefined ? 'agents 不可达' : 'create 可用',
160
+ },
161
+ systemPrompt: {
162
+ available: hasFn(systemPrompt, 'section') || hasFn(systemPrompt, 'context'),
163
+ detail: systemPrompt === undefined ? 'systemPrompt 不可达' : 'section/context 可用',
164
+ },
165
+ surfaceOpShape: probeSurfaceOpShape((agent as { session?: unknown })?.session),
166
+ probedAt: Date.now(),
167
+ };
168
+ }
169
+
170
+ /** 把能力矩阵压成一行行日志(启动时打一次)。 */
171
+ export function formatCapabilities(matrix: CapabilityMatrix): string[] {
172
+ const rows: Array<[string, Capability]> = [
173
+ ['compaction', matrix.compaction],
174
+ ['tokenMeter', matrix.tokenMeter],
175
+ ['toolResultPruner', matrix.toolResultPruner],
176
+ ['sessions', matrix.sessions],
177
+ ['agents', matrix.agents],
178
+ ['systemPrompt', matrix.systemPrompt],
179
+ ];
180
+ const lines = rows.map(
181
+ ([name, cap]) => `[lrc][cap] ${cap.available ? '可用 ' : '不可用'} ${name} | ${cap.detail}`,
182
+ );
183
+ lines.push(`[lrc][cap] surfaceOp 形状:${matrix.surfaceOpShape}`);
184
+ return lines;
185
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * 上下文画像:**全插件唯一的「上下文里到底有什么」测量入口**。
3
+ *
4
+ * ## 为什么需要
5
+ *
6
+ * 之前每条路径只知道自己那一块:prune 看工具结果、trim 看 token 总数、
7
+ * 沉淀看正文。没人知道 reasoning 已经占到 29.5% —— 那是用户用外部探针量出来的。
8
+ *
9
+ * 有了画像之后:
10
+ * - `lrc_status` 直接展示各类别占比,不用再跑外部脚本
11
+ * - 日志每 N 步打一行构成,增速异常能立刻看出来
12
+ * - 不变量测试可以断言「各类别占比不超过上限」
13
+ *
14
+ * ## 口径
15
+ *
16
+ * 只统计**当前 surface 可见节点**(`session.surface.nodes`),
17
+ * 已被 compaction/prune 遮蔽的节点不计入 —— 它们不在上下文里。
18
+ */
19
+ import { classifyEvent } from './event-classify.ts';
20
+ import { isSummaryNode, resolveSessionEvents } from './context-trim.ts';
21
+
22
+ /** 各类别的字符数。 */
23
+ export interface CategoryChars {
24
+ /** 系统提示 */
25
+ system: number;
26
+ /** 宿主注入上下文(技能目录 / 运行时 / 时间 / 审批) */
27
+ injected: number;
28
+ /** 真实用户输入 */
29
+ user: number;
30
+ /** 助手正文 */
31
+ assistantText: number;
32
+ /** 助手推理 */
33
+ reasoning: number;
34
+ /** 工具调用参数 */
35
+ toolArgs: number;
36
+ /** 工具结果 */
37
+ toolResult: number;
38
+ }
39
+
40
+ /** 一次上下文画像。 */
41
+ export interface ContextProfile {
42
+ /** surface 可见节点数 */
43
+ nodes: number;
44
+ /** 可见字符总数 */
45
+ totalChars: number;
46
+ /** 可见节点里的摘要(checkpoint)条数:>3 说明摘要层在堆叠 */
47
+ checkpoints: number;
48
+ /** 各类别字符数 */
49
+ byCategory: CategoryChars;
50
+ /** 各类别占比(0~1) */
51
+ shares: CategoryChars;
52
+ }
53
+
54
+ const EMPTY: CategoryChars = {
55
+ system: 0,
56
+ injected: 0,
57
+ user: 0,
58
+ assistantText: 0,
59
+ reasoning: 0,
60
+ toolArgs: 0,
61
+ toolResult: 0,
62
+ };
63
+
64
+ /** 统计内容块里的文本字符数。 */
65
+ function textChars(content: unknown): number {
66
+ if (typeof content === 'string') return content.length;
67
+ if (!Array.isArray(content)) return 0;
68
+ let chars = 0;
69
+ for (const block of content) {
70
+ const b = block as { type?: string; text?: unknown };
71
+ if (b?.type === 'text' && typeof b.text === 'string') chars += b.text.length;
72
+ }
73
+ return chars;
74
+ }
75
+
76
+ /** 统计内容块里的推理字符数。 */
77
+ function reasoningChars(content: unknown): number {
78
+ if (!Array.isArray(content)) return 0;
79
+ let chars = 0;
80
+ for (const block of content) {
81
+ const b = block as { type?: string; text?: unknown };
82
+ if (b?.type === 'reasoning' && typeof b.text === 'string') chars += b.text.length;
83
+ }
84
+ return chars;
85
+ }
86
+
87
+ /** 统计内容块里的工具参数字符数。 */
88
+ function toolArgsChars(content: unknown): number {
89
+ if (!Array.isArray(content)) return 0;
90
+ let chars = 0;
91
+ for (const block of content) {
92
+ const b = block as { type?: string; arguments?: unknown };
93
+ if (b?.type === 'tool-call' && typeof b.arguments === 'string') chars += b.arguments.length;
94
+ }
95
+ return chars;
96
+ }
97
+
98
+ /**
99
+ * 测量当前 surface 的上下文构成。
100
+ *
101
+ * @param session - 宿主会话
102
+ * @returns 上下文画像(session 不可读时返回全零)
103
+ */
104
+ export function profileSurface(session: unknown): ContextProfile {
105
+ const nodes = (session as { surface?: { nodes?: unknown } })?.surface?.nodes;
106
+ const events = resolveSessionEvents(session);
107
+ if (!Array.isArray(nodes) || events === undefined) {
108
+ return { nodes: 0, totalChars: 0, checkpoints: 0, byCategory: { ...EMPTY }, shares: { ...EMPTY } };
109
+ }
110
+
111
+ const byCategory: CategoryChars = { ...EMPTY };
112
+ let checkpoints = 0;
113
+ for (const seq of nodes) {
114
+ const event = events[seq as number] as
115
+ | {
116
+ type?: string;
117
+ data?: {
118
+ content?: unknown;
119
+ message?: { content?: unknown };
120
+ };
121
+ }
122
+ | undefined;
123
+ if (event === undefined) continue;
124
+ if (isSummaryNode(event)) checkpoints += 1;
125
+ const category = classifyEvent(event);
126
+
127
+ if (category === 'system') {
128
+ byCategory.system += textChars(event.data?.content ?? event.data?.message?.content);
129
+ } else if (category === 'injected') {
130
+ byCategory.injected += textChars(event.data?.content);
131
+ } else if (category === 'user') {
132
+ byCategory.user += textChars(event.data?.content);
133
+ } else if (category === 'assistant') {
134
+ const content = event.data?.message?.content;
135
+ byCategory.assistantText += textChars(content);
136
+ byCategory.reasoning += reasoningChars(content);
137
+ byCategory.toolArgs += toolArgsChars(content);
138
+ } else if (category === 'tool-result') {
139
+ const content = event.data?.message?.content;
140
+ // 真实形状:content = [{type:'text', text:'...'}](实测 223/223 事件如此)。
141
+ // 历史实现去读 content[0].content(嵌套块),形状猜错 → toolResult 恒为 0%,
142
+ // 于是「工具结果占满上下文」这件事在画像里完全看不见。两种形状都收。
143
+ byCategory.toolResult += textChars(content);
144
+ if (Array.isArray(content)) {
145
+ for (const block of content) {
146
+ const nested = (block as { content?: unknown })?.content;
147
+ if (nested !== undefined) byCategory.toolResult += textChars(nested);
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ const totalChars = Object.values(byCategory).reduce((sum, value) => sum + value, 0);
154
+ const shares: CategoryChars = { ...EMPTY };
155
+ if (totalChars > 0) {
156
+ for (const key of Object.keys(byCategory) as Array<keyof CategoryChars>) {
157
+ shares[key] = byCategory[key] / totalChars;
158
+ }
159
+ }
160
+
161
+ return { nodes: nodes.length, totalChars, checkpoints, byCategory, shares };
162
+ }
163
+
164
+ /** 一行可读摘要(日志与 `lrc_status` 用)。 */
165
+ export function formatProfile(profile: ContextProfile): string {
166
+ const pct = (value: number): string => `${Math.round(value * 100)}%`;
167
+ const parts = [
168
+ `toolResult ${pct(profile.shares.toolResult)}`,
169
+ `reasoning ${pct(profile.shares.reasoning)}`,
170
+ `toolArgs ${pct(profile.shares.toolArgs)}`,
171
+ `user ${pct(profile.shares.user)}`,
172
+ `injected ${pct(profile.shares.injected)}`,
173
+ `system ${pct(profile.shares.system)}`,
174
+ `assistantText ${pct(profile.shares.assistantText)}`,
175
+ ];
176
+ // checkpoint 条数是摘要自增殖的直接指标:>3 说明 trim 没能吸收旧摘要
177
+ const warn = profile.checkpoints > 3 ? ' ⚠摘要堆叠' : '';
178
+ return (
179
+ `[lrc][profile] 节点=${profile.nodes} 总字符=${profile.totalChars}` +
180
+ ` checkpoint=${profile.checkpoints}${warn} | ${parts.join(' ')}`
181
+ );
182
+ }