@burtson-labs/host-kit 0.3.1
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/LICENSE +201 -0
- package/README.md +55 -0
- package/dist/backgroundTasks.d.ts +113 -0
- package/dist/backgroundTasks.d.ts.map +1 -0
- package/dist/backgroundTasks.js +137 -0
- package/dist/backgroundTasks.js.map +1 -0
- package/dist/checkpoints.d.ts +99 -0
- package/dist/checkpoints.d.ts.map +1 -0
- package/dist/checkpoints.js +227 -0
- package/dist/checkpoints.js.map +1 -0
- package/dist/hooks.d.ts +51 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +152 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +95 -0
- package/dist/index.js.map +1 -0
- package/dist/insights.d.ts +398 -0
- package/dist/insights.d.ts.map +1 -0
- package/dist/insights.js +1933 -0
- package/dist/insights.js.map +1 -0
- package/dist/mcp.d.ts +60 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +281 -0
- package/dist/mcp.js.map +1 -0
- package/dist/mcpConnectors.d.ts +108 -0
- package/dist/mcpConnectors.d.ts.map +1 -0
- package/dist/mcpConnectors.js +217 -0
- package/dist/mcpConnectors.js.map +1 -0
- package/dist/mcpToolCache.d.ts +43 -0
- package/dist/mcpToolCache.d.ts.map +1 -0
- package/dist/mcpToolCache.js +150 -0
- package/dist/mcpToolCache.js.map +1 -0
- package/dist/mcpTrust.d.ts +22 -0
- package/dist/mcpTrust.d.ts.map +1 -0
- package/dist/mcpTrust.js +104 -0
- package/dist/mcpTrust.js.map +1 -0
- package/dist/memory.d.ts +38 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +151 -0
- package/dist/memory.js.map +1 -0
- package/dist/memoryIndex.d.ts +38 -0
- package/dist/memoryIndex.d.ts.map +1 -0
- package/dist/memoryIndex.js +142 -0
- package/dist/memoryIndex.js.map +1 -0
- package/dist/mentions.d.ts +33 -0
- package/dist/mentions.d.ts.map +1 -0
- package/dist/mentions.js +126 -0
- package/dist/mentions.js.map +1 -0
- package/dist/ollamaModels.d.ts +36 -0
- package/dist/ollamaModels.d.ts.map +1 -0
- package/dist/ollamaModels.js +83 -0
- package/dist/ollamaModels.js.map +1 -0
- package/dist/permissions.d.ts +72 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +271 -0
- package/dist/permissions.js.map +1 -0
- package/dist/tools/extraTools.d.ts +80 -0
- package/dist/tools/extraTools.d.ts.map +1 -0
- package/dist/tools/extraTools.js +471 -0
- package/dist/tools/extraTools.js.map +1 -0
- package/dist/tools/readMemoryTool.d.ts +3 -0
- package/dist/tools/readMemoryTool.d.ts.map +1 -0
- package/dist/tools/readMemoryTool.js +115 -0
- package/dist/tools/readMemoryTool.js.map +1 -0
- package/dist/tools/taskTool.d.ts +119 -0
- package/dist/tools/taskTool.d.ts.map +1 -0
- package/dist/tools/taskTool.js +466 -0
- package/dist/tools/taskTool.js.map +1 -0
- package/dist/tools/testRunTool.d.ts +59 -0
- package/dist/tools/testRunTool.d.ts.map +1 -0
- package/dist/tools/testRunTool.js +308 -0
- package/dist/tools/testRunTool.js.map +1 -0
- package/dist/turnLog.d.ts +89 -0
- package/dist/turnLog.d.ts.map +1 -0
- package/dist/turnLog.js +469 -0
- package/dist/turnLog.js.map +1 -0
- package/package.json +35 -0
package/dist/insights.js
ADDED
|
@@ -0,0 +1,1933 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* `bandit insights` — generate a stand-alone HTML report from local
|
|
4
|
+
* session + turn-log data so the user can see how they (and the agent)
|
|
5
|
+
* are actually using bandit. Written as a single self-contained .html
|
|
6
|
+
* file with inline CSS and inline SVG charts — no server, no external
|
|
7
|
+
* resources, opens in any browser, sharable as one file.
|
|
8
|
+
*
|
|
9
|
+
* Data sources (all local, no network):
|
|
10
|
+
* - ~/.bandit/sessions/*.jsonl — every REPL session, role+content
|
|
11
|
+
* - <cwd>/.bandit/turns/*.jsonl — per-turn telemetry for the
|
|
12
|
+
* current workspace (tool calls,
|
|
13
|
+
* results, errors, timestamps)
|
|
14
|
+
* - <cwd>/.bandit/agent-report.json (when present, for plan goals)
|
|
15
|
+
*
|
|
16
|
+
* The pipeline is intentionally tolerant — corrupt JSONL lines are
|
|
17
|
+
* skipped, missing files are no-ops, individual turn-log fields can be
|
|
18
|
+
* absent. The goal is "show what we have," not "fail because one
|
|
19
|
+
* record was malformed."
|
|
20
|
+
*/
|
|
21
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
22
|
+
if (k2 === undefined) k2 = k;
|
|
23
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
24
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
25
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
26
|
+
}
|
|
27
|
+
Object.defineProperty(o, k2, desc);
|
|
28
|
+
}) : (function(o, m, k, k2) {
|
|
29
|
+
if (k2 === undefined) k2 = k;
|
|
30
|
+
o[k2] = m[k];
|
|
31
|
+
}));
|
|
32
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
33
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
34
|
+
}) : function(o, v) {
|
|
35
|
+
o["default"] = v;
|
|
36
|
+
});
|
|
37
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
38
|
+
var ownKeys = function(o) {
|
|
39
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
40
|
+
var ar = [];
|
|
41
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
42
|
+
return ar;
|
|
43
|
+
};
|
|
44
|
+
return ownKeys(o);
|
|
45
|
+
};
|
|
46
|
+
return function (mod) {
|
|
47
|
+
if (mod && mod.__esModule) return mod;
|
|
48
|
+
var result = {};
|
|
49
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
50
|
+
__setModuleDefault(result, mod);
|
|
51
|
+
return result;
|
|
52
|
+
};
|
|
53
|
+
})();
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.computeInsights = computeInsights;
|
|
56
|
+
exports.buildAiInput = buildAiInput;
|
|
57
|
+
exports.buildInsightsAiCallback = buildInsightsAiCallback;
|
|
58
|
+
exports.renderInsightsHtml = renderInsightsHtml;
|
|
59
|
+
exports.writeInsightsReport = writeInsightsReport;
|
|
60
|
+
const fs = __importStar(require("fs"));
|
|
61
|
+
const os = __importStar(require("os"));
|
|
62
|
+
const path = __importStar(require("path"));
|
|
63
|
+
const HOME = os.homedir();
|
|
64
|
+
/**
|
|
65
|
+
* Walk every .jsonl session file in `~/.bandit/sessions/`, parse what
|
|
66
|
+
* we can, and aggregate per-session stats. Tolerant: any individual
|
|
67
|
+
* line that doesn't parse as JSON gets skipped silently. We never
|
|
68
|
+
* throw on individual file failures — a corrupt session shouldn't
|
|
69
|
+
* block the whole report.
|
|
70
|
+
*/
|
|
71
|
+
function loadSessions() {
|
|
72
|
+
const dir = path.join(HOME, '.bandit', 'sessions');
|
|
73
|
+
if (!fs.existsSync(dir))
|
|
74
|
+
return [];
|
|
75
|
+
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'));
|
|
76
|
+
const sessions = [];
|
|
77
|
+
for (const filename of files) {
|
|
78
|
+
const id = filename.replace(/\.jsonl$/, '');
|
|
79
|
+
// Filenames look like YYYYMMDD-HHMMSS-xxxx. Parse to ms-epoch when
|
|
80
|
+
// we can; fall back to the file's mtime when the name is non-standard.
|
|
81
|
+
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-/.exec(id);
|
|
82
|
+
let startedAt;
|
|
83
|
+
if (m) {
|
|
84
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
85
|
+
startedAt = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}`).getTime();
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
try {
|
|
89
|
+
startedAt = fs.statSync(path.join(dir, filename)).mtimeMs;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
startedAt = 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
let prompts = 0;
|
|
96
|
+
let assistantTurns = 0;
|
|
97
|
+
let approxChars = 0;
|
|
98
|
+
let toolCallCount = 0;
|
|
99
|
+
const toolNames = new Map();
|
|
100
|
+
try {
|
|
101
|
+
const text = fs.readFileSync(path.join(dir, filename), 'utf-8');
|
|
102
|
+
for (const line of text.split('\n')) {
|
|
103
|
+
if (!line.trim())
|
|
104
|
+
continue;
|
|
105
|
+
let parsed = null;
|
|
106
|
+
try {
|
|
107
|
+
parsed = JSON.parse(line);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!parsed || typeof parsed.content !== 'string')
|
|
113
|
+
continue;
|
|
114
|
+
approxChars += parsed.content.length;
|
|
115
|
+
if (parsed.role === 'user') {
|
|
116
|
+
// Tool-result messages are stored as user-role with a
|
|
117
|
+
// <tool_result> wrapper — exclude those from "user prompts."
|
|
118
|
+
// True user prompts are the rest.
|
|
119
|
+
if (!parsed.content.startsWith('<tool_result'))
|
|
120
|
+
prompts += 1;
|
|
121
|
+
}
|
|
122
|
+
else if (parsed.role === 'assistant') {
|
|
123
|
+
assistantTurns += 1;
|
|
124
|
+
// Count tool calls in the assistant content. Cheap regex,
|
|
125
|
+
// tolerant of either text-style <tool_call> or native JSON.
|
|
126
|
+
const matches = parsed.content.matchAll(/<tool_call>\s*\{[\s\S]*?"name"\s*:\s*"([^"]+)"/g);
|
|
127
|
+
for (const match of matches) {
|
|
128
|
+
toolCallCount += 1;
|
|
129
|
+
const name = match[1];
|
|
130
|
+
toolNames.set(name, (toolNames.get(name) ?? 0) + 1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* unreadable file — skip with default zeros */
|
|
137
|
+
}
|
|
138
|
+
sessions.push({ id, startedAt, prompts, assistantTurns, approxChars, toolCallCount, toolNames });
|
|
139
|
+
}
|
|
140
|
+
return sessions.sort((a, b) => b.startedAt - a.startedAt);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Walk turn logs from both scopes Bandit has used historically:
|
|
144
|
+
*
|
|
145
|
+
* - nearest workspace `.bandit/turns`
|
|
146
|
+
* - global `~/.bandit/turns`
|
|
147
|
+
*
|
|
148
|
+
* The report itself is global (`~/.bandit/insights.html`) and sessions
|
|
149
|
+
* are global, so excluding the global turn directory makes the report
|
|
150
|
+
* miss cross-repo arcs like Gmail/MCP cleanup or portfolio work that
|
|
151
|
+
* happened outside the repo where `/insights` was invoked.
|
|
152
|
+
*/
|
|
153
|
+
function loadTurnFiles(cwd) {
|
|
154
|
+
const dirs = [];
|
|
155
|
+
const seenDirs = new Set();
|
|
156
|
+
let workspace = cwd;
|
|
157
|
+
for (let depth = 0; depth < 6 && workspace !== '/'; depth += 1) {
|
|
158
|
+
const candidate = path.join(workspace, '.bandit', 'turns');
|
|
159
|
+
if (fs.existsSync(candidate)) {
|
|
160
|
+
dirs.push({ dir: candidate, workspace });
|
|
161
|
+
seenDirs.add(path.resolve(candidate));
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
workspace = path.dirname(workspace);
|
|
165
|
+
}
|
|
166
|
+
const globalTurns = path.join(HOME, '.bandit', 'turns');
|
|
167
|
+
if (fs.existsSync(globalTurns) && !seenDirs.has(path.resolve(globalTurns))) {
|
|
168
|
+
dirs.push({ dir: globalTurns, workspace: HOME });
|
|
169
|
+
}
|
|
170
|
+
const out = [];
|
|
171
|
+
for (const source of dirs) {
|
|
172
|
+
loadTurnFilesFromDir(source.dir, source.workspace, out);
|
|
173
|
+
}
|
|
174
|
+
return out.sort((a, b) => b.startedAt - a.startedAt);
|
|
175
|
+
}
|
|
176
|
+
function loadTurnFilesFromDir(dir, fallbackWorkspace, out) {
|
|
177
|
+
const files = fs.readdirSync(dir).filter((f) => f.startsWith('turn-') && f.endsWith('.jsonl'));
|
|
178
|
+
for (const filename of files) {
|
|
179
|
+
let startedAt = 0;
|
|
180
|
+
const m = /^turn-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})/.exec(filename);
|
|
181
|
+
if (m) {
|
|
182
|
+
const [, y, mo, d, h, mi, s] = m;
|
|
183
|
+
startedAt = new Date(`${y}-${mo}-${d}T${h}:${mi}:${s}Z`).getTime();
|
|
184
|
+
}
|
|
185
|
+
const events = [];
|
|
186
|
+
try {
|
|
187
|
+
const text = fs.readFileSync(path.join(dir, filename), 'utf-8');
|
|
188
|
+
for (const line of text.split('\n')) {
|
|
189
|
+
if (!line.trim())
|
|
190
|
+
continue;
|
|
191
|
+
try {
|
|
192
|
+
events.push(JSON.parse(line));
|
|
193
|
+
}
|
|
194
|
+
catch { /* skip */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
/* unreadable — skip */
|
|
199
|
+
}
|
|
200
|
+
out.push({ workspace: inferTurnWorkspace(events, fallbackWorkspace), filename, startedAt, events });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Mine turn logs for "what got accomplished" — distinct from raw tool
|
|
205
|
+
* counts. The numbers here go in the accomplishments hero section so
|
|
206
|
+
* the user sees outcomes ("47 files touched, 12 git operations") not
|
|
207
|
+
* just ("write_file ×34, apply_edit ×18, run_command ×62"). Heuristic
|
|
208
|
+
* but high-signal: the params dict is the source of truth for which
|
|
209
|
+
* file an edit hit, what a run_command was actually running, etc.
|
|
210
|
+
*/
|
|
211
|
+
/** File extensions → display labels for the "languages touched"
|
|
212
|
+
* breakdown. Anything not on the list collapses to "Other". Order
|
|
213
|
+
* doesn't matter — the render sorts by count. */
|
|
214
|
+
const LANG_BY_EXT = {
|
|
215
|
+
ts: 'TypeScript', tsx: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript', cjs: 'JavaScript',
|
|
216
|
+
py: 'Python', rs: 'Rust', go: 'Go', java: 'Java', kt: 'Kotlin', kts: 'Kotlin',
|
|
217
|
+
rb: 'Ruby', php: 'PHP', swift: 'Swift', cs: 'C#', cpp: 'C++', cc: 'C++', cxx: 'C++', c: 'C', h: 'C/C++', hpp: 'C++',
|
|
218
|
+
scala: 'Scala', clj: 'Clojure', ex: 'Elixir', exs: 'Elixir', erl: 'Erlang',
|
|
219
|
+
sh: 'Shell', bash: 'Shell', zsh: 'Shell', fish: 'Shell',
|
|
220
|
+
md: 'Markdown', mdx: 'Markdown', html: 'HTML', css: 'CSS', scss: 'SCSS', less: 'Less',
|
|
221
|
+
json: 'JSON', yaml: 'YAML', yml: 'YAML', toml: 'TOML', xml: 'XML',
|
|
222
|
+
sql: 'SQL', graphql: 'GraphQL', proto: 'Protobuf',
|
|
223
|
+
vue: 'Vue', svelte: 'Svelte', astro: 'Astro',
|
|
224
|
+
dockerfile: 'Docker', tf: 'Terraform'
|
|
225
|
+
};
|
|
226
|
+
// Test-runner pattern: matches the most common JS/Python/.NET/Go
|
|
227
|
+
// test invocations. The agent shells out to these via run_command,
|
|
228
|
+
// but newer builds also expose `test_run` and some skills expose
|
|
229
|
+
// `run_tests`, so the accomplishment pass recognizes all three.
|
|
230
|
+
const TEST_RUNNER_RE = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test|\bvitest\b|\bjest\b|\bpytest\b|\bdotnet\s+test\b|\bgo\s+test\b|\bcargo\s+test\b|\bmix\s+test\b/i;
|
|
231
|
+
const EDIT_TOOLS = new Set(['write_file', 'apply_edit', 'replace_range', 'apply_patch']);
|
|
232
|
+
const READ_TOOLS = new Set(['read_file', 'ls', 'list_files', 'search_code', 'web_fetch']);
|
|
233
|
+
const GIT_TOOLS = new Set(['git_status', 'git_diff', 'git_log', 'git_commit', 'git_branch', 'git_checkout', 'git_stash', 'git_pull', 'git_push']);
|
|
234
|
+
const EXTERNAL_READ_PREFIX_RE = /^(?:list|get|search|read|fetch|triage|inspect|check|describe|lookup|find)/i;
|
|
235
|
+
const EXTERNAL_MUTATION_RE = /^(?:create|update|modify|delete|remove|trash|archive|send|post|add|insert|replace|rename|move|copy|upload|revoke|grant|apply|set|mark|label)/i;
|
|
236
|
+
/** Replace a leading absolute home path with `~/...` so shared reports
|
|
237
|
+
* don't leak `/Users/<name>/` layouts. Anything that isn't under the
|
|
238
|
+
* active home directory is returned unchanged. */
|
|
239
|
+
function normalizePath(p) {
|
|
240
|
+
if (p.startsWith(HOME + '/'))
|
|
241
|
+
return '~/' + p.slice(HOME.length + 1);
|
|
242
|
+
if (p === HOME)
|
|
243
|
+
return '~';
|
|
244
|
+
return p;
|
|
245
|
+
}
|
|
246
|
+
function detectLanguage(filePath) {
|
|
247
|
+
const base = filePath.split('/').pop() ?? '';
|
|
248
|
+
if (base.toLowerCase() === 'dockerfile')
|
|
249
|
+
return 'Docker';
|
|
250
|
+
const ext = base.includes('.') ? base.slice(base.lastIndexOf('.') + 1).toLowerCase() : '';
|
|
251
|
+
if (!ext)
|
|
252
|
+
return null;
|
|
253
|
+
return LANG_BY_EXT[ext] ?? null;
|
|
254
|
+
}
|
|
255
|
+
function asParamString(params, key) {
|
|
256
|
+
const v = params[key];
|
|
257
|
+
return typeof v === 'string' ? v : '';
|
|
258
|
+
}
|
|
259
|
+
function commandLine(params) {
|
|
260
|
+
const cmd = asParamString(params, 'cmd');
|
|
261
|
+
const args = asParamString(params, 'args');
|
|
262
|
+
return `${cmd} ${args}`.trim();
|
|
263
|
+
}
|
|
264
|
+
function normalizeToolName(name) {
|
|
265
|
+
const withoutMcpPrefix = name.replace(/^mcp__[^_]+__/, '');
|
|
266
|
+
const parts = withoutMcpPrefix.split('.');
|
|
267
|
+
return parts[parts.length - 1] || withoutMcpPrefix;
|
|
268
|
+
}
|
|
269
|
+
function isExternalMutatingTool(name) {
|
|
270
|
+
if (!name.includes('.') && !name.startsWith('mcp__'))
|
|
271
|
+
return false;
|
|
272
|
+
const bare = normalizeToolName(name);
|
|
273
|
+
return EXTERNAL_MUTATION_RE.test(bare) && !EXTERNAL_READ_PREFIX_RE.test(bare);
|
|
274
|
+
}
|
|
275
|
+
function expandHome(value) {
|
|
276
|
+
if (value === '~')
|
|
277
|
+
return HOME;
|
|
278
|
+
if (value.startsWith('~/'))
|
|
279
|
+
return path.join(HOME, value.slice(2));
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
function collectStringValues(value, out) {
|
|
283
|
+
if (typeof value === 'string') {
|
|
284
|
+
out.push(value);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (Array.isArray(value)) {
|
|
288
|
+
for (const item of value)
|
|
289
|
+
collectStringValues(item, out);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (value && typeof value === 'object') {
|
|
293
|
+
for (const item of Object.values(value))
|
|
294
|
+
collectStringValues(item, out);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function inferWorkspaceFromText(value) {
|
|
298
|
+
const expanded = expandHome(value);
|
|
299
|
+
const githubRoot = path.join(HOME, 'Documents', 'GitHub') + path.sep;
|
|
300
|
+
const githubIndex = expanded.indexOf(githubRoot);
|
|
301
|
+
if (githubIndex >= 0) {
|
|
302
|
+
const rest = expanded.slice(githubIndex + githubRoot.length);
|
|
303
|
+
const repo = rest.split(/[/"'`\s:]+/)[0];
|
|
304
|
+
if (repo)
|
|
305
|
+
return path.join(githubRoot, repo);
|
|
306
|
+
}
|
|
307
|
+
const homePrefix = HOME + path.sep;
|
|
308
|
+
if (expanded.startsWith(homePrefix)) {
|
|
309
|
+
const rest = expanded.slice(homePrefix.length);
|
|
310
|
+
const first = rest.split(/[/"'`\s:]+/)[0];
|
|
311
|
+
if (first && /^[A-Za-z0-9._-]+$/.test(first) && !['Desktop', 'Documents', 'Downloads', 'Library'].includes(first)) {
|
|
312
|
+
return path.join(HOME, first);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
function inferTurnWorkspace(events, fallbackWorkspace) {
|
|
318
|
+
const candidates = new Map();
|
|
319
|
+
for (const ev of events) {
|
|
320
|
+
const values = [];
|
|
321
|
+
collectStringValues(ev.params, values);
|
|
322
|
+
collectStringValues(ev.prompt, values);
|
|
323
|
+
collectStringValues(ev.outputSnippet, values);
|
|
324
|
+
collectStringValues(ev.responsePreview, values);
|
|
325
|
+
collectStringValues(ev.finalPreview, values);
|
|
326
|
+
for (const value of values) {
|
|
327
|
+
const inferred = inferWorkspaceFromText(value);
|
|
328
|
+
if (inferred)
|
|
329
|
+
candidates.set(inferred, (candidates.get(inferred) ?? 0) + 1);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
const [top] = [...candidates.entries()].sort((a, b) => b[1] - a[1]);
|
|
333
|
+
return top?.[0] ?? fallbackWorkspace;
|
|
334
|
+
}
|
|
335
|
+
function isToolExecuteEvent(ev) {
|
|
336
|
+
return ev.type === 'tool-execute' || ev.type === 'subagent-tool-execute';
|
|
337
|
+
}
|
|
338
|
+
function isToolResultError(ev) {
|
|
339
|
+
return (ev.type === 'tool-result' || ev.type === 'subagent-tool-result' || ev.type === 'tool-error' || ev.type === 'subagent-tool-error') &&
|
|
340
|
+
(!!ev.isError || ev.type === 'tool-error' || ev.type === 'subagent-tool-error');
|
|
341
|
+
}
|
|
342
|
+
function addLanguage(langCounts, filePath) {
|
|
343
|
+
const lang = detectLanguage(filePath);
|
|
344
|
+
if (!lang)
|
|
345
|
+
return;
|
|
346
|
+
const set = langCounts.get(lang) ?? new Set();
|
|
347
|
+
set.add(filePath);
|
|
348
|
+
langCounts.set(lang, set);
|
|
349
|
+
}
|
|
350
|
+
function cleanSnippet(text, max = 160) {
|
|
351
|
+
return text
|
|
352
|
+
.replace(/```[\s\S]*?```/g, ' ')
|
|
353
|
+
.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, ' ')
|
|
354
|
+
.replace(/[#>*_`[\]]/g, '')
|
|
355
|
+
.replace(/\s+/g, ' ')
|
|
356
|
+
.trim()
|
|
357
|
+
.slice(0, max);
|
|
358
|
+
}
|
|
359
|
+
// Canonical titles are deliberately hand-picked — they collapse
|
|
360
|
+
// recurring multi-area efforts (self-eval sweeps, subagent research
|
|
361
|
+
// drills, repo explanations) into ONE highlight card aggregating all
|
|
362
|
+
// areas touched. Free-form prompt titles aren't canonical and stay
|
|
363
|
+
// per-area so they don't false-merge unrelated work.
|
|
364
|
+
const CANONICAL_TITLES = new Set();
|
|
365
|
+
function canonicalize(title) {
|
|
366
|
+
CANONICAL_TITLES.add(title);
|
|
367
|
+
return { title, canonical: true };
|
|
368
|
+
}
|
|
369
|
+
function titleFromPrompt(prompt) {
|
|
370
|
+
const cleaned = prompt
|
|
371
|
+
.replace(/\s+/g, ' ')
|
|
372
|
+
.replace(/^please\s+/i, '')
|
|
373
|
+
.trim();
|
|
374
|
+
if (!cleaned)
|
|
375
|
+
return { title: 'Untitled turn', canonical: false };
|
|
376
|
+
const lower = cleaned.toLowerCase();
|
|
377
|
+
if (/\b(?:gmail|inbox|email)\b/.test(lower) && /\b(?:mcp|archive|label|filter|cleanup|triage|clean)\b/.test(lower)) {
|
|
378
|
+
return canonicalize('Google MCP inbox automation and cleanup');
|
|
379
|
+
}
|
|
380
|
+
if (/\b(?:burtson-labs-mcp|mcp repo|google mcp|mcp server)\b/.test(lower)) {
|
|
381
|
+
return canonicalize('Burtson Labs Google MCP server buildout');
|
|
382
|
+
}
|
|
383
|
+
if (/\bportfolio\b/.test(lower) || /\bapp\.(?:jsx|tsx)\b/.test(lower)) {
|
|
384
|
+
return canonicalize('Portfolio build and refactor iterations');
|
|
385
|
+
}
|
|
386
|
+
if (/deep self[- ]evaluation|what (?:are you|is .*?) missing|better agent/.test(lower)) {
|
|
387
|
+
return canonicalize('Deep self-evaluation of Bandit agent capabilities');
|
|
388
|
+
}
|
|
389
|
+
if (/test using .*sub ?agents?|sub ?agents? to research/.test(lower)) {
|
|
390
|
+
return canonicalize('Subagent research test on this repo');
|
|
391
|
+
}
|
|
392
|
+
if (/explain what this repo does/.test(lower)) {
|
|
393
|
+
return canonicalize('Repo explanation');
|
|
394
|
+
}
|
|
395
|
+
if (/screenshot.*answer honestly|answer honestly.*screenshot/.test(lower)) {
|
|
396
|
+
return canonicalize('Honest assessment of screenshot prompt');
|
|
397
|
+
}
|
|
398
|
+
const firstSentence = cleaned.split(/(?<=[.!?])\s+/)[0] ?? cleaned;
|
|
399
|
+
const title = firstSentence.length > 110 ? firstSentence.slice(0, 107).trimEnd() + '...' : firstSentence;
|
|
400
|
+
return { title, canonical: false };
|
|
401
|
+
}
|
|
402
|
+
function isCanonicalTitle(title) {
|
|
403
|
+
return CANONICAL_TITLES.has(title);
|
|
404
|
+
}
|
|
405
|
+
function categoryFromPrompt(prompt, tools) {
|
|
406
|
+
const p = prompt.toLowerCase();
|
|
407
|
+
if (/\b(fix|bug|broken|error|failing|failure|regression|crash)\b/.test(p))
|
|
408
|
+
return 'Debugging';
|
|
409
|
+
if (/\b(test|verify|validation|coverage|smoke)\b/.test(p) || tools.has('test_run') || tools.has('run_tests'))
|
|
410
|
+
return 'Validation';
|
|
411
|
+
if (/\b(refactor|cleanup|clean up|restructure|split|extract)\b/.test(p))
|
|
412
|
+
return 'Refactor';
|
|
413
|
+
if (/\b(add|build|create|implement|ship|make|scaffold)\b/.test(p))
|
|
414
|
+
return 'Build';
|
|
415
|
+
if (/\b(review|audit|inspect|scan|evaluate|investigate|deep dive|self[- ]evaluation|improve|missing)\b/.test(p))
|
|
416
|
+
return 'Investigation';
|
|
417
|
+
if (/\b(doc|readme|email|draft|write|copy)\b/.test(p))
|
|
418
|
+
return 'Writing';
|
|
419
|
+
if (/\b(deploy|publish|release|version|npm|vsx|marketplace)\b/.test(p))
|
|
420
|
+
return 'Release';
|
|
421
|
+
if (tools.has('task'))
|
|
422
|
+
return 'Delegation';
|
|
423
|
+
return 'Working session';
|
|
424
|
+
}
|
|
425
|
+
function areaFromFilesAndPrompt(files, prompt) {
|
|
426
|
+
const buckets = [
|
|
427
|
+
{ label: 'Google/MCP server', re: /(?:^|\/)burtson-labs-mcp\//i, score: 0 },
|
|
428
|
+
{ label: 'Portfolio site', re: /(?:^|\/)(?:Portfolio|portfolio|mark-portfolio|Burtson\.io)\//, score: 0 },
|
|
429
|
+
{ label: 'Greg site', re: /(?:^|\/)gregoryhite-site\//, score: 0 },
|
|
430
|
+
{ label: 'Bandit CLI', re: /^apps\/bandit-cli\//, score: 0 },
|
|
431
|
+
{ label: 'VS Code extension', re: /^apps\/bandit-stealth\//, score: 0 },
|
|
432
|
+
{ label: 'Stealth web app', re: /^apps\/bandit-stealth-web\//, score: 0 },
|
|
433
|
+
{ label: 'Agent core', re: /^packages\/agent-core\//, score: 0 },
|
|
434
|
+
{ label: 'Stealth runtime', re: /^packages\/stealth-core-runtime\//, score: 0 },
|
|
435
|
+
{ label: 'Host kit', re: /^packages\/host-kit\//, score: 0 },
|
|
436
|
+
{ label: 'Agent UI', re: /^packages\/agent-ui\//, score: 0 },
|
|
437
|
+
{ label: 'Adapters', re: /^packages\/agent-adapters\//, score: 0 },
|
|
438
|
+
{ label: 'Deploy/Helm', re: /^(deploy|charts|apps\/[^/]+\/charts)\//, score: 0 },
|
|
439
|
+
{ label: 'Docs/roadmap', re: /^(docs|README\.md|SECURITY\.md|CONTRIBUTING\.md)/, score: 0 },
|
|
440
|
+
{ label: 'Examples', re: /^examples\//, score: 0 }
|
|
441
|
+
];
|
|
442
|
+
for (const f of files) {
|
|
443
|
+
const rel = f.replace(/^\.\//, '');
|
|
444
|
+
for (const b of buckets) {
|
|
445
|
+
if (b.re.test(rel))
|
|
446
|
+
b.score += 1;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const p = prompt.toLowerCase();
|
|
450
|
+
if (/\b(gmail|inbox|email|google mcp|mcp service|mcp server|burtson-labs-mcp)\b/.test(p))
|
|
451
|
+
buckets.push({ label: 'Google/MCP server', re: /$a/, score: 4 });
|
|
452
|
+
if (/\bportfolio|about page|app\.jsx|app\.tsx|burtson\.io\b/.test(p))
|
|
453
|
+
buckets.push({ label: 'Portfolio site', re: /$a/, score: 4 });
|
|
454
|
+
if (/\bgreg|rack design|raspberry pi cluster\b/.test(p))
|
|
455
|
+
buckets.push({ label: 'Greg site', re: /$a/, score: 3 });
|
|
456
|
+
if (/\binsights?\b/.test(p))
|
|
457
|
+
buckets.push({ label: 'Insights reporting', re: /$a/, score: 3 });
|
|
458
|
+
if (/\bmcp\b|connector/.test(p))
|
|
459
|
+
buckets.push({ label: 'MCP/connectors', re: /$a/, score: 3 });
|
|
460
|
+
if (/\bsubagent|background task|\/tasks\b/.test(p))
|
|
461
|
+
buckets.push({ label: 'Subagents/background work', re: /$a/, score: 3 });
|
|
462
|
+
if (/\bpermission|approval|security|secret|redact/.test(p))
|
|
463
|
+
buckets.push({ label: 'Safety/security', re: /$a/, score: 3 });
|
|
464
|
+
if (/\bcli\b|terminal|slash command|\/[a-z]/.test(p))
|
|
465
|
+
buckets.push({ label: 'Bandit CLI', re: /$a/, score: 2 });
|
|
466
|
+
if (/\bvs code|extension|cursor\b/.test(p))
|
|
467
|
+
buckets.push({ label: 'VS Code extension', re: /$a/, score: 2 });
|
|
468
|
+
const top = buckets.sort((a, b) => b.score - a.score)[0];
|
|
469
|
+
return top && top.score > 0 ? top.label : 'General Bandit work';
|
|
470
|
+
}
|
|
471
|
+
function computeAccomplishments(turnFiles) {
|
|
472
|
+
let filesWritten = 0;
|
|
473
|
+
let editsApplied = 0;
|
|
474
|
+
let gitOperations = 0;
|
|
475
|
+
let commitsMade = 0;
|
|
476
|
+
let subagentsSpawned = 0;
|
|
477
|
+
let testsRun = 0;
|
|
478
|
+
const fileTouches = new Map();
|
|
479
|
+
const langCounts = new Map();
|
|
480
|
+
for (const tf of turnFiles) {
|
|
481
|
+
for (const ev of tf.events) {
|
|
482
|
+
if (!isToolExecuteEvent(ev) || !ev.name)
|
|
483
|
+
continue;
|
|
484
|
+
const params = ev.params ?? {};
|
|
485
|
+
if (EDIT_TOOLS.has(ev.name)) {
|
|
486
|
+
if (ev.name === 'write_file')
|
|
487
|
+
filesWritten += 1;
|
|
488
|
+
else
|
|
489
|
+
editsApplied += 1;
|
|
490
|
+
const filePath = typeof params.path === 'string' ? params.path : null;
|
|
491
|
+
if (filePath) {
|
|
492
|
+
fileTouches.set(filePath, (fileTouches.get(filePath) ?? 0) + 1);
|
|
493
|
+
addLanguage(langCounts, filePath);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
else if (ev.name === 'task') {
|
|
497
|
+
subagentsSpawned += 1;
|
|
498
|
+
}
|
|
499
|
+
else if (GIT_TOOLS.has(ev.name)) {
|
|
500
|
+
gitOperations += 1;
|
|
501
|
+
if (ev.name === 'git_commit')
|
|
502
|
+
commitsMade += 1;
|
|
503
|
+
}
|
|
504
|
+
else if (ev.name === 'run_command') {
|
|
505
|
+
const cmd = asParamString(params, 'cmd');
|
|
506
|
+
const args = asParamString(params, 'args');
|
|
507
|
+
const full = commandLine(params);
|
|
508
|
+
if (cmd === 'git') {
|
|
509
|
+
gitOperations += 1;
|
|
510
|
+
if (/^\s*commit\b/.test(args))
|
|
511
|
+
commitsMade += 1;
|
|
512
|
+
}
|
|
513
|
+
if (TEST_RUNNER_RE.test(full))
|
|
514
|
+
testsRun += 1;
|
|
515
|
+
}
|
|
516
|
+
else if (ev.name === 'test_run' || ev.name === 'run_tests') {
|
|
517
|
+
testsRun += 1;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const topFiles = [...fileTouches.entries()]
|
|
522
|
+
.map(([p, touches]) => ({ path: normalizePath(p), touches }))
|
|
523
|
+
.sort((a, b) => b.touches - a.touches)
|
|
524
|
+
.slice(0, 8);
|
|
525
|
+
const languages = [...langCounts.entries()]
|
|
526
|
+
.map(([label, set]) => ({ label, count: set.size }))
|
|
527
|
+
.sort((a, b) => b.count - a.count);
|
|
528
|
+
return {
|
|
529
|
+
filesTouched: fileTouches.size,
|
|
530
|
+
filesWritten,
|
|
531
|
+
editsApplied,
|
|
532
|
+
gitOperations,
|
|
533
|
+
commitsMade,
|
|
534
|
+
subagentsSpawned,
|
|
535
|
+
testsRun,
|
|
536
|
+
topFiles,
|
|
537
|
+
languages
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
function summarizeHighlight(h) {
|
|
541
|
+
const parts = [];
|
|
542
|
+
if (h.turns > 1)
|
|
543
|
+
parts.push(`${h.turns} related turns`);
|
|
544
|
+
if (h.filesTouched > 0) {
|
|
545
|
+
const changes = h.edits + h.writes;
|
|
546
|
+
parts.push(`${changes} ${changes === 1 ? 'change' : 'changes'} across ${h.filesTouched} file${h.filesTouched === 1 ? '' : 's'}`);
|
|
547
|
+
}
|
|
548
|
+
if (h.filesInspected > 0)
|
|
549
|
+
parts.push(`${h.filesInspected} file${h.filesInspected === 1 ? '' : 's'} inspected`);
|
|
550
|
+
if (h.externalActions > 0)
|
|
551
|
+
parts.push(`${h.externalActions} external action${h.externalActions === 1 ? '' : 's'}`);
|
|
552
|
+
if (h.testsRun > 0)
|
|
553
|
+
parts.push(`${h.testsRun} test run${h.testsRun === 1 ? '' : 's'}`);
|
|
554
|
+
if (h.subagentsSpawned > 0)
|
|
555
|
+
parts.push(`${h.subagentsSpawned} subagent${h.subagentsSpawned === 1 ? '' : 's'} spawned`);
|
|
556
|
+
if (h.gitOperations > 0)
|
|
557
|
+
parts.push(`${h.gitOperations} git op${h.gitOperations === 1 ? '' : 's'}`);
|
|
558
|
+
if (h.commands.length > 0)
|
|
559
|
+
parts.push(`commands: ${h.commands.slice(0, 2).join(', ')}`);
|
|
560
|
+
if (parts.length === 0)
|
|
561
|
+
return cleanSnippet(h.prompt, 140);
|
|
562
|
+
return parts.join(' · ');
|
|
563
|
+
}
|
|
564
|
+
function extractOutcome(events) {
|
|
565
|
+
const final = [...events].reverse().find((ev) => ev.type === 'final-response' && typeof ev.finalPreview === 'string' && ev.finalPreview.trim().length > 0)?.finalPreview;
|
|
566
|
+
const response = final ?? [...events].reverse().find((ev) => typeof ev.responsePreview === 'string' &&
|
|
567
|
+
ev.responsePreview.trim().length > 0 &&
|
|
568
|
+
!ev.responsePreview.includes('<tool_call>'))?.responsePreview;
|
|
569
|
+
const raw = response ?? '';
|
|
570
|
+
if (!raw)
|
|
571
|
+
return '';
|
|
572
|
+
const cleaned = cleanSnippet(raw, 420)
|
|
573
|
+
.replace(/\bHere'?s what (?:I|we) (?:did|added|changed|shipped):?/gi, '')
|
|
574
|
+
.replace(/\bWhat(?:'|’)s done:?/gi, '')
|
|
575
|
+
.trim();
|
|
576
|
+
const successLine = cleaned
|
|
577
|
+
.split(/\s*(?:\n| {2,}|[-*]\s+)/)
|
|
578
|
+
.map((line) => line.trim())
|
|
579
|
+
.find((line) => /^(?:done|shipped|pushed|committed|created|fixed|added|all\b|success|the fix|changes are now applied)/i.test(line));
|
|
580
|
+
const candidate = successLine || cleaned.split(/(?<=[.!?])\s+/)[0] || cleaned;
|
|
581
|
+
if (/\b(?:cannot recall|nothing confirmed shipped|not yet|i have not|let me|full access|bandit-reasoning|the user wants|findings)\b/i.test(candidate)) {
|
|
582
|
+
return '';
|
|
583
|
+
}
|
|
584
|
+
const startsLikeOutcome = /^(?:done|shipped|pushed|committed|created|fixed|added|all\b|success|built|archived|cleaned|moved|updated|marked read|the fix|the changes are now applied|(?:the issue|the bug|the failure|the problem) (?:is|was )?(?:fixed|resolved)|i(?:'ve| have) (?:created|built|fixed|updated|added|pushed|committed|shipped))/i.test(candidate);
|
|
585
|
+
if (!startsLikeOutcome) {
|
|
586
|
+
return '';
|
|
587
|
+
}
|
|
588
|
+
return candidate.length > 220 ? candidate.slice(0, 217).trimEnd() + '...' : candidate;
|
|
589
|
+
}
|
|
590
|
+
function collapseSimilarHighlights(items) {
|
|
591
|
+
const groups = new Map();
|
|
592
|
+
// Track distinct areas that fell into a canonical-title group so the
|
|
593
|
+
// merged card can show "spanned 4 areas" instead of pretending it
|
|
594
|
+
// was scoped to one.
|
|
595
|
+
const areasByKey = new Map();
|
|
596
|
+
for (const h of items) {
|
|
597
|
+
const titleKey = h.title.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().slice(0, 90);
|
|
598
|
+
// Canonical titles (self-eval sweeps, subagent research drills,
|
|
599
|
+
// repo explanations) collapse cross-area — same effort repeated
|
|
600
|
+
// against different parts of the codebase is one highlight, not N.
|
|
601
|
+
// Free-form titles stay area-scoped so unrelated "fix the bug"
|
|
602
|
+
// prompts in different areas don't false-merge.
|
|
603
|
+
const key = isCanonicalTitle(h.title)
|
|
604
|
+
? `canonical::${titleKey}`
|
|
605
|
+
: `${h.area}::${titleKey}`;
|
|
606
|
+
const cur = groups.get(key);
|
|
607
|
+
if (!cur) {
|
|
608
|
+
groups.set(key, { ...h, topFiles: [...h.topFiles], languages: [...h.languages], tools: [...h.tools], commands: [...h.commands] });
|
|
609
|
+
areasByKey.set(key, new Set([h.area]));
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
areasByKey.get(key).add(h.area);
|
|
613
|
+
const fileMap = new Map();
|
|
614
|
+
for (const f of cur.topFiles)
|
|
615
|
+
fileMap.set(f.path, (fileMap.get(f.path) ?? 0) + f.touches);
|
|
616
|
+
for (const f of h.topFiles)
|
|
617
|
+
fileMap.set(f.path, (fileMap.get(f.path) ?? 0) + f.touches);
|
|
618
|
+
const langMap = new Map();
|
|
619
|
+
for (const l of cur.languages)
|
|
620
|
+
langMap.set(l.label, (langMap.get(l.label) ?? 0) + l.count);
|
|
621
|
+
for (const l of h.languages)
|
|
622
|
+
langMap.set(l.label, (langMap.get(l.label) ?? 0) + l.count);
|
|
623
|
+
const toolMap = new Map();
|
|
624
|
+
for (const t of cur.tools)
|
|
625
|
+
toolMap.set(t.name, (toolMap.get(t.name) ?? 0) + t.calls);
|
|
626
|
+
for (const t of h.tools)
|
|
627
|
+
toolMap.set(t.name, (toolMap.get(t.name) ?? 0) + t.calls);
|
|
628
|
+
cur.turns += h.turns;
|
|
629
|
+
cur.score += h.score;
|
|
630
|
+
if (h.timestamp > cur.timestamp) {
|
|
631
|
+
cur.timestamp = h.timestamp;
|
|
632
|
+
cur.date = h.date;
|
|
633
|
+
cur.turnFile = h.turnFile;
|
|
634
|
+
}
|
|
635
|
+
cur.filesTouched += h.filesTouched;
|
|
636
|
+
cur.filesInspected += h.filesInspected;
|
|
637
|
+
cur.writes += h.writes;
|
|
638
|
+
cur.edits += h.edits;
|
|
639
|
+
cur.externalActions += h.externalActions;
|
|
640
|
+
cur.testsRun += h.testsRun;
|
|
641
|
+
cur.gitOperations += h.gitOperations;
|
|
642
|
+
cur.commitsMade += h.commitsMade;
|
|
643
|
+
cur.subagentsSpawned += h.subagentsSpawned;
|
|
644
|
+
cur.errors += h.errors;
|
|
645
|
+
for (const cmd of h.commands) {
|
|
646
|
+
if (!cur.commands.includes(cmd) && cur.commands.length < 5)
|
|
647
|
+
cur.commands.push(cmd);
|
|
648
|
+
}
|
|
649
|
+
cur.topFiles = [...fileMap.entries()]
|
|
650
|
+
.sort((a, b) => b[1] - a[1])
|
|
651
|
+
.slice(0, 6)
|
|
652
|
+
.map(([path, touches]) => ({ path, touches }));
|
|
653
|
+
cur.languages = [...langMap.entries()]
|
|
654
|
+
.sort((a, b) => b[1] - a[1])
|
|
655
|
+
.map(([label, count]) => ({ label, count }));
|
|
656
|
+
cur.tools = [...toolMap.entries()]
|
|
657
|
+
.sort((a, b) => b[1] - a[1])
|
|
658
|
+
.slice(0, 6)
|
|
659
|
+
.map(([name, calls]) => ({ name, calls }));
|
|
660
|
+
cur.summary = summarizeHighlight(cur);
|
|
661
|
+
if (h.timestamp >= cur.timestamp && h.outcome)
|
|
662
|
+
cur.outcome = h.outcome;
|
|
663
|
+
}
|
|
664
|
+
// Stamp merged-area scope onto canonical-title highlights so the
|
|
665
|
+
// card reflects the cross-area reality. Free-form titles still
|
|
666
|
+
// carry their single area.
|
|
667
|
+
for (const [key, h] of groups) {
|
|
668
|
+
const areas = areasByKey.get(key);
|
|
669
|
+
if (areas && areas.size > 1) {
|
|
670
|
+
const sorted = [...areas].sort();
|
|
671
|
+
h.area = sorted.length <= 3
|
|
672
|
+
? sorted.join(' + ')
|
|
673
|
+
: `${sorted.slice(0, 2).join(', ')} +${sorted.length - 2} more`;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return [...groups.values()];
|
|
677
|
+
}
|
|
678
|
+
function computeWork(turnFiles) {
|
|
679
|
+
const highlights = [];
|
|
680
|
+
for (const tf of turnFiles) {
|
|
681
|
+
const prompt = tf.events.find((ev) => ev.type === 'user-prompt' && typeof ev.prompt === 'string')?.prompt?.trim() ?? '';
|
|
682
|
+
if (!prompt)
|
|
683
|
+
continue;
|
|
684
|
+
let timestamp = tf.startedAt;
|
|
685
|
+
if (!timestamp) {
|
|
686
|
+
const firstTs = tf.events.find((ev) => ev.t)?.t;
|
|
687
|
+
timestamp = firstTs ? Date.parse(firstTs) : 0;
|
|
688
|
+
}
|
|
689
|
+
let writes = 0;
|
|
690
|
+
let edits = 0;
|
|
691
|
+
let testsRun = 0;
|
|
692
|
+
let gitOperations = 0;
|
|
693
|
+
let commitsMade = 0;
|
|
694
|
+
let subagentsSpawned = 0;
|
|
695
|
+
let externalActions = 0;
|
|
696
|
+
let errors = 0;
|
|
697
|
+
const fileTouches = new Map();
|
|
698
|
+
const filesInspected = new Map();
|
|
699
|
+
const langCounts = new Map();
|
|
700
|
+
const tools = new Map();
|
|
701
|
+
const commands = [];
|
|
702
|
+
for (const ev of tf.events) {
|
|
703
|
+
if (isToolResultError(ev))
|
|
704
|
+
errors += 1;
|
|
705
|
+
if (ev.type === 'subagent-spawn')
|
|
706
|
+
subagentsSpawned += 1;
|
|
707
|
+
if (!isToolExecuteEvent(ev) || !ev.name)
|
|
708
|
+
continue;
|
|
709
|
+
const params = ev.params ?? {};
|
|
710
|
+
tools.set(ev.name, (tools.get(ev.name) ?? 0) + 1);
|
|
711
|
+
if (isExternalMutatingTool(ev.name))
|
|
712
|
+
externalActions += 1;
|
|
713
|
+
if (EDIT_TOOLS.has(ev.name)) {
|
|
714
|
+
const filePath = asParamString(params, 'path');
|
|
715
|
+
if (ev.name === 'write_file')
|
|
716
|
+
writes += 1;
|
|
717
|
+
else
|
|
718
|
+
edits += 1;
|
|
719
|
+
if (filePath) {
|
|
720
|
+
fileTouches.set(filePath, (fileTouches.get(filePath) ?? 0) + 1);
|
|
721
|
+
addLanguage(langCounts, filePath);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
else if (READ_TOOLS.has(ev.name)) {
|
|
725
|
+
const filePath = asParamString(params, 'path') || asParamString(params, 'pattern') || asParamString(params, 'query') || asParamString(params, 'url');
|
|
726
|
+
if (filePath)
|
|
727
|
+
filesInspected.set(filePath, (filesInspected.get(filePath) ?? 0) + 1);
|
|
728
|
+
}
|
|
729
|
+
else if (ev.name === 'task') {
|
|
730
|
+
// Count concrete `subagent-spawn` events when present so the
|
|
731
|
+
// same task is not double-counted. Older logs without spawn
|
|
732
|
+
// telemetry get a fallback after this loop.
|
|
733
|
+
}
|
|
734
|
+
else if (GIT_TOOLS.has(ev.name)) {
|
|
735
|
+
gitOperations += 1;
|
|
736
|
+
if (ev.name === 'git_commit')
|
|
737
|
+
commitsMade += 1;
|
|
738
|
+
}
|
|
739
|
+
else if (ev.name === 'run_command') {
|
|
740
|
+
const cmd = asParamString(params, 'cmd');
|
|
741
|
+
const args = asParamString(params, 'args');
|
|
742
|
+
const full = commandLine(params);
|
|
743
|
+
if (full && commands.length < 5)
|
|
744
|
+
commands.push(full);
|
|
745
|
+
if (cmd === 'git') {
|
|
746
|
+
gitOperations += 1;
|
|
747
|
+
if (/^\s*commit\b/.test(args))
|
|
748
|
+
commitsMade += 1;
|
|
749
|
+
}
|
|
750
|
+
if (TEST_RUNNER_RE.test(full))
|
|
751
|
+
testsRun += 1;
|
|
752
|
+
}
|
|
753
|
+
else if (ev.name === 'test_run' || ev.name === 'run_tests') {
|
|
754
|
+
testsRun += 1;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (subagentsSpawned === 0 && tools.has('task'))
|
|
758
|
+
subagentsSpawned = tools.get('task') ?? 0;
|
|
759
|
+
const allFiles = [...fileTouches.keys(), ...filesInspected.keys()];
|
|
760
|
+
const category = categoryFromPrompt(prompt, tools);
|
|
761
|
+
const area = areaFromFilesAndPrompt(allFiles, prompt);
|
|
762
|
+
const topFiles = [...fileTouches.entries()]
|
|
763
|
+
.sort((a, b) => b[1] - a[1])
|
|
764
|
+
.slice(0, 6)
|
|
765
|
+
.map(([p, touches]) => ({ path: normalizePath(p), touches }));
|
|
766
|
+
const languages = [...langCounts.entries()]
|
|
767
|
+
.map(([label, set]) => ({ label, count: set.size }))
|
|
768
|
+
.sort((a, b) => b.count - a.count);
|
|
769
|
+
const toolList = [...tools.entries()]
|
|
770
|
+
.map(([name, calls]) => ({ name, calls }))
|
|
771
|
+
.sort((a, b) => b.calls - a.calls)
|
|
772
|
+
.slice(0, 6);
|
|
773
|
+
const score = (writes + edits) * 6 +
|
|
774
|
+
fileTouches.size * 4 +
|
|
775
|
+
Math.min(filesInspected.size, 30) * 0.7 +
|
|
776
|
+
testsRun * 4 +
|
|
777
|
+
externalActions * 5 +
|
|
778
|
+
gitOperations * 2 +
|
|
779
|
+
subagentsSpawned * 3 +
|
|
780
|
+
toolList.reduce((sum, t) => sum + Math.min(t.calls, 5) * 0.15, 0) -
|
|
781
|
+
errors * 0.4;
|
|
782
|
+
if (score < 1.5 && toolList.length === 0)
|
|
783
|
+
continue;
|
|
784
|
+
const base = {
|
|
785
|
+
timestamp,
|
|
786
|
+
date: timestamp ? new Date(timestamp).toISOString().slice(0, 10) : 'unknown',
|
|
787
|
+
title: titleFromPrompt(prompt).title,
|
|
788
|
+
prompt,
|
|
789
|
+
area,
|
|
790
|
+
category,
|
|
791
|
+
turns: 1,
|
|
792
|
+
score,
|
|
793
|
+
turnFile: tf.filename,
|
|
794
|
+
filesTouched: fileTouches.size,
|
|
795
|
+
filesInspected: filesInspected.size,
|
|
796
|
+
writes,
|
|
797
|
+
edits,
|
|
798
|
+
outcome: extractOutcome(tf.events),
|
|
799
|
+
externalActions,
|
|
800
|
+
testsRun,
|
|
801
|
+
gitOperations,
|
|
802
|
+
commitsMade,
|
|
803
|
+
subagentsSpawned,
|
|
804
|
+
errors,
|
|
805
|
+
commands,
|
|
806
|
+
topFiles,
|
|
807
|
+
languages,
|
|
808
|
+
tools: toolList
|
|
809
|
+
};
|
|
810
|
+
highlights.push({ ...base, summary: summarizeHighlight(base) });
|
|
811
|
+
}
|
|
812
|
+
const collapsedHighlights = collapseSimilarHighlights(highlights);
|
|
813
|
+
// Build themes from the PRE-COLLAPSE highlights so per-area grouping
|
|
814
|
+
// is preserved — the cross-area collapse on highlights produces nice
|
|
815
|
+
// single-card summaries, but themes are meant to be per-area arcs.
|
|
816
|
+
// Using collapsed highlights here would turn "Self-eval sweeps that
|
|
817
|
+
// touched 5 areas" into a theme labeled "Agent core, Docs/roadmap +3
|
|
818
|
+
// more" which is the wrong shape for the "Bigger arcs" panel.
|
|
819
|
+
const themesByArea = new Map();
|
|
820
|
+
for (const h of highlights) {
|
|
821
|
+
const cur = themesByArea.get(h.area) ?? {
|
|
822
|
+
area: h.area,
|
|
823
|
+
turns: 0,
|
|
824
|
+
score: 0,
|
|
825
|
+
latestAt: 0,
|
|
826
|
+
fileTouches: new Map(),
|
|
827
|
+
filesInspected: 0,
|
|
828
|
+
langCounts: new Map(),
|
|
829
|
+
editsAndWrites: 0,
|
|
830
|
+
externalActions: 0,
|
|
831
|
+
testsRun: 0,
|
|
832
|
+
gitOperations: 0,
|
|
833
|
+
subagentsSpawned: 0,
|
|
834
|
+
sampleTitles: [],
|
|
835
|
+
outcomes: []
|
|
836
|
+
};
|
|
837
|
+
cur.turns += h.turns;
|
|
838
|
+
cur.score += h.score;
|
|
839
|
+
cur.latestAt = Math.max(cur.latestAt, h.timestamp);
|
|
840
|
+
cur.editsAndWrites += h.edits + h.writes;
|
|
841
|
+
cur.externalActions += h.externalActions;
|
|
842
|
+
cur.testsRun += h.testsRun;
|
|
843
|
+
cur.gitOperations += h.gitOperations;
|
|
844
|
+
cur.subagentsSpawned += h.subagentsSpawned;
|
|
845
|
+
for (const f of h.topFiles)
|
|
846
|
+
cur.fileTouches.set(f.path, (cur.fileTouches.get(f.path) ?? 0) + f.touches);
|
|
847
|
+
cur.filesInspected += h.filesInspected;
|
|
848
|
+
for (const l of h.languages) {
|
|
849
|
+
const set = cur.langCounts.get(l.label) ?? new Set();
|
|
850
|
+
for (let i = 0; i < l.count; i += 1)
|
|
851
|
+
set.add(`${h.turnFile}:${l.label}:${i}`);
|
|
852
|
+
cur.langCounts.set(l.label, set);
|
|
853
|
+
}
|
|
854
|
+
if (!cur.sampleTitles.includes(h.title) && cur.sampleTitles.length < 4)
|
|
855
|
+
cur.sampleTitles.push(h.title);
|
|
856
|
+
if (h.outcome && !cur.outcomes.includes(h.outcome) && cur.outcomes.length < 3)
|
|
857
|
+
cur.outcomes.push(h.outcome);
|
|
858
|
+
themesByArea.set(h.area, cur);
|
|
859
|
+
}
|
|
860
|
+
const themes = [...themesByArea.values()]
|
|
861
|
+
.map((t) => ({
|
|
862
|
+
title: t.area,
|
|
863
|
+
area: t.area,
|
|
864
|
+
turns: t.turns,
|
|
865
|
+
score: t.score,
|
|
866
|
+
latestAt: t.latestAt,
|
|
867
|
+
latestDate: t.latestAt ? new Date(t.latestAt).toISOString().slice(0, 10) : 'unknown',
|
|
868
|
+
filesTouched: t.fileTouches.size,
|
|
869
|
+
filesInspected: t.filesInspected,
|
|
870
|
+
editsAndWrites: t.editsAndWrites,
|
|
871
|
+
externalActions: t.externalActions,
|
|
872
|
+
testsRun: t.testsRun,
|
|
873
|
+
gitOperations: t.gitOperations,
|
|
874
|
+
subagentsSpawned: t.subagentsSpawned,
|
|
875
|
+
topFiles: [...t.fileTouches.entries()]
|
|
876
|
+
.sort((a, b) => b[1] - a[1])
|
|
877
|
+
.slice(0, 5)
|
|
878
|
+
.map(([p, touches]) => ({ path: p, touches })),
|
|
879
|
+
languages: [...t.langCounts.entries()]
|
|
880
|
+
.map(([label, set]) => ({ label, count: set.size }))
|
|
881
|
+
.sort((a, b) => b.count - a.count),
|
|
882
|
+
sampleTitles: t.sampleTitles,
|
|
883
|
+
outcomes: t.outcomes
|
|
884
|
+
}))
|
|
885
|
+
.sort((a, b) => b.score - a.score)
|
|
886
|
+
.slice(0, 8);
|
|
887
|
+
return {
|
|
888
|
+
highlights: collapsedHighlights
|
|
889
|
+
.sort((a, b) => b.score - a.score || b.timestamp - a.timestamp)
|
|
890
|
+
.slice(0, 16),
|
|
891
|
+
themes
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
function buildLocalStory(data) {
|
|
895
|
+
const paragraphs = [];
|
|
896
|
+
const usedThemes = new Set();
|
|
897
|
+
const findTheme = (patterns) => {
|
|
898
|
+
const titleMatch = data.work.themes.find((theme) => !usedThemes.has(theme.title) &&
|
|
899
|
+
patterns.some((pattern) => pattern.test(theme.title) ||
|
|
900
|
+
theme.sampleTitles.some((title) => pattern.test(title))));
|
|
901
|
+
if (titleMatch)
|
|
902
|
+
return titleMatch;
|
|
903
|
+
return data.work.themes.find((theme) => !usedThemes.has(theme.title) &&
|
|
904
|
+
patterns.some((pattern) => theme.outcomes.some((outcome) => pattern.test(outcome))));
|
|
905
|
+
};
|
|
906
|
+
const addTheme = (theme, copy) => {
|
|
907
|
+
if (!theme)
|
|
908
|
+
return;
|
|
909
|
+
const outcome = theme.outcomes.find(Boolean) ?? '';
|
|
910
|
+
paragraphs.push(copy(theme, outcome));
|
|
911
|
+
usedThemes.add(theme.title);
|
|
912
|
+
};
|
|
913
|
+
const countLabel = (n, singular, plural = `${singular}s`) => `${n} ${n === 1 ? singular : plural}`;
|
|
914
|
+
const turnWord = (n) => countLabel(n, 'turn');
|
|
915
|
+
addTheme(findTheme([/Google\/MCP/i, /\bGmail\b/i, /\binbox\b/i, /\bMCP server\b/i]), (theme, outcome) => {
|
|
916
|
+
const details = [
|
|
917
|
+
turnWord(theme.turns),
|
|
918
|
+
theme.filesTouched > 0 ? `${countLabel(theme.filesTouched, 'file')} touched` : '',
|
|
919
|
+
theme.testsRun > 0 ? countLabel(theme.testsRun, 'validation run') : '',
|
|
920
|
+
theme.externalActions > 0 ? countLabel(theme.externalActions, 'Gmail/MCP action') : ''
|
|
921
|
+
].filter(Boolean).join(', ');
|
|
922
|
+
return `The Google/MCP work now shows up as a real cross-repo arc: ${details}. Bandit can see both the server buildout and the Gmail cleanup/tooling work instead of treating them as disconnected snippets.${outcome ? ` One logged outcome: ${outcome}` : ''}`;
|
|
923
|
+
});
|
|
924
|
+
addTheme(findTheme([/\bPortfolio\b/i, /\bBurtson\.io\b/i, /\bApp\.(?:jsx|tsx)\b/i]), (theme, outcome) => {
|
|
925
|
+
const details = [
|
|
926
|
+
turnWord(theme.turns),
|
|
927
|
+
theme.filesTouched > 0 ? `${countLabel(theme.filesTouched, 'file')} touched` : '',
|
|
928
|
+
theme.editsAndWrites > 0 ? `${theme.editsAndWrites} edits/writes` : '',
|
|
929
|
+
theme.testsRun > 0 ? countLabel(theme.testsRun, 'validation run') : ''
|
|
930
|
+
].filter(Boolean).join(', ');
|
|
931
|
+
return `The portfolio work reads as an iterative product push, not a one-off edit: ${details}. The turn history captures content passes, repo/path fixes, and larger refactor loops across the portfolio codebase.${outcome ? ` One logged outcome: ${outcome}` : ''}`;
|
|
932
|
+
});
|
|
933
|
+
addTheme(findTheme([/^Bandit CLI$/i, /^VS Code extension$/i, /^Agent core$/i, /^Host kit$/i, /^Stealth runtime$/i]), (theme, outcome) => {
|
|
934
|
+
const details = [
|
|
935
|
+
turnWord(theme.turns),
|
|
936
|
+
theme.filesTouched > 0 ? `${countLabel(theme.filesTouched, 'file')} touched` : '',
|
|
937
|
+
theme.editsAndWrites > 0 ? `${theme.editsAndWrites} edits/writes` : '',
|
|
938
|
+
theme.subagentsSpawned > 0 ? countLabel(theme.subagentsSpawned, 'subagent') : ''
|
|
939
|
+
].filter(Boolean).join(', ');
|
|
940
|
+
return `Bandit itself has a visible improvement arc: ${details}. The report can now pull together CLI, extension, host-kit, and agent-core work so product polish does not disappear inside raw tool counts.${outcome ? ` One logged outcome: ${outcome}` : ''}`;
|
|
941
|
+
});
|
|
942
|
+
addTheme(findTheme([/\bGreg\b/i, /\bRaspberry Pi\b/i, /\brack design\b/i]), (theme, outcome) => {
|
|
943
|
+
const details = [
|
|
944
|
+
turnWord(theme.turns),
|
|
945
|
+
theme.filesTouched > 0 ? `${countLabel(theme.filesTouched, 'file')} touched` : '',
|
|
946
|
+
theme.editsAndWrites > 0 ? `${theme.editsAndWrites} edits/writes` : ''
|
|
947
|
+
].filter(Boolean).join(', ');
|
|
948
|
+
return `The Greg/Raspberry Pi thread is tracked as its own project arc: ${details}. That keeps infrastructure and site work visible alongside Bandit and portfolio development.${outcome ? ` One logged outcome: ${outcome}` : ''}`;
|
|
949
|
+
});
|
|
950
|
+
for (const theme of data.work.themes) {
|
|
951
|
+
if (paragraphs.length >= 4)
|
|
952
|
+
break;
|
|
953
|
+
if (usedThemes.has(theme.title))
|
|
954
|
+
continue;
|
|
955
|
+
const details = [
|
|
956
|
+
turnWord(theme.turns),
|
|
957
|
+
theme.filesTouched > 0 ? `${countLabel(theme.filesTouched, 'file')} touched` : '',
|
|
958
|
+
theme.editsAndWrites > 0 ? `${theme.editsAndWrites} edits/writes` : '',
|
|
959
|
+
theme.testsRun > 0 ? countLabel(theme.testsRun, 'validation run') : '',
|
|
960
|
+
theme.externalActions > 0 ? countLabel(theme.externalActions, 'external action') : ''
|
|
961
|
+
].filter(Boolean).join(', ');
|
|
962
|
+
const title = theme.sampleTitles[0] ?? theme.title;
|
|
963
|
+
paragraphs.push(`${theme.title} was another active lane: ${details}. Representative work: ${title}.${theme.outcomes[0] ? ` One logged outcome: ${theme.outcomes[0]}` : ''}`);
|
|
964
|
+
usedThemes.add(theme.title);
|
|
965
|
+
}
|
|
966
|
+
return paragraphs.slice(0, 4);
|
|
967
|
+
}
|
|
968
|
+
/** Compute current/longest streak of consecutive days the user had at
|
|
969
|
+
* least one session, plus the single peak day. Walks session
|
|
970
|
+
* timestamps; cheap even with thousands of sessions. */
|
|
971
|
+
function computeActivityMetrics(sessions) {
|
|
972
|
+
if (sessions.length === 0) {
|
|
973
|
+
return { streak: { current: 0, longest: 0 }, peakDay: null, firstSeenAt: null };
|
|
974
|
+
}
|
|
975
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
976
|
+
const promptsByDay = new Map();
|
|
977
|
+
for (const s of sessions) {
|
|
978
|
+
if (!s.startedAt)
|
|
979
|
+
continue;
|
|
980
|
+
const day = new Date(s.startedAt).toISOString().slice(0, 10);
|
|
981
|
+
promptsByDay.set(day, (promptsByDay.get(day) ?? 0) + Math.max(s.prompts, s.toolCallCount > 0 ? 1 : 0));
|
|
982
|
+
}
|
|
983
|
+
// Longest streak — sort active days, walk for consecutive runs.
|
|
984
|
+
const activeDays = [...promptsByDay.keys()].sort();
|
|
985
|
+
let longest = 0;
|
|
986
|
+
let run = 0;
|
|
987
|
+
let prevTs = 0;
|
|
988
|
+
for (const day of activeDays) {
|
|
989
|
+
const ts = new Date(day + 'T00:00:00Z').getTime();
|
|
990
|
+
if (prevTs && ts - prevTs === dayMs)
|
|
991
|
+
run += 1;
|
|
992
|
+
else
|
|
993
|
+
run = 1;
|
|
994
|
+
if (run > longest)
|
|
995
|
+
longest = run;
|
|
996
|
+
prevTs = ts;
|
|
997
|
+
}
|
|
998
|
+
// Current streak — count back from today.
|
|
999
|
+
const todayKey = new Date().toISOString().slice(0, 10);
|
|
1000
|
+
let current = 0;
|
|
1001
|
+
let cursorTs = new Date(todayKey + 'T00:00:00Z').getTime();
|
|
1002
|
+
while (promptsByDay.has(new Date(cursorTs).toISOString().slice(0, 10))) {
|
|
1003
|
+
current += 1;
|
|
1004
|
+
cursorTs -= dayMs;
|
|
1005
|
+
}
|
|
1006
|
+
// Peak day — single calendar day with the most prompts.
|
|
1007
|
+
let peakDay = null;
|
|
1008
|
+
for (const [date, prompts] of promptsByDay.entries()) {
|
|
1009
|
+
if (!peakDay || prompts > peakDay.prompts)
|
|
1010
|
+
peakDay = { date, prompts };
|
|
1011
|
+
}
|
|
1012
|
+
const firstSeenAt = sessions
|
|
1013
|
+
.map((s) => s.startedAt)
|
|
1014
|
+
.filter((ts) => ts > 0)
|
|
1015
|
+
.reduce((min, ts) => (ts < min ? ts : min), Number.MAX_SAFE_INTEGER);
|
|
1016
|
+
return {
|
|
1017
|
+
streak: { current, longest },
|
|
1018
|
+
peakDay,
|
|
1019
|
+
firstSeenAt: firstSeenAt === Number.MAX_SAFE_INTEGER ? null : firstSeenAt
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Roll per-tool stats and group error strings by tool. Errors get
|
|
1024
|
+
* de-duped to "this tool failed N times with these distinct messages"
|
|
1025
|
+
* so the user sees patterns, not a flat noisy list.
|
|
1026
|
+
*/
|
|
1027
|
+
function aggregate(turnFiles) {
|
|
1028
|
+
const toolStats = new Map();
|
|
1029
|
+
const errorBuckets = new Map();
|
|
1030
|
+
for (const tf of turnFiles) {
|
|
1031
|
+
for (const ev of tf.events) {
|
|
1032
|
+
if (ev.type === 'tool-execute' && ev.name) {
|
|
1033
|
+
const cur = toolStats.get(ev.name) ?? { calls: 0, errors: 0 };
|
|
1034
|
+
cur.calls += 1;
|
|
1035
|
+
toolStats.set(ev.name, cur);
|
|
1036
|
+
}
|
|
1037
|
+
else if ((ev.type === 'tool-result' || ev.type === 'tool-error') && ev.name && (ev.isError || ev.type === 'tool-error')) {
|
|
1038
|
+
const cur = toolStats.get(ev.name) ?? { calls: 0, errors: 0 };
|
|
1039
|
+
cur.errors += 1;
|
|
1040
|
+
// tool-error events carry `error`; tool-result events with
|
|
1041
|
+
// isError=true carry the message in `outputPreview`. Without
|
|
1042
|
+
// the fallback, errorBuckets stayed empty for tool-result
|
|
1043
|
+
// errors and the "Top error patterns" panel rendered "clean
|
|
1044
|
+
// run" even when the tools table tallied 100+ errors.
|
|
1045
|
+
const errText = ev.error ?? ev.outputPreview;
|
|
1046
|
+
if (errText)
|
|
1047
|
+
cur.lastError = errText.slice(0, 200);
|
|
1048
|
+
toolStats.set(ev.name, cur);
|
|
1049
|
+
if (errText) {
|
|
1050
|
+
const bucket = errorBuckets.get(ev.name) ?? new Map();
|
|
1051
|
+
// Trim error to a stable key — common prefixes match across runs.
|
|
1052
|
+
const key = errText.slice(0, 120);
|
|
1053
|
+
bucket.set(key, (bucket.get(key) ?? 0) + 1);
|
|
1054
|
+
errorBuckets.set(ev.name, bucket);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const errorClusters = new Map();
|
|
1060
|
+
for (const [tool, bucket] of errorBuckets.entries()) {
|
|
1061
|
+
const sorted = [...bucket.entries()]
|
|
1062
|
+
.map(([error, count]) => ({ error, count }))
|
|
1063
|
+
.sort((a, b) => b.count - a.count)
|
|
1064
|
+
.slice(0, 5);
|
|
1065
|
+
errorClusters.set(tool, sorted);
|
|
1066
|
+
}
|
|
1067
|
+
return { toolStats, errorClusters };
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Scan recent user prompts for emotional cues and tally them. Pure
|
|
1071
|
+
* keyword matching — fast, deterministic, runs locally. Output drives
|
|
1072
|
+
* both the report's sentiment chip row and the AI callback's input
|
|
1073
|
+
* (so the AI can reference frustration moments without doing arithmetic
|
|
1074
|
+
* itself, which it routinely does badly).
|
|
1075
|
+
*
|
|
1076
|
+
* Categories:
|
|
1077
|
+
* satisfied — "thanks", "got it", "works", "good", "ok"
|
|
1078
|
+
* happy — "ty", "thx", "nice", "lol"
|
|
1079
|
+
* excited — "love", "awesome", "amazing", "perfect", "blown away", "🔥"
|
|
1080
|
+
* frustrated — profanity stems, "wtf", "ugh", "stop", "still broken",
|
|
1081
|
+
* "didn't work", "again?", "this isn't working"
|
|
1082
|
+
* unsatisfied — "doesn't work", "still wrong", "not what I asked",
|
|
1083
|
+
* "no", "wrong", "stuck"
|
|
1084
|
+
*
|
|
1085
|
+
* Profanity content is NOT surfaced. The notable[] array carries
|
|
1086
|
+
* redacted snippets ("[redacted] still broken") so the report shows
|
|
1087
|
+
* context without echoing the words back.
|
|
1088
|
+
*/
|
|
1089
|
+
function scanSentiment(sessions) {
|
|
1090
|
+
const counts = {
|
|
1091
|
+
satisfied: 0,
|
|
1092
|
+
happy: 0,
|
|
1093
|
+
excited: 0,
|
|
1094
|
+
frustrated: 0,
|
|
1095
|
+
unsatisfied: 0,
|
|
1096
|
+
notable: []
|
|
1097
|
+
};
|
|
1098
|
+
// Deliberately matching common stems — case-insensitive, word-boundary
|
|
1099
|
+
// so "stuck" doesn't match "Stuckey" and "ass" doesn't match "class".
|
|
1100
|
+
const PROFANITY = /\b(f[*u@]ck(?:ing|er|ed)?|sh[*i!]t(?:ty|s)?|d[*a@]mn(?:it)?|b[*i!]tch|cr[*a@]p|hell|wtf|stfu)\b/i;
|
|
1101
|
+
const FRUSTRATED = /\b(ugh+|argh+|sigh|stop|still (?:broken|not working|wrong)|didn'?t work|isn'?t working|not working|again\??|why (?:isn'?t|aren'?t|won'?t|doesn'?t)|come on|seriously|stuck on|over and over|loop(?:ing)?|frustrat\w+|annoy\w+)\b/i;
|
|
1102
|
+
const UNSATISFIED = /\b(doesn'?t work|wrong|incorrect|not what i (?:asked|wanted)|that'?s? not (?:right|it)|nope|not (?:right|good|correct)|disappointed|underwhelm\w+)\b/i;
|
|
1103
|
+
const SATISFIED = /\b(thanks|thank you|got it|works|working now|cool|ok(?:ay)?|sounds good|makes sense|good (?:job|stuff|work)|nicely done|that worked)\b/i;
|
|
1104
|
+
const HAPPY = /\b(ty|thx|nice|haha|lol|sweet|neat)\b/i;
|
|
1105
|
+
const EXCITED = /\b(love (?:it|this|that)|awesome|amazing|incredible|perfect|fantastic|blown? away|brilliant|excellent|chef'?s? kiss|fabulous|legend\w*|impressive|so cool)|🔥|❤️|🎉|💯/i;
|
|
1106
|
+
const HOME_LOCAL = path.join(HOME, '.bandit', 'sessions');
|
|
1107
|
+
for (const session of sessions) {
|
|
1108
|
+
let prompts = [];
|
|
1109
|
+
try {
|
|
1110
|
+
const text = fs.readFileSync(path.join(HOME_LOCAL, session.id + '.jsonl'), 'utf-8');
|
|
1111
|
+
for (const line of text.split('\n')) {
|
|
1112
|
+
if (!line.trim())
|
|
1113
|
+
continue;
|
|
1114
|
+
let parsed = null;
|
|
1115
|
+
try {
|
|
1116
|
+
parsed = JSON.parse(line);
|
|
1117
|
+
}
|
|
1118
|
+
catch {
|
|
1119
|
+
continue;
|
|
1120
|
+
}
|
|
1121
|
+
if (!parsed || parsed.role !== 'user' || typeof parsed.content !== 'string')
|
|
1122
|
+
continue;
|
|
1123
|
+
if (parsed.content.startsWith('<tool_result'))
|
|
1124
|
+
continue;
|
|
1125
|
+
prompts.push(parsed.content);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
catch {
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
for (const raw of prompts) {
|
|
1132
|
+
const p = raw.trim();
|
|
1133
|
+
if (!p)
|
|
1134
|
+
continue;
|
|
1135
|
+
const profMatch = PROFANITY.test(p);
|
|
1136
|
+
const frusMatch = FRUSTRATED.test(p);
|
|
1137
|
+
const unsatMatch = UNSATISFIED.test(p);
|
|
1138
|
+
// Frustration / profanity / "doesn't work" all roll into frustrated.
|
|
1139
|
+
// A profanity hit is also counted as frustrated (people don't curse
|
|
1140
|
+
// when they're delighted), but we keep them separate in `notable`
|
|
1141
|
+
// so the report can flag profanity with a softer label.
|
|
1142
|
+
if (profMatch || frusMatch) {
|
|
1143
|
+
counts.frustrated++;
|
|
1144
|
+
if (counts.notable.length < 3) {
|
|
1145
|
+
// Redact profanity for the snippet so the report doesn't echo
|
|
1146
|
+
// it. Keep the surrounding 60 chars for context.
|
|
1147
|
+
const redacted = p.replace(PROFANITY, '[redacted]').replace(/\s+/g, ' ').slice(0, 80);
|
|
1148
|
+
counts.notable.push(redacted);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
if (unsatMatch)
|
|
1152
|
+
counts.unsatisfied++;
|
|
1153
|
+
if (SATISFIED.test(p))
|
|
1154
|
+
counts.satisfied++;
|
|
1155
|
+
if (HAPPY.test(p))
|
|
1156
|
+
counts.happy++;
|
|
1157
|
+
if (EXCITED.test(p))
|
|
1158
|
+
counts.excited++;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
return counts;
|
|
1162
|
+
}
|
|
1163
|
+
function computeInsights(cwd) {
|
|
1164
|
+
const sessions = loadSessions();
|
|
1165
|
+
const turnFiles = loadTurnFiles(cwd);
|
|
1166
|
+
const { toolStats, errorClusters } = aggregate(turnFiles);
|
|
1167
|
+
const accomplishments = computeAccomplishments(turnFiles);
|
|
1168
|
+
const work = computeWork(turnFiles);
|
|
1169
|
+
const totalPrompts = sessions.reduce((sum, s) => sum + s.prompts, 0);
|
|
1170
|
+
const totalApproxTokens = Math.round(sessions.reduce((sum, s) => sum + s.approxChars, 0) / 4);
|
|
1171
|
+
const { streak, peakDay, firstSeenAt } = computeActivityMetrics(sessions);
|
|
1172
|
+
const sentiment = scanSentiment(sessions);
|
|
1173
|
+
const data = {
|
|
1174
|
+
generatedAt: Date.now(),
|
|
1175
|
+
cwd,
|
|
1176
|
+
sessions,
|
|
1177
|
+
turnFiles,
|
|
1178
|
+
toolStats,
|
|
1179
|
+
errorClusters,
|
|
1180
|
+
totalPrompts,
|
|
1181
|
+
totalApproxTokens,
|
|
1182
|
+
accomplishments,
|
|
1183
|
+
work,
|
|
1184
|
+
localStory: [],
|
|
1185
|
+
streak,
|
|
1186
|
+
peakDay,
|
|
1187
|
+
firstSeenAt,
|
|
1188
|
+
sentiment
|
|
1189
|
+
};
|
|
1190
|
+
data.localStory = buildLocalStory(data);
|
|
1191
|
+
return data;
|
|
1192
|
+
}
|
|
1193
|
+
/** Build the privacy-aware payload handed to an AI summarizer.
|
|
1194
|
+
* Caps everything to small amounts of data — no raw turn logs, no
|
|
1195
|
+
* full session contents, prompt titles trimmed to first ~120 chars.
|
|
1196
|
+
* This is what gets sent to the user's LLM, so it must contain
|
|
1197
|
+
* nothing the user wouldn't paste into a chat themselves. */
|
|
1198
|
+
function buildAiInput(data) {
|
|
1199
|
+
const topTools = [...data.toolStats.entries()]
|
|
1200
|
+
.map(([name, s]) => ({
|
|
1201
|
+
name,
|
|
1202
|
+
calls: s.calls,
|
|
1203
|
+
errors: s.errors,
|
|
1204
|
+
errorRate: s.calls > 0 ? s.errors / s.calls : 0
|
|
1205
|
+
}))
|
|
1206
|
+
.sort((a, b) => b.calls - a.calls)
|
|
1207
|
+
.slice(0, 8);
|
|
1208
|
+
const topErrors = [...data.errorClusters.entries()]
|
|
1209
|
+
.flatMap(([tool, bucket]) => bucket.map((b) => ({ tool, error: b.error, count: b.count })))
|
|
1210
|
+
.sort((a, b) => b.count - a.count)
|
|
1211
|
+
.slice(0, 6);
|
|
1212
|
+
// Pull recent prompt excerpts (up to 280 chars × 25) for narrative
|
|
1213
|
+
// material. Skips tool_result echoes and metadata-only entries. Each
|
|
1214
|
+
// excerpt is timestamped to the session date so the LLM can write
|
|
1215
|
+
// "earlier in the period you…" / "yesterday you…" naturally.
|
|
1216
|
+
const recentPromptExcerpts = [];
|
|
1217
|
+
const dir = path.join(HOME, '.bandit', 'sessions');
|
|
1218
|
+
for (const session of data.sessions.slice(0, 12)) {
|
|
1219
|
+
if (recentPromptExcerpts.length >= 25)
|
|
1220
|
+
break;
|
|
1221
|
+
const sessionDate = new Date(session.startedAt).toISOString().slice(0, 10);
|
|
1222
|
+
try {
|
|
1223
|
+
const text = fs.readFileSync(path.join(dir, session.id + '.jsonl'), 'utf-8');
|
|
1224
|
+
for (const line of text.split('\n')) {
|
|
1225
|
+
if (recentPromptExcerpts.length >= 25)
|
|
1226
|
+
break;
|
|
1227
|
+
if (!line.trim())
|
|
1228
|
+
continue;
|
|
1229
|
+
let parsed = null;
|
|
1230
|
+
try {
|
|
1231
|
+
parsed = JSON.parse(line);
|
|
1232
|
+
}
|
|
1233
|
+
catch {
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
if (!parsed || parsed.role !== 'user' || typeof parsed.content !== 'string')
|
|
1237
|
+
continue;
|
|
1238
|
+
if (parsed.content.startsWith('<tool_result'))
|
|
1239
|
+
continue;
|
|
1240
|
+
if (parsed.content.startsWith('[Background tasks'))
|
|
1241
|
+
continue;
|
|
1242
|
+
const excerpt = parsed.content.replace(/\s+/g, ' ').trim().slice(0, 280);
|
|
1243
|
+
if (excerpt.length > 6)
|
|
1244
|
+
recentPromptExcerpts.push({ date: sessionDate, text: excerpt });
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
catch {
|
|
1248
|
+
/* unreadable — skip */
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
// windowDays — derived from oldest session timestamp so the LLM knows
|
|
1252
|
+
// whether to write "the last 3 days" or "the last 5 weeks".
|
|
1253
|
+
const sessionTimestamps = data.sessions
|
|
1254
|
+
.map((s) => s.startedAt)
|
|
1255
|
+
.filter((t) => typeof t === 'number' && t > 0);
|
|
1256
|
+
const oldest = sessionTimestamps.length > 0 ? Math.min(...sessionTimestamps) : Date.now();
|
|
1257
|
+
const windowDays = Math.max(1, Math.round((Date.now() - oldest) / (24 * 60 * 60 * 1000)));
|
|
1258
|
+
return {
|
|
1259
|
+
totalPrompts: data.totalPrompts,
|
|
1260
|
+
totalSessions: data.sessions.length,
|
|
1261
|
+
filesTouched: data.accomplishments.filesTouched,
|
|
1262
|
+
filesWritten: data.accomplishments.filesWritten,
|
|
1263
|
+
editsApplied: data.accomplishments.editsApplied,
|
|
1264
|
+
gitOperations: data.accomplishments.gitOperations,
|
|
1265
|
+
subagentsSpawned: data.accomplishments.subagentsSpawned,
|
|
1266
|
+
testsRun: data.accomplishments.testsRun,
|
|
1267
|
+
windowDays,
|
|
1268
|
+
topTools,
|
|
1269
|
+
topErrors,
|
|
1270
|
+
recentPromptExcerpts,
|
|
1271
|
+
workHighlights: data.work.highlights.slice(0, 10).map((h) => ({
|
|
1272
|
+
date: h.date,
|
|
1273
|
+
title: h.title,
|
|
1274
|
+
area: h.area,
|
|
1275
|
+
category: h.category,
|
|
1276
|
+
outcome: h.outcome,
|
|
1277
|
+
prompt: h.prompt.replace(/\s+/g, ' ').trim().slice(0, 400),
|
|
1278
|
+
turns: h.turns,
|
|
1279
|
+
filesTouched: h.filesTouched,
|
|
1280
|
+
filesInspected: h.filesInspected,
|
|
1281
|
+
externalActions: h.externalActions,
|
|
1282
|
+
testsRun: h.testsRun,
|
|
1283
|
+
gitOperations: h.gitOperations,
|
|
1284
|
+
subagentsSpawned: h.subagentsSpawned,
|
|
1285
|
+
commands: h.commands.slice(0, 3),
|
|
1286
|
+
topFiles: h.topFiles.slice(0, 4).map((f) => f.path),
|
|
1287
|
+
languages: h.languages.slice(0, 4).map((l) => l.label)
|
|
1288
|
+
})),
|
|
1289
|
+
workThemes: data.work.themes.slice(0, 6).map((t) => ({
|
|
1290
|
+
title: t.title,
|
|
1291
|
+
turns: t.turns,
|
|
1292
|
+
filesTouched: t.filesTouched,
|
|
1293
|
+
testsRun: t.testsRun,
|
|
1294
|
+
externalActions: t.externalActions,
|
|
1295
|
+
subagentsSpawned: t.subagentsSpawned,
|
|
1296
|
+
latest: t.latestDate,
|
|
1297
|
+
sampleTitles: t.sampleTitles.slice(0, 3),
|
|
1298
|
+
outcomes: t.outcomes.slice(0, 2),
|
|
1299
|
+
topFiles: t.topFiles.slice(0, 3).map((f) => f.path),
|
|
1300
|
+
languages: t.languages.slice(0, 4).map((l) => l.label)
|
|
1301
|
+
})),
|
|
1302
|
+
sentiment: data.sentiment
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Build the AI summary callback handed to `writeInsightsReport({ ai })`.
|
|
1307
|
+
*
|
|
1308
|
+
* Centralised so the CLI's `/insights` and the VS Code extension's
|
|
1309
|
+
* `banditStealth.insights` produce byte-identical prompts and parse
|
|
1310
|
+
* the response the same way. Without this, every surface that wants AI
|
|
1311
|
+
* summaries had to duplicate the system prompt + JSON-extraction logic
|
|
1312
|
+
* and the two could drift — the IDE's report ended up skipping AI
|
|
1313
|
+
* entirely ( ) while the CLI rendered the full
|
|
1314
|
+
* shipped/friction blocks. One helper, one prompt, identical output.
|
|
1315
|
+
*/
|
|
1316
|
+
function buildInsightsAiCallback(opts) {
|
|
1317
|
+
const system = `You are Bandit, an AI coding assistant, writing a journal-entry-style narrative of what the user did over the last ${'${windowDays}'} days. Read the prompt excerpts and work highlights CAREFULLY — they contain the actual story. Your job is to tell it back in a way that's specific, honest, and never generic.
|
|
1318
|
+
|
|
1319
|
+
Output JSON with these fields, in this order:
|
|
1320
|
+
|
|
1321
|
+
1. "storyline" — 2 to 4 paragraphs of NARRATIVE PROSE, second person ("you"), written like the Claude.ai insights summary. Each paragraph 2-4 sentences. NAME SPECIFIC THINGS the user worked on (from recentPromptExcerpts and workHighlights[].prompt — pull verbatim phrases like "deep self-evaluation of Bandit", "MCP connectors", "open source prep"). Mention file paths only when they add color (e.g. "concentrated in clients.ts"). Catch side-quests and non-coding threads (career thoughts, frustration moments, meta prompts) when the excerpts reveal them. NEVER list counters as a sentence — weave them into prose ("you spawned 122 subagents to probe X" not "subagents: 122"). NEVER write a generic opener like "Over the last N days you worked on various projects." If you can't make it specific, write less.
|
|
1322
|
+
|
|
1323
|
+
2. "shipped" — 3 short bullets, accomplishment framing. Specific, like the storyline.
|
|
1324
|
+
|
|
1325
|
+
3. "patterns" — 3 short bullets, HOW the user works (tool mix, commit cadence, debug style, language focus, delegation to subagents). Reference sentiment + recent prompts.
|
|
1326
|
+
|
|
1327
|
+
4. "friction" — 3 short bullets where YOU (Bandit) got in the user's way. Own every miss as Bandit's behavior; never blame the user. If sentiment shows frustrated/unsatisfied counts or notable phrases, name what Bandit did to earn it.
|
|
1328
|
+
|
|
1329
|
+
Bullet length: under 22 words each. Storyline paragraphs: 2-4 sentences each, conversational.
|
|
1330
|
+
|
|
1331
|
+
Return JSON ONLY: {"storyline":[...2-4 paragraph strings...],"shipped":[...3 strings...],"patterns":[...3 strings...],"friction":[...3 strings...]} and nothing else. No code fences, no markdown, no commentary.`;
|
|
1332
|
+
return async (input) => {
|
|
1333
|
+
const systemWithWindow = system.replace('${windowDays}', String(input.windowDays));
|
|
1334
|
+
const userMsg = JSON.stringify({
|
|
1335
|
+
windowDays: input.windowDays,
|
|
1336
|
+
totalPrompts: input.totalPrompts,
|
|
1337
|
+
totalSessions: input.totalSessions,
|
|
1338
|
+
filesTouched: input.filesTouched,
|
|
1339
|
+
editsApplied: input.editsApplied,
|
|
1340
|
+
filesWritten: input.filesWritten,
|
|
1341
|
+
gitOperations: input.gitOperations,
|
|
1342
|
+
subagentsSpawned: input.subagentsSpawned,
|
|
1343
|
+
testsRun: input.testsRun,
|
|
1344
|
+
topTools: input.topTools.slice(0, 6),
|
|
1345
|
+
topErrors: input.topErrors.slice(0, 6),
|
|
1346
|
+
recentPromptExcerpts: input.recentPromptExcerpts.slice(0, 25),
|
|
1347
|
+
workHighlights: input.workHighlights.slice(0, 10),
|
|
1348
|
+
workThemes: input.workThemes.slice(0, 6),
|
|
1349
|
+
sentiment: input.sentiment
|
|
1350
|
+
}, null, 2);
|
|
1351
|
+
const raw = await opts.oneShotChat(userMsg, {
|
|
1352
|
+
systemPrompt: systemWithWindow,
|
|
1353
|
+
// Storyline output is materially longer than the old 3-bullet
|
|
1354
|
+
// shape (~120-400 words vs ~60 words), so bump the timeout to
|
|
1355
|
+
// give a 27B-class local model room to finish without truncating.
|
|
1356
|
+
timeoutMs: opts.timeoutMs ?? 60000
|
|
1357
|
+
});
|
|
1358
|
+
if (!raw)
|
|
1359
|
+
return null;
|
|
1360
|
+
try {
|
|
1361
|
+
const m = raw.match(/\{[\s\S]*\}/);
|
|
1362
|
+
if (!m)
|
|
1363
|
+
return null;
|
|
1364
|
+
const parsed = JSON.parse(m[0]);
|
|
1365
|
+
const storyline = Array.isArray(parsed.storyline)
|
|
1366
|
+
? parsed.storyline.filter((s) => typeof s === 'string' && s.trim().length > 0).slice(0, 4)
|
|
1367
|
+
: [];
|
|
1368
|
+
const shipped = Array.isArray(parsed.shipped)
|
|
1369
|
+
? parsed.shipped.filter((b) => typeof b === 'string').slice(0, 3)
|
|
1370
|
+
: [];
|
|
1371
|
+
const friction = Array.isArray(parsed.friction)
|
|
1372
|
+
? parsed.friction.filter((b) => typeof b === 'string').slice(0, 3)
|
|
1373
|
+
: [];
|
|
1374
|
+
const patterns = Array.isArray(parsed.patterns)
|
|
1375
|
+
? parsed.patterns.filter((b) => typeof b === 'string').slice(0, 3)
|
|
1376
|
+
: [];
|
|
1377
|
+
if (storyline.length === 0 && shipped.length === 0 && friction.length === 0 && patterns.length === 0)
|
|
1378
|
+
return null;
|
|
1379
|
+
return { modelLabel: opts.modelLabel, storyline, shipped, friction, patterns };
|
|
1380
|
+
}
|
|
1381
|
+
catch {
|
|
1382
|
+
return null;
|
|
1383
|
+
}
|
|
1384
|
+
};
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Heuristic productivity tips. Each one is a (predicate, tip) pair —
|
|
1388
|
+
* fire when the predicate matches the user's data. Kept dumb on
|
|
1389
|
+
* purpose; tips show only when there's a clear signal in the numbers.
|
|
1390
|
+
*/
|
|
1391
|
+
function buildTips(data) {
|
|
1392
|
+
const tips = [];
|
|
1393
|
+
// Tip 1: apply_edit error rate over 20% → suggest /think on
|
|
1394
|
+
const applyEdit = data.toolStats.get('apply_edit');
|
|
1395
|
+
if (applyEdit && applyEdit.calls >= 5 && applyEdit.errors / applyEdit.calls > 0.2) {
|
|
1396
|
+
const pct = Math.round((applyEdit.errors / applyEdit.calls) * 100);
|
|
1397
|
+
tips.push(`<strong>apply_edit failing ${pct}% of the time</strong> over ${applyEdit.calls} calls. ` +
|
|
1398
|
+
`The most common cause is whitespace drift between your model's recall and the file on disk. ` +
|
|
1399
|
+
`Try <code>/think on</code> for tricky edits, or pin a smaller surface-area edit (single line).`);
|
|
1400
|
+
}
|
|
1401
|
+
// Tip 2: huge sessions never compacted → suggest /compact
|
|
1402
|
+
const giant = data.sessions.find((s) => s.approxChars > 200000);
|
|
1403
|
+
if (giant) {
|
|
1404
|
+
tips.push(`<strong>You have at least one massive session</strong> (${(giant.approxChars / 1000).toFixed(0)}KB of conversation). ` +
|
|
1405
|
+
`Use <code>/compact</code> mid-session to trim old tool results — keeps context small and turns fast.`);
|
|
1406
|
+
}
|
|
1407
|
+
// Tip 3: never used /tasks → tip about background subagents
|
|
1408
|
+
const usedTask = data.toolStats.has('task');
|
|
1409
|
+
if (data.totalPrompts > 30 && !usedTask) {
|
|
1410
|
+
tips.push(`<strong>You've never used the <code>task</code> tool.</strong> Long investigations ("audit every call site of X") ` +
|
|
1411
|
+
`block the conversation while they run. Try <code>task(run_in_background="true")</code> — the synopsis lands on a ` +
|
|
1412
|
+
`later turn so you can keep working in the meantime.`);
|
|
1413
|
+
}
|
|
1414
|
+
// Tip 4: many run_command failures → check the allowlist or shell escaping
|
|
1415
|
+
const runCmd = data.toolStats.get('run_command');
|
|
1416
|
+
if (runCmd && runCmd.calls >= 10 && runCmd.errors / runCmd.calls > 0.3) {
|
|
1417
|
+
const pct = Math.round((runCmd.errors / runCmd.calls) * 100);
|
|
1418
|
+
tips.push(`<strong>run_command failing ${pct}% of the time</strong> over ${runCmd.calls} calls. ` +
|
|
1419
|
+
`If you were on a pre-1.7.114 build, this was almost entirely the missing <code>mkdir</code> / <code>mv</code> / <code>cp</code> ` +
|
|
1420
|
+
`entries on the allow-list — those shipped in 1.7.114. After upgrading, the residual misses are usually arg-quoting ` +
|
|
1421
|
+
`(spaces in paths) or commands that genuinely need a package install first.`);
|
|
1422
|
+
}
|
|
1423
|
+
// Tip 5: never customized BANDIT.md → suggest /init
|
|
1424
|
+
const usedInit = [...data.toolStats.keys()].some((k) => k === 'create_skill') ||
|
|
1425
|
+
data.sessions.some((s) => s.toolNames.has('write_file'));
|
|
1426
|
+
if (data.totalPrompts > 20 && !usedInit) {
|
|
1427
|
+
tips.push(`<strong>The agent has access to project memory</strong>, but you haven't seeded one yet. ` +
|
|
1428
|
+
`Run <code>/init</code> in the workspace root — bandit scans the repo and writes a <code>BANDIT.md</code> ` +
|
|
1429
|
+
`with project conventions, build/test commands, and architecture notes. Every future prompt picks it up automatically.`);
|
|
1430
|
+
}
|
|
1431
|
+
// Always-on: encourage skill discovery if the agent has been heavily used.
|
|
1432
|
+
if (data.totalPrompts > 50 && tips.length < 4) {
|
|
1433
|
+
tips.push(`<strong>You've sent ${data.totalPrompts}+ prompts.</strong> Notice repeated workflows? ` +
|
|
1434
|
+
`Ask the agent: <em>"create a skill that does X"</em> — it'll write a markdown playbook to ` +
|
|
1435
|
+
`<code>.bandit/skills/</code> and the next prompt picks it up automatically.`);
|
|
1436
|
+
}
|
|
1437
|
+
return tips;
|
|
1438
|
+
}
|
|
1439
|
+
const escape = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
1440
|
+
const fmtBytes = (n) => {
|
|
1441
|
+
if (n >= 1000000)
|
|
1442
|
+
return `${(n / 1000000).toFixed(1)}M`;
|
|
1443
|
+
if (n >= 1000)
|
|
1444
|
+
return `${(n / 1000).toFixed(1)}K`;
|
|
1445
|
+
return `${n}`;
|
|
1446
|
+
};
|
|
1447
|
+
const fmtRelative = (ts, now) => {
|
|
1448
|
+
const ms = now - ts;
|
|
1449
|
+
if (ms < 60000)
|
|
1450
|
+
return 'just now';
|
|
1451
|
+
if (ms < 3600000)
|
|
1452
|
+
return `${Math.round(ms / 60000)}m ago`;
|
|
1453
|
+
if (ms < 86400000)
|
|
1454
|
+
return `${Math.round(ms / 3600000)}h ago`;
|
|
1455
|
+
if (ms < 7 * 86400000)
|
|
1456
|
+
return `${Math.round(ms / 86400000)}d ago`;
|
|
1457
|
+
return new Date(ts).toISOString().slice(0, 10);
|
|
1458
|
+
};
|
|
1459
|
+
/**
|
|
1460
|
+
* Render the data as a single self-contained HTML document. Inline
|
|
1461
|
+
* CSS, no JavaScript, no external resources — opens in any browser
|
|
1462
|
+
* and is share-as-a-single-file friendly. Intentionally simple:
|
|
1463
|
+
* tables, bar charts via flex-width divs, no chart library.
|
|
1464
|
+
*/
|
|
1465
|
+
function renderInsightsHtml(data) {
|
|
1466
|
+
const now = data.generatedAt;
|
|
1467
|
+
const tips = buildTips(data);
|
|
1468
|
+
// Top-N tools by call count
|
|
1469
|
+
const toolRows = [...data.toolStats.entries()]
|
|
1470
|
+
.map(([name, s]) => ({ name, ...s, errorRate: s.calls > 0 ? s.errors / s.calls : 0 }))
|
|
1471
|
+
.sort((a, b) => b.calls - a.calls)
|
|
1472
|
+
.slice(0, 15);
|
|
1473
|
+
const maxCalls = toolRows.reduce((m, r) => Math.max(m, r.calls), 0) || 1;
|
|
1474
|
+
// Recent sessions: filter out the empty-launch noise (1 prompt, 0 tool
|
|
1475
|
+
// calls, file size near the JSONL header). These are aborted REPL
|
|
1476
|
+
// startups (user opens bandit, types /quit) — twelve of them in a row
|
|
1477
|
+
// hide the actual work. Keep sessions that ran at least one tool call,
|
|
1478
|
+
// or sent more than one prompt (a real conversation).
|
|
1479
|
+
const recentSessions = data.sessions
|
|
1480
|
+
.filter((s) => s.toolCallCount > 0 || s.prompts > 1)
|
|
1481
|
+
.slice(0, 12);
|
|
1482
|
+
// Error clusters: pick the top tool with the most errors
|
|
1483
|
+
const errorList = [...data.errorClusters.entries()]
|
|
1484
|
+
.flatMap(([tool, bucket]) => bucket.map((b) => ({ tool, ...b })))
|
|
1485
|
+
.sort((a, b) => b.count - a.count)
|
|
1486
|
+
.slice(0, 8);
|
|
1487
|
+
// Prompts-per-day for the last 14 days
|
|
1488
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
1489
|
+
const promptsByDay = new Map();
|
|
1490
|
+
for (let i = 13; i >= 0; i -= 1) {
|
|
1491
|
+
const d = new Date(now - i * dayMs).toISOString().slice(0, 10);
|
|
1492
|
+
promptsByDay.set(d, 0);
|
|
1493
|
+
}
|
|
1494
|
+
for (const s of data.sessions) {
|
|
1495
|
+
const d = new Date(s.startedAt).toISOString().slice(0, 10);
|
|
1496
|
+
if (promptsByDay.has(d))
|
|
1497
|
+
promptsByDay.set(d, (promptsByDay.get(d) ?? 0) + s.prompts);
|
|
1498
|
+
}
|
|
1499
|
+
const dayMax = Math.max(...promptsByDay.values(), 1);
|
|
1500
|
+
const tipsHtml = tips.length > 0
|
|
1501
|
+
? `<ul>${tips.map((t) => `<li>${t}</li>`).join('')}</ul>`
|
|
1502
|
+
: `<p class="dim">Not enough data yet — keep using bandit and run <code>/insights</code> again later.</p>`;
|
|
1503
|
+
return `<!doctype html>
|
|
1504
|
+
<html lang="en">
|
|
1505
|
+
<head>
|
|
1506
|
+
<meta charset="utf-8">
|
|
1507
|
+
<title>Bandit Insights — ${new Date(now).toISOString().slice(0, 10)}</title>
|
|
1508
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1509
|
+
<style>
|
|
1510
|
+
:root {
|
|
1511
|
+
color-scheme: dark;
|
|
1512
|
+
--bg: #0b0f17;
|
|
1513
|
+
--panel: #131826;
|
|
1514
|
+
--border: #1f2638;
|
|
1515
|
+
--text: #e7e9ea;
|
|
1516
|
+
--muted: #71767b;
|
|
1517
|
+
--accent: #38bdf8;
|
|
1518
|
+
--accent-strong: #0ea5e9;
|
|
1519
|
+
--warn: #f5a524;
|
|
1520
|
+
--bad: #f87171;
|
|
1521
|
+
--good: #4ade80;
|
|
1522
|
+
}
|
|
1523
|
+
* { box-sizing: border-box; }
|
|
1524
|
+
body {
|
|
1525
|
+
margin: 0;
|
|
1526
|
+
background: var(--bg);
|
|
1527
|
+
color: var(--text);
|
|
1528
|
+
font-family: "Inter", -apple-system, system-ui, "Segoe UI", Helvetica, sans-serif;
|
|
1529
|
+
-webkit-font-smoothing: antialiased;
|
|
1530
|
+
line-height: 1.5;
|
|
1531
|
+
}
|
|
1532
|
+
.wrap { max-width: 1080px; margin: 0 auto; padding: 32px 20px 80px; }
|
|
1533
|
+
header {
|
|
1534
|
+
display: flex; align-items: baseline; justify-content: space-between;
|
|
1535
|
+
margin-bottom: 24px; padding-bottom: 16px; border-bottom: 1px solid var(--border);
|
|
1536
|
+
}
|
|
1537
|
+
h1 {
|
|
1538
|
+
margin: 0;
|
|
1539
|
+
font-size: 22px;
|
|
1540
|
+
letter-spacing: 0.02em;
|
|
1541
|
+
display: inline-flex;
|
|
1542
|
+
align-items: center;
|
|
1543
|
+
gap: 12px;
|
|
1544
|
+
}
|
|
1545
|
+
h1 .logo {
|
|
1546
|
+
width: 32px;
|
|
1547
|
+
height: 32px;
|
|
1548
|
+
object-fit: contain;
|
|
1549
|
+
/* Fall back gracefully if the CDN is unreachable (image is decorative). */
|
|
1550
|
+
}
|
|
1551
|
+
h1 .accent { color: var(--accent); }
|
|
1552
|
+
.meta { color: var(--muted); font-size: 13px; }
|
|
1553
|
+
h2 {
|
|
1554
|
+
margin: 32px 0 12px;
|
|
1555
|
+
font-size: 14px;
|
|
1556
|
+
text-transform: uppercase;
|
|
1557
|
+
letter-spacing: 0.12em;
|
|
1558
|
+
color: var(--muted);
|
|
1559
|
+
font-weight: 600;
|
|
1560
|
+
}
|
|
1561
|
+
.panel {
|
|
1562
|
+
background: var(--panel);
|
|
1563
|
+
border: 1px solid var(--border);
|
|
1564
|
+
border-radius: 10px;
|
|
1565
|
+
padding: 16px 18px;
|
|
1566
|
+
}
|
|
1567
|
+
.grid { display: grid; gap: 16px; }
|
|
1568
|
+
.grid-4 { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
|
|
1569
|
+
.stat { padding: 14px; }
|
|
1570
|
+
.stat .v { font-size: 28px; font-weight: 700; color: var(--accent); }
|
|
1571
|
+
.stat .l { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 2px; }
|
|
1572
|
+
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
1573
|
+
th, td { text-align: left; padding: 6px 8px; }
|
|
1574
|
+
th { color: var(--muted); font-weight: 500; text-transform: uppercase; font-size: 11px; letter-spacing: 0.06em; border-bottom: 1px solid var(--border); }
|
|
1575
|
+
tr td { border-bottom: 1px solid var(--border); }
|
|
1576
|
+
tr:last-child td { border-bottom: 0; }
|
|
1577
|
+
td.right, th.right { text-align: right; font-variant-numeric: tabular-nums; }
|
|
1578
|
+
.bar-cell { padding: 4px 8px; }
|
|
1579
|
+
.bar { display: flex; align-items: center; gap: 8px; }
|
|
1580
|
+
.bar-track { flex: 1; height: 8px; background: rgba(56,189,248,0.08); border-radius: 999px; overflow: hidden; }
|
|
1581
|
+
.bar-fill { height: 100%; background: var(--accent); }
|
|
1582
|
+
.bar-fill.warn { background: var(--warn); }
|
|
1583
|
+
.bar-fill.bad { background: var(--bad); }
|
|
1584
|
+
.err-rate { font-size: 11px; padding: 2px 6px; border-radius: 999px; }
|
|
1585
|
+
.err-rate.ok { background: rgba(74,222,128,0.12); color: var(--good); }
|
|
1586
|
+
.err-rate.warn { background: rgba(245,165,36,0.12); color: var(--warn); }
|
|
1587
|
+
.err-rate.bad { background: rgba(248,113,113,0.12); color: var(--bad); }
|
|
1588
|
+
.day-bar { display: flex; align-items: end; gap: 4px; height: 100px; padding: 8px 0; }
|
|
1589
|
+
.day-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; }
|
|
1590
|
+
.day-col .col { width: 100%; min-height: 2px; background: var(--accent); border-radius: 2px 2px 0 0; }
|
|
1591
|
+
.day-col .lab { font-size: 9px; color: var(--muted); transform: rotate(-45deg); transform-origin: center; }
|
|
1592
|
+
.day-col .v { font-size: 10px; color: var(--muted); }
|
|
1593
|
+
.errors li { margin-bottom: 8px; font-size: 13px; }
|
|
1594
|
+
.errors .tool { font-family: var(--mono, monospace); color: var(--accent); }
|
|
1595
|
+
.errors .err { color: var(--bad); font-size: 12px; word-break: break-word; }
|
|
1596
|
+
.tips li { margin-bottom: 12px; padding: 12px 14px; background: rgba(56,189,248,0.04); border-left: 3px solid var(--accent); border-radius: 0 6px 6px 0; }
|
|
1597
|
+
.tips code { background: rgba(255,255,255,0.06); padding: 1px 5px; border-radius: 3px; font-size: 90%; }
|
|
1598
|
+
.dim { color: var(--muted); }
|
|
1599
|
+
.pill { display: inline-block; font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(56,189,248,0.12); color: var(--accent); margin-left: 6px; vertical-align: middle; }
|
|
1600
|
+
.ai-grid { display: grid; gap: 16px; grid-template-columns: 1fr 1fr; }
|
|
1601
|
+
@media (max-width: 720px) { .ai-grid { grid-template-columns: 1fr; } }
|
|
1602
|
+
.ai-card { padding: 16px 18px; }
|
|
1603
|
+
.storyline { padding: 22px 26px; margin-bottom: 16px; background: linear-gradient(135deg, rgba(56,189,248,0.05), rgba(255,255,255,0.02)); border-left: 3px solid var(--accent); }
|
|
1604
|
+
.storyline p { margin: 0 0 14px; font-size: 14.5px; line-height: 1.65; color: var(--text); }
|
|
1605
|
+
.storyline p:last-child { margin-bottom: 0; }
|
|
1606
|
+
.ai-card h3 { margin: 0 0 10px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); font-weight: 600; }
|
|
1607
|
+
.ai-card.shipped h3 { color: var(--good); }
|
|
1608
|
+
.ai-card.friction h3 { color: var(--warn); }
|
|
1609
|
+
.ai-card.patterns h3 { color: var(--accent); }
|
|
1610
|
+
.sent-row { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
1611
|
+
.sent-chip { font-size: 12px; padding: 5px 12px; border-radius: 999px; background: rgba(56,189,248,0.05); border: 1px solid var(--border); color: var(--text); }
|
|
1612
|
+
.sent-chip.sent-pos { background: rgba(34,197,94,0.07); border-color: rgba(34,197,94,0.25); }
|
|
1613
|
+
.sent-chip.sent-pos strong { color: var(--good); }
|
|
1614
|
+
.sent-chip.sent-neg { background: rgba(248,113,113,0.07); border-color: rgba(248,113,113,0.25); }
|
|
1615
|
+
.sent-chip.sent-neg strong { color: var(--warn); }
|
|
1616
|
+
.sent-notable { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
|
|
1617
|
+
.sent-notable-label { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); margin-bottom: 6px; }
|
|
1618
|
+
.sent-notable ul { margin: 0; padding-left: 18px; }
|
|
1619
|
+
.sent-notable li { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
1620
|
+
.ai-card ul { padding-left: 18px; margin: 0; }
|
|
1621
|
+
.ai-card li { margin-bottom: 8px; font-size: 13px; line-height: 1.55; }
|
|
1622
|
+
.work-grid { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); }
|
|
1623
|
+
.work-card h3 { margin: 0 0 6px; font-size: 15px; line-height: 1.3; }
|
|
1624
|
+
.work-card .sub { font-size: 12px; color: var(--muted); margin-bottom: 10px; }
|
|
1625
|
+
.work-card .summary { font-size: 13px; color: var(--text); margin-bottom: 10px; }
|
|
1626
|
+
.work-card .outcome { font-size: 12px; color: var(--good); padding: 8px 10px; margin: 8px 0 10px; border-radius: 6px; background: rgba(74,222,128,0.07); border: 1px solid rgba(74,222,128,0.16); }
|
|
1627
|
+
.work-card .file-list { margin: 8px 0 0; padding-left: 0; list-style: none; }
|
|
1628
|
+
.work-card .file-list li { font-size: 12px; color: var(--muted); margin-bottom: 4px; word-break: break-word; }
|
|
1629
|
+
.theme-card h3 { margin: 0 0 8px; font-size: 16px; }
|
|
1630
|
+
.theme-meta { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 10px; }
|
|
1631
|
+
.theme-meta span { font-size: 11px; padding: 3px 8px; border-radius: 999px; background: rgba(255,255,255,0.05); color: var(--muted); }
|
|
1632
|
+
.theme-card ul { margin: 8px 0 0; padding-left: 18px; }
|
|
1633
|
+
.theme-card li { font-size: 12px; color: var(--muted); margin-bottom: 4px; }
|
|
1634
|
+
.lang-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
1635
|
+
.lang-chip { font-size: 11px; padding: 4px 10px; border-radius: 999px; background: rgba(56,189,248,0.08); color: var(--text); border: 1px solid rgba(56,189,248,0.18); }
|
|
1636
|
+
.lang-chip strong { color: var(--accent); }
|
|
1637
|
+
.share-btn { display: inline-block; margin-top: 8px; padding: 8px 14px; background: var(--accent); color: var(--bg); text-decoration: none; border-radius: 6px; font-size: 13px; font-weight: 600; }
|
|
1638
|
+
.share-btn:hover { background: var(--accent-strong); }
|
|
1639
|
+
ul, ol { padding-left: 18px; }
|
|
1640
|
+
footer { margin-top: 40px; padding-top: 16px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; text-align: center; }
|
|
1641
|
+
footer a { color: var(--accent); }
|
|
1642
|
+
</style>
|
|
1643
|
+
</head>
|
|
1644
|
+
<body>
|
|
1645
|
+
<div class="wrap">
|
|
1646
|
+
|
|
1647
|
+
<header>
|
|
1648
|
+
<h1>
|
|
1649
|
+
<img src="https://cdn.burtson.ai/logos/bandit-stealth.png" alt="Bandit" class="logo" />
|
|
1650
|
+
Bandit <span class="accent">insights</span>
|
|
1651
|
+
</h1>
|
|
1652
|
+
<span class="meta">generated ${new Date(now).toISOString().slice(0, 16).replace('T', ' ')} · ${escape(data.cwd)}</span>
|
|
1653
|
+
</header>
|
|
1654
|
+
|
|
1655
|
+
<h2>At a glance</h2>
|
|
1656
|
+
<div class="grid grid-4">
|
|
1657
|
+
<div class="panel stat"><div class="v">${data.sessions.length}</div><div class="l">sessions</div></div>
|
|
1658
|
+
<div class="panel stat"><div class="v">${data.totalPrompts}</div><div class="l">user prompts</div></div>
|
|
1659
|
+
<div class="panel stat"><div class="v">~${fmtBytes(data.totalApproxTokens)}</div><div class="l">tokens (est)</div></div>
|
|
1660
|
+
<div class="panel stat"><div class="v">${[...data.toolStats.values()].reduce((s, t) => s + t.calls, 0)}</div><div class="l">tool calls</div></div>
|
|
1661
|
+
</div>
|
|
1662
|
+
${data.streak.longest > 0 || data.peakDay || data.firstSeenAt ? `
|
|
1663
|
+
<div class="grid grid-4" style="margin-top:12px">
|
|
1664
|
+
${data.streak.longest > 0 ? `<div class="panel stat"><div class="v">${data.streak.longest}d</div><div class="l">longest streak${data.streak.current > 0 ? ` · ${data.streak.current} now` : ''}</div></div>` : ''}
|
|
1665
|
+
${data.peakDay ? `<div class="panel stat"><div class="v">${data.peakDay.prompts}</div><div class="l">peak day · ${escape(data.peakDay.date)}</div></div>` : ''}
|
|
1666
|
+
${data.firstSeenAt ? `<div class="panel stat"><div class="v">${Math.max(1, Math.floor((now - data.firstSeenAt) / (24 * 60 * 60 * 1000)))}d</div><div class="l">since first run</div></div>` : ''}
|
|
1667
|
+
${data.accomplishments.commitsMade > 0 ? `<div class="panel stat"><div class="v">${data.accomplishments.commitsMade}</div><div class="l">commits made</div></div>` : ''}
|
|
1668
|
+
</div>` : ''}
|
|
1669
|
+
${data.ai ? `
|
|
1670
|
+
<h2>Your story <span class="pill">${escape(data.ai.modelLabel)}</span></h2>
|
|
1671
|
+
${data.ai.storyline && data.ai.storyline.length > 0 ? `
|
|
1672
|
+
<div class="panel storyline">
|
|
1673
|
+
${data.ai.storyline.map((p) => `<p>${escape(p)}</p>`).join('')}
|
|
1674
|
+
</div>` : ''}
|
|
1675
|
+
<div class="ai-grid">
|
|
1676
|
+
<div class="panel ai-card shipped">
|
|
1677
|
+
<h3>What you shipped</h3>
|
|
1678
|
+
<ul>${data.ai.shipped.map((b) => `<li>${escape(b)}</li>`).join('')}</ul>
|
|
1679
|
+
</div>
|
|
1680
|
+
${data.ai.patterns && data.ai.patterns.length > 0 ? `
|
|
1681
|
+
<div class="panel ai-card patterns">
|
|
1682
|
+
<h3>How you work</h3>
|
|
1683
|
+
<ul>${data.ai.patterns.map((b) => `<li>${escape(b)}</li>`).join('')}</ul>
|
|
1684
|
+
</div>` : ''}
|
|
1685
|
+
<div class="panel ai-card friction">
|
|
1686
|
+
<h3>Where Bandit got in your way</h3>
|
|
1687
|
+
<ul>${data.ai.friction.map((b) => `<li>${escape(b)}</li>`).join('')}</ul>
|
|
1688
|
+
</div>
|
|
1689
|
+
</div>` : ''}
|
|
1690
|
+
${data.localStory.length > 0 ? `
|
|
1691
|
+
<h2>Recent wins <span class="pill">local synthesis</span></h2>
|
|
1692
|
+
<div class="panel storyline">
|
|
1693
|
+
${data.localStory.map((p) => `<p>${escape(p)}</p>`).join('')}
|
|
1694
|
+
</div>` : ''}
|
|
1695
|
+
${(() => {
|
|
1696
|
+
const s = data.sentiment;
|
|
1697
|
+
const total = s.satisfied + s.happy + s.excited + s.frustrated + s.unsatisfied;
|
|
1698
|
+
if (total === 0)
|
|
1699
|
+
return '';
|
|
1700
|
+
const chip = (label, count, klass) => count > 0 ? `<span class="sent-chip ${klass}">${escape(label)} <strong>${count}</strong></span>` : '';
|
|
1701
|
+
const notable = s.notable.length > 0
|
|
1702
|
+
? `<div class="sent-notable"><div class="sent-notable-label">Frustration moments</div><ul>${s.notable.map(n => `<li>${escape(n)}</li>`).join('')}</ul></div>`
|
|
1703
|
+
: '';
|
|
1704
|
+
return `
|
|
1705
|
+
<h2>How you felt</h2>
|
|
1706
|
+
<div class="panel">
|
|
1707
|
+
<div class="sent-row">
|
|
1708
|
+
${chip('excited', s.excited, 'sent-pos')}
|
|
1709
|
+
${chip('happy', s.happy, 'sent-pos')}
|
|
1710
|
+
${chip('satisfied', s.satisfied, 'sent-pos')}
|
|
1711
|
+
${chip('unsatisfied', s.unsatisfied, 'sent-neg')}
|
|
1712
|
+
${chip('frustrated', s.frustrated, 'sent-neg')}
|
|
1713
|
+
</div>
|
|
1714
|
+
${notable}
|
|
1715
|
+
</div>`;
|
|
1716
|
+
})()}
|
|
1717
|
+
|
|
1718
|
+
${data.work.themes.length > 0 ? `
|
|
1719
|
+
<h2>Bigger arcs</h2>
|
|
1720
|
+
<div class="work-grid">
|
|
1721
|
+
${data.work.themes.slice(0, 6).map((theme) => `
|
|
1722
|
+
<div class="panel theme-card">
|
|
1723
|
+
<h3>${escape(theme.title)}</h3>
|
|
1724
|
+
<div class="theme-meta">
|
|
1725
|
+
<span>${theme.turns} turn${theme.turns === 1 ? '' : 's'}</span>
|
|
1726
|
+
${theme.filesTouched > 0 ? `<span>${theme.filesTouched} files touched</span>` : ''}
|
|
1727
|
+
${theme.editsAndWrites > 0 ? `<span>${theme.editsAndWrites} edits/writes</span>` : ''}
|
|
1728
|
+
${theme.externalActions > 0 ? `<span>${theme.externalActions} external actions</span>` : ''}
|
|
1729
|
+
${theme.testsRun > 0 ? `<span>${theme.testsRun} tests</span>` : ''}
|
|
1730
|
+
${theme.subagentsSpawned > 0 ? `<span>${theme.subagentsSpawned} subagents</span>` : ''}
|
|
1731
|
+
<span>latest ${escape(theme.latestDate)}</span>
|
|
1732
|
+
</div>
|
|
1733
|
+
${theme.languages.length > 0 ? `<div class="lang-row">${theme.languages.slice(0, 5).map((l) => `<span class="lang-chip">${escape(l.label)} <strong>${l.count}</strong></span>`).join('')}</div>` : ''}
|
|
1734
|
+
${theme.outcomes.length > 0 ? `<ul>${theme.outcomes.slice(0, 2).map((t) => `<li>${escape(t)}</li>`).join('')}</ul>` : ''}
|
|
1735
|
+
${theme.sampleTitles.length > 0 ? `<ul>${theme.sampleTitles.slice(0, 3).map((t) => `<li>${escape(t)}</li>`).join('')}</ul>` : ''}
|
|
1736
|
+
</div>`).join('')}
|
|
1737
|
+
</div>` : ''}
|
|
1738
|
+
|
|
1739
|
+
${data.work.highlights.length > 0 ? `
|
|
1740
|
+
<h2>Largest work highlights</h2>
|
|
1741
|
+
<div class="work-grid">
|
|
1742
|
+
${data.work.highlights.slice(0, 10).map((h) => `
|
|
1743
|
+
<div class="panel work-card">
|
|
1744
|
+
<div class="sub">${escape(h.date)} · ${escape(h.area)} · ${escape(h.category)}</div>
|
|
1745
|
+
<h3>${escape(h.title)}</h3>
|
|
1746
|
+
<div class="summary">${escape(h.summary)}</div>
|
|
1747
|
+
${h.outcome ? `<div class="outcome">${escape(h.outcome)}</div>` : ''}
|
|
1748
|
+
${h.languages.length > 0 ? `<div class="lang-row">${h.languages.slice(0, 4).map((l) => `<span class="lang-chip">${escape(l.label)} <strong>${l.count}</strong></span>`).join('')}</div>` : ''}
|
|
1749
|
+
${h.topFiles.length > 0 ? `<ul class="file-list">${h.topFiles.slice(0, 4).map((f) => `<li><code>${escape(f.path)}</code> ${escape('×' + f.touches)}</li>`).join('')}</ul>` : ''}
|
|
1750
|
+
</div>`).join('')}
|
|
1751
|
+
</div>` : ''}
|
|
1752
|
+
|
|
1753
|
+
<h2>Accomplishments</h2>
|
|
1754
|
+
<div class="grid grid-4">
|
|
1755
|
+
<div class="panel stat"><div class="v">${data.accomplishments.filesTouched}</div><div class="l">files touched</div></div>
|
|
1756
|
+
<div class="panel stat"><div class="v">${data.accomplishments.editsApplied + data.accomplishments.filesWritten}</div><div class="l">edits + writes</div></div>
|
|
1757
|
+
<div class="panel stat"><div class="v">${data.accomplishments.gitOperations}</div><div class="l">git operations</div></div>
|
|
1758
|
+
<div class="panel stat"><div class="v">${data.accomplishments.subagentsSpawned}</div><div class="l">subagents spawned</div></div>
|
|
1759
|
+
</div>
|
|
1760
|
+
${data.accomplishments.testsRun > 0 ? `<div class="panel" style="margin-top:8px"><strong>${data.accomplishments.testsRun}</strong> test runs detected (npm test, pytest, vitest, dotnet test, go test, cargo test).</div>` : ''}
|
|
1761
|
+
${data.accomplishments.languages.length > 0 ? `
|
|
1762
|
+
<div class="panel" style="margin-top:12px">
|
|
1763
|
+
<h3 style="margin:0 0 10px;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;">Languages touched</h3>
|
|
1764
|
+
<div class="lang-row">
|
|
1765
|
+
${data.accomplishments.languages.map((l) => `<span class="lang-chip">${escape(l.label)} <strong>${l.count}</strong></span>`).join('')}
|
|
1766
|
+
</div>
|
|
1767
|
+
</div>` : ''}
|
|
1768
|
+
${data.accomplishments.topFiles.length > 0 ? `
|
|
1769
|
+
<div class="panel" style="margin-top:12px">
|
|
1770
|
+
<h3 style="margin:0 0 8px;font-size:13px;color:var(--muted);text-transform:uppercase;letter-spacing:0.08em;">Most-touched files</h3>
|
|
1771
|
+
<table>
|
|
1772
|
+
<tbody>
|
|
1773
|
+
${data.accomplishments.topFiles.map((f) => `<tr>
|
|
1774
|
+
<td><code>${escape(f.path)}</code></td>
|
|
1775
|
+
<td class="right">${f.touches}× edited</td>
|
|
1776
|
+
</tr>`).join('')}
|
|
1777
|
+
</tbody>
|
|
1778
|
+
</table>
|
|
1779
|
+
</div>` : ''}
|
|
1780
|
+
|
|
1781
|
+
<h2>Activity (last 14 days)</h2>
|
|
1782
|
+
<div class="panel">
|
|
1783
|
+
<div class="day-bar">
|
|
1784
|
+
${[...promptsByDay.entries()].map(([day, count]) => {
|
|
1785
|
+
const h = Math.max(2, Math.round((count / dayMax) * 80));
|
|
1786
|
+
return `<div class="day-col" title="${day}: ${count} prompts">
|
|
1787
|
+
<div class="v">${count > 0 ? count : '·'}</div>
|
|
1788
|
+
<div class="col" style="height:${h}px"></div>
|
|
1789
|
+
<div class="lab">${day.slice(5)}</div>
|
|
1790
|
+
</div>`;
|
|
1791
|
+
}).join('')}
|
|
1792
|
+
</div>
|
|
1793
|
+
</div>
|
|
1794
|
+
|
|
1795
|
+
<h2>Tool usage (top ${toolRows.length})</h2>
|
|
1796
|
+
<div class="panel">
|
|
1797
|
+
${toolRows.length === 0
|
|
1798
|
+
? '<p class="dim">No tool-call telemetry yet — turn logs accumulate as the agent works.</p>'
|
|
1799
|
+
: `<table>
|
|
1800
|
+
<thead><tr><th>Tool</th><th class="right">Calls</th><th class="bar-cell">Volume</th><th class="right">Errors</th><th class="right">Error rate</th></tr></thead>
|
|
1801
|
+
<tbody>
|
|
1802
|
+
${toolRows.map((r) => {
|
|
1803
|
+
const pct = Math.round((r.calls / maxCalls) * 100);
|
|
1804
|
+
const errPct = Math.round(r.errorRate * 100);
|
|
1805
|
+
const errClass = errPct >= 25 ? 'bad' : errPct >= 10 ? 'warn' : 'ok';
|
|
1806
|
+
const fillClass = errPct >= 25 ? 'bad' : errPct >= 10 ? 'warn' : '';
|
|
1807
|
+
return `<tr>
|
|
1808
|
+
<td><code>${escape(r.name)}</code></td>
|
|
1809
|
+
<td class="right">${r.calls}</td>
|
|
1810
|
+
<td class="bar-cell"><div class="bar"><div class="bar-track"><div class="bar-fill ${fillClass}" style="width:${pct}%"></div></div></div></td>
|
|
1811
|
+
<td class="right">${r.errors}</td>
|
|
1812
|
+
<td class="right"><span class="err-rate ${errClass}">${errPct}%</span></td>
|
|
1813
|
+
</tr>`;
|
|
1814
|
+
}).join('')}
|
|
1815
|
+
</tbody>
|
|
1816
|
+
</table>`}
|
|
1817
|
+
</div>
|
|
1818
|
+
|
|
1819
|
+
<h2>Productivity tips</h2>
|
|
1820
|
+
<div class="panel tips">
|
|
1821
|
+
${tipsHtml}
|
|
1822
|
+
</div>
|
|
1823
|
+
|
|
1824
|
+
<h2>Recent sessions</h2>
|
|
1825
|
+
<div class="panel">
|
|
1826
|
+
${recentSessions.length === 0
|
|
1827
|
+
? '<p class="dim">No sessions yet — run <code>bandit</code> in your terminal to start one.</p>'
|
|
1828
|
+
: `<table>
|
|
1829
|
+
<thead><tr><th>Session</th><th>When</th><th class="right">Prompts</th><th class="right">Tool calls</th><th class="right">Size</th></tr></thead>
|
|
1830
|
+
<tbody>
|
|
1831
|
+
${recentSessions.map((s) => `<tr>
|
|
1832
|
+
<td><code>${escape(s.id)}</code></td>
|
|
1833
|
+
<td>${escape(fmtRelative(s.startedAt, now))}</td>
|
|
1834
|
+
<td class="right">${s.prompts}</td>
|
|
1835
|
+
<td class="right">${s.toolCallCount}</td>
|
|
1836
|
+
<td class="right">${fmtBytes(s.approxChars)}</td>
|
|
1837
|
+
</tr>`).join('')}
|
|
1838
|
+
</tbody>
|
|
1839
|
+
</table>`}
|
|
1840
|
+
</div>
|
|
1841
|
+
|
|
1842
|
+
<h2>Top error patterns</h2>
|
|
1843
|
+
<div class="panel">
|
|
1844
|
+
${errorList.length === 0
|
|
1845
|
+
? '<p class="dim">No tool errors recorded — clean run.</p>'
|
|
1846
|
+
: `<ul class="errors">
|
|
1847
|
+
${errorList.map((e) => `<li>
|
|
1848
|
+
<span class="tool">${escape(e.tool)}</span> · <strong>${e.count}×</strong>
|
|
1849
|
+
<div class="err">${escape(e.error)}</div>
|
|
1850
|
+
</li>`).join('')}
|
|
1851
|
+
</ul>`}
|
|
1852
|
+
</div>
|
|
1853
|
+
|
|
1854
|
+
${(() => {
|
|
1855
|
+
// Mailto body uses ONLY aggregate counts — no paths, no prompt
|
|
1856
|
+
// titles, no error strings. The full report is the HTML file
|
|
1857
|
+
// itself; we tell the user to attach it manually if they want
|
|
1858
|
+
// to share more than the headline numbers. Subject + body are
|
|
1859
|
+
// URL-encoded so emojis / spaces don't break the mailto handler.
|
|
1860
|
+
const subject = `Bandit insights — ${new Date(now).toISOString().slice(0, 10)}`;
|
|
1861
|
+
const bodyParts = [
|
|
1862
|
+
`Sharing my Bandit usage to help make it better. Counts only — full report HTML attached separately if I'm sending the file.`,
|
|
1863
|
+
``,
|
|
1864
|
+
`Sessions: ${data.sessions.length}`,
|
|
1865
|
+
`User prompts: ${data.totalPrompts}`,
|
|
1866
|
+
`Tool calls: ${[...data.toolStats.values()].reduce((s, t) => s + t.calls, 0)}`,
|
|
1867
|
+
`Files touched: ${data.accomplishments.filesTouched}`,
|
|
1868
|
+
`Edits + writes: ${data.accomplishments.editsApplied + data.accomplishments.filesWritten}`,
|
|
1869
|
+
`Git operations: ${data.accomplishments.gitOperations} (${data.accomplishments.commitsMade} commits)`,
|
|
1870
|
+
`Subagents spawned: ${data.accomplishments.subagentsSpawned}`,
|
|
1871
|
+
`Tests run: ${data.accomplishments.testsRun}`
|
|
1872
|
+
];
|
|
1873
|
+
if (data.streak.longest > 0)
|
|
1874
|
+
bodyParts.push(`Longest streak: ${data.streak.longest} days (current: ${data.streak.current})`);
|
|
1875
|
+
if (data.peakDay)
|
|
1876
|
+
bodyParts.push(`Peak day: ${data.peakDay.date} (${data.peakDay.prompts} prompts)`);
|
|
1877
|
+
if (data.accomplishments.languages.length > 0) {
|
|
1878
|
+
bodyParts.push(`Languages touched: ${data.accomplishments.languages.map((l) => `${l.label} ${l.count}`).join(', ')}`);
|
|
1879
|
+
}
|
|
1880
|
+
bodyParts.push('', `What I'd love to see improved:`, '');
|
|
1881
|
+
const bodyLines = bodyParts.join('\n');
|
|
1882
|
+
const href = `mailto:team@burtson.ai?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(bodyLines)}`;
|
|
1883
|
+
return `<h2>Help shape Bandit</h2>
|
|
1884
|
+
<div class="panel">
|
|
1885
|
+
<p style="margin:0 0 10px;font-size:13px;color:var(--text)">Want to send these aggregates to the Bandit team? The mailto button drops counts only into your default mail app — no file paths, no prompt titles, no error strings. If you want to share the full report, attach this HTML file manually.</p>
|
|
1886
|
+
<a href="${href}" class="share-btn">Share insights with team@burtson.ai</a>
|
|
1887
|
+
</div>`;
|
|
1888
|
+
})()}
|
|
1889
|
+
|
|
1890
|
+
<footer>
|
|
1891
|
+
Generated by <a href="https://burtson.ai">Bandit</a> · self-contained, no telemetry was sent · share this file freely
|
|
1892
|
+
</footer>
|
|
1893
|
+
|
|
1894
|
+
</div>
|
|
1895
|
+
</body>
|
|
1896
|
+
</html>
|
|
1897
|
+
`;
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* CLI entry: write the HTML report and (optionally) open it in the
|
|
1901
|
+
* default browser. Returns the absolute output path on success.
|
|
1902
|
+
*
|
|
1903
|
+
* Default output is `~/.bandit/insights.html` — same root the CLI uses
|
|
1904
|
+
* for sessions/config/themes/skills. The bulk of the report's data
|
|
1905
|
+
* source is `~/.bandit/sessions/*.jsonl` (global, all repos), so the
|
|
1906
|
+
* report belongs at user level, not at any individual workspace's
|
|
1907
|
+
* `.bandit/` dir. The `~/.bandit/` directory is created if missing.
|
|
1908
|
+
* Pass `--out <path>` to override (resolved against cwd).
|
|
1909
|
+
*/
|
|
1910
|
+
async function writeInsightsReport(opts) {
|
|
1911
|
+
const data = computeInsights(opts.cwd);
|
|
1912
|
+
if (opts.ai) {
|
|
1913
|
+
try {
|
|
1914
|
+
const summary = await opts.ai(buildAiInput(data));
|
|
1915
|
+
if (summary && summary.shipped.length > 0) {
|
|
1916
|
+
data.ai = summary;
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
catch {
|
|
1920
|
+
// Static report still gets written. The AI section just won't
|
|
1921
|
+
// render. We deliberately don't surface the error to the user
|
|
1922
|
+
// here — the slash command logs its own status.
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
const html = renderInsightsHtml(data);
|
|
1926
|
+
const outPath = opts.out
|
|
1927
|
+
? path.resolve(opts.cwd, opts.out)
|
|
1928
|
+
: path.join(HOME, '.bandit', 'insights.html');
|
|
1929
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
1930
|
+
fs.writeFileSync(outPath, html, 'utf-8');
|
|
1931
|
+
return outPath;
|
|
1932
|
+
}
|
|
1933
|
+
//# sourceMappingURL=insights.js.map
|