@better-than-coca/dsh-session-handoff 0.0.0-stage → 0.5.3
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 +160 -2
- package/client.js +837 -0
- package/cordis.patch.yml +31 -0
- package/lib/archive.js +224 -0
- package/lib/checkpoint.js +20 -0
- package/lib/config.js +90 -0
- package/lib/debug.js +13 -0
- package/lib/handoff.js +178 -0
- package/lib/history.js +103 -0
- package/lib/http.js +193 -0
- package/lib/index.js +722 -0
- package/lib/pressure.js +46 -0
- package/lib/route-card.js +130 -0
- package/lib/settings.js +155 -0
- package/lib/summary-engine.js +308 -0
- package/package.json +54 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,722 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-session-handoff
|
|
3
|
+
*
|
|
4
|
+
* Watches context pressure, asks the model to check in with the user before the
|
|
5
|
+
* context gets expensive, and turns an accepted handoff into
|
|
6
|
+
* "summarize this session, then create a brand-new session seeded with the
|
|
7
|
+
* checkpoint".
|
|
8
|
+
*
|
|
9
|
+
* Genuine-new-session design (0.3):
|
|
10
|
+
* - the outgoing session is NEVER compacted or otherwise mutated; its log stays
|
|
11
|
+
* the archive the child reads from;
|
|
12
|
+
* - the child is an ordinary session created by the host, so it holds no copy of
|
|
13
|
+
* the old conversation and can itself hand off again later;
|
|
14
|
+
* - the checkpoint is injected once, on the child's first turn, from a durable
|
|
15
|
+
* record under <dsh home>/handoff.
|
|
16
|
+
*
|
|
17
|
+
* Design constraints held deliberately:
|
|
18
|
+
* - never rewrites or deletes session history;
|
|
19
|
+
* - never touches configuration files;
|
|
20
|
+
* - dryRun defaults to true, so installing the plugin changes nothing until an
|
|
21
|
+
* operator opts in;
|
|
22
|
+
* - every listener swallows its own errors: a broken handoff must never break
|
|
23
|
+
* the turn that triggered it.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { randomUUID } from 'node:crypto';
|
|
27
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
28
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
29
|
+
import { debugLog } from './debug.js';
|
|
30
|
+
import { resolveConfig } from './config.js';
|
|
31
|
+
import { readPressure, formatPressure } from './pressure.js';
|
|
32
|
+
import { refusalReasons, performHandoff, summarizationInput } from './handoff.js';
|
|
33
|
+
import { sessionEvents, searchEvents, readEventDetail } from './history.js';
|
|
34
|
+
import { forkCard } from './route-card.js';
|
|
35
|
+
import { readHandoff, readSessionEvents, writeHandoff, readPending, consumePending, clearPending } from './archive.js';
|
|
36
|
+
import { registerHandoffRoutes } from './http.js';
|
|
37
|
+
import { readSettings, writeSettings, clearSettings, describe as describeSettings } from './settings.js';
|
|
38
|
+
|
|
39
|
+
const PLUGIN = 'dsh-session-handoff';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Why a known Session can still be unreachable: the host creates an Agent on
|
|
43
|
+
* demand and only then attaches the surface the summarizer replays. A Session
|
|
44
|
+
* that has not run a turn since the process started has history but no surface,
|
|
45
|
+
* so it cannot be handed off until it is used once.
|
|
46
|
+
*/
|
|
47
|
+
const DORMANT_REASON = 'this conversation has not run a turn since the host started, so its live context is not loaded yet; send one message here, then hand off';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* First message of the follow-up Session. The Host injects the checkpoint ahead
|
|
51
|
+
* of it on that Session's first step, so the text only exists to open a turn.
|
|
52
|
+
*/
|
|
53
|
+
const FOLLOW_UP_PROMPT = '继续';
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
const TEXT_OUTPUT = {
|
|
57
|
+
schema: { type: 'string' },
|
|
58
|
+
render: (_args, value) => [{ type: 'text', text: String(value) }]
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function state() {
|
|
62
|
+
return {
|
|
63
|
+
counts: new WeakMap(),
|
|
64
|
+
handoffs: new WeakMap(),
|
|
65
|
+
busy: new WeakSet(),
|
|
66
|
+
pending: new WeakSet(),
|
|
67
|
+
notifiedAt: new WeakMap(),
|
|
68
|
+
routed: new WeakSet(),
|
|
69
|
+
doneNotified: new WeakSet(),
|
|
70
|
+
autoTried: new WeakSet(),
|
|
71
|
+
// The browser button addresses a session by id, which a WeakMap cannot do.
|
|
72
|
+
// These two maps are the only strong references the plugin keeps, and they
|
|
73
|
+
// are capped at MAX_LIVE_SESSIONS entries.
|
|
74
|
+
live: new Map(),
|
|
75
|
+
status: new Map(),
|
|
76
|
+
running: new Set(),
|
|
77
|
+
// Outcome of the brand-new Session each parent opened, keyed by parent id.
|
|
78
|
+
followUp: new Map()
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Upper bound on remembered live Agents, so a long-lived process cannot grow. */
|
|
83
|
+
const MAX_LIVE_SESSIONS = 16;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Remember the live Agent behind a session so the browser button can reach it.
|
|
87
|
+
* @param st - plugin state.
|
|
88
|
+
* @param agent - agent whose session was just observed.
|
|
89
|
+
*/
|
|
90
|
+
function rememberAgent(st, agent) {
|
|
91
|
+
const session = agent?.session;
|
|
92
|
+
if (session === undefined || session === null) return;
|
|
93
|
+
const id = String(session.id);
|
|
94
|
+
st.live.delete(id);
|
|
95
|
+
st.live.set(id, agent);
|
|
96
|
+
while (st.live.size > MAX_LIVE_SESSIONS) {
|
|
97
|
+
const oldest = st.live.keys().next().value;
|
|
98
|
+
if (oldest === undefined) break;
|
|
99
|
+
st.live.delete(oldest);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve a live Agent by session id, falling back to the most recent one.
|
|
105
|
+
* @param st - plugin state.
|
|
106
|
+
* @param sessionId - requested session, or undefined from a caller without one.
|
|
107
|
+
* @returns the Agent, or undefined when nothing live is known.
|
|
108
|
+
*/
|
|
109
|
+
function resolveAgent(st, sessionId) {
|
|
110
|
+
if (typeof sessionId === 'string' && sessionId !== '') {
|
|
111
|
+
const exact = st.live.get(sessionId);
|
|
112
|
+
if (exact !== undefined) return exact;
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
const keys = [...st.live.keys()];
|
|
116
|
+
const last = keys[keys.length - 1];
|
|
117
|
+
return last === undefined ? undefined : st.live.get(last);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function pluginMessage(text) {
|
|
121
|
+
return createUserMessage({
|
|
122
|
+
content: [{ type: 'text', text }],
|
|
123
|
+
// Session format v4 rejects the retired `kind: 'plugin'` wrapper; a
|
|
124
|
+
// producer-owned kind is required. See dsh-session-format-v3-to-v4.
|
|
125
|
+
source: { kind: 'plugin:' + PLUGIN, plugin: PLUGIN }
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function appendNotice(messages, notice) {
|
|
130
|
+
return [...messages, notice];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function askNotice(cfg, reading) {
|
|
134
|
+
return [
|
|
135
|
+
'[' + PLUGIN + '] 上下文压力已达 ' + formatPressure(reading) + '。',
|
|
136
|
+
'在继续执行下一步之前,先用 ask_user_question 问用户一句:',
|
|
137
|
+
'「上下文已到 ' + (reading.ratio * 100).toFixed(0) + '%,要不要现在把上下文摘要出来,开一个新会话接着做?」',
|
|
138
|
+
'如果用户同意,调用 session_handoff(action:"handoff");如果用户拒绝,直接继续,不要重复询问。',
|
|
139
|
+
'不要自行执行交接,也不要解释这条提醒的来源。'
|
|
140
|
+
].join('\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function doneNotice(record) {
|
|
144
|
+
const stats = record.stats ?? {};
|
|
145
|
+
return [
|
|
146
|
+
'[' + PLUGIN + '] 摘要已生成:压缩比 ' + Math.round(stats.ratio ?? 0) + ':1,' + (stats.summaryTokens ?? 0) + ' tokens。',
|
|
147
|
+
'旧会话没有被压缩、也没有被修改,仍然完整,可以继续在这里检索原文。',
|
|
148
|
+
'现在请提示用户:新建一个对话(全新的空白会话),在里面发出第一句话,摘要会自动成为那个会话的上下文开头。摘要等到那一刻才会被使用,不会丢。',
|
|
149
|
+
'本会话从现在起只作为档案,不要在这里继续派发新任务。'
|
|
150
|
+
].join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Place the checkpoint ahead of the user's first prompt, after any system turns.
|
|
155
|
+
* @param messages - the turn's message list.
|
|
156
|
+
* @param notice - plugin message carrying the checkpoint.
|
|
157
|
+
* @returns new message list.
|
|
158
|
+
*/
|
|
159
|
+
function injectBeforePrompt(messages, notice) {
|
|
160
|
+
let index = 0;
|
|
161
|
+
while (index < messages.length && messages[index]?.role === 'system') index += 1;
|
|
162
|
+
return [...messages.slice(0, index), notice, ...messages.slice(index)];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Whether this session has never had a completed exchange, i.e. it is a brand-new
|
|
167
|
+
* conversation whose first prompt is this turn.
|
|
168
|
+
*
|
|
169
|
+
* The turn number is the primary signal, not the prompt count: the host appends the
|
|
170
|
+
* submitted prompt to the durable log only after the first pre-step resolves, so a
|
|
171
|
+
* brand-new conversation legitimately shows zero real prompts here. The log is used
|
|
172
|
+
* to reject sessions that already have a reply, and an empty or unreadable log is
|
|
173
|
+
* NOT treated as new, so a checkpoint can never land in a session whose history we
|
|
174
|
+
* cannot see.
|
|
175
|
+
* @param session - session being entered.
|
|
176
|
+
* @param position - the step position the host passed (`{ turn, step }`).
|
|
177
|
+
* @returns true for a first-turn conversation.
|
|
178
|
+
*/
|
|
179
|
+
export function isFirstConversation(session, position) {
|
|
180
|
+
const events = sessionEvents(session);
|
|
181
|
+
if (events.length === 0) return false;
|
|
182
|
+
const turn = position?.turn;
|
|
183
|
+
if (typeof turn === 'number' && turn !== 1) return false;
|
|
184
|
+
let prompts = 0;
|
|
185
|
+
for (const event of events) {
|
|
186
|
+
if (event?.type === 'assistant/message') return false;
|
|
187
|
+
if (event?.type === 'user/message' && event?.data?.source?.kind === 'user') prompts += 1;
|
|
188
|
+
}
|
|
189
|
+
return prompts <= 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export const name = PLUGIN;
|
|
193
|
+
|
|
194
|
+
export function apply(ctx, rawConfig = {}) {
|
|
195
|
+
const warn = (message) => ctx.logger.warn(PLUGIN + ': ' + message);
|
|
196
|
+
// Panel edits live in their own file and win over the profile patch, so the
|
|
197
|
+
// settings page can take effect without a restart and without this plugin
|
|
198
|
+
// ever rewriting a host configuration file.
|
|
199
|
+
let cfg = resolveConfig({ ...rawConfig, ...readSettings() }, warn);
|
|
200
|
+
// Assigned once the tools scope activates. Until then it is a no-op, so a
|
|
201
|
+
// settings save that lands before the registry exists cannot fail.
|
|
202
|
+
let syncHistoryTool = () => {};
|
|
203
|
+
const reapply = () => {
|
|
204
|
+
cfg = resolveConfig({ ...rawConfig, ...readSettings() }, warn);
|
|
205
|
+
try {
|
|
206
|
+
syncHistoryTool();
|
|
207
|
+
} catch {
|
|
208
|
+
// Re-registering one tool must never break saving the rest.
|
|
209
|
+
}
|
|
210
|
+
return cfg;
|
|
211
|
+
};
|
|
212
|
+
const st = state();
|
|
213
|
+
ctx.on('agent/pre-step', async (payload, next) => {
|
|
214
|
+
const result = await next();
|
|
215
|
+
try {
|
|
216
|
+
if (cfg.mode === 'off') return result;
|
|
217
|
+
if (result === undefined || result.kind !== 'enter' || !Array.isArray(result.messages)) return result;
|
|
218
|
+
const session = payload?.agent?.session;
|
|
219
|
+
if (session === undefined) return result;
|
|
220
|
+
rememberAgent(st, payload.agent);
|
|
221
|
+
|
|
222
|
+
const header = session.header ?? {};
|
|
223
|
+
if (typeof header.delegationDepth === 'number' && header.delegationDepth > 0) return result;
|
|
224
|
+
|
|
225
|
+
// A handed-off session receives its checkpoint exactly once.
|
|
226
|
+
// The waiting checkpoint belongs to the next brand-new conversation the
|
|
227
|
+
// user opens, not to any session this plugin could create itself.
|
|
228
|
+
const pending = readPending();
|
|
229
|
+
if (pending !== undefined && pending.consumedBy === undefined
|
|
230
|
+
&& String(session.id) !== pending.parentSession
|
|
231
|
+
&& isFirstConversation(session, payload)) {
|
|
232
|
+
if (st.routed.has(session)) return result;
|
|
233
|
+
st.routed.add(session);
|
|
234
|
+
consumePending(String(session.id));
|
|
235
|
+
writeHandoff({
|
|
236
|
+
version: 1,
|
|
237
|
+
childId: String(session.id),
|
|
238
|
+
parentSession: pending.parentSession,
|
|
239
|
+
createdAt: pending.createdAt,
|
|
240
|
+
summary: pending.summary,
|
|
241
|
+
stats: pending.stats,
|
|
242
|
+
injectedAt: Date.now(),
|
|
243
|
+
...(pending.provider === undefined ? {} : { provider: pending.provider }),
|
|
244
|
+
...(pending.model === undefined ? {} : { model: pending.model })
|
|
245
|
+
});
|
|
246
|
+
return { ...result, messages: injectBeforePrompt(result.messages, pluginMessage(pending.checkpoint)) };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (header.isSeeded === true) {
|
|
250
|
+
if (st.routed.has(session)) return result;
|
|
251
|
+
st.routed.add(session);
|
|
252
|
+
return { ...result, messages: appendNotice(result.messages, pluginMessage(forkCard(session))) };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const record = st.handoffs.get(session);
|
|
256
|
+
if (record !== undefined) {
|
|
257
|
+
if (st.doneNotified.has(session)) return result;
|
|
258
|
+
st.doneNotified.add(session);
|
|
259
|
+
return { ...result, messages: appendNotice(result.messages, pluginMessage(doneNotice(record))) };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const reading = readPressure(ctx, session);
|
|
263
|
+
if (reading === null || reading.ratio < cfg.askRatio) return result;
|
|
264
|
+
|
|
265
|
+
const last = st.notifiedAt.get(session) ?? 0;
|
|
266
|
+
if (Date.now() - last < cfg.noticeCooldownMs) return result;
|
|
267
|
+
st.notifiedAt.set(session, Date.now());
|
|
268
|
+
return { ...result, messages: appendNotice(result.messages, pluginMessage(askNotice(cfg, reading))) };
|
|
269
|
+
} catch (error) {
|
|
270
|
+
ctx.logger.warn(PLUGIN + ': pre-step notice skipped: ' + (error instanceof Error ? error.message : String(error)));
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
ctx.on('agent/status', (payload) => {
|
|
276
|
+
try {
|
|
277
|
+
const agent = payload?.agent;
|
|
278
|
+
const session = agent?.session;
|
|
279
|
+
if (session === undefined) return;
|
|
280
|
+
// The browser button only starts the summarizer while the session is
|
|
281
|
+
// idle; remember the last observed status so it can tell.
|
|
282
|
+
rememberAgent(st, agent);
|
|
283
|
+
st.status.set(String(session.id), String(payload?.status ?? ''));
|
|
284
|
+
if (payload?.status !== 'idle') return;
|
|
285
|
+
debugLog('status=idle event received');
|
|
286
|
+
if (cfg.mode === 'off') return;
|
|
287
|
+
|
|
288
|
+
debugLog('idle: session=' + String(session?.id) + ' pending=' + st.pending.has(session) + ' mode=' + cfg.mode);
|
|
289
|
+
const wanted = st.pending.has(session)
|
|
290
|
+
|| (cfg.mode === 'auto' && !st.autoTried.has(session) && autoDue(ctx, cfg, session));
|
|
291
|
+
if (!wanted) return;
|
|
292
|
+
st.pending.delete(session);
|
|
293
|
+
st.autoTried.add(session);
|
|
294
|
+
void runHandoffTracked(ctx, cfg, st, agent);
|
|
295
|
+
} catch (error) {
|
|
296
|
+
debugLog('idle handler threw: ' + (error instanceof Error ? error.stack : String(error)));
|
|
297
|
+
ctx.logger.warn(PLUGIN + ': idle check failed: ' + (error instanceof Error ? error.message : String(error)));
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Start or queue a handoff for one Agent.
|
|
303
|
+
*
|
|
304
|
+
* This is the same decision the `session_handoff` tool makes, minus the
|
|
305
|
+
* consent step: the browser button IS the consent. The summarizer still only
|
|
306
|
+
* runs on an idle Agent, so a click that lands mid-turn queues instead.
|
|
307
|
+
* @param agent - live Agent whose session should be handed off.
|
|
308
|
+
* @returns a small result object for the browser.
|
|
309
|
+
*/
|
|
310
|
+
const requestHandoff = (agent) => {
|
|
311
|
+
const session = agent?.session;
|
|
312
|
+
if (session === undefined) return { ok: false, code: 'no-live-session', error: 'no live session' };
|
|
313
|
+
const reasons = refusalReasons(session, cfg, st);
|
|
314
|
+
if (reasons.length > 0) return { ok: false, code: 'refused', error: reasons.join('; ') };
|
|
315
|
+
if (cfg.dryRun) return { ok: true, dryRun: true, message: 'dry-run: nothing was changed' };
|
|
316
|
+
if (cfg.mode === 'off') return { ok: false, code: 'mode-off', error: 'mode is off' };
|
|
317
|
+
const id = String(session.id);
|
|
318
|
+
// The browser half opens the follow-up Session itself, so the click needs
|
|
319
|
+
// the working directory to create one in and the switch that allows it.
|
|
320
|
+
const follow = {
|
|
321
|
+
autoOpen: cfg.autoOpenSession,
|
|
322
|
+
cwd: typeof session.header?.cwd === 'string' ? session.header.cwd : null
|
|
323
|
+
};
|
|
324
|
+
if (st.running.has(id)) return { ok: true, running: true, ...follow };
|
|
325
|
+
if (st.status.get(id) === 'idle') {
|
|
326
|
+
st.pending.delete(session);
|
|
327
|
+
void runHandoffTracked(ctx, cfg, st, agent);
|
|
328
|
+
return { ok: true, running: true, ...follow };
|
|
329
|
+
}
|
|
330
|
+
st.pending.add(session);
|
|
331
|
+
return { ok: true, queued: true, ...follow };
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Everything the button needs to render one state, as plain data.
|
|
336
|
+
* @param sessionId - requested session, or undefined to use the most recent.
|
|
337
|
+
* @returns status payload; never throws.
|
|
338
|
+
*/
|
|
339
|
+
const report = (sessionId) => {
|
|
340
|
+
const agent = resolveAgent(st, sessionId);
|
|
341
|
+
const session = agent?.session;
|
|
342
|
+
const id = session === undefined ? undefined : String(session.id);
|
|
343
|
+
const reading = session === undefined ? null : readPressure(ctx, session);
|
|
344
|
+
// A Session the browser names but no Agent owns is dormant, not unknown:
|
|
345
|
+
// it exists in the sidebar and needs one turn before it can be summarized.
|
|
346
|
+
const dormant = session === undefined && typeof sessionId === 'string' && sessionId !== '';
|
|
347
|
+
const reasons = session === undefined ? [dormant ? DORMANT_REASON : 'no live session'] : refusalReasons(session, cfg, st);
|
|
348
|
+
const waiting = readPending();
|
|
349
|
+
return {
|
|
350
|
+
sessionId: id ?? null,
|
|
351
|
+
mode: cfg.mode,
|
|
352
|
+
dryRun: cfg.dryRun,
|
|
353
|
+
pressure: reading === null ? null : {
|
|
354
|
+
ratio: reading.ratio,
|
|
355
|
+
used: reading.used,
|
|
356
|
+
window: reading.window,
|
|
357
|
+
text: formatPressure(reading)
|
|
358
|
+
},
|
|
359
|
+
askRatio: cfg.askRatio,
|
|
360
|
+
autoRatio: cfg.autoRatio,
|
|
361
|
+
eligible: reasons.length === 0,
|
|
362
|
+
reason: reasons.length === 0 ? null : reasons.join('; '),
|
|
363
|
+
running: id !== undefined && st.running.has(id),
|
|
364
|
+
dormant,
|
|
365
|
+
followUp: id === undefined ? null : st.followUp.get(id) ?? null,
|
|
366
|
+
// The browser half opens the follow-up session itself, so it needs the
|
|
367
|
+
// working directory to create one in and the switch that allows it.
|
|
368
|
+
cwd: typeof session?.header?.cwd === 'string' ? session.header.cwd : null,
|
|
369
|
+
autoOpen: cfg.autoOpenSession,
|
|
370
|
+
pending: waiting === undefined ? 'none' : waiting.consumedBy === undefined ? 'waiting' : 'consumed',
|
|
371
|
+
pendingParent: waiting === undefined ? null : waiting.parentSession
|
|
372
|
+
};
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Attach an existing Session to the Workspace that owns a directory.
|
|
377
|
+
*
|
|
378
|
+
* The sidebar lists a Workspace's own sessionIds, so a Session created
|
|
379
|
+
* outside that membership runs but stays invisible. This repairs such a
|
|
380
|
+
* Session — the follow-up flow already attaches what it creates, and this is
|
|
381
|
+
* the one-step recovery for anything created before that fix.
|
|
382
|
+
* @param sessionId - Session to attach.
|
|
383
|
+
* @param cwd - its directory, as stored in the Session header.
|
|
384
|
+
* @returns `{ ok }` or an explicit refusal.
|
|
385
|
+
*/
|
|
386
|
+
const attachToWorkspace = async (sessionId, cwd) => {
|
|
387
|
+
try {
|
|
388
|
+
const registry = ctx.get('workspaceRegistry');
|
|
389
|
+
if (registry === undefined || typeof registry.resolveByPath !== 'function') {
|
|
390
|
+
return { ok: false, code: 'unavailable', error: 'the host exposes no workspace registry' };
|
|
391
|
+
}
|
|
392
|
+
const workspace = await registry.resolveByPath(cwd);
|
|
393
|
+
if (workspace === undefined) return { ok: false, code: 'no-workspace', error: 'no workspace owns ' + cwd };
|
|
394
|
+
if (typeof workspace.attachSession !== 'function') {
|
|
395
|
+
return { ok: false, code: 'unavailable', error: 'this workspace cannot attach sessions' };
|
|
396
|
+
}
|
|
397
|
+
await workspace.attachSession(sessionId);
|
|
398
|
+
return { ok: true, sessionId, workspaceId: String(workspace.id ?? '') };
|
|
399
|
+
} catch (error) {
|
|
400
|
+
return { ok: false, code: 'attach-failed', error: messageOf(error) };
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
ctx.inject(['tools'], (scoped) => {
|
|
405
|
+
const tools = scoped.get('tools') ?? scoped.tools;
|
|
406
|
+
if (tools === undefined || typeof tools.register !== 'function') return;
|
|
407
|
+
try {
|
|
408
|
+
tools.register(defineTool({
|
|
409
|
+
name: 'session_handoff',
|
|
410
|
+
description: 'Inspect or perform the context handoff. action:"status" reports pressure, eligibility and any waiting checkpoint; action:"handoff" queues a summarization that runs as soon as the current turn ends and parks the checkpoint for the next brand-new conversation; action:"cancel" discards a waiting checkpoint. The current session is never compacted or modified. Never call "handoff" without explicit user consent.',
|
|
411
|
+
parameters: {
|
|
412
|
+
action: {
|
|
413
|
+
type: 'string',
|
|
414
|
+
required: true,
|
|
415
|
+
description: '"status" to inspect, "handoff" to request the handoff, "cancel" to discard a waiting checkpoint.'
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
output: TEXT_OUTPUT,
|
|
419
|
+
isConcurrencySafe: () => false,
|
|
420
|
+
execute: (args, exec) => describeOrQueue(ctx, cfg, st, args, exec)
|
|
421
|
+
}));
|
|
422
|
+
// The archive tool can be switched off at runtime from the settings
|
|
423
|
+
// page, so its registration follows cfg on every save instead of being
|
|
424
|
+
// decided once at load. `tools.register` returns the disposer.
|
|
425
|
+
let stopHistory = null;
|
|
426
|
+
const syncHistoryRegistrations = () => {
|
|
427
|
+
const wanted = cfg.includeHistoryTool === true;
|
|
428
|
+
if (wanted && stopHistory === null) {
|
|
429
|
+
const stop = tools.register(defineTool({
|
|
430
|
+
name: 'handoff_history',
|
|
431
|
+
description: 'Search or read a raw session log. source:"self" (default) reads this session; source:"parent" reads the archived log of the session this one was handed off from. A literal text search returns seq numbers, and reading a seq returns the original event in full.',
|
|
432
|
+
parameters: {
|
|
433
|
+
query: { type: 'string', description: 'Literal text to search for (case-insensitive).' },
|
|
434
|
+
seq: { type: 'integer', description: 'Exact event sequence number to read in full.' },
|
|
435
|
+
source: { type: 'string', description: '"self" (default) or "parent" (the archived session this one came from).' },
|
|
436
|
+
before: { type: 'integer', description: 'Neighbour events to include before the target seq.' },
|
|
437
|
+
after: { type: 'integer', description: 'Neighbour events to include after the target seq.' },
|
|
438
|
+
limit: { type: 'integer', description: 'Maximum search hits (default 8, max 30).' }
|
|
439
|
+
},
|
|
440
|
+
output: TEXT_OUTPUT,
|
|
441
|
+
isConcurrencySafe: () => true,
|
|
442
|
+
execute: (args, exec) => lookupHistory(ctx, args, exec)
|
|
443
|
+
}));
|
|
444
|
+
stopHistory = typeof stop === 'function' ? stop : () => {};
|
|
445
|
+
} else if (!wanted && stopHistory !== null) {
|
|
446
|
+
try {
|
|
447
|
+
stopHistory();
|
|
448
|
+
} catch {
|
|
449
|
+
// Releasing an already-released registration is fine.
|
|
450
|
+
}
|
|
451
|
+
stopHistory = null;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
syncHistoryTool = syncHistoryRegistrations;
|
|
455
|
+
syncHistoryRegistrations();
|
|
456
|
+
} catch (error) {
|
|
457
|
+
ctx.logger.warn(PLUGIN + ': tool registration skipped: ' + (error instanceof Error ? error.message : String(error)));
|
|
458
|
+
}
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
// The assistant-action button is optional decoration: without a web server
|
|
462
|
+
// (CLI-only deployments) the plugin keeps working through its tool.
|
|
463
|
+
ctx.inject(['webServer'], (scoped) => {
|
|
464
|
+
const server = scoped.get('webServer') ?? scoped.webServer;
|
|
465
|
+
if (server === undefined || server === null || typeof server.register !== 'function') return;
|
|
466
|
+
try {
|
|
467
|
+
const dispose = registerHandoffRoutes(server, {
|
|
468
|
+
report,
|
|
469
|
+
request: requestHandoff,
|
|
470
|
+
resolve: (sessionId) => resolveAgent(st, sessionId),
|
|
471
|
+
attach: attachToWorkspace,
|
|
472
|
+
// Distinguish "that Session is dormant" from "nothing is live at all".
|
|
473
|
+
absent: (sessionId) => (typeof sessionId === 'string' && sessionId !== ''
|
|
474
|
+
? { ok: false, code: 'session-dormant', error: DORMANT_REASON }
|
|
475
|
+
: { ok: false, code: 'no-live-session', error: 'no live session to hand off' }),
|
|
476
|
+
settings: {
|
|
477
|
+
describe: () => describeSettings(cfg),
|
|
478
|
+
update(values) {
|
|
479
|
+
writeSettings(values);
|
|
480
|
+
reapply();
|
|
481
|
+
return describeSettings(cfg);
|
|
482
|
+
},
|
|
483
|
+
reset() {
|
|
484
|
+
clearSettings();
|
|
485
|
+
reapply();
|
|
486
|
+
return describeSettings(cfg);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
});
|
|
490
|
+
if (typeof scoped.effect === 'function') {
|
|
491
|
+
scoped.effect(() => () => dispose(), PLUGIN + ': handoff routes');
|
|
492
|
+
}
|
|
493
|
+
} catch (error) {
|
|
494
|
+
ctx.logger.warn(PLUGIN + ': handoff routes skipped: ' + (error instanceof Error ? error.message : String(error)));
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function autoDue(ctx, cfg, session) {
|
|
500
|
+
const reading = readPressure(ctx, session);
|
|
501
|
+
return reading !== null && reading.ratio >= cfg.autoRatio;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function messageOf(error) {
|
|
505
|
+
if (error === null || error === undefined) return 'unknown';
|
|
506
|
+
if (error instanceof Error) return error.message;
|
|
507
|
+
if (typeof error === 'string') return error;
|
|
508
|
+
return String(error.code ?? error.message ?? error);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Continue in a brand-new Session the way the Host's own New Session flow does.
|
|
513
|
+
*
|
|
514
|
+
* It goes through the Session controller rather than through a Session object
|
|
515
|
+
* this plugin builds: the controller is what the New Session button calls, so
|
|
516
|
+
* the child is an ordinary listed conversation that can itself be handed off
|
|
517
|
+
* later. The checkpoint parked by {@link performHandoff} is picked up by the
|
|
518
|
+
* pre-step hook on the child's first turn, ahead of {@link FOLLOW_UP_PROMPT}.
|
|
519
|
+
*
|
|
520
|
+
* @param ctx - plugin context.
|
|
521
|
+
* @param cfg - resolved configuration.
|
|
522
|
+
* @param st - plugin state.
|
|
523
|
+
* @param parent - Session that was just handed off.
|
|
524
|
+
* @returns the outcome record also exposed through the status route.
|
|
525
|
+
*/
|
|
526
|
+
async function continueInNewSession(ctx, cfg, st, parent) {
|
|
527
|
+
const id = String(parent.id);
|
|
528
|
+
const record = (code, extra) => {
|
|
529
|
+
const value = { code, at: Date.now(), ...extra };
|
|
530
|
+
st.followUp.set(id, value);
|
|
531
|
+
return value;
|
|
532
|
+
};
|
|
533
|
+
if (cfg.autoOpenSession !== true) return record('manual');
|
|
534
|
+
const controller = ctx.get('sessionController');
|
|
535
|
+
if (controller === undefined || typeof controller.create !== 'function' || typeof controller.prompt !== 'function') {
|
|
536
|
+
ctx.logger.warn(PLUGIN + ': follow-up session skipped: the host exposes no session controller');
|
|
537
|
+
return record('unavailable');
|
|
538
|
+
}
|
|
539
|
+
const cwd = typeof parent.header?.cwd === 'string' ? parent.header.cwd : '';
|
|
540
|
+
if (cwd === '') return record('no-cwd');
|
|
541
|
+
// A Session created from a bare cwd is NOT attached to the Workspace, and the
|
|
542
|
+
// sidebar lists a Workspace's own sessionIds: the child would run fine but be
|
|
543
|
+
// invisible. Handing the controller the Workspace makes it attach the child
|
|
544
|
+
// itself, so fall back to cwd only when no Workspace owns this directory.
|
|
545
|
+
let workspaceId;
|
|
546
|
+
try {
|
|
547
|
+
const registry = ctx.get('workspaceRegistry');
|
|
548
|
+
if (registry !== undefined && typeof registry.resolveByPath === 'function') {
|
|
549
|
+
const workspace = await registry.resolveByPath(cwd);
|
|
550
|
+
if (workspace !== undefined && typeof workspace.id === 'string' && workspace.id !== '') workspaceId = workspace.id;
|
|
551
|
+
}
|
|
552
|
+
} catch (error) {
|
|
553
|
+
debugLog('follow-up: workspace lookup failed: ' + messageOf(error));
|
|
554
|
+
}
|
|
555
|
+
let childId;
|
|
556
|
+
try {
|
|
557
|
+
const created = await controller.create(workspaceId === undefined ? { cwd } : { workspaceId });
|
|
558
|
+
childId = created === null || created === undefined ? undefined : created.sessionId;
|
|
559
|
+
} catch (error) {
|
|
560
|
+
ctx.logger.warn(PLUGIN + ': follow-up session not created: ' + messageOf(error));
|
|
561
|
+
return record('create-failed', { detail: messageOf(error) });
|
|
562
|
+
}
|
|
563
|
+
if (typeof childId !== 'string' || childId === '') {
|
|
564
|
+
return record('create-failed', { detail: 'the host returned no session id' });
|
|
565
|
+
}
|
|
566
|
+
try {
|
|
567
|
+
// The controller reads the signal before admission, so a signal is not
|
|
568
|
+
// optional even for this fire-and-forget prompt.
|
|
569
|
+
await controller.prompt({
|
|
570
|
+
requestId: randomUUID(),
|
|
571
|
+
sessionId: childId,
|
|
572
|
+
mode: 'queue',
|
|
573
|
+
content: [{ type: 'text', text: FOLLOW_UP_PROMPT }]
|
|
574
|
+
}, new AbortController().signal);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
ctx.logger.warn(PLUGIN + ': follow-up session ' + childId + ' was created but not prompted: ' + messageOf(error));
|
|
577
|
+
return record('prompt-failed', { sessionId: childId, detail: messageOf(error) });
|
|
578
|
+
}
|
|
579
|
+
if (workspaceId !== undefined) {
|
|
580
|
+
// The controller attaches only when it created from a Workspace id; this
|
|
581
|
+
// also covers a reused blank Session that predates the attach.
|
|
582
|
+
try {
|
|
583
|
+
const registry = ctx.get('workspaceRegistry');
|
|
584
|
+
const workspace = registry === undefined ? undefined : registry.get?.(workspaceId);
|
|
585
|
+
if (workspace !== undefined && typeof workspace.attachSession === 'function') await workspace.attachSession(childId);
|
|
586
|
+
} catch (error) {
|
|
587
|
+
debugLog('follow-up: attach after prompt failed: ' + messageOf(error));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
ctx.logger.info(PLUGIN + ': follow-up session ' + childId + ' created; the checkpoint is injected on its first step');
|
|
591
|
+
return record('opened', { sessionId: childId });
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Run one handoff while reporting it as in-flight, so a second click cannot
|
|
596
|
+
* start a parallel summarization.
|
|
597
|
+
* @param ctx - plugin context.
|
|
598
|
+
* @param cfg - resolved configuration.
|
|
599
|
+
* @param st - plugin state.
|
|
600
|
+
* @param agent - live Agent.
|
|
601
|
+
*/
|
|
602
|
+
async function runHandoffTracked(ctx, cfg, st, agent) {
|
|
603
|
+
const session = agent?.session;
|
|
604
|
+
const id = session === undefined ? '' : String(session.id);
|
|
605
|
+
if (id !== '') st.running.add(id);
|
|
606
|
+
try {
|
|
607
|
+
await runHandoff(ctx, cfg, st, agent);
|
|
608
|
+
} finally {
|
|
609
|
+
if (id !== '') st.running.delete(id);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function runHandoff(ctx, cfg, st, agent) {
|
|
614
|
+
const session = agent?.session;
|
|
615
|
+
if (session === undefined) return;
|
|
616
|
+
debugLog('runHandoff start session=' + String(session.id));
|
|
617
|
+
const reasons = refusalReasons(session, cfg, st);
|
|
618
|
+
if (reasons.length > 0) {
|
|
619
|
+
ctx.logger.info(PLUGIN + ': handoff skipped: ' + reasons.join('; '));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
const result = await performHandoff(ctx, cfg, st, agent);
|
|
623
|
+
debugLog('performHandoff result=' + JSON.stringify(result));
|
|
624
|
+
if (!result.ok) {
|
|
625
|
+
ctx.logger.warn(PLUGIN + ': handoff not completed: ' + result.reason);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
// The checkpoint is parked; the follow-up Session is the Host's own, so the
|
|
629
|
+
// pre-step hook injects it there on the first turn.
|
|
630
|
+
await continueInNewSession(ctx, cfg, st, session);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function describeOrQueue(ctx, cfg, st, args, exec) {
|
|
634
|
+
const agent = exec?.agent;
|
|
635
|
+
const session = agent?.session;
|
|
636
|
+
if (session === undefined) return 'ERROR: no live session is attached to this tool call.';
|
|
637
|
+
const reading = readPressure(ctx, session);
|
|
638
|
+
const reasons = refusalReasons(session, cfg, st);
|
|
639
|
+
|
|
640
|
+
if (args.action === 'status') {
|
|
641
|
+
const lines = [
|
|
642
|
+
'mode: ' + cfg.mode,
|
|
643
|
+
'dryRun: ' + cfg.dryRun,
|
|
644
|
+
'pressure: ' + formatPressure(reading),
|
|
645
|
+
'askRatio: ' + cfg.askRatio + ' autoRatio: ' + cfg.autoRatio + ' minRatio: ' + cfg.minRatio,
|
|
646
|
+
'eligible: ' + (reasons.length === 0 ? 'yes' : 'no — ' + reasons.join('; '))
|
|
647
|
+
];
|
|
648
|
+
// The handoff summarizes the live surface; if these host APIs are not
|
|
649
|
+
// readable on this deployment, say so here instead of at handoff time.
|
|
650
|
+
const surface = summarizationInput(session);
|
|
651
|
+
lines.push('surface: ' + (surface === null ? 'unreadable — the handoff would refuse' : surface.messages.length + ' messages ready to summarize'));
|
|
652
|
+
const waiting = readPending();
|
|
653
|
+
lines.push('pending checkpoint: ' + (waiting === undefined
|
|
654
|
+
? 'none'
|
|
655
|
+
: waiting.consumedBy === undefined
|
|
656
|
+
? 'waiting for a brand-new conversation (open a new chat and send one message)'
|
|
657
|
+
: 'consumed by ' + waiting.consumedBy));
|
|
658
|
+
const record = st.handoffs.get(session);
|
|
659
|
+
if (record !== undefined) lines.push('last handoff: checkpoint ready (' + Math.round(record.stats?.ratio ?? 0) + ':1), parent log kept intact');
|
|
660
|
+
const own = readHandoff(String(session.id));
|
|
661
|
+
if (own !== undefined) lines.push('this session was handed off from: ' + own.parentSession);
|
|
662
|
+
return lines.join('\n');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (args.action === 'cancel') {
|
|
666
|
+
return clearPending() ? 'CANCELLED: the waiting checkpoint was discarded.' : 'Nothing was waiting.';
|
|
667
|
+
}
|
|
668
|
+
if (args.action !== 'handoff') return 'ERROR: action must be "status", "handoff" or "cancel".';
|
|
669
|
+
if (reasons.length > 0) return 'REFUSED: ' + reasons.join('; ');
|
|
670
|
+
if (cfg.dryRun) {
|
|
671
|
+
return [
|
|
672
|
+
'DRY-RUN: nothing was changed.',
|
|
673
|
+
'pressure: ' + formatPressure(reading),
|
|
674
|
+
'would summarize this session into one checkpoint and park it for the next new conversation.',
|
|
675
|
+
'this session would NOT be compacted and NOT be modified.',
|
|
676
|
+
'Set dryRun: false in the profile patch to enable the real handoff.'
|
|
677
|
+
].join('\n');
|
|
678
|
+
}
|
|
679
|
+
if (cfg.mode === 'off') return 'REFUSED: mode is off.';
|
|
680
|
+
st.pending.add(session);
|
|
681
|
+
return 'QUEUED: the handoff runs as soon as the current turn ends. Tell the user it is scheduled, and that afterwards they must open a NEW conversation whose first message will receive the checkpoint.';
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function lookupHistory(ctx, args, exec) {
|
|
685
|
+
const session = exec?.agent?.session;
|
|
686
|
+
if (session === undefined) return 'ERROR: no live session is attached to this tool call.';
|
|
687
|
+
const source = typeof args.source === 'string' && args.source !== '' ? args.source : 'self';
|
|
688
|
+
let events;
|
|
689
|
+
let label;
|
|
690
|
+
if (source === 'parent') {
|
|
691
|
+
const record = readHandoff(String(session.id));
|
|
692
|
+
if (record === undefined) return 'This session has no handoff record, so it has no parent archive to read.';
|
|
693
|
+
events = readSessionEvents(record.parentSession);
|
|
694
|
+
label = 'parent ' + record.parentSession;
|
|
695
|
+
if (events.length === 0) {
|
|
696
|
+
return 'The parent archive for ' + record.parentSession + ' is not readable: no session log was found under the DSH sessions directory.';
|
|
697
|
+
}
|
|
698
|
+
} else if (source === 'self') {
|
|
699
|
+
events = sessionEvents(session);
|
|
700
|
+
label = 'this session';
|
|
701
|
+
if (events.length === 0) return 'This session has no readable event log.';
|
|
702
|
+
} else {
|
|
703
|
+
return 'ERROR: source must be "self" or "parent".';
|
|
704
|
+
}
|
|
705
|
+
const prefix = source === 'parent' ? '[parent] ' : '';
|
|
706
|
+
const limit = Number.isSafeInteger(args.limit) ? Math.min(Math.max(args.limit, 1), 30) : 8;
|
|
707
|
+
|
|
708
|
+
if (Number.isSafeInteger(args.seq)) {
|
|
709
|
+
const before = Number.isSafeInteger(args.before) ? Math.min(Math.max(args.before, 0), 20) : 1;
|
|
710
|
+
const after = Number.isSafeInteger(args.after) ? Math.min(Math.max(args.after, 0), 20) : 1;
|
|
711
|
+
return prefix + readEventDetail(events, args.seq, before, after);
|
|
712
|
+
}
|
|
713
|
+
const query = typeof args.query === 'string' ? args.query.trim() : '';
|
|
714
|
+
if (query === '') return 'Provide either query (literal text) or seq (exact event number).';
|
|
715
|
+
const hits = searchEvents(events, query, limit);
|
|
716
|
+
if (hits.length === 0) {
|
|
717
|
+
return 'No event in ' + label + ' contains "' + query + '". That log holds seq 0..' + (events.length - 1) + ', ' + events.length + ' events.';
|
|
718
|
+
}
|
|
719
|
+
return hits.map((hit) => prefix + '[' + hit.seq + '] ' + hit.type + ': ' + hit.text).join('\n');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
export default apply;
|