@stackmemoryai/stackmemory 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ import { fileURLToPath as __fileURLToPath } from 'url';
2
+ import { dirname as __pathDirname } from 'path';
3
+ const __filename = __fileURLToPath(import.meta.url);
4
+ const __dirname = __pathDirname(__filename);
5
+ import Database from "better-sqlite3";
6
+ import { mkdirSync } from "fs";
7
+ import { join } from "path";
8
+ import { homedir } from "os";
9
+ import { randomUUID } from "crypto";
10
+ function classifyErrorText(text) {
11
+ const lower = text.toLowerCase();
12
+ if (lower.includes("lint") || lower.includes("eslint") || lower.includes("prettier"))
13
+ return "lint_failure";
14
+ if (lower.includes("test") && (lower.includes("fail") || lower.includes("error")))
15
+ return "test_failure";
16
+ if (lower.includes("timeout") || lower.includes("timed out"))
17
+ return "timeout";
18
+ if (lower.includes("conflict") || lower.includes("merge"))
19
+ return "git_conflict";
20
+ if (lower.includes("429") || lower.includes("rate limit"))
21
+ return "rate_limit";
22
+ if (lower.includes("permission") || lower.includes("EACCES") || lower.includes("not found"))
23
+ return "permission_or_missing";
24
+ if (lower.includes("build") && lower.includes("error"))
25
+ return "build_failure";
26
+ return null;
27
+ }
28
+ function getTracesDbPath() {
29
+ return join(homedir(), ".stackmemory", "conductor", "traces.db");
30
+ }
31
+ function openTracesDb(dbPath) {
32
+ const path = dbPath ?? getTracesDbPath();
33
+ mkdirSync(join(path, ".."), { recursive: true });
34
+ const db = new Database(path);
35
+ db.pragma("journal_mode = WAL");
36
+ db.pragma("foreign_keys = ON");
37
+ db.exec(`
38
+ CREATE TABLE IF NOT EXISTS conductor_traces (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ issue_id TEXT NOT NULL,
41
+ session_id TEXT NOT NULL,
42
+ attempt INTEGER NOT NULL,
43
+ turn_number INTEGER NOT NULL,
44
+ timestamp INTEGER NOT NULL,
45
+ phase TEXT,
46
+ tool_names TEXT,
47
+ tool_count INTEGER DEFAULT 0,
48
+ files_modified INTEGER DEFAULT 0,
49
+ input_tokens INTEGER DEFAULT 0,
50
+ output_tokens INTEGER DEFAULT 0,
51
+ cache_creation_tokens INTEGER DEFAULT 0,
52
+ cache_read_tokens INTEGER DEFAULT 0,
53
+ message_preview TEXT,
54
+ event_json TEXT NOT NULL,
55
+ UNIQUE(session_id, turn_number)
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_traces_issue
59
+ ON conductor_traces(issue_id, attempt);
60
+ CREATE INDEX IF NOT EXISTS idx_traces_session
61
+ ON conductor_traces(session_id);
62
+ CREATE INDEX IF NOT EXISTS idx_traces_phase
63
+ ON conductor_traces(phase);
64
+ CREATE INDEX IF NOT EXISTS idx_traces_timestamp
65
+ ON conductor_traces(timestamp DESC);
66
+ `);
67
+ return db;
68
+ }
69
+ function withDb(db, fn) {
70
+ const ownDb = db ?? openTracesDb();
71
+ try {
72
+ return fn(ownDb);
73
+ } finally {
74
+ if (!db) ownDb.close();
75
+ }
76
+ }
77
+ class TraceCollector {
78
+ db;
79
+ ownsDb;
80
+ sessionId;
81
+ issueId;
82
+ attempt;
83
+ turnCounter = 0;
84
+ insertStmt;
85
+ constructor(opts) {
86
+ this.issueId = opts.issueId;
87
+ this.attempt = opts.attempt;
88
+ this.sessionId = `${opts.issueId}-${opts.attempt}-${randomUUID().slice(0, 8)}`;
89
+ this.ownsDb = !opts.db;
90
+ this.db = opts.db ?? openTracesDb();
91
+ this.insertStmt = this.db.prepare(`
92
+ INSERT INTO conductor_traces (
93
+ issue_id, session_id, attempt, turn_number, timestamp,
94
+ phase, tool_names, tool_count, files_modified,
95
+ input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
96
+ message_preview, event_json
97
+ ) VALUES (
98
+ @issue_id, @session_id, @attempt, @turn_number, @timestamp,
99
+ @phase, @tool_names, @tool_count, @files_modified,
100
+ @input_tokens, @output_tokens, @cache_creation_tokens, @cache_read_tokens,
101
+ @message_preview, @event_json
102
+ )
103
+ `);
104
+ }
105
+ get session() {
106
+ return this.sessionId;
107
+ }
108
+ /**
109
+ * Record a turn using pre-extracted data from the orchestrator's stream parser.
110
+ * Avoids re-iterating content blocks — the caller already did that work.
111
+ */
112
+ recordTurn(turnData, phase, eventJson) {
113
+ this.insertStmt.run({
114
+ issue_id: this.issueId,
115
+ session_id: this.sessionId,
116
+ attempt: this.attempt,
117
+ turn_number: this.turnCounter++,
118
+ timestamp: Date.now(),
119
+ phase: phase ?? null,
120
+ tool_names: turnData.toolNames.length > 0 ? JSON.stringify(turnData.toolNames) : null,
121
+ tool_count: turnData.toolCount,
122
+ files_modified: turnData.filesModified,
123
+ input_tokens: turnData.inputTokens,
124
+ output_tokens: turnData.outputTokens,
125
+ cache_creation_tokens: turnData.cacheCreationTokens,
126
+ cache_read_tokens: turnData.cacheReadTokens,
127
+ message_preview: turnData.textPreview,
128
+ event_json: eventJson
129
+ });
130
+ }
131
+ /** Record a result event (final output) */
132
+ recordResult(event) {
133
+ const resultText = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
134
+ this.insertStmt.run({
135
+ issue_id: this.issueId,
136
+ session_id: this.sessionId,
137
+ attempt: this.attempt,
138
+ turn_number: this.turnCounter++,
139
+ timestamp: Date.now(),
140
+ phase: "result",
141
+ tool_names: null,
142
+ tool_count: 0,
143
+ files_modified: 0,
144
+ input_tokens: 0,
145
+ output_tokens: 0,
146
+ cache_creation_tokens: 0,
147
+ cache_read_tokens: 0,
148
+ message_preview: (resultText || "").slice(0, 500),
149
+ event_json: JSON.stringify(event).slice(0, 5e4)
150
+ });
151
+ }
152
+ /** Close the DB connection only if we own it */
153
+ close() {
154
+ if (!this.ownsDb) return;
155
+ try {
156
+ this.db.close();
157
+ } catch {
158
+ }
159
+ }
160
+ }
161
+ function stringifyEventTruncated(event) {
162
+ return JSON.stringify(event, (_key, value) => {
163
+ if (value && typeof value === "object" && !Array.isArray(value)) {
164
+ const obj = value;
165
+ if (obj.type === "tool_use" && obj.input) {
166
+ const inputStr = JSON.stringify(obj.input);
167
+ if (inputStr.length > 2e3) {
168
+ return {
169
+ ...obj,
170
+ input: { _truncated: true, length: inputStr.length }
171
+ };
172
+ }
173
+ }
174
+ if (obj.type === "tool_result" && obj.content) {
175
+ const contentStr = typeof obj.content === "string" ? obj.content : JSON.stringify(obj.content);
176
+ if (contentStr.length > 2e3) {
177
+ return { ...obj, content: `[truncated: ${contentStr.length} chars]` };
178
+ }
179
+ }
180
+ }
181
+ return value;
182
+ });
183
+ }
184
+ function listSessions(issueId, db) {
185
+ return withDb(db, (d) => {
186
+ const rows = d.prepare(
187
+ `
188
+ SELECT
189
+ issue_id,
190
+ session_id,
191
+ attempt,
192
+ COUNT(*) as total_turns,
193
+ SUM(tool_count) as total_tool_calls,
194
+ SUM(files_modified) as total_files_modified,
195
+ SUM(input_tokens) as total_input_tokens,
196
+ SUM(output_tokens) as total_output_tokens,
197
+ GROUP_CONCAT(DISTINCT phase) as phases,
198
+ MIN(timestamp) as started_at,
199
+ MAX(timestamp) as ended_at
200
+ FROM conductor_traces
201
+ WHERE issue_id = ?
202
+ GROUP BY session_id
203
+ ORDER BY started_at DESC
204
+ `
205
+ ).all(issueId);
206
+ return rows.map((r) => ({
207
+ issue_id: r.issue_id,
208
+ session_id: r.session_id,
209
+ attempt: r.attempt,
210
+ total_turns: r.total_turns,
211
+ total_tool_calls: r.total_tool_calls || 0,
212
+ total_files_modified: r.total_files_modified || 0,
213
+ total_input_tokens: r.total_input_tokens || 0,
214
+ total_output_tokens: r.total_output_tokens || 0,
215
+ phases: (r.phases || "").split(",").filter(Boolean),
216
+ started_at: r.started_at,
217
+ ended_at: r.ended_at,
218
+ duration_ms: r.ended_at - r.started_at
219
+ }));
220
+ });
221
+ }
222
+ function getSessionTurns(sessionId, db) {
223
+ return withDb(
224
+ db,
225
+ (d) => d.prepare(
226
+ `
227
+ SELECT * FROM conductor_traces
228
+ WHERE session_id = ?
229
+ ORDER BY turn_number ASC
230
+ `
231
+ ).all(sessionId)
232
+ );
233
+ }
234
+ function getPhaseBreakdown(sessionId, db) {
235
+ return withDb(
236
+ db,
237
+ (d) => d.prepare(
238
+ `
239
+ SELECT
240
+ phase,
241
+ COUNT(*) as turns,
242
+ SUM(tool_count) as tool_calls,
243
+ SUM(input_tokens) as input_tokens,
244
+ SUM(output_tokens) as output_tokens
245
+ FROM conductor_traces
246
+ WHERE session_id = ? AND phase IS NOT NULL
247
+ GROUP BY phase
248
+ ORDER BY MIN(turn_number) ASC
249
+ `
250
+ ).all(sessionId)
251
+ );
252
+ }
253
+ function getToolFrequencies(issueId, db) {
254
+ return withDb(
255
+ db,
256
+ (d) => d.prepare(
257
+ `
258
+ SELECT j.value as tool_name, COUNT(*) as count
259
+ FROM conductor_traces, json_each(tool_names) j
260
+ WHERE issue_id = ? AND tool_names IS NOT NULL
261
+ GROUP BY j.value
262
+ ORDER BY count DESC
263
+ `
264
+ ).all(issueId)
265
+ );
266
+ }
267
+ function getFailureTurns(issueId, tailCount = 5, db) {
268
+ return withDb(
269
+ db,
270
+ (d) => d.prepare(
271
+ `
272
+ SELECT t.* FROM conductor_traces t
273
+ INNER JOIN (
274
+ SELECT session_id, MAX(turn_number) as max_turn
275
+ FROM conductor_traces
276
+ WHERE issue_id = ?
277
+ GROUP BY session_id
278
+ ) latest ON t.session_id = latest.session_id
279
+ AND t.turn_number > latest.max_turn - ?
280
+ WHERE t.issue_id = ?
281
+ ORDER BY t.session_id, t.turn_number ASC
282
+ `
283
+ ).all(issueId, tailCount, issueId)
284
+ );
285
+ }
286
+ function getTraceStats(db) {
287
+ return withDb(
288
+ db,
289
+ (d) => d.prepare(
290
+ `
291
+ SELECT
292
+ COUNT(DISTINCT session_id) as total_sessions,
293
+ COUNT(*) as total_turns,
294
+ SUM(input_tokens) as total_input_tokens,
295
+ SUM(output_tokens) as total_output_tokens,
296
+ COUNT(DISTINCT issue_id) as issues_traced
297
+ FROM conductor_traces
298
+ `
299
+ ).get()
300
+ );
301
+ }
302
+ export {
303
+ TraceCollector,
304
+ classifyErrorText,
305
+ getFailureTurns,
306
+ getPhaseBreakdown,
307
+ getSessionTurns,
308
+ getToolFrequencies,
309
+ getTraceStats,
310
+ getTracesDbPath,
311
+ listSessions,
312
+ openTracesDb,
313
+ stringifyEventTruncated
314
+ };
@@ -22,6 +22,16 @@ import {
22
22
  getAgentStatusDir,
23
23
  getOutcomesLogPath
24
24
  } from "./orchestrator.js";
