@better-than-coca/dsh-session-handoff 0.0.0-stage → 0.5.5
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 +159 -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/pressure.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context pressure reading.
|
|
3
|
+
*
|
|
4
|
+
* The host already folds prompt-side pressure into the `contextPressure`
|
|
5
|
+
* session projection (see @deepseek-ai/dsh-token-meter), so this plugin reads
|
|
6
|
+
* that projection instead of re-pricing the surface itself. When the
|
|
7
|
+
* projection is unavailable the plugin stays silent rather than guessing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read the current pressure of one session.
|
|
12
|
+
* @param ctx - host context exposing `sessionProjections`.
|
|
13
|
+
* @param session - session to measure.
|
|
14
|
+
* @returns `{ used, window, ratio }` or `null` when unmeasurable.
|
|
15
|
+
*/
|
|
16
|
+
export function readPressure(ctx, session) {
|
|
17
|
+
if (session === undefined || session === null) return null;
|
|
18
|
+
const projections = ctx.get('sessionProjections');
|
|
19
|
+
if (projections === undefined || typeof projections.snapshot !== 'function') return null;
|
|
20
|
+
let snapshot;
|
|
21
|
+
try {
|
|
22
|
+
snapshot = projections.snapshot(session);
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const pressure = snapshot?.values?.contextPressure;
|
|
27
|
+
if (pressure === undefined || pressure === null) return null;
|
|
28
|
+
const window = pressure.contextWindow;
|
|
29
|
+
if (!Number.isFinite(window) || window <= 0) return null;
|
|
30
|
+
const used = Number.isFinite(pressure.projectedTokens)
|
|
31
|
+
? pressure.projectedTokens
|
|
32
|
+
: pressure.pressureTokens;
|
|
33
|
+
if (!Number.isFinite(used) || used <= 0) return null;
|
|
34
|
+
return { used, window, ratio: used / window };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Human-readable pressure line used by tool output and notices.
|
|
39
|
+
* @param reading - result of {@link readPressure}.
|
|
40
|
+
* @returns compact text such as `62.4% (624k / 1000k)`.
|
|
41
|
+
*/
|
|
42
|
+
export function formatPressure(reading) {
|
|
43
|
+
if (reading === null) return 'unavailable';
|
|
44
|
+
const percent = (reading.ratio * 100).toFixed(1);
|
|
45
|
+
return percent + '% (' + Math.round(reading.used / 1000) + 'k / ' + Math.round(reading.window / 1000) + 'k)';
|
|
46
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Route card for a handed-off session.
|
|
3
|
+
*
|
|
4
|
+
* The child starts from one checkpoint and nothing else: the parent's raw
|
|
5
|
+
* conversation is not copied into it. Whether the model may answer from the
|
|
6
|
+
* checkpoint alone depends on how much was compressed into how little text, so
|
|
7
|
+
* the card is graded by the measured compression ratio.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { sessionEvents } from './history.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Trust line: at or below this ratio the checkpoint carried every fact the quiz
|
|
14
|
+
* asked for (measured 28.6:1).
|
|
15
|
+
*/
|
|
16
|
+
export const TRUST_RATIO = 30;
|
|
17
|
+
/**
|
|
18
|
+
* Hard line: above this ratio concrete values start colliding or vanishing
|
|
19
|
+
* (measured 177:1 / 231:1 kept only topic words).
|
|
20
|
+
*/
|
|
21
|
+
export const STRONG_RATIO = 80;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Count the checkpoint's own tokens, never the invisible ones.
|
|
25
|
+
*
|
|
26
|
+
* `usage.outputTokens` includes the reasoning block that never reaches the
|
|
27
|
+
* checkpoint (a measured run reported 3312 while the text was 3041), so using it
|
|
28
|
+
* raw understates the ratio and makes the card over-trust a skeleton summary.
|
|
29
|
+
* The character fallback deliberately errs towards a HIGHER ratio: under-counting
|
|
30
|
+
* tokens can only make the card more cautious.
|
|
31
|
+
* @param text - checkpoint text.
|
|
32
|
+
* @param usage - provider usage report, when available.
|
|
33
|
+
* @returns estimated checkpoint token count, at least 1.
|
|
34
|
+
*/
|
|
35
|
+
export function summaryTokensOf(text, usage) {
|
|
36
|
+
const chars = typeof text === 'string' ? text.length : 0;
|
|
37
|
+
const reported = Number(usage?.outputTokens);
|
|
38
|
+
const reasoning = Number(usage?.reasoningTokens);
|
|
39
|
+
const textTokens = Number.isFinite(reported) && reported > 0
|
|
40
|
+
? reported - (Number.isFinite(reasoning) && reasoning > 0 ? reasoning : 0)
|
|
41
|
+
: 0;
|
|
42
|
+
if (textTokens > 0) return Math.round(textTokens);
|
|
43
|
+
return Math.max(1, Math.round(chars / 2.6));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function summaryText(data) {
|
|
47
|
+
const summary = data?.summary;
|
|
48
|
+
if (Array.isArray(summary)) {
|
|
49
|
+
return summary.map((block) => (typeof block?.text === 'string' ? block.text : '')).join('\n');
|
|
50
|
+
}
|
|
51
|
+
return typeof summary === 'string' ? summary : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Measure the newest checkpoint in a session log.
|
|
56
|
+
* Kept for sessions compacted in place (the pre-0.3 fork path); a genuine
|
|
57
|
+
* handoff child has no checkpoint of its own and passes stats in instead.
|
|
58
|
+
* @param session - session whose own log is inspected.
|
|
59
|
+
* @returns tokens/text of the summary and the input it replaced, or null.
|
|
60
|
+
*/
|
|
61
|
+
export function compressionStats(session) {
|
|
62
|
+
const events = sessionEvents(session);
|
|
63
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
64
|
+
const event = events[index];
|
|
65
|
+
if (event?.type !== 'compaction/summary') continue;
|
|
66
|
+
const text = summaryText(event.data).trim();
|
|
67
|
+
if (text === '') return null;
|
|
68
|
+
const summaryTokens = summaryTokensOf(text, event.data?.usage);
|
|
69
|
+
const shadowed = Number(event.data?.shadowedTokenCount ?? 0);
|
|
70
|
+
return { chars: text.length, summaryTokens, shadowed, ratio: shadowed / summaryTokens };
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the first-turn route card for a legacy fork child.
|
|
77
|
+
*
|
|
78
|
+
* Sessions created by the pre-0.3 fork path copied the parent's events into
|
|
79
|
+
* themselves, so their own log IS the archive. Telling such a session to read a
|
|
80
|
+
* parent record would send it to a file that does not exist.
|
|
81
|
+
* @param session - the forked session.
|
|
82
|
+
* @returns Markdown-ish text for a plugin message.
|
|
83
|
+
*/
|
|
84
|
+
export function forkCard(session) {
|
|
85
|
+
const stats = compressionStats(session);
|
|
86
|
+
const lines = [
|
|
87
|
+
'[dsh-session-handoff] 本会话是旧版 fork 出来的会话:父会话的原始事件已经复制进本会话自己的日志。'
|
|
88
|
+
];
|
|
89
|
+
if (stats !== null) {
|
|
90
|
+
lines.push('交接压缩比 ' + Math.round(stats.ratio) + ':1(' + stats.shadowed + ' tokens 压成 ' + stats.summaryTokens + ' tokens);上下文里只有摘要 + 近期原文。');
|
|
91
|
+
}
|
|
92
|
+
lines.push('需要旧细节时调用 handoff_history({ query: "关键词" })——默认 source 就是本会话,原文在这里。');
|
|
93
|
+
return lines.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build the first-turn route card for a handed-off session.
|
|
98
|
+
* @param session - the handed-off session.
|
|
99
|
+
* @param stats - handoff measurement from the child's record; falls back to a
|
|
100
|
+
* checkpoint found in the session's own log.
|
|
101
|
+
* @returns Markdown-ish text for a plugin message.
|
|
102
|
+
*/
|
|
103
|
+
export function routeCard(session, stats) {
|
|
104
|
+
const resolved = stats === undefined || stats === null ? compressionStats(session) : stats;
|
|
105
|
+
const parent = typeof resolved?.parentSession === 'string'
|
|
106
|
+
? resolved.parentSession
|
|
107
|
+
: typeof session?.header?.parentSession === 'string' ? session.header.parentSession : undefined;
|
|
108
|
+
const lines = [
|
|
109
|
+
parent === undefined
|
|
110
|
+
? '[dsh-session-handoff] 本会话是从旧会话交接出来的新会话,上下文只有下面这份摘要。'
|
|
111
|
+
: '[dsh-session-handoff] 本会话是从 session ' + parent + ' 交接出来的新会话,上下文只有下面这份摘要。',
|
|
112
|
+
'旧会话的完整对话不在本会话里,仍原样保存在它的日志中,可以只读检索。'
|
|
113
|
+
];
|
|
114
|
+
if (resolved === null) {
|
|
115
|
+
lines.push('需要旧细节时调用 handoff_history({ query: "关键词", source: "parent" }) 检索原文。');
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
118
|
+
const detail = Math.round(resolved.ratio) + ':1(' + resolved.shadowed + ' tokens 压成 ' + resolved.summaryTokens + ' tokens)';
|
|
119
|
+
if (resolved.ratio > STRONG_RATIO) {
|
|
120
|
+
lines.push('交接压缩比 ' + detail + ',摘要只剩骨架。');
|
|
121
|
+
lines.push('硬性要求:函数名、常量值、错误码、文件路径这类具体事实,必须先 handoff_history({ query, source: "parent" }) 查到原文再回答,禁止凭记忆。');
|
|
122
|
+
} else if (resolved.ratio > TRUST_RATIO) {
|
|
123
|
+
lines.push('交接压缩比 ' + detail + ',摘要保住了大部分事实,但丢掉了「事实属于哪一条/哪一份」这类归属信息。');
|
|
124
|
+
lines.push('具体事实可以照摘要直接回答;涉及出处归属、逐字引用、编号对应时,先用 handoff_history({ query, source: "parent" }) 核对。');
|
|
125
|
+
} else {
|
|
126
|
+
lines.push('交接压缩比 ' + detail + ',摘要保留了主要细节。');
|
|
127
|
+
lines.push('摘要能直接回答的先答;只有涉及具体数值或原文措辞且不确定时,才用 handoff_history({ query, source: "parent" }) 核对。');
|
|
128
|
+
}
|
|
129
|
+
return lines.join('\n');
|
|
130
|
+
}
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator settings written from the browser panel.
|
|
3
|
+
*
|
|
4
|
+
* The profile patch (cordis.patch.yml) stays the authoritative installed
|
|
5
|
+
* configuration; this module owns only the handful of knobs the panel exposes,
|
|
6
|
+
* and stores them beside the handoff records as one small JSON file.
|
|
7
|
+
*
|
|
8
|
+
* Precedence at load time: DEFAULTS < profile patch < settings.json. Deleting
|
|
9
|
+
* the file therefore restores exactly what the patch says, which is the whole
|
|
10
|
+
* point of keeping it separate: the plugin still never rewrites a host
|
|
11
|
+
* configuration file.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { DEFAULTS } from './config.js';
|
|
17
|
+
import { handoffRoot } from './archive.js';
|
|
18
|
+
|
|
19
|
+
const MINUTE = 60 * 1000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The controls the browser panel draws, in display order.
|
|
23
|
+
*
|
|
24
|
+
* This array is the single source of truth for the panel: the browser half
|
|
25
|
+
* renders whatever it receives from the Host instead of hard-coding limits, so
|
|
26
|
+
* the two halves cannot drift.
|
|
27
|
+
*/
|
|
28
|
+
export const CONTROLS = Object.freeze([
|
|
29
|
+
Object.freeze({ key: 'mode', kind: 'enum', options: Object.freeze(['ask', 'auto', 'off']), fallback: DEFAULTS.mode }),
|
|
30
|
+
Object.freeze({ key: 'askRatio', kind: 'ratio', min: 0.3, max: 0.9, step: 0.05, fallback: DEFAULTS.askRatio }),
|
|
31
|
+
Object.freeze({ key: 'autoRatio', kind: 'ratio', min: 0.5, max: 0.95, step: 0.05, fallback: DEFAULTS.autoRatio }),
|
|
32
|
+
Object.freeze({ key: 'noticeCooldownMs', kind: 'duration', min: MINUTE, max: 60 * MINUTE, step: MINUTE, fallback: DEFAULTS.noticeCooldownMs }),
|
|
33
|
+
Object.freeze({ key: 'dryRun', kind: 'boolean', fallback: DEFAULTS.dryRun }),
|
|
34
|
+
Object.freeze({ key: 'maxHandoffsPerSession', kind: 'count', min: 1, max: 5, fallback: DEFAULTS.maxHandoffsPerSession }),
|
|
35
|
+
Object.freeze({ key: 'includeHistoryTool', kind: 'boolean', fallback: DEFAULTS.includeHistoryTool }),
|
|
36
|
+
Object.freeze({ key: 'autoOpenSession', kind: 'boolean', fallback: DEFAULTS.autoOpenSession })
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const CONTROL_KEYS = new Set(CONTROLS.map(control => control.key));
|
|
40
|
+
|
|
41
|
+
/** The file holding panel edits, beside the handoff records. */
|
|
42
|
+
export function settingsPath() {
|
|
43
|
+
return join(dirname(handoffRoot()), 'dsh-session-handoff', 'settings.json');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Coerce one incoming value to its control's domain.
|
|
48
|
+
* @param control - the control declaration.
|
|
49
|
+
* @param value - raw incoming value.
|
|
50
|
+
* @returns the accepted value, or undefined when unacceptable.
|
|
51
|
+
*/
|
|
52
|
+
function accept(control, value) {
|
|
53
|
+
if (control.kind === 'boolean') return typeof value === 'boolean' ? value : undefined;
|
|
54
|
+
if (control.kind === 'enum') return control.options.includes(value) ? value : undefined;
|
|
55
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
|
|
56
|
+
const clamped = Math.min(control.max, Math.max(control.min, value));
|
|
57
|
+
if (control.kind === 'count') {
|
|
58
|
+
const rounded = Math.round(clamped);
|
|
59
|
+
return Number.isSafeInteger(rounded) ? rounded : undefined;
|
|
60
|
+
}
|
|
61
|
+
if (control.kind === 'ratio') return Math.round(clamped * 100) / 100;
|
|
62
|
+
if (control.kind === 'duration') return Math.round(clamped / control.step) * control.step;
|
|
63
|
+
return clamped;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Keep only known keys with acceptable values.
|
|
68
|
+
* @param input - candidate settings object.
|
|
69
|
+
* @returns a fresh object holding the accepted subset.
|
|
70
|
+
*/
|
|
71
|
+
export function sanitize(input) {
|
|
72
|
+
const source = input !== null && typeof input === 'object' ? input : {};
|
|
73
|
+
const out = {};
|
|
74
|
+
for (const control of CONTROLS) {
|
|
75
|
+
if (!(control.key in source)) continue;
|
|
76
|
+
const value = accept(control, source[control.key]);
|
|
77
|
+
if (value !== undefined) out[control.key] = value;
|
|
78
|
+
}
|
|
79
|
+
if (typeof out.askRatio === 'number' && typeof out.autoRatio === 'number'
|
|
80
|
+
&& out.autoRatio <= out.askRatio) {
|
|
81
|
+
out.autoRatio = Math.min(0.95, Math.round((out.askRatio + 0.05) * 100) / 100);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Read the stored overrides.
|
|
88
|
+
* @returns the stored object, or an empty object when absent or unreadable.
|
|
89
|
+
*/
|
|
90
|
+
export function readSettings() {
|
|
91
|
+
const file = settingsPath();
|
|
92
|
+
if (!existsSync(file)) return {};
|
|
93
|
+
try {
|
|
94
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
95
|
+
const values = parsed !== null && typeof parsed === 'object' && parsed.values !== undefined
|
|
96
|
+
? parsed.values
|
|
97
|
+
: parsed;
|
|
98
|
+
return sanitize(values);
|
|
99
|
+
} catch {
|
|
100
|
+
// A corrupt file must never stop the plugin from loading; the patch wins.
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Merge a patch into the stored overrides and persist the result.
|
|
107
|
+
* @param patch - keys to change; a value of null removes that key.
|
|
108
|
+
* @returns the stored overrides after the write.
|
|
109
|
+
*/
|
|
110
|
+
export function writeSettings(patch) {
|
|
111
|
+
const next = { ...readSettings() };
|
|
112
|
+
const incoming = patch !== null && typeof patch === 'object' ? patch : {};
|
|
113
|
+
for (const key of Object.keys(incoming)) {
|
|
114
|
+
if (CONTROL_KEYS.has(key) && incoming[key] === null) delete next[key];
|
|
115
|
+
}
|
|
116
|
+
Object.assign(next, sanitize(incoming));
|
|
117
|
+
const file = settingsPath();
|
|
118
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
119
|
+
const payload = JSON.stringify({ version: 1, updatedAt: Date.now(), values: next }, null, 2);
|
|
120
|
+
const temp = file + '.tmp-' + String(process.pid);
|
|
121
|
+
writeFileSync(temp, payload, 'utf8');
|
|
122
|
+
renameSync(temp, file);
|
|
123
|
+
return next;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Drop every stored override, restoring the profile patch's values.
|
|
128
|
+
* @returns true when a file was removed.
|
|
129
|
+
*/
|
|
130
|
+
export function clearSettings() {
|
|
131
|
+
const file = settingsPath();
|
|
132
|
+
if (!existsSync(file)) return false;
|
|
133
|
+
rmSync(file, { force: true });
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Shape the panel's read payload.
|
|
139
|
+
* @param effective - the configuration currently in force.
|
|
140
|
+
* @returns controls, effective values, and the raw overrides.
|
|
141
|
+
*/
|
|
142
|
+
export function describe(effective) {
|
|
143
|
+
const overrides = readSettings();
|
|
144
|
+
const values = {};
|
|
145
|
+
for (const control of CONTROLS) {
|
|
146
|
+
const value = effective === undefined || effective === null ? undefined : effective[control.key];
|
|
147
|
+
values[control.key] = value === undefined ? control.fallback : value;
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
controls: CONTROLS.map(control => ({ ...control })),
|
|
151
|
+
values,
|
|
152
|
+
overrides,
|
|
153
|
+
path: settingsPath()
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handoff summary backend.
|
|
3
|
+
*
|
|
4
|
+
* Same compaction policy as the stock backend (pressure, retention, range
|
|
5
|
+
* selection, durable commit are inherited), but the summarization directive is
|
|
6
|
+
* replaced so the checkpoint keeps exact facts instead of collapsing them into
|
|
7
|
+
* topic words.
|
|
8
|
+
*
|
|
9
|
+
* Failure containment: this is a NEW root-level entry. It never disables the
|
|
10
|
+
* host compactor; if this module fails to load or throws, the per-preset
|
|
11
|
+
* compactor keeps working and only the handoff feature degrades.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
15
|
+
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic';
|
|
16
|
+
|
|
17
|
+
const PLUGIN = 'dsh-session-handoff';
|
|
18
|
+
|
|
19
|
+
/** Directive appended after the replayed conversation. */
|
|
20
|
+
const HANDOFF_INSTRUCTION = [
|
|
21
|
+
'You are compacting this conversation into a checkpoint that another model will resume from.',
|
|
22
|
+
'The recent tail stays verbatim; you only condense the span above.',
|
|
23
|
+
'',
|
|
24
|
+
'Output EXACTLY the Markdown structure below, every heading once, in order. Use terse bullets.',
|
|
25
|
+
'Write "(none)" only when a section is genuinely empty; never omit a heading.',
|
|
26
|
+
'',
|
|
27
|
+
'## Primary Request and Intent',
|
|
28
|
+
'- [the user goals, quoting exact wording where it decides behaviour]',
|
|
29
|
+
'',
|
|
30
|
+
'## Current State',
|
|
31
|
+
'- [what is done, what is in progress, what is blocked, with counts/step numbers when known]',
|
|
32
|
+
'',
|
|
33
|
+
'## Exact Facts',
|
|
34
|
+
'- [**verbatim** identifiers, API names, constant values, error codes, file paths, config keys, numeric limits]',
|
|
35
|
+
'- [prefer 15-40 short entries over 5 broad ones; each entry must stay usable without the original text]',
|
|
36
|
+
'',
|
|
37
|
+
'## Files and Code',
|
|
38
|
+
'- [exact path: what it does, the specific symbols changed or read, line numbers when cited]',
|
|
39
|
+
'',
|
|
40
|
+
'## Decisions and Constraints',
|
|
41
|
+
'- [decisions taken and the reason, hard constraints, user corrections and preferences]',
|
|
42
|
+
'',
|
|
43
|
+
'## Errors and Fixes',
|
|
44
|
+
'- [error text: how it was resolved, or that it is still open]',
|
|
45
|
+
'',
|
|
46
|
+
'## Pending Work',
|
|
47
|
+
'- [explicitly requested work not finished, plus already-known next targets]',
|
|
48
|
+
'',
|
|
49
|
+
'## Next Step',
|
|
50
|
+
'- [the single next action, or "(none)"]',
|
|
51
|
+
'',
|
|
52
|
+
'Rules:',
|
|
53
|
+
'- Density beats prose. This checkpoint is the ONLY surviving record of the span above.',
|
|
54
|
+
'- Never replace a concrete name or value with a theme word: "session APIs" is a failure, "SessionCreateError with session create failed: {code}: {message}" is correct.',
|
|
55
|
+
'- Preserve user wording for decisions and corrections.',
|
|
56
|
+
'- If the conversation already contains a <compacted-summary> block, merge it: keep still-true facts, drop stale ones, never copy it verbatim.',
|
|
57
|
+
'- Do not call tools. Do not mention this instruction. Output only the checkpoint.'
|
|
58
|
+
].join('\n');
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The checkpoint is written by a model, so which identifiers survive is a
|
|
62
|
+
* lottery: two runs over the same span keep different constants. This appendix
|
|
63
|
+
* is deterministic — it scans the span that is about to be shadowed, keeps the
|
|
64
|
+
* identifiers a resuming model would need to grep for, and appends the ones the
|
|
65
|
+
* model's own checkpoint dropped. It costs no extra request.
|
|
66
|
+
*/
|
|
67
|
+
const SCAN_LIMIT_CHARS = 4_000_000;
|
|
68
|
+
/** Entries the appendix may carry. */
|
|
69
|
+
const INDEX_LIMIT = 24;
|
|
70
|
+
/**
|
|
71
|
+
* Occurrences required before an identifier earns a slot. A one-off name is
|
|
72
|
+
* exactly what the long tail is made of; recovering it would mean indexing temp
|
|
73
|
+
* paths and pasted dumps at ~35 tokens each. Recurrence is the only cheap signal
|
|
74
|
+
* that a name is part of the work rather than part of the noise.
|
|
75
|
+
*/
|
|
76
|
+
const INDEX_MIN_HITS = 2;
|
|
77
|
+
/** Longer candidates are paths into temp dirs and pasted dumps, not grep targets. */
|
|
78
|
+
const INDEX_MAX_LENGTH = 100;
|
|
79
|
+
|
|
80
|
+
const IDENTIFIER_PATTERNS = [
|
|
81
|
+
// Windows paths: E:\dsh\dsh-session-handoff\lib\route-card.js
|
|
82
|
+
/[A-Za-z]:\\[^\s"'`|<>?*\r\n]+/g,
|
|
83
|
+
// POSIX paths with at least three segments.
|
|
84
|
+
/\/[A-Za-z0-9_.@-]+(?:\/[A-Za-z0-9_.@-]+){2,}/g,
|
|
85
|
+
// Inline code spans: `askRatio`
|
|
86
|
+
/`([^`\n]{3,80})`/g,
|
|
87
|
+
// SCREAMING_SNAKE constants only: JSON/URL/API are noise, TRUST_RATIO is not.
|
|
88
|
+
/\b[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+\b/g,
|
|
89
|
+
// Dotted members: usage.outputTokens, sessionController.fork. The lookarounds
|
|
90
|
+
// keep it from firing INSIDE a path (\session.v4.jsonl.zstd would otherwise
|
|
91
|
+
// survive as a fake member after the path itself was rejected as ephemeral).
|
|
92
|
+
/(?<![\\/\w.-])[a-z][A-Za-z0-9]{2,}(?:\.[A-Za-z][A-Za-z0-9]+)+(?![\w.-])/g
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
|
96
|
+
const LONG_HEX = /\b[0-9a-f]{12,}\b/i;
|
|
97
|
+
const EPHEMERAL = /(?:[\\/]Temp[\\/]|[\\/]tmp[\\/]|_npx[\\/])/i;
|
|
98
|
+
|
|
99
|
+
const IDENTIFIER_NOISE = new Set([
|
|
100
|
+
'example.com', 'node_modules', 'package.json', 'console.log', 'console.error',
|
|
101
|
+
'process.env', 'module.exports', 'window.location', 'document.title',
|
|
102
|
+
'string', 'number', 'boolean', 'object', 'function', 'undefined', 'null'
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
function clean(raw) {
|
|
106
|
+
return raw
|
|
107
|
+
.replace(/[\u0000-\u001f]+/g, '')
|
|
108
|
+
.replace(/^[.\-`'"\s]+/, '')
|
|
109
|
+
.trim()
|
|
110
|
+
.replace(/[.,;:)\]'"]+$/, '');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isNoiseCandidate(raw) {
|
|
114
|
+
if (raw.length > INDEX_MAX_LENGTH) return true;
|
|
115
|
+
if (UUID.test(raw) || LONG_HEX.test(raw)) return true;
|
|
116
|
+
if (EPHEMERAL.test(raw)) return true;
|
|
117
|
+
return IDENTIFIER_NOISE.has(raw.toLowerCase());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function scanText(value, out, budget) {
|
|
121
|
+
if (value === null || value === undefined || budget.left <= 0) return;
|
|
122
|
+
if (typeof value === 'string') {
|
|
123
|
+
const slice = value.length > budget.left ? value.slice(0, budget.left) : value;
|
|
124
|
+
budget.left -= slice.length;
|
|
125
|
+
out.push(slice);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(value)) {
|
|
129
|
+
for (const item of value) scanText(item, out, budget);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (typeof value === 'object') {
|
|
133
|
+
for (const key of Object.keys(value)) scanText(value[key], out, budget);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Flatten the span that is about to be shadowed into one searchable string.
|
|
139
|
+
* @param messages - the replayed conversation handed to {@link summarize}.
|
|
140
|
+
* @returns plain text, bounded to {@link SCAN_LIMIT_CHARS}.
|
|
141
|
+
*/
|
|
142
|
+
export function shadowedText(messages) {
|
|
143
|
+
const parts = [];
|
|
144
|
+
scanText(messages, parts, { left: SCAN_LIMIT_CHARS });
|
|
145
|
+
return parts.join('\n');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function normalise(value) {
|
|
149
|
+
return value.toLowerCase().replace(/\\/g, '/').replace(/[\u3002\uff0c\uff1b\uff1a]+$/g, '');
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Build the deterministic identifier appendix for a checkpoint.
|
|
154
|
+
* @param shadowed - text of the span the checkpoint replaces.
|
|
155
|
+
* @param summary - the checkpoint the model produced.
|
|
156
|
+
* @returns Markdown appendix, or an empty string when nothing is missing.
|
|
157
|
+
*/
|
|
158
|
+
export function identifierIndex(shadowed, summary) {
|
|
159
|
+
if (typeof shadowed !== 'string' || shadowed === '') return '';
|
|
160
|
+
const counts = new Map();
|
|
161
|
+
for (const pattern of IDENTIFIER_PATTERNS) {
|
|
162
|
+
pattern.lastIndex = 0;
|
|
163
|
+
let match;
|
|
164
|
+
while ((match = pattern.exec(shadowed)) !== null) {
|
|
165
|
+
const raw = clean(match[1] ?? match[0]);
|
|
166
|
+
if (raw.length < 4) continue;
|
|
167
|
+
if (isNoiseCandidate(raw)) continue;
|
|
168
|
+
counts.set(raw, (counts.get(raw) ?? 0) + 1);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (counts.size === 0) return '';
|
|
172
|
+
|
|
173
|
+
const known = normalise(typeof summary === 'string' ? summary : '');
|
|
174
|
+
const picked = [...counts.entries()]
|
|
175
|
+
.filter(([raw, hits]) => hits >= INDEX_MIN_HITS && !known.includes(normalise(raw)))
|
|
176
|
+
.sort((a, b) => (b[1] - a[1]) || (b[0].length - a[0].length))
|
|
177
|
+
.slice(0, INDEX_LIMIT);
|
|
178
|
+
if (picked.length === 0) return '';
|
|
179
|
+
return '\n## Identifier Index\n'
|
|
180
|
+
+ 'Deterministic sweep: names that recurred in the shadowed span but are absent from the checkpoint above. Grep these before trusting recall.\n'
|
|
181
|
+
+ picked.map(([raw]) => '- `' + raw + '`').join('\n');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function finishError(finish) {
|
|
185
|
+
if (finish === undefined) return undefined;
|
|
186
|
+
switch (finish.kind) {
|
|
187
|
+
case 'error':
|
|
188
|
+
case 'aborted': {
|
|
189
|
+
const error = new Error(finish.failure?.message ?? 'summarization failed');
|
|
190
|
+
error.code = finish.failure?.code;
|
|
191
|
+
return error;
|
|
192
|
+
}
|
|
193
|
+
case 'max-tokens': {
|
|
194
|
+
const error = new Error('summarization truncated at the token cap (incomplete checkpoint)');
|
|
195
|
+
error.code = 'MAX_TOKENS';
|
|
196
|
+
return error;
|
|
197
|
+
}
|
|
198
|
+
default:
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function targetFor(agent, config) {
|
|
204
|
+
const durable = agent?.session?.requestHeader?.()?.config;
|
|
205
|
+
const configuredProvider = config.summarizationProvider;
|
|
206
|
+
const configuredModel = config.summarizationModel;
|
|
207
|
+
if (typeof configuredProvider === 'string' && configuredProvider !== ''
|
|
208
|
+
&& typeof configuredModel === 'string' && configuredModel !== '') {
|
|
209
|
+
return { provider: configuredProvider, model: configuredModel, controls: {} };
|
|
210
|
+
}
|
|
211
|
+
if (durable !== undefined && typeof durable.provider === 'string' && durable.provider !== ''
|
|
212
|
+
&& typeof durable.model === 'string' && durable.model !== '') {
|
|
213
|
+
return {
|
|
214
|
+
provider: durable.provider,
|
|
215
|
+
model: durable.model,
|
|
216
|
+
controls: {
|
|
217
|
+
...(durable.reasoningEffort === undefined ? {} : { reasoningEffort: durable.reasoningEffort }),
|
|
218
|
+
...(durable.temperature === undefined ? {} : { temperature: durable.temperature }),
|
|
219
|
+
...(durable.stop === undefined ? {} : { stop: [...durable.stop] })
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const options = agent?.options ?? {};
|
|
224
|
+
if (typeof options.provider === 'string' && options.provider !== ''
|
|
225
|
+
&& typeof options.model === 'string' && options.model !== '') {
|
|
226
|
+
return { provider: options.provider, model: options.model, controls: {} };
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Compaction backend whose checkpoint keeps exact facts. */
|
|
232
|
+
export class HandoffSummaryEngine extends BasicCompactionEngine {
|
|
233
|
+
async summarize(input, agent, signal) {
|
|
234
|
+
const target = targetFor(agent, this.config);
|
|
235
|
+
if (target === undefined) {
|
|
236
|
+
throw new Error('no provider/model available for summarization: route one request first');
|
|
237
|
+
}
|
|
238
|
+
const instruction = createUserMessage({
|
|
239
|
+
content: [{ type: 'text', text: HANDOFF_INSTRUCTION }],
|
|
240
|
+
source: { kind: 'plugin:' + PLUGIN, plugin: PLUGIN }
|
|
241
|
+
});
|
|
242
|
+
const assembler = new BlockAssembler();
|
|
243
|
+
for await (const chunk of this.ctx.llm.stream({
|
|
244
|
+
provider: target.provider,
|
|
245
|
+
model: target.model,
|
|
246
|
+
...target.controls,
|
|
247
|
+
messages: [...input.messages, instruction],
|
|
248
|
+
...(input.system === undefined ? {} : { system: input.system }),
|
|
249
|
+
...(input.tools === undefined ? {} : { tools: [...input.tools] }),
|
|
250
|
+
maxTokens: this.config.maxTokens,
|
|
251
|
+
sessionId: agent.session.id,
|
|
252
|
+
purpose: 'compaction',
|
|
253
|
+
...(signal === undefined ? {} : { signal })
|
|
254
|
+
})) {
|
|
255
|
+
assembler.push(chunk);
|
|
256
|
+
}
|
|
257
|
+
const finishKind = assembler.finish?.kind;
|
|
258
|
+
(globalThis.__handoffTrace ??= []).push('finish=' + String(finishKind));
|
|
259
|
+
const terminal = finishError(assembler.finish);
|
|
260
|
+
if (terminal !== undefined) throw terminal;
|
|
261
|
+
const rawOutput = assembler.blocks();
|
|
262
|
+
const summary = rawOutput.filter((block) => block?.type === 'text');
|
|
263
|
+
const index = identifierIndex(
|
|
264
|
+
shadowedText(input.messages),
|
|
265
|
+
summary.map((block) => (typeof block.text === 'string' ? block.text : '')).join('\n')
|
|
266
|
+
);
|
|
267
|
+
if (index !== '') summary.push({ type: 'text', text: index });
|
|
268
|
+
if (!summary.some((block) => typeof block.text === 'string' && block.text.trim() !== '')) {
|
|
269
|
+
throw new Error('handoff summary produced no text (finish=' + String(finishKind) + ', blocks=' + rawOutput.length + ')');
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
summary,
|
|
273
|
+
rawOutput,
|
|
274
|
+
llmStreamCall: true,
|
|
275
|
+
provider: target.provider,
|
|
276
|
+
model: target.model,
|
|
277
|
+
maxTokens: this.config.maxTokens,
|
|
278
|
+
...(assembler.usage === undefined ? {} : { usage: assembler.usage })
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export const name = 'handoff-summary-engine';
|
|
284
|
+
|
|
285
|
+
// The loader feeds these services into the context before apply() runs; without
|
|
286
|
+
// them BasicCompactionEngine.compactNow() throws "cannot get property ... without inject".
|
|
287
|
+
export const inject = BasicCompactionEngine.inject;
|
|
288
|
+
|
|
289
|
+
/** The instance this bundle mounts at the root context. */
|
|
290
|
+
let mounted;
|
|
291
|
+
|
|
292
|
+
export function apply(ctx, config = {}) {
|
|
293
|
+
mounted = new HandoffSummaryEngine(ctx, config);
|
|
294
|
+
return mounted;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The root-level summarizer this bundle mounted.
|
|
299
|
+
*
|
|
300
|
+
* The handoff needs exactly this backend: compaction backends live inside each
|
|
301
|
+
* agent preset's isolated group, and those use the stock directive, which
|
|
302
|
+
* collapses facts into topic words. Calling summarize() on this instance keeps
|
|
303
|
+
* the fact-dense checkpoint without committing anything to any session.
|
|
304
|
+
* @returns the mounted instance, or undefined when the bundle did not load.
|
|
305
|
+
*/
|
|
306
|
+
export function rootSummaryEngine() {
|
|
307
|
+
return mounted;
|
|
308
|
+
}
|