@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/cordis.patch.yml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# The handoff needs a fact-dense summarizer reachable from the ROOT context.
|
|
2
|
+
# Compaction backends live inside each agent preset's isolated group, where
|
|
3
|
+
# root-level code cannot reach them, so this bundle mounts its own instance.
|
|
4
|
+
#
|
|
5
|
+
# This entry is a summarizer host, not a compactor: `auto: false` means it never
|
|
6
|
+
# joins automatic pressure handling, and the handoff path calls the engine's
|
|
7
|
+
# summarize() directly WITHOUT committing anything back into the session. The
|
|
8
|
+
# outgoing session is therefore never mutated.
|
|
9
|
+
#
|
|
10
|
+
# It is ADDED, never a replacement: no host entry is disabled, so a failure here
|
|
11
|
+
# degrades the handoff feature only.
|
|
12
|
+
- insert:
|
|
13
|
+
- id: handoff-compaction-root
|
|
14
|
+
name: '@better-than-coca/dsh-session-handoff/summary-engine'
|
|
15
|
+
config:
|
|
16
|
+
auto: false
|
|
17
|
+
thresholdRatio: 0.8
|
|
18
|
+
retainRatio: 0.16
|
|
19
|
+
maxTokens: 8192
|
|
20
|
+
|
|
21
|
+
- id: session-handoff
|
|
22
|
+
name: '@better-than-coca/dsh-session-handoff'
|
|
23
|
+
disabled: true
|
|
24
|
+
config:
|
|
25
|
+
mode: ask
|
|
26
|
+
dryRun: true
|
|
27
|
+
askRatio: 0.55
|
|
28
|
+
autoRatio: 0.75
|
|
29
|
+
minRatio: 0.3
|
|
30
|
+
maxHandoffsPerSession: 1
|
|
31
|
+
includeHistoryTool: true
|
package/lib/archive.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parent-session archive access.
|
|
3
|
+
*
|
|
4
|
+
* A genuine-new-session handoff never copies the parent log: the child receives
|
|
5
|
+
* one injected checkpoint, and every concrete detail stays in the parent's own
|
|
6
|
+
* append-only log. That makes the parent log a first-class read dependency, so
|
|
7
|
+
* this module owns locating it, decoding it, and remembering which parent a
|
|
8
|
+
* child came from.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is read-only with respect to session logs. The only writes are
|
|
11
|
+
* this plugin's own handoff records under <dsh home>/handoff.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import zlib from 'node:zlib';
|
|
18
|
+
|
|
19
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
20
|
+
const LOG_NAME = 'session.v4.jsonl.zstd';
|
|
21
|
+
/** How many frames a decode may merge before the file is treated as unreadable. */
|
|
22
|
+
const MAX_FRAMES = 200000;
|
|
23
|
+
|
|
24
|
+
/** DSH home. Overridable so tests never touch the real profile. */
|
|
25
|
+
function home() {
|
|
26
|
+
const override = process.env.DSH_SESSION_HANDOFF_HOME;
|
|
27
|
+
return override === undefined || override === '' ? join(homedir(), '.dsh') : override;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function sessionsRoot() {
|
|
31
|
+
return join(home(), 'sessions');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function handoffRoot() {
|
|
35
|
+
return join(home(), 'handoff');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Find the durable log of one session, wherever its workspace directory sits.
|
|
40
|
+
* @param sessionId - session identity.
|
|
41
|
+
* @returns absolute path, or undefined when the log is absent.
|
|
42
|
+
*/
|
|
43
|
+
export function locateSessionLog(sessionId) {
|
|
44
|
+
if (typeof sessionId !== 'string' || sessionId === '') return undefined;
|
|
45
|
+
const root = sessionsRoot();
|
|
46
|
+
if (!existsSync(root)) return undefined;
|
|
47
|
+
let workspaces;
|
|
48
|
+
try {
|
|
49
|
+
workspaces = readdirSync(root, { withFileTypes: true });
|
|
50
|
+
} catch {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
for (const entry of workspaces) {
|
|
54
|
+
if (!entry.isDirectory()) continue;
|
|
55
|
+
const candidate = join(root, entry.name, sessionId, LOG_NAME);
|
|
56
|
+
if (existsSync(candidate)) return candidate;
|
|
57
|
+
}
|
|
58
|
+
return undefined;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Decode an append-only zstd file: every append is its own frame, and a single
|
|
63
|
+
* decompress call only yields the first one.
|
|
64
|
+
* @param buffer - raw file bytes.
|
|
65
|
+
* @returns concatenated UTF-8 payload.
|
|
66
|
+
*/
|
|
67
|
+
export function decodeFrames(buffer) {
|
|
68
|
+
const offsets = [];
|
|
69
|
+
for (let index = 0; index + 4 <= buffer.length; index += 1) {
|
|
70
|
+
if (buffer.compare(ZSTD_MAGIC, 0, 4, index, index + 4) === 0) {
|
|
71
|
+
offsets.push(index);
|
|
72
|
+
if (offsets.length > MAX_FRAMES) break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (offsets.length === 0) return buffer.toString('utf8');
|
|
76
|
+
const parts = [];
|
|
77
|
+
for (let index = 0; index < offsets.length; index += 1) {
|
|
78
|
+
const end = index + 1 < offsets.length ? offsets[index + 1] : buffer.length;
|
|
79
|
+
try {
|
|
80
|
+
parts.push(zlib.zstdDecompressSync(buffer.subarray(offsets[index], end)).toString('utf8'));
|
|
81
|
+
} catch {
|
|
82
|
+
// A partially written trailing frame must not poison the rest.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return parts.join('');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const logCache = new Map();
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Read the durable events of one archived session.
|
|
92
|
+
* @param sessionId - session identity.
|
|
93
|
+
* @returns parsed events, or an empty array when the log is missing.
|
|
94
|
+
*/
|
|
95
|
+
export function readSessionEvents(sessionId) {
|
|
96
|
+
const path = locateSessionLog(sessionId);
|
|
97
|
+
if (path === undefined) return [];
|
|
98
|
+
let stats;
|
|
99
|
+
try {
|
|
100
|
+
stats = statSync(path);
|
|
101
|
+
} catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
const cached = logCache.get(path);
|
|
105
|
+
if (cached !== undefined && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) return cached.events;
|
|
106
|
+
let buffer;
|
|
107
|
+
try {
|
|
108
|
+
buffer = readFileSync(path);
|
|
109
|
+
} catch {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
112
|
+
if (typeof zlib.zstdDecompressSync !== 'function') return [];
|
|
113
|
+
const events = [];
|
|
114
|
+
for (const line of decodeFrames(buffer).split('\n')) {
|
|
115
|
+
if (line.trim() === '') continue;
|
|
116
|
+
try {
|
|
117
|
+
events.push(JSON.parse(line));
|
|
118
|
+
} catch {
|
|
119
|
+
// Skip a torn line rather than failing the whole archive.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
logCache.set(path, { mtimeMs: stats.mtimeMs, size: stats.size, events });
|
|
123
|
+
return events;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The waiting checkpoint.
|
|
128
|
+
*
|
|
129
|
+
* A handoff does not create a session: the host hides sessions with no events and
|
|
130
|
+
* the plugin cannot write into one it did not create through the UI. Instead the
|
|
131
|
+
* checkpoint waits here, and the next brand-new conversation the user opens picks
|
|
132
|
+
* it up on its first prompt.
|
|
133
|
+
*/
|
|
134
|
+
function pendingPath() {
|
|
135
|
+
return join(handoffRoot(), 'pending.json');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function writePending(record) {
|
|
139
|
+
mkdirSync(handoffRoot(), { recursive: true });
|
|
140
|
+
writeFileSync(pendingPath(), JSON.stringify(record, null, 2), 'utf8');
|
|
141
|
+
return pendingPath();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function readPending() {
|
|
145
|
+
const path = pendingPath();
|
|
146
|
+
if (!existsSync(path)) return undefined;
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
149
|
+
} catch {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Record which conversation took the waiting checkpoint.
|
|
156
|
+
* @param sessionId - session that received it.
|
|
157
|
+
* @returns the updated record, or undefined when nothing was pending.
|
|
158
|
+
*/
|
|
159
|
+
export function consumePending(sessionId) {
|
|
160
|
+
const record = readPending();
|
|
161
|
+
if (record === undefined) return undefined;
|
|
162
|
+
const next = { ...record, consumedBy: sessionId, consumedAt: Date.now() };
|
|
163
|
+
writeFileSync(pendingPath(), JSON.stringify(next, null, 2), 'utf8');
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Drop a waiting checkpoint without using it. */
|
|
168
|
+
export function clearPending() {
|
|
169
|
+
const path = pendingPath();
|
|
170
|
+
if (!existsSync(path)) return false;
|
|
171
|
+
try {
|
|
172
|
+
rmSync(path, { force: true });
|
|
173
|
+
return true;
|
|
174
|
+
} catch {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function recordPath(childId) {
|
|
180
|
+
return join(handoffRoot(), childId + '.json');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Persist the handoff record that tells a child where its parent archive is.
|
|
185
|
+
* @param record - handoff identity, checkpoint text, and measurement.
|
|
186
|
+
* @returns the absolute record path.
|
|
187
|
+
*/
|
|
188
|
+
export function writeHandoff(record) {
|
|
189
|
+
mkdirSync(handoffRoot(), { recursive: true });
|
|
190
|
+
const path = recordPath(record.childId);
|
|
191
|
+
writeFileSync(path, JSON.stringify(record, null, 2), 'utf8');
|
|
192
|
+
return path;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Read the handoff record of one child session.
|
|
197
|
+
* @param childId - child session identity.
|
|
198
|
+
* @returns the record, or undefined when this session was not handed off to.
|
|
199
|
+
*/
|
|
200
|
+
export function readHandoff(childId) {
|
|
201
|
+
if (typeof childId !== 'string' || childId === '') return undefined;
|
|
202
|
+
const path = recordPath(childId);
|
|
203
|
+
if (!existsSync(path)) return undefined;
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Rewrite a handoff record after its checkpoint was injected into the child.
|
|
213
|
+
* The record is kept: it is the child's only pointer back to its parent archive.
|
|
214
|
+
* @param childId - child session identity.
|
|
215
|
+
* @param patch - fields to merge.
|
|
216
|
+
*/
|
|
217
|
+
export function updateHandoff(childId, patch) {
|
|
218
|
+
const record = readHandoff(childId);
|
|
219
|
+
if (record === undefined) return undefined;
|
|
220
|
+
const next = { ...record, ...patch };
|
|
221
|
+
writeFileSync(recordPath(childId), JSON.stringify(next, null, 2), 'utf8');
|
|
222
|
+
return next;
|
|
223
|
+
}
|
|
224
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkpoint framing.
|
|
3
|
+
*
|
|
4
|
+
* The exact preamble and tags the host uses for its own compaction checkpoints,
|
|
5
|
+
* so a handed-off session treats the injected text as established background
|
|
6
|
+
* rather than as a request it should answer.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const SUMMARY_OPEN_TAG = '<compacted-summary>';
|
|
10
|
+
export const SUMMARY_CLOSE_TAG = '</compacted-summary>';
|
|
11
|
+
export const CHECKPOINT_PREAMBLE = 'This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Wrap checkpoint text in the host's framing.
|
|
15
|
+
* @param summaryText - the generated checkpoint body.
|
|
16
|
+
* @returns framed text ready to inject or append.
|
|
17
|
+
*/
|
|
18
|
+
export function frameCheckpoint(summaryText) {
|
|
19
|
+
return [CHECKPOINT_PREAMBLE, '', SUMMARY_OPEN_TAG, summaryText, SUMMARY_CLOSE_TAG].join('\n');
|
|
20
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration resolution and validation for dsh-session-handoff.
|
|
3
|
+
* Every value is validated once at plugin load; a bad value never throws into
|
|
4
|
+
* the host loader, it falls back to the documented default and is reported.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export const DEFAULTS = Object.freeze({
|
|
8
|
+
/** off | ask | auto */
|
|
9
|
+
mode: 'ask',
|
|
10
|
+
/** Safety default: only report what would happen. */
|
|
11
|
+
dryRun: true,
|
|
12
|
+
/** Ratio of the model context window at which the model is asked to check in. */
|
|
13
|
+
askRatio: 0.55,
|
|
14
|
+
/** Ratio at which auto mode performs the handoff without asking. */
|
|
15
|
+
autoRatio: 0.75,
|
|
16
|
+
/** Never hand off below this ratio, even if requested. */
|
|
17
|
+
minRatio: 0.3,
|
|
18
|
+
/** Hard cap: refuse further handoffs once reached for the same session. */
|
|
19
|
+
maxHandoffsPerSession: 1,
|
|
20
|
+
/** Register the handoff_history lookup tool. */
|
|
21
|
+
includeHistoryTool: true,
|
|
22
|
+
/** Open a brand-new session and continue there once the checkpoint is parked. */
|
|
23
|
+
autoOpenSession: true,
|
|
24
|
+
/** Minimum gap between two "context is filling up" notices. */
|
|
25
|
+
noticeCooldownMs: 10 * 60 * 1000,
|
|
26
|
+
/** Timeout for the summarization request that builds the checkpoint. */
|
|
27
|
+
summarizeTimeoutMs: 180000
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const RATIO_KEYS = ['askRatio', 'autoRatio', 'minRatio'];
|
|
31
|
+
|
|
32
|
+
function isRatio(value) {
|
|
33
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 && value < 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Merge user configuration over the defaults, rejecting invalid values.
|
|
38
|
+
* @param raw - configuration object supplied by the loader.
|
|
39
|
+
* @param warn - reporter used for every rejected value.
|
|
40
|
+
* @returns a frozen resolved configuration.
|
|
41
|
+
*/
|
|
42
|
+
export function resolveConfig(raw, warn = () => {}) {
|
|
43
|
+
const input = raw !== null && typeof raw === 'object' ? raw : {};
|
|
44
|
+
const resolved = { ...DEFAULTS };
|
|
45
|
+
|
|
46
|
+
if (input.mode !== undefined) {
|
|
47
|
+
if (input.mode === 'off' || input.mode === 'ask' || input.mode === 'auto') resolved.mode = input.mode;
|
|
48
|
+
else warn('mode must be one of off|ask|auto; using ' + DEFAULTS.mode);
|
|
49
|
+
}
|
|
50
|
+
if (input.dryRun !== undefined) {
|
|
51
|
+
if (typeof input.dryRun === 'boolean') resolved.dryRun = input.dryRun;
|
|
52
|
+
else warn('dryRun must be a boolean; using ' + DEFAULTS.dryRun);
|
|
53
|
+
}
|
|
54
|
+
for (const key of RATIO_KEYS) {
|
|
55
|
+
if (input[key] === undefined) continue;
|
|
56
|
+
if (isRatio(input[key])) resolved[key] = input[key];
|
|
57
|
+
else warn(key + ' must be a number in (0, 1); using ' + DEFAULTS[key]);
|
|
58
|
+
}
|
|
59
|
+
if (!(resolved.askRatio < resolved.autoRatio)) {
|
|
60
|
+
warn('askRatio must be lower than autoRatio; keeping defaults');
|
|
61
|
+
resolved.askRatio = DEFAULTS.askRatio;
|
|
62
|
+
resolved.autoRatio = DEFAULTS.autoRatio;
|
|
63
|
+
}
|
|
64
|
+
if (resolved.minRatio > resolved.askRatio) resolved.minRatio = resolved.askRatio;
|
|
65
|
+
|
|
66
|
+
if (input.includeHistoryTool !== undefined) {
|
|
67
|
+
if (typeof input.includeHistoryTool === 'boolean') resolved.includeHistoryTool = input.includeHistoryTool;
|
|
68
|
+
else warn('includeHistoryTool must be a boolean; using ' + DEFAULTS.includeHistoryTool);
|
|
69
|
+
}
|
|
70
|
+
if (input.autoOpenSession !== undefined) {
|
|
71
|
+
if (typeof input.autoOpenSession === 'boolean') resolved.autoOpenSession = input.autoOpenSession;
|
|
72
|
+
else warn('autoOpenSession must be a boolean; using ' + DEFAULTS.autoOpenSession);
|
|
73
|
+
}
|
|
74
|
+
if (input.maxHandoffsPerSession !== undefined) {
|
|
75
|
+
const value = input.maxHandoffsPerSession;
|
|
76
|
+
if (Number.isSafeInteger(value) && value >= 0) resolved.maxHandoffsPerSession = value;
|
|
77
|
+
else warn('maxHandoffsPerSession must be a non-negative integer; using ' + DEFAULTS.maxHandoffsPerSession);
|
|
78
|
+
}
|
|
79
|
+
if (input.noticeCooldownMs !== undefined) {
|
|
80
|
+
const value = input.noticeCooldownMs;
|
|
81
|
+
if (Number.isFinite(value) && value >= 0) resolved.noticeCooldownMs = value;
|
|
82
|
+
else warn('noticeCooldownMs must be a non-negative number; using ' + DEFAULTS.noticeCooldownMs);
|
|
83
|
+
}
|
|
84
|
+
const timeout = input.summarizeTimeoutMs ?? input.compactTimeoutMs;
|
|
85
|
+
if (timeout !== undefined) {
|
|
86
|
+
if (Number.isSafeInteger(timeout) && timeout > 0) resolved.summarizeTimeoutMs = timeout;
|
|
87
|
+
else warn('summarizeTimeoutMs must be a positive integer; using ' + DEFAULTS.summarizeTimeoutMs);
|
|
88
|
+
}
|
|
89
|
+
return Object.freeze(resolved);
|
|
90
|
+
}
|
package/lib/debug.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in trace log used while validating the handoff chain.
|
|
3
|
+
* Enabled only when DSH_SESSION_HANDOFF_DEBUG points at a file.
|
|
4
|
+
*/
|
|
5
|
+
import { appendFileSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
export function debugLog(message) {
|
|
8
|
+
const target = process.env.DSH_SESSION_HANDOFF_DEBUG;
|
|
9
|
+
if (target === undefined || target === '') return;
|
|
10
|
+
try {
|
|
11
|
+
appendFileSync(target, new Date().toISOString() + ' ' + message + '\n');
|
|
12
|
+
} catch {}
|
|
13
|
+
}
|
package/lib/handoff.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handoff transaction.
|
|
3
|
+
*
|
|
4
|
+
* The parent is never mutated: its surface, its context window, and its log stay
|
|
5
|
+
* exactly as they were, which is what makes it a usable archive. The checkpoint
|
|
6
|
+
* does not go into a session created by this plugin either — the host hides
|
|
7
|
+
* sessions that hold no events, and a plugin cannot write into one it did not open
|
|
8
|
+
* through the UI. So the checkpoint waits in a pending record, and the next
|
|
9
|
+
* brand-new conversation picks it up on its first prompt.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readPressure, formatPressure } from './pressure.js';
|
|
13
|
+
import { sessionEvents } from './history.js';
|
|
14
|
+
import { debugLog } from './debug.js';
|
|
15
|
+
import { rootSummaryEngine } from './summary-engine.js';
|
|
16
|
+
import { summaryTokensOf, routeCard } from './route-card.js';
|
|
17
|
+
import { frameCheckpoint } from './checkpoint.js';
|
|
18
|
+
import { writePending } from './archive.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a service from the agent's own composition first, then the plugin's
|
|
22
|
+
* context.
|
|
23
|
+
* @param agent - live agent whose context carries the composed services.
|
|
24
|
+
* @param ctx - the plugin's own context.
|
|
25
|
+
* @param name - service name.
|
|
26
|
+
* @returns the service instance, or undefined.
|
|
27
|
+
*/
|
|
28
|
+
function serviceFor(agent, ctx, name) {
|
|
29
|
+
const scoped = agent?.ctx;
|
|
30
|
+
if (scoped !== undefined && typeof scoped.get === 'function') {
|
|
31
|
+
const fromAgent = scoped.get(name);
|
|
32
|
+
if (fromAgent !== undefined) return fromAgent;
|
|
33
|
+
}
|
|
34
|
+
return ctx.get(name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Collect the reasons a handoff must be refused right now.
|
|
39
|
+
* @param session - session proposing to hand off.
|
|
40
|
+
* @param cfg - resolved configuration.
|
|
41
|
+
* @param state - plugin runtime state.
|
|
42
|
+
* @returns an array of human-readable refusal reasons (empty means allowed).
|
|
43
|
+
*/
|
|
44
|
+
export function refusalReasons(session, cfg, state) {
|
|
45
|
+
const reasons = [];
|
|
46
|
+
const header = session?.header ?? {};
|
|
47
|
+
if (typeof header.delegationDepth === 'number' && header.delegationDepth > 0) reasons.push('subagent sessions cannot hand off');
|
|
48
|
+
const used = state.counts.get(session) ?? 0;
|
|
49
|
+
if (used >= cfg.maxHandoffsPerSession) reasons.push('handoff limit reached (' + used + '/' + cfg.maxHandoffsPerSession + ') for this session');
|
|
50
|
+
const events = sessionEvents(session);
|
|
51
|
+
if (events.length === 0) reasons.push('session log is empty');
|
|
52
|
+
return reasons;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Rebuild the exact message list the model currently sees, the same way the
|
|
57
|
+
* host's compactor does before summarizing. Only public session methods are
|
|
58
|
+
* used, so this stays valid if the compactor's internals move.
|
|
59
|
+
* @param session - live session.
|
|
60
|
+
* @returns `{ messages, tools? }`, or null when the surface is unreadable.
|
|
61
|
+
*/
|
|
62
|
+
export function summarizationInput(session) {
|
|
63
|
+
const nodes = session?.surface?.nodes;
|
|
64
|
+
if (!Array.isArray(nodes) || nodes.length === 0) return null;
|
|
65
|
+
if (typeof session.eventAt !== 'function' || typeof session.deriveEventMessage !== 'function') return null;
|
|
66
|
+
const headEvent = session.eventAt(nodes[0]);
|
|
67
|
+
const system = headEvent?.type === 'system/message' ? session.deriveEventMessage(headEvent) : null;
|
|
68
|
+
const systemHead = system === null || system === undefined ? null : system;
|
|
69
|
+
const regionSeqs = systemHead === null ? nodes : nodes.slice(1);
|
|
70
|
+
const messages = [];
|
|
71
|
+
for (const seq of regionSeqs) {
|
|
72
|
+
const event = session.eventAt(seq);
|
|
73
|
+
if (event === undefined || event === null) continue;
|
|
74
|
+
const message = session.deriveEventMessage(event);
|
|
75
|
+
if (message !== null && message !== undefined) messages.push(message);
|
|
76
|
+
}
|
|
77
|
+
if (messages.length === 0) return null;
|
|
78
|
+
const header = typeof session.requestHeader === 'function' ? session.requestHeader() : undefined;
|
|
79
|
+
return {
|
|
80
|
+
...(header?.tools === undefined ? {} : { tools: [...header.tools] }),
|
|
81
|
+
messages: systemHead === null ? messages : [systemHead, ...messages]
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Price the span the checkpoint replaces.
|
|
87
|
+
* @param ctx - host context exposing `tokenMeter`.
|
|
88
|
+
* @param session - session being measured.
|
|
89
|
+
* @returns shadowed token count, or null when the meter is unavailable.
|
|
90
|
+
*/
|
|
91
|
+
export function shadowedTokens(ctx, session) {
|
|
92
|
+
try {
|
|
93
|
+
const meter = ctx.get('tokenMeter');
|
|
94
|
+
if (meter === undefined || typeof meter.measure !== 'function') return null;
|
|
95
|
+
const nodes = meter.measure(session)?.nodes ?? [];
|
|
96
|
+
let total = 0;
|
|
97
|
+
for (const node of nodes) if (Number.isFinite(node.tokens)) total += node.tokens;
|
|
98
|
+
return total > 0 ? total : null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Execute one handoff: summarize the parent's surface and park the checkpoint for
|
|
106
|
+
* the next new conversation.
|
|
107
|
+
* @param ctx - host context.
|
|
108
|
+
* @param cfg - resolved configuration.
|
|
109
|
+
* @param state - plugin runtime state.
|
|
110
|
+
* @param agent - the live agent whose session is handing off.
|
|
111
|
+
* @returns a result record; never throws.
|
|
112
|
+
*/
|
|
113
|
+
export async function performHandoff(ctx, cfg, state, agent) {
|
|
114
|
+
const session = agent?.session;
|
|
115
|
+
if (session === undefined || session === null) return { ok: false, reason: 'no live session' };
|
|
116
|
+
if (state.busy.has(session)) return { ok: false, reason: 'another handoff is already running' };
|
|
117
|
+
|
|
118
|
+
state.busy.add(session);
|
|
119
|
+
try {
|
|
120
|
+
const reading = readPressure(ctx, session);
|
|
121
|
+
if (reading === null) return { ok: false, reason: 'context pressure is unmeasurable' };
|
|
122
|
+
if (reading.ratio < cfg.minRatio) {
|
|
123
|
+
return { ok: false, reason: 'pressure ' + formatPressure(reading) + ' is below minRatio ' + cfg.minRatio };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const engine = rootSummaryEngine();
|
|
127
|
+
if (engine === undefined || typeof engine.summarize !== 'function') {
|
|
128
|
+
return { ok: false, reason: 'the handoff summarizer is not mounted; nothing was written' };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const input = summarizationInput(session);
|
|
132
|
+
if (input === null) return { ok: false, reason: 'session surface is empty or unreadable; nothing was written' };
|
|
133
|
+
|
|
134
|
+
debugLog('performHandoff: summarize start messages=' + input.messages.length);
|
|
135
|
+
const summarized = await engine.summarize(input, agent, AbortSignal.timeout(cfg.summarizeTimeoutMs));
|
|
136
|
+
debugLog('performHandoff: summarize done');
|
|
137
|
+
|
|
138
|
+
const blocks = Array.isArray(summarized?.summary) ? summarized.summary : [];
|
|
139
|
+
const summaryText = blocks
|
|
140
|
+
.map((block) => (typeof block?.text === 'string' ? block.text : ''))
|
|
141
|
+
.join('\n')
|
|
142
|
+
.trim();
|
|
143
|
+
if (summaryText === '') return { ok: false, reason: 'the summarizer produced an empty checkpoint; nothing was written' };
|
|
144
|
+
|
|
145
|
+
const summaryTokens = summaryTokensOf(summaryText, summarized?.usage);
|
|
146
|
+
const shadowed = shadowedTokens(ctx, session) ?? reading.used;
|
|
147
|
+
const stats = { shadowed, summaryTokens, ratio: shadowed / summaryTokens };
|
|
148
|
+
const checkpoint = frameCheckpoint(summaryText) + '\n\n' + routeCard(null, { ...stats, parentSession: session.id });
|
|
149
|
+
const path = writePending({
|
|
150
|
+
version: 1,
|
|
151
|
+
parentSession: session.id,
|
|
152
|
+
createdAt: Date.now(),
|
|
153
|
+
summary: summaryText,
|
|
154
|
+
checkpoint,
|
|
155
|
+
stats,
|
|
156
|
+
...(summarized?.provider === undefined ? {} : { provider: summarized.provider }),
|
|
157
|
+
...(summarized?.model === undefined ? {} : { model: summarized.model })
|
|
158
|
+
});
|
|
159
|
+
debugLog('performHandoff: pending written ' + path);
|
|
160
|
+
|
|
161
|
+
state.counts.set(session, (state.counts.get(session) ?? 0) + 1);
|
|
162
|
+
state.handoffs.set(session, { at: Date.now(), before: reading, stats, pending: true });
|
|
163
|
+
ctx.logger.info(
|
|
164
|
+
'session-handoff: checkpoint ready for ' + session.id +
|
|
165
|
+
' (checkpoint ' + summaryTokens + ' tokens, ratio ' + Math.round(stats.ratio) + ':1, parent untouched, before=' + formatPressure(reading) + ')'
|
|
166
|
+
);
|
|
167
|
+
return { ok: true, pending: true, stats, before: reading };
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
170
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
171
|
+
const causeText = cause instanceof Error ? cause.message : cause === undefined ? '' : String(cause);
|
|
172
|
+
debugLog('performHandoff threw: ' + message + (causeText === '' ? '' : ' <- cause: ' + causeText));
|
|
173
|
+
ctx.logger.warn('session-handoff: handoff aborted: ' + message);
|
|
174
|
+
return { ok: false, reason: message };
|
|
175
|
+
} finally {
|
|
176
|
+
state.busy.delete(session);
|
|
177
|
+
}
|
|
178
|
+
}
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read and search the raw event log of the current session.
|
|
3
|
+
*
|
|
4
|
+
* A forked child inherits the parent's raw events verbatim, so the child can
|
|
5
|
+
* recover any earlier detail without cross-session search: the route points at
|
|
6
|
+
* seq numbers that exist in its own log. This module is the lookup half of
|
|
7
|
+
* that route and needs no SQLite full-text index.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Snapshot the raw event log of a session across the two supported shapes.
|
|
12
|
+
* @param session - target session.
|
|
13
|
+
* @returns the durable event array, possibly empty.
|
|
14
|
+
*/
|
|
15
|
+
export function sessionEvents(session) {
|
|
16
|
+
if (session === undefined || session === null) return [];
|
|
17
|
+
if (typeof session.snapshotEvents === 'function') {
|
|
18
|
+
const events = session.snapshotEvents();
|
|
19
|
+
return Array.isArray(events) ? events : [];
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(session.events)) return session.events;
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Extract the human-readable text of one event.
|
|
27
|
+
* @param event - durable session event.
|
|
28
|
+
* @returns text blocks joined by newlines, or a JSON fallback.
|
|
29
|
+
*/
|
|
30
|
+
export function eventText(event) {
|
|
31
|
+
const data = event?.data;
|
|
32
|
+
if (data === undefined || data === null) return '';
|
|
33
|
+
const parts = [];
|
|
34
|
+
const message = data.message ?? data;
|
|
35
|
+
const content = message?.content;
|
|
36
|
+
if (Array.isArray(content)) {
|
|
37
|
+
for (const block of content) {
|
|
38
|
+
if (block?.type === 'text' && typeof block.text === 'string' && block.text !== '') parts.push(block.text);
|
|
39
|
+
else if (block?.type === 'reasoning' && typeof block.text === 'string' && block.text !== '') parts.push(block.text);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (parts.length > 0) return parts.join('\n');
|
|
43
|
+
if (typeof data.text === 'string') return data.text;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.stringify(data);
|
|
46
|
+
} catch {
|
|
47
|
+
return '';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function snippet(text, query, radius) {
|
|
52
|
+
const flat = text.replace(/\s+/g, ' ');
|
|
53
|
+
if (query === '') return flat.slice(0, radius * 2);
|
|
54
|
+
const at = flat.toLowerCase().indexOf(query.toLowerCase());
|
|
55
|
+
if (at < 0) return flat.slice(0, radius * 2);
|
|
56
|
+
const start = Math.max(0, at - radius);
|
|
57
|
+
return (start > 0 ? '…' : '') + flat.slice(start, at + query.length + radius) + '…';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Literal substring search across the raw event log.
|
|
62
|
+
* @param events - event array from {@link sessionEvents}.
|
|
63
|
+
* @param query - literal text to look for (case-insensitive).
|
|
64
|
+
* @param limit - maximum number of hits.
|
|
65
|
+
* @returns matching lines, newest last.
|
|
66
|
+
*/
|
|
67
|
+
export function searchEvents(events, query, limit) {
|
|
68
|
+
const hits = [];
|
|
69
|
+
for (let index = events.length - 1; index >= 0 && hits.length < limit; index -= 1) {
|
|
70
|
+
const event = events[index];
|
|
71
|
+
const text = eventText(event);
|
|
72
|
+
if (text === '') continue;
|
|
73
|
+
if (text.toLowerCase().indexOf(query.toLowerCase()) < 0) continue;
|
|
74
|
+
hits.push({ seq: event.seq, type: event.type, text: snippet(text, query, 120) });
|
|
75
|
+
}
|
|
76
|
+
return hits.reverse();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read one event in full plus optional neighbours.
|
|
81
|
+
* @param events - event array from {@link sessionEvents}.
|
|
82
|
+
* @param seq - target event sequence number.
|
|
83
|
+
* @param before - number of preceding events to include.
|
|
84
|
+
* @param after - number of following events to include.
|
|
85
|
+
* @returns rendered text, or an explanation when the seq is absent.
|
|
86
|
+
*/
|
|
87
|
+
export function readEventDetail(events, seq, before, after) {
|
|
88
|
+
const target = events.find((event) => event.seq === seq);
|
|
89
|
+
if (target === undefined) return 'No event at seq ' + seq + ' in this session.';
|
|
90
|
+
const first = Math.max(0, seq - before);
|
|
91
|
+
const last = Math.min(events.length - 1, seq + after);
|
|
92
|
+
const lines = [];
|
|
93
|
+
for (let index = first; index <= last; index += 1) {
|
|
94
|
+
const event = events[index];
|
|
95
|
+
if (event === undefined) continue;
|
|
96
|
+
const marker = event.seq === seq ? '>>' : ' ';
|
|
97
|
+
const body = event.seq === seq
|
|
98
|
+
? '\n' + (eventText(event) || JSON.stringify(event.data)).slice(0, 6000)
|
|
99
|
+
: ' ' + snippet(eventText(event) || JSON.stringify(event.data), '', 90);
|
|
100
|
+
lines.push(marker + ' [' + event.seq + '] ' + event.type + body);
|
|
101
|
+
}
|
|
102
|
+
return lines.join('\n');
|
|
103
|
+
}
|