@bahulam/code 2.6.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.
Files changed (273) hide show
  1. package/README.md +80 -0
  2. package/package.json +49 -0
  3. package/pulse/app/activity/page.tsx +190 -0
  4. package/pulse/app/api/activity/route.ts +138 -0
  5. package/pulse/app/api/benchmark/route.ts +113 -0
  6. package/pulse/app/api/benchmarks/route.ts +195 -0
  7. package/pulse/app/api/costs/route.ts +88 -0
  8. package/pulse/app/api/export/route.ts +77 -0
  9. package/pulse/app/api/history/route.ts +11 -0
  10. package/pulse/app/api/import/route.ts +31 -0
  11. package/pulse/app/api/memory/route.ts +50 -0
  12. package/pulse/app/api/plans/route.ts +9 -0
  13. package/pulse/app/api/projects/[slug]/route.ts +96 -0
  14. package/pulse/app/api/projects/route.ts +121 -0
  15. package/pulse/app/api/sessions/[id]/replay/route.ts +20 -0
  16. package/pulse/app/api/sessions/[id]/route.ts +31 -0
  17. package/pulse/app/api/sessions/route.ts +112 -0
  18. package/pulse/app/api/settings/route.ts +14 -0
  19. package/pulse/app/api/stats/route.ts +143 -0
  20. package/pulse/app/api/todos/route.ts +9 -0
  21. package/pulse/app/api/tools/route.ts +160 -0
  22. package/pulse/app/benchmarks/page.tsx +224 -0
  23. package/pulse/app/costs/page.tsx +179 -0
  24. package/pulse/app/export/page.tsx +465 -0
  25. package/pulse/app/favicon.ico +0 -0
  26. package/pulse/app/globals.css +263 -0
  27. package/pulse/app/help/page.tsx +143 -0
  28. package/pulse/app/history/page.tsx +157 -0
  29. package/pulse/app/layout.tsx +46 -0
  30. package/pulse/app/memory/page.tsx +365 -0
  31. package/pulse/app/overview-client.tsx +393 -0
  32. package/pulse/app/page.tsx +14 -0
  33. package/pulse/app/plans/page.tsx +308 -0
  34. package/pulse/app/projects/[slug]/page.tsx +390 -0
  35. package/pulse/app/projects/page.tsx +110 -0
  36. package/pulse/app/sessions/[id]/page.tsx +243 -0
  37. package/pulse/app/sessions/page.tsx +39 -0
  38. package/pulse/app/settings/page.tsx +188 -0
  39. package/pulse/app/todos/page.tsx +211 -0
  40. package/pulse/app/tools/page.tsx +249 -0
  41. package/pulse/cli.js +164 -0
  42. package/pulse/components/activity/day-of-week-chart.tsx +35 -0
  43. package/pulse/components/activity/streak-card.tsx +36 -0
  44. package/pulse/components/costs/cache-efficiency-panel.tsx +76 -0
  45. package/pulse/components/costs/cost-by-project-chart.tsx +48 -0
  46. package/pulse/components/costs/cost-over-time-chart.tsx +95 -0
  47. package/pulse/components/costs/model-token-table.tsx +60 -0
  48. package/pulse/components/global-search.tsx +193 -0
  49. package/pulse/components/keyboard-nav-provider.tsx +23 -0
  50. package/pulse/components/layout/bottom-nav.tsx +53 -0
  51. package/pulse/components/layout/client-layout.tsx +31 -0
  52. package/pulse/components/layout/sidebar-context.tsx +50 -0
  53. package/pulse/components/layout/sidebar.tsx +183 -0
  54. package/pulse/components/layout/top-bar.tsx +121 -0
  55. package/pulse/components/overview/activity-heatmap.tsx +107 -0
  56. package/pulse/components/overview/conversation-table.tsx +148 -0
  57. package/pulse/components/overview/model-breakdown-donut.tsx +95 -0
  58. package/pulse/components/overview/peak-hours-chart.tsx +87 -0
  59. package/pulse/components/overview/project-activity-donut.tsx +96 -0
  60. package/pulse/components/overview/stat-card.tsx +102 -0
  61. package/pulse/components/overview/usage-over-time-chart.tsx +166 -0
  62. package/pulse/components/projects/project-card.tsx +175 -0
  63. package/pulse/components/sessions/replay/assistant-markdown.tsx +94 -0
  64. package/pulse/components/sessions/replay/compaction-card.tsx +25 -0
  65. package/pulse/components/sessions/replay/session-sidebar.tsx +231 -0
  66. package/pulse/components/sessions/replay/token-accumulation-chart.tsx +98 -0
  67. package/pulse/components/sessions/replay/tool-call-badge.tsx +127 -0
  68. package/pulse/components/sessions/replay/turn-cards.tsx +220 -0
  69. package/pulse/components/sessions/replay/user-tool-result.tsx +158 -0
  70. package/pulse/components/sessions/session-badges.tsx +49 -0
  71. package/pulse/components/sessions/session-table.tsx +299 -0
  72. package/pulse/components/theme-provider.tsx +44 -0
  73. package/pulse/components/tools/feature-adoption-table.tsx +58 -0
  74. package/pulse/components/tools/mcp-server-panel.tsx +45 -0
  75. package/pulse/components/tools/tool-ranking-chart.tsx +57 -0
  76. package/pulse/components/tools/version-history-table.tsx +32 -0
  77. package/pulse/components/ui/alert.tsx +66 -0
  78. package/pulse/components/ui/badge.tsx +48 -0
  79. package/pulse/components/ui/breadcrumb.tsx +109 -0
  80. package/pulse/components/ui/button.tsx +64 -0
  81. package/pulse/components/ui/calendar.tsx +220 -0
  82. package/pulse/components/ui/card.tsx +92 -0
  83. package/pulse/components/ui/command.tsx +158 -0
  84. package/pulse/components/ui/dialog.tsx +158 -0
  85. package/pulse/components/ui/input.tsx +21 -0
  86. package/pulse/components/ui/popover.tsx +89 -0
  87. package/pulse/components/ui/progress.tsx +31 -0
  88. package/pulse/components/ui/select.tsx +190 -0
  89. package/pulse/components/ui/separator.tsx +28 -0
  90. package/pulse/components/ui/sheet.tsx +143 -0
  91. package/pulse/components/ui/skeleton.tsx +13 -0
  92. package/pulse/components/ui/table.tsx +116 -0
  93. package/pulse/components/ui/tabs.tsx +91 -0
  94. package/pulse/components/ui/tooltip.tsx +57 -0
  95. package/pulse/components/use-global-keyboard-nav.ts +79 -0
  96. package/pulse/components.json +23 -0
  97. package/pulse/eslint.config.mjs +18 -0
  98. package/pulse/lib/bahulam-paths.ts +23 -0
  99. package/pulse/lib/claude-reader.ts +592 -0
  100. package/pulse/lib/decode.ts +129 -0
  101. package/pulse/lib/pricing.ts +102 -0
  102. package/pulse/lib/replay-parser.ts +165 -0
  103. package/pulse/lib/tool-categories.ts +127 -0
  104. package/pulse/lib/utils.ts +6 -0
  105. package/pulse/next-env.d.ts +6 -0
  106. package/pulse/next.config.ts +16 -0
  107. package/pulse/package.json +45 -0
  108. package/pulse/postcss.config.mjs +7 -0
  109. package/pulse/public/activity.png +0 -0
  110. package/pulse/public/cc-lens.png +0 -0
  111. package/pulse/public/command-k.png +0 -0
  112. package/pulse/public/costs.png +0 -0
  113. package/pulse/public/dashboard-dark.png +0 -0
  114. package/pulse/public/dashboard-white.png +0 -0
  115. package/pulse/public/export.png +0 -0
  116. package/pulse/public/file.svg +1 -0
  117. package/pulse/public/globe.svg +1 -0
  118. package/pulse/public/next.svg +1 -0
  119. package/pulse/public/projects.png +0 -0
  120. package/pulse/public/session-chat.png +0 -0
  121. package/pulse/public/todos.png +0 -0
  122. package/pulse/public/tools.png +0 -0
  123. package/pulse/public/vercel.svg +1 -0
  124. package/pulse/public/window.svg +1 -0
  125. package/pulse/tsconfig.json +34 -0
  126. package/pulse/types/claude.ts +294 -0
  127. package/src/agents/loader.mjs +94 -0
  128. package/src/agents/multi_workflow_loader.mjs +330 -0
  129. package/src/agents/parser.mjs +205 -0
  130. package/src/agents/scaffold.mjs +222 -0
  131. package/src/agents/teams.mjs +123 -0
  132. package/src/agents/workflow_loader.mjs +122 -0
  133. package/src/agents/workflow_scaffold.mjs +249 -0
  134. package/src/auth/oauth.mjs +220 -0
  135. package/src/auth/tarang-auth.mjs +306 -0
  136. package/src/commands/agent.mjs +220 -0
  137. package/src/commands/workflow.mjs +581 -0
  138. package/src/config/cli-args.mjs +200 -0
  139. package/src/config/env.mjs +263 -0
  140. package/src/config/hook-runner.mjs +100 -0
  141. package/src/config/memory-loader.mjs +32 -0
  142. package/src/config/settings-loader.mjs +45 -0
  143. package/src/config/settings.mjs +132 -0
  144. package/src/context/ast-parser.mjs +298 -0
  145. package/src/context/bm25.mjs +85 -0
  146. package/src/context/retriever.mjs +308 -0
  147. package/src/context/skeleton.mjs +134 -0
  148. package/src/context/symbol-indexer.mjs +375 -0
  149. package/src/core/agent-history.mjs +111 -0
  150. package/src/core/agent-loop.mjs +486 -0
  151. package/src/core/approval-log.mjs +104 -0
  152. package/src/core/approval.mjs +476 -0
  153. package/src/core/attachments.mjs +380 -0
  154. package/src/core/backend-url.mjs +55 -0
  155. package/src/core/cache-control.mjs +92 -0
  156. package/src/core/cache.mjs +105 -0
  157. package/src/core/callback-client.mjs +180 -0
  158. package/src/core/checkpoints.mjs +142 -0
  159. package/src/core/compact-history.mjs +127 -0
  160. package/src/core/context-envelope.mjs +54 -0
  161. package/src/core/context-manager.mjs +198 -0
  162. package/src/core/error-guidance.mjs +311 -0
  163. package/src/core/file-diff.mjs +217 -0
  164. package/src/core/headless.mjs +448 -0
  165. package/src/core/hooks-manager.mjs +87 -0
  166. package/src/core/jsonl-writer.mjs +449 -0
  167. package/src/core/local-agent.mjs +537 -0
  168. package/src/core/local-store.mjs +836 -0
  169. package/src/core/mode-selector.mjs +51 -0
  170. package/src/core/output-filter.mjs +177 -0
  171. package/src/core/paths.mjs +190 -0
  172. package/src/core/policy-resolver.mjs +156 -0
  173. package/src/core/pricing.mjs +336 -0
  174. package/src/core/project-artifacts.mjs +39 -0
  175. package/src/core/project-context-loader.mjs +139 -0
  176. package/src/core/providers.mjs +219 -0
  177. package/src/core/rate-limit-display.mjs +121 -0
  178. package/src/core/rate-limiter.mjs +119 -0
  179. package/src/core/resume-mode.mjs +192 -0
  180. package/src/core/risk-tier.mjs +337 -0
  181. package/src/core/safety.mjs +203 -0
  182. package/src/core/scheduler.mjs +173 -0
  183. package/src/core/session-manager.mjs +360 -0
  184. package/src/core/session.mjs +143 -0
  185. package/src/core/settings-sync.mjs +85 -0
  186. package/src/core/stagnation.mjs +57 -0
  187. package/src/core/stream-client.mjs +829 -0
  188. package/src/core/streaming.mjs +182 -0
  189. package/src/core/system-prompt.mjs +140 -0
  190. package/src/core/tasks.mjs +196 -0
  191. package/src/core/tool-executor.mjs +1950 -0
  192. package/src/core/trust.mjs +158 -0
  193. package/src/core/work-scope.mjs +248 -0
  194. package/src/hooks/engine.mjs +162 -0
  195. package/src/index.mjs +426 -0
  196. package/src/mcp/client.mjs +253 -0
  197. package/src/mcp/transport-shttp.mjs +130 -0
  198. package/src/mcp/transport-sse.mjs +131 -0
  199. package/src/mcp/transport-ws.mjs +134 -0
  200. package/src/onboarding/preflight.mjs +360 -0
  201. package/src/permissions/checker.mjs +57 -0
  202. package/src/permissions/command-classifier.mjs +652 -0
  203. package/src/permissions/injection-check.mjs +60 -0
  204. package/src/permissions/path-check.mjs +102 -0
  205. package/src/permissions/prompt.mjs +73 -0
  206. package/src/permissions/sandbox.mjs +112 -0
  207. package/src/plugins/loader.mjs +138 -0
  208. package/src/skills/installer.mjs +188 -0
  209. package/src/skills/loader.mjs +252 -0
  210. package/src/skills/runner.mjs +55 -0
  211. package/src/state/orbit.mjs +263 -0
  212. package/src/state/verbosity.mjs +99 -0
  213. package/src/telemetry/index.mjs +96 -0
  214. package/src/terminal/agents.mjs +177 -0
  215. package/src/terminal/analytics.mjs +292 -0
  216. package/src/terminal/ansi.mjs +695 -0
  217. package/src/terminal/init.mjs +145 -0
  218. package/src/terminal/main.mjs +269 -0
  219. package/src/terminal/repl-explore.mjs +35 -0
  220. package/src/terminal/repl-format.mjs +257 -0
  221. package/src/terminal/repl-render.mjs +561 -0
  222. package/src/terminal/repl-resume.mjs +625 -0
  223. package/src/terminal/repl-state.mjs +103 -0
  224. package/src/terminal/repl-utils.mjs +34 -0
  225. package/src/terminal/repl.mjs +3832 -0
  226. package/src/terminal/skills.mjs +54 -0
  227. package/src/terminal/tool-display.mjs +240 -0
  228. package/src/tools/agent.mjs +137 -0
  229. package/src/tools/ask-user.mjs +61 -0
  230. package/src/tools/bash.mjs +231 -0
  231. package/src/tools/cron-create.mjs +120 -0
  232. package/src/tools/cron-delete.mjs +49 -0
  233. package/src/tools/cron-list.mjs +37 -0
  234. package/src/tools/edit.mjs +82 -0
  235. package/src/tools/enter-worktree.mjs +69 -0
  236. package/src/tools/exit-worktree.mjs +57 -0
  237. package/src/tools/glob.mjs +117 -0
  238. package/src/tools/grep.mjs +129 -0
  239. package/src/tools/lint.mjs +71 -0
  240. package/src/tools/ls.mjs +58 -0
  241. package/src/tools/lsp.mjs +115 -0
  242. package/src/tools/multi-edit.mjs +94 -0
  243. package/src/tools/notebook-edit.mjs +96 -0
  244. package/src/tools/project-overview.mjs +641 -0
  245. package/src/tools/read-mcp-resource.mjs +57 -0
  246. package/src/tools/read.mjs +138 -0
  247. package/src/tools/registry.mjs +116 -0
  248. package/src/tools/remote-trigger.mjs +84 -0
  249. package/src/tools/send-message.mjs +64 -0
  250. package/src/tools/skill.mjs +52 -0
  251. package/src/tools/test-runner.mjs +49 -0
  252. package/src/tools/todo-write.mjs +68 -0
  253. package/src/tools/tool-search.mjs +77 -0
  254. package/src/tools/web-fetch.mjs +65 -0
  255. package/src/tools/web-search.mjs +89 -0
  256. package/src/tools/write.mjs +55 -0
  257. package/src/ui/approval.mjs +263 -0
  258. package/src/ui/banner.mjs +235 -0
  259. package/src/ui/commands.mjs +537 -0
  260. package/src/ui/formatter.mjs +409 -0
  261. package/src/ui/icons.mjs +164 -0
  262. package/src/ui/input-dock.mjs +444 -0
  263. package/src/ui/markdown.mjs +278 -0
  264. package/src/ui/mission-report.mjs +296 -0
  265. package/src/ui/palette.mjs +189 -0
  266. package/src/ui/slash-commands.mjs +245 -0
  267. package/src/ui/spinner.mjs +116 -0
  268. package/src/ui/sub-agent.mjs +152 -0
  269. package/src/ui/term.mjs +159 -0
  270. package/src/ui/text-layout.mjs +127 -0
  271. package/src/ui/tool-card.mjs +463 -0
  272. package/src/ui/tool-details.mjs +312 -0
  273. package/src/ui/transcript-block.mjs +21 -0
