agentboss 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 (53) hide show
  1. package/README.md +34 -0
  2. package/bin/aboss.js +288 -0
  3. package/client/dist/assets/index-C1wFD_Vo.css +1 -0
  4. package/client/dist/assets/index-DBj1Ujlx.js +137 -0
  5. package/client/dist/index.html +34 -0
  6. package/package.json +64 -0
  7. package/server/analysis/daily-aggregator.js +258 -0
  8. package/server/analysis/difficulty.js +129 -0
  9. package/server/analysis/dimensions/ai-knowledge.js +172 -0
  10. package/server/analysis/dimensions/ai-tools.js +161 -0
  11. package/server/analysis/dimensions/judgement.js +107 -0
  12. package/server/analysis/dimensions/llm-merge.js +57 -0
  13. package/server/analysis/dimensions/output-quality.js +167 -0
  14. package/server/analysis/dimensions/problem-definition.js +104 -0
  15. package/server/analysis/dimensions/system-thinking.js +225 -0
  16. package/server/analysis/evidence-builder.js +104 -0
  17. package/server/analysis/job.js +273 -0
  18. package/server/analysis/report-builder.js +581 -0
  19. package/server/analysis/scoring-v2.js +72 -0
  20. package/server/analysis/text-signals.js +179 -0
  21. package/server/analysis/thresholds-v2.js +358 -0
  22. package/server/api/advice.js +124 -0
  23. package/server/api/analysis.js +141 -0
  24. package/server/api/execution.js +330 -0
  25. package/server/api/metrics.js +277 -0
  26. package/server/api/overview.js +308 -0
  27. package/server/api/project.js +255 -0
  28. package/server/api/reports.js +125 -0
  29. package/server/api/sessions.js +118 -0
  30. package/server/api/settings.js +119 -0
  31. package/server/db/connection.js +175 -0
  32. package/server/db/queries.js +1051 -0
  33. package/server/db/schema.js +487 -0
  34. package/server/etl/active-time.js +150 -0
  35. package/server/etl/backfill-subagents.js +178 -0
  36. package/server/etl/claude-code.js +826 -0
  37. package/server/etl/detect.js +341 -0
  38. package/server/etl/judge-filter.js +117 -0
  39. package/server/etl/opencode.js +606 -0
  40. package/server/execution/job.js +662 -0
  41. package/server/execution/prompt.js +227 -0
  42. package/server/execution/runner.js +218 -0
  43. package/server/index.js +94 -0
  44. package/server/llm/advice-prompt.js +339 -0
  45. package/server/llm/advice.js +384 -0
  46. package/server/llm/analysis-prompt.js +162 -0
  47. package/server/llm/cli-runner.js +249 -0
  48. package/server/llm/judge-prompts.js +179 -0
  49. package/server/llm/judge.js +118 -0
  50. package/server/llm/project-advice-prompt.js +332 -0
  51. package/server/llm/project-advice.js +491 -0
  52. package/server/llm/session-analyzer.js +122 -0
  53. package/server/utils/project.js +80 -0