25
+ import {
26
+ openTracesDb,
27
+ listSessions,
28
+ getSessionTurns,
29
+ getPhaseBreakdown,
30
+ getToolFrequencies,
31
+ getFailureTurns,
32
+ getTraceStats,
33
+ classifyErrorText
34
+ } from "./conductor-traces.js";
25
35
  function getGlobalStorePath() {
26
36
  const dir = join(homedir(), ".stackmemory", "conductor");
27
37
  if (!existsSync(dir)) {
@@ -70,6 +80,17 @@ function fmtTokens(n) {
70
80
  if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
71
81
  return String(n);
72
82
  }
83
+ function formatTokens(n) {
84
+ return fmtTokens(n) + " tok";
85
+ }
86
+ function formatDuration(ms) {
87
+ const seconds = Math.floor(ms / 1e3);
88
+ if (seconds < 60) return `${seconds}s`;
89
+ const minutes = Math.floor(seconds / 60);
90
+ if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
91
+ const hours = Math.floor(minutes / 60);
92
+ return `${hours}h ${minutes % 60}m`;
93
+ }
73
94
  function budgetBar(pct, width = 30) {
74
95
  const filled = Math.min(Math.round(pct / 100 * width), width);
75
96
  const empty = width - filled;
@@ -354,6 +375,44 @@ function predictDifficulty(labels, description, priority, outcomes) {
354
375
  }
355
376
  return { difficulty, confidence, reasons };
356
377
  }
378
+ function analyzeErrorsFromTraces(failedIssues) {
379
+ const patterns = {};
380
+ const evidence = [];
381
+ let db;
382
+ try {
383
+ db = openTracesDb();
384
+ } catch {
385
+ return { patterns, evidence };
386
+ }
387
+ try {
388
+ for (const issueId of failedIssues) {
389
+ const turns = getFailureTurns(issueId, 3, db);
390
+ if (turns.length === 0) continue;
391
+ for (const turn of turns) {
392
+ const preview = turn.message_preview || "";
393
+ const tools = turn.tool_names ? JSON.parse(turn.tool_names) : [];
394
+ const pattern = classifyErrorText(preview);
395
+ if (pattern) {
396
+ patterns[pattern] = (patterns[pattern] || 0) + 1;
397
+ }
398
+ if (evidence.length < 15) {
399
+ evidence.push({
400
+ issue: issueId,
401
+ phase: turn.phase || "unknown",
402
+ tools,
403
+ preview: preview.slice(0, 300)
404
+ });
405
+ }
406
+ }
407
+ }
408
+ } finally {
409
+ db.close();
410
+ }
411
+ if (Object.keys(patterns).length === 0 && failedIssues.length > 0) {
412
+ patterns["unknown"] = failedIssues.length;
413
+ }
414
+ return { patterns, evidence };
415
+ }
357
416
  function loadOutcomes() {
358
417
  const logPath = getOutcomesLogPath();
359
418
  if (!existsSync(logPath)) return [];
@@ -465,7 +524,8 @@ async function evolvePromptTemplate(input) {
465
524
  errorPatterns,
466
525
  recs,
467
526
  outcomes,
468
- dryRun
527
+ dryRun,
528
+ traceEvidence
469
529
  } = input;
470
530
  let currentTemplate;
471
531
  if (existsSync(templatePath)) {
@@ -478,11 +538,24 @@ async function evolvePromptTemplate(input) {
478
538
  }
479
539
  const failPhaseSummary = Object.entries(failPhases).sort((a, b) => b[1] - a[1]).map(([phase, count]) => ` - ${phase}: ${count} failures`).join("\n");
480
540
  const errorSummary = Object.entries(errorPatterns).sort((a, b) => b[1] - a[1]).map(([pattern, count]) => ` - ${pattern}: ${count} occurrences`).join("\n");
481
- const failedOutcomes = outcomes.filter((o) => o.outcome === "failure" && o.errorTail).slice(-5);
482
- const errorTails = failedOutcomes.map(
483
- (o) => ` [${o.issue} attempt ${o.attempt}, phase: ${o.phase}]
541
+ let failureEvidenceSection;
542
+ if (traceEvidence && traceEvidence.length > 0) {
543
+ const evidenceLines = traceEvidence.slice(0, 10).map((ev) => {
544
+ const tools = ev.tools.length > 0 ? ev.tools.join(", ") : "none";
545
+ return ` [${ev.issue}, phase: ${ev.phase}, tools: ${tools}]
546
+ ${ev.preview.slice(0, 200)}`;
547
+ });
548
+ failureEvidenceSection = `ACTUAL FAILURE EVIDENCE (from conversation traces):
549
+ ${evidenceLines.join("\n\n")}`;
550
+ } else {
551
+ const failedOutcomes = outcomes.filter((o) => o.outcome === "failure" && o.errorTail).slice(-5);
552
+ const errorTails = failedOutcomes.map(
553
+ (o) => ` [${o.issue} attempt ${o.attempt}, phase: ${o.phase}]
484
554
  ${o.errorTail}`
485
- ).join("\n\n");
555
+ ).join("\n\n");
556
+ failureEvidenceSection = `SAMPLE ERROR TAILS FROM RECENT FAILURES:
557
+ ${errorTails || " (none)"}`;
558
+ }
486
559
  const mutationPrompt = `You are optimizing a prompt template for autonomous AI coding agents managed by a conductor system.
487
560
 
488
561
  CURRENT PROMPT TEMPLATE:
@@ -500,8 +573,7 @@ ${failPhaseSummary || " (none)"}
500
573
  ERROR PATTERNS:
501
574
  ${errorSummary || " (none)"}
502
575
 
503
- SAMPLE ERROR TAILS FROM RECENT FAILURES:
504
- ${errorTails || " (none)"}
576
+ ${failureEvidenceSection}
505
577
 
506
578
  RECOMMENDATIONS FROM ANALYSIS:
507
579
  ${recs.map((r) => `- ${r}`).join("\n")}
@@ -991,6 +1063,9 @@ function createConductorCommands() {
991
1063
  "--dry-run",
992
1064
  "Show evolved template without writing (use with --evolve)",
993
1065
  false
1066
+ ).option(
1067
+ "--no-evidence",
1068
+ "Disable trace-based evidence display (on by default when traces.db exists)"
994
1069
  ).option(
995
1070
  "--predict",
996
1071
  "Show difficulty predictions alongside actual outcomes",
@@ -1039,22 +1114,23 @@ function createConductorCommands() {
1039
1114
  const retrySuccessRate = retries.length > 0 ? Math.round(
1040
1115
  retries.filter((o) => o.outcome === "success").length / retries.length * 100
1041
1116
  ) : 0;
1042
- const errorPatterns = {};
1043
- for (const o of outcomes.filter(
1044
- (o2) => o2.outcome === "failure" && o2.errorTail
1045
- )) {
1046
- const tail = o.errorTail;
1047
- if (tail.includes("lint"))
1048
- errorPatterns["lint_failure"] = (errorPatterns["lint_failure"] || 0) + 1;
1049
- else if (tail.includes("test"))
1050
- errorPatterns["test_failure"] = (errorPatterns["test_failure"] || 0) + 1;
1051
- else if (tail.includes("timeout") || tail.includes("timed out"))
1052
- errorPatterns["timeout"] = (errorPatterns["timeout"] || 0) + 1;
1053
- else if (tail.includes("conflict"))
1054
- errorPatterns["git_conflict"] = (errorPatterns["git_conflict"] || 0) + 1;
1055
- else if (tail.includes("429") || tail.includes("rate"))
1056
- errorPatterns["rate_limit"] = (errorPatterns["rate_limit"] || 0) + 1;
1057
- else errorPatterns["unknown"] = (errorPatterns["unknown"] || 0) + 1;
1117
+ const failedIssueIds = [
1118
+ ...new Set(
1119
+ outcomes.filter((o) => o.outcome === "failure").map((o) => o.issue)
1120
+ )
1121
+ ];
1122
+ const traceAnalysis = analyzeErrorsFromTraces(failedIssueIds);
1123
+ let errorPatterns = traceAnalysis.patterns;
1124
+ let traceEvidence = traceAnalysis.evidence;
1125
+ if (Object.keys(errorPatterns).length === 0 || Object.keys(errorPatterns).length === 1 && errorPatterns["unknown"]) {
1126
+ errorPatterns = {};
1127
+ traceEvidence = [];
1128
+ for (const o of outcomes.filter(
1129
+ (o2) => o2.outcome === "failure" && o2.errorTail
1130
+ )) {
1131
+ const pattern = classifyErrorText(o.errorTail) ?? "unknown";
1132
+ errorPatterns[pattern] = (errorPatterns[pattern] || 0) + 1;
1133
+ }
1058
1134
  }
1059
1135
  if (options.export) {
1060
1136
  const analysis = {
@@ -1101,16 +1177,42 @@ function createConductorCommands() {
1101
1177
  }
1102
1178
  }
1103
1179
  if (Object.keys(errorPatterns).length > 0) {
1104
- console.log(`
1105
- ${c.b}Error Patterns${c.r}`);
1180
+ const sourceLabel = traceEvidence.length > 0 ? "(from traces)" : "(from errorTail)";
1181
+ console.log(
1182
+ `
1183
+ ${c.b}Error Patterns${c.r} ${c.d}${sourceLabel}${c.r}`
1184
+ );
1106
1185
  const sorted = Object.entries(errorPatterns).sort(
1107
1186
  (a, b) => b[1] - a[1]
1108
1187
  );
1109
1188
  for (const [pattern, count] of sorted) {
1110
1189
  console.log(
1111
- ` ${c.red}\u25CF${c.r} ${pattern.padEnd(16)} ${c.white}${count}${c.r}`
1190
+ ` ${c.red}\u25CF${c.r} ${pattern.padEnd(20)} ${c.white}${count}${c.r}`
1191
+ );
1192
+ }
1193
+ }
1194
+ if (options.evidence && traceEvidence.length > 0) {
1195
+ console.log(
1196
+ `
1197
+ ${c.b}Failure Evidence${c.r} ${c.d}(from traces)${c.r}`
1198
+ );
1199
+ for (const ev of traceEvidence.slice(0, 15)) {
1200
+ const tools = ev.tools.length > 0 ? ev.tools.join(", ") : "-";
1201
+ console.log(
1202
+ ` ${c.cyan}${ev.issue}${c.r} [${ev.phase}] tools: ${tools}`
1112
1203
  );
1204
+ if (ev.preview) {
1205
+ const lines = ev.preview.split("\n").slice(0, 3);
1206
+ for (const line of lines) {
1207
+ console.log(` ${c.d}${line.slice(0, 100)}${c.r}`);
1208
+ }
1209
+ }
1113
1210
  }
1211
+ } else if (options.evidence && traceEvidence.length === 0) {
1212
+ console.log(
1213
+ `
1214
+ ${c.d}No trace data available. Run conductor with trace logging enabled to collect evidence.${c.r}`
1215
+ );
1114
1216
  }
1115
1217
  console.log(`
1116
1218
  ${c.b}Recommendations${c.r}`);
@@ -1186,7 +1288,8 @@ function createConductorCommands() {
1186
1288
  errorPatterns,
1187
1289
  recs,
1188
1290
  outcomes,
1189
- dryRun: options.dryRun
1291
+ dryRun: options.dryRun,
1292
+ traceEvidence
1190
1293
  });
1191
1294
  }
1192
1295
  if (options.predict) {
@@ -1704,6 +1807,204 @@ function createConductorCommands() {
1704
1807
  process.on("SIGTERM", cleanup);
1705
1808
  });
1706
1809
  });
1810
+ cmd.command("traces <issue-id>").description("Show conversation traces for an agent run").option("--session <id>", "Show specific session").option("--json", "Output as JSON", false).option("--tools", "Show tool frequency breakdown", false).option("--failures", "Show failure-turn details", false).option("-n, --tail <count>", "Failure tail turns", "5").action(
1811
+ (issueId, options) => {
1812
+ const db = openTracesDb();
1813
+ try {
1814
+ if (options.tools) {
1815
+ const freq = getToolFrequencies(issueId, db);
1816
+ if (freq.length === 0) {
1817
+ console.log(`No traces found for ${issueId}`);
1818
+ return;
1819
+ }
1820
+ console.log(
1821
+ `
1822
+ ${c.b}Tool Frequency${c.r} \u2014 ${c.cyan}${issueId}${c.r}
1823
+ `
1824
+ );
1825
+ for (const { tool_name, count } of freq.slice(0, 20)) {
1826
+ const bar = "\u2501".repeat(Math.min(count, 40));
1827
+ console.log(
1828
+ ` ${c.d}${tool_name.padEnd(20)}${c.r} ${bar} ${count}`
1829
+ );
1830
+ }
1831
+ console.log("");
1832
+ return;
1833
+ }
1834
+ if (options.failures) {
1835
+ const turns = getFailureTurns(
1836
+ issueId,
1837
+ parseInt(options.tail, 10),
1838
+ db
1839
+ );
1840
+ if (turns.length === 0) {
1841
+ console.log(`No traces found for ${issueId}`);
1842
+ return;
1843
+ }
1844
+ console.log(
1845
+ `
1846
+ ${c.b}Failure Turns${c.r} \u2014 ${c.cyan}${issueId}${c.r}
1847
+ `
1848
+ );
1849
+ for (const t of turns) {
1850
+ const tools = t.tool_names ? JSON.parse(t.tool_names).join(", ") : "-";
1851
+ const ts = new Date(t.timestamp).toISOString().slice(11, 19);
1852
+ console.log(
1853
+ ` ${c.d}[${ts}]${c.r} turn ${t.turn_number} | phase: ${t.phase || "-"} | tools: ${tools}`
1854
+ );
1855
+ if (t.message_preview) {
1856
+ const preview = t.message_preview.slice(0, 200);
1857
+ console.log(` ${c.d}${preview}${c.r}`);
1858
+ }
1859
+ }
1860
+ console.log("");
1861
+ return;
1862
+ }
1863
+ if (options.session) {
1864
+ const turns = getSessionTurns(options.session, db);
1865
+ if (turns.length === 0) {
1866
+ console.log(`No turns found for session ${options.session}`);
1867
+ return;
1868
+ }
1869
+ if (options.json) {
1870
+ console.log(JSON.stringify(turns, null, 2));
1871
+ return;
1872
+ }
1873
+ const phases = getPhaseBreakdown(options.session, db);
1874
+ console.log(
1875
+ `
1876
+ ${c.b}Session${c.r} ${c.cyan}${options.session}${c.r}`
1877
+ );
1878
+ console.log(` ${turns.length} turns
1879
+ `);
1880
+ if (phases.length > 0) {
1881
+ console.log(` ${c.b}Phase Breakdown${c.r}`);
1882
+ for (const p of phases) {
1883
+ console.log(
1884
+ ` ${(p.phase || "unknown").padEnd(14)} ${String(p.turns).padStart(3)} turns ${String(p.tool_calls).padStart(4)} tools ${formatTokens(p.input_tokens + p.output_tokens)}`
1885
+ );
1886
+ }
1887
+ console.log("");
1888
+ }
1889
+ console.log(` ${c.b}Turn Log${c.r}`);
1890
+ for (const t of turns) {
1891
+ const tools = t.tool_names ? JSON.parse(t.tool_names).join(", ") : "-";
1892
+ const ts = new Date(t.timestamp).toISOString().slice(11, 19);
1893
+ const tokens = t.input_tokens + t.output_tokens;
1894
+ console.log(
1895
+ ` ${c.d}${String(t.turn_number).padStart(3)}${c.r} [${ts}] ${(t.phase || "-").padEnd(12)} tools: ${tools.padEnd(30)} ${formatTokens(tokens)}`
1896
+ );
1897
+ }
1898
+ console.log("");
1899
+ return;
1900
+ }
1901
+ const sessions = listSessions(issueId, db);
1902
+ if (sessions.length === 0) {
1903
+ console.log(`No traces found for ${issueId}`);
1904
+ return;
1905
+ }
1906
+ if (options.json) {
1907
+ console.log(JSON.stringify(sessions, null, 2));
1908
+ return;
1909
+ }
1910
+ console.log(
1911
+ `
1912
+ ${c.b}Trace Sessions${c.r} \u2014 ${c.cyan}${issueId}${c.r}
1913
+ `
1914
+ );
1915
+ for (const s of sessions) {
1916
+ const dur = s.duration_ms > 0 ? formatDuration(s.duration_ms) : "-";
1917
+ const tokens = formatTokens(
1918
+ s.total_input_tokens + s.total_output_tokens
1919
+ );
1920
+ const time = new Date(s.started_at).toISOString().slice(0, 19);
1921
+ console.log(
1922
+ ` ${c.d}attempt ${s.attempt}${c.r} ${s.total_turns} turns ${s.total_tool_calls} tools ${tokens} ${dur} ${c.d}${time}${c.r}`
1923
+ );
1924
+ console.log(` ${c.d}session: ${s.session_id}${c.r}`);
1925
+ console.log(` phases: ${s.phases.join(" \u2192 ")}`);
1926
+ console.log("");
1927
+ }
1928
+ } finally {
1929
+ db.close();
1930
+ }
1931
+ }
1932
+ );
1933
+ cmd.command("replay <session-id>").description("Replay a full agent conversation from traces").option("-n, --turns <count>", "Show only last N turns").option("--json", "Output raw event JSON", false).action((sessionId, options) => {
1934
+ const db = openTracesDb();
1935
+ try {
1936
+ let turns = getSessionTurns(sessionId, db);
1937
+ if (turns.length === 0) {
1938
+ console.error(`No traces found for session ${sessionId}`);
1939
+ return;
1940
+ }
1941
+ if (options.turns) {
1942
+ const n = parseInt(options.turns, 10);
1943
+ turns = turns.slice(-n);
1944
+ }
1945
+ if (options.json) {
1946
+ for (const t of turns) {
1947
+ console.log(t.event_json);
1948
+ }
1949
+ return;
1950
+ }
1951
+ console.log(
1952
+ `
1953
+ ${c.b}Replay${c.r} ${c.cyan}${sessionId}${c.r} \u2014 ${turns.length} turns
1954
+ `
1955
+ );
1956
+ for (const t of turns) {
1957
+ const ts = new Date(t.timestamp).toISOString().slice(11, 19);
1958
+ const tokens = t.input_tokens + t.output_tokens;
1959
+ console.log(
1960
+ `${c.purple}\u2500\u2500 Turn ${t.turn_number} \u2500\u2500${c.r} [${ts}] ${t.phase || ""} ${tokens > 0 ? formatTokens(tokens) : ""}`
1961
+ );
1962
+ if (t.tool_names) {
1963
+ const tools = JSON.parse(t.tool_names);
1964
+ for (const tool of tools) {
1965
+ console.log(` ${c.cyan}\u25B8 ${tool}${c.r}`);
1966
+ }
1967
+ }
1968
+ if (t.message_preview) {
1969
+ const lines = t.message_preview.split("\n");
1970
+ for (const line of lines.slice(0, 10)) {
1971
+ console.log(` ${c.d}${line}${c.r}`);
1972
+ }
1973
+ if (lines.length > 10) {
1974
+ console.log(
1975
+ ` ${c.d}... (${lines.length - 10} more lines)${c.r}`
1976
+ );
1977
+ }
1978
+ }
1979
+ console.log("");
1980
+ }
1981
+ } finally {
1982
+ db.close();
1983
+ }
1984
+ });
1985
+ cmd.command("trace-stats").description("Show aggregate trace statistics").option("--json", "Output as JSON", false).action((options) => {
1986
+ const stats = getTraceStats();
1987
+ if (options.json) {
1988
+ console.log(JSON.stringify(stats, null, 2));
1989
+ return;
1990
+ }
1991
+ console.log(`
1992
+ ${c.b}Conductor Trace Stats${c.r}
1993
+ `);
1994
+ console.log(` Sessions: ${stats.total_sessions}`);
1995
+ console.log(` Turns: ${stats.total_turns}`);
1996
+ console.log(` Issues: ${stats.issues_traced}`);
1997
+ console.log(
1998
+ ` Tokens: ${formatTokens((stats.total_input_tokens || 0) + (stats.total_output_tokens || 0))}`
1999
+ );
2000
+ console.log(
2001
+ ` Input: ${formatTokens(stats.total_input_tokens || 0)}`
2002
+ );
2003
+ console.log(
2004
+ ` Output: ${formatTokens(stats.total_output_tokens || 0)}`
2005
+ );
2006
+ console.log("");
2007
+ });
1707
2008
  return cmd;
1708
2009
  }
1709
2010
  export {
@@ -30,6 +30,10 @@ import {
30
30
  } from "../../core/worktree/preflight.js";
31
31
  import { ContextCapture } from "../../core/worktree/capture.js";
32
32
  import { extractKeywords } from "../../core/utils/text.js";
33
+ import {
34
+ TraceCollector,
35
+ stringifyEventTruncated
36
+ } from "./conductor-traces.js";
33
37
  function getOutcomesLogPath() {
34
38
  return join(homedir(), ".stackmemory", "conductor", "outcomes.jsonl");
35
39
  }
@@ -1272,6 +1276,17 @@ class Conductor {
1272
1276
  run.logStream = logStream;
1273
1277
  const tee = new TeeTransform(logStream);
1274
1278
  proc.stdout.pipe(tee);
1279
+ let traceCollector;
1280
+ try {
1281
+ traceCollector = new TraceCollector({
1282
+ issueId: issue.identifier,
1283
+ attempt: run.attempt
1284
+ });
1285
+ } catch {
1286
+ logger.warn("Failed to initialize trace collector", {
1287
+ identifier: issue.identifier
1288
+ });
1289
+ }
1275
1290
  this.writeAgentStatus(issue.identifier, run);
1276
1291
  let stderr = "";
1277
1292
  let lastResultText = "";
@@ -1303,23 +1318,53 @@ class Conductor {
1303
1318
  this.trackUsage(issue.identifier, msgUsage);
1304
1319
  }
1305
1320
  const content = message?.content || [];
1321
+ const turnToolNames = [];
1322
+ let turnFilesModified = 0;
1323
+ const turnTextParts = [];
1306
1324
  for (const block of content) {
1307
1325
  if (block.type === "tool_use") {
1308
1326
  run.toolCalls++;
1309
- const toolLower = (block.name || "").toLowerCase();
1327
+ const name = block.name || "";
1328
+ turnToolNames.push(name);
1329
+ const toolLower = name.toLowerCase();
1310
1330
  if (toolLower.includes("edit") || toolLower.includes("write")) {
1311
1331
  run.filesModified++;
1332
+ turnFilesModified++;
1312
1333
  }
1313
1334
  }
1314
1335
  if (block.type === "text" && block.text) {
1315
- run.tokensUsed += Math.ceil(
1316
- block.text.length / 4
1336
+ const text = block.text;
1337
+ run.tokensUsed += Math.ceil(text.length / 4);
1338
+ turnTextParts.push(text);
1339
+ }
1340
+ }
1341
+ try {
1342
+ if (traceCollector) {
1343
+ const turnData = {
1344
+ toolNames: turnToolNames,
1345
+ toolCount: turnToolNames.length,
1346
+ filesModified: turnFilesModified,
1347
+ textPreview: turnTextParts.length > 0 ? turnTextParts.join("\n").slice(0, 500) : null,
1348
+ inputTokens: msgUsage?.input_tokens ?? 0,
1349
+ outputTokens: msgUsage?.output_tokens ?? 0,
1350
+ cacheCreationTokens: msgUsage?.cache_creation_input_tokens ?? 0,
1351
+ cacheReadTokens: msgUsage?.cache_read_input_tokens ?? 0
1352
+ };
1353
+ traceCollector.recordTurn(
1354
+ turnData,
1355
+ phase,
1356
+ stringifyEventTruncated(event)
1317
1357
  );
1318
1358
  }
1359
+ } catch {
1319
1360
  }
1320
1361
  }
1321
1362
  if (event.type === "result" && event.result) {
1322
1363
  lastResultText = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
1364
+ try {
1365
+ traceCollector?.recordResult(event);
1366
+ } catch {
1367
+ }
1323
1368
  }
1324
1369
  if (run.toolCalls % 5 === 0 || phase) {
1325
1370
  this.writeAgentStatus(issue.identifier, run);
@@ -1337,10 +1382,12 @@ class Conductor {
1337
1382
  });
1338
1383
  proc.on("error", (err) => {
1339
1384
  clearTimeout(timer);
1385
+ traceCollector?.close();
1340
1386
  reject(new Error(`Failed to spawn claude: ${err.message}`));
1341
1387
  });
1342
1388
  proc.on("close", (code) => {
1343
1389
  clearTimeout(timer);
1390
+ traceCollector?.close();
1344
1391
  run.process = null;
1345
1392
  if (run.logStream && !run.logStream.destroyed) {
1346
1393
  run.logStream.end();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "description": "Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, and automatic hooks.",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",