@stackmemoryai/stackmemory 1.6.1 → 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 [];
@@ -394,6 +453,68 @@ function spawnClaudePrint(prompt, timeoutMs = 12e4) {
394
453
  child.stdin.end();
395
454
  });
396
455
  }
456
+ function printSimpleDiff(oldText, newText) {
457
+ const oldLines = oldText.split("\n");
458
+ const newLines = newText.split("\n");
459
+ const oldSet = new Set(oldLines);
460
+ const newSet = new Set(newLines);
461
+ const removed = oldLines.filter((l) => !newSet.has(l));
462
+ const added = newLines.filter((l) => !oldSet.has(l));
463
+ const removedSet = new Set(removed);
464
+ const addedSet = new Set(added);
465
+ const CONTEXT = 2;
466
+ const changedOld = /* @__PURE__ */ new Set();
467
+ for (let i = 0; i < oldLines.length; i++) {
468
+ if (removedSet.has(oldLines[i])) changedOld.add(i);
469
+ }
470
+ const changedNew = /* @__PURE__ */ new Set();
471
+ for (let i = 0; i < newLines.length; i++) {
472
+ if (addedSet.has(newLines[i])) changedNew.add(i);
473
+ }
474
+ let lastPrinted = -1;
475
+ for (let i = 0; i < oldLines.length; i++) {
476
+ let nearChange = false;
477
+ for (let j = Math.max(0, i - CONTEXT); j <= Math.min(oldLines.length - 1, i + CONTEXT); j++) {
478
+ if (changedOld.has(j)) {
479
+ nearChange = true;
480
+ break;
481
+ }
482
+ }
483
+ if (!nearChange) continue;
484
+ if (lastPrinted >= 0 && i > lastPrinted + 1) {
485
+ console.log(` ${c.d}...${c.r}`);
486
+ }
487
+ if (removedSet.has(oldLines[i])) {
488
+ console.log(` ${c.red}- ${oldLines[i]}${c.r}`);
489
+ } else {
490
+ console.log(` ${oldLines[i]}`);
491
+ }
492
+ lastPrinted = i;
493
+ }
494
+ if (removed.length > 0 && added.length > 0) {
495
+ console.log(` ${c.d}---${c.r}`);
496
+ }
497
+ lastPrinted = -1;
498
+ for (let i = 0; i < newLines.length; i++) {
499
+ let nearChange = false;
500
+ for (let j = Math.max(0, i - CONTEXT); j <= Math.min(newLines.length - 1, i + CONTEXT); j++) {
501
+ if (changedNew.has(j)) {
502
+ nearChange = true;
503
+ break;
504
+ }
505
+ }
506
+ if (!nearChange) continue;
507
+ if (lastPrinted >= 0 && i > lastPrinted + 1) {
508
+ console.log(` ${c.d}...${c.r}`);
509
+ }
510
+ if (addedSet.has(newLines[i])) {
511
+ console.log(` ${c.green}+ ${newLines[i]}${c.r}`);
512
+ } else {
513
+ console.log(` ${newLines[i]}`);
514
+ }
515
+ lastPrinted = i;
516
+ }
517
+ }
397
518
  async function evolvePromptTemplate(input) {
398
519
  const {
399
520
  templatePath,
@@ -402,7 +523,9 @@ async function evolvePromptTemplate(input) {
402
523
  failPhases,
403
524
  errorPatterns,
404
525
  recs,
405
- outcomes
526
+ outcomes,
527
+ dryRun,
528
+ traceEvidence
406
529
  } = input;
407
530
  let currentTemplate;
408
531
  if (existsSync(templatePath)) {
@@ -415,11 +538,24 @@ async function evolvePromptTemplate(input) {
415
538
  }
416
539
  const failPhaseSummary = Object.entries(failPhases).sort((a, b) => b[1] - a[1]).map(([phase, count]) => ` - ${phase}: ${count} failures`).join("\n");
417
540
  const errorSummary = Object.entries(errorPatterns).sort((a, b) => b[1] - a[1]).map(([pattern, count]) => ` - ${pattern}: ${count} occurrences`).join("\n");
418
- const failedOutcomes = outcomes.filter((o) => o.outcome === "failure" && o.errorTail).slice(-5);
419
- const errorTails = failedOutcomes.map(
420
- (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}]
421
554
  ${o.errorTail}`
422
- ).join("\n\n");
555
+ ).join("\n\n");
556
+ failureEvidenceSection = `SAMPLE ERROR TAILS FROM RECENT FAILURES:
557
+ ${errorTails || " (none)"}`;
558
+ }
423
559
  const mutationPrompt = `You are optimizing a prompt template for autonomous AI coding agents managed by a conductor system.
424
560
 
425
561
  CURRENT PROMPT TEMPLATE:
@@ -437,8 +573,7 @@ ${failPhaseSummary || " (none)"}
437
573
  ERROR PATTERNS:
438
574
  ${errorSummary || " (none)"}
439
575
 
440
- SAMPLE ERROR TAILS FROM RECENT FAILURES:
441
- ${errorTails || " (none)"}
576
+ ${failureEvidenceSection}
442
577
 
443
578
  RECOMMENDATIONS FROM ANALYSIS:
444
579
  ${recs.map((r) => `- ${r}`).join("\n")}
@@ -475,6 +610,17 @@ OUTPUT THE IMPROVED TEMPLATE:`;
475
610
  );
476
611
  return;
477
612
  }
613
+ if (dryRun) {
614
+ console.log(`
615
+ ${c.b}${c.cyan}Dry-run diff:${c.r}
616
+ `);
617
+ printSimpleDiff(currentTemplate, evolved.trim());
618
+ console.log(
619
+ `
620
+ ${c.d}Dry run \u2014 no files modified. Run without --dry-run to apply.${c.r}`
621
+ );
622
+ return;
623
+ }
478
624
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
479
625
  const backupPath = templatePath.replace(".md", `.backup-${timestamp}.md`);
480
626
  copyFileSync(templatePath, backupPath);
@@ -913,6 +1059,13 @@ function createConductorCommands() {
913
1059
  "--evolve",
914
1060
  "Auto-mutate prompt template using GEPA-style evolution from failure data",
915
1061
  false
1062
+ ).option(
1063
+ "--dry-run",
1064
+ "Show evolved template without writing (use with --evolve)",
1065
+ false
1066
+ ).option(
1067
+ "--no-evidence",
1068
+ "Disable trace-based evidence display (on by default when traces.db exists)"
916
1069
  ).option(
917
1070
  "--predict",
918
1071
  "Show difficulty predictions alongside actual outcomes",
@@ -961,22 +1114,23 @@ function createConductorCommands() {
961
1114
  const retrySuccessRate = retries.length > 0 ? Math.round(
962
1115
  retries.filter((o) => o.outcome === "success").length / retries.length * 100
963
1116
  ) : 0;
964
- const errorPatterns = {};
965
- for (const o of outcomes.filter(
966
- (o2) => o2.outcome === "failure" && o2.errorTail
967
- )) {
968
- const tail = o.errorTail;
969
- if (tail.includes("lint"))
970
- errorPatterns["lint_failure"] = (errorPatterns["lint_failure"] || 0) + 1;
971
- else if (tail.includes("test"))
972
- errorPatterns["test_failure"] = (errorPatterns["test_failure"] || 0) + 1;
973
- else if (tail.includes("timeout") || tail.includes("timed out"))
974
- errorPatterns["timeout"] = (errorPatterns["timeout"] || 0) + 1;
975
- else if (tail.includes("conflict"))
976
- errorPatterns["git_conflict"] = (errorPatterns["git_conflict"] || 0) + 1;
977
- else if (tail.includes("429") || tail.includes("rate"))
978
- errorPatterns["rate_limit"] = (errorPatterns["rate_limit"] || 0) + 1;
979
- 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
+ }
980
1134
  }
981
1135
  if (options.export) {
982
1136
  const analysis = {
@@ -1023,17 +1177,43 @@ function createConductorCommands() {
1023
1177
  }
1024
1178
  }
1025
1179
  if (Object.keys(errorPatterns).length > 0) {
1026
- console.log(`
1027
- ${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
+ );
1028
1185
  const sorted = Object.entries(errorPatterns).sort(
1029
1186
  (a, b) => b[1] - a[1]
1030
1187
  );
1031
1188
  for (const [pattern, count] of sorted) {
1032
1189
  console.log(
1033
- ` ${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}`
1034
1191
  );
1035
1192
  }
1036
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}`
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
+ }
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
+ );
1216
+ }
1037
1217
  console.log(`
1038
1218
  ${c.b}Recommendations${c.r}`);
1039
1219
  const recs = [];
@@ -1107,7 +1287,9 @@ function createConductorCommands() {
1107
1287
  failPhases,
1108
1288
  errorPatterns,
1109
1289
  recs,
1110
- outcomes
1290
+ outcomes,
1291
+ dryRun: options.dryRun,
1292
+ traceEvidence
1111
1293
  });
1112
1294
  }
1113
1295
  if (options.predict) {
@@ -1329,6 +1511,9 @@ function createConductorCommands() {
1329
1511
  "--model <model>",
1330
1512
  'Model routing: "auto" (complexity-based) or a specific model ID',
1331
1513
  "auto"
1514
+ ).option(
1515
+ "--no-pr",
1516
+ "Disable automatic GitHub PR creation after agent success"
1332
1517
  ).action(async (options) => {
1333
1518
  ensureDefaultPromptTemplate();
1334
1519
  const conductor = new Conductor({
@@ -1344,7 +1529,8 @@ function createConductorCommands() {
1344
1529
  maxRetries: parseInt(options.retries, 10),
1345
1530
  turnTimeoutMs: parseInt(options.turnTimeout, 10),
1346
1531
  agentMode: options.mode === "adapter" ? "adapter" : "cli",
1347
- model: options.model
1532
+ model: options.model,
1533
+ autoPR: options.pr
1348
1534
  });
1349
1535
  await conductor.start();
1350
1536
  });
@@ -1621,6 +1807,204 @@ function createConductorCommands() {
1621
1807
  process.on("SIGTERM", cleanup);
1622
1808
  });
1623
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
+ });
1624
2008
  return cmd;
1625
2009
  }
1626
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
  }
@@ -38,6 +42,44 @@ function logAgentOutcome(entry) {
38
42
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
39
43
  appendFileSync(getOutcomesLogPath(), JSON.stringify(entry) + "\n");
40
44
  }
45
+ function createPullRequest(opts) {
46
+ try {
47
+ execSync(`git push -u origin "${opts.branch}"`, {
48
+ cwd: opts.workspacePath,
49
+ stdio: "pipe",
50
+ timeout: 6e4
51
+ });
52
+ const prTitle = `feat(conductor): ${opts.issueId} \u2014 ${opts.title}`;
53
+ const prBody = [
54
+ "## Summary",
55
+ "",
56
+ `Automated PR from conductor agent for **${opts.issueId}**.`,
57
+ "",
58
+ `- **Files modified:** ${opts.filesModified}`,
59
+ `- **Tool calls:** ${opts.toolCalls}`,
60
+ "",
61
+ "_This PR was auto-created by StackMemory Conductor._"
62
+ ].join("\n");
63
+ const result = execSync(
64
+ `gh pr create --base "${opts.baseBranch}" --head "${opts.branch}" --title "${prTitle.replace(/"/g, '\\"')}" --body "${prBody.replace(/"/g, '\\"')}"`,
65
+ {
66
+ cwd: opts.workspacePath,
67
+ encoding: "utf-8",
68
+ stdio: ["pipe", "pipe", "pipe"],
69
+ timeout: 3e4
70
+ }
71
+ );
72
+ const prUrl = result.trim();
73
+ logger.info("Created PR", { issueId: opts.issueId, prUrl });
74
+ return prUrl;
75
+ } catch (err) {
76
+ logger.warn("Failed to create PR (best-effort)", {
77
+ issueId: opts.issueId,
78
+ error: err.message
79
+ });
80
+ return null;
81
+ }
82
+ }
41
83
  function getRetryStrategy(issue, outcomes) {
42
84
  if (!outcomes) {
43
85
  const logPath = getOutcomesLogPath();
@@ -771,6 +813,24 @@ class Conductor {
771
813
  await this.runAgent(issue, run);
772
814
  run.status = "completed";
773
815
  this.completeCount++;
816
+ let prUrl;
817
+ if (this.config.autoPR !== false) {
818
+ const wsKey = this.sanitizeIdentifier(issue.identifier);
819
+ const branchName = `conductor/${wsKey}`;
820
+ const url = createPullRequest({
821
+ branch: branchName,
822
+ baseBranch: this.config.baseBranch,
823
+ issueId: issue.identifier,
824
+ title: issue.title,
825
+ filesModified: run.filesModified,
826
+ toolCalls: run.toolCalls,
827
+ workspacePath: run.workspacePath
828
+ });
829
+ if (url) {
830
+ prUrl = url;
831
+ console.log(`[${issue.identifier}] PR created: ${url}`);
832
+ }
833
+ }
774
834
  logAgentOutcome({
775
835
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
776
836
  issue: issue.identifier,
@@ -782,7 +842,8 @@ class Conductor {
782
842
  tokensUsed: run.tokensUsed,
783
843
  durationMs: Date.now() - run.startedAt,
784
844
  hasCommits: true,
785
- labels: issue.labels.map((l) => l.name)
845
+ labels: issue.labels.map((l) => l.name),
846
+ prUrl
786
847
  });
787
848
  await this.runHook(
788
849
  "after-run",
@@ -1215,6 +1276,17 @@ class Conductor {
1215
1276
  run.logStream = logStream;
1216
1277
  const tee = new TeeTransform(logStream);
1217
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
+ }
1218
1290
  this.writeAgentStatus(issue.identifier, run);
1219
1291
  let stderr = "";
1220
1292
  let lastResultText = "";
@@ -1246,23 +1318,53 @@ class Conductor {
1246
1318
  this.trackUsage(issue.identifier, msgUsage);
1247
1319
  }
1248
1320
  const content = message?.content || [];
1321
+ const turnToolNames = [];
1322
+ let turnFilesModified = 0;
1323
+ const turnTextParts = [];
1249
1324
  for (const block of content) {
1250
1325
  if (block.type === "tool_use") {
1251
1326
  run.toolCalls++;
1252
- const toolLower = (block.name || "").toLowerCase();
1327
+ const name = block.name || "";
1328
+ turnToolNames.push(name);
1329
+ const toolLower = name.toLowerCase();
1253
1330
  if (toolLower.includes("edit") || toolLower.includes("write")) {
1254
1331
  run.filesModified++;
1332
+ turnFilesModified++;
1255
1333
  }
1256
1334
  }
1257
1335
  if (block.type === "text" && block.text) {
1258
- run.tokensUsed += Math.ceil(
1259
- 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)
1260
1357
  );
1261
1358
  }
1359
+ } catch {
1262
1360
  }
1263
1361
  }
1264
1362
  if (event.type === "result" && event.result) {
1265
1363
  lastResultText = typeof event.result === "string" ? event.result : JSON.stringify(event.result);
1364
+ try {
1365
+ traceCollector?.recordResult(event);
1366
+ } catch {
1367
+ }
1266
1368
  }
1267
1369
  if (run.toolCalls % 5 === 0 || phase) {
1268
1370
  this.writeAgentStatus(issue.identifier, run);
@@ -1280,10 +1382,12 @@ class Conductor {
1280
1382
  });
1281
1383
  proc.on("error", (err) => {
1282
1384
  clearTimeout(timer);
1385
+ traceCollector?.close();
1283
1386
  reject(new Error(`Failed to spawn claude: ${err.message}`));
1284
1387
  });
1285
1388
  proc.on("close", (code) => {
1286
1389
  clearTimeout(timer);
1390
+ traceCollector?.close();
1287
1391
  run.process = null;
1288
1392
  if (run.logStream && !run.logStream.destroyed) {
1289
1393
  run.logStream.end();
@@ -1714,6 +1818,24 @@ class Conductor {
1714
1818
  } catch {
1715
1819
  }
1716
1820
  }
1821
+ let prUrl;
1822
+ if (hasCommits && this.config.autoPR !== false) {
1823
+ const wsKey = this.sanitizeIdentifier(run.issue.identifier);
1824
+ const branchName = `conductor/${wsKey}`;
1825
+ const url = createPullRequest({
1826
+ branch: branchName,
1827
+ baseBranch: this.config.baseBranch,
1828
+ issueId: run.issue.identifier,
1829
+ title: run.issue.title,
1830
+ filesModified: run.filesModified,
1831
+ toolCalls: run.toolCalls,
1832
+ workspacePath: wsPath
1833
+ });
1834
+ if (url) {
1835
+ prUrl = url;
1836
+ console.log(`[${run.issue.identifier}] PR created: ${url}`);
1837
+ }
1838
+ }
1717
1839
  const durationMs = Date.now() - run.startedAt;
1718
1840
  logAgentOutcome({
1719
1841
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -1727,7 +1849,8 @@ class Conductor {
1727
1849
  durationMs,
1728
1850
  hasCommits,
1729
1851
  labels: run.issue.labels.map((l) => l.name),
1730
- errorTail
1852
+ errorTail,
1853
+ prUrl
1731
1854
  });
1732
1855
  if (hasCommits) {
1733
1856
  logger.info("Stale agent had commits, transitioning to In Review", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.6.1",
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",