context-mode 1.0.65 → 1.0.66

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.
@@ -0,0 +1,708 @@
1
+ /**
2
+ * AnalyticsEngine — All 27 metrics from SessionDB.
3
+ *
4
+ * Computes session-level and cross-session analytics using SQL queries
5
+ * and JavaScript post-processing. Groups:
6
+ *
7
+ * Group 1 (SQL Direct): 17 metrics — direct SQL against session tables
8
+ * Group 2 (JS Computed): 3 metrics — SQL + JS post-processing
9
+ * Group 3 (Runtime): 4 metrics — stubs for server.ts tracking
10
+ * Group 4 (New Extractor): 3 metrics — stubs for future extractors
11
+ *
12
+ * Usage:
13
+ * const engine = new AnalyticsEngine(sessionDb);
14
+ * const report = engine.queryAll(runtimeStats);
15
+ */
16
+ // ─────────────────────────────────────────────────────────
17
+ // Category labels and hints for session continuity display
18
+ // ─────────────────────────────────────────────────────────
19
+ /** Human-readable labels for event categories. */
20
+ export const categoryLabels = {
21
+ file: "Files tracked",
22
+ rule: "Project rules (CLAUDE.md)",
23
+ prompt: "Your requests saved",
24
+ mcp: "Plugin tools used",
25
+ git: "Git operations",
26
+ env: "Environment setup",
27
+ error: "Errors caught",
28
+ task: "Tasks in progress",
29
+ decision: "Your decisions",
30
+ cwd: "Working directory",
31
+ skill: "Skills used",
32
+ subagent: "Delegated work",
33
+ intent: "Session mode",
34
+ data: "Data references",
35
+ role: "Behavioral directives",
36
+ };
37
+ /** Explains why each category matters for continuity. */
38
+ export const categoryHints = {
39
+ file: "Restored after compact \u2014 no need to re-read",
40
+ rule: "Your project instructions survive context resets",
41
+ prompt: "Continues exactly where you left off",
42
+ decision: "Applied automatically \u2014 won\u2019t ask again",
43
+ task: "Picks up from where it stopped",
44
+ error: "Tracked and monitored across compacts",
45
+ git: "Branch, commit, and repo state preserved",
46
+ env: "Runtime config carried forward",
47
+ mcp: "Tool usage patterns remembered",
48
+ subagent: "Delegation history preserved",
49
+ skill: "Skill invocations tracked",
50
+ };
51
+ // ─────────────────────────────────────────────────────────
52
+ // AnalyticsEngine
53
+ // ─────────────────────────────────────────────────────────
54
+ export class AnalyticsEngine {
55
+ db;
56
+ /**
57
+ * Create an AnalyticsEngine.
58
+ *
59
+ * Accepts either a SessionDB instance (extracts internal db via
60
+ * the protected getter — use the static fromDB helper for raw adapters)
61
+ * or any object with a prepare() method for direct usage.
62
+ */
63
+ constructor(db) {
64
+ this.db = db;
65
+ }
66
+ // ═══════════════════════════════════════════════════════
67
+ // GROUP 1 — SQL Direct (17 metrics)
68
+ // ═══════════════════════════════════════════════════════
69
+ /**
70
+ * #5 Weekly Trend — sessions started per day over the last 7 days.
71
+ * Returns an array of { day, sessions } sorted by day.
72
+ */
73
+ weeklyTrend() {
74
+ return this.db.prepare(`SELECT date(started_at) as day, COUNT(*) as sessions
75
+ FROM session_meta
76
+ WHERE started_at > datetime('now', '-7 days')
77
+ GROUP BY day`).all();
78
+ }
79
+ /**
80
+ * #7 Session Continuity — event category distribution for a session.
81
+ * Shows what the session focused on (file ops, git, errors, etc.).
82
+ */
83
+ sessionContinuity(sessionId) {
84
+ return this.db.prepare(`SELECT category, COUNT(*) as count
85
+ FROM session_events
86
+ WHERE session_id = ?
87
+ GROUP BY category`).all(sessionId);
88
+ }
89
+ /**
90
+ * #8 Commit Count — number of git commits made during a session.
91
+ * Matches events where category='git' and data contains 'commit'.
92
+ */
93
+ commitCount(sessionId) {
94
+ const row = this.db.prepare(`SELECT COUNT(*) as cnt
95
+ FROM session_events
96
+ WHERE session_id = ? AND category = 'git' AND data LIKE '%commit%'`).get(sessionId);
97
+ return row.cnt;
98
+ }
99
+ /**
100
+ * #9 Error Count — total error events in a session.
101
+ */
102
+ errorCount(sessionId) {
103
+ const row = this.db.prepare(`SELECT COUNT(*) as cnt
104
+ FROM session_events
105
+ WHERE session_id = ? AND category = 'error'`).get(sessionId);
106
+ return row.cnt;
107
+ }
108
+ /**
109
+ * #10 Session Duration — elapsed minutes from session start to last event.
110
+ * Returns null if last_event_at is not set (session still initializing).
111
+ */
112
+ sessionDuration(sessionId) {
113
+ const row = this.db.prepare(`SELECT (julianday(last_event_at) - julianday(started_at)) * 24 * 60 as minutes
114
+ FROM session_meta
115
+ WHERE session_id = ?`).get(sessionId);
116
+ return row?.minutes ?? null;
117
+ }
118
+ /**
119
+ * #11 Error Rate — percentage of events that are errors in a session.
120
+ * Returns 0 for sessions with no events (division by zero protection).
121
+ */
122
+ errorRate(sessionId) {
123
+ const row = this.db.prepare(`SELECT ROUND(100.0 * SUM(CASE WHEN category='error' THEN 1 ELSE 0 END) / COUNT(*), 1) as rate
124
+ FROM session_events
125
+ WHERE session_id = ?`).get(sessionId);
126
+ return row.rate ?? 0;
127
+ }
128
+ /**
129
+ * #12 Tool Diversity — number of distinct MCP tools used in a session.
130
+ * Higher diversity suggests more sophisticated tool usage.
131
+ */
132
+ toolDiversity(sessionId) {
133
+ const row = this.db.prepare(`SELECT COUNT(DISTINCT data) as cnt
134
+ FROM session_events
135
+ WHERE session_id = ? AND category = 'mcp'`).get(sessionId);
136
+ return row.cnt;
137
+ }
138
+ /**
139
+ * #14 Hourly Productivity — event distribution by hour of day.
140
+ * Optionally scoped to a session; omit sessionId for all sessions.
141
+ */
142
+ hourlyProductivity(sessionId) {
143
+ if (sessionId) {
144
+ return this.db.prepare(`SELECT strftime('%H', created_at) as hour, COUNT(*) as count
145
+ FROM session_events
146
+ WHERE session_id = ?
147
+ GROUP BY hour`).all(sessionId);
148
+ }
149
+ return this.db.prepare(`SELECT strftime('%H', created_at) as hour, COUNT(*) as count
150
+ FROM session_events
151
+ GROUP BY hour`).all();
152
+ }
153
+ /**
154
+ * #15 Project Distribution — session count per project directory.
155
+ * Sorted descending by session count.
156
+ */
157
+ projectDistribution() {
158
+ return this.db.prepare(`SELECT project_dir, COUNT(*) as sessions
159
+ FROM session_meta
160
+ GROUP BY project_dir
161
+ ORDER BY sessions DESC`).all();
162
+ }
163
+ /**
164
+ * #16 Compaction Count — number of snapshot compactions for a session.
165
+ * Higher counts indicate longer/more active sessions.
166
+ */
167
+ compactionCount(sessionId) {
168
+ const row = this.db.prepare(`SELECT compact_count
169
+ FROM session_meta
170
+ WHERE session_id = ?`).get(sessionId);
171
+ return row?.compact_count ?? 0;
172
+ }
173
+ /**
174
+ * #17 Weekly Session Count — total sessions started in the last 7 days.
175
+ */
176
+ weeklySessionCount() {
177
+ const row = this.db.prepare(`SELECT COUNT(*) as cnt
178
+ FROM session_meta
179
+ WHERE started_at > datetime('now', '-7 days')`).get();
180
+ return row.cnt;
181
+ }
182
+ /**
183
+ * #18 Commits Per Session — average commits across all sessions.
184
+ * Returns 0 when no sessions exist (NULLIF prevents division by zero).
185
+ */
186
+ commitsPerSession() {
187
+ const row = this.db.prepare(`SELECT ROUND(1.0 * (SELECT COUNT(*) FROM session_events WHERE category='git' AND data LIKE '%commit%')
188
+ / NULLIF((SELECT COUNT(DISTINCT session_id) FROM session_meta), 0), 1) as avg`).get();
189
+ return row.avg ?? 0;
190
+ }
191
+ /**
192
+ * #22 CLAUDE.md Freshness — last update timestamp for each rule file.
193
+ * Helps identify stale configuration files.
194
+ */
195
+ claudeMdFreshness() {
196
+ return this.db.prepare(`SELECT data, MAX(created_at) as last_updated
197
+ FROM session_events
198
+ WHERE category = 'rule'
199
+ GROUP BY data`).all();
200
+ }
201
+ /**
202
+ * #24 Rework Rate — files edited more than once (indicates iteration/rework).
203
+ * Sorted descending by edit count.
204
+ */
205
+ reworkRate(sessionId) {
206
+ if (sessionId) {
207
+ return this.db.prepare(`SELECT data, COUNT(*) as edits
208
+ FROM session_events
209
+ WHERE session_id = ? AND category = 'file'
210
+ GROUP BY data
211
+ HAVING edits > 1
212
+ ORDER BY edits DESC`).all(sessionId);
213
+ }
214
+ return this.db.prepare(`SELECT data, COUNT(*) as edits
215
+ FROM session_events
216
+ WHERE category = 'file'
217
+ GROUP BY data
218
+ HAVING edits > 1
219
+ ORDER BY edits DESC`).all();
220
+ }
221
+ /**
222
+ * #25 Session Outcome — classify a session as 'productive' or 'exploratory'.
223
+ * Productive: has at least one commit AND last event is not an error.
224
+ */
225
+ sessionOutcome(sessionId) {
226
+ const row = this.db.prepare(`
227
+ SELECT CASE
228
+ WHEN EXISTS(SELECT 1 FROM session_events WHERE session_id=? AND category='git' AND data LIKE '%commit%')
229
+ AND NOT EXISTS(SELECT 1 FROM session_events WHERE session_id=?
230
+ AND category='error' AND id=(SELECT MAX(id) FROM session_events WHERE session_id=?))
231
+ THEN 'productive'
232
+ ELSE 'exploratory'
233
+ END as outcome
234
+ `).get(sessionId, sessionId, sessionId);
235
+ return row.outcome;
236
+ }
237
+ /**
238
+ * #26 Subagent Usage — subagent spawn counts grouped by type/purpose.
239
+ */
240
+ subagentUsage(sessionId) {
241
+ return this.db.prepare(`SELECT COUNT(*) as total, data
242
+ FROM session_events
243
+ WHERE session_id = ? AND category = 'subagent'
244
+ GROUP BY data`).all(sessionId);
245
+ }
246
+ /**
247
+ * #27 Skill Usage — skill/slash-command invocation frequency.
248
+ * Sorted descending by invocation count.
249
+ */
250
+ skillUsage(sessionId) {
251
+ return this.db.prepare(`SELECT data, COUNT(*) as invocations
252
+ FROM session_events
253
+ WHERE session_id = ? AND category = 'skill'
254
+ GROUP BY data
255
+ ORDER BY invocations DESC`).all(sessionId);
256
+ }
257
+ // ═══════════════════════════════════════════════════════
258
+ // GROUP 2 — JS Computed (3 metrics)
259
+ // ═══════════════════════════════════════════════════════
260
+ /**
261
+ * #4 Session Mix — percentage of sessions classified as productive.
262
+ * Iterates all sessions and uses #25 (sessionOutcome) to classify each.
263
+ */
264
+ sessionMix() {
265
+ const sessions = this.db.prepare(`SELECT session_id FROM session_meta`).all();
266
+ if (sessions.length === 0) {
267
+ return { productive: 0, exploratory: 0 };
268
+ }
269
+ let productiveCount = 0;
270
+ for (const s of sessions) {
271
+ if (this.sessionOutcome(s.session_id) === "productive") {
272
+ productiveCount++;
273
+ }
274
+ }
275
+ const productivePct = Math.round((100 * productiveCount) / sessions.length);
276
+ return {
277
+ productive: productivePct,
278
+ exploratory: 100 - productivePct,
279
+ };
280
+ }
281
+ /**
282
+ * #13 / #20 Efficiency Score — composite score (0-100) measuring session productivity.
283
+ *
284
+ * Components:
285
+ * - Error rate (lower = better): weight 30%
286
+ * - Tool diversity (higher = better): weight 20%
287
+ * - Commit presence (boolean bonus): weight 25%
288
+ * - Rework rate (lower = better): weight 15%
289
+ * - Session duration efficiency (moderate = better): weight 10%
290
+ *
291
+ * Formula: score = 100 - errorPenalty + diversityBonus + commitBonus - reworkPenalty + durationBonus - 40
292
+ * The -40 baseline prevents empty sessions from scoring 100.
293
+ */
294
+ efficiencyScore(sessionId) {
295
+ const errRate = this.errorRate(sessionId);
296
+ const diversity = this.toolDiversity(sessionId);
297
+ const commits = this.commitCount(sessionId);
298
+ const totalEvents = this.db.prepare(`SELECT COUNT(*) as cnt FROM session_events WHERE session_id = ?`).get(sessionId).cnt;
299
+ const fileEvents = this.db.prepare(`SELECT COUNT(*) as cnt FROM session_events WHERE session_id = ? AND category = 'file'`).get(sessionId).cnt;
300
+ // Rework: files edited more than once in this session
301
+ const reworkFiles = this.db.prepare(`SELECT COUNT(*) as cnt FROM (SELECT data, COUNT(*) as edits FROM session_events WHERE session_id = ? AND category = 'file' GROUP BY data HAVING edits > 1)`).get(sessionId);
302
+ const reworkRatio = fileEvents > 0 ? reworkFiles.cnt / fileEvents : 0;
303
+ // Duration in minutes
304
+ const duration = this.sessionDuration(sessionId) ?? 0;
305
+ // Score components
306
+ const errorPenalty = Math.min(errRate * 0.3, 30);
307
+ const diversityBonus = Math.min(diversity * 4, 20);
308
+ const commitBonus = commits > 0 ? 25 : 0;
309
+ const reworkPenalty = Math.min(reworkRatio * 15, 15);
310
+ const durationBonus = duration > 5 && duration < 60 ? 10 : duration >= 60 ? 5 : 0;
311
+ const score = Math.round(Math.max(0, Math.min(100, 100 - errorPenalty + diversityBonus + commitBonus - reworkPenalty + durationBonus - 40)));
312
+ return score;
313
+ }
314
+ /**
315
+ * #23 Iteration Cycles — counts edit-error-fix sequences in a session.
316
+ *
317
+ * Walks events chronologically and detects patterns where a file event
318
+ * is followed by an error event, then another file event.
319
+ */
320
+ iterationCycles(sessionId) {
321
+ const events = this.db.prepare(`SELECT category, data FROM session_events WHERE session_id = ? ORDER BY id ASC`).all(sessionId);
322
+ let cycles = 0;
323
+ for (let i = 0; i < events.length - 2; i++) {
324
+ if (events[i].category === "file" &&
325
+ events[i + 1].category === "error" &&
326
+ events[i + 2].category === "file") {
327
+ cycles++;
328
+ i += 2; // Skip past this cycle
329
+ }
330
+ }
331
+ return cycles;
332
+ }
333
+ // ═══════════════════════════════════════════════════════
334
+ // GROUP 3 — Runtime (4 metrics, stubs)
335
+ // ═══════════════════════════════════════════════════════
336
+ /**
337
+ * #1 Context Savings Total — bytes kept out of context window.
338
+ *
339
+ * Stub: requires server.ts to accumulate rawBytes and contextBytes
340
+ * during a live session. Call with tracked values.
341
+ */
342
+ static contextSavingsTotal(rawBytes, contextBytes) {
343
+ const savedBytes = rawBytes - contextBytes;
344
+ const savedPercent = rawBytes > 0
345
+ ? Math.round((savedBytes / rawBytes) * 1000) / 10
346
+ : 0;
347
+ return { rawBytes, contextBytes, savedBytes, savedPercent };
348
+ }
349
+ /**
350
+ * #2 Think in Code Comparison — ratio of file size to sandbox output size.
351
+ *
352
+ * Stub: requires server.ts tracking of execute/execute_file calls.
353
+ */
354
+ static thinkInCodeComparison(fileBytes, outputBytes) {
355
+ const ratio = outputBytes > 0
356
+ ? Math.round((fileBytes / outputBytes) * 10) / 10
357
+ : 0;
358
+ return { fileBytes, outputBytes, ratio };
359
+ }
360
+ /**
361
+ * #3 Tool Savings — per-tool breakdown of context savings.
362
+ *
363
+ * Stub: requires per-tool accumulators in server.ts.
364
+ */
365
+ static toolSavings(tools) {
366
+ return tools.map((t) => ({
367
+ ...t,
368
+ savedBytes: t.rawBytes - t.contextBytes,
369
+ }));
370
+ }
371
+ /**
372
+ * #19 Sandbox I/O — total input/output bytes processed by the sandbox.
373
+ *
374
+ * Stub: requires PolyglotExecutor byte counters.
375
+ */
376
+ static sandboxIO(inputBytes, outputBytes) {
377
+ return { inputBytes, outputBytes };
378
+ }
379
+ // ═══════════════════════════════════════════════════════
380
+ // GROUP 4 — New Extractor Needed (3 metrics)
381
+ // ═══════════════════════════════════════════════════════
382
+ /**
383
+ * #6 Pattern Detected — identifies recurring patterns in a session.
384
+ *
385
+ * Analyzes category distribution and detects dominant patterns
386
+ * (>60% threshold). Falls back to combination detection and
387
+ * "balanced" for evenly distributed sessions.
388
+ */
389
+ patternDetected(sessionId) {
390
+ const categories = this.sessionContinuity(sessionId);
391
+ const total = categories.reduce((sum, c) => sum + c.count, 0);
392
+ if (total === 0)
393
+ return "no activity";
394
+ // Sort by count descending
395
+ categories.sort((a, b) => b.count - a.count);
396
+ const dominant = categories[0];
397
+ const ratio = dominant.count / total;
398
+ if (ratio > 0.6) {
399
+ const patterns = {
400
+ file: "heavy file editor",
401
+ git: "git-focused",
402
+ mcp: "tool-heavy",
403
+ error: "debugging session",
404
+ plan: "planning session",
405
+ subagent: "delegation-heavy",
406
+ rule: "configuration session",
407
+ task: "task management",
408
+ };
409
+ return patterns[dominant.category] ?? `${dominant.category}-focused`;
410
+ }
411
+ // Check for common combinations
412
+ if (categories.find((c) => c.category === "git") &&
413
+ categories.find((c) => c.category === "file")) {
414
+ return "build and commit";
415
+ }
416
+ return "balanced";
417
+ }
418
+ /**
419
+ * #21 Permission Denials — count of tool calls blocked by security rules.
420
+ *
421
+ * Filters error events containing "denied", "blocked", or "permission".
422
+ * Stub: ideally requires a dedicated extractor in extract.ts.
423
+ */
424
+ permissionDenials(sessionId) {
425
+ const row = this.db.prepare(`SELECT COUNT(*) as cnt
426
+ FROM session_events
427
+ WHERE session_id = ? AND category = 'error'
428
+ AND (data LIKE '%denied%' OR data LIKE '%blocked%' OR data LIKE '%permission%')`).get(sessionId);
429
+ return row.cnt;
430
+ }
431
+ // ═══════════════════════════════════════════════════════
432
+ // queryAll — single unified report from ONE source
433
+ // ═══════════════════════════════════════════════════════
434
+ /**
435
+ * Build a complete FullReport by merging runtime stats (passed in)
436
+ * with all 27 DB-backed metrics and continuity data.
437
+ *
438
+ * This is the ONE call that ctx_stats should use.
439
+ */
440
+ queryAll(runtimeStats) {
441
+ // ── Resolve latest session ID ──
442
+ const latestSession = this.db.prepare("SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1").get();
443
+ const sid = latestSession?.session_id ?? "";
444
+ // ── Runtime savings ──
445
+ const totalBytesReturned = Object.values(runtimeStats.bytesReturned).reduce((sum, b) => sum + b, 0);
446
+ const totalCalls = Object.values(runtimeStats.calls).reduce((sum, c) => sum + c, 0);
447
+ const keptOut = runtimeStats.bytesIndexed + runtimeStats.bytesSandboxed;
448
+ const totalProcessed = keptOut + totalBytesReturned;
449
+ const savingsRatio = totalProcessed / Math.max(totalBytesReturned, 1);
450
+ const reductionPct = totalProcessed > 0
451
+ ? Math.round((1 - totalBytesReturned / totalProcessed) * 100)
452
+ : 0;
453
+ const toolNames = new Set([
454
+ ...Object.keys(runtimeStats.calls),
455
+ ...Object.keys(runtimeStats.bytesReturned),
456
+ ]);
457
+ const byTool = Array.from(toolNames).sort().map((tool) => ({
458
+ tool,
459
+ calls: runtimeStats.calls[tool] || 0,
460
+ context_kb: Math.round((runtimeStats.bytesReturned[tool] || 0) / 1024 * 10) / 10,
461
+ tokens: Math.round((runtimeStats.bytesReturned[tool] || 0) / 4),
462
+ }));
463
+ const uptimeMs = Date.now() - runtimeStats.sessionStart;
464
+ const uptimeMin = (uptimeMs / 60_000).toFixed(1);
465
+ // ── Cache ──
466
+ let cache;
467
+ if (runtimeStats.cacheHits > 0 || runtimeStats.cacheBytesSaved > 0) {
468
+ const totalWithCache = totalProcessed + runtimeStats.cacheBytesSaved;
469
+ const totalSavingsRatio = totalWithCache / Math.max(totalBytesReturned, 1);
470
+ const ttlHoursLeft = Math.max(0, 24 - Math.floor((Date.now() - runtimeStats.sessionStart) / (60 * 60 * 1000)));
471
+ cache = {
472
+ hits: runtimeStats.cacheHits,
473
+ bytes_saved: runtimeStats.cacheBytesSaved,
474
+ ttl_hours_left: ttlHoursLeft,
475
+ total_with_cache: totalWithCache,
476
+ total_savings_ratio: totalSavingsRatio,
477
+ };
478
+ }
479
+ // ── Session metrics ──
480
+ const durationMin = sid ? this.sessionDuration(sid) : null;
481
+ const toolCallsDb = sid ? this.db.prepare("SELECT COUNT(*) as cnt FROM session_events WHERE session_id = ? AND category = 'mcp'").get(sid).cnt : 0;
482
+ // ── Activity metrics ──
483
+ const commits = sid ? this.commitCount(sid) : 0;
484
+ const errors = sid ? this.errorCount(sid) : 0;
485
+ const errorRatePct = sid ? this.errorRate(sid) : 0;
486
+ const toolDiversity = sid ? this.toolDiversity(sid) : 0;
487
+ const effScore = sid ? this.efficiencyScore(sid) : 0;
488
+ const commitsPerSessionAvg = this.commitsPerSession();
489
+ const sessionOutcome = sid ? this.sessionOutcome(sid) : "exploratory";
490
+ const mix = this.sessionMix();
491
+ // ── Pattern metrics ──
492
+ const hourlyRaw = this.hourlyProductivity(sid || undefined);
493
+ const hourlyCommits = Array.from({ length: 24 }, (_, i) => {
494
+ const h = String(i).padStart(2, "0");
495
+ return hourlyRaw.find((r) => r.hour === h)?.count ?? 0;
496
+ });
497
+ const weeklyTrend = this.weeklyTrend();
498
+ const iterCycles = sid ? this.iterationCycles(sid) : 0;
499
+ const rework = sid ? this.reworkRate(sid) : this.reworkRate();
500
+ // ── Health metrics ──
501
+ const claudeMdFreshness = this.claudeMdFreshness().map((r) => {
502
+ const daysAgo = r.last_updated
503
+ ? Math.round((Date.now() - new Date(r.last_updated).getTime()) / 86_400_000)
504
+ : null;
505
+ return { project: r.data, days_ago: daysAgo };
506
+ });
507
+ const compactionsThisWeek = sid ? this.compactionCount(sid) : 0;
508
+ const weeklySessions = this.weeklySessionCount();
509
+ const permDenials = sid ? this.permissionDenials(sid) : 0;
510
+ // ── Agent metrics ──
511
+ const subagents = sid
512
+ ? this.subagentUsage(sid).map((r) => ({ type: r.data, count: r.total }))
513
+ : [];
514
+ const skills = sid
515
+ ? this.skillUsage(sid).map((r) => ({ name: r.data, count: r.invocations }))
516
+ : [];
517
+ // ── Continuity data ──
518
+ const eventTotal = this.db.prepare("SELECT COUNT(*) as cnt FROM session_events").get().cnt;
519
+ const byCategory = this.db.prepare("SELECT category, COUNT(*) as cnt FROM session_events GROUP BY category ORDER BY cnt DESC").all();
520
+ const meta = this.db.prepare("SELECT compact_count FROM session_meta ORDER BY started_at DESC LIMIT 1").get();
521
+ const compactCount = meta?.compact_count ?? 0;
522
+ const resume = this.db.prepare("SELECT event_count, consumed FROM session_resume ORDER BY created_at DESC LIMIT 1").get();
523
+ const resumeReady = resume ? !resume.consumed : false;
524
+ // Build category previews
525
+ const previewRows = this.db.prepare("SELECT category, type, data FROM session_events ORDER BY id DESC").all();
526
+ const previews = new Map();
527
+ for (const row of previewRows) {
528
+ if (!previews.has(row.category))
529
+ previews.set(row.category, new Set());
530
+ const set = previews.get(row.category);
531
+ if (set.size < 5) {
532
+ let display = row.data;
533
+ if (row.category === "file") {
534
+ display = row.data.split("/").pop() || row.data;
535
+ }
536
+ else if (row.category === "prompt") {
537
+ display = display.length > 50 ? display.slice(0, 47) + "..." : display;
538
+ }
539
+ if (display.length > 40)
540
+ display = display.slice(0, 37) + "...";
541
+ set.add(display);
542
+ }
543
+ }
544
+ const continuityByCategory = byCategory.map((row) => ({
545
+ category: row.category,
546
+ count: row.cnt,
547
+ label: categoryLabels[row.category] || row.category,
548
+ preview: previews.get(row.category)
549
+ ? Array.from(previews.get(row.category)).join(", ")
550
+ : "",
551
+ why: categoryHints[row.category] || "Survives context resets",
552
+ }));
553
+ return {
554
+ savings: {
555
+ processed_kb: Math.round(totalProcessed / 1024 * 10) / 10,
556
+ entered_kb: Math.round(totalBytesReturned / 1024 * 10) / 10,
557
+ saved_kb: Math.round(keptOut / 1024 * 10) / 10,
558
+ pct: reductionPct,
559
+ savings_ratio: Math.round(savingsRatio * 10) / 10,
560
+ by_tool: byTool,
561
+ total_calls: totalCalls,
562
+ total_bytes_returned: totalBytesReturned,
563
+ kept_out: keptOut,
564
+ total_processed: totalProcessed,
565
+ },
566
+ cache,
567
+ session: {
568
+ id: sid,
569
+ duration_min: durationMin !== null ? Math.round(durationMin * 10) / 10 : null,
570
+ tool_calls: toolCallsDb,
571
+ uptime_min: uptimeMin,
572
+ },
573
+ activity: {
574
+ commits,
575
+ errors,
576
+ error_rate_pct: errorRatePct,
577
+ tool_diversity: toolDiversity,
578
+ efficiency_score: effScore,
579
+ commits_per_session_avg: commitsPerSessionAvg,
580
+ session_outcome: sessionOutcome,
581
+ productive_pct: mix.productive,
582
+ exploratory_pct: mix.exploratory,
583
+ },
584
+ patterns: {
585
+ hourly_commits: hourlyCommits,
586
+ weekly_trend: weeklyTrend,
587
+ iteration_cycles: iterCycles,
588
+ rework: rework.map((r) => ({ file: r.data, edits: r.edits })),
589
+ },
590
+ health: {
591
+ claude_md_freshness: claudeMdFreshness,
592
+ compactions_this_week: compactionsThisWeek,
593
+ weekly_sessions: weeklySessions,
594
+ permission_denials: permDenials,
595
+ },
596
+ agents: { subagents, skills },
597
+ continuity: {
598
+ total_events: eventTotal,
599
+ by_category: continuityByCategory,
600
+ compact_count: compactCount,
601
+ resume_ready: resumeReady,
602
+ },
603
+ };
604
+ }
605
+ }
606
+ // ─────────────────────────────────────────────────────────
607
+ // formatReport — renders FullReport as markdown
608
+ // ─────────────────────────────────────────────────────────
609
+ /** Format bytes as human-readable KB or MB. */
610
+ function kb(b) {
611
+ if (b >= 1024 * 1024)
612
+ return `${(b / 1024 / 1024).toFixed(1)}MB`;
613
+ return `${(b / 1024).toFixed(1)}KB`;
614
+ }
615
+ /**
616
+ * Render a FullReport as the same markdown output ctx_stats has always produced.
617
+ *
618
+ * Preserves the exact output format: Context Window Protection table,
619
+ * TTL Cache section, Session Continuity table, and Analytics JSON block.
620
+ */
621
+ export function formatReport(report) {
622
+ const lines = [
623
+ `## context-mode \u2014 Session Report (${report.session.uptime_min} min)`,
624
+ ];
625
+ // ── Feature 1: Context Window Protection ──
626
+ lines.push("", `### Context Window Protection`, "");
627
+ if (report.savings.total_calls === 0) {
628
+ lines.push(`No context-mode tool calls yet. Use \`batch_execute\`, \`execute\`, or \`fetch_and_index\` to keep raw output out of your context window.`);
629
+ }
630
+ else {
631
+ lines.push(`| Metric | Value |`, `|--------|------:|`, `| Total data processed | **${kb(report.savings.total_processed)}** |`, `| Kept in sandbox (never entered context) | **${kb(report.savings.kept_out)}** |`, `| Entered context | ${kb(report.savings.total_bytes_returned)} |`, `| Estimated tokens saved | ~${Math.round(report.savings.kept_out / 4).toLocaleString()} |`, `| **Context savings** | **${report.savings.savings_ratio.toFixed(1)}x (${report.savings.pct}% reduction)** |`);
632
+ // Per-tool breakdown
633
+ if (report.savings.by_tool.length > 0) {
634
+ lines.push("", `| Tool | Calls | Context | Tokens |`, `|------|------:|--------:|-------:|`);
635
+ for (const t of report.savings.by_tool) {
636
+ lines.push(`| ${t.tool} | ${t.calls} | ${kb(t.calls > 0 ? (t.tokens * 4) : 0)} | ~${t.tokens.toLocaleString()} |`);
637
+ }
638
+ lines.push(`| **Total** | **${report.savings.total_calls}** | **${kb(report.savings.total_bytes_returned)}** | **~${Math.round(report.savings.total_bytes_returned / 4).toLocaleString()}** |`);
639
+ }
640
+ if (report.savings.kept_out > 0) {
641
+ lines.push("", `Without context-mode, **${kb(report.savings.total_processed)}** of raw output would flood your context window. Instead, **${report.savings.pct}%** stayed in sandbox.`);
642
+ }
643
+ // Cache savings section
644
+ if (report.cache) {
645
+ lines.push("", `### TTL Cache`, "", `| Metric | Value |`, `|--------|------:|`, `| Cache hits | **${report.cache.hits}** |`, `| Data avoided by cache | **${kb(report.cache.bytes_saved)}** |`, `| Network requests saved | **${report.cache.hits}** |`, `| TTL remaining | **~${report.cache.ttl_hours_left}h** |`, "", `Content was already indexed in the knowledge base \u2014 ${report.cache.hits} fetch${report.cache.hits > 1 ? "es" : ""} skipped entirely. **${kb(report.cache.bytes_saved)}** of network I/O avoided. Search results served directly from local FTS5 index.`);
646
+ if (report.cache.total_savings_ratio > report.savings.savings_ratio) {
647
+ lines.push("", `**Total context savings (sandbox + cache): ${report.cache.total_savings_ratio.toFixed(1)}x** \u2014 ${kb(report.cache.total_with_cache)} processed, only ${kb(report.savings.total_bytes_returned)} entered context.`);
648
+ }
649
+ }
650
+ }
651
+ // ── Session Continuity ──
652
+ if (report.continuity.total_events > 0) {
653
+ lines.push("", "### Session Continuity", "", "| What's preserved | Count | I remember... | Why it matters |", "|------------------|------:|---------------|----------------|");
654
+ for (const row of report.continuity.by_category) {
655
+ lines.push(`| ${row.label} | ${row.count} | ${row.preview} | ${row.why} |`);
656
+ }
657
+ lines.push(`| **Total** | **${report.continuity.total_events}** | | **Zero knowledge lost on compact** |`);
658
+ lines.push("");
659
+ if (report.continuity.compact_count > 0) {
660
+ lines.push(`Context has been compacted **${report.continuity.compact_count} time(s)** \u2014 session knowledge was preserved each time.`);
661
+ }
662
+ else {
663
+ lines.push(`When your context compacts, all of this will restore Claude's awareness \u2014 no starting from scratch.`);
664
+ }
665
+ if (report.continuity.resume_ready) {
666
+ lines.push(`Resume snapshot ready for the next compaction.`);
667
+ }
668
+ lines.push("");
669
+ lines.push(`> **Note:** Previous session data is loaded when you start a new session. Without \`--continue\`, old session history is cleaned up to keep the database lean.`);
670
+ }
671
+ // ── Analytics JSON ──
672
+ const analyticsJson = {
673
+ session: {
674
+ duration_min: report.session.duration_min,
675
+ tool_calls: report.session.tool_calls,
676
+ },
677
+ activity: {
678
+ commits: report.activity.commits,
679
+ errors: report.activity.errors,
680
+ error_rate_pct: report.activity.error_rate_pct,
681
+ tool_diversity: report.activity.tool_diversity,
682
+ efficiency_score: report.activity.efficiency_score,
683
+ commits_per_session_avg: report.activity.commits_per_session_avg,
684
+ session_outcome: report.activity.session_outcome,
685
+ },
686
+ patterns: {
687
+ hourly_commits: report.patterns.hourly_commits,
688
+ weekly_trend: report.patterns.weekly_trend,
689
+ iteration_cycles: report.patterns.iteration_cycles,
690
+ rework: report.patterns.rework,
691
+ },
692
+ health: {
693
+ claude_md_freshness: report.health.claude_md_freshness,
694
+ compactions_this_week: report.health.compactions_this_week,
695
+ weekly_sessions: report.health.weekly_sessions,
696
+ permission_denials: report.health.permission_denials,
697
+ },
698
+ agents: {
699
+ subagents: report.agents.subagents,
700
+ skills: report.agents.skills,
701
+ },
702
+ continuity: { total_events: report.continuity.total_events },
703
+ };
704
+ lines.push("", "### Analytics (27 metrics)", "", "```json", JSON.stringify(analyticsJson, null, 2), "```");
705
+ // Formatting directive
706
+ lines.push("", "---", "_Display this entire report as-is in your response. Do NOT summarize, collapse, or paraphrase any section._");
707
+ return lines.join("\n");
708
+ }