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.
- package/README.md +34 -0
- package/bin/aboss.js +288 -0
- package/client/dist/assets/index-C1wFD_Vo.css +1 -0
- package/client/dist/assets/index-DBj1Ujlx.js +137 -0
- package/client/dist/index.html +34 -0
- package/package.json +64 -0
- package/server/analysis/daily-aggregator.js +258 -0
- package/server/analysis/difficulty.js +129 -0
- package/server/analysis/dimensions/ai-knowledge.js +172 -0
- package/server/analysis/dimensions/ai-tools.js +161 -0
- package/server/analysis/dimensions/judgement.js +107 -0
- package/server/analysis/dimensions/llm-merge.js +57 -0
- package/server/analysis/dimensions/output-quality.js +167 -0
- package/server/analysis/dimensions/problem-definition.js +104 -0
- package/server/analysis/dimensions/system-thinking.js +225 -0
- package/server/analysis/evidence-builder.js +104 -0
- package/server/analysis/job.js +273 -0
- package/server/analysis/report-builder.js +581 -0
- package/server/analysis/scoring-v2.js +72 -0
- package/server/analysis/text-signals.js +179 -0
- package/server/analysis/thresholds-v2.js +358 -0
- package/server/api/advice.js +124 -0
- package/server/api/analysis.js +141 -0
- package/server/api/execution.js +330 -0
- package/server/api/metrics.js +277 -0
- package/server/api/overview.js +308 -0
- package/server/api/project.js +255 -0
- package/server/api/reports.js +125 -0
- package/server/api/sessions.js +118 -0
- package/server/api/settings.js +119 -0
- package/server/db/connection.js +175 -0
- package/server/db/queries.js +1051 -0
- package/server/db/schema.js +487 -0
- package/server/etl/active-time.js +150 -0
- package/server/etl/backfill-subagents.js +178 -0
- package/server/etl/claude-code.js +826 -0
- package/server/etl/detect.js +341 -0
- package/server/etl/judge-filter.js +117 -0
- package/server/etl/opencode.js +606 -0
- package/server/execution/job.js +662 -0
- package/server/execution/prompt.js +227 -0
- package/server/execution/runner.js +218 -0
- package/server/index.js +94 -0
- package/server/llm/advice-prompt.js +339 -0
- package/server/llm/advice.js +384 -0
- package/server/llm/analysis-prompt.js +162 -0
- package/server/llm/cli-runner.js +249 -0
- package/server/llm/judge-prompts.js +179 -0
- package/server/llm/judge.js +118 -0
- package/server/llm/project-advice-prompt.js +332 -0
- package/server/llm/project-advice.js +491 -0
- package/server/llm/session-analyzer.js +122 -0
- 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
|
+
};
|