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/LICENSE +21 -0
- package/README.md +182 -0
- package/cordis.patch.yml +23 -0
- package/dist/index.js +2985 -0
- package/package.json +64 -0
- package/src/budget.ts +195 -0
- package/src/capabilities.ts +185 -0
- package/src/context-profile.ts +182 -0
- package/src/context-trim.ts +1106 -0
- package/src/cursor.ts +195 -0
- package/src/event-classify.ts +75 -0
- package/src/governor.ts +168 -0
- package/src/handoff.ts +221 -0
- package/src/index.ts +1164 -0
- package/src/lrc-client.ts +210 -0
- package/src/memory-filter.ts +506 -0
- package/src/offloader.ts +475 -0
- package/src/runtime.ts +305 -0
- package/src/session-text.ts +188 -0
package/src/cursor.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 增量游标:只提炼上次之后的新增对话。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要
|
|
5
|
+
*
|
|
6
|
+
* 每次卸载都把整段 transcript 重新提炼一遍,同一句话会被反复写入 ——
|
|
7
|
+
* 这是"上下文抢救"造成记忆膨胀的根因。
|
|
8
|
+
*
|
|
9
|
+
* ## 哈希校验
|
|
10
|
+
*
|
|
11
|
+
* transcript 可能被宿主截断或重写(比如会话恢复)。若只记行号,
|
|
12
|
+
* 行号会指向错误的内容。所以同时记录"最后一行内容的哈希":
|
|
13
|
+
* 对不上就判定游标失效,退回全量提炼并记 warn。
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
|
|
19
|
+
export interface CursorState {
|
|
20
|
+
sessionId: string;
|
|
21
|
+
/** 已处理到第几行(0 表示还没处理过) */
|
|
22
|
+
lastLine: number;
|
|
23
|
+
/** 第 lastLine 行内容的哈希,用于校验 transcript 是否被重写 */
|
|
24
|
+
lastHash: string;
|
|
25
|
+
updatedAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SliceResult {
|
|
29
|
+
/** 需要送去提炼的新增文本 */
|
|
30
|
+
text: string;
|
|
31
|
+
/** 本次从第几行开始(0-based) */
|
|
32
|
+
fromLine: number;
|
|
33
|
+
/** 游标是否失效(transcript 被重写),调用方应记 warn */
|
|
34
|
+
reset: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 行内容哈希(取前 16 位十六进制,够用且省空间)。 */
|
|
38
|
+
export function hashLine(line: string): string {
|
|
39
|
+
return createHash('sha256').update(line, 'utf8').digest('hex').slice(0, 16);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 快照的**实质内容指纹**:折叠空白后取哈希。
|
|
44
|
+
*
|
|
45
|
+
* 为什么折叠空白:快照是 1~3 行短文本,换行/缩进差异不构成"状态变了",
|
|
46
|
+
* 但会让宿主认为 runtime-context 变了并 append 一条新消息。
|
|
47
|
+
*/
|
|
48
|
+
export function snapshotFingerprint(text: string): string {
|
|
49
|
+
return hashLine(text.replace(/\s+/g, ' ').trim());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 快照是否发生了**实质变化**(仅空白差异不算)。
|
|
54
|
+
*
|
|
55
|
+
* ## 为什么需要
|
|
56
|
+
*
|
|
57
|
+
* 宿主把 `systemPrompt.context` 渲染成一条 runtime-context 用户消息,
|
|
58
|
+
* 文本一变就 append 一条新消息(旧消息留在 surface 里)。快照每轮都变
|
|
59
|
+
* 就会不断插入新消息:既打乱前缀缓存,又让上下文持续膨胀。
|
|
60
|
+
*
|
|
61
|
+
* 因此只有实质变化才更新快照;内容相同(含仅空白差异)时保持原样,
|
|
62
|
+
* 让宿主侧 `RuntimeContextProjection` 判定"无需更新"。
|
|
63
|
+
*/
|
|
64
|
+
export function snapshotChanged(previous: string, next: string): boolean {
|
|
65
|
+
if (previous === next) return false;
|
|
66
|
+
return snapshotFingerprint(previous) !== snapshotFingerprint(next);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 按游标切出新增文本(**纯函数**,便于测试)。
|
|
71
|
+
*
|
|
72
|
+
* @param lines 当前 transcript 的全部行
|
|
73
|
+
* @param state 上次保存的游标(null 表示首次)
|
|
74
|
+
*/
|
|
75
|
+
export function sliceByCursor(lines: string[], state: CursorState | null): SliceResult {
|
|
76
|
+
if (state === null || state.lastLine <= 0) {
|
|
77
|
+
return { text: lines.join('\n'), fromLine: 0, reset: false };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 游标越界:transcript 变短了
|
|
81
|
+
if (state.lastLine > lines.length) {
|
|
82
|
+
return { text: lines.join('\n'), fromLine: 0, reset: true };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 哈希校验:最后一行内容变了 ⇒ transcript 被重写
|
|
86
|
+
const anchor = lines[state.lastLine - 1] ?? '';
|
|
87
|
+
if (hashLine(anchor) !== state.lastHash) {
|
|
88
|
+
return { text: lines.join('\n'), fromLine: 0, reset: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
text: lines.slice(state.lastLine).join('\n'),
|
|
93
|
+
fromLine: state.lastLine,
|
|
94
|
+
reset: false,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 根据本次处理结果推进游标。 */
|
|
99
|
+
export function advanceCursor(
|
|
100
|
+
lines: string[],
|
|
101
|
+
sessionId: string,
|
|
102
|
+
fromLine: number,
|
|
103
|
+
consumedLines: number,
|
|
104
|
+
): CursorState {
|
|
105
|
+
const lastLine = Math.min(fromLine + consumedLines, lines.length);
|
|
106
|
+
return {
|
|
107
|
+
sessionId,
|
|
108
|
+
lastLine,
|
|
109
|
+
lastHash: lastLine > 0 ? hashLine(lines[lastLine - 1] ?? '') : '',
|
|
110
|
+
updatedAt: Date.now(),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 文件持久化的游标存储(按 sessionId 分槽)。 */
|
|
115
|
+
export class CursorStore {
|
|
116
|
+
private readonly filePath: string;
|
|
117
|
+
private cache: Record<string, CursorState> | null = null;
|
|
118
|
+
|
|
119
|
+
constructor(filePath: string) {
|
|
120
|
+
this.filePath = filePath;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
private loadAll(): Record<string, CursorState> {
|
|
124
|
+
if (this.cache !== null) return this.cache;
|
|
125
|
+
try {
|
|
126
|
+
if (existsSync(this.filePath)) {
|
|
127
|
+
this.cache = JSON.parse(readFileSync(this.filePath, 'utf8')) as Record<string, CursorState>;
|
|
128
|
+
} else {
|
|
129
|
+
this.cache = {};
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// 文件损坏不应阻断卸载:当作空游标,退回全量
|
|
133
|
+
this.cache = {};
|
|
134
|
+
}
|
|
135
|
+
return this.cache;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get(sessionId: string): CursorState | null {
|
|
139
|
+
return this.loadAll()[sessionId] ?? null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
set(state: CursorState): void {
|
|
143
|
+
const all = this.loadAll();
|
|
144
|
+
all[state.sessionId] = state;
|
|
145
|
+
try {
|
|
146
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
147
|
+
writeFileSync(this.filePath, JSON.stringify(all, null, 2), 'utf8');
|
|
148
|
+
} catch {
|
|
149
|
+
// 落盘失败不影响本次卸载,下次会退回全量
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* 最近已发送块的持久化。
|
|
156
|
+
*
|
|
157
|
+
* 为什么需要:`Offloader.recent` 是内存态,插件重启后丢失,
|
|
158
|
+
* 跨会话的换措辞复述会重新入库。落盘后查重窗口跨重启保留。
|
|
159
|
+
*/
|
|
160
|
+
export class RecentStore {
|
|
161
|
+
private readonly filePath: string;
|
|
162
|
+
private cache: string[] | null = null;
|
|
163
|
+
|
|
164
|
+
constructor(filePath: string) {
|
|
165
|
+
this.filePath = filePath;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
load(): string[] {
|
|
169
|
+
if (this.cache !== null) return this.cache;
|
|
170
|
+
try {
|
|
171
|
+
if (existsSync(this.filePath)) {
|
|
172
|
+
const parsed = JSON.parse(readFileSync(this.filePath, 'utf8')) as unknown;
|
|
173
|
+
this.cache = Array.isArray(parsed)
|
|
174
|
+
? parsed.filter((item): item is string => typeof item === 'string')
|
|
175
|
+
: [];
|
|
176
|
+
} else {
|
|
177
|
+
this.cache = [];
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// 文件损坏不应阻断卸载:当作空窗口
|
|
181
|
+
this.cache = [];
|
|
182
|
+
}
|
|
183
|
+
return this.cache;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
save(items: string[]): void {
|
|
187
|
+
this.cache = items;
|
|
188
|
+
try {
|
|
189
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
190
|
+
writeFileSync(this.filePath, JSON.stringify(items), 'utf8');
|
|
191
|
+
} catch {
|
|
192
|
+
// 落盘失败不影响本次卸载
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 事件分类:**全插件唯一的事件类型判定入口**。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么必须收口
|
|
5
|
+
*
|
|
6
|
+
* 之前每个模块各自写 `if (type === 'assistant/message')`:
|
|
7
|
+
* - `session-text.ts` 判用户消息
|
|
8
|
+
* - `context-trim.ts` 判裁剪边界
|
|
9
|
+
* - `index.ts` 判沉淀来源
|
|
10
|
+
*
|
|
11
|
+
* 结果是每出现一个新事件类型(`system/message`、`user-approval`、
|
|
12
|
+
* `skill-catalog`…)就要挨个补一遍,漏一个就是一次生产故障
|
|
13
|
+
* (`system/message` 漏判直接让 trim 必挂并熔断)。
|
|
14
|
+
*
|
|
15
|
+
* 收口后只有这里需要认识新类型,其余模块只消费分类结果。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** 一个 surface 事件的内容类别。 */
|
|
19
|
+
export type EventCategory =
|
|
20
|
+
/** 系统提示(DSH 某些版本把它作为 surface 的 node 0) */
|
|
21
|
+
| 'system'
|
|
22
|
+
/** 宿主注入的上下文:技能目录 / 运行时 / 时间 / 模型切换 / 审批提示 */
|
|
23
|
+
| 'injected'
|
|
24
|
+
/** 用户真正说的话 */
|
|
25
|
+
| 'user'
|
|
26
|
+
/** 助手消息(正文 / 推理 / 工具调用) */
|
|
27
|
+
| 'assistant'
|
|
28
|
+
/** 工具结果 */
|
|
29
|
+
| 'tool-result'
|
|
30
|
+
/** 未知类型(保守处理:不参与裁剪、不参与沉淀) */
|
|
31
|
+
| 'unknown';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 判定一个事件的内容类别。
|
|
35
|
+
*
|
|
36
|
+
* 用**白名单**:只有明确认识的类型才归入对话类别,其余一律 `unknown`。
|
|
37
|
+
* 这样新类型出现时最坏是「不处理」,而不是「被误当成可裁剪节点」。
|
|
38
|
+
*
|
|
39
|
+
* @param event - 会话事件
|
|
40
|
+
* @returns 内容类别
|
|
41
|
+
*/
|
|
42
|
+
export function classifyEvent(event: unknown): EventCategory {
|
|
43
|
+
const e = event as { type?: string; data?: { source?: { kind?: string } } };
|
|
44
|
+
switch (e?.type) {
|
|
45
|
+
case 'system/message':
|
|
46
|
+
return 'system';
|
|
47
|
+
case 'assistant/message':
|
|
48
|
+
return 'assistant';
|
|
49
|
+
case 'tool/result':
|
|
50
|
+
return 'tool-result';
|
|
51
|
+
case 'user/message': {
|
|
52
|
+
const kind = e.data?.source?.kind;
|
|
53
|
+
// 没有 source 视为真实用户消息(兼容旧格式)
|
|
54
|
+
return kind === undefined || kind === 'user' ? 'user' : 'injected';
|
|
55
|
+
}
|
|
56
|
+
default:
|
|
57
|
+
return 'unknown';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 是否是可替换的对话节点(裁剪边界、剪枝目标)。
|
|
63
|
+
*
|
|
64
|
+
* `system` / `injected` / `unknown` 都不能被替换:
|
|
65
|
+
* 替换系统提示会被 `compactRegion` 拒绝,替换注入上下文会丢运行时事实。
|
|
66
|
+
*/
|
|
67
|
+
export function isConversationNode(event: unknown): boolean {
|
|
68
|
+
const category = classifyEvent(event);
|
|
69
|
+
return category === 'user' || category === 'assistant' || category === 'tool-result';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 是否是真实用户消息(沉淀来源判定)。 */
|
|
73
|
+
export function isRealUserEvent(event: unknown): boolean {
|
|
74
|
+
return classifyEvent(event) === 'user';
|
|
75
|
+
}
|
package/src/governor.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 单一步进控制器:能力门控 + 熔断 + 节流 + 有序执行。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要
|
|
5
|
+
*
|
|
6
|
+
* 之前每条路径各自判断「要不要跑、能不能跑、失败了怎么办」,
|
|
7
|
+
* 结果是:一条路径失败后每步重试(实测 60 次重复 flush),
|
|
8
|
+
* 另一条路径静默失效却没人知道。
|
|
9
|
+
*
|
|
10
|
+
* 把编排收口到一处后:
|
|
11
|
+
* - **能力门控**:能力矩阵说不可用,就一次都不调用
|
|
12
|
+
* - **熔断**:连续失败 N 次后禁用该动作,只告警一次
|
|
13
|
+
* - **节流**:按步数间隔执行,避免每步白扫
|
|
14
|
+
* - **有序**:先剪枝(便宜)再裁剪(贵),剪枝后 token 降下来裁剪可能就不必跑
|
|
15
|
+
*
|
|
16
|
+
* 本模块不依赖 DSH,纯编排,可直接单测。
|
|
17
|
+
*/
|
|
18
|
+
import type { CapabilityMatrix } from './capabilities.ts';
|
|
19
|
+
|
|
20
|
+
/** 单个动作的执行结果。 */
|
|
21
|
+
export interface GovernorOutcome {
|
|
22
|
+
ok: boolean;
|
|
23
|
+
/** 机器可读原因:ok / below-threshold / no-range / error:… */
|
|
24
|
+
reason: string;
|
|
25
|
+
/** 诊断细节 */
|
|
26
|
+
detail?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 一个受控动作。 */
|
|
30
|
+
export interface GovernorAction {
|
|
31
|
+
/** 动作名(用于熔断与状态展示) */
|
|
32
|
+
name: string;
|
|
33
|
+
/** 是否启用(能力矩阵 + 配置共同决定) */
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
/** 两次执行之间至少间隔多少步(默认 1 = 每步) */
|
|
36
|
+
throttleSteps?: number;
|
|
37
|
+
/** 执行一次;允许同步或异步 */
|
|
38
|
+
run: () => GovernorOutcome | Promise<GovernorOutcome>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 控制器状态(跨步保留)。 */
|
|
42
|
+
export interface GovernorState {
|
|
43
|
+
/** 能力矩阵(首次探测后固定) */
|
|
44
|
+
capabilities: CapabilityMatrix | null;
|
|
45
|
+
/** 已执行步数 */
|
|
46
|
+
steps: number;
|
|
47
|
+
/** 每个动作的连续失败次数 */
|
|
48
|
+
failures: Record<string, number>;
|
|
49
|
+
/** 已熔断的动作 */
|
|
50
|
+
disabled: Set<string>;
|
|
51
|
+
/** 每个动作最近一次结果 */
|
|
52
|
+
lastOutcome: Record<string, GovernorOutcome>;
|
|
53
|
+
/** 每个动作上次执行的步数 */
|
|
54
|
+
lastRunStep: Record<string, number>;
|
|
55
|
+
/** 每个动作累计执行次数 */
|
|
56
|
+
runs: Record<string, number>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 连续失败多少次后熔断。 */
|
|
60
|
+
export const MAX_CONSECUTIVE_FAILURES = 3;
|
|
61
|
+
|
|
62
|
+
export function createGovernorState(): GovernorState {
|
|
63
|
+
return {
|
|
64
|
+
capabilities: null,
|
|
65
|
+
steps: 0,
|
|
66
|
+
failures: {},
|
|
67
|
+
disabled: new Set<string>(),
|
|
68
|
+
lastOutcome: {},
|
|
69
|
+
lastRunStep: {},
|
|
70
|
+
runs: {},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 执行一步。
|
|
76
|
+
*
|
|
77
|
+
* 动作按数组顺序执行;被禁用/熔断/未到节流间隔的跳过。
|
|
78
|
+
* 单个动作抛错不会中断后续动作。
|
|
79
|
+
*
|
|
80
|
+
* @param deps - 状态、动作列表、日志函数
|
|
81
|
+
* @returns 本步实际执行的动作名
|
|
82
|
+
*/
|
|
83
|
+
export async function runGovernor(deps: {
|
|
84
|
+
state: GovernorState;
|
|
85
|
+
actions: GovernorAction[];
|
|
86
|
+
log: (message: string) => void;
|
|
87
|
+
warn: (message: string) => void;
|
|
88
|
+
}): Promise<string[]> {
|
|
89
|
+
const { state, actions, log, warn } = deps;
|
|
90
|
+
state.steps += 1;
|
|
91
|
+
const executed: string[] = [];
|
|
92
|
+
|
|
93
|
+
for (const action of actions) {
|
|
94
|
+
if (!action.enabled) continue;
|
|
95
|
+
if (state.disabled.has(action.name)) continue;
|
|
96
|
+
|
|
97
|
+
const throttle = action.throttleSteps ?? 1;
|
|
98
|
+
const last = state.lastRunStep[action.name];
|
|
99
|
+
if (last !== undefined && state.steps - last < throttle) continue;
|
|
100
|
+
|
|
101
|
+
state.lastRunStep[action.name] = state.steps;
|
|
102
|
+
state.runs[action.name] = (state.runs[action.name] ?? 0) + 1;
|
|
103
|
+
executed.push(action.name);
|
|
104
|
+
|
|
105
|
+
let outcome: GovernorOutcome;
|
|
106
|
+
try {
|
|
107
|
+
outcome = await action.run();
|
|
108
|
+
} catch (error) {
|
|
109
|
+
outcome = {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: `error:${error instanceof Error ? error.message : String(error)}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
state.lastOutcome[action.name] = outcome;
|
|
115
|
+
|
|
116
|
+
if (outcome.ok) {
|
|
117
|
+
state.failures[action.name] = 0;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 非异常原因(below-threshold / no-range)不算失败,不触发熔断
|
|
122
|
+
if (!outcome.reason.startsWith('error')) continue;
|
|
123
|
+
|
|
124
|
+
const failures = (state.failures[action.name] ?? 0) + 1;
|
|
125
|
+
state.failures[action.name] = failures;
|
|
126
|
+
if (failures >= MAX_CONSECUTIVE_FAILURES) {
|
|
127
|
+
state.disabled.add(action.name);
|
|
128
|
+
warn(
|
|
129
|
+
`[lrc][governor] ${action.name} 连续失败 ${failures} 次,已熔断(本次会话不再尝试):${outcome.reason}`,
|
|
130
|
+
);
|
|
131
|
+
} else {
|
|
132
|
+
warn(
|
|
133
|
+
`[lrc][governor] ${action.name} 失败(${failures}/${MAX_CONSECUTIVE_FAILURES}):${outcome.reason}`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (executed.length > 0) {
|
|
139
|
+
log(`[lrc][governor] 第 ${state.steps} 步执行:${executed.join(' → ')}`);
|
|
140
|
+
}
|
|
141
|
+
return executed;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 把控制器状态压成可读文本(`lrc_status` 工具用)。 */
|
|
145
|
+
export function formatGovernorState(state: GovernorState): string {
|
|
146
|
+
const lines: string[] = [`步数:${state.steps}`];
|
|
147
|
+
const names = new Set([
|
|
148
|
+
...Object.keys(state.runs),
|
|
149
|
+
...Object.keys(state.lastOutcome),
|
|
150
|
+
...state.disabled,
|
|
151
|
+
]);
|
|
152
|
+
if (names.size === 0) {
|
|
153
|
+
lines.push('尚未执行任何动作');
|
|
154
|
+
return lines.join('\n');
|
|
155
|
+
}
|
|
156
|
+
for (const name of names) {
|
|
157
|
+
const outcome = state.lastOutcome[name];
|
|
158
|
+
const disabled = state.disabled.has(name) ? ' [已熔断]' : '';
|
|
159
|
+
const runs = state.runs[name] ?? 0;
|
|
160
|
+
const failures = state.failures[name] ?? 0;
|
|
161
|
+
lines.push(
|
|
162
|
+
`- ${name}${disabled}:执行 ${runs} 次,连续失败 ${failures}` +
|
|
163
|
+
`${outcome === undefined ? '' : `,最近 ${outcome.ok ? '成功' : outcome.reason}`}` +
|
|
164
|
+
`${outcome?.detail === undefined ? '' : `(${outcome.detail})`}`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return lines.join('\n');
|
|
168
|
+
}
|
package/src/handoff.ts
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 路径 6:新建会话 + 摘要注入(会话交接)。
|
|
3
|
+
*
|
|
4
|
+
* ## 为什么需要
|
|
5
|
+
*
|
|
6
|
+
* 路径 1/2 只能把上下文压小,压到极限仍然会撞窗口。这时唯一的出路是
|
|
7
|
+
* 「另起炉灶」:把当前会话的结论整理成一份交接摘要,注入一个新会话,
|
|
8
|
+
* 原会话原样保留(可随时回看)。
|
|
9
|
+
*
|
|
10
|
+
* ## 为什么做成显式工具而不是自动触发
|
|
11
|
+
*
|
|
12
|
+
* 新建会话会改变用户的工作现场。自动触发一旦判断失误,用户会突然发现
|
|
13
|
+
* 自己在另一个会话里。所以只提供 `lrc_handoff` 工具,由用户或模型显式调用。
|
|
14
|
+
*
|
|
15
|
+
* ## 两条创建路径
|
|
16
|
+
*
|
|
17
|
+
* 1. `ctx.agents.create()` —— 建一个**带 agent 的**新会话,可直接继续对话(首选)
|
|
18
|
+
* 2. `ctx.sessions.create()` —— 只建会话,没有 agent(兜底,至少内容落盘)
|
|
19
|
+
*
|
|
20
|
+
* 两条都失败时只返回摘要文本,不抛错。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** 交接摘要的输入。 */
|
|
24
|
+
export interface HandoffInput {
|
|
25
|
+
/** 当前会话的最后一条真实用户请求 */
|
|
26
|
+
lastUserRequest: string;
|
|
27
|
+
/** 最近几条助手正文(按时间正序) */
|
|
28
|
+
recentAssistant: string[];
|
|
29
|
+
/** 最近一次沉淀产出的状态快照 */
|
|
30
|
+
snapshot: string;
|
|
31
|
+
/** LRC 归档索引行(可为空) */
|
|
32
|
+
indexLines: string[];
|
|
33
|
+
/** 调用方补充的下一步说明 */
|
|
34
|
+
note: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 交接结果。 */
|
|
38
|
+
export interface HandoffResult {
|
|
39
|
+
ok: boolean;
|
|
40
|
+
/** 新会话 id(创建成功时) */
|
|
41
|
+
sessionId?: string;
|
|
42
|
+
/** agent / session / text-only */
|
|
43
|
+
mode: string;
|
|
44
|
+
/** 交接摘要全文 */
|
|
45
|
+
text: string;
|
|
46
|
+
/** 失败原因 */
|
|
47
|
+
reason?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 组装交接摘要。
|
|
52
|
+
*
|
|
53
|
+
* 只保留「接手的人必须知道」的四块:当前任务、最近结论、长期记忆索引、下一步。
|
|
54
|
+
* 不复制整段对话 —— 那等于把上下文原样搬过去。
|
|
55
|
+
*/
|
|
56
|
+
export function buildHandoffText(input: HandoffInput): string {
|
|
57
|
+
const sections: string[] = ['# 会话交接', ''];
|
|
58
|
+
|
|
59
|
+
sections.push('## 当前任务');
|
|
60
|
+
sections.push(input.lastUserRequest.trim().length > 0 ? input.lastUserRequest.trim() : '(未记录)');
|
|
61
|
+
sections.push('');
|
|
62
|
+
|
|
63
|
+
if (input.recentAssistant.length > 0) {
|
|
64
|
+
sections.push('## 最近结论');
|
|
65
|
+
for (const line of input.recentAssistant) {
|
|
66
|
+
const flat = line.replace(/\s+/g, ' ').trim();
|
|
67
|
+
if (flat.length > 0) sections.push(`- ${flat.length > 300 ? `${flat.slice(0, 300)}…` : flat}`);
|
|
68
|
+
}
|
|
69
|
+
sections.push('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (input.snapshot.trim().length > 0) {
|
|
73
|
+
sections.push('## 已沉淀状态');
|
|
74
|
+
sections.push(input.snapshot.trim());
|
|
75
|
+
sections.push('');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (input.indexLines.length > 0) {
|
|
79
|
+
sections.push('## 长期记忆索引');
|
|
80
|
+
for (const line of input.indexLines) sections.push(`- ${line}`);
|
|
81
|
+
sections.push('');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
sections.push('## 下一步');
|
|
85
|
+
sections.push(input.note.trim().length > 0 ? input.note.trim() : '(由接手方根据上述上下文继续)');
|
|
86
|
+
|
|
87
|
+
return sections.join('\n');
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 从会话日志里取最后一条真实用户请求与最近几条助手正文。 */
|
|
91
|
+
export function extractHandoffSource(session: unknown): {
|
|
92
|
+
lastUserRequest: string;
|
|
93
|
+
recentAssistant: string[];
|
|
94
|
+
} {
|
|
95
|
+
const log = (session as { log?: unknown })?.log;
|
|
96
|
+
if (!Array.isArray(log)) return { lastUserRequest: '', recentAssistant: [] };
|
|
97
|
+
|
|
98
|
+
let lastUserRequest = '';
|
|
99
|
+
const assistant: string[] = [];
|
|
100
|
+
for (const raw of log) {
|
|
101
|
+
const event = raw as {
|
|
102
|
+
type?: string;
|
|
103
|
+
data?: { message?: { content?: unknown }; content?: unknown; source?: { kind?: string } };
|
|
104
|
+
};
|
|
105
|
+
if (event?.type === 'user/message') {
|
|
106
|
+
// 只认用户真正说的话,跳过技能目录 / 运行时上下文
|
|
107
|
+
const kind = event.data?.source?.kind;
|
|
108
|
+
if (kind !== undefined && kind !== 'user') continue;
|
|
109
|
+
const text = textOf(event.data?.content);
|
|
110
|
+
if (text.length > 0) lastUserRequest = text;
|
|
111
|
+
} else if (event?.type === 'assistant/message') {
|
|
112
|
+
const text = textOf(event.data?.message?.content);
|
|
113
|
+
if (text.length > 0) assistant.push(text);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { lastUserRequest, recentAssistant: assistant.slice(-5) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function textOf(content: unknown): string {
|
|
120
|
+
if (typeof content === 'string') return content;
|
|
121
|
+
if (!Array.isArray(content)) return '';
|
|
122
|
+
const out: string[] = [];
|
|
123
|
+
for (const block of content) {
|
|
124
|
+
const b = block as { type?: string; text?: unknown };
|
|
125
|
+
if (b?.type === 'text' && typeof b.text === 'string' && b.text.length > 0) out.push(b.text);
|
|
126
|
+
}
|
|
127
|
+
return out.join('\n');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function safeGet(ctx: unknown, name: string): unknown {
|
|
131
|
+
try {
|
|
132
|
+
return (ctx as { get?: (n: string) => unknown })?.get?.(name);
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 创建新会话并注入交接摘要。
|
|
140
|
+
*
|
|
141
|
+
* 优先建「带 agent 的会话」,这样接手方可以直接继续对话;
|
|
142
|
+
* 失败则退化为「只建会话」;再失败就只返回摘要文本。
|
|
143
|
+
*
|
|
144
|
+
* @param deps - 宿主 ctx、当前 agent、交接摘要
|
|
145
|
+
* @returns 交接结果(永不抛错)
|
|
146
|
+
*/
|
|
147
|
+
export async function createHandoffSession(deps: {
|
|
148
|
+
ctx: unknown;
|
|
149
|
+
agent: unknown;
|
|
150
|
+
text: string;
|
|
151
|
+
log: (message: string) => void;
|
|
152
|
+
}): Promise<HandoffResult> {
|
|
153
|
+
const { ctx, agent, text, log } = deps;
|
|
154
|
+
const current = agent as {
|
|
155
|
+
session?: { id?: string; header?: { cwd?: string } };
|
|
156
|
+
options?: unknown;
|
|
157
|
+
};
|
|
158
|
+
const currentId = current?.session?.id;
|
|
159
|
+
const cwd = current?.session?.header?.cwd;
|
|
160
|
+
const meta = {
|
|
161
|
+
...(cwd === undefined ? {} : { cwd }),
|
|
162
|
+
...(currentId === undefined ? {} : { parentSession: currentId }),
|
|
163
|
+
};
|
|
164
|
+
const userMessage = {
|
|
165
|
+
content: [{ type: 'text', text }],
|
|
166
|
+
source: { kind: 'user' },
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// 路径 A:带 agent 的新会话(可直接继续对话)
|
|
170
|
+
const agents = safeGet(ctx, 'agents') as
|
|
171
|
+
| { create?: (options: unknown) => Promise<{ agent?: { session?: unknown } }> }
|
|
172
|
+
| undefined;
|
|
173
|
+
if (typeof agents?.create === 'function') {
|
|
174
|
+
try {
|
|
175
|
+
const handle = await agents.create({
|
|
176
|
+
sessionId: `session-handoff-${Date.now()}`,
|
|
177
|
+
meta,
|
|
178
|
+
...(current?.options === undefined ? {} : { agentOptions: current.options }),
|
|
179
|
+
});
|
|
180
|
+
const session = handle?.agent?.session as
|
|
181
|
+
| { id?: string; append?: (type: string, data: unknown) => unknown }
|
|
182
|
+
| undefined;
|
|
183
|
+
if (typeof session?.append === 'function') {
|
|
184
|
+
session.append('user/message', userMessage);
|
|
185
|
+
log(`[lrc][handoff] 已创建带 agent 的新会话:${session.id ?? 'unknown'}`);
|
|
186
|
+
return { ok: true, sessionId: session.id, mode: 'agent', text };
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
log(
|
|
190
|
+
`[lrc][handoff] agents.create 失败,退回 sessions.create:${
|
|
191
|
+
error instanceof Error ? error.message : String(error)
|
|
192
|
+
}`,
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 路径 B:只建会话(没有 agent,但内容落盘)
|
|
198
|
+
const sessions = safeGet(ctx, 'sessions') as
|
|
199
|
+
| { create?: (id?: string, options?: unknown) => unknown }
|
|
200
|
+
| undefined;
|
|
201
|
+
if (typeof sessions?.create === 'function') {
|
|
202
|
+
try {
|
|
203
|
+
const session = sessions.create(undefined, { meta }) as
|
|
204
|
+
| { id?: string; append?: (type: string, data: unknown) => unknown }
|
|
205
|
+
| undefined;
|
|
206
|
+
if (typeof session?.append === 'function') {
|
|
207
|
+
session.append('user/message', userMessage);
|
|
208
|
+
log(`[lrc][handoff] 已创建新会话(无 agent):${session.id ?? 'unknown'}`);
|
|
209
|
+
return { ok: true, sessionId: session.id, mode: 'session', text };
|
|
210
|
+
}
|
|
211
|
+
} catch (error) {
|
|
212
|
+
log(
|
|
213
|
+
`[lrc][handoff] sessions.create 失败,只返回摘要:${
|
|
214
|
+
error instanceof Error ? error.message : String(error)
|
|
215
|
+
}`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { ok: false, mode: 'text-only', text, reason: 'sessions-unavailable' };
|
|
221
|
+
}
|