@kangyishuai/dsh-session-rewind 0.2.0 → 0.3.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/README.md +11 -0
- package/README.zh-CN.md +11 -0
- package/lib/service.js +74 -0
- package/lib/tool.js +254 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,6 +22,17 @@ Long conversations drift. A few turns later you wish you had answered a prompt d
|
|
|
22
22
|
- **Composer.** The target message's text (and images) is put back into the composer, ready to edit and send again. An existing draft is never overwritten. Tick **Send this message again right after rewinding** in the confirmation dialog to retry the turn in one click (conversation-only rewinds).
|
|
23
23
|
- **Rewind panel.** A new header action opens a panel with four tabs: **Withdrawn** (every message each rewind removed, searchable, copy or put back in the composer), **History** (audit of each rewind: scope, message count, tokens, file results), **Rescue points** (restore files from an earlier rescue), and **Settings** (default scope, page size, confirm-on-list, diagnostics).
|
|
24
24
|
|
|
25
|
+
## Read-only agent tool
|
|
26
|
+
|
|
27
|
+
The plugin also registers one **read-only** tool, `session_rewind_candidates`, so the model can reason about rewinding without performing it:
|
|
28
|
+
|
|
29
|
+
| Call | Result |
|
|
30
|
+
| ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
31
|
+
| `session_rewind_candidates {}` | Newest-first list of messages this conversation can be rewound to (`query` filters, `limit` caps at 50). |
|
|
32
|
+
| `session_rewind_candidates { target_seq }` | Impact preview for one message: how many messages would be withdrawn, whether an unfinished tool call would be truncated, and the target text. |
|
|
33
|
+
|
|
34
|
+
The tool never rewinds, never edits files and never appends to the session log — the rewind itself stays a user action (that is the point of the ADR 0006 design). Set `readOnlyTool: false` to unregister it; **Settings → Diagnostics** reports whether it registered.
|
|
35
|
+
|
|
25
36
|
## Install
|
|
26
37
|
|
|
27
38
|
```bash
|
package/README.zh-CN.md
CHANGED
|
@@ -22,6 +22,17 @@
|
|
|
22
22
|
- **输入框**:目标消息文本(以及图片)放回输入框,改完可直接重发;已有草稿绝不覆盖。勾选「回退后立即重发」可一键重试这一轮(仅对话模式)。
|
|
23
23
|
- **回溯面板**:会话头部新增一个入口,四个页签:**已撤回**(每次回溯撤回的全部消息,可搜索、复制、放回输入框)、**回溯记录**(审计:范围、条数、token、文件结果)、**救援点**(手动恢复文件)、**设置**(默认范围、每页数量、列表确认、诊断)。
|
|
24
24
|
|
|
25
|
+
## 只读 agent 工具
|
|
26
|
+
|
|
27
|
+
插件还会注册一个**只读**工具 `session_rewind_candidates`,让模型能自己判断「能退回到哪、退回去会丢什么」,但**不执行**回溯:
|
|
28
|
+
|
|
29
|
+
| 调用 | 结果 |
|
|
30
|
+
| ------------------------------------------ | -------------------------------------------------------------------- |
|
|
31
|
+
| `session_rewind_candidates {}` | 按新→旧列出可回退的消息(`query` 过滤,`limit` 上限 50)。 |
|
|
32
|
+
| `session_rewind_candidates { target_seq }` | 单条消息的影响面:会撤回多少条、是否截断未完成的工具调用、目标文本。 |
|
|
33
|
+
|
|
34
|
+
这个工具不回溯、不碰文件、不写会话日志——真正的回退始终由用户在界面确认(这是 ADR 0006 的设计前提)。设 `readOnlyTool: false` 可关闭;**设置 → 诊断**里能看到它是否注册成功。
|
|
35
|
+
|
|
25
36
|
## 安装
|
|
26
37
|
|
|
27
38
|
```bash
|
package/lib/service.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "./operations.js";
|
|
30
30
|
import { findOrphans, pruneOrphans } from "./orphans.js";
|
|
31
31
|
import { cutsOf, hasOpenToolCalls, listCandidatesPage, targetContent } from "./plan.js";
|
|
32
|
+
import { describeToolImpact, installRewindTool, listToolCandidates } from "./tool.js";
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* 在 decorator 语法不可用(V8 装饰器关闭)的运行时里登记一个 Remote 方法。
|
|
@@ -162,6 +163,18 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
162
163
|
this._store = null;
|
|
163
164
|
this.installFileTracking();
|
|
164
165
|
this.installCommand();
|
|
166
|
+
// 只读 agent 工具:让模型自己看清候选与影响面(绝不执行回溯)。
|
|
167
|
+
this.readOnlyToolInstalled = false;
|
|
168
|
+
if (this.config.readOnlyTool !== false) {
|
|
169
|
+
installRewindTool({
|
|
170
|
+
ctx: this.ctx,
|
|
171
|
+
logger: this.ctx?.logger ?? console,
|
|
172
|
+
host: { call: (sessionId, request) => this.toolPayload(sessionId, request) },
|
|
173
|
+
onInstalled: () => {
|
|
174
|
+
this.readOnlyToolInstalled = true;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
165
178
|
// 启动时清一次已删除会话的残留数据(失败只告警,不阻塞插件)。
|
|
166
179
|
if (this.config.cleanupOrphans !== false) void this.cleanupOrphans();
|
|
167
180
|
}
|
|
@@ -398,6 +411,58 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
398
411
|
}
|
|
399
412
|
}
|
|
400
413
|
|
|
414
|
+
/**
|
|
415
|
+
* 只读工具的数据面:候选列表 / 单条影响面。绝不写日志。
|
|
416
|
+
* @param {string} sessionId
|
|
417
|
+
* @param {{query?: string, limit?: number, targetSeq?: number}} [request]
|
|
418
|
+
* @returns {Promise<any>}
|
|
419
|
+
*/
|
|
420
|
+
async toolPayload(sessionId, request) {
|
|
421
|
+
try {
|
|
422
|
+
const deps = this.deps();
|
|
423
|
+
const { session } = sessionEvents(deps, sessionId);
|
|
424
|
+
const events = session.snapshotEvents();
|
|
425
|
+
const nodes = Array.isArray(session.surface?.nodes) ? session.surface.nodes : [];
|
|
426
|
+
if (typeof request?.targetSeq === "number") {
|
|
427
|
+
const impact = describeToolImpact(events, nodes, request.targetSeq);
|
|
428
|
+
if (impact.ok !== true)
|
|
429
|
+
return { ok: true, value: { kind: "error", session_id: sessionId, error: impact.error } };
|
|
430
|
+
return {
|
|
431
|
+
ok: true,
|
|
432
|
+
value: {
|
|
433
|
+
kind: "impact",
|
|
434
|
+
session_id: sessionId,
|
|
435
|
+
impact: {
|
|
436
|
+
target_seq: impact.targetSeq,
|
|
437
|
+
removed_messages: impact.removedCount,
|
|
438
|
+
crosses_open_tool_call: impact.crossesOpenToolCall,
|
|
439
|
+
text: impact.text,
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
const listed = listToolCandidates(events, nodes, {
|
|
445
|
+
...(typeof request?.query === "string" ? { query: request.query } : {}),
|
|
446
|
+
...(typeof request?.limit === "number" ? { limit: request.limit } : {}),
|
|
447
|
+
});
|
|
448
|
+
return {
|
|
449
|
+
ok: true,
|
|
450
|
+
value: {
|
|
451
|
+
kind: "list",
|
|
452
|
+
session_id: sessionId,
|
|
453
|
+
total: listed.total,
|
|
454
|
+
candidates: listed.items.map((item) => ({
|
|
455
|
+
seq: item.seq,
|
|
456
|
+
...(typeof item.time === "number" ? { time: item.time } : {}),
|
|
457
|
+
preview: item.preview,
|
|
458
|
+
})),
|
|
459
|
+
},
|
|
460
|
+
};
|
|
461
|
+
} catch (error) {
|
|
462
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
401
466
|
/**
|
|
402
467
|
* 回溯预览(dry-run):不写任何东西。
|
|
403
468
|
* @param {{sessionId?: string, targetSeq?: number}} [request]
|
|
@@ -837,6 +902,15 @@ export class SessionRewindService extends TypertRemoteService {
|
|
|
837
902
|
} catch (error) {
|
|
838
903
|
push("审计日志", false, error instanceof Error ? error.message : String(error));
|
|
839
904
|
}
|
|
905
|
+
push(
|
|
906
|
+
"只读工具",
|
|
907
|
+
this.config.readOnlyTool === false ? true : this.readOnlyToolInstalled === true,
|
|
908
|
+
this.config.readOnlyTool === false
|
|
909
|
+
? "disabled by config"
|
|
910
|
+
: this.readOnlyToolInstalled === true
|
|
911
|
+
? "enabled"
|
|
912
|
+
: "not registered",
|
|
913
|
+
);
|
|
840
914
|
try {
|
|
841
915
|
const orphans = await findOrphans({ dataRoot: this.dataRoot });
|
|
842
916
|
const count = orphans.sessionIds.length + orphans.trashIds.length + orphans.rescueIds.length;
|
package/lib/tool.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
// 只读 agent 工具 `session_rewind_candidates`:让模型自己看清「能退回到哪几步、退回去会丢什么」。
|
|
3
|
+
//
|
|
4
|
+
// 设计约束:
|
|
5
|
+
// - 只读:绝不写日志、绝不执行回溯。真正的回退仍由用户点确认框触发(ADR 0006)。
|
|
6
|
+
// - 不 import 宿主包(defineTool 在 @deepseek-ai/dsh-tools 里,插件不能依赖);这里直接
|
|
7
|
+
// 构造 registry 需要的普通对象,`parameters` 与 `output.schema` 都是标准 JSON Schema。
|
|
8
|
+
// - 形状不符就静默跳过:老版本没有 `tools` 服务时,插件其余功能照常工作。
|
|
9
|
+
import { hasOpenToolCalls, listCandidatesPage, planRewind } from "./plan.js";
|
|
10
|
+
|
|
11
|
+
/** 工具名(小写下划线,符合宿主 run_code 之外的自定义工具习惯)。 */
|
|
12
|
+
export const TOOL_NAME = "session_rewind_candidates";
|
|
13
|
+
|
|
14
|
+
/** 一次最多列多少条候选。 */
|
|
15
|
+
export const TOOL_MAX_LIMIT = 50;
|
|
16
|
+
/** 默认条数。 */
|
|
17
|
+
export const TOOL_DEFAULT_LIMIT = 10;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 把面向模型的省略描述压成一行。
|
|
21
|
+
* @param {string} text
|
|
22
|
+
* @param {number} max
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function oneLine(text, max) {
|
|
26
|
+
const flat = String(text ?? "")
|
|
27
|
+
.replace(/\s+/gu, " ")
|
|
28
|
+
.trim();
|
|
29
|
+
return flat.length <= max ? flat : `${flat.slice(0, Math.max(max - 1, 1))}…`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 列出可回退的候选消息。
|
|
34
|
+
* @param {any[]} events
|
|
35
|
+
* @param {number[]} surfaceNodes
|
|
36
|
+
* @param {{query?: string, limit?: number}} [options]
|
|
37
|
+
* @returns {{sessionId?: string, total: number, items: Array<{seq: number, time: number, preview: string, text: string}>}}
|
|
38
|
+
*/
|
|
39
|
+
export function listToolCandidates(events, surfaceNodes, options = {}) {
|
|
40
|
+
const limit =
|
|
41
|
+
Number.isSafeInteger(options.limit) && Number(options.limit) > 0
|
|
42
|
+
? Math.min(Number(options.limit), TOOL_MAX_LIMIT)
|
|
43
|
+
: TOOL_DEFAULT_LIMIT;
|
|
44
|
+
const page = listCandidatesPage(events, surfaceNodes, {
|
|
45
|
+
limit,
|
|
46
|
+
q: typeof options.query === "string" ? options.query : "",
|
|
47
|
+
});
|
|
48
|
+
return {
|
|
49
|
+
total: page.candidates.length,
|
|
50
|
+
items: page.candidates.map((candidate) => ({
|
|
51
|
+
seq: candidate.seq,
|
|
52
|
+
time: candidate.time,
|
|
53
|
+
preview: oneLine(candidate.preview ?? candidate.text ?? "", 160),
|
|
54
|
+
text: String(candidate.text ?? ""),
|
|
55
|
+
})),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 评估「退回到这条消息」的影响面:撤回多少条消息、有没有未完成的工具调用。
|
|
61
|
+
* @param {any[]} events
|
|
62
|
+
* @param {number[]} surfaceNodes
|
|
63
|
+
* @param {number} targetSeq
|
|
64
|
+
* @returns {{ok: true, targetSeq: number, removedCount: number, crossesOpenToolCall: boolean, text: string} | {ok: false, error: string}}
|
|
65
|
+
*/
|
|
66
|
+
export function describeToolImpact(events, surfaceNodes, targetSeq) {
|
|
67
|
+
const plan = planRewind(events, surfaceNodes, targetSeq);
|
|
68
|
+
if ("error" in plan) return { ok: false, error: String(plan.error?.code ?? plan.error) };
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
targetSeq,
|
|
72
|
+
removedCount: plan.shadowedSeqs.length,
|
|
73
|
+
crossesOpenToolCall: hasOpenToolCalls(events, targetSeq),
|
|
74
|
+
// planRewind 已经把目标消息的文本拼好(`user/message` 的 data 本身就是 message)。
|
|
75
|
+
text: typeof plan.text === "string" ? plan.text : "",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 构造工具定义(不依赖宿主 defineTool)。
|
|
81
|
+
* @param {{call: (sessionId: string, request: any) => Promise<any>}} host
|
|
82
|
+
* @returns {any}
|
|
83
|
+
*/
|
|
84
|
+
export function createRewindToolDefinition(host) {
|
|
85
|
+
return {
|
|
86
|
+
name: TOOL_NAME,
|
|
87
|
+
description:
|
|
88
|
+
"List the user messages this conversation can be rewound to, or preview the impact of rewinding to one of them. Read-only: it never rewinds, never edits files and never appends to the session log. The user performs the actual rewind from the UI.",
|
|
89
|
+
parameters: {
|
|
90
|
+
type: "object",
|
|
91
|
+
additionalProperties: false,
|
|
92
|
+
properties: {
|
|
93
|
+
query: {
|
|
94
|
+
type: "string",
|
|
95
|
+
description: "Optional case-insensitive substring filter over message text.",
|
|
96
|
+
},
|
|
97
|
+
limit: {
|
|
98
|
+
type: "integer",
|
|
99
|
+
description: `How many candidates to return (default ${TOOL_DEFAULT_LIMIT}, max ${TOOL_MAX_LIMIT}; clamped).`,
|
|
100
|
+
},
|
|
101
|
+
target_seq: {
|
|
102
|
+
type: "integer",
|
|
103
|
+
description:
|
|
104
|
+
"Preview the impact of rewinding to this message sequence instead of listing candidates.",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
output: {
|
|
109
|
+
// 标准 JSON Schema(宿主的受支持子集):`required` 必须是字符串数组,
|
|
110
|
+
// 不接受 defineTool 那种 `required: true` 的属性级写法。
|
|
111
|
+
schema: {
|
|
112
|
+
type: "object",
|
|
113
|
+
additionalProperties: false,
|
|
114
|
+
required: ["kind"],
|
|
115
|
+
properties: {
|
|
116
|
+
kind: { type: "string", enum: ["list", "impact", "error"] },
|
|
117
|
+
session_id: { type: "string" },
|
|
118
|
+
total: { type: "integer" },
|
|
119
|
+
candidates: {
|
|
120
|
+
type: "array",
|
|
121
|
+
items: {
|
|
122
|
+
type: "object",
|
|
123
|
+
additionalProperties: false,
|
|
124
|
+
required: ["seq"],
|
|
125
|
+
properties: {
|
|
126
|
+
seq: { type: "integer" },
|
|
127
|
+
time: { type: "integer" },
|
|
128
|
+
preview: { type: "string" },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
impact: {
|
|
133
|
+
type: "object",
|
|
134
|
+
additionalProperties: false,
|
|
135
|
+
properties: {
|
|
136
|
+
target_seq: { type: "integer" },
|
|
137
|
+
removed_messages: { type: "integer" },
|
|
138
|
+
crosses_open_tool_call: { type: "boolean" },
|
|
139
|
+
text: { type: "string" },
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
error: { type: "string" },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
/**
|
|
146
|
+
* @param {any} _args
|
|
147
|
+
* @param {any} value
|
|
148
|
+
* @returns {any[]}
|
|
149
|
+
*/
|
|
150
|
+
render: (_args, value) => {
|
|
151
|
+
const lines = [];
|
|
152
|
+
if (value?.error) {
|
|
153
|
+
lines.push(`Rewind preview failed: ${value.error}`);
|
|
154
|
+
} else if (value?.kind === "impact" && value.impact) {
|
|
155
|
+
lines.push(
|
|
156
|
+
`Rewinding to message #${value.impact.target_seq} would withdraw ${value.impact.removed_messages} message(s) from the model context.`,
|
|
157
|
+
);
|
|
158
|
+
if (value.impact.crosses_open_tool_call) {
|
|
159
|
+
lines.push(
|
|
160
|
+
"Warning: an unfinished tool call follows this message; rewinding truncates it.",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
if (value.impact.text) lines.push(`Target message: ${oneLine(value.impact.text, 400)}`);
|
|
164
|
+
lines.push("The user must confirm the rewind in the UI; this tool does not perform it.");
|
|
165
|
+
} else {
|
|
166
|
+
const candidates = Array.isArray(value?.candidates) ? value.candidates : [];
|
|
167
|
+
if (candidates.length === 0) {
|
|
168
|
+
lines.push("No messages can be rewound to in this conversation.");
|
|
169
|
+
} else {
|
|
170
|
+
lines.push(
|
|
171
|
+
`Messages this conversation can be rewound to (${candidates.length} shown):`,
|
|
172
|
+
);
|
|
173
|
+
for (const item of candidates) {
|
|
174
|
+
lines.push(`- #${item.seq}: ${oneLine(item.preview, 160)}`);
|
|
175
|
+
}
|
|
176
|
+
lines.push(
|
|
177
|
+
"Call again with target_seq to preview the impact of one message. Rewinding itself is a user action in the UI.",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return [{ type: "text", text: lines.join("\n") }];
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
/**
|
|
185
|
+
* @param {any} args
|
|
186
|
+
* @param {any} exec
|
|
187
|
+
* @returns {Promise<any>}
|
|
188
|
+
*/
|
|
189
|
+
async execute(args, exec) {
|
|
190
|
+
const sessionId = exec?.agent?.session?.id;
|
|
191
|
+
if (typeof sessionId !== "string" || sessionId === "") {
|
|
192
|
+
return { kind: "error", error: "no active session" };
|
|
193
|
+
}
|
|
194
|
+
try {
|
|
195
|
+
const payload = await host.call(sessionId, {
|
|
196
|
+
query: typeof args?.query === "string" ? args.query : undefined,
|
|
197
|
+
limit: typeof args?.limit === "number" ? args.limit : undefined,
|
|
198
|
+
targetSeq: typeof args?.target_seq === "number" ? args.target_seq : undefined,
|
|
199
|
+
});
|
|
200
|
+
if (payload?.ok !== true) {
|
|
201
|
+
return { kind: "error", error: String(payload?.error ?? "unavailable") };
|
|
202
|
+
}
|
|
203
|
+
return payload.value;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return {
|
|
206
|
+
kind: "error",
|
|
207
|
+
error: error instanceof Error ? error.message : String(error),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 注册只读工具。`tools` 服务缺失或注册失败时静默跳过。
|
|
216
|
+
* @param {{ctx: any, host: {call: (sessionId: string, request: any) => Promise<any>}, logger?: any, onInstalled?: () => void}} input
|
|
217
|
+
* @returns {boolean} 是否注册成功(`tools` 服务就绪时会返回 true)
|
|
218
|
+
*/
|
|
219
|
+
export function installRewindTool(input) {
|
|
220
|
+
const { ctx, host, logger, onInstalled } = input;
|
|
221
|
+
const install = (/** @type {any} */ scope) => {
|
|
222
|
+
try {
|
|
223
|
+
const tools = scope?.tools ?? scope?.get?.("tools");
|
|
224
|
+
if (!tools || typeof tools.register !== "function") return;
|
|
225
|
+
const definition = createRewindToolDefinition(host);
|
|
226
|
+
const disposer = tools.register(definition);
|
|
227
|
+
scope?.effect?.(() => disposer, "session-rewind: read-only tool");
|
|
228
|
+
if (typeof onInstalled === "function") onInstalled();
|
|
229
|
+
return true;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
logger?.warn?.(
|
|
232
|
+
`session-rewind: failed to register ${TOOL_NAME}: ${error instanceof Error ? error.message : String(error)}`,
|
|
233
|
+
);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
try {
|
|
238
|
+
if (typeof ctx.inject === "function") {
|
|
239
|
+
ctx.inject(["tools"], install);
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
const tools = ctx.get?.("tools");
|
|
243
|
+
if (tools && typeof tools.register === "function") {
|
|
244
|
+
install({ tools, effect: ctx.effect?.bind(ctx) });
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
} catch (error) {
|
|
249
|
+
logger?.warn?.(
|
|
250
|
+
`session-rewind: cannot install ${TOOL_NAME}: ${error instanceof Error ? error.message : String(error)}`,
|
|
251
|
+
);
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
package/package.json
CHANGED