dsh-auto-compaction 0.1.3 → 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/README.md +2 -2
- package/cordis.patch.yml +10 -0
- package/lib/client.js +880 -0
- package/lib/index.js +234 -600
- package/package.json +18 -9
- package/lib/plan.js +0 -79
- package/lib/status.js +0 -52
- package/lib/summary.js +0 -147
- package/scripts/preset-scope.mjs +0 -409
- package/scripts/row-native.yml +0 -6
- package/scripts/row-plugin.yml +0 -10
package/lib/index.js
CHANGED
|
@@ -1,650 +1,284 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* dsh-
|
|
2
|
+
* dsh-auto-compaction — node half (bundle).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Registers no service (so it can be mounted alongside the engine without any
|
|
5
|
+
* risk of a duplicate `ctx.compaction`). Its only job is to mirror its own
|
|
6
|
+
* configuration into the sidecar file the engine reads on every decision:
|
|
5
7
|
*
|
|
6
|
-
*
|
|
7
|
-
* for the agent to go idle, then asks the routed model — replaying the current
|
|
8
|
-
* conversation as the prompt prefix so the provider's cache is reused — for a
|
|
9
|
-
* handoff checkpoint, and replaces the whole compactable surface with that one
|
|
10
|
-
* message. The next user turn therefore starts from system prompt + tools +
|
|
11
|
-
* handoff text, and nothing else.
|
|
8
|
+
* %USERPROFILE%\.dsh-handoff-compaction\settings.json
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* template and the idle gate differ.
|
|
10
|
+
* Writing on every (re)load is what makes a settings save take effect live:
|
|
11
|
+
* DSH reloads this plugin, this function runs, the engine picks the file up at
|
|
12
|
+
* its next idle check.
|
|
17
13
|
*/
|
|
18
14
|
import z from "@deepseek-ai/schemastery";
|
|
19
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync,
|
|
15
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { createRequire } from "node:module";
|
|
20
17
|
import { homedir } from "node:os";
|
|
21
|
-
import { join } from "node:path";
|
|
22
|
-
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const DEFAULT_TEMPLATE_FILE = join(SETTINGS_DIR, "template.md");
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { execFile, spawn } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
export const name = "dsh-auto-compaction";
|
|
22
|
+
export const inject = [];
|
|
23
|
+
|
|
24
|
+
export const Config = z.object({
|
|
25
|
+
enabled: z.boolean().default(true),
|
|
26
|
+
thresholdRatio: z.number().default(90),
|
|
27
|
+
scopePreset: z.boolean().default(false),
|
|
28
|
+
presets: z.string().default("lightweight-local"),
|
|
29
|
+
templateFile: z.string().default(""),
|
|
30
|
+
openTemplate: z.string().default(""),
|
|
31
|
+
displayStyle: z.string().default("orbit"),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const DIR = join(homedir(), ".dsh-handoff-compaction");
|
|
35
|
+
const SETTINGS_FILE = join(DIR, "settings.json");
|
|
36
|
+
const TEMPLATE_FILE = join(DIR, "template.md");
|
|
37
|
+
const UI_LOG = join(DIR, "ui.log");
|
|
38
|
+
const STATUS_DIR = process.env.DSH_HANDOFF_STATUS_DIR || join(DIR, "status");
|
|
39
|
+
const STATUS_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
44
40
|
|
|
45
41
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
42
|
+
* Read the engine's per-session status sidecar without importing the engine
|
|
43
|
+
* package: DSH's host module fallback cannot resolve a linked package from the
|
|
44
|
+
* UI package's real path, and a top-level import here blocks startup.
|
|
48
45
|
*/
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
function readStatus(sessionId) {
|
|
47
|
+
if (typeof sessionId !== "string" || !STATUS_SESSION_ID.test(sessionId)) return undefined;
|
|
51
48
|
try {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
const parsed = JSON.parse(readFileSync(SETTINGS_FILE, "utf8"));
|
|
55
|
-
settingsCache = { mtimeMs: stat.mtimeMs, value: parsed && typeof parsed === "object" ? parsed : undefined };
|
|
56
|
-
return settingsCache.value;
|
|
49
|
+
const parsed = JSON.parse(readFileSync(join(STATUS_DIR, sessionId + ".json"), "utf8"));
|
|
50
|
+
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
57
51
|
} catch {
|
|
58
52
|
return undefined;
|
|
59
53
|
}
|
|
60
54
|
}
|
|
55
|
+
const requireFromUi = createRequire(import.meta.url);
|
|
61
56
|
|
|
62
|
-
function
|
|
63
|
-
|
|
64
|
-
const env = process.env.DSH_HANDOFF_TEMPLATE;
|
|
65
|
-
if (typeof env === "string" && env.trim().length > 0) return env.trim();
|
|
66
|
-
return DEFAULT_TEMPLATE_FILE;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Read the handoff template, falling back to the built-in text. */
|
|
70
|
-
function readTemplate(filePath, autoCreate) {
|
|
71
|
-
try {
|
|
72
|
-
if (!existsSync(filePath)) {
|
|
73
|
-
if (!autoCreate) return { text: undefined, source: "builtin (file missing)" };
|
|
74
|
-
mkdirSync(join(filePath, ".."), { recursive: true });
|
|
75
|
-
writeFileSync(filePath, BUILTIN_INSTRUCTION + "\n", "utf8");
|
|
76
|
-
return { text: BUILTIN_INSTRUCTION, source: "builtin (written to " + filePath + ")" };
|
|
77
|
-
}
|
|
78
|
-
const text = readFileSync(filePath, "utf8").replace(/^\uFEFF/, "");
|
|
79
|
-
if (text.trim().length < 20) return { text: undefined, source: "builtin (file empty)" };
|
|
80
|
-
return { text, source: filePath };
|
|
81
|
-
} catch (error) {
|
|
82
|
-
return { text: undefined, source: `builtin (read failed: ${error instanceof Error ? error.message : String(error)})` };
|
|
83
|
-
}
|
|
57
|
+
function trace(message) {
|
|
58
|
+
try { mkdirSync(DIR, { recursive: true }); appendFileSync(UI_LOG, new Date().toISOString() + " " + message + String.fromCharCode(10), "utf8"); } catch { /* ignore */ }
|
|
84
59
|
}
|
|
85
60
|
|
|
61
|
+
let lastOpenRequest = "";
|
|
86
62
|
|
|
87
|
-
const
|
|
88
|
-
"你现在要为本会话写一份交接文档:我会用它替换掉上面的全部对话记录,然后由你在一个空白的上下文里继续同一件事。写漏的东西将永久丢失。",
|
|
89
|
-
"",
|
|
90
|
-
"只输出 Markdown,严格按下面小节顺序,每节用短横线要点,不许省略任何小节;没有内容写「(无)」。",
|
|
91
|
-
"",
|
|
92
|
-
"## 用户目标与原始要求",
|
|
93
|
-
"- 用户最初想做什么、之后怎么改过口;关键原话逐字引用",
|
|
94
|
-
"",
|
|
95
|
-
"## 当前任务状态",
|
|
96
|
-
"- 在做什么、进行到哪一步、哪些已完成、哪些没完成",
|
|
97
|
-
"",
|
|
98
|
-
"## 涉及的文件与代码",
|
|
99
|
-
"- 精确路径:为什么重要、改了什么、还有哪里没改",
|
|
100
|
-
"",
|
|
101
|
-
"## 已验证的事实与命令",
|
|
102
|
-
"- 跑过的命令、测试结果、报错原文、环境约束(版本、端口、路径)",
|
|
103
|
-
"",
|
|
104
|
-
"## 关键决策与被放弃的方案",
|
|
105
|
-
"- 选了什么、为什么;试过但放弃的方案及放弃原因",
|
|
106
|
-
"",
|
|
107
|
-
"## 待办与下一步",
|
|
108
|
-
"- 用户明确要求但尚未完成的项;紧接着要执行的第一步",
|
|
109
|
-
"",
|
|
110
|
-
"## 风险与注意事项",
|
|
111
|
-
"- 容易踩的坑、用户的偏好与禁忌",
|
|
112
|
-
"",
|
|
113
|
-
"规则:",
|
|
114
|
-
"- 保留精确的路径、命令、错误串、标识符、数值、函数签名;不要概括成\"修改了相关文件\"这类废话。",
|
|
115
|
-
"- 如果对话里有用户粘贴的长文档或上一轮交接文档(例如包在 <compacted-summary> 标记里的那份),不要原样照抄:保留仍然生效的硬约束(路径、命令、版本、验收标准)并逐字引用其关键句,其余概括;过时的内容删掉,最终只输出一份同样结构的文档。",
|
|
116
|
-
"- 如果用户的早期消息给出了一份磁盘上的工程交接文档的绝对路径,必须逐字保留该路径并注明\"需要时重新读取该文件\";不要把它的内容当成必须在本文档里复述的东西。",
|
|
117
|
-
"- 不要提到这份交接文档本身,也不要说上下文被压缩或摘要。",
|
|
118
|
-
"- 只输出文档正文,不要调用任何工具。",
|
|
119
|
-
"",
|
|
120
|
-
"写入时还要记住下面两条,它们会被交给后续的你执行:",
|
|
121
|
-
"- 更早的对话消息不会留在你的上下文里,也没有任何工具或磁盘检索能把它们找回来。不要去搜索会话文件、日志或历史记录还原旧对话;文档里没写的,就当作不知道,直接说不知道。",
|
|
122
|
-
"- 用户问起\"之前说过什么\"时,以本文档为准;文档没有逐字保留的内容(例如旧回答的原文),直接说明已经被压缩掉,不要编造,也不要为了找它去翻文件。",
|
|
123
|
-
].join("\n");
|
|
63
|
+
const DISPLAY_STYLES = new Set(["orbit", "strip", "ring", "topbar"]);
|
|
124
64
|
|
|
125
|
-
function
|
|
126
|
-
|
|
127
|
-
case "error":
|
|
128
|
-
case "aborted":
|
|
129
|
-
return new LlmError(finish.failure.message, finish.failure.code, finish.failure);
|
|
130
|
-
case "max-tokens": {
|
|
131
|
-
const error = new Error("handoff summary truncated at the token cap (incomplete checkpoint)");
|
|
132
|
-
error.code = "MAX_TOKENS";
|
|
133
|
-
return error;
|
|
134
|
-
}
|
|
135
|
-
default:
|
|
136
|
-
return undefined;
|
|
137
|
-
}
|
|
65
|
+
function normalizeDisplayStyle(value) {
|
|
66
|
+
return typeof value === "string" && DISPLAY_STYLES.has(value) ? value : "orbit";
|
|
138
67
|
}
|
|
139
68
|
|
|
140
|
-
function
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
69
|
+
function normalize(config) {
|
|
70
|
+
const raw = Number(config.thresholdRatio ?? 90);
|
|
71
|
+
const pct = Number.isFinite(raw) ? Math.max(1, Math.min(100, raw)) : 90;
|
|
72
|
+
return {
|
|
73
|
+
enabled: config.enabled !== false,
|
|
74
|
+
thresholdRatio: pct / 100,
|
|
75
|
+
scope: config.scopePreset === true ? "preset" : "global",
|
|
76
|
+
presets: String(config.presets ?? "")
|
|
77
|
+
.split(/[,,\s]+/)
|
|
78
|
+
.map((part) => part.trim())
|
|
79
|
+
.filter((part) => part.length > 0),
|
|
80
|
+
templateFile: String(config.templateFile ?? "").trim(),
|
|
81
|
+
displayStyle: normalizeDisplayStyle(config.displayStyle),
|
|
82
|
+
};
|
|
150
83
|
}
|
|
151
84
|
|
|
152
|
-
function
|
|
153
|
-
|
|
85
|
+
function writeSettings(ctx, settings) {
|
|
86
|
+
try {
|
|
87
|
+
mkdirSync(DIR, { recursive: true });
|
|
88
|
+
writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
89
|
+
ctx.logger?.info?.(`[handoff-ui] settings -> ${SETTINGS_FILE} (${JSON.stringify(settings)})`);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
ctx.logger?.warn?.(`[handoff-ui] settings write failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
92
|
+
}
|
|
154
93
|
}
|
|
155
94
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
retainRatio: z.number(),
|
|
163
|
-
retainTokens: z.number(),
|
|
164
|
-
summarizationProvider: z.string(),
|
|
165
|
-
summarizationModel: z.string(),
|
|
166
|
-
maxTokens: z.number(),
|
|
167
|
-
compactionRetries: z.number(),
|
|
168
|
-
maxOverflowRetries: z.number(),
|
|
169
|
-
contextWindowOverride: z.number(),
|
|
170
|
-
reservedCompletionTokensOverride: z.number(),
|
|
171
|
-
idleDelayMs: z.number(),
|
|
172
|
-
minIntervalMs: z.number(),
|
|
173
|
-
minTokenGrowth: z.number(),
|
|
174
|
-
timeoutMs: z.number(),
|
|
175
|
-
dryRun: z.boolean(),
|
|
176
|
-
handoffInstruction: z.string(),
|
|
177
|
-
traceFile: z.string(),
|
|
178
|
-
traceConsole: z.boolean(),
|
|
179
|
-
templateFile: z.string(),
|
|
180
|
-
presetId: z.string(),
|
|
181
|
-
autoCreateTemplate: z.boolean(),
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
constructor(ctx, config = {}) {
|
|
185
|
-
const {
|
|
186
|
-
contextWindowOverride,
|
|
187
|
-
reservedCompletionTokensOverride,
|
|
188
|
-
idleDelayMs,
|
|
189
|
-
minIntervalMs,
|
|
190
|
-
minTokenGrowth,
|
|
191
|
-
timeoutMs,
|
|
192
|
-
dryRun,
|
|
193
|
-
handoffInstruction,
|
|
194
|
-
traceFile,
|
|
195
|
-
traceConsole,
|
|
196
|
-
templateFile,
|
|
197
|
-
presetId,
|
|
198
|
-
autoCreateTemplate,
|
|
199
|
-
...base
|
|
200
|
-
} = config;
|
|
201
|
-
// The stock trigger is registered so the plugin switch can hand compaction
|
|
202
|
-
// back to the native engine live; compactIfNeeded() suppresses it while the
|
|
203
|
-
// handoff path is enabled.
|
|
204
|
-
super(ctx, { ...base, auto: true });
|
|
205
|
-
this.handoff = {
|
|
206
|
-
instruction: typeof handoffInstruction === "string" && handoffInstruction.trim().length > 0 ? handoffInstruction : undefined,
|
|
207
|
-
contextWindowOverride: contextWindowOverride ?? 0,
|
|
208
|
-
reservedOverride: reservedCompletionTokensOverride ?? -1,
|
|
209
|
-
idleDelayMs: idleDelayMs ?? 1_500,
|
|
210
|
-
minIntervalMs: minIntervalMs ?? 60_000,
|
|
211
|
-
minTokenGrowth: minTokenGrowth ?? 2_048,
|
|
212
|
-
timeoutMs: timeoutMs ?? 600_000,
|
|
213
|
-
dryRun: dryRun ?? false,
|
|
214
|
-
templateFile: templatePathFrom(templateFile ?? readSettings()?.templateFile),
|
|
215
|
-
presetId: typeof presetId === "string" ? presetId : "",
|
|
216
|
-
autoCreateTemplate: autoCreateTemplate !== false,
|
|
217
|
-
};
|
|
218
|
-
this.handoff.traceFile = typeof traceFile === "string" ? traceFile : "";
|
|
219
|
-
this.handoff.traceConsole = traceConsole === true;
|
|
220
|
-
// Materialise the template so the settings card can point at a real file.
|
|
221
|
-
try { readTemplate(this.handoff.templateFile, this.handoff.autoCreateTemplate); } catch { /* never block construction */ }
|
|
222
|
-
this.attempts = new WeakMap();
|
|
223
|
-
this.running = new WeakSet();
|
|
224
|
-
this.timers = new WeakMap();
|
|
225
|
-
this.agents = new WeakMap();
|
|
226
|
-
ctx.on("agent/status", ({ agent, status }) => {
|
|
227
|
-
if (agent?.session) this.agents.set(agent.session, agent);
|
|
228
|
-
if (status === "idle") this.scheduleCheck(agent, "agent/status idle");
|
|
229
|
-
});
|
|
230
|
-
// A finished turn is the gap the user described; agent/status is not the only
|
|
231
|
-
// place DSH reports it, so both paths are wired.
|
|
232
|
-
ctx.on("session/event", (session, event) => {
|
|
233
|
-
if (event?.type !== "turn/end") return;
|
|
234
|
-
const owner = this.agents.get(session);
|
|
235
|
-
if (owner !== undefined) this.scheduleCheck(owner, "turn/end");
|
|
95
|
+
function launch(ctx, label, command, args) {
|
|
96
|
+
try {
|
|
97
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: false });
|
|
98
|
+
child.on("error", (error) => {
|
|
99
|
+
trace("spawn error " + label + " (" + command + "): " + (error instanceof Error ? error.message : String(error)));
|
|
100
|
+
ctx.logger?.warn?.(`[handoff-ui] ${label} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
236
101
|
});
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Settings switch. Missing settings or a missing field keeps the plugin on;
|
|
246
|
-
* only an explicit `enabled: false` hands compaction back to the stock engine.
|
|
247
|
-
*/
|
|
248
|
-
handoffEnabled() {
|
|
249
|
-
const live = readSettings();
|
|
250
|
-
return !(live !== undefined && live.enabled === false);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/**
|
|
254
|
-
* The base registers its between-step pressure / overflow triggers whenever
|
|
255
|
-
* `auto` is on. While the handoff path is enabled they must stay inert, so
|
|
256
|
-
* the plugin still only compacts from its idle gate. Disabled: delegate, and
|
|
257
|
-
* DSH's native compaction behaves exactly as without the plugin.
|
|
258
|
-
*/
|
|
259
|
-
async compactIfNeeded(agent, trigger, signal) {
|
|
260
|
-
if (this.handoffEnabled()) return null;
|
|
261
|
-
return super.compactIfNeeded(agent, trigger, signal);
|
|
102
|
+
child.on("spawn", () => trace("spawned " + label + " (" + command + ")"));
|
|
103
|
+
child.unref();
|
|
104
|
+
return true;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
trace("spawn threw " + label + ": " + (error instanceof Error ? error.message : String(error)));
|
|
107
|
+
return false;
|
|
262
108
|
}
|
|
109
|
+
}
|
|
263
110
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
111
|
+
async function handleOpen(ctx, request, target) {
|
|
112
|
+
const trimmed = String(request ?? "").trim();
|
|
113
|
+
if (trimmed === "" || trimmed === lastOpenRequest) return { ok: true, value: { skipped: true } };
|
|
114
|
+
lastOpenRequest = trimmed;
|
|
115
|
+
const mode = trimmed.startsWith("folder") ? "folder" : "file";
|
|
116
|
+
const root = process.env.SystemRoot || "C:\\Windows";
|
|
117
|
+
const explorerPath = join(root, "explorer.exe");
|
|
118
|
+
const explorer = existsSync(explorerPath) ? explorerPath : "explorer.exe";
|
|
119
|
+
|
|
120
|
+
trace("open requested mode=" + mode + " target=" + target + " explorer=" + explorer);
|
|
121
|
+
if (!existsSync(target)) {
|
|
122
|
+
const message = "file not found: " + target;
|
|
123
|
+
trace("open failed: " + message);
|
|
124
|
+
return { ok: false, error: { code: "auto-compaction/open-missing", message } };
|
|
275
125
|
}
|
|
276
126
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
this.timers.set(agent, timer);
|
|
287
|
-
}
|
|
127
|
+
// Launch through the existing Explorer shell. DSH's host runs plugin
|
|
128
|
+
// children in a job object; cmd/PowerShell descendants are killed with the
|
|
129
|
+
// request, while Explorer hands the file to the shell and exits.
|
|
130
|
+
const args = mode === "folder" ? ["/select," + target] : [target];
|
|
131
|
+
const ok = launch(ctx, mode + "/explorer", explorer, args);
|
|
132
|
+
return ok
|
|
133
|
+
? { ok: true, value: { mode, target, opener: "explorer" } }
|
|
134
|
+
: { ok: false, error: { code: "auto-compaction/open-failed", message: "explorer failed to start" } };
|
|
135
|
+
}
|
|
288
136
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return
|
|
137
|
+
function scopeScriptPath() {
|
|
138
|
+
try {
|
|
139
|
+
const entry = requireFromUi.resolve("dsh-auto-compaction-engine");
|
|
140
|
+
return join(dirname(entry), "..", "scripts", "preset-scope.mjs");
|
|
141
|
+
} catch {
|
|
142
|
+
return join(homedir(), ".dsh-local-pkgs", "dsh-auto-compaction-engine", "scripts", "preset-scope.mjs");
|
|
295
143
|
}
|
|
144
|
+
}
|
|
296
145
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (target === undefined) return this.log(`${why}: no routed provider/model yet; skipping`);
|
|
306
|
-
|
|
307
|
-
const info = await this.ctx.llm.resolveModelInfo(target.provider, target.model);
|
|
308
|
-
const headerMaxTokens = session.requestHeader?.()?.config?.maxTokens;
|
|
309
|
-
const reserved = this.handoff.reservedOverride >= 0
|
|
310
|
-
? this.handoff.reservedOverride
|
|
311
|
-
: (headerMaxTokens ?? info.defaultMaxTokens ?? 0);
|
|
312
|
-
const contextWindow = this.handoff.contextWindowOverride > 0
|
|
313
|
-
? this.handoff.contextWindowOverride
|
|
314
|
-
: info.context?.contextWindow;
|
|
315
|
-
|
|
316
|
-
const live = readSettings();
|
|
317
|
-
const scope = live?.scope === "preset" ? "preset" : "global";
|
|
318
|
-
const selectedPresets = Array.isArray(live?.presets) ? live.presets : [];
|
|
319
|
-
if (scope === "preset" && this.handoff.presetId !== "" && !selectedPresets.includes(this.handoff.presetId)) {
|
|
320
|
-
return this.log(`${why}: skipped by scope (preset "${this.handoff.presetId}" not selected)`);
|
|
321
|
-
}
|
|
322
|
-
const liveRatio = typeof live?.thresholdRatio === "number" && live.thresholdRatio > 0 && live.thresholdRatio <= 1
|
|
323
|
-
? live.thresholdRatio
|
|
324
|
-
: undefined;
|
|
325
|
-
|
|
326
|
-
const decision = decideTrigger({
|
|
327
|
-
totalTokens: measurement.totalTokens,
|
|
328
|
-
contextWindow,
|
|
329
|
-
reservedCompletionTokens: reserved,
|
|
330
|
-
headroomTokens: this.config.headroomTokens ?? 0,
|
|
331
|
-
thresholdRatio: liveRatio ?? this.config.thresholdRatio ?? 0.9,
|
|
332
|
-
});
|
|
333
|
-
if (!decision.trigger) {
|
|
334
|
-
return this.log(`${why}: ${measurement.totalTokens}/${decision.thresholdTokens ?? "?"} tokens${decision.ok === false ? ` (${decision.reason})` : ""}`);
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
const last = this.attempts.get(agent);
|
|
338
|
-
if (!shouldAttempt({
|
|
339
|
-
now: Date.now(),
|
|
340
|
-
last,
|
|
341
|
-
tokens: measurement.totalTokens,
|
|
342
|
-
thresholdTokens: decision.thresholdTokens,
|
|
343
|
-
minIntervalMs: this.handoff.minIntervalMs,
|
|
344
|
-
minTokenGrowth: this.handoff.minTokenGrowth,
|
|
345
|
-
})) {
|
|
346
|
-
return this.log(`over threshold (${measurement.totalTokens} >= ${decision.thresholdTokens}) but holding: retry gate`);
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
this.attempts.set(agent, { at: Date.now(), tokens: measurement.totalTokens });
|
|
350
|
-
if (this.handoff.dryRun) {
|
|
351
|
-
return this.log(`DRY-RUN: would hand off now (${measurement.totalTokens} >= ${decision.thresholdTokens} of an assumed ${contextWindow}-token window)`);
|
|
146
|
+
function execSwitchOnce(command, env, script) {
|
|
147
|
+
return new Promise((resolve, reject) => {
|
|
148
|
+
execFile(command, [script, "enable"], { timeout: 6000, windowsHide: true, env }, (error, stdout, stderr) => {
|
|
149
|
+
if (error) {
|
|
150
|
+
error.stdout = stdout;
|
|
151
|
+
error.stderr = stderr;
|
|
152
|
+
reject(error);
|
|
153
|
+
return;
|
|
352
154
|
}
|
|
155
|
+
resolve({ stdout, stderr });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
353
159
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
});
|
|
366
|
-
try {
|
|
367
|
-
const result = await this.compactNow(agent, AbortSignal.timeout(this.handoff.timeoutMs));
|
|
368
|
-
if (result === null || result === undefined) {
|
|
369
|
-
writeStatus(session.id, {
|
|
370
|
-
phase: "done",
|
|
371
|
-
step: "done",
|
|
372
|
-
completedAt: Date.now(),
|
|
373
|
-
shadowedNodes: 0,
|
|
374
|
-
shadowedTokens: 0,
|
|
375
|
-
note: "no compactable range",
|
|
376
|
-
});
|
|
377
|
-
this.log("idle: no compactable range; nothing replaced", "warn");
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
writeStatus(session.id, {
|
|
381
|
-
phase: "done",
|
|
382
|
-
step: "done",
|
|
383
|
-
completedAt: Date.now(),
|
|
384
|
-
shadowedNodes: result.shadowedSeqs.length,
|
|
385
|
-
shadowedTokens: result.shadowedTokenCount,
|
|
386
|
-
missingSections: [],
|
|
387
|
-
retrying: false,
|
|
388
|
-
});
|
|
389
|
-
this.log(`committed: replaced ${result.shadowedSeqs.length} node(s) (~${result.shadowedTokenCount} tokens) with the handoff checkpoint`);
|
|
390
|
-
} catch (error) {
|
|
391
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
392
|
-
writeStatus(agent.session?.id, {
|
|
393
|
-
phase: "failed",
|
|
394
|
-
step: "failed",
|
|
395
|
-
failedAt: Date.now(),
|
|
396
|
-
error: message,
|
|
397
|
-
errorCode: errorCodeOf(error),
|
|
398
|
-
});
|
|
399
|
-
throw error;
|
|
400
|
-
} finally {
|
|
401
|
-
this.running.delete(agent);
|
|
402
|
-
}
|
|
403
|
-
} catch (error) {
|
|
404
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
405
|
-
writeStatus(agent.session?.id, {
|
|
406
|
-
phase: "failed",
|
|
407
|
-
step: "failed",
|
|
408
|
-
failedAt: Date.now(),
|
|
409
|
-
error: message,
|
|
410
|
-
errorCode: errorCodeOf(error),
|
|
411
|
-
});
|
|
412
|
-
this.log(`failed: ${message}`, "warn");
|
|
413
|
-
}
|
|
160
|
+
/**
|
|
161
|
+
* Apply the current settings sidecar to the preset/profile YAML. `node` is the
|
|
162
|
+
* normal path; the Electron binary with ELECTRON_RUN_AS_NODE is the fallback
|
|
163
|
+
* when the host has no node on PATH.
|
|
164
|
+
*/
|
|
165
|
+
async function applyScopeSwitch() {
|
|
166
|
+
const script = scopeScriptPath();
|
|
167
|
+
if (!existsSync(script)) {
|
|
168
|
+
const message = "preset-scope.mjs not found: " + script;
|
|
169
|
+
trace("scope switch failed: " + message);
|
|
170
|
+
return { ok: false, message };
|
|
414
171
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
if (!this.handoffEnabled()) return super.compactNow(agent, signal, sourceCommandId);
|
|
425
|
-
const sessionId = agent?.session?.id;
|
|
426
|
-
let totalTokens;
|
|
427
|
-
try { totalTokens = this.ctx.tokenMeter.measure(agent.session).totalTokens; } catch { totalTokens = undefined; }
|
|
428
|
-
writeStatus(sessionId, {
|
|
429
|
-
phase: "running",
|
|
430
|
-
step: "prepare",
|
|
431
|
-
startedAt: Date.now(),
|
|
432
|
-
timeoutMs: this.handoff.timeoutMs,
|
|
433
|
-
...(typeof totalTokens === "number" ? { totalTokens } : {}),
|
|
434
|
-
retrying: false,
|
|
435
|
-
writing: false,
|
|
436
|
-
missingSections: [],
|
|
437
|
-
error: "",
|
|
438
|
-
errorCode: "",
|
|
439
|
-
});
|
|
172
|
+
const explicit = process.env.DSH_NODE;
|
|
173
|
+
const attempts = typeof explicit === "string" && explicit.trim().length > 0
|
|
174
|
+
? [{ command: explicit.trim(), env: process.env }]
|
|
175
|
+
: [
|
|
176
|
+
{ command: "node", env: process.env },
|
|
177
|
+
{ command: process.execPath, env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" } },
|
|
178
|
+
];
|
|
179
|
+
let lastError;
|
|
180
|
+
for (const attempt of attempts) {
|
|
440
181
|
try {
|
|
441
|
-
const result = await
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
completedAt: Date.now(),
|
|
446
|
-
shadowedNodes: Array.isArray(result?.shadowedSeqs) ? result.shadowedSeqs.length : 0,
|
|
447
|
-
shadowedTokens: typeof result?.shadowedTokenCount === "number" ? result.shadowedTokenCount : 0,
|
|
448
|
-
missingSections: [],
|
|
449
|
-
retrying: false,
|
|
450
|
-
writing: false,
|
|
451
|
-
...(result === null || result === undefined ? { note: "no compactable range" } : {}),
|
|
452
|
-
});
|
|
453
|
-
return result;
|
|
182
|
+
const result = await execSwitchOnce(attempt.command, attempt.env, script);
|
|
183
|
+
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`.trim();
|
|
184
|
+
trace("scope switch ok" + (output ? " | " + output : ""));
|
|
185
|
+
return { ok: true, output };
|
|
454
186
|
} catch (error) {
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
phase: "failed",
|
|
458
|
-
step: "failed",
|
|
459
|
-
failedAt: Date.now(),
|
|
460
|
-
error: message,
|
|
461
|
-
errorCode: errorCodeOf(error),
|
|
462
|
-
});
|
|
463
|
-
throw error;
|
|
187
|
+
lastError = error;
|
|
188
|
+
if (error?.code !== "ENOENT") break;
|
|
464
189
|
}
|
|
465
190
|
}
|
|
191
|
+
const output = `${lastError?.stdout ?? ""}${lastError?.stderr ?? ""}`.trim();
|
|
192
|
+
const message = (lastError?.message ?? String(lastError)) + (output ? "\n" + output : "");
|
|
193
|
+
trace("scope switch failed: " + message);
|
|
194
|
+
return { ok: false, message };
|
|
195
|
+
}
|
|
466
196
|
|
|
467
|
-
|
|
468
|
-
async summarize(input, agent, signal) {
|
|
469
|
-
// Native pressure/overflow compaction runs through this same hook, so the
|
|
470
|
-
// stock prompt must come back the moment the plugin is switched off.
|
|
471
|
-
if (!this.handoffEnabled()) return super.summarize(input, agent, signal);
|
|
472
|
-
const latest = agent.session.requestHeader?.()?.config;
|
|
473
|
-
const configured = this.config.summarizationProvider && this.config.summarizationModel
|
|
474
|
-
? { provider: this.config.summarizationProvider, model: this.config.summarizationModel }
|
|
475
|
-
: undefined;
|
|
476
|
-
const agentTarget = agent.options?.provider && agent.options?.model
|
|
477
|
-
? { provider: agent.options.provider, model: agent.options.model }
|
|
478
|
-
: undefined;
|
|
479
|
-
const target = configured ?? latest ?? agentTarget;
|
|
480
|
-
if (target === undefined) throw new Error("no provider/model available for the handoff summary");
|
|
481
|
-
// Replaying the prefix only reuses the provider cache when the auxiliary
|
|
482
|
-
// request carries the same routing knobs as the routed one. Strata writes
|
|
483
|
-
// the reasoning directive into the prefix, so a missing effort breaks the
|
|
484
|
-
// cache a few thousand tokens in. Only inherit it when the summary target
|
|
485
|
-
// is the same model that produced the latest request.
|
|
486
|
-
const sameTarget = latest !== undefined && target.provider === latest.provider && target.model === latest.model;
|
|
487
|
-
const reasoningEffort = sameTarget ? latest.reasoningEffort : undefined;
|
|
488
|
-
|
|
489
|
-
const resolved = this.handoff.instruction === undefined
|
|
490
|
-
? readTemplate(this.handoff.templateFile, this.handoff.autoCreateTemplate)
|
|
491
|
-
: { text: this.handoff.instruction, source: "config handoffInstruction" };
|
|
492
|
-
const instruction = typeof resolved.text === "string" && resolved.text.trim().length > 0
|
|
493
|
-
? resolved.text
|
|
494
|
-
: BUILTIN_INSTRUCTION;
|
|
495
|
-
this.log(`template: ${resolved.source}${resolved.text === undefined ? " (fallback)" : ""}`);
|
|
496
|
-
|
|
497
|
-
const expected = expectedSections(instruction);
|
|
498
|
-
const baseMessages = [
|
|
499
|
-
...input.messages,
|
|
500
|
-
Object.freeze({ role: "user", content: [{ type: "text", text: instruction }] }),
|
|
501
|
-
];
|
|
502
|
-
const baseOptions = {
|
|
503
|
-
provider: target.provider,
|
|
504
|
-
model: target.model,
|
|
505
|
-
toolHistory: agent.session.toolHistory(),
|
|
506
|
-
...(input.tools === undefined ? {} : { tools: [...input.tools] }),
|
|
507
|
-
maxTokens: this.config.maxTokens,
|
|
508
|
-
...(reasoningEffort === undefined ? {} : { reasoningEffort }),
|
|
509
|
-
sessionId: agent.session.id,
|
|
510
|
-
purpose: "compaction",
|
|
511
|
-
...(signal === undefined ? {} : { signal }),
|
|
512
|
-
};
|
|
513
|
-
const sessionId = agent.session?.id;
|
|
197
|
+
const CHANNEL = "/dsh-auto-compaction";
|
|
514
198
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
reason: "not attempted",
|
|
524
|
-
};
|
|
525
|
-
const attempts = [];
|
|
199
|
+
function readSettings() {
|
|
200
|
+
try {
|
|
201
|
+
const parsed = JSON.parse(readFileSync(SETTINGS_FILE, "utf8"));
|
|
202
|
+
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
203
|
+
} catch {
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
526
207
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
208
|
+
function sanitize(payload) {
|
|
209
|
+
const pct = Number(payload?.thresholdRatio);
|
|
210
|
+
const ratio = Number.isFinite(pct) ? Math.max(1, Math.min(100, pct)) : 90;
|
|
211
|
+
const presets = Array.isArray(payload?.presets)
|
|
212
|
+
? payload.presets.filter((v) => typeof v === "string" && v.trim().length > 0)
|
|
213
|
+
: String(payload?.presets ?? "").split(/[,,s]+/).filter((v) => v.trim().length > 0);
|
|
214
|
+
return {
|
|
215
|
+
enabled: payload?.enabled !== false,
|
|
216
|
+
thresholdRatio: ratio / 100,
|
|
217
|
+
scope: payload?.scope === "preset" ? "preset" : "global",
|
|
218
|
+
presets,
|
|
219
|
+
templateFile: String(payload?.templateFile ?? "").trim(),
|
|
220
|
+
displayStyle: normalizeDisplayStyle(payload?.displayStyle),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
538
223
|
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
...baseMessages,
|
|
546
|
-
Object.freeze({ role: "assistant", content: [{ type: "text", text: combined }] }),
|
|
547
|
-
Object.freeze({ role: "user", content: [{ type: "text", text: continuationInstruction(validation) }] }),
|
|
548
|
-
]
|
|
549
|
-
: [
|
|
550
|
-
...baseMessages,
|
|
551
|
-
Object.freeze({
|
|
552
|
-
role: "user",
|
|
553
|
-
content: [{ type: "text", text: rewriteInstruction(validation, this.config.maxTokens) }],
|
|
554
|
-
}),
|
|
555
|
-
];
|
|
224
|
+
export function apply(ctx, config = {}) {
|
|
225
|
+
const seeded = normalize(config);
|
|
226
|
+
if (readSettings() === undefined) writeSettings(ctx, seeded);
|
|
227
|
+
void handleOpen(ctx, config.openTemplate, seeded.templateFile || TEMPLATE_FILE).catch((error) => {
|
|
228
|
+
ctx.logger?.warn?.(`[handoff-ui] openTemplate failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
229
|
+
});
|
|
556
230
|
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
? mergeSummary(combined, clean)
|
|
564
|
-
: clean;
|
|
565
|
-
rawOutput = result.rawOutput;
|
|
566
|
-
usage = mergeUsage(usage, result.usage);
|
|
567
|
-
validation = validateSummary(combined, expected);
|
|
568
|
-
if (truncatedNow) {
|
|
569
|
-
validation = {
|
|
570
|
-
...validation,
|
|
571
|
-
ok: false,
|
|
572
|
-
truncated: true,
|
|
573
|
-
reason: validation.reason.length > 0 ? `${validation.reason}; truncation marker` : "truncation marker",
|
|
574
|
-
};
|
|
231
|
+
ctx.inject(["connection"], (scope) => {
|
|
232
|
+
try {
|
|
233
|
+
const connection = scope.root?.connection ?? scope.connection;
|
|
234
|
+
if (connection === undefined) {
|
|
235
|
+
ctx.logger?.warn?.("[handoff-ui] no connection service; settings RPC unavailable");
|
|
236
|
+
return;
|
|
575
237
|
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
238
|
+
const dispose = connection.rpc.handle(CHANNEL, async (endpoint, payload) => {
|
|
239
|
+
try {
|
|
240
|
+
if (endpoint === "status") {
|
|
241
|
+
// A switched-off plugin leaves no live status behind; hide any
|
|
242
|
+
// sidecar left over from a compaction that ran before the switch.
|
|
243
|
+
if (readSettings()?.enabled === false) return { ok: true, value: null };
|
|
244
|
+
return { ok: true, value: readStatus(String(payload?.sessionId ?? "")) ?? null };
|
|
245
|
+
}
|
|
246
|
+
if (endpoint === "get") {
|
|
247
|
+
const stored = readSettings() ?? seeded;
|
|
248
|
+
return {
|
|
249
|
+
ok: true,
|
|
250
|
+
value: {
|
|
251
|
+
...stored,
|
|
252
|
+
enabled: stored.enabled !== false,
|
|
253
|
+
displayStyle: normalizeDisplayStyle(stored.displayStyle),
|
|
254
|
+
defaultTemplate: TEMPLATE_FILE,
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
if (endpoint === "set") {
|
|
259
|
+
const next = sanitize(payload);
|
|
260
|
+
trace("set " + JSON.stringify(next));
|
|
261
|
+
writeSettings(ctx, next);
|
|
262
|
+
const applied = await applyScopeSwitch();
|
|
263
|
+
if (!applied.ok) {
|
|
264
|
+
return { ok: false, error: { code: "auto-compaction/switch-failed", message: applied.message }, value: next };
|
|
265
|
+
}
|
|
266
|
+
return { ok: true, value: { ...next, switchApplied: true, defaultTemplate: TEMPLATE_FILE } };
|
|
267
|
+
}
|
|
268
|
+
if (endpoint === "open") {
|
|
269
|
+
trace("open " + JSON.stringify(payload));
|
|
270
|
+
return await handleOpen(ctx, String(payload?.kind ?? "file") + ":" + Date.now(), String(payload?.path ?? "") || TEMPLATE_FILE);
|
|
271
|
+
}
|
|
272
|
+
return { ok: false, error: { code: "auto-compaction/bad-request", message: "unknown endpoint: " + String(endpoint) } };
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return { ok: false, error: { code: "auto-compaction/failed", message: error instanceof Error ? error.message : String(error) } };
|
|
275
|
+
}
|
|
581
276
|
});
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
phase: "running",
|
|
588
|
-
step: "summary",
|
|
589
|
-
summaryAttempt: next,
|
|
590
|
-
retrying: true,
|
|
591
|
-
writing: next === MAX_SUMMARY_ATTEMPTS,
|
|
592
|
-
missingSections: validation.missing,
|
|
593
|
-
summaryChars: combined.length,
|
|
594
|
-
summaryAttempts: attempts,
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
if (!validation.ok) {
|
|
600
|
-
const error = new Error(`handoff summary incomplete after ${attempts.length} attempt(s): ${validation.reason}`);
|
|
601
|
-
error.code = "INCOMPLETE_SUMMARY";
|
|
602
|
-
error.attempts = attempts;
|
|
603
|
-
throw error;
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const finalText = combined.trim();
|
|
607
|
-
if (finalText.length === 0) throw new Error("handoff summary produced no text content");
|
|
608
|
-
writeStatus(sessionId, {
|
|
609
|
-
phase: "running",
|
|
610
|
-
step: "commit",
|
|
611
|
-
summaryAttempt: attempts.length,
|
|
612
|
-
retrying: false,
|
|
613
|
-
writing: false,
|
|
614
|
-
missingSections: [],
|
|
615
|
-
summaryChars: finalText.length,
|
|
616
|
-
summaryAttempts: attempts,
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
return {
|
|
620
|
-
summary: [{ type: "text", text: finalText }],
|
|
621
|
-
rawOutput,
|
|
622
|
-
llmStreamCall: true,
|
|
623
|
-
provider: target.provider,
|
|
624
|
-
model: target.model,
|
|
625
|
-
maxTokens: this.config.maxTokens,
|
|
626
|
-
...(usage === undefined ? {} : { usage }),
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/** One summary LLM call; max-tokens is returned as a partial, not thrown. */
|
|
631
|
-
async runSummaryRequest(baseOptions, messages) {
|
|
632
|
-
const assembler = new BlockAssembler();
|
|
633
|
-
for await (const chunk of this.ctx.llm.stream({ ...baseOptions, messages })) assembler.push(chunk);
|
|
634
|
-
const finish = assembler.finish ?? { kind: "stop" };
|
|
635
|
-
if (finish.kind === "error" || finish.kind === "aborted") {
|
|
636
|
-
const error = finishError(finish);
|
|
637
|
-
if (error !== undefined) throw error;
|
|
277
|
+
scope.effect(() => dispose, "auto-compaction settings rpc");
|
|
278
|
+
trace("rpc ready on " + CHANNEL);
|
|
279
|
+
ctx.logger?.info?.("[handoff-ui] settings RPC ready on " + CHANNEL);
|
|
280
|
+
} catch (error) {
|
|
281
|
+
ctx.logger?.warn?.("[handoff-ui] RPC registration failed: " + (error instanceof Error ? error.message : String(error)));
|
|
638
282
|
}
|
|
639
|
-
|
|
640
|
-
if (contentHasImage(rawOutput)) throw new LlmError("handoff summary cannot contain image output", "UNSUPPORTED_CONTENT");
|
|
641
|
-
const text = rawOutput.filter((block) => block.type === "text").map((block) => block.text).join("");
|
|
642
|
-
if (text.trim().length === 0) throw new Error("handoff summary produced no text content");
|
|
643
|
-
return {
|
|
644
|
-
rawOutput,
|
|
645
|
-
text,
|
|
646
|
-
finishKind: finish.kind,
|
|
647
|
-
usage: assembler.usage,
|
|
648
|
-
};
|
|
649
|
-
}
|
|
283
|
+
});
|
|
650
284
|
}
|