@ai-studio-3d/vyasa 0.1.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 (237) hide show
  1. package/README.md +69 -0
  2. package/data/common-kit.json +34 -0
  3. package/data/pricing.json +119 -0
  4. package/data/schema/AnalyticsData.json +214 -0
  5. package/data/schema/FoundationData.json +42 -0
  6. package/data/schema/KitData.json +321 -0
  7. package/data/schema/SearchData.json +173 -0
  8. package/data/schema/SessionDetailData.json +231 -0
  9. package/data/schema/SessionsListData.json +115 -0
  10. package/data/schema/WasteData.json +95 -0
  11. package/dist/cli.d.ts +3 -0
  12. package/dist/cli.d.ts.map +1 -0
  13. package/dist/cli.js +343 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/core/billing/index.d.ts +4 -0
  16. package/dist/core/billing/index.d.ts.map +1 -0
  17. package/dist/core/billing/index.js +43 -0
  18. package/dist/core/billing/index.js.map +1 -0
  19. package/dist/core/budget/index.d.ts +30 -0
  20. package/dist/core/budget/index.d.ts.map +1 -0
  21. package/dist/core/budget/index.js +61 -0
  22. package/dist/core/budget/index.js.map +1 -0
  23. package/dist/core/codex/index.d.ts +3 -0
  24. package/dist/core/codex/index.d.ts.map +1 -0
  25. package/dist/core/codex/index.js +212 -0
  26. package/dist/core/codex/index.js.map +1 -0
  27. package/dist/core/cursor/index.d.ts +3 -0
  28. package/dist/core/cursor/index.d.ts.map +1 -0
  29. package/dist/core/cursor/index.js +304 -0
  30. package/dist/core/cursor/index.js.map +1 -0
  31. package/dist/core/cursor/scan.d.ts +28 -0
  32. package/dist/core/cursor/scan.d.ts.map +1 -0
  33. package/dist/core/cursor/scan.js +79 -0
  34. package/dist/core/cursor/scan.js.map +1 -0
  35. package/dist/core/decisions/index.d.ts +21 -0
  36. package/dist/core/decisions/index.d.ts.map +1 -0
  37. package/dist/core/decisions/index.js +133 -0
  38. package/dist/core/decisions/index.js.map +1 -0
  39. package/dist/core/dlp/index.d.ts +7 -0
  40. package/dist/core/dlp/index.d.ts.map +1 -0
  41. package/dist/core/dlp/index.js +115 -0
  42. package/dist/core/dlp/index.js.map +1 -0
  43. package/dist/core/health/index.d.ts +10 -0
  44. package/dist/core/health/index.d.ts.map +1 -0
  45. package/dist/core/health/index.js +21 -0
  46. package/dist/core/health/index.js.map +1 -0
  47. package/dist/core/index/index.d.ts +9 -0
  48. package/dist/core/index/index.d.ts.map +1 -0
  49. package/dist/core/index/index.js +103 -0
  50. package/dist/core/index/index.js.map +1 -0
  51. package/dist/core/index/ingest.d.ts +16 -0
  52. package/dist/core/index/ingest.d.ts.map +1 -0
  53. package/dist/core/index/ingest.js +184 -0
  54. package/dist/core/index/ingest.js.map +1 -0
  55. package/dist/core/index/platforms.d.ts +21 -0
  56. package/dist/core/index/platforms.d.ts.map +1 -0
  57. package/dist/core/index/platforms.js +44 -0
  58. package/dist/core/index/platforms.js.map +1 -0
  59. package/dist/core/index/pricing-map.d.ts +10 -0
  60. package/dist/core/index/pricing-map.d.ts.map +1 -0
  61. package/dist/core/index/pricing-map.js +16 -0
  62. package/dist/core/index/pricing-map.js.map +1 -0
  63. package/dist/core/index/progress.d.ts +26 -0
  64. package/dist/core/index/progress.d.ts.map +1 -0
  65. package/dist/core/index/progress.js +59 -0
  66. package/dist/core/index/progress.js.map +1 -0
  67. package/dist/core/index/scan.d.ts +9 -0
  68. package/dist/core/index/scan.d.ts.map +1 -0
  69. package/dist/core/index/scan.js +42 -0
  70. package/dist/core/index/scan.js.map +1 -0
  71. package/dist/core/index/statements.d.ts +18 -0
  72. package/dist/core/index/statements.d.ts.map +1 -0
  73. package/dist/core/index/statements.js +82 -0
  74. package/dist/core/index/statements.js.map +1 -0
  75. package/dist/core/index/tail.d.ts +42 -0
  76. package/dist/core/index/tail.d.ts.map +1 -0
  77. package/dist/core/index/tail.js +123 -0
  78. package/dist/core/index/tail.js.map +1 -0
  79. package/dist/core/jsonl/index.d.ts +3 -0
  80. package/dist/core/jsonl/index.d.ts.map +1 -0
  81. package/dist/core/jsonl/index.js +106 -0
  82. package/dist/core/jsonl/index.js.map +1 -0
  83. package/dist/core/jsonl-validate/index.d.ts +10 -0
  84. package/dist/core/jsonl-validate/index.d.ts.map +1 -0
  85. package/dist/core/jsonl-validate/index.js +40 -0
  86. package/dist/core/jsonl-validate/index.js.map +1 -0
  87. package/dist/core/kit/detection.d.ts +94 -0
  88. package/dist/core/kit/detection.d.ts.map +1 -0
  89. package/dist/core/kit/detection.js +312 -0
  90. package/dist/core/kit/detection.js.map +1 -0
  91. package/dist/core/kit/index.d.ts +12 -0
  92. package/dist/core/kit/index.d.ts.map +1 -0
  93. package/dist/core/kit/index.js +131 -0
  94. package/dist/core/kit/index.js.map +1 -0
  95. package/dist/core/kit/scan.d.ts +28 -0
  96. package/dist/core/kit/scan.d.ts.map +1 -0
  97. package/dist/core/kit/scan.js +432 -0
  98. package/dist/core/kit/scan.js.map +1 -0
  99. package/dist/core/recap/index.d.ts +18 -0
  100. package/dist/core/recap/index.d.ts.map +1 -0
  101. package/dist/core/recap/index.js +88 -0
  102. package/dist/core/recap/index.js.map +1 -0
  103. package/dist/core/repetition/index.d.ts +5 -0
  104. package/dist/core/repetition/index.d.ts.map +1 -0
  105. package/dist/core/repetition/index.js +0 -0
  106. package/dist/core/repetition/index.js.map +1 -0
  107. package/dist/core/schema/index.d.ts +514 -0
  108. package/dist/core/schema/index.d.ts.map +1 -0
  109. package/dist/core/schema/index.js +2 -0
  110. package/dist/core/schema/index.js.map +1 -0
  111. package/dist/core/schema-watch/index.d.ts +34 -0
  112. package/dist/core/schema-watch/index.d.ts.map +1 -0
  113. package/dist/core/schema-watch/index.js +87 -0
  114. package/dist/core/schema-watch/index.js.map +1 -0
  115. package/dist/core/search/index.d.ts +44 -0
  116. package/dist/core/search/index.d.ts.map +1 -0
  117. package/dist/core/search/index.js +641 -0
  118. package/dist/core/search/index.js.map +1 -0
  119. package/dist/core/settings/index.d.ts +24 -0
  120. package/dist/core/settings/index.d.ts.map +1 -0
  121. package/dist/core/settings/index.js +76 -0
  122. package/dist/core/settings/index.js.map +1 -0
  123. package/dist/core/share/bundle.d.ts +40 -0
  124. package/dist/core/share/bundle.d.ts.map +1 -0
  125. package/dist/core/share/bundle.js +158 -0
  126. package/dist/core/share/bundle.js.map +1 -0
  127. package/dist/core/share/canonical/from-claude.d.ts +13 -0
  128. package/dist/core/share/canonical/from-claude.d.ts.map +1 -0
  129. package/dist/core/share/canonical/from-claude.js +129 -0
  130. package/dist/core/share/canonical/from-claude.js.map +1 -0
  131. package/dist/core/share/canonical/from-codex.d.ts +18 -0
  132. package/dist/core/share/canonical/from-codex.d.ts.map +1 -0
  133. package/dist/core/share/canonical/from-codex.js +183 -0
  134. package/dist/core/share/canonical/from-codex.js.map +1 -0
  135. package/dist/core/share/canonical/to-claude.d.ts +15 -0
  136. package/dist/core/share/canonical/to-claude.d.ts.map +1 -0
  137. package/dist/core/share/canonical/to-claude.js +146 -0
  138. package/dist/core/share/canonical/to-claude.js.map +1 -0
  139. package/dist/core/share/canonical/to-codex.d.ts +21 -0
  140. package/dist/core/share/canonical/to-codex.d.ts.map +1 -0
  141. package/dist/core/share/canonical/to-codex.js +124 -0
  142. package/dist/core/share/canonical/to-codex.js.map +1 -0
  143. package/dist/core/share/canonical/tool-map.d.ts +61 -0
  144. package/dist/core/share/canonical/tool-map.d.ts.map +1 -0
  145. package/dist/core/share/canonical/tool-map.js +299 -0
  146. package/dist/core/share/canonical/tool-map.js.map +1 -0
  147. package/dist/core/share/canonical/types.d.ts +57 -0
  148. package/dist/core/share/canonical/types.d.ts.map +1 -0
  149. package/dist/core/share/canonical/types.js +9 -0
  150. package/dist/core/share/canonical/types.js.map +1 -0
  151. package/dist/core/share/import.d.ts +28 -0
  152. package/dist/core/share/import.d.ts.map +1 -0
  153. package/dist/core/share/import.js +174 -0
  154. package/dist/core/share/import.js.map +1 -0
  155. package/dist/core/share/manifest.d.ts +37 -0
  156. package/dist/core/share/manifest.d.ts.map +1 -0
  157. package/dist/core/share/manifest.js +31 -0
  158. package/dist/core/share/manifest.js.map +1 -0
  159. package/dist/core/share/preview.d.ts +4 -0
  160. package/dist/core/share/preview.d.ts.map +1 -0
  161. package/dist/core/share/preview.js +153 -0
  162. package/dist/core/share/preview.js.map +1 -0
  163. package/dist/core/share/primer.d.ts +19 -0
  164. package/dist/core/share/primer.d.ts.map +1 -0
  165. package/dist/core/share/primer.js +196 -0
  166. package/dist/core/share/primer.js.map +1 -0
  167. package/dist/core/share/resume.d.ts +10 -0
  168. package/dist/core/share/resume.d.ts.map +1 -0
  169. package/dist/core/share/resume.js +58 -0
  170. package/dist/core/share/resume.js.map +1 -0
  171. package/dist/core/share/scrub.d.ts +10 -0
  172. package/dist/core/share/scrub.d.ts.map +1 -0
  173. package/dist/core/share/scrub.js +198 -0
  174. package/dist/core/share/scrub.js.map +1 -0
  175. package/dist/core/share/tar.d.ts +12 -0
  176. package/dist/core/share/tar.d.ts.map +1 -0
  177. package/dist/core/share/tar.js +78 -0
  178. package/dist/core/share/tar.js.map +1 -0
  179. package/dist/core/tags/index.d.ts +48 -0
  180. package/dist/core/tags/index.d.ts.map +1 -0
  181. package/dist/core/tags/index.js +113 -0
  182. package/dist/core/tags/index.js.map +1 -0
  183. package/dist/core/today/index.d.ts +25 -0
  184. package/dist/core/today/index.d.ts.map +1 -0
  185. package/dist/core/today/index.js +42 -0
  186. package/dist/core/today/index.js.map +1 -0
  187. package/dist/core/waste/abandoned.d.ts +12 -0
  188. package/dist/core/waste/abandoned.d.ts.map +1 -0
  189. package/dist/core/waste/abandoned.js +127 -0
  190. package/dist/core/waste/abandoned.js.map +1 -0
  191. package/dist/core/waste/cache-miss.d.ts +9 -0
  192. package/dist/core/waste/cache-miss.d.ts.map +1 -0
  193. package/dist/core/waste/cache-miss.js +84 -0
  194. package/dist/core/waste/cache-miss.js.map +1 -0
  195. package/dist/core/waste/index.d.ts +12 -0
  196. package/dist/core/waste/index.d.ts.map +1 -0
  197. package/dist/core/waste/index.js +45 -0
  198. package/dist/core/waste/index.js.map +1 -0
  199. package/dist/core/waste/wrong-model.d.ts +11 -0
  200. package/dist/core/waste/wrong-model.d.ts.map +1 -0
  201. package/dist/core/waste/wrong-model.js +104 -0
  202. package/dist/core/waste/wrong-model.js.map +1 -0
  203. package/dist/db/index.d.ts +10 -0
  204. package/dist/db/index.d.ts.map +1 -0
  205. package/dist/db/index.js +29 -0
  206. package/dist/db/index.js.map +1 -0
  207. package/dist/db/migrate.d.ts +8 -0
  208. package/dist/db/migrate.d.ts.map +1 -0
  209. package/dist/db/migrate.js +282 -0
  210. package/dist/db/migrate.js.map +1 -0
  211. package/dist/server/events.d.ts +4 -0
  212. package/dist/server/events.d.ts.map +1 -0
  213. package/dist/server/events.js +54 -0
  214. package/dist/server/events.js.map +1 -0
  215. package/dist/server/index.d.ts +7 -0
  216. package/dist/server/index.d.ts.map +1 -0
  217. package/dist/server/index.js +238 -0
  218. package/dist/server/index.js.map +1 -0
  219. package/dist/server/routes/foundation.d.ts +2 -0
  220. package/dist/server/routes/foundation.d.ts.map +1 -0
  221. package/dist/server/routes/foundation.js +2 -0
  222. package/dist/server/routes/foundation.js.map +1 -0
  223. package/dist/server/routes/search.d.ts +2 -0
  224. package/dist/server/routes/search.d.ts.map +1 -0
  225. package/dist/server/routes/search.js +2 -0
  226. package/dist/server/routes/search.js.map +1 -0
  227. package/dist/server/routes/sessions.d.ts +2 -0
  228. package/dist/server/routes/sessions.d.ts.map +1 -0
  229. package/dist/server/routes/sessions.js +2 -0
  230. package/dist/server/routes/sessions.js.map +1 -0
  231. package/dist/server/routes/waste.d.ts +2 -0
  232. package/dist/server/routes/waste.d.ts.map +1 -0
  233. package/dist/server/routes/waste.js +2 -0
  234. package/dist/server/routes/waste.js.map +1 -0
  235. package/dist/web/assets/index-Ba1VvTj0.js +37 -0
  236. package/dist/web/index.html +12 -0
  237. package/package.json +76 -0