@@ -0,0 +1,491 @@
1
+ /**
2
+ * High-level entry point for project-level AI advice generation.
3
+ *
4
+ * This is the cross-session counterpart of server/llm/advice.js:
5
+ * given a project + time window, assemble all per-session llm_advice
6
+ * payloads under that project, feed them to the LLM, and persist the
7
+ * second-pass summary into project_advice.
8
+ *
9
+ * Pipeline:
10
+ * 1. settings gate — reuses enable_llm_judge (same toggle)
11
+ * 2. resolve sessions — query unified_session by project + window
12
+ * 3. load per-session advice — pull cached llm_advice for each, skip
13
+ * sessions that don't have one yet
14
+ * 4. cache check — project_advice keyed by (project, scope,
15
+ * windowFrom, windowTo) — fresh if
16
+ * version matches AND the set of
17
+ * session_ids hasn't changed
18
+ * 5. CLI detection — opencode > claude
19
+ * 6. assemble + truncate — see project-advice-prompt.js
20
+ * 7. runJudge under withSlot — 90 s timeout, JSON parsing, sentinel
21
+ * 8. persist — UPSERT into project_advice
22
+ *
23
+ * Mirrors advice.js's failure-as-data convention: returns `{ ok:false,
24
+ * reason }` for normal-path failures instead of throwing.
25
+ *
26
+ * @author Felix
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const {
32
+ detectAvailableCli,
33
+ runJudge,
34
+ withSlot,
35
+ } = require('./cli-runner');
36
+ const {
37
+ PROJECT_ADVICE_PROMPT_VERSION,
38
+ buildProjectAdvicePrompt,
39
+ truncateContext,
40
+ annotateContext,
41
+ } = require('./project-advice-prompt');
42
+ const { loadAdvice } = require('./advice');
43
+ const {
44
+ queryAll,
45
+ queryOne,
46
+ } = require('../db/queries');
47
+ const { saveDb } = require('../db/connection');
48
+ const { canonicalProject } = require('../utils/project');
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Settings gate (shared toggle with judge / session advice)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ let _settingsCache = null;
55
+ let _settingsCacheAt = 0;
56
+ const SETTINGS_TTL_MS = 10_000;
57
+
58
+ function getSettings(db) {
59
+ const now = Date.now();
60
+ if (_settingsCache && now - _settingsCacheAt < SETTINGS_TTL_MS) {
61
+ return _settingsCache;
62
+ }
63
+ const rows = db.exec(
64
+ "SELECT key, value FROM user_settings WHERE key = 'enable_llm_judge'"
65
+ );
66
+ let enable = false;
67
+ if (rows[0]) {
68
+ for (const [, v] of rows[0].values) {
69
+ enable = String(v) === '1' || String(v).toLowerCase() === 'true';
70
+ }
71
+ }
72
+ _settingsCache = { enable_llm_judge: enable };
73
+ _settingsCacheAt = now;
74
+ return _settingsCache;
75
+ }
76
+
77
+ function invalidateProjectAdviceSettingsCache() {
78
+ _settingsCache = null;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Session resolution
83
+ // ---------------------------------------------------------------------------
84
+
85
+ /**
86
+ * Resolve all sessions belonging to a project within the window. The
87
+ * project key is matched against the canonical form of unified_session.project
88
+ * — we LOAD candidates by a broad prefix-match LIKE clause and then
89
+ * canonicalise + exact-compare in JS, because storing the raw path means
90
+ * "C:/foo" and "C//foo" don't match via SQL alone.
91
+ *
92
+ * Returns rows sorted by started_at desc (newest first) so truncation
93
+ * keeps the most recent context.
94
+ */
95
+ function resolveProjectSessions(db, projectKey, windowFrom, windowTo) {
96
+ // Use a broad LIKE so we don't miss path variants — final filter is JS-side.
97
+ // The canonical key has no trailing slash so we also accept the raw form +/.
98
+ const rough = canonicalProject(projectKey);
99
+ const stem = rough.replace(/[:/\\]/g, '%'); // very loose, JS filter is the gate
100
+
101
+ let sql = `
102
+ SELECT id, title, model, date, started_at, cost_usd, message_count,
103
+ error_count, project, parent_session_id, agent_type
104
+ FROM unified_session
105
+ WHERE project LIKE ?
106
+ `;
107
+ const params = [`%${stem}%`];
108
+ if (windowFrom && windowTo) {
109
+ sql += ' AND date BETWEEN ? AND ?';
110
+ params.push(windowFrom, windowTo);
111
+ }
112
+ sql += ' ORDER BY started_at DESC';
113
+ const rows = queryAll(db, sql, params);
114
+
115
+ return rows.filter((r) => canonicalProject(r.project) === rough);
116
+ }
117
+
118
+ /**
119
+ * For each session row, load the cached per-session llm_advice payload.
120
+ * Sessions without a cached advice are still returned (with advice=null)
121
+ * so the caller can count "how many of N still need session-level analysis".
122
+ *
123
+ * `parentSessionId` is forwarded so callers that want to hide subagents
124
+ * from the rendered session list can filter without re-querying.
125
+ */
126
+ function attachSessionAdvice(db, rows) {
127
+ return rows.map((r) => {
128
+ const advice = loadAdvice(db, r.id);
129
+ return {
130
+ id: r.id,
131
+ title: r.title || '',
132
+ model: r.model || '',
133
+ date: r.date || '',
134
+ cost: r.cost_usd || 0,
135
+ msgCount: r.message_count || 0,
136
+ errorCount: r.error_count || 0,
137
+ parentSessionId: r.parent_session_id || null,
138
+ agentType: r.agent_type || null,
139
+ advice, // may be null
140
+ };
141
+ });
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Cache load / store
146
+ // ---------------------------------------------------------------------------
147
+
148
+ function loadProjectAdvice(db, project, scope, windowFrom, windowTo) {
149
+ const row = queryOne(
150
+ db,
151
+ `SELECT * FROM project_advice
152
+ WHERE project = ? AND scope = ? AND window_from = ? AND window_to = ?`,
153
+ [project, scope, windowFrom || '', windowTo || '']
154
+ );
155
+ if (!row) return null;
156
+ let payload = null;
157
+ try { payload = row.llm_advice ? JSON.parse(row.llm_advice) : null; }
158
+ catch { payload = null; }
159
+ let sessionIds = [];
160
+ try { sessionIds = row.session_ids ? JSON.parse(row.session_ids) : []; }
161
+ catch { sessionIds = []; }
162
+ return {
163
+ project: row.project,
164
+ scope: row.scope,
165
+ windowFrom: row.window_from,
166
+ windowTo: row.window_to,
167
+ sessionCount: row.session_count || 0,
168
+ sessionIds,
169
+ v: row.v,
170
+ cli: row.cli,
171
+ cachedAt: row.cached_at,
172
+ payload,
173
+ };
174
+ }
175
+
176
+ function storeProjectAdvice(db, key, payload, meta) {
177
+ const json = JSON.stringify(payload);
178
+ const sessionIdsJson = JSON.stringify(meta.sessionIds || []);
179
+ const existing = queryOne(
180
+ db,
181
+ `SELECT 1 FROM project_advice
182
+ WHERE project = ? AND scope = ? AND window_from = ? AND window_to = ?`,
183
+ [key.project, key.scope, key.windowFrom, key.windowTo]
184
+ );
185
+ if (existing) {
186
+ db.run(
187
+ `UPDATE project_advice
188
+ SET session_count = ?, session_ids = ?, llm_advice = ?,
189
+ v = ?, cli = ?, cached_at = ?
190
+ WHERE project = ? AND scope = ? AND window_from = ? AND window_to = ?`,
191
+ [
192
+ meta.sessionCount, sessionIdsJson, json,
193
+ PROJECT_ADVICE_PROMPT_VERSION, meta.cli, new Date().toISOString(),
194
+ key.project, key.scope, key.windowFrom, key.windowTo,
195
+ ]
196
+ );
197
+ } else {
198
+ db.run(
199
+ `INSERT INTO project_advice
200
+ (project, scope, window_from, window_to, session_count, session_ids,
201
+ llm_advice, v, cli, cached_at)
202
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
203
+ [
204
+ key.project, key.scope, key.windowFrom, key.windowTo,
205
+ meta.sessionCount, sessionIdsJson, json,
206
+ PROJECT_ADVICE_PROMPT_VERSION, meta.cli, new Date().toISOString(),
207
+ ]
208
+ );
209
+ }
210
+ try { saveDb(); } catch { /* noop */ }
211
+ }
212
+
213
+ /**
214
+ * Return a compact index of every cached project_advice row for this
215
+ * project — no payloads, just window keys + metadata. The UI uses this
216
+ * to (a) pick a default window that actually has a cache, and (b) mark
217
+ * which tabs already have results.
218
+ *
219
+ * @param {object} db
220
+ * @param {string} project canonical project path
221
+ * @returns {Array<{ scope, windowFrom, windowTo, sessionCount, v, cachedAt }>}
222
+ */
223
+ function listProjectAdviceCaches(db, project) {
224
+ const rows = queryAll(
225
+ db,
226
+ `SELECT scope, window_from, window_to, session_count, v, cached_at
227
+ FROM project_advice
228
+ WHERE project = ?
229
+ ORDER BY cached_at DESC`,
230
+ [project]
231
+ );
232
+ return rows.map((r) => ({
233
+ scope: r.scope,
234
+ windowFrom: r.window_from,
235
+ windowTo: r.window_to,
236
+ sessionCount: r.session_count || 0,
237
+ v: r.v,
238
+ cachedAt: r.cached_at,
239
+ }));
240
+ }
241
+
242
+ /** Fresh = same prompt version AND same set of session_ids. */
243
+ function isCacheFresh(cached, currentIds) {
244
+ if (!cached || !cached.payload) return false;
245
+ if (cached.v !== PROJECT_ADVICE_PROMPT_VERSION) return false;
246
+ if ((cached.sessionIds || []).length !== currentIds.length) return false;
247
+ const a = [...cached.sessionIds].sort();
248
+ const b = [...currentIds].sort();
249
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
250
+ return true;
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // Stats aggregation
255
+ // ---------------------------------------------------------------------------
256
+
257
+ function summariseStats(db, sessions) {
258
+ let totalCost = 0;
259
+ let totalTokens = 0;
260
+ let totalErrors = 0;
261
+ let totalActiveMinutes = 0;
262
+ for (const s of sessions) {
263
+ totalCost += Number(s.cost) || 0;
264
+ totalErrors += Number(s.errorCount) || 0;
265
+ }
266
+ // Pull token / active_minute totals in one shot — we don't keep them on the
267
+ // already-trimmed session row to avoid loading bytes we won't use.
268
+ const ids = sessions.map((s) => s.id);
269
+ if (ids.length) {
270
+ const placeholders = ids.map(() => '?').join(',');
271
+ const row = queryOne(
272
+ db,
273
+ `SELECT SUM(COALESCE(tokens_input, 0) + COALESCE(tokens_output, 0)
274
+ + COALESCE(tokens_reasoning, 0)) AS tokens,
275
+ SUM(COALESCE(active_minutes, 0)) AS active_minutes
276
+ FROM unified_session
277
+ WHERE id IN (${placeholders})`,
278
+ ids
279
+ );
280
+ if (row) {
281
+ totalTokens = Number(row.tokens) || 0;
282
+ totalActiveMinutes = Number(row.active_minutes) || 0;
283
+ }
284
+ }
285
+ return {
286
+ sessionCount: sessions.length,
287
+ totalCost,
288
+ totalTokens,
289
+ totalErrors,
290
+ totalActiveMinutes,
291
+ };
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // Public: generateProjectAdvice
296
+ // ---------------------------------------------------------------------------
297
+
298
+ /**
299
+ * Resolve sessions and (optionally) generate cross-session advice.
300
+ *
301
+ * Required opts:
302
+ * - project (string) raw or canonical path
303
+ * - scope ('daily'|'weekly'|'all')
304
+ * - windowFrom (YYYY-MM-DD, optional when scope='all')
305
+ * - windowTo (YYYY-MM-DD, optional when scope='all')
306
+ *
307
+ * Optional opts:
308
+ * - force (boolean, default false) bypass cache
309
+ *
310
+ * @returns {Promise<
311
+ * { ok: true, data: { project, scope, windowFrom, windowTo, payload,
312
+ * sessionCount, sessionsWithAdvice, missingAdvice,
313
+ * cachedAt, fromCache },
314
+ * payload?: object }
315
+ * | { ok: false, reason: string, error?: string }
316
+ * >}
317
+ */
318
+ async function generateProjectAdvice(db, opts = {}) {
319
+ const force = opts.force === true;
320
+ const log = (...a) => {
321
+ if (process.env.ABOSS_ADVICE_DEBUG === '1') {
322
+ console.error('[project-advice]', opts.project, opts.scope, ...a);
323
+ }
324
+ };
325
+
326
+ try {
327
+ const project = canonicalProject(opts.project || '');
328
+ const scope = opts.scope || 'all';
329
+ const windowFrom = scope === 'all' ? '' : (opts.windowFrom || '');
330
+ const windowTo = scope === 'all' ? '' : (opts.windowTo || '');
331
+
332
+ if (!project) return { ok: false, reason: 'no-project' };
333
+ if (scope !== 'all' && (!windowFrom || !windowTo)) {
334
+ return { ok: false, reason: 'no-window' };
335
+ }
336
+
337
+ // 1. settings gate
338
+ const settings = getSettings(db);
339
+ if (!settings.enable_llm_judge) return { ok: false, reason: 'llm-disabled' };
340
+
341
+ // 2. resolve sessions
342
+ const raw = resolveProjectSessions(db, project, windowFrom, windowTo);
343
+ if (raw.length === 0) return { ok: false, reason: 'no-sessions' };
344
+
345
+ // 3. attach per-session advice; only keep ones that already have advice.
346
+ const enriched = attachSessionAdvice(db, raw);
347
+ const withAdvice = enriched.filter((s) => s.advice && s.advice.categories);
348
+ const missing = enriched.length - withAdvice.length;
349
+ if (withAdvice.length === 0) {
350
+ return {
351
+ ok: false,
352
+ reason: 'no-session-advice',
353
+ meta: { sessionCount: enriched.length, missingAdvice: missing },
354
+ };
355
+ }
356
+
357
+ const key = { project, scope, windowFrom, windowTo };
358
+ const currentIds = withAdvice.map((s) => s.id);
359
+
360
+ // 4. cache check
361
+ const cached = loadProjectAdvice(db, project, scope, windowFrom, windowTo);
362
+ if (!force && isCacheFresh(cached, currentIds)) {
363
+ log('cache hit');
364
+ return {
365
+ ok: true,
366
+ data: {
367
+ ...key,
368
+ payload: cached.payload,
369
+ sessionCount: cached.sessionCount,
370
+ sessionsWithAdvice: withAdvice.length,
371
+ missingAdvice: missing,
372
+ cachedAt: cached.cachedAt,
373
+ fromCache: true,
374
+ },
375
+ };
376
+ }
377
+
378
+ // 5. CLI detection
379
+ const cli = await detectAvailableCli();
380
+ if (!cli) return { ok: false, reason: 'no-cli' };
381
+
382
+ // 6. assemble + truncate
383
+ const stats = summariseStats(db, withAdvice);
384
+ const ctx = annotateContext({
385
+ project, scope, windowFrom, windowTo,
386
+ stats,
387
+ sessions: withAdvice,
388
+ });
389
+ const trimmed = truncateContext(ctx);
390
+ const prompt = buildProjectAdvicePrompt(trimmed);
391
+ log('spawning', cli.name, 'prompt bytes=', prompt.length,
392
+ 'truncated=', trimmed.truncated, 'sessions=', trimmed.sessions.length);
393
+
394
+ // 7. run
395
+ const result = await withSlot(() => runJudge({ prompt, timeoutMs: 120_000 }));
396
+ if (!result.ok) {
397
+ return { ok: false, reason: result.reason, error: result.error };
398
+ }
399
+
400
+ const payload = normaliseProjectPayload(result.data, {
401
+ truncated: trimmed.truncated,
402
+ omittedSessions: trimmed.omittedSessions,
403
+ });
404
+
405
+ // 8. persist
406
+ storeProjectAdvice(db, key, payload, {
407
+ sessionCount: withAdvice.length,
408
+ sessionIds: currentIds,
409
+ cli: result.cli,
410
+ });
411
+
412
+ return {
413
+ ok: true,
414
+ data: {
415
+ ...key,
416
+ payload,
417
+ sessionCount: withAdvice.length,
418
+ sessionsWithAdvice: withAdvice.length,
419
+ missingAdvice: missing,
420
+ cachedAt: new Date().toISOString(),
421
+ fromCache: false,
422
+ },
423
+ };
424
+ } catch (err) {
425
+ log('internal', err && err.message);
426
+ return { ok: false, reason: 'internal', error: err && err.message };
427
+ }
428
+ }
429
+
430
+ // ---------------------------------------------------------------------------
431
+ // Payload normalisation (mirrors session normaliseAdvicePayload)
432
+ // ---------------------------------------------------------------------------
433
+
434
+ const ALL_CATEGORIES = ['cost', 'accuracy', 'context', 'skills', 'workflow'];
435
+ const ALL_SEVERITIES = ['high', 'medium', 'low'];
436
+ const ALL_EXECUTORS = ['opencode', 'claude', 'manual'];
437
+ const ALL_CWD_HINTS = ['project_root'];
438
+
439
+ function normaliseProjectPayload(raw, meta) {
440
+ const cats = (raw && typeof raw.categories === 'object' && raw.categories) || {};
441
+ const categories = {};
442
+ for (const key of ALL_CATEGORIES) {
443
+ const arr = Array.isArray(cats[key]) ? cats[key] : [];
444
+ categories[key] = arr.map(normaliseItem).filter((it) => it && it.evidence);
445
+ }
446
+ const patternsRaw = Array.isArray(raw && raw.crossSessionPatterns) ? raw.crossSessionPatterns : [];
447
+ const crossSessionPatterns = patternsRaw
448
+ .filter((p) => typeof p === 'string')
449
+ .map((p) => p.trim())
450
+ .filter(Boolean)
451
+ .slice(0, 5);
452
+ return {
453
+ v: PROJECT_ADVICE_PROMPT_VERSION,
454
+ cachedAt: new Date().toISOString(),
455
+ truncated: meta.truncated || false,
456
+ omittedSessions: meta.omittedSessions || 0,
457
+ summary: typeof raw?.summary === 'string' ? raw.summary : '',
458
+ crossSessionPatterns,
459
+ categories,
460
+ rationale: typeof raw?.rationale === 'string' ? raw.rationale : '',
461
+ };
462
+ }
463
+
464
+ function normaliseItem(it) {
465
+ if (!it || typeof it !== 'object') return null;
466
+ const severity = ALL_SEVERITIES.includes(it.severity) ? it.severity : 'low';
467
+ let executor = ALL_EXECUTORS.includes(it.executor) ? it.executor : 'manual';
468
+ let actionable = it.actionable === true;
469
+ const cwd_hint = ALL_CWD_HINTS.includes(it.cwd_hint) ? it.cwd_hint : 'project_root';
470
+ if (executor === 'manual') actionable = false;
471
+ if (actionable && executor === 'manual') executor = 'opencode';
472
+ return {
473
+ severity,
474
+ title: typeof it.title === 'string' ? it.title.trim() : '',
475
+ why: typeof it.why === 'string' ? it.why.trim() : '',
476
+ action: typeof it.action === 'string' ? it.action.trim() : '',
477
+ evidence: typeof it.evidence === 'string' ? it.evidence.trim() : '',
478
+ actionable,
479
+ executor,
480
+ cwd_hint,
481
+ };
482
+ }
483
+
484
+ module.exports = {
485
+ generateProjectAdvice,
486
+ loadProjectAdvice,
487
+ listProjectAdviceCaches,
488
+ resolveProjectSessions,
489
+ attachSessionAdvice,
490
+ invalidateProjectAdviceSettingsCache,
491
+ };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Unified per-session LLM analyzer — ONE CLI call that returns both the
3
+ * v2.1 capability scores and the collaboration advice.
4
+ *
5
+ * Supersedes the two separate calls (judge.judgeSession + advice.generateAdvice).
6
+ * Pipeline:
7
+ * 1. settings gate (enable_llm_judge)
8
+ * 2. assemble context (reuses advice.assembleContext) + real difficulty
9
+ * 3. cache check in session_analysis.llm_judge_v2 (v + msgCount)
10
+ * 4. truncate + build combined prompt
11
+ * 5. runJudge under withSlot (90 s)
12
+ * 6. return { scores, advice, rationale, v, msgCount, cli, cachedAt } | null
13
+ *
14
+ * Returns null on disabled / no-cli / failure so callers fall back to rules.
15
+ *
16
+ * @author Felix
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const { detectAvailableCli, runJudge, withSlot } = require('./cli-runner');
22
+ const {
23
+ ANALYSIS_PROMPT_VERSION,
24
+ buildSessionAnalysisPrompt,
25
+ truncateContext,
26
+ } = require('./analysis-prompt');
27
+ const { assembleContext } = require('./advice');
28
+ const { classifySession } = require('../analysis/difficulty');
29
+ const { queryOne } = require('../db/queries');
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Settings gate (mirrors judge.js / advice.js; tiny TTL cache)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ let _settingsCache = null;
36
+ let _settingsCacheAt = 0;
37
+ const SETTINGS_TTL_MS = 10_000;
38
+
39
+ function getSettings(db) {
40
+ const now = Date.now();
41
+ if (_settingsCache && now - _settingsCacheAt < SETTINGS_TTL_MS) return _settingsCache;
42
+ const rows = db.exec("SELECT value FROM user_settings WHERE key = 'enable_llm_judge'");
43
+ let enable = false;
44
+ if (rows[0]) {
45
+ for (const [v] of rows[0].values) {
46
+ enable = String(v) === '1' || String(v).toLowerCase() === 'true';
47
+ }
48
+ }
49
+ _settingsCache = { enable_llm_judge: enable };
50
+ _settingsCacheAt = now;
51
+ return _settingsCache;
52
+ }
53
+
54
+ /** Drop the settings cache (called by PUT /api/settings). */
55
+ function invalidateAnalyzerSettingsCache() { _settingsCache = null; }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Cache
59
+ // ---------------------------------------------------------------------------
60
+
61
+ function loadCache(db, sessionId) {
62
+ const row = queryOne(db, 'SELECT llm_judge_v2 FROM session_analysis WHERE session_id = ?', [sessionId]);
63
+ if (!row || !row.llm_judge_v2) return null;
64
+ try { return JSON.parse(row.llm_judge_v2); }
65
+ catch { return null; }
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Public: analyzeSessionLLM
70
+ // ---------------------------------------------------------------------------
71
+
72
+ /**
73
+ * Run (or return cached) the combined scores+advice analysis for a session.
74
+ *
75
+ * @param {object} db
76
+ * @param {object} session unified_session row
77
+ * @param {object} [opts] { force?: boolean }
78
+ * @returns {Promise<{scores:object, advice:object, rationale?:string,
79
+ * v:number, msgCount:number, cli:string, cachedAt:string} | null>}
80
+ */
81
+ async function analyzeSessionLLM(db, session, opts = {}) {
82
+ const settings = getSettings(db);
83
+ if (!settings.enable_llm_judge) return null;
84
+
85
+ const ctxFull = assembleContext(db, session.id);
86
+ if (!ctxFull) return null;
87
+ const msgCount = ctxFull.messages.length;
88
+
89
+ if (opts.force !== true) {
90
+ const cache = loadCache(db, session.id);
91
+ if (cache && cache.v === ANALYSIS_PROMPT_VERSION && cache.msgCount === msgCount && cache.scores) {
92
+ return cache;
93
+ }
94
+ }
95
+
96
+ const cli = await detectAvailableCli();
97
+ if (!cli) return null;
98
+
99
+ // Surface the REAL difficulty to the rubric (advice.assembleContext nulls
100
+ // it out on purpose; scoring needs it).
101
+ const difficulty = classifySession(session).bucket;
102
+ ctxFull.session.difficulty = difficulty;
103
+
104
+ const ctx = truncateContext(ctxFull);
105
+ ctx.session = ctxFull.session; // truncateContext shallow-copies; keep difficulty
106
+ const prompt = buildSessionAnalysisPrompt(ctx);
107
+
108
+ const result = await withSlot(() => runJudge({ prompt, timeoutMs: 90_000 }));
109
+ if (!result.ok || !result.data || !result.data.scores) return null;
110
+
111
+ return {
112
+ scores: result.data.scores,
113
+ advice: result.data.advice || null,
114
+ rationale: typeof result.data.rationale === 'string' ? result.data.rationale : '',
115
+ v: ANALYSIS_PROMPT_VERSION,
116
+ msgCount,
117
+ cli: result.cli,
118
+ cachedAt: new Date().toISOString(),
119
+ };
120
+ }
121
+
122
+ module.exports = { analyzeSessionLLM, invalidateAnalyzerSettingsCache };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Project path helpers shared across API + analysis layers.
3
+ *
4
+ * The canonical form is the single source of truth for "is this the same
5
+ * project?" comparisons. Always normalise before using a path as a map
6
+ * key, as a DB primary key, or before deciding two rows belong together.
7
+ *
8
+ * Lifted out of server/api/overview.js so the yesterday-report builder
9
+ * and the project-detail page can collapse paths the same way.
10
+ *
11
+ * @author Felix
12
+ */
13
+
14
+ /**
15
+ * Normalise a project path so equivalent paths collapse to one row.
16
+ *
17
+ * Real-world data has two issues that need cleaning before grouping:
18
+ * 1. OpenCode sometimes records Windows drives as "C//felix/code/X"
19
+ * (the colon got dropped); we treat that as "C:/felix/code/X".
20
+ * 2. Trailing slashes and back-slash / forward-slash mixing also
21
+ * produce duplicates.
22
+ *
23
+ * The returned canonical form uses forward slashes only, no trailing
24
+ * slash, and re-inserts the colon after a single-letter drive prefix.
25
+ *
26
+ * @param {string} p
27
+ * @returns {string}
28
+ */
29
+ function canonicalProject(p) {
30
+ if (!p) return p;
31
+ let s = String(p).replace(/\\/g, '/'); // back-slash → forward
32
+ s = s.replace(/^([A-Za-z])\/\//, '$1://'); // "C//foo" → "C://foo"
33
+ s = s.replace(/^([A-Za-z]):?\/+/, '$1:/'); // collapse "C:////" or "C/" → "C:/"
34
+ s = s.replace(/\/+/g, '/'); // collapse internal "//"
35
+ s = s.replace(/\/+$/, ''); // strip trailing slash
36
+ return s;
37
+ }
38
+
39
+ /**
40
+ * Reshape and de-duplicate top-project rows. Groups by canonical project
41
+ * key, sums all numeric fields, then sorts by cost desc and applies the
42
+ * requested limit.
43
+ *
44
+ * @param {Object[]} rows raw SQL rows (already aggregated by raw project)
45
+ * @param {number} limit
46
+ * @returns {Object[]}
47
+ */
48
+ function mapTopProjects(rows, limit) {
49
+ const byKey = new Map();
50
+ for (const r of rows) {
51
+ const key = canonicalProject(r.project);
52
+ if (!byKey.has(key)) {
53
+ byKey.set(key, {
54
+ project: key,
55
+ sessions: 0,
56
+ cost: 0,
57
+ additions: 0,
58
+ deletions: 0,
59
+ files: 0,
60
+ });
61
+ }
62
+ const acc = byKey.get(key);
63
+ acc.sessions += r.sessions || 0;
64
+ acc.cost += r.cost || 0;
65
+ acc.additions += r.additions || 0;
66
+ acc.deletions += r.deletions || 0;
67
+ acc.files += r.files || 0;
68
+ }
69
+ const merged = Array.from(byKey.values()).map((r) => ({
70
+ ...r,
71
+ cost: Math.round(r.cost * 10000) / 10000,
72
+ }));
73
+ merged.sort((a, b) => (b.cost - a.cost) || (b.sessions - a.sessions));
74
+ return merged.slice(0, limit);
75
+ }
76
+
77
+ module.exports = {
78
+ canonicalProject,
79
+ mapTopProjects,
80
+ };