@axplusb/kepler 2.0.7 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/config/hook-runner.mjs +100 -0
- package/src/config/memory-loader.mjs +32 -0
- package/src/config/settings-loader.mjs +45 -0
- package/src/core/approval-log.mjs +104 -0
- package/src/core/approval.mjs +164 -24
- package/src/core/context-envelope.mjs +54 -0
- package/src/core/headless.mjs +14 -1
- package/src/core/jsonl-writer.mjs +50 -0
- package/src/core/local-store.mjs +486 -5
- package/src/core/policy-resolver.mjs +156 -0
- package/src/core/project-context-loader.mjs +139 -0
- package/src/core/rate-limit-display.mjs +97 -0
- package/src/core/resume-mode.mjs +154 -0
- package/src/core/risk-tier.mjs +88 -2
- package/src/core/safety.mjs +3 -0
- package/src/core/session-manager.mjs +53 -10
- package/src/core/stream-client.mjs +69 -10
- package/src/core/system-prompt.mjs +6 -1
- package/src/core/tasks.mjs +130 -0
- package/src/core/tool-executor.mjs +72 -6
- package/src/core/trust.mjs +158 -0
- package/src/onboarding/preflight.mjs +27 -11
- package/src/permissions/command-classifier.mjs +78 -0
- package/src/terminal/init.mjs +145 -0
- package/src/terminal/main.mjs +7 -0
- package/src/terminal/repl.mjs +1735 -140
- package/src/tools/bash.mjs +57 -9
- package/src/tools/project-overview.mjs +83 -10
- package/src/ui/approval.mjs +154 -35
- package/src/ui/formatter.mjs +39 -5
- package/src/ui/mission-report.mjs +55 -22
- package/src/ui/slash-commands.mjs +57 -5
- package/src/ui/tool-card.mjs +51 -1
package/src/core/local-store.mjs
CHANGED
|
@@ -50,6 +50,42 @@ function normalizeMessageContent(content) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function truncateText(text, max = 1200) {
|
|
54
|
+
const compact = String(text || '').replace(/\s+/g, ' ').trim();
|
|
55
|
+
if (compact.length <= max) return compact;
|
|
56
|
+
return compact.slice(0, max - 3) + '...';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function stringifyInput(input) {
|
|
60
|
+
try {
|
|
61
|
+
return JSON.stringify(input || {});
|
|
62
|
+
} catch {
|
|
63
|
+
return String(input || {});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function entryText(entry) {
|
|
68
|
+
if (typeof entry.content === 'string') return entry.content;
|
|
69
|
+
if (!Array.isArray(entry.content)) return '';
|
|
70
|
+
return entry.content
|
|
71
|
+
.filter(block => block.type === 'text')
|
|
72
|
+
.map(block => block.text || '')
|
|
73
|
+
.filter(Boolean)
|
|
74
|
+
.join('\n');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function entryToolUses(entry) {
|
|
78
|
+
return Array.isArray(entry.content)
|
|
79
|
+
? entry.content.filter(block => block.type === 'tool_use')
|
|
80
|
+
: [];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function entryToolResults(entry) {
|
|
84
|
+
return Array.isArray(entry.content)
|
|
85
|
+
? entry.content.filter(block => block.type === 'tool_result')
|
|
86
|
+
: [];
|
|
87
|
+
}
|
|
88
|
+
|
|
53
89
|
/**
|
|
54
90
|
* List all session JSONL files across all projects.
|
|
55
91
|
* Returns [{slug, sessionId, filePath, mtime}] sorted by mtime desc.
|
|
@@ -82,11 +118,66 @@ function findSessionFile(sessionId) {
|
|
|
82
118
|
return listSessionFiles().find((entry) => entry.sessionId === sessionId) || null;
|
|
83
119
|
}
|
|
84
120
|
|
|
121
|
+
function looksLikeProjectRoot(dirPath) {
|
|
122
|
+
return [
|
|
123
|
+
'.git',
|
|
124
|
+
'package.json',
|
|
125
|
+
'pyproject.toml',
|
|
126
|
+
'setup.py',
|
|
127
|
+
'go.mod',
|
|
128
|
+
'Cargo.toml',
|
|
129
|
+
].some(name => fs.existsSync(path.join(dirPath, name)));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function inferProjectRoot(rawPath) {
|
|
133
|
+
if (!rawPath || typeof rawPath !== 'string' || !path.isAbsolute(rawPath)) return '';
|
|
134
|
+
let current = rawPath;
|
|
135
|
+
try {
|
|
136
|
+
if (fs.existsSync(current) && fs.statSync(current).isFile()) {
|
|
137
|
+
current = path.dirname(current);
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
current = path.dirname(current);
|
|
141
|
+
}
|
|
142
|
+
while (current && current !== path.dirname(current)) {
|
|
143
|
+
if (fs.existsSync(current) && looksLikeProjectRoot(current)) return current;
|
|
144
|
+
current = path.dirname(current);
|
|
145
|
+
}
|
|
146
|
+
return fs.existsSync(rawPath) && fs.statSync(rawPath).isDirectory() ? rawPath : '';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectAbsoluteStrings(value, out = []) {
|
|
150
|
+
if (typeof value === 'string') {
|
|
151
|
+
if (path.isAbsolute(value)) out.push(value);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
if (Array.isArray(value)) {
|
|
155
|
+
for (const item of value) collectAbsoluteStrings(item, out);
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
if (value && typeof value === 'object') {
|
|
159
|
+
for (const item of Object.values(value)) collectAbsoluteStrings(item, out);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function collectAbsolutePathsFromText(text) {
|
|
165
|
+
const out = [];
|
|
166
|
+
const pattern = /\/(?:[^/\s'"`]+(?:[ /][^/\s'"`]+)*)/g;
|
|
167
|
+
for (const match of String(text || '').matchAll(pattern)) {
|
|
168
|
+
const raw = match[0].replace(/[),.;:]+$/, '');
|
|
169
|
+
if (raw && path.isAbsolute(raw)) out.push(raw);
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
85
174
|
/**
|
|
86
175
|
* Parse a session JSONL file and extract metadata.
|
|
87
176
|
* Reads line-by-line (streaming) to handle large files.
|
|
88
177
|
*/
|
|
89
178
|
async function parseSessionMeta(filePath) {
|
|
179
|
+
// PRD-068 §5.14.11: adds endStatus / contextTokens / costUsd / partial for
|
|
180
|
+
// the /resume picker columns and the context-length driven mode decision.
|
|
90
181
|
const meta = {
|
|
91
182
|
sessionId: null,
|
|
92
183
|
project: null,
|
|
@@ -102,19 +193,34 @@ async function parseSessionMeta(filePath) {
|
|
|
102
193
|
startTime: null,
|
|
103
194
|
endTime: null,
|
|
104
195
|
gitBranch: null,
|
|
196
|
+
// ── PRD-068 §5.14 derived fields ───────────────────────────────────
|
|
197
|
+
endStatus: 'unknown', // 'completed' | 'interrupted' | 'errored' | 'unknown'
|
|
198
|
+
contextTokens: 0, // projected transcript size when serialized
|
|
199
|
+
costUsd: 0, // sum of per-turn provider costs recorded in transcript
|
|
200
|
+
partial: false, // true if some lines failed to parse
|
|
201
|
+
fileBytes: 0, // raw file size (byte-based ctx fallback if no usage totals)
|
|
202
|
+
resumeSummary: null, // latest resume_summary marker, if the session has been checkpointed
|
|
105
203
|
};
|
|
106
204
|
|
|
107
205
|
const toolCounts = {};
|
|
108
206
|
const modelSet = new Set();
|
|
109
207
|
|
|
208
|
+
// endStatus tracking
|
|
209
|
+
let lastMessageRole = null;
|
|
210
|
+
let hadError = false;
|
|
211
|
+
const pendingToolCalls = new Set(); // tool_use_ids awaiting a tool_result
|
|
212
|
+
|
|
110
213
|
try {
|
|
214
|
+
try { meta.fileBytes = fs.statSync(filePath).size; } catch {}
|
|
215
|
+
|
|
111
216
|
const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
112
217
|
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
113
218
|
|
|
114
219
|
for await (const line of rl) {
|
|
115
220
|
if (!line.trim()) continue;
|
|
116
221
|
let obj;
|
|
117
|
-
try { obj = JSON.parse(line); }
|
|
222
|
+
try { obj = JSON.parse(line); }
|
|
223
|
+
catch { meta.partial = true; continue; }
|
|
118
224
|
|
|
119
225
|
if (obj.sessionId && !meta.sessionId) meta.sessionId = obj.sessionId;
|
|
120
226
|
if (obj.cwd && !meta.project) meta.project = obj.cwd;
|
|
@@ -126,8 +232,29 @@ async function parseSessionMeta(filePath) {
|
|
|
126
232
|
if (!meta.endTime || ts > meta.endTime) meta.endTime = ts;
|
|
127
233
|
}
|
|
128
234
|
|
|
235
|
+
// Backend kepler_event payloads may carry cost / error markers.
|
|
236
|
+
if (obj.type === 'kepler_event' && obj.event) {
|
|
237
|
+
const ev = obj.event;
|
|
238
|
+
if (ev.type === 'complete' && typeof ev.cost_usd === 'number') meta.costUsd += ev.cost_usd;
|
|
239
|
+
if (ev.type === 'session_info' && typeof ev.total_cost_usd === 'number') meta.costUsd = ev.total_cost_usd;
|
|
240
|
+
if (ev.type === 'error' || ev.error === true) hadError = true;
|
|
241
|
+
if (ev.type === 'resume_summary' && typeof ev.data?.summary === 'string') {
|
|
242
|
+
meta.resumeSummary = {
|
|
243
|
+
sourceMessageCount: Number(ev.data.source_message_count) || 0,
|
|
244
|
+
previousSourceMessageCount: Number(ev.data.previous_source_message_count) || 0,
|
|
245
|
+
fullMessageCount: Number(ev.data.full_message_count) || 0,
|
|
246
|
+
summaryChars: ev.data.summary.length,
|
|
247
|
+
summarySource: ev.data.summary_source || '',
|
|
248
|
+
mode: ev.data.mode || '',
|
|
249
|
+
modeLabel: ev.data.mode_label || '',
|
|
250
|
+
timestamp: obj.timestamp || null,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
129
255
|
if (obj.type === 'user') {
|
|
130
256
|
meta.userMessages++;
|
|
257
|
+
lastMessageRole = 'user';
|
|
131
258
|
// Capture first user prompt (string content only)
|
|
132
259
|
if (!meta.firstPrompt) {
|
|
133
260
|
const content = obj.message?.content;
|
|
@@ -135,10 +262,21 @@ async function parseSessionMeta(filePath) {
|
|
|
135
262
|
meta.firstPrompt = content.slice(0, 100);
|
|
136
263
|
}
|
|
137
264
|
}
|
|
265
|
+
// A user turn may carry tool_results — clear matched pending calls
|
|
266
|
+
const content = obj.message?.content;
|
|
267
|
+
if (Array.isArray(content)) {
|
|
268
|
+
for (const block of content) {
|
|
269
|
+
if (block.type === 'tool_result' && block.tool_use_id) {
|
|
270
|
+
pendingToolCalls.delete(block.tool_use_id);
|
|
271
|
+
if (block.is_error) hadError = true;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
138
275
|
}
|
|
139
276
|
|
|
140
277
|
if (obj.type === 'assistant') {
|
|
141
278
|
meta.assistantMessages++;
|
|
279
|
+
lastMessageRole = 'assistant';
|
|
142
280
|
const usage = obj.message?.usage;
|
|
143
281
|
if (usage) {
|
|
144
282
|
meta.inputTokens += usage.input_tokens || 0;
|
|
@@ -149,24 +287,41 @@ async function parseSessionMeta(filePath) {
|
|
|
149
287
|
const model = obj.message?.model;
|
|
150
288
|
if (model) modelSet.add(model);
|
|
151
289
|
|
|
152
|
-
// Count tool_use blocks
|
|
290
|
+
// Count tool_use blocks — and track them as pending until we see the result
|
|
153
291
|
const content = obj.message?.content;
|
|
154
292
|
if (Array.isArray(content)) {
|
|
155
293
|
for (const block of content) {
|
|
156
294
|
if (block.type === 'tool_use' && block.name) {
|
|
157
295
|
toolCounts[block.name] = (toolCounts[block.name] || 0) + 1;
|
|
296
|
+
if (block.id) pendingToolCalls.add(block.id);
|
|
158
297
|
}
|
|
159
298
|
}
|
|
160
299
|
}
|
|
161
300
|
}
|
|
162
301
|
}
|
|
163
|
-
} catch {
|
|
302
|
+
} catch { meta.partial = true; }
|
|
164
303
|
|
|
165
304
|
meta.toolCalls = Object.entries(toolCounts)
|
|
166
305
|
.map(([name, count]) => ({ name, count }))
|
|
167
306
|
.sort((a, b) => b.count - a.count);
|
|
168
307
|
meta.models = [...modelSet];
|
|
169
308
|
|
|
309
|
+
// Projected context size — prefer recorded usage totals if available, else
|
|
310
|
+
// fall back to the byte-based estimate (~4 chars/token for English).
|
|
311
|
+
const usageTotal = meta.inputTokens + meta.outputTokens + meta.cacheReadTokens;
|
|
312
|
+
meta.contextTokens = usageTotal > 0 ? usageTotal : Math.round(meta.fileBytes / 4);
|
|
313
|
+
|
|
314
|
+
// Derive endStatus from the tail of the transcript.
|
|
315
|
+
if (hadError) {
|
|
316
|
+
meta.endStatus = 'errored';
|
|
317
|
+
} else if (pendingToolCalls.size > 0 || lastMessageRole === 'user') {
|
|
318
|
+
meta.endStatus = 'interrupted';
|
|
319
|
+
} else if (lastMessageRole === 'assistant') {
|
|
320
|
+
meta.endStatus = 'completed';
|
|
321
|
+
} else {
|
|
322
|
+
meta.endStatus = 'unknown';
|
|
323
|
+
}
|
|
324
|
+
|
|
170
325
|
return meta;
|
|
171
326
|
}
|
|
172
327
|
|
|
@@ -182,6 +337,7 @@ export async function getRecentSessions(n = 10) {
|
|
|
182
337
|
sessions.push({
|
|
183
338
|
...meta,
|
|
184
339
|
slug: f.slug,
|
|
340
|
+
filePath: f.filePath,
|
|
185
341
|
mtime: f.mtime,
|
|
186
342
|
});
|
|
187
343
|
}
|
|
@@ -192,13 +348,22 @@ export async function getRecentSessions(n = 10) {
|
|
|
192
348
|
* Return normalized entries for a single session transcript.
|
|
193
349
|
* @param {string} sessionId
|
|
194
350
|
*/
|
|
195
|
-
export async function getSessionDetail(sessionId) {
|
|
196
|
-
const file =
|
|
351
|
+
export async function getSessionDetail(sessionId, options = {}) {
|
|
352
|
+
const file = options.filePath
|
|
353
|
+
? {
|
|
354
|
+
sessionId,
|
|
355
|
+
slug: path.basename(path.dirname(options.filePath)),
|
|
356
|
+
filePath: options.filePath,
|
|
357
|
+
mtime: fs.statSync(options.filePath).mtimeMs,
|
|
358
|
+
}
|
|
359
|
+
: findSessionFile(sessionId);
|
|
197
360
|
if (!file) return null;
|
|
198
361
|
|
|
199
362
|
const entries = [];
|
|
363
|
+
const replayEvents = [];
|
|
200
364
|
const fileStream = fs.createReadStream(file.filePath, { encoding: 'utf-8' });
|
|
201
365
|
const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
|
|
366
|
+
let order = 0;
|
|
202
367
|
|
|
203
368
|
for await (const line of rl) {
|
|
204
369
|
if (!line.trim()) continue;
|
|
@@ -208,9 +373,20 @@ export async function getSessionDetail(sessionId) {
|
|
|
208
373
|
} catch {
|
|
209
374
|
continue;
|
|
210
375
|
}
|
|
376
|
+
const entryOrder = order++;
|
|
377
|
+
|
|
378
|
+
if (obj.type === 'kepler_event' && obj.event?.type) {
|
|
379
|
+
replayEvents.push({
|
|
380
|
+
order: entryOrder,
|
|
381
|
+
timestamp: obj.timestamp || null,
|
|
382
|
+
event: obj.event,
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
211
386
|
|
|
212
387
|
const message = obj.message || {};
|
|
213
388
|
entries.push({
|
|
389
|
+
order: entryOrder,
|
|
214
390
|
type: obj.type || null,
|
|
215
391
|
timestamp: obj.timestamp || null,
|
|
216
392
|
cwd: obj.cwd || null,
|
|
@@ -231,9 +407,314 @@ export async function getSessionDetail(sessionId) {
|
|
|
231
407
|
mtime: file.mtime,
|
|
232
408
|
meta,
|
|
233
409
|
entries,
|
|
410
|
+
replayEvents,
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Convert a rich transcript into display history and backend continuity.
|
|
416
|
+
* Display history is intentionally richer than backend history: it includes
|
|
417
|
+
* tool calls/results so /history can reconstruct prior work.
|
|
418
|
+
*
|
|
419
|
+
* @param {object} detail - result from getSessionDetail()
|
|
420
|
+
* @param {'compact'|'full'} mode
|
|
421
|
+
*/
|
|
422
|
+
export function buildResumeHistory(detail, mode = 'compact') {
|
|
423
|
+
if (!detail) {
|
|
424
|
+
return {
|
|
425
|
+
displayHistory: [],
|
|
426
|
+
agentHistory: [],
|
|
427
|
+
summary: '',
|
|
428
|
+
stats: { userMessages: 0, assistantMessages: 0, toolCalls: 0, toolResults: 0 },
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const displayHistory = [];
|
|
433
|
+
const fullAgentHistory = [];
|
|
434
|
+
const userPrompts = [];
|
|
435
|
+
const assistantTexts = [];
|
|
436
|
+
const toolCounts = new Map();
|
|
437
|
+
const importantResults = [];
|
|
438
|
+
let toolCalls = 0;
|
|
439
|
+
let toolResults = 0;
|
|
440
|
+
|
|
441
|
+
for (const entry of detail.entries || []) {
|
|
442
|
+
if (entry.role === 'user' && typeof entry.content === 'string') {
|
|
443
|
+
const content = entry.content;
|
|
444
|
+
displayHistory.push({ role: 'user', content, timestamp: entry.timestamp, order: entry.order });
|
|
445
|
+
fullAgentHistory.push({ role: 'user', content });
|
|
446
|
+
userPrompts.push(content);
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
if (entry.role === 'assistant') {
|
|
451
|
+
const text = entryText(entry);
|
|
452
|
+
const tools = entryToolUses(entry);
|
|
453
|
+
for (const tool of tools) {
|
|
454
|
+
toolCalls++;
|
|
455
|
+
toolCounts.set(tool.name, (toolCounts.get(tool.name) || 0) + 1);
|
|
456
|
+
const line = `${tool.name} ${stringifyInput(tool.input)}`;
|
|
457
|
+
displayHistory.push({
|
|
458
|
+
role: 'tool',
|
|
459
|
+
content: line,
|
|
460
|
+
timestamp: entry.timestamp,
|
|
461
|
+
order: entry.order,
|
|
462
|
+
tool: tool.name,
|
|
463
|
+
kind: 'call',
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
if (text) {
|
|
467
|
+
displayHistory.push({ role: 'assistant', content: text, timestamp: entry.timestamp, order: entry.order });
|
|
468
|
+
assistantTexts.push(text);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const fullContent = [
|
|
472
|
+
text,
|
|
473
|
+
...tools.map(tool => `[tool_call] ${tool.name} ${stringifyInput(tool.input)}`),
|
|
474
|
+
].filter(Boolean).join('\n\n');
|
|
475
|
+
if (fullContent) fullAgentHistory.push({ role: 'assistant', content: fullContent });
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (entry.role === 'user' && Array.isArray(entry.content)) {
|
|
480
|
+
const results = entryToolResults(entry);
|
|
481
|
+
for (const result of results) {
|
|
482
|
+
toolResults++;
|
|
483
|
+
const content = truncateText(result.content, 1200);
|
|
484
|
+
const label = `[tool_result] ${result.tool_use_id || 'tool'}${result.is_error ? ' (error)' : ''}: ${content}`;
|
|
485
|
+
displayHistory.push({
|
|
486
|
+
role: 'tool',
|
|
487
|
+
content: label,
|
|
488
|
+
timestamp: entry.timestamp,
|
|
489
|
+
order: entry.order,
|
|
490
|
+
tool: result.tool_use_id || 'tool',
|
|
491
|
+
kind: 'result',
|
|
492
|
+
});
|
|
493
|
+
fullAgentHistory.push({ role: 'user', content: label });
|
|
494
|
+
if (importantResults.length < 12 && content) importantResults.push(label);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const toolSummary = [...toolCounts.entries()]
|
|
500
|
+
.sort((a, b) => b[1] - a[1])
|
|
501
|
+
.map(([name, count]) => `${name} x${count}`)
|
|
502
|
+
.join(', ') || 'none recorded';
|
|
503
|
+
const latestUser = userPrompts[userPrompts.length - 1] || detail.meta?.firstPrompt || '';
|
|
504
|
+
const latestAssistant = assistantTexts[assistantTexts.length - 1] || '';
|
|
505
|
+
const projectRoots = getTranscriptProjectRoots(detail);
|
|
506
|
+
const summaryLines = [
|
|
507
|
+
'Session continuity summary from the resumed local transcript.',
|
|
508
|
+
`Session: ${detail.sessionId}`,
|
|
509
|
+
detail.meta?.project ? `Project: ${detail.meta.project}` : '',
|
|
510
|
+
projectRoots.length ? `Registered project roots: ${projectRoots.join(', ')}` : '',
|
|
511
|
+
detail.meta?.startTime ? `Started: ${detail.meta.startTime}` : '',
|
|
512
|
+
detail.meta?.endTime ? `Last activity: ${detail.meta.endTime}` : '',
|
|
513
|
+
`Prior user requests (${userPrompts.length}):`,
|
|
514
|
+
...userPrompts.slice(-8).map(text => `- ${truncateText(text, 300)}`),
|
|
515
|
+
`Assistant progress notes (${assistantTexts.length}):`,
|
|
516
|
+
...assistantTexts.slice(-6).map(text => `- ${truncateText(text, 400)}`),
|
|
517
|
+
`Tools used: ${toolSummary}`,
|
|
518
|
+
importantResults.length ? 'Important recent tool results:' : '',
|
|
519
|
+
...importantResults.slice(-8).map(text => `- ${truncateText(text, 500)}`),
|
|
520
|
+
latestUser ? `Most recent user request: ${truncateText(latestUser, 500)}` : '',
|
|
521
|
+
latestAssistant ? `Most recent assistant response: ${truncateText(latestAssistant, 500)}` : '',
|
|
522
|
+
].filter(Boolean);
|
|
523
|
+
const summary = summaryLines.join('\n');
|
|
524
|
+
const metadata = buildResumeMetadata({ detail, projectRoots, userPrompts, assistantTexts, toolCalls, toolResults, toolSummary });
|
|
525
|
+
const originalRequest = userPrompts[0] || detail.meta?.firstPrompt || '';
|
|
526
|
+
const summaryCheckpoint = latestResumeSummaryCheckpoint(detail);
|
|
527
|
+
const priorSummary = String(summaryCheckpoint?.data?.summary || '').trim();
|
|
528
|
+
const summaryCheckpointMessageCount = clampMessageCount(
|
|
529
|
+
summaryCheckpoint?.data?.source_message_count,
|
|
530
|
+
fullAgentHistory.length
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
// PRD-068 §5.14.4: resume mode picker.
|
|
534
|
+
// 'full' — every turn sent verbatim (unchanged)
|
|
535
|
+
// 'tail-N' — recap block as system prime + last N conversation messages so the
|
|
536
|
+
// agent has real recent conversation to reference.
|
|
537
|
+
// 'summary' — recap block only. Cheapest continuity, biggest lossiness.
|
|
538
|
+
let agentHistory;
|
|
539
|
+
let sourceMessages = fullAgentHistory;
|
|
540
|
+
let summaryMessageIndex = -1;
|
|
541
|
+
let activeSummary = summary;
|
|
542
|
+
let summaryCoveredMessageCount = 0;
|
|
543
|
+
if (mode === 'full') {
|
|
544
|
+
agentHistory = fullAgentHistory;
|
|
545
|
+
summaryCoveredMessageCount = fullAgentHistory.length;
|
|
546
|
+
} else if (mode === 'recap+tail' || /^tail-\d+$/.test(String(mode || ''))) {
|
|
547
|
+
const tailTurns = tailTurnsForMode(mode, detail);
|
|
548
|
+
const { tail, startIndex } = tailHistorySliceByRecentMessages(fullAgentHistory, tailTurns);
|
|
549
|
+
const deltaStart = Math.min(summaryCheckpointMessageCount, startIndex);
|
|
550
|
+
sourceMessages = fullAgentHistory.slice(deltaStart, startIndex);
|
|
551
|
+
const deltaSummary = sourceMessages.length
|
|
552
|
+
? summaryForMessages(sourceMessages, {
|
|
553
|
+
fallback: summary,
|
|
554
|
+
label: `Summary of earlier turns before the last ${tailTurns} conversation messages`,
|
|
555
|
+
})
|
|
556
|
+
: '';
|
|
557
|
+
activeSummary = combineResumeSummaries(priorSummary, deltaSummary)
|
|
558
|
+
|| summaryForMessages(fullAgentHistory.slice(0, startIndex), {
|
|
559
|
+
fallback: summary,
|
|
560
|
+
label: `Summary of earlier turns before the last ${tailTurns} conversation messages`,
|
|
561
|
+
});
|
|
562
|
+
summaryCoveredMessageCount = Math.min(
|
|
563
|
+
fullAgentHistory.length,
|
|
564
|
+
Math.max(summaryCheckpointMessageCount, startIndex)
|
|
565
|
+
);
|
|
566
|
+
agentHistory = [
|
|
567
|
+
{ role: 'user', content: metadata },
|
|
568
|
+
{ role: 'user', content: `Original user request from this resumed session:\n${originalRequest || '(unknown)'}` },
|
|
569
|
+
{ role: 'user', content: activeSummary },
|
|
570
|
+
...tail,
|
|
571
|
+
];
|
|
572
|
+
summaryMessageIndex = 2;
|
|
573
|
+
} else {
|
|
574
|
+
// 'summary' (was 'compact' — renamed per PRD-068 §5.14.4)
|
|
575
|
+
sourceMessages = fullAgentHistory.slice(summaryCheckpointMessageCount);
|
|
576
|
+
const deltaSummary = priorSummary && sourceMessages.length
|
|
577
|
+
? summaryForMessages(sourceMessages, {
|
|
578
|
+
fallback: summary,
|
|
579
|
+
label: 'New turns after the previous resume summary',
|
|
580
|
+
})
|
|
581
|
+
: '';
|
|
582
|
+
activeSummary = combineResumeSummaries(priorSummary, deltaSummary) || summary;
|
|
583
|
+
summaryCoveredMessageCount = fullAgentHistory.length;
|
|
584
|
+
agentHistory = [
|
|
585
|
+
{ role: 'user', content: metadata },
|
|
586
|
+
{ role: 'user', content: `Original user request from this resumed session:\n${originalRequest || '(unknown)'}` },
|
|
587
|
+
{ role: 'user', content: activeSummary },
|
|
588
|
+
];
|
|
589
|
+
summaryMessageIndex = 2;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
displayHistory,
|
|
594
|
+
agentHistory,
|
|
595
|
+
sourceMessages,
|
|
596
|
+
summaryMessageIndex,
|
|
597
|
+
summary: activeSummary,
|
|
598
|
+
priorSummary,
|
|
599
|
+
summaryCheckpointMessageCount,
|
|
600
|
+
summaryCoveredMessageCount,
|
|
601
|
+
fullMessageCount: fullAgentHistory.length,
|
|
602
|
+
mode,
|
|
603
|
+
stats: {
|
|
604
|
+
userMessages: userPrompts.length,
|
|
605
|
+
assistantMessages: assistantTexts.length,
|
|
606
|
+
toolCalls,
|
|
607
|
+
toolResults,
|
|
608
|
+
},
|
|
234
609
|
};
|
|
235
610
|
}
|
|
236
611
|
|
|
612
|
+
export function combineResumeSummaries(priorSummary, deltaSummary) {
|
|
613
|
+
const prior = String(priorSummary || '').trim();
|
|
614
|
+
const delta = String(deltaSummary || '').trim();
|
|
615
|
+
if (prior && delta) {
|
|
616
|
+
return [
|
|
617
|
+
prior,
|
|
618
|
+
'',
|
|
619
|
+
'New activity since previous summary:',
|
|
620
|
+
delta,
|
|
621
|
+
].join('\n');
|
|
622
|
+
}
|
|
623
|
+
return prior || delta || '';
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function buildResumeMetadata({ detail, projectRoots, userPrompts, assistantTexts, toolCalls, toolResults, toolSummary }) {
|
|
627
|
+
return [
|
|
628
|
+
'Resume metadata.',
|
|
629
|
+
`Session: ${detail.sessionId}`,
|
|
630
|
+
detail.meta?.project ? `Project: ${detail.meta.project}` : '',
|
|
631
|
+
projectRoots.length ? `Registered project roots: ${projectRoots.join(', ')}` : '',
|
|
632
|
+
detail.meta?.startTime ? `Started: ${detail.meta.startTime}` : '',
|
|
633
|
+
detail.meta?.endTime ? `Last activity: ${detail.meta.endTime}` : '',
|
|
634
|
+
`Total user turns: ${userPrompts.length}`,
|
|
635
|
+
`Assistant messages: ${assistantTexts.length}`,
|
|
636
|
+
`Tool calls/results: ${toolCalls}/${toolResults}`,
|
|
637
|
+
`Tools used: ${toolSummary}`,
|
|
638
|
+
].filter(Boolean).join('\n');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function summaryForMessages(messages, { fallback, label }) {
|
|
642
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
643
|
+
if (!list.length) {
|
|
644
|
+
return `${label}:\nNo earlier turns before the retained tail.`;
|
|
645
|
+
}
|
|
646
|
+
const lines = [label + ':'];
|
|
647
|
+
for (const msg of list.slice(-24)) {
|
|
648
|
+
const role = msg.role || 'message';
|
|
649
|
+
lines.push(`- ${role}: ${truncateText(msg.content, 450)}`);
|
|
650
|
+
}
|
|
651
|
+
const text = lines.join('\n');
|
|
652
|
+
return text.trim() || fallback;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function tailTurnsForMode(mode, detail) {
|
|
656
|
+
const match = String(mode || '').match(/^tail-(\d+)$/);
|
|
657
|
+
if (match) return Math.max(1, Number(match[1]) || 1);
|
|
658
|
+
return Number.isFinite(Number(detail?.recapTailTurns))
|
|
659
|
+
? Math.max(1, Number(detail.recapTailTurns))
|
|
660
|
+
: 8;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function tailHistorySliceByRecentMessages(history, turns) {
|
|
664
|
+
const list = Array.isArray(history) ? history : [];
|
|
665
|
+
const count = Math.max(1, Number(turns) || 1);
|
|
666
|
+
const start = Math.max(0, list.length - count);
|
|
667
|
+
return { tail: list.slice(start), startIndex: start };
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function latestResumeSummaryCheckpoint(detail) {
|
|
671
|
+
const events = Array.isArray(detail?.replayEvents) ? detail.replayEvents : [];
|
|
672
|
+
let latest = null;
|
|
673
|
+
for (const item of events) {
|
|
674
|
+
const event = item?.event;
|
|
675
|
+
const data = event?.data || {};
|
|
676
|
+
if (event?.type !== 'resume_summary' || typeof data.summary !== 'string') continue;
|
|
677
|
+
if (!latest || Number(item.order ?? -1) >= Number(latest.order ?? -1)) {
|
|
678
|
+
latest = { ...item, data };
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return latest;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function clampMessageCount(value, max) {
|
|
685
|
+
const n = Number(value);
|
|
686
|
+
if (!Number.isFinite(n)) return 0;
|
|
687
|
+
return Math.max(0, Math.min(Math.floor(n), Math.max(0, Number(max) || 0)));
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function getTranscriptProjectRoots(detail) {
|
|
691
|
+
const roots = new Set();
|
|
692
|
+
if (detail?.meta?.project && fs.existsSync(detail.meta.project)) {
|
|
693
|
+
roots.add(detail.meta.project);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
for (const entry of detail?.entries || []) {
|
|
697
|
+
if (typeof entry.content === 'string') {
|
|
698
|
+
for (const candidate of collectAbsolutePathsFromText(entry.content)) {
|
|
699
|
+
const root = inferProjectRoot(candidate);
|
|
700
|
+
if (root) roots.add(root);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
for (const tool of entryToolUses(entry)) {
|
|
704
|
+
if (tool.name === 'get_project_overview') {
|
|
705
|
+
const root = inferProjectRoot(tool.input?.path || tool.input?.root || tool.input?.cwd);
|
|
706
|
+
if (root) roots.add(root);
|
|
707
|
+
}
|
|
708
|
+
for (const candidate of collectAbsoluteStrings(tool.input)) {
|
|
709
|
+
const root = inferProjectRoot(candidate);
|
|
710
|
+
if (root) roots.add(root);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
return [...roots];
|
|
716
|
+
}
|
|
717
|
+
|
|
237
718
|
/**
|
|
238
719
|
* Aggregate stats across sessions within a date range.
|
|
239
720
|
* @param {number} days — look back this many days (0 = all time)
|