@@ -0,0 +1,44 @@
1
+ import Database from 'better-sqlite3';
2
+ import type { FoundationData, WasteData, SearchData, SessionDetailData, SessionsListData, LiveData, AnalyticsRange, AnalyticsData } from '../schema/index.js';
3
+ export interface SearchFilters {
4
+ q?: string;
5
+ project?: string;
6
+ model?: string;
7
+ platform?: string;
8
+ dateFrom?: string;
9
+ dateTo?: string;
10
+ outcome?: 'any' | 'completed' | 'abandoned';
11
+ limit?: number;
12
+ offset?: number;
13
+ }
14
+ export interface SessionsFilters {
15
+ project?: string;
16
+ model?: string;
17
+ platform?: string;
18
+ dateFrom?: string;
19
+ dateTo?: string;
20
+ outcome?: 'any' | 'completed' | 'abandoned';
21
+ sort?: 'date' | 'cost' | 'duration';
22
+ tag?: string;
23
+ /** Include zero-value "empty" sessions (no tokens, no cost). Default false — they're noise. */
24
+ includeEmpty?: boolean;
25
+ limit?: number;
26
+ offset?: number;
27
+ }
28
+ export declare function search(db: Database.Database, filters: SearchFilters): SearchData;
29
+ export declare function getFoundation(db: Database.Database): FoundationData;
30
+ type WasteRange = '7d' | '30d' | '90d' | 'all';
31
+ export declare function getWaste(db: Database.Database, range: WasteRange): WasteData;
32
+ export declare function getSessionDetail(db: Database.Database, sessionId: string): SessionDetailData | null;
33
+ export declare function getSessions(db: Database.Database, filters: SessionsFilters): SessionsListData;
34
+ /**
35
+ * Sessions whose last message (`endedAt`) landed inside the live window — the
36
+ * sessions an agent is actively writing right now. Reuses getSessions (so name
37
+ * resolution, tags, errorCount all come for free), then filters by recency and
38
+ * re-sorts by most-recent activity. The SSE `session-updated` stream + a short
39
+ * client poll keep this fresh, so a running session climbs in near-real-time.
40
+ */
41
+ export declare function getLiveSessions(db: Database.Database, nowMs: number, windowMinutes?: number): LiveData;
42
+ export declare function getAnalytics(db: Database.Database, range: AnalyticsRange): AnalyticsData;
43
+ export {};
44
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/core/search/index.ts"],"names":[],"mappings":"AAAA,OAAO,QAAQ,MAAM,gBAAgB,CAAA;AACrC,OAAO,KAAK,EACV,cAAc,EACd,SAAS,EAGT,UAAU,EAKV,iBAAiB,EAGjB,gBAAgB,EAChB,QAAQ,EACR,cAAc,EACd,aAAa,EAEd,MAAM,oBAAoB,CAAA;AAG3B,MAAM,WAAW,aAAa;IAC5B,CAAC,CAAC,EAAE,MAAM,CAAA;IACV,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,KAAK,GAAG,WAAW,GAAG,WAAW,CAAA;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,OAAO,CAAC,EAAE,KAAK,GAAG,WAAW,GAAG,WAAW,CAAA;IAC3C,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,UAAU,CAAA;IACnC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,+FAA+F;IAC/F,YAAY,CAAC,EAAE,OAAO,CAAA;IACtB,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,MAAM,CAAC,EAAE,MAAM,CAAA;CAChB;AA2GD,wBAAgB,MAAM,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAE,OAAO,EAAE,aAAa,GAAG,UAAU,CAuKhF;AAID,wBAAgB,aAAa,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,GAAG,cAAc,CAyCnE;AAID,KAAK,UAAU,GAAG,IAAI,GAAG,KAAK,GAAG,KAAK,GAAG,KAAK,CAAA;AAU9C,wBAAgB,QAAQ,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAE,KAAK,EAAE,UAAU,GAAG,SAAS,CAmD5E;AAgCD,wBAAgB,gBAAgB,CAC9B,EAAE,EAAE,QAAQ,CAAC,QAAQ,EACrB,SAAS,EAAE,MAAM,GAChB,iBAAiB,GAAG,IAAI,CA2F1B;AAID,wBAAgB,WAAW,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAE,OAAO,EAAE,eAAe,GAAG,gBAAgB,CAsI7F;AAID;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,aAAa,SAAK,GAAG,QAAQ,CAOlG;AAID,wBAAgB,YAAY,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAE,KAAK,EAAE,cAAc,GAAG,aAAa,CAqLxF"}
@@ -0,0 +1,641 @@
1
+ import { normalizeTag, resolveName, getSessionMeta } from '../tags/index.js';
2
+ /** A session that produced no measurable work: no tokens and no cost. Pure noise in the list
3
+ * (e.g. Cursor globalStorage rows, instantly-abandoned sessions). Hidden by default. */
4
+ const EMPTY_SESSION_SQL = '(total_input_tokens + total_output_tokens + total_cache_read + total_cache_creation = 0 AND total_cost_cents = 0)';
5
+ function loadPricingMap(db) {
6
+ // ASC order so later entries (most recent period_start) overwrite earlier ones
7
+ const rows = db
8
+ .prepare(`SELECT model, input_per_mtok, output_per_mtok, cache_write_per_mtok, cache_read_per_mtok
9
+ FROM pricing ORDER BY period_start ASC`)
10
+ .all();
11
+ const map = new Map();
12
+ for (const row of rows)
13
+ map.set(row.model, row);
14
+ return map;
15
+ }
16
+ function costCents(pricing, inputTokens, outputTokens, cacheCreation, cacheRead) {
17
+ // Grouped arithmetic matches the ingest formula to avoid floating-point divergence
18
+ return (((inputTokens * pricing.input_per_mtok +
19
+ outputTokens * pricing.output_per_mtok +
20
+ cacheCreation * pricing.cache_write_per_mtok +
21
+ cacheRead * pricing.cache_read_per_mtok) /
22
+ 1_000_000) *
23
+ 100);
24
+ }
25
+ // ─── sanitise FTS5 query ─────────────────────────────────────────────────────
26
+ function sanitiseFts(q) {
27
+ const escaped = q.replace(/"/g, '""');
28
+ return `"${escaped}"`;
29
+ }
30
+ // Escape HTML in FTS5 snippet output, then restore only our intentional <mark> delimiters.
31
+ // FTS5 snippet() does not escape source text, so user JSONL may contain HTML/script tags.
32
+ function escapeSnippet(raw) {
33
+ return raw
34
+ .replace(/&/g, '&amp;')
35
+ .replace(/</g, '&lt;')
36
+ .replace(/>/g, '&gt;')
37
+ .replace(/&lt;mark&gt;/g, '<mark>')
38
+ .replace(/&lt;\/mark&gt;/g, '</mark>');
39
+ }
40
+ // ─── search() ────────────────────────────────────────────────────────────────
41
+ // FTS column layout (migration 0011): 0 session_id, 1 message_id, 2 content,
42
+ // 3 tool_inputs, 4 tool_outputs, 5 errors. snippet() takes the absolute column index.
43
+ const FTS_COL_CONTENT = 2;
44
+ const FTS_COL_TOOL_INPUT = 3;
45
+ const FTS_COL_TOOL_OUTPUT = 4;
46
+ // Max hits surfaced per session in the grouped view (the rest roll up into hitCount).
47
+ const HITS_PER_SESSION = 4;
48
+ /** Pick which FTS column actually matched (so we can chip the hit's kind) and return the
49
+ * best snippet for display. A snippet contains a real hit only if it carries a <mark>.
50
+ * Precedence when several columns matched: prose first, then tool input, then tool output. */
51
+ function pickHit(row) {
52
+ const hasMark = (s) => s.includes('<mark>');
53
+ if (hasMark(row.snip_content))
54
+ return { kind: 'text', snippet: escapeSnippet(row.snip_content) };
55
+ if (hasMark(row.snip_input))
56
+ return { kind: 'tool_input', snippet: escapeSnippet(row.snip_input) };
57
+ if (hasMark(row.snip_output))
58
+ return { kind: 'tool_output', snippet: escapeSnippet(row.snip_output) };
59
+ // No mark anywhere (rare — e.g. prefix/column-scoped quirks). Fall back to prose text.
60
+ return { kind: 'text', snippet: row.snip_content ? escapeSnippet(row.snip_content) : '' };
61
+ }
62
+ export function search(db, filters) {
63
+ const rawLimit = filters.limit ?? 20;
64
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 100) : 20;
65
+ const rawOffset = filters.offset ?? 0;
66
+ const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
67
+ const q = filters.q?.trim() ?? '';
68
+ // Build filter clauses (applied after the base query)
69
+ const extraWhere = [];
70
+ const extraParams = [];
71
+ if (filters.project) {
72
+ extraWhere.push('s.project = ?');
73
+ extraParams.push(filters.project);
74
+ }
75
+ if (filters.model) {
76
+ extraWhere.push('first_msg.model = ?');
77
+ extraParams.push(filters.model);
78
+ }
79
+ if (filters.platform) {
80
+ extraWhere.push('s.platform = ?');
81
+ extraParams.push(filters.platform);
82
+ }
83
+ if (filters.dateFrom) {
84
+ extraWhere.push('s.started_at >= ?');
85
+ extraParams.push(filters.dateFrom);
86
+ }
87
+ if (filters.dateTo) {
88
+ extraWhere.push('s.started_at <= ?');
89
+ extraParams.push(filters.dateTo);
90
+ }
91
+ if (filters.outcome && filters.outcome !== 'any') {
92
+ extraWhere.push('s.last_outcome = ?');
93
+ extraParams.push(filters.outcome);
94
+ }
95
+ const whereClause = extraWhere.length > 0 ? ' AND ' + extraWhere.join(' AND ') : '';
96
+ // Shared session columns + name-resolution joins (session_meta drives the display name).
97
+ const sessionCols = `s.project, s.platform, s.started_at, s.last_outcome,
98
+ sm.custom_name, sm.claude_custom_title, sm.claude_ai_title`;
99
+ const sessionJoins = `LEFT JOIN session_meta sm ON sm.session_id = s.id`;
100
+ let baseQuery;
101
+ let countQuery;
102
+ let baseParams;
103
+ if (q) {
104
+ const ftsQuery = sanitiseFts(q);
105
+ // One snippet() per indexed text column so we can tag the hit's kind by which one marked.
106
+ baseQuery = `
107
+ SELECT fts.session_id, fts.message_id,
108
+ snippet(messages_fts, ${FTS_COL_CONTENT}, '<mark>', '</mark>', '...', 10) AS snip_content,
109
+ snippet(messages_fts, ${FTS_COL_TOOL_INPUT}, '<mark>', '</mark>', '...', 10) AS snip_input,
110
+ snippet(messages_fts, ${FTS_COL_TOOL_OUTPUT}, '<mark>', '</mark>', '...', 10) AS snip_output,
111
+ m.idx, first_msg.model, ${sessionCols}
112
+ FROM messages_fts fts
113
+ JOIN messages m ON m.id = fts.message_id
114
+ JOIN sessions s ON s.id = fts.session_id
115
+ ${sessionJoins}
116
+ LEFT JOIN messages first_msg ON first_msg.session_id = s.id AND first_msg.idx = 0
117
+ WHERE messages_fts MATCH ?${whereClause}
118
+ ORDER BY rank
119
+ `;
120
+ countQuery = `
121
+ SELECT COUNT(*) as n
122
+ FROM messages_fts fts
123
+ JOIN messages m ON m.id = fts.message_id
124
+ JOIN sessions s ON s.id = fts.session_id
125
+ LEFT JOIN messages first_msg ON first_msg.session_id = s.id AND first_msg.idx = 0
126
+ WHERE messages_fts MATCH ?${whereClause}
127
+ `;
128
+ baseParams = [ftsQuery, ...extraParams];
129
+ }
130
+ else {
131
+ // No query: list the most recent sessions (one row each, via their first message).
132
+ baseQuery = `
133
+ SELECT s.id AS session_id, m.id AS message_id,
134
+ '' AS snip_content, '' AS snip_input, '' AS snip_output,
135
+ m.idx, m.model, ${sessionCols}
136
+ FROM sessions s
137
+ JOIN messages m ON m.session_id = s.id AND m.idx = 0
138
+ ${sessionJoins}
139
+ WHERE 1=1${whereClause}
140
+ ORDER BY s.started_at DESC
141
+ `;
142
+ countQuery = `
143
+ SELECT COUNT(*) as n
144
+ FROM sessions s
145
+ JOIN messages m ON m.session_id = s.id AND m.idx = 0
146
+ WHERE 1=1${whereClause}
147
+ `;
148
+ baseParams = extraParams;
149
+ }
150
+ const total = db.prepare(countQuery).get(...baseParams).n;
151
+ const paginatedQuery = baseQuery + ' LIMIT ? OFFSET ?';
152
+ const rows = db.prepare(paginatedQuery).all(...baseParams, limit, offset);
153
+ const enriched = rows.map((r) => ({ row: r, ...pickHit(r) }));
154
+ // Flat list — kept for back-compat with callers/tests that read results/total.
155
+ const results = enriched.map(({ row, snippet }) => ({
156
+ sessionId: row.session_id,
157
+ messageId: row.message_id,
158
+ msgIdx: row.idx,
159
+ snippet,
160
+ session: {
161
+ project: row.project,
162
+ startedAt: row.started_at,
163
+ model: row.model,
164
+ },
165
+ }));
166
+ // Grouped view — collapse this page's hits by session, preserving rank order of first
167
+ // appearance. Each group caps its surfaced hits (ordered by msgIdx) but counts them all.
168
+ const groupMap = new Map();
169
+ const order = [];
170
+ for (const { row, kind, snippet } of enriched) {
171
+ let group = groupMap.get(row.session_id);
172
+ if (!group) {
173
+ group = {
174
+ sessionId: row.session_id,
175
+ project: row.project,
176
+ name: resolveName({
177
+ id: row.session_id,
178
+ project: row.project,
179
+ custom_name: row.custom_name,
180
+ claude_custom_title: row.claude_custom_title,
181
+ claude_ai_title: row.claude_ai_title,
182
+ }),
183
+ platform: row.platform,
184
+ startedAt: row.started_at,
185
+ model: row.model,
186
+ lastOutcome: row.last_outcome,
187
+ hitCount: 0,
188
+ hits: [],
189
+ };
190
+ groupMap.set(row.session_id, group);
191
+ order.push(row.session_id);
192
+ }
193
+ group.hitCount += 1;
194
+ group.hits.push({ msgIdx: row.idx, snippet, kind });
195
+ }
196
+ const groups = order.map((id) => {
197
+ const g = groupMap.get(id);
198
+ const hits = [...g.hits].sort((a, b) => a.msgIdx - b.msgIdx).slice(0, HITS_PER_SESSION);
199
+ return { ...g, hits };
200
+ });
201
+ // Distinct sessions across the WHOLE result set (not just this page), so the UI can show
202
+ // "N sessions" even while paginating hits.
203
+ const sessionTotal = q
204
+ ? db
205
+ .prepare(`SELECT COUNT(DISTINCT fts.session_id) AS n
206
+ FROM messages_fts fts
207
+ JOIN messages m ON m.id = fts.message_id
208
+ JOIN sessions s ON s.id = fts.session_id
209
+ LEFT JOIN messages first_msg ON first_msg.session_id = s.id AND first_msg.idx = 0
210
+ WHERE messages_fts MATCH ?${whereClause}`)
211
+ .get(...baseParams).n
212
+ : total;
213
+ return { total, results, sessionTotal, groups };
214
+ }
215
+ // ─── getFoundation() ─────────────────────────────────────────────────────────
216
+ export function getFoundation(db) {
217
+ const { totalCostCents } = db
218
+ .prepare(`SELECT COALESCE(SUM(total_cost_cents), 0) AS totalCostCents
219
+ FROM sessions
220
+ WHERE started_at >= datetime('now', '-7 days')`)
221
+ .get();
222
+ const { sumRead, sumDenom } = db
223
+ .prepare(`SELECT COALESCE(SUM(total_cache_read), 0) AS sumRead,
224
+ COALESCE(SUM(total_input_tokens + total_cache_read), 0) AS sumDenom
225
+ FROM sessions
226
+ WHERE started_at >= datetime('now', '-30 days')`)
227
+ .get();
228
+ const cacheHitRateLast30Days = sumDenom === 0 ? 0 : sumRead / sumDenom;
229
+ const modelRows = db
230
+ .prepare(`SELECT m.model, SUM(u.output_tokens) AS total_output
231
+ FROM messages m JOIN usage u ON u.message_id = m.id
232
+ WHERE m.model IS NOT NULL
233
+ GROUP BY m.model
234
+ ORDER BY total_output DESC`)
235
+ .all();
236
+ const grandTotal = modelRows.reduce((acc, r) => acc + r.total_output, 0);
237
+ const modelMix = modelRows.map((r) => ({
238
+ model: r.model,
239
+ pct: grandTotal === 0 ? 0 : (r.total_output / grandTotal) * 100,
240
+ }));
241
+ return {
242
+ totalCostCentsThisWeek: totalCostCents,
243
+ cacheHitRateLast30Days,
244
+ modelMix,
245
+ };
246
+ }
247
+ function rangeToCutoff(range) {
248
+ if (range === 'all')
249
+ return null;
250
+ const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
251
+ const d = new Date();
252
+ d.setDate(d.getDate() - days);
253
+ return d.toISOString();
254
+ }
255
+ export function getWaste(db, range) {
256
+ const cutoff = rangeToCutoff(range);
257
+ const rows = db
258
+ .prepare(`SELECT wa.session_id, wa.category, wa.amount_cents, wa.anchor_msg_idx, wa.evidence_json,
259
+ s.project, s.started_at
260
+ FROM waste_attributions wa
261
+ JOIN sessions s ON s.id = wa.session_id
262
+ WHERE (? IS NULL OR s.started_at >= ?)
263
+ ORDER BY wa.category, wa.amount_cents DESC`)
264
+ .all(cutoff, cutoff);
265
+ // Group by category
266
+ const categoryMap = new Map();
267
+ for (const row of rows) {
268
+ if (!categoryMap.has(row.category))
269
+ categoryMap.set(row.category, []);
270
+ categoryMap.get(row.category).push({
271
+ sessionId: row.session_id,
272
+ project: row.project,
273
+ startedAt: row.started_at,
274
+ amountCents: row.amount_cents,
275
+ anchorMsgIdx: row.anchor_msg_idx,
276
+ evidenceJson: row.evidence_json,
277
+ });
278
+ }
279
+ const categories = [];
280
+ let totalCents = 0;
281
+ for (const [category, sessions] of categoryMap) {
282
+ const catTotal = sessions.reduce((acc, s) => acc + s.amountCents, 0);
283
+ totalCents += catTotal;
284
+ categories.push({
285
+ category,
286
+ totalCents: catTotal,
287
+ sessionCount: sessions.length,
288
+ sessions,
289
+ });
290
+ }
291
+ return { totalCents, categories };
292
+ }
293
+ export function getSessionDetail(db, sessionId) {
294
+ const sessionRow = db
295
+ .prepare(`SELECT id, project, platform, started_at, ended_at, message_count,
296
+ total_input_tokens, total_output_tokens, total_cache_creation,
297
+ total_cache_read, total_cost_cents, last_outcome
298
+ FROM sessions WHERE id = ?`)
299
+ .get(sessionId);
300
+ if (!sessionRow)
301
+ return null;
302
+ const msgRows = db
303
+ .prepare(`SELECT m.id, m.idx, m.ts, m.role, m.model, m.content_json,
304
+ u.input_tokens, u.output_tokens, u.cache_creation_tokens, u.cache_read_tokens
305
+ FROM messages m
306
+ LEFT JOIN usage u ON u.message_id = m.id
307
+ WHERE m.session_id = ?
308
+ ORDER BY m.idx ASC`)
309
+ .all(sessionId);
310
+ const pricingMap = loadPricingMap(db);
311
+ const messages = [];
312
+ const timeline = [];
313
+ let cumulativeCostCents = 0;
314
+ for (const row of msgRows) {
315
+ const detail = {
316
+ id: row.id,
317
+ idx: row.idx,
318
+ role: row.role,
319
+ ts: row.ts,
320
+ model: row.model,
321
+ contentJson: row.content_json,
322
+ };
323
+ if (row.input_tokens != null)
324
+ detail.inputTokens = row.input_tokens;
325
+ if (row.output_tokens != null)
326
+ detail.outputTokens = row.output_tokens;
327
+ if (row.cache_creation_tokens != null)
328
+ detail.cacheCreationTokens = row.cache_creation_tokens;
329
+ if (row.cache_read_tokens != null)
330
+ detail.cacheReadTokens = row.cache_read_tokens;
331
+ messages.push(detail);
332
+ // Accumulate cost for assistant messages with usage
333
+ if (row.role === 'assistant' && row.model && row.input_tokens != null) {
334
+ const pricing = pricingMap.get(row.model);
335
+ if (pricing) {
336
+ cumulativeCostCents += costCents(pricing, row.input_tokens ?? 0, row.output_tokens ?? 0, row.cache_creation_tokens ?? 0, row.cache_read_tokens ?? 0);
337
+ }
338
+ }
339
+ timeline.push({
340
+ msgIdx: row.idx,
341
+ ts: row.ts,
342
+ cumulativeCostCents,
343
+ });
344
+ }
345
+ const meta = getSessionMeta(db, sessionRow.id);
346
+ return {
347
+ session: {
348
+ id: sessionRow.id,
349
+ project: sessionRow.project,
350
+ platform: sessionRow.platform,
351
+ startedAt: sessionRow.started_at,
352
+ endedAt: sessionRow.ended_at,
353
+ messageCount: sessionRow.message_count,
354
+ totalInputTokens: sessionRow.total_input_tokens,
355
+ totalOutputTokens: sessionRow.total_output_tokens,
356
+ totalCacheCreation: sessionRow.total_cache_creation,
357
+ totalCacheRead: sessionRow.total_cache_read,
358
+ totalCostCents: sessionRow.total_cost_cents,
359
+ lastOutcome: sessionRow.last_outcome,
360
+ name: meta.name,
361
+ customName: meta.customName,
362
+ tags: meta.tags,
363
+ },
364
+ messages,
365
+ timeline,
366
+ // Default empty; the session-detail route overrides this with getDecisions() (F3).
367
+ decisions: [],
368
+ };
369
+ }
370
+ // ─── getSessions() ────────────────────────────────────────────────────────────
371
+ export function getSessions(db, filters) {
372
+ const rawLimit = filters.limit ?? 50;
373
+ const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, 200) : 50;
374
+ const rawOffset = filters.offset ?? 0;
375
+ const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0;
376
+ const validSorts = {
377
+ date: 'started_at DESC',
378
+ cost: 'total_cost_cents DESC',
379
+ duration: '(CASE WHEN started_at IS NOT NULL AND ended_at IS NOT NULL THEN (julianday(ended_at) - julianday(started_at)) * 86400000 ELSE NULL END) DESC NULLS LAST',
380
+ };
381
+ const sortKey = filters.sort && Object.hasOwn(validSorts, filters.sort) ? filters.sort : 'date';
382
+ const orderClause = validSorts[sortKey];
383
+ const where = [];
384
+ const params = [];
385
+ if (filters.project) {
386
+ where.push('project = ?');
387
+ params.push(filters.project);
388
+ }
389
+ if (filters.model) {
390
+ where.push('EXISTS (SELECT 1 FROM messages WHERE session_id = sessions.id AND model = ?)');
391
+ params.push(filters.model);
392
+ }
393
+ if (filters.platform) {
394
+ where.push('platform = ?');
395
+ params.push(filters.platform);
396
+ }
397
+ if (filters.dateFrom) {
398
+ where.push('started_at >= ?');
399
+ params.push(filters.dateFrom);
400
+ }
401
+ if (filters.dateTo) {
402
+ where.push('started_at <= ?');
403
+ params.push(filters.dateTo);
404
+ }
405
+ if (filters.outcome && filters.outcome !== 'any') {
406
+ where.push('last_outcome = ?');
407
+ params.push(filters.outcome);
408
+ }
409
+ if (filters.tag) {
410
+ where.push('EXISTS (SELECT 1 FROM session_tags st WHERE st.session_id = sessions.id AND st.tag = ?)');
411
+ params.push(normalizeTag(filters.tag));
412
+ }
413
+ // How many sessions (under the OTHER filters) are zero-value "empty" — surfaced so the UI can
414
+ // say "N hidden" with a toggle. Computed before the empty-exclusion is added to the where.
415
+ const hiddenEmptyCount = filters.includeEmpty
416
+ ? 0
417
+ : db.prepare(`SELECT COUNT(*) as n FROM sessions ${where.length ? 'WHERE ' + where.join(' AND ') + ' AND ' : 'WHERE '}${EMPTY_SESSION_SQL}`).get(...params).n;
418
+ // Hide zero-value sessions by default.
419
+ if (!filters.includeEmpty)
420
+ where.push(`NOT ${EMPTY_SESSION_SQL}`);
421
+ const whereClause = where.length > 0 ? 'WHERE ' + where.join(' AND ') : '';
422
+ const total = db.prepare(`SELECT COUNT(*) as n FROM sessions ${whereClause}`).get(...params).n;
423
+ const rows = db
424
+ .prepare(`SELECT sessions.id, project, platform, started_at, ended_at, message_count,
425
+ total_cost_cents, total_input_tokens, total_output_tokens,
426
+ total_cache_read, last_outcome,
427
+ m.custom_name AS custom_name,
428
+ m.claude_custom_title AS claude_custom_title,
429
+ m.claude_ai_title AS claude_ai_title,
430
+ CASE WHEN started_at IS NOT NULL AND ended_at IS NOT NULL
431
+ THEN CAST((julianday(ended_at) - julianday(started_at)) * 86400000 AS INTEGER)
432
+ ELSE NULL END AS duration_ms,
433
+ (SELECT COUNT(*) FROM tool_calls tc
434
+ JOIN messages msg ON msg.id = tc.message_id
435
+ WHERE msg.session_id = sessions.id AND tc.error IS NOT NULL) AS error_count
436
+ FROM sessions LEFT JOIN session_meta m ON m.session_id = sessions.id ${whereClause}
437
+ ORDER BY ${orderClause}
438
+ LIMIT ? OFFSET ?`)
439
+ .all(...params, limit, offset);
440
+ // Batch-fetch tags for this page (no N+1)
441
+ const tagsBySession = new Map();
442
+ if (rows.length > 0) {
443
+ const placeholders = rows.map(() => '?').join(',');
444
+ const tagRows = db
445
+ .prepare(`SELECT session_id, tag FROM session_tags WHERE session_id IN (${placeholders}) ORDER BY tag`)
446
+ .all(...rows.map((r) => r.id));
447
+ for (const tr of tagRows) {
448
+ const list = tagsBySession.get(tr.session_id) ?? [];
449
+ list.push(tr.tag);
450
+ tagsBySession.set(tr.session_id, list);
451
+ }
452
+ }
453
+ return {
454
+ total,
455
+ hiddenEmptyCount,
456
+ sessions: rows.map((r) => ({
457
+ id: r.id,
458
+ project: r.project,
459
+ platform: r.platform,
460
+ startedAt: r.started_at,
461
+ endedAt: r.ended_at,
462
+ messageCount: r.message_count,
463
+ totalCostCents: r.total_cost_cents,
464
+ totalInputTokens: r.total_input_tokens,
465
+ totalOutputTokens: r.total_output_tokens,
466
+ totalCacheRead: r.total_cache_read,
467
+ lastOutcome: r.last_outcome,
468
+ durationMs: r.duration_ms,
469
+ errorCount: r.error_count,
470
+ name: resolveName(r),
471
+ tags: tagsBySession.get(r.id) ?? [],
472
+ })),
473
+ };
474
+ }
475
+ // ─── getLiveSessions() ────────────────────────────────────────────────────────
476
+ /**
477
+ * Sessions whose last message (`endedAt`) landed inside the live window — the
478
+ * sessions an agent is actively writing right now. Reuses getSessions (so name
479
+ * resolution, tags, errorCount all come for free), then filters by recency and
480
+ * re-sorts by most-recent activity. The SSE `session-updated` stream + a short
481
+ * client poll keep this fresh, so a running session climbs in near-real-time.
482
+ */
483
+ export function getLiveSessions(db, nowMs, windowMinutes = 15) {
484
+ const cutoffIso = new Date(nowMs - windowMinutes * 60_000).toISOString();
485
+ const { sessions } = getSessions(db, { sort: 'date', limit: 200 });
486
+ const live = sessions
487
+ .filter((s) => s.endedAt != null && s.endedAt >= cutoffIso)
488
+ .sort((a, b) => (b.endedAt ?? '').localeCompare(a.endedAt ?? ''));
489
+ return { now: nowMs, windowMinutes, sessions: live };
490
+ }
491
+ // ─── getAnalytics() ───────────────────────────────────────────────────────────
492
+ export function getAnalytics(db, range) {
493
+ const cutoff = range === 'all'
494
+ ? null
495
+ : (() => {
496
+ const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
497
+ const d = new Date();
498
+ d.setDate(d.getDate() - days);
499
+ return d.toISOString();
500
+ })();
501
+ const cutoffClause = cutoff ? 'WHERE started_at >= ?' : '';
502
+ const cutoffParam = cutoff ? [cutoff] : [];
503
+ const dailyCutoffClause = cutoff
504
+ ? 'WHERE started_at IS NOT NULL AND started_at >= ?'
505
+ : 'WHERE started_at IS NOT NULL';
506
+ const dailyRows = db
507
+ .prepare(`SELECT strftime('%Y-%m-%d', started_at) AS date,
508
+ SUM(total_cost_cents) AS costCents,
509
+ COUNT(*) AS sessionCount
510
+ FROM sessions ${dailyCutoffClause}
511
+ GROUP BY strftime('%Y-%m-%d', started_at)
512
+ ORDER BY date ASC`)
513
+ .all(...cutoffParam);
514
+ const projectRows = db
515
+ .prepare(`SELECT project,
516
+ SUM(total_cost_cents) AS costCents,
517
+ COUNT(*) AS sessionCount
518
+ FROM sessions ${cutoffClause}
519
+ GROUP BY project
520
+ ORDER BY costCents DESC
521
+ LIMIT 10`)
522
+ .all(...cutoffParam);
523
+ // modelCost: join messages + usage, filter by session start date
524
+ const sessionJoin = cutoff
525
+ ? 'JOIN sessions s ON s.id = m.session_id WHERE s.started_at >= ? AND m.model IS NOT NULL'
526
+ : 'JOIN sessions s ON s.id = m.session_id WHERE m.model IS NOT NULL';
527
+ const modelRows = db
528
+ .prepare(`SELECT m.model,
529
+ SUM(u.output_tokens) AS outputTokens,
530
+ SUM(u.input_tokens) AS inputTokens,
531
+ SUM(u.cache_creation_tokens) AS cacheCreation,
532
+ SUM(u.cache_read_tokens) AS cacheRead
533
+ FROM messages m
534
+ JOIN usage u ON u.message_id = m.id
535
+ ${sessionJoin}
536
+ GROUP BY m.model
537
+ ORDER BY outputTokens DESC
538
+ LIMIT 10`)
539
+ .all(...cutoffParam);
540
+ const pricingMap = loadPricingMap(db);
541
+ const modelCost = modelRows.map((r) => {
542
+ const pricing = pricingMap.get(r.model);
543
+ const cost = pricing
544
+ ? costCents(pricing, r.inputTokens, r.outputTokens, r.cacheCreation, r.cacheRead)
545
+ : 0;
546
+ return { model: r.model, costCents: cost, outputTokens: r.outputTokens };
547
+ });
548
+ // dailyTokens: tokens (in+out) per calendar day — tokens are the truer effort signal
549
+ // than dollars, which read $0 on most sessions.
550
+ const dailyTokenRows = db
551
+ .prepare(`SELECT strftime('%Y-%m-%d', started_at) AS date,
552
+ SUM(total_input_tokens + total_output_tokens) AS tokens
553
+ FROM sessions ${dailyCutoffClause}
554
+ GROUP BY strftime('%Y-%m-%d', started_at)
555
+ ORDER BY date ASC`)
556
+ .all(...cutoffParam);
557
+ // summary: headline stats across the range.
558
+ const summaryRow = db
559
+ .prepare(`SELECT COUNT(*) AS totalSessions,
560
+ SUM(total_input_tokens + total_output_tokens) AS totalTokens,
561
+ COUNT(DISTINCT strftime('%Y-%m-%d', started_at)) AS activeDays
562
+ FROM sessions ${dailyCutoffClause}`)
563
+ .get(...cutoffParam);
564
+ // activityClock: day-of-week (%w → 0=Sun..6=Sat) × hour (%H), in LOCAL time, so the
565
+ // grid reflects when the user actually worked. Error counts come from tool_calls.
566
+ const clockRows = db
567
+ .prepare(`SELECT CAST(strftime('%w', started_at, 'localtime') AS INTEGER) AS dayOfWeek,
568
+ CAST(strftime('%H', started_at, 'localtime') AS INTEGER) AS hour,
569
+ COUNT(*) AS sessionCount,
570
+ SUM(total_input_tokens + total_output_tokens) AS tokens
571
+ FROM sessions ${dailyCutoffClause}
572
+ GROUP BY dayOfWeek, hour`)
573
+ .all(...cutoffParam);
574
+ // Error tool-calls per bucket — joined through messages→sessions, anchored on the
575
+ // session's local start hour/day so the tint aligns with the same grid cell.
576
+ const errorRows = db
577
+ .prepare(`SELECT CAST(strftime('%w', s.started_at, 'localtime') AS INTEGER) AS dayOfWeek,
578
+ CAST(strftime('%H', s.started_at, 'localtime') AS INTEGER) AS hour,
579
+ COUNT(*) AS errorCount
580
+ FROM tool_calls tc
581
+ JOIN messages m ON m.id = tc.message_id
582
+ JOIN sessions s ON s.id = m.session_id
583
+ WHERE tc.error IS NOT NULL AND s.started_at IS NOT NULL
584
+ ${cutoff ? 'AND s.started_at >= ?' : ''}
585
+ GROUP BY dayOfWeek, hour`)
586
+ .all(...cutoffParam);
587
+ const errorByCell = new Map();
588
+ for (const e of errorRows)
589
+ errorByCell.set(e.dayOfWeek * 24 + e.hour, e.errorCount);
590
+ let maxSessions = 0;
591
+ let maxTokens = 0;
592
+ let busiestHour = null;
593
+ let busiestHourCount = -1;
594
+ const hourTotals = new Array(24).fill(0);
595
+ const cells = clockRows.map((r) => {
596
+ const tokens = r.tokens ?? 0;
597
+ if (r.sessionCount > maxSessions)
598
+ maxSessions = r.sessionCount;
599
+ if (tokens > maxTokens)
600
+ maxTokens = tokens;
601
+ hourTotals[r.hour] += r.sessionCount;
602
+ return {
603
+ dayOfWeek: r.dayOfWeek,
604
+ hour: r.hour,
605
+ sessionCount: r.sessionCount,
606
+ tokens,
607
+ errorCount: errorByCell.get(r.dayOfWeek * 24 + r.hour) ?? 0,
608
+ };
609
+ });
610
+ for (let h = 0; h < 24; h++) {
611
+ if (hourTotals[h] > busiestHourCount) {
612
+ busiestHourCount = hourTotals[h];
613
+ busiestHour = hourTotals[h] > 0 ? h : busiestHour;
614
+ }
615
+ }
616
+ if (busiestHourCount <= 0)
617
+ busiestHour = null;
618
+ return {
619
+ range,
620
+ dailyCost: dailyRows.map((r) => ({
621
+ date: r.date,
622
+ costCents: r.costCents,
623
+ sessionCount: r.sessionCount,
624
+ })),
625
+ projectCost: projectRows.map((r) => ({
626
+ project: r.project,
627
+ costCents: r.costCents,
628
+ sessionCount: r.sessionCount,
629
+ })),
630
+ modelCost,
631
+ dailyTokens: dailyTokenRows.map((r) => ({ date: r.date, tokens: r.tokens ?? 0 })),
632
+ summary: {
633
+ totalSessions: summaryRow.totalSessions,
634
+ totalTokens: summaryRow.totalTokens ?? 0,
635
+ activeDays: summaryRow.activeDays,
636
+ busiestHour,
637
+ },
638
+ activityClock: { cells, maxSessions, maxTokens },
639
+ };
640
+ }
641
+ //# sourceMappingURL=index.js.map