agentboss 0.1.0 → 0.1.2

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.
@@ -1,581 +1,574 @@
1
- /**
2
- * Report builder for Agent Boss API responses.
3
- *
4
- * Assembles structured report payloads by fetching v2 dimension scores,
5
- * daily summaries, and session lists from boss.db.
6
- *
7
- * @author Felix
8
- */
9
-
10
- const {
11
- queryAll,
12
- getSessionsByDateRange,
13
- getSessionById,
14
- getAnalysisBySession,
15
- getDailySummaries,
16
- getAnalysisState,
17
- getOverviewTopProjects,
18
- } = require('../db/queries');
19
- const { mapTopProjects } = require('../utils/project');
20
-
21
- // ---------------------------------------------------------------------------
22
- // Date helpers
23
- // ---------------------------------------------------------------------------
24
-
25
- /**
26
- * Format a Date as YYYY-MM-DD.
27
- * @param {Date} d
28
- * @returns {string}
29
- */
30
- function _fmt(d) {
31
- const y = d.getFullYear();
32
- const m = String(d.getMonth() + 1).padStart(2, '0');
33
- const day = String(d.getDate()).padStart(2, '0');
34
- return `${y}-${m}-${day}`;
35
- }
36
-
37
- /**
38
- * Get yesterday's date as YYYY-MM-DD.
39
- * @returns {string}
40
- */
41
- function _yesterday() {
42
- const d = new Date();
43
- d.setDate(d.getDate() - 1);
44
- return _fmt(d);
45
- }
46
-
47
- /**
48
- * Get the Monday of the current ISO week (or the week containing the given
49
- * date).
50
- * @param {Date} [d]
51
- * @returns {string}
52
- */
53
- function _mondayOf(d) {
54
- const dt = d ? new Date(d) : new Date();
55
- const day = dt.getDay(); // 0=Sun … 6=Sat
56
- const diff = day === 0 ? 6 : day - 1;
57
- dt.setDate(dt.getDate() - diff);
58
- return _fmt(dt);
59
- }
60
-
61
- /**
62
- * Get the date N days ago as YYYY-MM-DD.
63
- * @param {number} n
64
- * @returns {string}
65
- */
66
- function _daysAgo(n) {
67
- const d = new Date();
68
- d.setDate(d.getDate() - n);
69
- return _fmt(d);
70
- }
71
-
72
- // ---------------------------------------------------------------------------
73
- // Shared helpers
74
- // ---------------------------------------------------------------------------
75
-
76
- /**
77
- * Compute current v2 dimensions (H1/H2/E1/E2/O1 averaged across the
78
- * date range; H3 from the rolling aggregator over the same window).
79
- *
80
- * @param {object} db
81
- * @param {string} fromDate
82
- * @param {string} toDate
83
- * @returns {{ H1:number|null, H2:number|null, H3:number|null, E1:number|null, E2:number|null, O1:number|null }}
84
- */
85
- function getCurrentDimensionsV2(db, fromDate, toDate) {
86
- const sessions = getSessionsByDateRange(db, fromDate, toDate, undefined, 10000, 0);
87
-
88
- const acc = { H1: { s: 0, n: 0 }, H2: { s: 0, n: 0 }, E1: { s: 0, n: 0 }, E2: { s: 0, n: 0 }, O1: { s: 0, n: 0 } };
89
-
90
- for (const s of sessions) {
91
- const a = getAnalysisBySession(db, s.id);
92
- if (!a || a.status !== 'done') continue;
93
- for (const [k, col] of [['H1','score_h1'],['H2','score_h2'],['E1','score_e1'],['E2','score_e2'],['O1','score_o1']]) {
94
- if (a[col] != null) { acc[k].s += a[col]; acc[k].n++; }
95
- }
96
- }
97
-
98
- const result = {};
99
- for (const [k, v] of Object.entries(acc)) {
100
- result[k] = v.n > 0 ? Math.round((v.s / v.n) * 10) / 10 : null;
101
- }
102
-
103
- // H3 is a rolling aggregate — compute on demand for this window.
104
- let h3 = null;
105
- try {
106
- const { analyzeRange } = require('./dimensions/system-thinking');
107
- const r = analyzeRange(db, fromDate, toDate);
108
- h3 = r.score;
109
- } catch (_) { h3 = null; }
110
- result.H3 = h3;
111
-
112
- // ENV = "AI 能力环境诊断" = average of E1 (knowledge) and E2 (tools).
113
- // Only computed when at least one side is present.
114
- if (result.E1 != null && result.E2 != null) {
115
- result.ENV = Math.round(((result.E1 + result.E2) / 2) * 10) / 10;
116
- } else if (result.E1 != null) {
117
- result.ENV = result.E1;
118
- } else if (result.E2 != null) {
119
- result.ENV = result.E2;
120
- } else {
121
- result.ENV = null;
122
- }
123
-
124
- return result;
125
- }
126
-
127
- /**
128
- * Combine E1 + E2 into the v2.1 "AI 能力环境诊断" composite for a single
129
- * session. Returns null if both inputs are null.
130
- *
131
- * @param {number|null} e1
132
- * @param {number|null} e2
133
- * @returns {number|null}
134
- */
135
- function envScore(e1, e2) {
136
- if (e1 == null && e2 == null) return null;
137
- if (e1 == null) return e2;
138
- if (e2 == null) return e1;
139
- return Math.round(((e1 + e2) / 2) * 10) / 10;
140
- }
141
-
142
- /**
143
- * Map a 0-100 score to the same L1-L4 buckets the rest of v2 uses
144
- * (≥85 L4 · ≥65 L3 · ≥40 L2 · else L1). Mirrors thresholds-v2#scoreToLevel
145
- * duplicated here to avoid pulling a server/analysis dep into the
146
- * report builder.
147
- *
148
- * @param {number|null} score
149
- * @returns {1|2|3|4|null}
150
- */
151
- function levelFromScore(score) {
152
- if (score == null) return null;
153
- if (score >= 85) return 4;
154
- if (score >= 65) return 3;
155
- if (score >= 40) return 2;
156
- return 1;
157
- }
158
-
159
- /**
160
- * Build stats summary from daily_summary row(s) or compute from sessions.
161
- * @param {object} db
162
- * @param {string} fromDate
163
- * @param {string} toDate
164
- * @returns {object}
165
- */
166
- function buildStats(db, fromDate, toDate) {
167
- const summaries = getDailySummaries(db, fromDate, toDate);
168
-
169
- let sessions = 0;
170
- let cost = 0;
171
- let activeMinutes = 0;
172
- let totalTokens = 0;
173
- let errors = 0;
174
-
175
- for (const s of summaries) {
176
- sessions += s.session_count || 0;
177
- cost += s.cost_usd || 0;
178
- activeMinutes += s.active_minutes || 0;
179
- totalTokens += (s.tokens_input || 0) + (s.tokens_output || 0) + (s.tokens_reasoning || 0);
180
- errors += s.error_count || 0;
181
- }
182
-
183
- return {
184
- sessions,
185
- cost: Math.round(cost * 100) / 100,
186
- activeMinutes,
187
- totalTokens,
188
- errors,
189
- avgCost: sessions > 0 ? Math.round((cost / sessions) * 100) / 100 : 0,
190
- avgActiveMinutes: sessions > 0 ? Math.round(activeMinutes / sessions) : 0,
191
- avgTokens: sessions > 0 ? Math.round(totalTokens / sessions) : 0,
192
- avgErrors: sessions > 0 ? Math.round((errors / sessions) * 100) / 100 : 0,
193
- };
194
- }
195
-
196
- /**
197
- * Build session list with per-session scores and metadata.
198
- *
199
- * Subagents (parent_session_id IS NOT NULL) are skipped here so the
200
- * "会话列表" UI shows top-level work only. Aggregate stats on the
201
- * same page (stats, dimensions, daily_summary, collab bill) are
202
- * computed by other queries that still include them — see the comment
203
- * on the parent_session_id column in schema.js for the rationale.
204
- *
205
- * @param {object} db
206
- * @param {string} fromDate
207
- * @param {string} toDate
208
- * @returns {object[]}
209
- */
210
- function buildSessionList(db, fromDate, toDate) {
211
- const sessions = getSessionsByDateRange(db, fromDate, toDate, undefined, 10000, 0);
212
- const list = [];
213
-
214
- for (const s of sessions) {
215
- if (s.parent_session_id) continue; // skip subagents
216
- const analysis = getAnalysisBySession(db, s.id);
217
- list.push({
218
- id: s.id,
219
- title: s.title || '(untitled)',
220
- source: s.source,
221
- // date/startedAt let the UI group sessions by day (weekly report)
222
- date: s.date,
223
- startedAt: s.started_at,
224
- cost: Math.round((s.cost_usd || 0) * 100) / 100,
225
- duration: s.duration_minutes || 0,
226
- // v2 main-axis scores (UI averages H1/H2/H3/ENV/O1 into a single
227
- // composite column). ENV is derived from E1 + E2 client-side via
228
- // its own column would explode the row width.
229
- scoreH1: analysis ? analysis.score_h1 : null,
230
- scoreH2: analysis ? analysis.score_h2 : null,
231
- scoreH3: analysis ? analysis.score_h3 : null,
232
- scoreEnv: analysis ? envScore(analysis.score_e1, analysis.score_e2) : null,
233
- scoreO1: analysis ? analysis.score_o1 : null,
234
- status: analysis ? analysis.status : 'pending',
235
- });
236
- }
237
-
238
- return list;
239
- }
240
-
241
- /**
242
- * Build analysis status from the analysis_state table and session data.
243
- * @param {object} db
244
- * @param {string} fromDate
245
- * @param {string} toDate
246
- * @returns {{ status: string, analyzedCount: number, totalCount: number }}
247
- */
248
- function buildAnalysisStatus(db, fromDate, toDate) {
249
- const state = getAnalysisState(db);
250
- const sessions = getSessionsByDateRange(db, fromDate, toDate, undefined, 10000, 0);
251
- let analyzed = 0;
252
-
253
- for (const s of sessions) {
254
- const a = getAnalysisBySession(db, s.id);
255
- if (a && a.status === 'done') analyzed++;
256
- }
257
-
258
- return {
259
- status: state ? state.status : 'idle',
260
- analyzedCount: analyzed,
261
- totalCount: sessions.length,
262
- };
263
- }
264
-
265
- // ---------------------------------------------------------------------------
266
- // Public API
267
- // ---------------------------------------------------------------------------
268
-
269
- /**
270
- * Build yesterday report data.
271
- * @param {object} db sql.js Database instance
272
- * @returns {object} Report payload for the API
273
- */
274
- function buildYesterdayReport(db) {
275
- const date = _yesterday();
276
- const fromDate = date;
277
- const toDate = date;
278
-
279
- // 1. Stats
280
- const stats = buildStats(db, fromDate, toDate);
281
-
282
- // 2. Dimensions v2 (H1/H2/H3/E1/E2/O1)
283
- const currentV2 = getCurrentDimensionsV2(db, fromDate, toDate);
284
-
285
- // 3. Sessions
286
- const sessionList = buildSessionList(db, fromDate, toDate);
287
-
288
- // 4. Top projects (canonical-key de-duped, top 5 by cost). Pull a
289
- // larger candidate pool so collapsing duplicates leaves enough rows.
290
- const topProjectsRaw = getOverviewTopProjects(db, fromDate, toDate, 40);
291
- const topProjects = mapTopProjects(topProjectsRaw, 5);
292
-
293
- // 5. Analysis status
294
- const analysisStatus = buildAnalysisStatus(db, fromDate, toDate);
295
-
296
- return {
297
- date,
298
- stats,
299
- dimensionsV2: { current: currentV2 },
300
- sessions: sessionList,
301
- topProjects,
302
- analysisStatus,
303
- };
304
- }
305
-
306
- /**
307
- * Build weekly report data.
308
- * @param {object} db
309
- * @param {string} [weekStart] ISO date of Monday, defaults to current week
310
- * @returns {object}
311
- */
312
- function buildWeeklyReport(db, weekStart) {
313
- const monday = weekStart || _mondayOf();
314
- const sundayDate = new Date(monday + 'T00:00:00');
315
- sundayDate.setDate(sundayDate.getDate() + 6);
316
- const sunday = _fmt(sundayDate);
317
-
318
- // 1. Stats
319
- const stats = buildStats(db, monday, sunday);
320
-
321
- // 2. Dimensions v2 — H1..O1 over the week, H3 over the same rolling window.
322
- const currentV2 = getCurrentDimensionsV2(db, monday, sunday);
323
-
324
- // 3. Sessions
325
- const sessionList = buildSessionList(db, monday, sunday);
326
-
327
- // 4. Daily breakdown (one summary per day)
328
- const dailyBreakdown = getDailySummaries(db, monday, sunday).map((s) => ({
329
- date: s.date,
330
- sessions: s.session_count || 0,
331
- cost: Math.round((s.cost_usd || 0) * 100) / 100,
332
- activeMinutes: s.active_minutes || 0,
333
- errors: s.error_count || 0,
334
- }));
335
-
336
- // 5. Top projects within the week (canonical-key de-duped, top 5 by cost).
337
- const topProjectsRaw = getOverviewTopProjects(db, monday, sunday, 40);
338
- const topProjects = mapTopProjects(topProjectsRaw, 5);
339
-
340
- // 6. Analysis status
341
- const analysisStatus = buildAnalysisStatus(db, monday, sunday);
342
-
343
- return {
344
- weekStart: monday,
345
- weekEnd: sunday,
346
- stats,
347
- dimensionsV2: { current: currentV2 },
348
- sessions: sessionList,
349
- topProjects,
350
- dailyBreakdown,
351
- analysisStatus,
352
- };
353
- }
354
-
355
- /**
356
- * Build session detail data.
357
- * @param {object} db
358
- * @param {string} sessionId
359
- * @returns {object|null} null if session not found
360
- */
361
- function buildSessionDetail(db, sessionId) {
362
- const session = getSessionById(db, sessionId);
363
- if (!session) return null;
364
-
365
- const analysis = getAnalysisBySession(db, sessionId);
366
-
367
- // v2: parse sub_scores_v2 if present.
368
- let subScoresV2 = null;
369
- let subLevelsV2 = null;
370
- let subEvidenceV2 = null;
371
- if (analysis && analysis.sub_scores_v2) {
372
- try {
373
- const parsed = JSON.parse(analysis.sub_scores_v2);
374
- subScoresV2 = parsed.subScores || null;
375
- subLevelsV2 = parsed.subLevels || null;
376
- subEvidenceV2 = parsed.subEvidence || null;
377
- } catch (_e) { /* ignore */ }
378
- }
379
-
380
- // v2: LLM judge payload — raw 0..1 values, per-field scoring evidence
381
- // (details) and the one-line rationale, keyed e1/o1.
382
- let llmJudgeV2 = null;
383
- if (analysis && analysis.llm_judge_v2) {
384
- try { llmJudgeV2 = JSON.parse(analysis.llm_judge_v2); }
385
- catch (_e) { /* ignore */ }
386
- }
387
-
388
- // Per-session AI advice (see server/llm/advice.js). Bundled into the
389
- // session-detail response so the SPA renders cached suggestions on the
390
- // first paint without a second round-trip. null if never generated.
391
- let advice = null;
392
- if (analysis && analysis.llm_advice) {
393
- try { advice = JSON.parse(analysis.llm_advice); }
394
- catch (_e) { /* ignore */ }
395
- }
396
-
397
- // Tool call breakdown
398
- const toolBreakdown = queryAll(
399
- db,
400
- `SELECT tool_name, COUNT(*) AS total,
401
- SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errors
402
- FROM unified_tool_call
403
- WHERE session_id = ?
404
- GROUP BY tool_name
405
- ORDER BY total DESC`,
406
- [sessionId]
407
- );
408
-
409
- // Per-tool sample invocations (up to N most recent) for UI hover details.
410
- // We fetch a flat list ordered by tool then time-desc, then bucket in JS so
411
- // we don't issue one query per tool.
412
- const SAMPLE_LIMIT_PER_TOOL = 5;
413
- const allCalls = queryAll(
414
- db,
415
- `SELECT tool_name, status, error_message, target_file, timestamp
416
- FROM unified_tool_call
417
- WHERE session_id = ?
418
- ORDER BY tool_name ASC, timestamp DESC`,
419
- [sessionId]
420
- );
421
- const samplesByTool = {};
422
- for (const c of allCalls) {
423
- const list = samplesByTool[c.tool_name] || (samplesByTool[c.tool_name] = []);
424
- if (list.length < SAMPLE_LIMIT_PER_TOOL) {
425
- list.push({
426
- timestamp: c.timestamp,
427
- status: c.status,
428
- errorMessage: c.error_message,
429
- targetFile: c.target_file,
430
- });
431
- }
432
- }
433
-
434
- // Message timeline summary + full transcript (for the "原始对话" panel).
435
- // `text` is the 4KB-capped payload the ETL writes; tool calls live in
436
- // unified_tool_call and we mix them into the same timeline below.
437
- const messages = queryAll(
438
- db,
439
- `SELECT id, role, content_length, is_error, timestamp, text, model_id, tokens_input, tokens_output
440
- FROM unified_message
441
- WHERE session_id = ?
442
- ORDER BY timestamp`,
443
- [sessionId]
444
- );
445
-
446
- const userMessages = messages.filter((m) => m.role === 'user');
447
- const assistantMessages = messages.filter((m) => m.role === 'assistant');
448
-
449
- // Interleave messages and tool calls by timestamp so the transcript
450
- // reads in true chronological order.
451
- const toolCallsForTimeline = queryAll(
452
- db,
453
- `SELECT id, tool_name, status, error_message, target_file, timestamp
454
- FROM unified_tool_call
455
- WHERE session_id = ?
456
- ORDER BY timestamp`,
457
- [sessionId]
458
- );
459
-
460
- const transcript = [
461
- ...messages.map((m) => ({
462
- kind: 'message',
463
- id: m.id,
464
- role: m.role,
465
- text: m.text || '',
466
- isError: !!m.is_error,
467
- timestamp: m.timestamp,
468
- contentLength: m.content_length || 0,
469
- modelId: m.model_id || null,
470
- tokensInput: m.tokens_input || 0,
471
- tokensOutput: m.tokens_output || 0,
472
- })),
473
- ...toolCallsForTimeline.map((t) => ({
474
- kind: 'tool',
475
- id: t.id,
476
- tool: t.tool_name,
477
- status: t.status,
478
- errorMessage: t.error_message,
479
- targetFile: t.target_file,
480
- timestamp: t.timestamp,
481
- })),
482
- ].sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
483
-
484
- return {
485
- id: session.id,
486
- source: session.source,
487
- date: session.date,
488
- title: session.title || '(untitled)',
489
- project: session.project,
490
- model: session.model,
491
- startedAt: session.started_at,
492
- endedAt: session.ended_at,
493
- durationMinutes: session.duration_minutes || 0,
494
- activeMinutes: session.active_minutes || 0,
495
- cost: Math.round((session.cost_usd || 0) * 100) / 100,
496
- tokens: {
497
- input: session.tokens_input || 0,
498
- output: session.tokens_output || 0,
499
- reasoning: session.tokens_reasoning || 0,
500
- cacheRead: session.tokens_cache_read || 0,
501
- cacheWrite: session.tokens_cache_write || 0,
502
- },
503
- messageCount: session.message_count || 0,
504
- errorCount: session.error_count || 0,
505
- toolCallCount: session.tool_call_count || 0,
506
- reverted: !!session.reverted,
507
- summary: {
508
- additions: session.summary_additions || 0,
509
- deletions: session.summary_deletions || 0,
510
- files: session.summary_files || 0,
511
- },
512
- // v2 capability model — see docs/superpowers/specs/2026-06-13-…
513
- // ENV is a *display* dimension that fuses E1 + E2 into "AI 能力环境
514
- // 诊断" so the UI can show 5 axes instead of 6. E1 / E2 stay in the
515
- // payload for callers who want the raw breakdown.
516
- dimensionsV2: analysis
517
- ? {
518
- H1: analysis.score_h1,
519
- H2: analysis.score_h2,
520
- H3: analysis.score_h3,
521
- E1: analysis.score_e1,
522
- E2: analysis.score_e2,
523
- ENV: envScore(analysis.score_e1, analysis.score_e2),
524
- O1: analysis.score_o1,
525
- }
526
- : null,
527
- levelsV2: analysis
528
- ? {
529
- H1: analysis.level_h1,
530
- H2: analysis.level_h2,
531
- H3: analysis.level_h3,
532
- E1: analysis.level_e1,
533
- E2: analysis.level_e2,
534
- // ENV level derived from its score band (≥85 L4, ≥65 L3, ≥40 L2)
535
- ENV: levelFromScore(envScore(analysis.score_e1, analysis.score_e2)),
536
- O1: analysis.level_o1,
537
- }
538
- : null,
539
- subScoresV2,
540
- subLevelsV2,
541
- subEvidenceV2,
542
- llmJudgeV2,
543
- advice,
544
- difficulty: analysis ? analysis.difficulty : null,
545
- judgeSource: analysis ? analysis.judge_source : null,
546
- analysisStatus: analysis ? analysis.status : 'pending',
547
- toolBreakdown: toolBreakdown.map((t) => ({
548
- tool: t.tool_name,
549
- total: t.total,
550
- errors: t.errors,
551
- samples: samplesByTool[t.tool_name] || [],
552
- })),
553
- transcript,
554
- messageSummary: {
555
- userCount: userMessages.length,
556
- assistantCount: assistantMessages.length,
557
- avgUserLength:
558
- userMessages.length > 0
559
- ? Math.round(
560
- userMessages.reduce((s, m) => s + (m.content_length || 0), 0) /
561
- userMessages.length
562
- )
563
- : 0,
564
- avgAssistantLength:
565
- assistantMessages.length > 0
566
- ? Math.round(
567
- assistantMessages.reduce((s, m) => s + (m.content_length || 0), 0) /
568
- assistantMessages.length
569
- )
570
- : 0,
571
- errorMessages: messages.filter((m) => m.is_error).length,
572
- },
573
- };
574
- }
575
-
576
- module.exports = {
577
- buildYesterdayReport,
578
- buildWeeklyReport,
579
- buildSessionDetail,
580
- getCurrentDimensionsV2,
581
- };
1
+ /**
2
+ * Report builder for Agent Boss API responses.
3
+ *
4
+ * Assembles structured report payloads by fetching v2 dimension scores,
5
+ * daily summaries, and session lists from boss.db.
6
+ *
7
+ * @author Felix
8
+ */
9
+
10
+ const {
11
+ queryAll,
12
+ getSessionsByDateRange,
13
+ getSessionById,
14
+ getAnalysisBySession,
15
+ getDailySummaries,
16
+ getAnalysisState,
17
+ getOverviewTopProjects,
18
+ } = require('../db/queries');
19
+ const { mapTopProjects } = require('../utils/project');
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Date helpers
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Format a Date as YYYY-MM-DD.
27
+ * @param {Date} d
28
+ * @returns {string}
29
+ */
30
+ function _fmt(d) {
31
+ const y = d.getFullYear();
32
+ const m = String(d.getMonth() + 1).padStart(2, '0');
33
+ const day = String(d.getDate()).padStart(2, '0');
34
+ return `${y}-${m}-${day}`;
35
+ }
36
+
37
+ /**
38
+ * Get yesterday's date as YYYY-MM-DD.
39
+ * @returns {string}
40
+ */
41
+ function _yesterday() {
42
+ const d = new Date();
43
+ d.setDate(d.getDate() - 1);
44
+ return _fmt(d);
45
+ }
46
+
47
+ /**
48
+ * Get the Monday of the current ISO week (or the week containing the given
49
+ * date).
50
+ * @param {Date} [d]
51
+ * @returns {string}
52
+ */
53
+ function _mondayOf(d) {
54
+ const dt = d ? new Date(d) : new Date();
55
+ const day = dt.getDay(); // 0=Sun … 6=Sat
56
+ const diff = day === 0 ? 6 : day - 1;
57
+ dt.setDate(dt.getDate() - diff);
58
+ return _fmt(dt);
59
+ }
60
+
61
+ /**
62
+ * Get the date N days ago as YYYY-MM-DD.
63
+ * @param {number} n
64
+ * @returns {string}
65
+ */
66
+ function _daysAgo(n) {
67
+ const d = new Date();
68
+ d.setDate(d.getDate() - n);
69
+ return _fmt(d);
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Shared helpers
74
+ // ---------------------------------------------------------------------------
75
+
76
+ /**
77
+ * Compute current v2 dimensions (H1/H2/E1/E2/O1 averaged across the
78
+ * date range; H3 from the rolling aggregator over the same window).
79
+ *
80
+ * @param {object} db
81
+ * @param {string} fromDate
82
+ * @param {string} toDate
83
+ * @returns {{ H1:number|null, H2:number|null, H3:number|null, E1:number|null, E2:number|null, O1:number|null }}
84
+ */
85
+ function getCurrentDimensionsV2(db, fromDate, toDate) {
86
+ const sessions = getSessionsByDateRange(db, fromDate, toDate, undefined, 10000, 0);
87
+
88
+ const acc = { H1: { s: 0, n: 0 }, H2: { s: 0, n: 0 }, H3: { s: 0, n: 0 }, E1: { s: 0, n: 0 }, E2: { s: 0, n: 0 }, O1: { s: 0, n: 0 } };
89
+
90
+ for (const s of sessions) {
91
+ const a = getAnalysisBySession(db, s.id);
92
+ if (!a || a.status !== 'done') continue;
93
+ for (const [k, col] of [['H1','score_h1'],['H2','score_h2'],['H3','score_h3'],['E1','score_e1'],['E2','score_e2'],['O1','score_o1']]) {
94
+ if (a[col] != null) { acc[k].s += a[col]; acc[k].n++; }
95
+ }
96
+ }
97
+
98
+ // H3 is now scored per session by the LLM (like the others) and averaged
99
+ // here no more rolling-window aggregate.
100
+ const result = {};
101
+ for (const [k, v] of Object.entries(acc)) {
102
+ result[k] = v.n > 0 ? Math.round((v.s / v.n) * 10) / 10 : null;
103
+ }
104
+
105
+ // ENV = "AI 能力环境诊断" = average of E1 (knowledge) and E2 (tools).
106
+ // Only computed when at least one side is present.
107
+ if (result.E1 != null && result.E2 != null) {
108
+ result.ENV = Math.round(((result.E1 + result.E2) / 2) * 10) / 10;
109
+ } else if (result.E1 != null) {
110
+ result.ENV = result.E1;
111
+ } else if (result.E2 != null) {
112
+ result.ENV = result.E2;
113
+ } else {
114
+ result.ENV = null;
115
+ }
116
+
117
+ return result;
118
+ }
119
+
120
+ /**
121
+ * Combine E1 + E2 into the v2.1 "AI 能力环境诊断" composite for a single
122
+ * session. Returns null if both inputs are null.
123
+ *
124
+ * @param {number|null} e1
125
+ * @param {number|null} e2
126
+ * @returns {number|null}
127
+ */
128
+ function envScore(e1, e2) {
129
+ if (e1 == null && e2 == null) return null;
130
+ if (e1 == null) return e2;
131
+ if (e2 == null) return e1;
132
+ return Math.round(((e1 + e2) / 2) * 10) / 10;
133
+ }
134
+
135
+ /**
136
+ * Map a 0-100 score to the same L1-L4 buckets the rest of v2 uses
137
+ * (≥85 L4 · ≥65 L3 · ≥40 L2 · else L1). Mirrors thresholds-v2#scoreToLevel
138
+ * duplicated here to avoid pulling a server/analysis dep into the
139
+ * report builder.
140
+ *
141
+ * @param {number|null} score
142
+ * @returns {1|2|3|4|null}
143
+ */
144
+ function levelFromScore(score) {
145
+ if (score == null) return null;
146
+ if (score >= 85) return 4;
147
+ if (score >= 65) return 3;
148
+ if (score >= 40) return 2;
149
+ return 1;
150
+ }
151
+
152
+ /**
153
+ * Build stats summary from daily_summary row(s) or compute from sessions.
154
+ * @param {object} db
155
+ * @param {string} fromDate
156
+ * @param {string} toDate
157
+ * @returns {object}
158
+ */
159
+ function buildStats(db, fromDate, toDate) {
160
+ const summaries = getDailySummaries(db, fromDate, toDate);
161
+
162
+ let sessions = 0;
163
+ let cost = 0;
164
+ let activeMinutes = 0;
165
+ let totalTokens = 0;
166
+ let errors = 0;
167
+
168
+ for (const s of summaries) {
169
+ sessions += s.session_count || 0;
170
+ cost += s.cost_usd || 0;
171
+ activeMinutes += s.active_minutes || 0;
172
+ totalTokens += (s.tokens_input || 0) + (s.tokens_output || 0) + (s.tokens_reasoning || 0);
173
+ errors += s.error_count || 0;
174
+ }
175
+
176
+ return {
177
+ sessions,
178
+ cost: Math.round(cost * 100) / 100,
179
+ activeMinutes,
180
+ totalTokens,
181
+ errors,
182
+ avgCost: sessions > 0 ? Math.round((cost / sessions) * 100) / 100 : 0,
183
+ avgActiveMinutes: sessions > 0 ? Math.round(activeMinutes / sessions) : 0,
184
+ avgTokens: sessions > 0 ? Math.round(totalTokens / sessions) : 0,
185
+ avgErrors: sessions > 0 ? Math.round((errors / sessions) * 100) / 100 : 0,
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Build session list with per-session scores and metadata.
191
+ *
192
+ * Subagents (parent_session_id IS NOT NULL) are skipped here so the
193
+ * "会话列表" UI shows top-level work only. Aggregate stats on the
194
+ * same page (stats, dimensions, daily_summary, collab bill) are
195
+ * computed by other queries that still include them — see the comment
196
+ * on the parent_session_id column in schema.js for the rationale.
197
+ *
198
+ * @param {object} db
199
+ * @param {string} fromDate
200
+ * @param {string} toDate
201
+ * @returns {object[]}
202
+ */
203
+ function buildSessionList(db, fromDate, toDate) {
204
+ const sessions = getSessionsByDateRange(db, fromDate, toDate, undefined, 10000, 0);
205
+ const list = [];
206
+
207
+ for (const s of sessions) {
208
+ if (s.parent_session_id) continue; // skip subagents
209
+ const analysis = getAnalysisBySession(db, s.id);
210
+ list.push({
211
+ id: s.id,
212
+ title: s.title || '(untitled)',
213
+ source: s.source,
214
+ // date/startedAt let the UI group sessions by day (weekly report)
215
+ date: s.date,
216
+ startedAt: s.started_at,
217
+ cost: Math.round((s.cost_usd || 0) * 100) / 100,
218
+ duration: s.duration_minutes || 0,
219
+ // v2 main-axis scores (UI averages H1/H2/H3/ENV/O1 into a single
220
+ // composite column). ENV is derived from E1 + E2 client-side via
221
+ // its own column would explode the row width.
222
+ scoreH1: analysis ? analysis.score_h1 : null,
223
+ scoreH2: analysis ? analysis.score_h2 : null,
224
+ scoreH3: analysis ? analysis.score_h3 : null,
225
+ scoreEnv: analysis ? envScore(analysis.score_e1, analysis.score_e2) : null,
226
+ scoreO1: analysis ? analysis.score_o1 : null,
227
+ status: analysis ? analysis.status : 'pending',
228
+ });
229
+ }
230
+
231
+ return list;
232
+ }
233
+
234
+ /**
235
+ * Build analysis status from the analysis_state table and session data.
236
+ * @param {object} db
237
+ * @param {string} fromDate
238
+ * @param {string} toDate
239
+ * @returns {{ status: string, analyzedCount: number, totalCount: number }}
240
+ */
241
+ function buildAnalysisStatus(db, fromDate, toDate) {
242
+ const state = getAnalysisState(db);
243
+ const sessions = getSessionsByDateRange(db, fromDate, toDate, undefined, 10000, 0);
244
+ let analyzed = 0;
245
+
246
+ for (const s of sessions) {
247
+ const a = getAnalysisBySession(db, s.id);
248
+ if (a && a.status === 'done') analyzed++;
249
+ }
250
+
251
+ return {
252
+ status: state ? state.status : 'idle',
253
+ analyzedCount: analyzed,
254
+ totalCount: sessions.length,
255
+ };
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // Public API
260
+ // ---------------------------------------------------------------------------
261
+
262
+ /**
263
+ * Build yesterday report data.
264
+ * @param {object} db sql.js Database instance
265
+ * @returns {object} Report payload for the API
266
+ */
267
+ function buildYesterdayReport(db) {
268
+ const date = _yesterday();
269
+ const fromDate = date;
270
+ const toDate = date;
271
+
272
+ // 1. Stats
273
+ const stats = buildStats(db, fromDate, toDate);
274
+
275
+ // 2. Dimensions v2 (H1/H2/H3/E1/E2/O1)
276
+ const currentV2 = getCurrentDimensionsV2(db, fromDate, toDate);
277
+
278
+ // 3. Sessions
279
+ const sessionList = buildSessionList(db, fromDate, toDate);
280
+
281
+ // 4. Top projects (canonical-key de-duped, top 5 by cost). Pull a
282
+ // larger candidate pool so collapsing duplicates leaves enough rows.
283
+ const topProjectsRaw = getOverviewTopProjects(db, fromDate, toDate, 40);
284
+ const topProjects = mapTopProjects(topProjectsRaw, 5);
285
+
286
+ // 5. Analysis status
287
+ const analysisStatus = buildAnalysisStatus(db, fromDate, toDate);
288
+
289
+ return {
290
+ date,
291
+ stats,
292
+ dimensionsV2: { current: currentV2 },
293
+ sessions: sessionList,
294
+ topProjects,
295
+ analysisStatus,
296
+ };
297
+ }
298
+
299
+ /**
300
+ * Build weekly report data.
301
+ * @param {object} db
302
+ * @param {string} [weekStart] ISO date of Monday, defaults to current week
303
+ * @returns {object}
304
+ */
305
+ function buildWeeklyReport(db, weekStart) {
306
+ const monday = weekStart || _mondayOf();
307
+ const sundayDate = new Date(monday + 'T00:00:00');
308
+ sundayDate.setDate(sundayDate.getDate() + 6);
309
+ const sunday = _fmt(sundayDate);
310
+
311
+ // 1. Stats
312
+ const stats = buildStats(db, monday, sunday);
313
+
314
+ // 2. Dimensions v2 H1..O1 over the week, H3 over the same rolling window.
315
+ const currentV2 = getCurrentDimensionsV2(db, monday, sunday);
316
+
317
+ // 3. Sessions
318
+ const sessionList = buildSessionList(db, monday, sunday);
319
+
320
+ // 4. Daily breakdown (one summary per day)
321
+ const dailyBreakdown = getDailySummaries(db, monday, sunday).map((s) => ({
322
+ date: s.date,
323
+ sessions: s.session_count || 0,
324
+ cost: Math.round((s.cost_usd || 0) * 100) / 100,
325
+ activeMinutes: s.active_minutes || 0,
326
+ errors: s.error_count || 0,
327
+ }));
328
+
329
+ // 5. Top projects within the week (canonical-key de-duped, top 5 by cost).
330
+ const topProjectsRaw = getOverviewTopProjects(db, monday, sunday, 40);
331
+ const topProjects = mapTopProjects(topProjectsRaw, 5);
332
+
333
+ // 6. Analysis status
334
+ const analysisStatus = buildAnalysisStatus(db, monday, sunday);
335
+
336
+ return {
337
+ weekStart: monday,
338
+ weekEnd: sunday,
339
+ stats,
340
+ dimensionsV2: { current: currentV2 },
341
+ sessions: sessionList,
342
+ topProjects,
343
+ dailyBreakdown,
344
+ analysisStatus,
345
+ };
346
+ }
347
+
348
+ /**
349
+ * Build session detail data.
350
+ * @param {object} db
351
+ * @param {string} sessionId
352
+ * @returns {object|null} null if session not found
353
+ */
354
+ function buildSessionDetail(db, sessionId) {
355
+ const session = getSessionById(db, sessionId);
356
+ if (!session) return null;
357
+
358
+ const analysis = getAnalysisBySession(db, sessionId);
359
+
360
+ // v2: parse sub_scores_v2 if present.
361
+ let subScoresV2 = null;
362
+ let subLevelsV2 = null;
363
+ let subEvidenceV2 = null;
364
+ if (analysis && analysis.sub_scores_v2) {
365
+ try {
366
+ const parsed = JSON.parse(analysis.sub_scores_v2);
367
+ subScoresV2 = parsed.subScores || null;
368
+ subLevelsV2 = parsed.subLevels || null;
369
+ subEvidenceV2 = parsed.subEvidence || null;
370
+ } catch (_e) { /* ignore */ }
371
+ }
372
+
373
+ // v2: LLM judge payload — raw 0..1 values, per-field scoring evidence
374
+ // (details) and the one-line rationale, keyed e1/o1.
375
+ let llmJudgeV2 = null;
376
+ if (analysis && analysis.llm_judge_v2) {
377
+ try { llmJudgeV2 = JSON.parse(analysis.llm_judge_v2); }
378
+ catch (_e) { /* ignore */ }
379
+ }
380
+
381
+ // Per-session AI advice (see server/llm/advice.js). Bundled into the
382
+ // session-detail response so the SPA renders cached suggestions on the
383
+ // first paint without a second round-trip. null if never generated.
384
+ let advice = null;
385
+ if (analysis && analysis.llm_advice) {
386
+ try { advice = JSON.parse(analysis.llm_advice); }
387
+ catch (_e) { /* ignore */ }
388
+ }
389
+
390
+ // Tool call breakdown
391
+ const toolBreakdown = queryAll(
392
+ db,
393
+ `SELECT tool_name, COUNT(*) AS total,
394
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errors
395
+ FROM unified_tool_call
396
+ WHERE session_id = ?
397
+ GROUP BY tool_name
398
+ ORDER BY total DESC`,
399
+ [sessionId]
400
+ );
401
+
402
+ // Per-tool sample invocations (up to N most recent) for UI hover details.
403
+ // We fetch a flat list ordered by tool then time-desc, then bucket in JS so
404
+ // we don't issue one query per tool.
405
+ const SAMPLE_LIMIT_PER_TOOL = 5;
406
+ const allCalls = queryAll(
407
+ db,
408
+ `SELECT tool_name, status, error_message, target_file, timestamp
409
+ FROM unified_tool_call
410
+ WHERE session_id = ?
411
+ ORDER BY tool_name ASC, timestamp DESC`,
412
+ [sessionId]
413
+ );
414
+ const samplesByTool = {};
415
+ for (const c of allCalls) {
416
+ const list = samplesByTool[c.tool_name] || (samplesByTool[c.tool_name] = []);
417
+ if (list.length < SAMPLE_LIMIT_PER_TOOL) {
418
+ list.push({
419
+ timestamp: c.timestamp,
420
+ status: c.status,
421
+ errorMessage: c.error_message,
422
+ targetFile: c.target_file,
423
+ });
424
+ }
425
+ }
426
+
427
+ // Message timeline summary + full transcript (for the "原始对话" panel).
428
+ // `text` is the 4KB-capped payload the ETL writes; tool calls live in
429
+ // unified_tool_call and we mix them into the same timeline below.
430
+ const messages = queryAll(
431
+ db,
432
+ `SELECT id, role, content_length, is_error, timestamp, text, model_id, tokens_input, tokens_output
433
+ FROM unified_message
434
+ WHERE session_id = ?
435
+ ORDER BY timestamp`,
436
+ [sessionId]
437
+ );
438
+
439
+ const userMessages = messages.filter((m) => m.role === 'user');
440
+ const assistantMessages = messages.filter((m) => m.role === 'assistant');
441
+
442
+ // Interleave messages and tool calls by timestamp so the transcript
443
+ // reads in true chronological order.
444
+ const toolCallsForTimeline = queryAll(
445
+ db,
446
+ `SELECT id, tool_name, status, error_message, target_file, timestamp
447
+ FROM unified_tool_call
448
+ WHERE session_id = ?
449
+ ORDER BY timestamp`,
450
+ [sessionId]
451
+ );
452
+
453
+ const transcript = [
454
+ ...messages.map((m) => ({
455
+ kind: 'message',
456
+ id: m.id,
457
+ role: m.role,
458
+ text: m.text || '',
459
+ isError: !!m.is_error,
460
+ timestamp: m.timestamp,
461
+ contentLength: m.content_length || 0,
462
+ modelId: m.model_id || null,
463
+ tokensInput: m.tokens_input || 0,
464
+ tokensOutput: m.tokens_output || 0,
465
+ })),
466
+ ...toolCallsForTimeline.map((t) => ({
467
+ kind: 'tool',
468
+ id: t.id,
469
+ tool: t.tool_name,
470
+ status: t.status,
471
+ errorMessage: t.error_message,
472
+ targetFile: t.target_file,
473
+ timestamp: t.timestamp,
474
+ })),
475
+ ].sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
476
+
477
+ return {
478
+ id: session.id,
479
+ source: session.source,
480
+ date: session.date,
481
+ title: session.title || '(untitled)',
482
+ project: session.project,
483
+ model: session.model,
484
+ startedAt: session.started_at,
485
+ endedAt: session.ended_at,
486
+ durationMinutes: session.duration_minutes || 0,
487
+ activeMinutes: session.active_minutes || 0,
488
+ cost: Math.round((session.cost_usd || 0) * 100) / 100,
489
+ tokens: {
490
+ input: session.tokens_input || 0,
491
+ output: session.tokens_output || 0,
492
+ reasoning: session.tokens_reasoning || 0,
493
+ cacheRead: session.tokens_cache_read || 0,
494
+ cacheWrite: session.tokens_cache_write || 0,
495
+ },
496
+ messageCount: session.message_count || 0,
497
+ errorCount: session.error_count || 0,
498
+ toolCallCount: session.tool_call_count || 0,
499
+ reverted: !!session.reverted,
500
+ summary: {
501
+ additions: session.summary_additions || 0,
502
+ deletions: session.summary_deletions || 0,
503
+ files: session.summary_files || 0,
504
+ },
505
+ // v2 capability model — see docs/superpowers/specs/2026-06-13-…
506
+ // ENV is a *display* dimension that fuses E1 + E2 into "AI 能力环境
507
+ // 诊断" so the UI can show 5 axes instead of 6. E1 / E2 stay in the
508
+ // payload for callers who want the raw breakdown.
509
+ dimensionsV2: analysis
510
+ ? {
511
+ H1: analysis.score_h1,
512
+ H2: analysis.score_h2,
513
+ H3: analysis.score_h3,
514
+ E1: analysis.score_e1,
515
+ E2: analysis.score_e2,
516
+ ENV: envScore(analysis.score_e1, analysis.score_e2),
517
+ O1: analysis.score_o1,
518
+ }
519
+ : null,
520
+ levelsV2: analysis
521
+ ? {
522
+ H1: analysis.level_h1,
523
+ H2: analysis.level_h2,
524
+ H3: analysis.level_h3,
525
+ E1: analysis.level_e1,
526
+ E2: analysis.level_e2,
527
+ // ENV level derived from its score band (≥85 L4, ≥65 L3, ≥40 L2)
528
+ ENV: levelFromScore(envScore(analysis.score_e1, analysis.score_e2)),
529
+ O1: analysis.level_o1,
530
+ }
531
+ : null,
532
+ subScoresV2,
533
+ subLevelsV2,
534
+ subEvidenceV2,
535
+ llmJudgeV2,
536
+ advice,
537
+ difficulty: analysis ? analysis.difficulty : null,
538
+ judgeSource: analysis ? analysis.judge_source : null,
539
+ analysisStatus: analysis ? analysis.status : 'pending',
540
+ toolBreakdown: toolBreakdown.map((t) => ({
541
+ tool: t.tool_name,
542
+ total: t.total,
543
+ errors: t.errors,
544
+ samples: samplesByTool[t.tool_name] || [],
545
+ })),
546
+ transcript,
547
+ messageSummary: {
548
+ userCount: userMessages.length,
549
+ assistantCount: assistantMessages.length,
550
+ avgUserLength:
551
+ userMessages.length > 0
552
+ ? Math.round(
553
+ userMessages.reduce((s, m) => s + (m.content_length || 0), 0) /
554
+ userMessages.length
555
+ )
556
+ : 0,
557
+ avgAssistantLength:
558
+ assistantMessages.length > 0
559
+ ? Math.round(
560
+ assistantMessages.reduce((s, m) => s + (m.content_length || 0), 0) /
561
+ assistantMessages.length
562
+ )
563
+ : 0,
564
+ errorMessages: messages.filter((m) => m.is_error).length,
565
+ },
566
+ };
567
+ }
568
+
569
+ module.exports = {
570
+ buildYesterdayReport,
571
+ buildWeeklyReport,
572
+ buildSessionDetail,
573
+ getCurrentDimensionsV2,
574
+ };