@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.
- package/README.md +69 -0
- package/data/common-kit.json +34 -0
- package/data/pricing.json +119 -0
- package/data/schema/AnalyticsData.json +214 -0
- package/data/schema/FoundationData.json +42 -0
- package/data/schema/KitData.json +321 -0
- package/data/schema/SearchData.json +173 -0
- package/data/schema/SessionDetailData.json +231 -0
- package/data/schema/SessionsListData.json +115 -0
- package/data/schema/WasteData.json +95 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +343 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/billing/index.d.ts +4 -0
- package/dist/core/billing/index.d.ts.map +1 -0
- package/dist/core/billing/index.js +43 -0
- package/dist/core/billing/index.js.map +1 -0
- package/dist/core/budget/index.d.ts +30 -0
- package/dist/core/budget/index.d.ts.map +1 -0
- package/dist/core/budget/index.js +61 -0
- package/dist/core/budget/index.js.map +1 -0
- package/dist/core/codex/index.d.ts +3 -0
- package/dist/core/codex/index.d.ts.map +1 -0
- package/dist/core/codex/index.js +212 -0
- package/dist/core/codex/index.js.map +1 -0
- package/dist/core/cursor/index.d.ts +3 -0
- package/dist/core/cursor/index.d.ts.map +1 -0
- package/dist/core/cursor/index.js +304 -0
- package/dist/core/cursor/index.js.map +1 -0
- package/dist/core/cursor/scan.d.ts +28 -0
- package/dist/core/cursor/scan.d.ts.map +1 -0
- package/dist/core/cursor/scan.js +79 -0
- package/dist/core/cursor/scan.js.map +1 -0
- package/dist/core/decisions/index.d.ts +21 -0
- package/dist/core/decisions/index.d.ts.map +1 -0
- package/dist/core/decisions/index.js +133 -0
- package/dist/core/decisions/index.js.map +1 -0
- package/dist/core/dlp/index.d.ts +7 -0
- package/dist/core/dlp/index.d.ts.map +1 -0
- package/dist/core/dlp/index.js +115 -0
- package/dist/core/dlp/index.js.map +1 -0
- package/dist/core/health/index.d.ts +10 -0
- package/dist/core/health/index.d.ts.map +1 -0
- package/dist/core/health/index.js +21 -0
- package/dist/core/health/index.js.map +1 -0
- package/dist/core/index/index.d.ts +9 -0
- package/dist/core/index/index.d.ts.map +1 -0
- package/dist/core/index/index.js +103 -0
- package/dist/core/index/index.js.map +1 -0
- package/dist/core/index/ingest.d.ts +16 -0
- package/dist/core/index/ingest.d.ts.map +1 -0
- package/dist/core/index/ingest.js +184 -0
- package/dist/core/index/ingest.js.map +1 -0
- package/dist/core/index/platforms.d.ts +21 -0
- package/dist/core/index/platforms.d.ts.map +1 -0
- package/dist/core/index/platforms.js +44 -0
- package/dist/core/index/platforms.js.map +1 -0
- package/dist/core/index/pricing-map.d.ts +10 -0
- package/dist/core/index/pricing-map.d.ts.map +1 -0
- package/dist/core/index/pricing-map.js +16 -0
- package/dist/core/index/pricing-map.js.map +1 -0
- package/dist/core/index/progress.d.ts +26 -0
- package/dist/core/index/progress.d.ts.map +1 -0
- package/dist/core/index/progress.js +59 -0
- package/dist/core/index/progress.js.map +1 -0
- package/dist/core/index/scan.d.ts +9 -0
- package/dist/core/index/scan.d.ts.map +1 -0
- package/dist/core/index/scan.js +42 -0
- package/dist/core/index/scan.js.map +1 -0
- package/dist/core/index/statements.d.ts +18 -0
- package/dist/core/index/statements.d.ts.map +1 -0
- package/dist/core/index/statements.js +82 -0
- package/dist/core/index/statements.js.map +1 -0
- package/dist/core/index/tail.d.ts +42 -0
- package/dist/core/index/tail.d.ts.map +1 -0
- package/dist/core/index/tail.js +123 -0
- package/dist/core/index/tail.js.map +1 -0
- package/dist/core/jsonl/index.d.ts +3 -0
- package/dist/core/jsonl/index.d.ts.map +1 -0
- package/dist/core/jsonl/index.js +106 -0
- package/dist/core/jsonl/index.js.map +1 -0
- package/dist/core/jsonl-validate/index.d.ts +10 -0
- package/dist/core/jsonl-validate/index.d.ts.map +1 -0
- package/dist/core/jsonl-validate/index.js +40 -0
- package/dist/core/jsonl-validate/index.js.map +1 -0
- package/dist/core/kit/detection.d.ts +94 -0
- package/dist/core/kit/detection.d.ts.map +1 -0
- package/dist/core/kit/detection.js +312 -0
- package/dist/core/kit/detection.js.map +1 -0
- package/dist/core/kit/index.d.ts +12 -0
- package/dist/core/kit/index.d.ts.map +1 -0
- package/dist/core/kit/index.js +131 -0
- package/dist/core/kit/index.js.map +1 -0
- package/dist/core/kit/scan.d.ts +28 -0
- package/dist/core/kit/scan.d.ts.map +1 -0
- package/dist/core/kit/scan.js +432 -0
- package/dist/core/kit/scan.js.map +1 -0
- package/dist/core/recap/index.d.ts +18 -0
- package/dist/core/recap/index.d.ts.map +1 -0
- package/dist/core/recap/index.js +88 -0
- package/dist/core/recap/index.js.map +1 -0
- package/dist/core/repetition/index.d.ts +5 -0
- package/dist/core/repetition/index.d.ts.map +1 -0
- package/dist/core/repetition/index.js +0 -0
- package/dist/core/repetition/index.js.map +1 -0
- package/dist/core/schema/index.d.ts +514 -0
- package/dist/core/schema/index.d.ts.map +1 -0
- package/dist/core/schema/index.js +2 -0
- package/dist/core/schema/index.js.map +1 -0
- package/dist/core/schema-watch/index.d.ts +34 -0
- package/dist/core/schema-watch/index.d.ts.map +1 -0
- package/dist/core/schema-watch/index.js +87 -0
- package/dist/core/schema-watch/index.js.map +1 -0
- package/dist/core/search/index.d.ts +44 -0
- package/dist/core/search/index.d.ts.map +1 -0
- package/dist/core/search/index.js +641 -0
- package/dist/core/search/index.js.map +1 -0
- package/dist/core/settings/index.d.ts +24 -0
- package/dist/core/settings/index.d.ts.map +1 -0
- package/dist/core/settings/index.js +76 -0
- package/dist/core/settings/index.js.map +1 -0
- package/dist/core/share/bundle.d.ts +40 -0
- package/dist/core/share/bundle.d.ts.map +1 -0
- package/dist/core/share/bundle.js +158 -0
- package/dist/core/share/bundle.js.map +1 -0
- package/dist/core/share/canonical/from-claude.d.ts +13 -0
- package/dist/core/share/canonical/from-claude.d.ts.map +1 -0
- package/dist/core/share/canonical/from-claude.js +129 -0
- package/dist/core/share/canonical/from-claude.js.map +1 -0
- package/dist/core/share/canonical/from-codex.d.ts +18 -0
- package/dist/core/share/canonical/from-codex.d.ts.map +1 -0
- package/dist/core/share/canonical/from-codex.js +183 -0
- package/dist/core/share/canonical/from-codex.js.map +1 -0
- package/dist/core/share/canonical/to-claude.d.ts +15 -0
- package/dist/core/share/canonical/to-claude.d.ts.map +1 -0
- package/dist/core/share/canonical/to-claude.js +146 -0
- package/dist/core/share/canonical/to-claude.js.map +1 -0
- package/dist/core/share/canonical/to-codex.d.ts +21 -0
- package/dist/core/share/canonical/to-codex.d.ts.map +1 -0
- package/dist/core/share/canonical/to-codex.js +124 -0
- package/dist/core/share/canonical/to-codex.js.map +1 -0
- package/dist/core/share/canonical/tool-map.d.ts +61 -0
- package/dist/core/share/canonical/tool-map.d.ts.map +1 -0
- package/dist/core/share/canonical/tool-map.js +299 -0
- package/dist/core/share/canonical/tool-map.js.map +1 -0
- package/dist/core/share/canonical/types.d.ts +57 -0
- package/dist/core/share/canonical/types.d.ts.map +1 -0
- package/dist/core/share/canonical/types.js +9 -0
- package/dist/core/share/canonical/types.js.map +1 -0
- package/dist/core/share/import.d.ts +28 -0
- package/dist/core/share/import.d.ts.map +1 -0
- package/dist/core/share/import.js +174 -0
- package/dist/core/share/import.js.map +1 -0
- package/dist/core/share/manifest.d.ts +37 -0
- package/dist/core/share/manifest.d.ts.map +1 -0
- package/dist/core/share/manifest.js +31 -0
- package/dist/core/share/manifest.js.map +1 -0
- package/dist/core/share/preview.d.ts +4 -0
- package/dist/core/share/preview.d.ts.map +1 -0
- package/dist/core/share/preview.js +153 -0
- package/dist/core/share/preview.js.map +1 -0
- package/dist/core/share/primer.d.ts +19 -0
- package/dist/core/share/primer.d.ts.map +1 -0
- package/dist/core/share/primer.js +196 -0
- package/dist/core/share/primer.js.map +1 -0
- package/dist/core/share/resume.d.ts +10 -0
- package/dist/core/share/resume.d.ts.map +1 -0
- package/dist/core/share/resume.js +58 -0
- package/dist/core/share/resume.js.map +1 -0
- package/dist/core/share/scrub.d.ts +10 -0
- package/dist/core/share/scrub.d.ts.map +1 -0
- package/dist/core/share/scrub.js +198 -0
- package/dist/core/share/scrub.js.map +1 -0
- package/dist/core/share/tar.d.ts +12 -0
- package/dist/core/share/tar.d.ts.map +1 -0
- package/dist/core/share/tar.js +78 -0
- package/dist/core/share/tar.js.map +1 -0
- package/dist/core/tags/index.d.ts +48 -0
- package/dist/core/tags/index.d.ts.map +1 -0
- package/dist/core/tags/index.js +113 -0
- package/dist/core/tags/index.js.map +1 -0
- package/dist/core/today/index.d.ts +25 -0
- package/dist/core/today/index.d.ts.map +1 -0
- package/dist/core/today/index.js +42 -0
- package/dist/core/today/index.js.map +1 -0
- package/dist/core/waste/abandoned.d.ts +12 -0
- package/dist/core/waste/abandoned.d.ts.map +1 -0
- package/dist/core/waste/abandoned.js +127 -0
- package/dist/core/waste/abandoned.js.map +1 -0
- package/dist/core/waste/cache-miss.d.ts +9 -0
- package/dist/core/waste/cache-miss.d.ts.map +1 -0
- package/dist/core/waste/cache-miss.js +84 -0
- package/dist/core/waste/cache-miss.js.map +1 -0
- package/dist/core/waste/index.d.ts +12 -0
- package/dist/core/waste/index.d.ts.map +1 -0
- package/dist/core/waste/index.js +45 -0
- package/dist/core/waste/index.js.map +1 -0
- package/dist/core/waste/wrong-model.d.ts +11 -0
- package/dist/core/waste/wrong-model.d.ts.map +1 -0
- package/dist/core/waste/wrong-model.js +104 -0
- package/dist/core/waste/wrong-model.js.map +1 -0
- package/dist/db/index.d.ts +10 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +29 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/migrate.d.ts +8 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +282 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/server/events.d.ts +4 -0
- package/dist/server/events.d.ts.map +1 -0
- package/dist/server/events.js +54 -0
- package/dist/server/events.js.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +238 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/routes/foundation.d.ts +2 -0
- package/dist/server/routes/foundation.d.ts.map +1 -0
- package/dist/server/routes/foundation.js +2 -0
- package/dist/server/routes/foundation.js.map +1 -0
- package/dist/server/routes/search.d.ts +2 -0
- package/dist/server/routes/search.d.ts.map +1 -0
- package/dist/server/routes/search.js +2 -0
- package/dist/server/routes/search.js.map +1 -0
- package/dist/server/routes/sessions.d.ts +2 -0
- package/dist/server/routes/sessions.d.ts.map +1 -0
- package/dist/server/routes/sessions.js +2 -0
- package/dist/server/routes/sessions.js.map +1 -0
- package/dist/server/routes/waste.d.ts +2 -0
- package/dist/server/routes/waste.d.ts.map +1 -0
- package/dist/server/routes/waste.js +2 -0
- package/dist/server/routes/waste.js.map +1 -0
- package/dist/web/assets/index-Ba1VvTj0.js +37 -0
- package/dist/web/index.html +12 -0
- 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, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/<mark>/g, '<mark>')
|
|
38
|
+
.replace(/<\/mark>/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
|