@@ -0,0 +1,836 @@
1
+ /**
2
+ * Local Store Reader — scans ~/.bahulam/ JSONL files for historical stats.
3
+ *
4
+ * Provides read helpers for CLI commands (/stats, /history, /tokens, /tools, /sessions).
5
+ * All data comes from local JSONL files — no cloud dependency.
6
+ */
7
+
8
+ import * as fs from 'node:fs';
9
+ import * as path from 'node:path';
10
+ import * as readline from 'node:readline';
11
+ import { bahulamHome } from './paths.mjs';
12
+
13
+ const KEPLER_DIR = bahulamHome();
14
+ const PROJECTS_DIR = path.join(KEPLER_DIR, 'projects');
15
+
16
+ function normalizeBlock(block) {
17
+ if (!block || typeof block !== 'object') {
18
+ return { type: 'unknown', value: String(block ?? '') };
19
+ }
20
+ if (block.type === 'text') {
21
+ return { type: 'text', text: block.text || '' };
22
+ }
23
+ if (block.type === 'tool_use') {
24
+ return {
25
+ type: 'tool_use',
26
+ id: block.id || null,
27
+ name: block.name || 'unknown',
28
+ input: block.input || {},
29
+ };
30
+ }
31
+ if (block.type === 'tool_result') {
32
+ return {
33
+ type: 'tool_result',
34
+ tool_use_id: block.tool_use_id || null,
35
+ content: typeof block.content === 'string' ? block.content : JSON.stringify(block.content || ''),
36
+ is_error: !!block.is_error,
37
+ };
38
+ }
39
+ return { ...block };
40
+ }
41
+
42
+ function normalizeMessageContent(content) {
43
+ if (typeof content === 'string') return content;
44
+ if (Array.isArray(content)) return content.map(normalizeBlock);
45
+ if (content == null) return '';
46
+ try {
47
+ return JSON.stringify(content);
48
+ } catch {
49
+ return String(content);
50
+ }
51
+ }
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
+
89
+ /**
90
+ * List all session JSONL files across all projects.
91
+ * Returns [{slug, sessionId, filePath, mtime}] sorted by mtime desc.
92
+ */
93
+ function listSessionFiles() {
94
+ const results = [];
95
+ try {
96
+ const slugs = fs.readdirSync(PROJECTS_DIR);
97
+ for (const slug of slugs) {
98
+ const slugDir = path.join(PROJECTS_DIR, slug);
99
+ if (!fs.statSync(slugDir).isDirectory()) continue;
100
+ const files = fs.readdirSync(slugDir).filter(f => f.endsWith('.jsonl'));
101
+ for (const file of files) {
102
+ const filePath = path.join(slugDir, file);
103
+ const stat = fs.statSync(filePath);
104
+ results.push({
105
+ slug,
106
+ sessionId: file.replace('.jsonl', ''),
107
+ filePath,
108
+ mtime: stat.mtimeMs,
109
+ });
110
+ }
111
+ }
112
+ } catch { /* projects dir may not exist yet */ }
113
+ results.sort((a, b) => b.mtime - a.mtime);
114
+ return results;
115
+ }
116
+
117
+ function findSessionFile(sessionId) {
118
+ return listSessionFiles().find((entry) => entry.sessionId === sessionId) || null;
119
+ }
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
+
174
+ /**
175
+ * Parse a session JSONL file and extract metadata.
176
+ * Reads line-by-line (streaming) to handle large files.
177
+ */
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.
181
+ const meta = {
182
+ sessionId: null,
183
+ project: null,
184
+ firstPrompt: null,
185
+ userMessages: 0,
186
+ assistantMessages: 0,
187
+ inputTokens: 0,
188
+ outputTokens: 0,
189
+ cacheReadTokens: 0,
190
+ cacheCreationTokens: 0,
191
+ toolCalls: [], // [{name, count}]
192
+ models: [], // [model strings]
193
+ modelLimits: {}, // role -> {model, context_length, max_output, source}
194
+ startTime: null,
195
+ endTime: null,
196
+ gitBranch: null,
197
+ // ── PRD-068 §5.14 derived fields ───────────────────────────────────
198
+ endStatus: 'unknown', // 'completed' | 'interrupted' | 'errored' | 'unknown'
199
+ contextTokens: 0, // projected transcript size when serialized
200
+ contextTokenSource: 'jsonl_bytes',
201
+ costUsd: 0, // sum of per-turn provider costs recorded in transcript
202
+ partial: false, // true if some lines failed to parse
203
+ fileBytes: 0, // raw file size (byte-based ctx fallback if no usage totals)
204
+ resumeSummary: null, // latest resume_summary marker, if the session has been checkpointed
205
+ };
206
+
207
+ const toolCounts = {};
208
+ const modelSet = new Set();
209
+
210
+ // endStatus tracking
211
+ let lastMessageRole = null;
212
+ let hadError = false;
213
+ const pendingToolCalls = new Set(); // tool_use_ids awaiting a tool_result
214
+
215
+ try {
216
+ try { meta.fileBytes = fs.statSync(filePath).size; } catch {}
217
+
218
+ const fileStream = fs.createReadStream(filePath, { encoding: 'utf-8' });
219
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
220
+
221
+ for await (const line of rl) {
222
+ if (!line.trim()) continue;
223
+ let obj;
224
+ try { obj = JSON.parse(line); }
225
+ catch { meta.partial = true; continue; }
226
+
227
+ if (obj.sessionId && !meta.sessionId) meta.sessionId = obj.sessionId;
228
+ if (obj.cwd && !meta.project) meta.project = obj.cwd;
229
+ if (obj.gitBranch && !meta.gitBranch) meta.gitBranch = obj.gitBranch;
230
+
231
+ const ts = obj.timestamp;
232
+ if (ts) {
233
+ if (!meta.startTime || ts < meta.startTime) meta.startTime = ts;
234
+ if (!meta.endTime || ts > meta.endTime) meta.endTime = ts;
235
+ }
236
+
237
+ // Backend kepler_event payloads may carry cost / error markers.
238
+ if (obj.type === 'kepler_event' && obj.event) {
239
+ const ev = obj.event;
240
+ if (ev.type === 'complete' && typeof ev.cost_usd === 'number') meta.costUsd += ev.cost_usd;
241
+ if (ev.type === 'session_info') {
242
+ if (typeof ev.total_cost_usd === 'number') meta.costUsd = ev.total_cost_usd;
243
+ const info = ev.data || ev;
244
+ if (info.model_limits && typeof info.model_limits === 'object') {
245
+ meta.modelLimits = info.model_limits;
246
+ }
247
+ if (info.models && typeof info.models === 'object') {
248
+ for (const model of Object.values(info.models)) {
249
+ if (typeof model === 'string' && model) modelSet.add(model);
250
+ }
251
+ }
252
+ }
253
+ if (ev.type === 'error' || ev.error === true) hadError = true;
254
+ if (ev.type === 'resume_summary' && typeof ev.data?.summary === 'string') {
255
+ meta.resumeSummary = {
256
+ sourceMessageCount: Number(ev.data.source_message_count) || 0,
257
+ previousSourceMessageCount: Number(ev.data.previous_source_message_count) || 0,
258
+ fullMessageCount: Number(ev.data.full_message_count) || 0,
259
+ summaryChars: ev.data.summary.length,
260
+ summarySource: ev.data.summary_source || '',
261
+ mode: ev.data.mode || '',
262
+ modeLabel: ev.data.mode_label || '',
263
+ timestamp: obj.timestamp || null,
264
+ };
265
+ }
266
+ }
267
+
268
+ if (obj.type === 'user') {
269
+ meta.userMessages++;
270
+ lastMessageRole = 'user';
271
+ // Capture first user prompt (string content only)
272
+ if (!meta.firstPrompt) {
273
+ const content = obj.message?.content;
274
+ if (typeof content === 'string' && content.length > 0) {
275
+ meta.firstPrompt = content.slice(0, 100);
276
+ }
277
+ }
278
+ // A user turn may carry tool_results — clear matched pending calls
279
+ const content = obj.message?.content;
280
+ if (Array.isArray(content)) {
281
+ for (const block of content) {
282
+ if (block.type === 'tool_result' && block.tool_use_id) {
283
+ pendingToolCalls.delete(block.tool_use_id);
284
+ if (block.is_error) hadError = true;
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ if (obj.type === 'assistant') {
291
+ meta.assistantMessages++;
292
+ lastMessageRole = 'assistant';
293
+ const usage = obj.message?.usage;
294
+ if (usage) {
295
+ meta.inputTokens += usage.input_tokens || 0;
296
+ meta.outputTokens += usage.output_tokens || 0;
297
+ meta.cacheReadTokens += usage.cache_read_input_tokens || 0;
298
+ meta.cacheCreationTokens += usage.cache_creation_input_tokens || 0;
299
+ }
300
+ const model = obj.message?.model;
301
+ if (model) modelSet.add(model);
302
+
303
+ // Count tool_use blocks — and track them as pending until we see the result
304
+ const content = obj.message?.content;
305
+ if (Array.isArray(content)) {
306
+ for (const block of content) {
307
+ if (block.type === 'tool_use' && block.name) {
308
+ toolCounts[block.name] = (toolCounts[block.name] || 0) + 1;
309
+ if (block.id) pendingToolCalls.add(block.id);
310
+ }
311
+ }
312
+ }
313
+ }
314
+ }
315
+ } catch { meta.partial = true; }
316
+
317
+ meta.toolCalls = Object.entries(toolCounts)
318
+ .map(([name, count]) => ({ name, count }))
319
+ .sort((a, b) => b.count - a.count);
320
+ meta.models = [...modelSet];
321
+
322
+ // Projected context size for resume should estimate the serialized payload,
323
+ // not cumulative provider usage. Provider input tokens are charged per turn
324
+ // and include repeated/cache-read context, so summing them can show millions
325
+ // of "context" tokens for a transcript that serializes far smaller.
326
+ meta.contextTokens = Math.max(0, Math.round(meta.fileBytes / 4));
327
+
328
+ // Derive endStatus from the tail of the transcript.
329
+ if (hadError) {
330
+ meta.endStatus = 'errored';
331
+ } else if (pendingToolCalls.size > 0 || lastMessageRole === 'user') {
332
+ meta.endStatus = 'interrupted';
333
+ } else if (lastMessageRole === 'assistant') {
334
+ meta.endStatus = 'completed';
335
+ } else {
336
+ meta.endStatus = 'unknown';
337
+ }
338
+
339
+ return meta;
340
+ }
341
+
342
+ /**
343
+ * Get recent sessions with metadata.
344
+ * @param {number} n — max sessions to return
345
+ */
346
+ export async function getRecentSessions(n = 10) {
347
+ const files = listSessionFiles().slice(0, n);
348
+ const sessions = [];
349
+ for (const f of files) {
350
+ const meta = await parseSessionMeta(f.filePath);
351
+ sessions.push({
352
+ ...meta,
353
+ slug: f.slug,
354
+ filePath: f.filePath,
355
+ mtime: f.mtime,
356
+ });
357
+ }
358
+ return sessions;
359
+ }
360
+
361
+ /**
362
+ * Return normalized entries for a single session transcript.
363
+ * @param {string} sessionId
364
+ */
365
+ export async function getSessionDetail(sessionId, options = {}) {
366
+ const file = options.filePath
367
+ ? {
368
+ sessionId,
369
+ slug: path.basename(path.dirname(options.filePath)),
370
+ filePath: options.filePath,
371
+ mtime: fs.statSync(options.filePath).mtimeMs,
372
+ }
373
+ : findSessionFile(sessionId);
374
+ if (!file) return null;
375
+
376
+ const entries = [];
377
+ const replayEvents = [];
378
+ const fileStream = fs.createReadStream(file.filePath, { encoding: 'utf-8' });
379
+ const rl = readline.createInterface({ input: fileStream, crlfDelay: Infinity });
380
+ let order = 0;
381
+
382
+ for await (const line of rl) {
383
+ if (!line.trim()) continue;
384
+ let obj;
385
+ try {
386
+ obj = JSON.parse(line);
387
+ } catch {
388
+ continue;
389
+ }
390
+ const entryOrder = order++;
391
+
392
+ if (obj.type === 'kepler_event' && obj.event?.type) {
393
+ replayEvents.push({
394
+ order: entryOrder,
395
+ timestamp: obj.timestamp || null,
396
+ event: obj.event,
397
+ });
398
+ continue;
399
+ }
400
+
401
+ const message = obj.message || {};
402
+ entries.push({
403
+ order: entryOrder,
404
+ type: obj.type || null,
405
+ timestamp: obj.timestamp || null,
406
+ cwd: obj.cwd || null,
407
+ role: message.role || null,
408
+ model: message.model || null,
409
+ usage: message.usage || null,
410
+ content: normalizeMessageContent(message.content),
411
+ uuid: obj.uuid || null,
412
+ parentUuid: obj.parentUuid || null,
413
+ });
414
+ }
415
+
416
+ const meta = await parseSessionMeta(file.filePath);
417
+ return {
418
+ sessionId: file.sessionId,
419
+ slug: file.slug,
420
+ filePath: file.filePath,
421
+ mtime: file.mtime,
422
+ meta,
423
+ entries,
424
+ replayEvents,
425
+ };
426
+ }
427
+
428
+ /**
429
+ * Convert a rich transcript into display history and backend continuity.
430
+ * Display history is intentionally richer than backend history: it includes
431
+ * tool calls/results so /history can reconstruct prior work.
432
+ *
433
+ * @param {object} detail - result from getSessionDetail()
434
+ * @param {'compact'|'full'} mode
435
+ */
436
+ export function buildResumeHistory(detail, mode = 'compact') {
437
+ if (!detail) {
438
+ return {
439
+ displayHistory: [],
440
+ agentHistory: [],
441
+ summary: '',
442
+ stats: { userMessages: 0, assistantMessages: 0, toolCalls: 0, toolResults: 0 },
443
+ };
444
+ }
445
+
446
+ const displayHistory = [];
447
+ const fullAgentHistory = [];
448
+ const userPrompts = [];
449
+ const assistantTexts = [];
450
+ const toolCounts = new Map();
451
+ const importantResults = [];
452
+ let toolCalls = 0;
453
+ let toolResults = 0;
454
+
455
+ for (const entry of detail.entries || []) {
456
+ if (entry.role === 'user' && typeof entry.content === 'string') {
457
+ const content = entry.content;
458
+ displayHistory.push({ role: 'user', content, timestamp: entry.timestamp, order: entry.order });
459
+ fullAgentHistory.push({ role: 'user', content });
460
+ userPrompts.push(content);
461
+ continue;
462
+ }
463
+
464
+ if (entry.role === 'assistant') {
465
+ const text = entryText(entry);
466
+ const tools = entryToolUses(entry);
467
+ for (const tool of tools) {
468
+ toolCalls++;
469
+ toolCounts.set(tool.name, (toolCounts.get(tool.name) || 0) + 1);
470
+ const line = `${tool.name} ${stringifyInput(tool.input)}`;
471
+ displayHistory.push({
472
+ role: 'tool',
473
+ content: line,
474
+ timestamp: entry.timestamp,
475
+ order: entry.order,
476
+ tool: tool.name,
477
+ kind: 'call',
478
+ });
479
+ }
480
+ if (text) {
481
+ displayHistory.push({ role: 'assistant', content: text, timestamp: entry.timestamp, order: entry.order });
482
+ assistantTexts.push(text);
483
+ }
484
+
485
+ const fullContent = [
486
+ text,
487
+ ...tools.map(tool => `[tool_call] ${tool.name} ${stringifyInput(tool.input)}`),
488
+ ].filter(Boolean).join('\n\n');
489
+ if (fullContent) fullAgentHistory.push({ role: 'assistant', content: fullContent });
490
+ continue;
491
+ }
492
+
493
+ if (entry.role === 'user' && Array.isArray(entry.content)) {
494
+ const results = entryToolResults(entry);
495
+ for (const result of results) {
496
+ toolResults++;
497
+ const content = truncateText(result.content, 1200);
498
+ const label = `[tool_result] ${result.tool_use_id || 'tool'}${result.is_error ? ' (error)' : ''}: ${content}`;
499
+ displayHistory.push({
500
+ role: 'tool',
501
+ content: label,
502
+ timestamp: entry.timestamp,
503
+ order: entry.order,
504
+ tool: result.tool_use_id || 'tool',
505
+ kind: 'result',
506
+ });
507
+ fullAgentHistory.push({ role: 'user', content: label });
508
+ if (importantResults.length < 12 && content) importantResults.push(label);
509
+ }
510
+ }
511
+ }
512
+
513
+ const toolSummary = [...toolCounts.entries()]
514
+ .sort((a, b) => b[1] - a[1])
515
+ .map(([name, count]) => `${name} x${count}`)
516
+ .join(', ') || 'none recorded';
517
+ const latestUser = userPrompts[userPrompts.length - 1] || detail.meta?.firstPrompt || '';
518
+ const latestAssistant = assistantTexts[assistantTexts.length - 1] || '';
519
+ const projectRoots = getTranscriptProjectRoots(detail);
520
+ const summaryLines = [
521
+ 'Session continuity summary from the resumed local transcript.',
522
+ `Session: ${detail.sessionId}`,
523
+ detail.meta?.project ? `Project: ${detail.meta.project}` : '',
524
+ projectRoots.length ? `Registered project roots: ${projectRoots.join(', ')}` : '',
525
+ detail.meta?.startTime ? `Started: ${detail.meta.startTime}` : '',
526
+ detail.meta?.endTime ? `Last activity: ${detail.meta.endTime}` : '',
527
+ `Prior user requests (${userPrompts.length}):`,
528
+ ...userPrompts.slice(-8).map(text => `- ${truncateText(text, 300)}`),
529
+ `Assistant progress notes (${assistantTexts.length}):`,
530
+ ...assistantTexts.slice(-6).map(text => `- ${truncateText(text, 400)}`),
531
+ `Tools used: ${toolSummary}`,
532
+ importantResults.length ? 'Important recent tool results:' : '',
533
+ ...importantResults.slice(-8).map(text => `- ${truncateText(text, 500)}`),
534
+ latestUser ? `Most recent user request: ${truncateText(latestUser, 500)}` : '',
535
+ latestAssistant ? `Most recent assistant response: ${truncateText(latestAssistant, 500)}` : '',
536
+ ].filter(Boolean);
537
+ const summary = summaryLines.join('\n');
538
+ const metadata = buildResumeMetadata({ detail, projectRoots, userPrompts, assistantTexts, toolCalls, toolResults, toolSummary });
539
+ const originalRequest = userPrompts[0] || detail.meta?.firstPrompt || '';
540
+ const summaryCheckpoint = latestResumeSummaryCheckpoint(detail);
541
+ const priorSummary = String(summaryCheckpoint?.data?.summary || '').trim();
542
+ const summaryCheckpointMessageCount = clampMessageCount(
543
+ summaryCheckpoint?.data?.source_message_count,
544
+ fullAgentHistory.length
545
+ );
546
+
547
+ // PRD-068 §5.14.4: resume mode picker.
548
+ // 'full' — every turn sent verbatim (unchanged)
549
+ // 'checkpoint-full' — latest summary checkpoint + every message after it
550
+ // 'tail-N' — recap block + last N conversation messages
551
+ // 'summary' — recap block only. Cheapest continuity, biggest lossiness.
552
+ let agentHistory;
553
+ let sourceMessages = fullAgentHistory;
554
+ let summaryMessageIndex = -1;
555
+ let activeSummary = summary;
556
+ let summaryCoveredMessageCount = 0;
557
+ if (mode === 'full') {
558
+ agentHistory = fullAgentHistory;
559
+ summaryCoveredMessageCount = fullAgentHistory.length;
560
+ } else if (mode === 'checkpoint-full') {
561
+ sourceMessages = [];
562
+ const tail = fullAgentHistory.slice(summaryCheckpointMessageCount);
563
+ activeSummary = priorSummary
564
+ || summaryForMessages(fullAgentHistory.slice(0, summaryCheckpointMessageCount), {
565
+ fallback: summary,
566
+ label: 'Summary checkpoint',
567
+ });
568
+ summaryCoveredMessageCount = summaryCheckpointMessageCount;
569
+ agentHistory = [
570
+ { role: 'user', content: metadata },
571
+ { role: 'user', content: `Original user request from this resumed session:\n${originalRequest || '(unknown)'}` },
572
+ { role: 'user', content: activeSummary || summary },
573
+ ...tail,
574
+ ];
575
+ summaryMessageIndex = 2;
576
+ } else if (mode === 'recap+tail' || /^tail-\d+$/.test(String(mode || ''))) {
577
+ const tailTurns = tailTurnsForMode(mode, detail);
578
+ const { tail, startIndex } = tailHistorySliceByRecentMessages(fullAgentHistory, tailTurns);
579
+ const deltaStart = Math.min(summaryCheckpointMessageCount, startIndex);
580
+ sourceMessages = fullAgentHistory.slice(deltaStart, startIndex);
581
+ const deltaSummary = sourceMessages.length
582
+ ? summaryForMessages(sourceMessages, {
583
+ fallback: summary,
584
+ label: `Summary of earlier turns before the last ${tailTurns} conversation messages`,
585
+ })
586
+ : '';
587
+ activeSummary = combineResumeSummaries(priorSummary, deltaSummary)
588
+ || summaryForMessages(fullAgentHistory.slice(0, startIndex), {
589
+ fallback: summary,
590
+ label: `Summary of earlier turns before the last ${tailTurns} conversation messages`,
591
+ });
592
+ summaryCoveredMessageCount = Math.min(
593
+ fullAgentHistory.length,
594
+ Math.max(summaryCheckpointMessageCount, startIndex)
595
+ );
596
+ agentHistory = [
597
+ { role: 'user', content: metadata },
598
+ { role: 'user', content: `Original user request from this resumed session:\n${originalRequest || '(unknown)'}` },
599
+ { role: 'user', content: activeSummary },
600
+ ...tail,
601
+ ];
602
+ summaryMessageIndex = 2;
603
+ } else {
604
+ // 'summary' (was 'compact' — renamed per PRD-068 §5.14.4)
605
+ sourceMessages = fullAgentHistory.slice(summaryCheckpointMessageCount);
606
+ const deltaSummary = priorSummary && sourceMessages.length
607
+ ? summaryForMessages(sourceMessages, {
608
+ fallback: summary,
609
+ label: 'New turns after the previous resume summary',
610
+ })
611
+ : '';
612
+ activeSummary = combineResumeSummaries(priorSummary, deltaSummary) || summary;
613
+ summaryCoveredMessageCount = fullAgentHistory.length;
614
+ agentHistory = [
615
+ { role: 'user', content: metadata },
616
+ { role: 'user', content: `Original user request from this resumed session:\n${originalRequest || '(unknown)'}` },
617
+ { role: 'user', content: activeSummary },
618
+ ];
619
+ summaryMessageIndex = 2;
620
+ }
621
+
622
+ return {
623
+ displayHistory,
624
+ agentHistory,
625
+ sourceMessages,
626
+ summaryMessageIndex,
627
+ summary: activeSummary,
628
+ priorSummary,
629
+ summaryCheckpointMessageCount,
630
+ summaryCoveredMessageCount,
631
+ fullMessageCount: fullAgentHistory.length,
632
+ mode,
633
+ stats: {
634
+ userMessages: userPrompts.length,
635
+ assistantMessages: assistantTexts.length,
636
+ toolCalls,
637
+ toolResults,
638
+ },
639
+ };
640
+ }
641
+
642
+ export function combineResumeSummaries(priorSummary, deltaSummary) {
643
+ const prior = String(priorSummary || '').trim();
644
+ const delta = String(deltaSummary || '').trim();
645
+ if (prior && delta) {
646
+ return [
647
+ prior,
648
+ '',
649
+ 'New activity since previous summary:',
650
+ delta,
651
+ ].join('\n');
652
+ }
653
+ return prior || delta || '';
654
+ }
655
+
656
+ function buildResumeMetadata({ detail, projectRoots, userPrompts, assistantTexts, toolCalls, toolResults, toolSummary }) {
657
+ return [
658
+ 'Resume metadata.',
659
+ `Session: ${detail.sessionId}`,
660
+ detail.meta?.project ? `Project: ${detail.meta.project}` : '',
661
+ projectRoots.length ? `Registered project roots: ${projectRoots.join(', ')}` : '',
662
+ detail.meta?.startTime ? `Started: ${detail.meta.startTime}` : '',
663
+ detail.meta?.endTime ? `Last activity: ${detail.meta.endTime}` : '',
664
+ `Total user turns: ${userPrompts.length}`,
665
+ `Assistant messages: ${assistantTexts.length}`,
666
+ `Tool calls/results: ${toolCalls}/${toolResults}`,
667
+ `Tools used: ${toolSummary}`,
668
+ ].filter(Boolean).join('\n');
669
+ }
670
+
671
+ function summaryForMessages(messages, { fallback, label }) {
672
+ const list = Array.isArray(messages) ? messages : [];
673
+ if (!list.length) {
674
+ return `${label}:\nNo earlier turns before the retained tail.`;
675
+ }
676
+ const lines = [label + ':'];
677
+ for (const msg of list.slice(-24)) {
678
+ const role = msg.role || 'message';
679
+ lines.push(`- ${role}: ${truncateText(msg.content, 450)}`);
680
+ }
681
+ const text = lines.join('\n');
682
+ return text.trim() || fallback;
683
+ }
684
+
685
+ function tailTurnsForMode(mode, detail) {
686
+ const match = String(mode || '').match(/^tail-(\d+)$/);
687
+ if (match) return Math.max(1, Number(match[1]) || 1);
688
+ return Number.isFinite(Number(detail?.recapTailTurns))
689
+ ? Math.max(1, Number(detail.recapTailTurns))
690
+ : 8;
691
+ }
692
+
693
+ function tailHistorySliceByRecentMessages(history, turns) {
694
+ const list = Array.isArray(history) ? history : [];
695
+ const count = Math.max(1, Number(turns) || 1);
696
+ const start = Math.max(0, list.length - count);
697
+ return { tail: list.slice(start), startIndex: start };
698
+ }
699
+
700
+ function latestResumeSummaryCheckpoint(detail) {
701
+ const events = Array.isArray(detail?.replayEvents) ? detail.replayEvents : [];
702
+ let latest = null;
703
+ for (const item of events) {
704
+ const event = item?.event;
705
+ const data = event?.data || {};
706
+ if (event?.type !== 'resume_summary' || typeof data.summary !== 'string') continue;
707
+ if (!latest || Number(item.order ?? -1) >= Number(latest.order ?? -1)) {
708
+ latest = { ...item, data };
709
+ }
710
+ }
711
+ return latest;
712
+ }
713
+
714
+ function clampMessageCount(value, max) {
715
+ const n = Number(value);
716
+ if (!Number.isFinite(n)) return 0;
717
+ return Math.max(0, Math.min(Math.floor(n), Math.max(0, Number(max) || 0)));
718
+ }
719
+
720
+ export function getTranscriptProjectRoots(detail) {
721
+ const roots = new Set();
722
+ if (detail?.meta?.project && fs.existsSync(detail.meta.project)) {
723
+ roots.add(detail.meta.project);
724
+ }
725
+
726
+ for (const entry of detail?.entries || []) {
727
+ if (typeof entry.content === 'string') {
728
+ for (const candidate of collectAbsolutePathsFromText(entry.content)) {
729
+ const root = inferProjectRoot(candidate);
730
+ if (root) roots.add(root);
731
+ }
732
+ }
733
+ for (const tool of entryToolUses(entry)) {
734
+ if (tool.name === 'get_project_overview') {
735
+ const root = inferProjectRoot(tool.input?.path || tool.input?.root || tool.input?.cwd);
736
+ if (root) roots.add(root);
737
+ }
738
+ for (const candidate of collectAbsoluteStrings(tool.input)) {
739
+ const root = inferProjectRoot(candidate);
740
+ if (root) roots.add(root);
741
+ }
742
+ }
743
+ }
744
+
745
+ return [...roots];
746
+ }
747
+
748
+ /**
749
+ * Aggregate stats across sessions within a date range.
750
+ * @param {number} days — look back this many days (0 = all time)
751
+ */
752
+ export async function getSessionStats(days = 30) {
753
+ const files = listSessionFiles();
754
+ const cutoff = days > 0 ? Date.now() - (days * 86400000) : 0;
755
+ const filtered = files.filter(f => f.mtime >= cutoff);
756
+
757
+ const stats = {
758
+ totalSessions: filtered.length,
759
+ totalUserMessages: 0,
760
+ totalAssistantMessages: 0,
761
+ totalInputTokens: 0,
762
+ totalOutputTokens: 0,
763
+ totalCacheReadTokens: 0,
764
+ totalToolCalls: 0,
765
+ toolBreakdown: {},
766
+ modelBreakdown: {},
767
+ };
768
+
769
+ for (const f of filtered) {
770
+ const meta = await parseSessionMeta(f.filePath);
771
+ stats.totalUserMessages += meta.userMessages;
772
+ stats.totalAssistantMessages += meta.assistantMessages;
773
+ stats.totalInputTokens += meta.inputTokens;
774
+ stats.totalOutputTokens += meta.outputTokens;
775
+ stats.totalCacheReadTokens += meta.cacheReadTokens;
776
+
777
+ for (const tc of meta.toolCalls) {
778
+ stats.toolBreakdown[tc.name] = (stats.toolBreakdown[tc.name] || 0) + tc.count;
779
+ stats.totalToolCalls += tc.count;
780
+ }
781
+ for (const model of meta.models) {
782
+ stats.modelBreakdown[model] = (stats.modelBreakdown[model] || 0) + 1;
783
+ }
784
+ }
785
+
786
+ return stats;
787
+ }
788
+
789
+ /**
790
+ * Get tool breakdown ranked by usage.
791
+ * @param {number} days — look back period
792
+ */
793
+ export async function getToolBreakdown(days = 30) {
794
+ const stats = await getSessionStats(days);
795
+ return Object.entries(stats.toolBreakdown)
796
+ .map(([name, count]) => ({ name, count }))
797
+ .sort((a, b) => b.count - a.count);
798
+ }
799
+
800
+ /**
801
+ * Get model breakdown with session counts.
802
+ * @param {number} days — look back period
803
+ */
804
+ export async function getModelBreakdown(days = 30) {
805
+ const stats = await getSessionStats(days);
806
+ return Object.entries(stats.modelBreakdown)
807
+ .map(([model, sessions]) => ({ model, sessions }))
808
+ .sort((a, b) => b.sessions - a.sessions);
809
+ }
810
+
811
+ /**
812
+ * Read history.jsonl entries.
813
+ * @param {number} n — max entries to return (most recent first)
814
+ */
815
+ export function getHistory(n = 50) {
816
+ const historyPath = path.join(KEPLER_DIR, 'history.jsonl');
817
+ try {
818
+ const content = fs.readFileSync(historyPath, 'utf-8');
819
+ const lines = content.trim().split('\n').filter(Boolean);
820
+ const entries = [];
821
+ for (const line of lines) {
822
+ try { entries.push(JSON.parse(line)); } catch { /* skip bad lines */ }
823
+ }
824
+ return entries.slice(-n).reverse();
825
+ } catch {
826
+ return [];
827
+ }
828
+ }
829
+
830
+ export function getStorePaths() {
831
+ return {
832
+ keplerDir: KEPLER_DIR,
833
+ projectsDir: PROJECTS_DIR,
834
+ historyPath: path.join(KEPLER_DIR, 'history.jsonl'),
835
+ };
836
+ }