muonroi-cli 1.8.0 → 1.8.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.
Files changed (101) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +122 -122
  3. package/dist/src/agent-harness/mock-model.js +112 -4
  4. package/dist/src/cli/cost-forensics.js +17 -14
  5. package/dist/src/council/prompts.js +20 -20
  6. package/dist/src/flow/compaction/index.d.ts +1 -0
  7. package/dist/src/flow/compaction/index.js +4 -0
  8. package/dist/src/generated/version.d.ts +1 -1
  9. package/dist/src/generated/version.js +1 -1
  10. package/dist/src/index.js +4 -2
  11. package/dist/src/lsp/manager.js +11 -3
  12. package/dist/src/lsp/manager.test.js +39 -0
  13. package/dist/src/mcp/auto-setup.js +0 -8
  14. package/dist/src/mcp/oauth-callback.js +2 -2
  15. package/dist/src/mcp/parse-headers.test.js +14 -14
  16. package/dist/src/mcp/self-verify-jobs.d.ts +1 -0
  17. package/dist/src/mcp/self-verify-runner.js +1 -1
  18. package/dist/src/mcp/setup-guide-text.js +74 -74
  19. package/dist/src/mcp/smoke.test.js +43 -43
  20. package/dist/src/ops/doctor.js +7 -7
  21. package/dist/src/orchestrator/__tests__/batch-turn-runner.test.js +1 -0
  22. package/dist/src/orchestrator/__tests__/message-processor.test.js +1 -0
  23. package/dist/src/orchestrator/__tests__/prompts.test.d.ts +1 -0
  24. package/dist/src/orchestrator/__tests__/prompts.test.js +88 -0
  25. package/dist/src/orchestrator/__tests__/sub-session-delegation.test.d.ts +1 -0
  26. package/dist/src/orchestrator/__tests__/sub-session-delegation.test.js +507 -0
  27. package/dist/src/orchestrator/auto-commit.js +9 -7
  28. package/dist/src/orchestrator/message-processor.d.ts +2 -0
  29. package/dist/src/orchestrator/message-processor.js +7 -0
  30. package/dist/src/orchestrator/orchestrator.d.ts +3 -0
  31. package/dist/src/orchestrator/orchestrator.js +362 -28
  32. package/dist/src/orchestrator/pending-calls.js +2 -1
  33. package/dist/src/orchestrator/preprocessor.js +3 -2
  34. package/dist/src/orchestrator/prompts.d.ts +8 -0
  35. package/dist/src/orchestrator/prompts.js +65 -18
  36. package/dist/src/orchestrator/sub-agent-cap.d.ts +2 -0
  37. package/dist/src/orchestrator/sub-agent-cap.js +12 -4
  38. package/dist/src/orchestrator/subagent-compactor.d.ts +7 -0
  39. package/dist/src/orchestrator/subagent-compactor.js +65 -14
  40. package/dist/src/orchestrator/subagent-compactor.spec.js +125 -9
  41. package/dist/src/orchestrator/tool-engine.d.ts +2 -0
  42. package/dist/src/orchestrator/tool-engine.js +80 -21
  43. package/dist/src/orchestrator/turn-runner-deps.d.ts +2 -0
  44. package/dist/src/pil/__tests__/llm-classify.test.js +79 -1
  45. package/dist/src/pil/layer1-intent.js +37 -37
  46. package/dist/src/pil/layer2_5-ponytail.js +8 -8
  47. package/dist/src/pil/llm-classify.d.ts +11 -0
  48. package/dist/src/pil/llm-classify.js +193 -0
  49. package/dist/src/product-loop/done-gate.js +3 -3
  50. package/dist/src/product-loop/loop-driver.js +18 -18
  51. package/dist/src/product-loop/progress-snapshot.js +4 -4
  52. package/dist/src/providers/mcp-vision-bridge.js +48 -48
  53. package/dist/src/reporter/index.js +1 -1
  54. package/dist/src/scaffold/bb-ecosystem-apply.js +47 -47
  55. package/dist/src/scaffold/bb-quality-gate.js +5 -5
  56. package/dist/src/scaffold/continuation-prompt.js +60 -60
  57. package/dist/src/scaffold/init-new.js +453 -453
  58. package/dist/src/self-qa/__tests__/scenario-planner.test.js +3 -3
  59. package/dist/src/self-qa/agentic-loop.js +11 -2
  60. package/dist/src/storage/__tests__/hierarchical-session.test.d.ts +1 -0
  61. package/dist/src/storage/__tests__/hierarchical-session.test.js +161 -0
  62. package/dist/src/storage/__tests__/migrations.test.js +2 -2
  63. package/dist/src/storage/__tests__/sweep-stale-pending.test.js +2 -2
  64. package/dist/src/storage/index.d.ts +1 -1
  65. package/dist/src/storage/index.js +1 -1
  66. package/dist/src/storage/interaction-log.js +5 -5
  67. package/dist/src/storage/migrations.js +135 -123
  68. package/dist/src/storage/session-experience-store.js +4 -4
  69. package/dist/src/storage/sessions.js +43 -43
  70. package/dist/src/storage/transcript-response-entry.test.js +18 -5
  71. package/dist/src/storage/transcript.d.ts +2 -0
  72. package/dist/src/storage/transcript.js +223 -97
  73. package/dist/src/storage/usage.js +34 -17
  74. package/dist/src/storage/workspaces.js +12 -12
  75. package/dist/src/tools/native-tools.js +10 -1
  76. package/dist/src/tools/registry.d.ts +1 -0
  77. package/dist/src/tools/registry.js +30 -2
  78. package/dist/src/types/index.d.ts +1 -1
  79. package/dist/src/ui/app.js +0 -0
  80. package/dist/src/ui/slash/council-inspect.js +4 -4
  81. package/dist/src/ui/slash/expand.js +14 -1
  82. package/dist/src/ui/use-app-logic.js +0 -0
  83. package/dist/src/utils/__tests__/compaction-caps.test.js +9 -9
  84. package/dist/src/utils/__tests__/llm-deadline.test.js +16 -4
  85. package/dist/src/utils/__tests__/logger.test.d.ts +1 -0
  86. package/dist/src/utils/__tests__/logger.test.js +115 -0
  87. package/dist/src/utils/__tests__/settings-agent-first.test.d.ts +1 -0
  88. package/dist/src/utils/__tests__/settings-agent-first.test.js +48 -0
  89. package/dist/src/utils/clipboard-image.js +23 -23
  90. package/dist/src/utils/install-manager.d.ts +1 -0
  91. package/dist/src/utils/install-manager.js +76 -7
  92. package/dist/src/utils/install-manager.test.js +43 -1
  93. package/dist/src/utils/logger.d.ts +28 -0
  94. package/dist/src/utils/logger.js +171 -0
  95. package/dist/src/utils/settings.d.ts +9 -1
  96. package/dist/src/utils/settings.js +59 -22
  97. package/dist/src/utils/side-question.js +2 -2
  98. package/dist/src/utils/skills.js +3 -3
  99. package/dist/src/utils/update-checker.test.js +5 -1
  100. package/dist/src/verify/__tests__/coverage-parsers.test.js +30 -30
  101. package/package.json +1 -1
@@ -43,12 +43,12 @@ export class SessionStore {
43
43
  const now = new Date().toISOString();
44
44
  const id = createSessionId();
45
45
  const db = getDatabase();
46
- db.prepare(`
47
- INSERT INTO sessions (
48
- id, workspace_id, title, model, mode, cwd_at_start, cwd_last, status, created_at, updated_at
49
- ) VALUES (
50
- @id, @workspace_id, NULL, @model, @mode, @cwd_at_start, @cwd_last, 'active', @created_at, @updated_at
51
- )
46
+ db.prepare(`
47
+ INSERT INTO sessions (
48
+ id, workspace_id, title, model, mode, cwd_at_start, cwd_last, status, created_at, updated_at
49
+ ) VALUES (
50
+ @id, @workspace_id, NULL, @model, @mode, @cwd_at_start, @cwd_last, 'active', @created_at, @updated_at
51
+ )
52
52
  `).run({
53
53
  id,
54
54
  workspace_id: this.workspace.id,
@@ -73,35 +73,35 @@ export class SessionStore {
73
73
  */
74
74
  listRecentSessions(limit = 20) {
75
75
  const rows = getDatabase()
76
- .prepare(`
77
- SELECT s.id, s.workspace_id, s.title, s.model, s.mode, s.cwd_at_start, s.cwd_last, s.status, s.created_at, s.updated_at
78
- FROM sessions s
79
- WHERE s.workspace_id = ?
80
- AND EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id)
81
- ORDER BY s.updated_at DESC
82
- LIMIT ?
76
+ .prepare(`
77
+ SELECT s.id, s.workspace_id, s.title, s.model, s.mode, s.cwd_at_start, s.cwd_last, s.status, s.created_at, s.updated_at
78
+ FROM sessions s
79
+ WHERE s.workspace_id = ?
80
+ AND EXISTS (SELECT 1 FROM messages m WHERE m.session_id = s.id)
81
+ ORDER BY s.updated_at DESC
82
+ LIMIT ?
83
83
  `)
84
84
  .all(this.workspace.id, limit);
85
85
  return rows.map(toSessionInfo);
86
86
  }
87
87
  getLatestSession() {
88
88
  const row = getDatabase()
89
- .prepare(`
90
- SELECT id, workspace_id, title, model, mode, cwd_at_start, cwd_last, status, created_at, updated_at
91
- FROM sessions
92
- WHERE workspace_id = ?
93
- ORDER BY updated_at DESC
94
- LIMIT 1
89
+ .prepare(`
90
+ SELECT id, workspace_id, title, model, mode, cwd_at_start, cwd_last, status, created_at, updated_at
91
+ FROM sessions
92
+ WHERE workspace_id = ?
93
+ ORDER BY updated_at DESC
94
+ LIMIT 1
95
95
  `)
96
96
  .get(this.workspace.id);
97
97
  return row ? toSessionInfo(row) : null;
98
98
  }
99
99
  getSessionById(id) {
100
100
  const row = getDatabase()
101
- .prepare(`
102
- SELECT id, workspace_id, title, model, mode, cwd_at_start, cwd_last, status, created_at, updated_at
103
- FROM sessions
104
- WHERE id = ?
101
+ .prepare(`
102
+ SELECT id, workspace_id, title, model, mode, cwd_at_start, cwd_last, status, created_at, updated_at
103
+ FROM sessions
104
+ WHERE id = ?
105
105
  `)
106
106
  .get(id);
107
107
  return row ? toSessionInfo(row) : null;
@@ -116,49 +116,49 @@ export class SessionStore {
116
116
  setStatus(id, status) {
117
117
  const now = new Date().toISOString();
118
118
  getDatabase()
119
- .prepare(`
120
- UPDATE sessions
121
- SET status = ?, updated_at = ?
122
- WHERE id = ?
119
+ .prepare(`
120
+ UPDATE sessions
121
+ SET status = ?, updated_at = ?
122
+ WHERE id = ?
123
123
  `)
124
124
  .run(status, now, id);
125
125
  }
126
126
  setTitle(id, title) {
127
127
  const now = new Date().toISOString();
128
128
  getDatabase()
129
- .prepare(`
130
- UPDATE sessions
131
- SET title = ?, updated_at = ?
132
- WHERE id = ?
129
+ .prepare(`
130
+ UPDATE sessions
131
+ SET title = ?, updated_at = ?
132
+ WHERE id = ?
133
133
  `)
134
134
  .run(title, now, id);
135
135
  }
136
136
  setModel(id, model) {
137
137
  const now = new Date().toISOString();
138
138
  getDatabase()
139
- .prepare(`
140
- UPDATE sessions
141
- SET model = ?, updated_at = ?
142
- WHERE id = ?
139
+ .prepare(`
140
+ UPDATE sessions
141
+ SET model = ?, updated_at = ?
142
+ WHERE id = ?
143
143
  `)
144
144
  .run(model, now, id);
145
145
  }
146
146
  setMode(id, mode) {
147
147
  const now = new Date().toISOString();
148
148
  getDatabase()
149
- .prepare(`
150
- UPDATE sessions
151
- SET mode = ?, updated_at = ?
152
- WHERE id = ?
149
+ .prepare(`
150
+ UPDATE sessions
151
+ SET mode = ?, updated_at = ?
152
+ WHERE id = ?
153
153
  `)
154
154
  .run(mode, now, id);
155
155
  }
156
156
  touchSession(id, cwd) {
157
157
  getDatabase()
158
- .prepare(`
159
- UPDATE sessions
160
- SET cwd_last = ?, updated_at = ?
161
- WHERE id = ?
158
+ .prepare(`
159
+ UPDATE sessions
160
+ SET cwd_last = ?, updated_at = ?
161
+ WHERE id = ?
162
162
  `)
163
163
  .run(cwd, new Date().toISOString(), id);
164
164
  }
@@ -18,11 +18,24 @@ import { afterEach, describe, expect, it, vi } from "vitest";
18
18
  let MESSAGE_ROWS = [];
19
19
  vi.mock("./db", () => ({
20
20
  getDatabase: () => ({
21
- prepare: (sql) => ({
22
- all: () => (sql.includes("FROM messages") ? MESSAGE_ROWS : []),
23
- get: () => undefined,
24
- run: () => undefined,
25
- }),
21
+ prepare: (sql) => {
22
+ const lower = sql.toLowerCase();
23
+ return {
24
+ all: (...params) => {
25
+ if (lower.includes("from messages")) {
26
+ return MESSAGE_ROWS;
27
+ }
28
+ if (lower.includes("from sessions")) {
29
+ if (lower.includes("where id in")) {
30
+ return params.map((id) => ({ id }));
31
+ }
32
+ }
33
+ return [];
34
+ },
35
+ get: () => undefined,
36
+ run: () => undefined,
37
+ };
38
+ },
26
39
  }),
27
40
  withTransaction: (fn) => fn({}),
28
41
  }));
@@ -104,5 +104,7 @@ export declare function sweepStalePendingRows(staleAfterMs?: number): {
104
104
  };
105
105
  export declare function markToolCallErrored(sessionId: string, toolCallId: string, errorMessage: string): void;
106
106
  export declare function appendCompaction(sessionId: string, firstKeptSeq: number, summary: string, tokensBefore: number): void;
107
+ export declare function revertLatestCompaction(sessionId: string): void;
108
+ export declare function getSessionChain(sessionId: string): string[];
107
109
  export declare function buildChatEntries(sessionId: string): ChatEntry[];
108
110
  export declare function getLastTodoWriteArgs(sessionId: string): string | null;
@@ -1,5 +1,6 @@
1
1
  import { getCompactionSummaryText } from "../orchestrator/compaction.js";
2
2
  import { getResponseTaskType, isResponseTool } from "../pil/response-tools.js";
3
+ import { logger } from "../utils/logger.js";
3
4
  import { getDatabase, withTransaction } from "./db.js";
4
5
  import { extractToolResultFromOutput, getOutputKind, isOutputSuccess } from "./tool-results.js";
5
6
  import { buildEffectiveTranscript } from "./transcript-view.js";
@@ -327,16 +328,15 @@ export function markMessageErrored(sessionId, seq) {
327
328
  */
328
329
  export function sweepStalePendingRows(staleAfterMs = 5 * 60 * 1000) {
329
330
  const cutoff = new Date(Date.now() - staleAfterMs).toISOString();
330
- const now = new Date().toISOString();
331
331
  try {
332
332
  const db = getDatabase();
333
333
  const toolCalls = db
334
334
  .prepare(`
335
335
  UPDATE tool_calls
336
- SET status = 'aborted', completed_at = ?
336
+ SET status = 'aborted', completed_at = started_at
337
337
  WHERE status = 'pending' AND started_at < ?
338
338
  `)
339
- .run(now, cutoff);
339
+ .run(cutoff);
340
340
  const messages = db
341
341
  .prepare(`
342
342
  UPDATE messages
@@ -379,98 +379,204 @@ export function appendCompaction(sessionId, firstKeptSeq, summary, tokensBefore)
379
379
  `).run(new Date().toISOString(), sessionId);
380
380
  });
381
381
  }
382
+ export function revertLatestCompaction(sessionId) {
383
+ withTransaction((db) => {
384
+ db.prepare(`
385
+ DELETE FROM compactions
386
+ WHERE session_id = ?
387
+ AND id = (SELECT MAX(id) FROM compactions WHERE session_id = ?)
388
+ `).run(sessionId, sessionId);
389
+ db.prepare(`
390
+ UPDATE sessions
391
+ SET updated_at = ?
392
+ WHERE id = ?
393
+ `).run(new Date().toISOString(), sessionId);
394
+ });
395
+ }
396
+ export function getSessionChain(sessionId) {
397
+ logger.debug("storage", "Resolving session chain", { sessionId });
398
+ const db = getDatabase();
399
+ // 1. Walk up to the root parent
400
+ let rootId = sessionId;
401
+ const visitedUp = new Set();
402
+ while (rootId && !visitedUp.has(rootId)) {
403
+ visitedUp.add(rootId);
404
+ try {
405
+ const row = db.prepare("SELECT parent_session_id FROM sessions WHERE id = ?").get(rootId);
406
+ if (row?.parent_session_id) {
407
+ rootId = row.parent_session_id;
408
+ }
409
+ else {
410
+ break;
411
+ }
412
+ }
413
+ catch {
414
+ break;
415
+ }
416
+ }
417
+ // 2. Find all descendants of the root session using a BFS queue to handle multiple child branches
418
+ const allIds = new Set([rootId]);
419
+ const queue = [rootId];
420
+ while (queue.length > 0) {
421
+ const currentId = queue.shift();
422
+ try {
423
+ const children = db.prepare("SELECT id FROM sessions WHERE parent_session_id = ?").all(currentId);
424
+ for (const child of children) {
425
+ if (!allIds.has(child.id)) {
426
+ allIds.add(child.id);
427
+ queue.push(child.id);
428
+ }
429
+ }
430
+ }
431
+ catch (err) {
432
+ logger.error("storage", `Failed to query child sessions for ${currentId} during walk`, { error: err });
433
+ }
434
+ }
435
+ // 3. Sort chronologically by querying the database for all matched sessions ordered by created_at
436
+ let chain = [];
437
+ try {
438
+ const placeholders = Array.from(allIds)
439
+ .map(() => "?")
440
+ .join(",");
441
+ const sortedRows = db
442
+ .prepare(`
443
+ SELECT id FROM sessions
444
+ WHERE id IN (${placeholders})
445
+ ORDER BY created_at ASC
446
+ `)
447
+ .all(...Array.from(allIds));
448
+ chain = sortedRows.map((r) => r.id);
449
+ }
450
+ catch (err) {
451
+ logger.error("storage", `Failed to sort session chain for root ${rootId}`, { error: err });
452
+ // Fallback to unsorted array if query fails
453
+ chain = Array.from(allIds);
454
+ }
455
+ logger.debug("storage", "Resolved session chain successfully", { sessionId, chainLength: chain.length, chain });
456
+ return chain;
457
+ }
382
458
  export function buildChatEntries(sessionId) {
383
- const toolResults = loadStoredToolResults(sessionId);
459
+ const chain = getSessionChain(sessionId);
460
+ logger.debug("storage", "Building chat entries from session chain", { sessionId, chainLength: chain.length });
461
+ const toolResults = new Map();
462
+ for (const sid of chain) {
463
+ const results = loadStoredToolResults(sid);
464
+ for (const [k, v] of results) {
465
+ toolResults.set(k, v);
466
+ }
467
+ }
468
+ // Load parent/child session metadata to reconcile user prompt timestamps across rotation/sub-session boundaries
469
+ const db = getDatabase();
470
+ const sessionMeta = new Map();
471
+ try {
472
+ if (chain.length > 1) {
473
+ const placeholders = chain.map(() => "?").join(",");
474
+ const rows = db
475
+ .prepare(`SELECT id, parent_session_id, created_at FROM sessions WHERE id IN (${placeholders})`)
476
+ .all(...chain);
477
+ for (const r of rows) {
478
+ sessionMeta.set(r.id, { parent_session_id: r.parent_session_id, created_at: r.created_at });
479
+ }
480
+ }
481
+ }
482
+ catch (err) {
483
+ logger.error("storage", "Failed to load session metadata for chain in buildChatEntries", { error: err });
484
+ }
485
+ // Map each child sub-session's initial user prompt to its creation timestamp
486
+ const childPrompts = new Map();
487
+ for (let i = 1; i < chain.length; i++) {
488
+ const sid = chain[i];
489
+ const meta = sessionMeta.get(sid);
490
+ if (meta && meta.parent_session_id) {
491
+ const records = buildEffectiveMessageRecords(sid);
492
+ const firstUser = records.find((r) => r.message.role === "user");
493
+ if (firstUser) {
494
+ const content = renderUserContent(firstUser.message.content);
495
+ if (content) {
496
+ childPrompts.set(content, new Date(meta.created_at));
497
+ }
498
+ }
499
+ }
500
+ }
384
501
  const callMap = new Map();
385
502
  const entries = [];
386
- // Response-tool callIds already rendered as a structured_response from a
387
- // persisted tool-RESULT row (success path). After the loop we recover any
388
- // response tool that was CALLED but produced no result row — i.e. respond_*
389
- // whose execution ERRORED (the AI SDK does not persist tool-error parts as
390
- // tool-result rows). Its answer lives in the call args; without this it is
391
- // dropped on the turn-finalize rebuild and the user sees an empty turn or
392
- // only the last council debate round.
393
503
  const renderedResponseCallIds = new Set();
394
504
  let lastTimestamp;
395
- for (const row of buildEffectiveMessageRecords(sessionId)) {
396
- const { message, timestamp } = row;
397
- lastTimestamp = timestamp;
398
- if (message.role === "user") {
399
- const content = renderUserContent(message.content);
400
- if (content) {
401
- entries.push({ type: "user", content, timestamp });
505
+ for (let i = 0; i < chain.length; i++) {
506
+ const sid = chain[i];
507
+ const records = buildEffectiveMessageRecords(sid);
508
+ const isChildSession = i > 0;
509
+ for (let j = 0; j < records.length; j++) {
510
+ const { message, timestamp } = records[j];
511
+ lastTimestamp = timestamp;
512
+ if (message.role === "user") {
513
+ const content = renderUserContent(message.content);
514
+ if (isChildSession && childPrompts.has(content)) {
515
+ // Skip B's user message because it's a duplicate of A's user message
516
+ continue;
517
+ }
518
+ if (content) {
519
+ entries.push({ type: "user", content, timestamp });
520
+ }
521
+ continue;
402
522
  }
403
- continue;
404
- }
405
- if (message.role === "system") {
406
- const summaryText = getCompactionSummaryText(message);
407
- const content = summaryText ?? (typeof message.content === "string" ? message.content.trim() : "");
408
- if (content && !isInternalCouncilMarker(content)) {
409
- // A compaction checkpoint is internal context-management, NOT the
410
- // assistant's answer. Tag it with a source label so the UI renders it
411
- // as a clearly-marked, collapsible checkpoint instead of dumping the
412
- // raw "## Goal / ## Context For Suffix" scaffolding as a chat reply.
413
- entries.push(summaryText !== null
414
- ? { type: "assistant", content, timestamp, sourceLabel: "⋯ context checkpoint (auto-compacted)" }
415
- : { type: "assistant", content, timestamp });
523
+ if (message.role === "system") {
524
+ const summaryText = getCompactionSummaryText(message);
525
+ if (isChildSession && j === 0 && summaryText !== null) {
526
+ // Skip the first message in a child session if it's the parent compaction summary
527
+ // to keep the visual timeline clean, since the user already sees the parent history.
528
+ continue;
529
+ }
530
+ const content = summaryText ?? (typeof message.content === "string" ? message.content.trim() : "");
531
+ if (content && !isInternalCouncilMarker(content)) {
532
+ entries.push(summaryText !== null
533
+ ? { type: "assistant", content, timestamp, sourceLabel: "⋯ context checkpoint (auto-compacted)" }
534
+ : { type: "assistant", content, timestamp });
535
+ }
536
+ continue;
416
537
  }
417
- continue;
418
- }
419
- if (message.role === "assistant") {
420
- const text = renderAssistantContent(message.content, callMap);
421
- if (text) {
422
- entries.push({ type: "assistant", content: text, timestamp });
538
+ if (message.role === "assistant") {
539
+ const text = renderAssistantContent(message.content, callMap);
540
+ if (text) {
541
+ entries.push({ type: "assistant", content: text, timestamp });
542
+ }
543
+ continue;
423
544
  }
424
- continue;
425
- }
426
- if (message.role === "tool" && Array.isArray(message.content)) {
427
- for (const part of message.content) {
428
- if (part.type !== "tool-result")
429
- continue;
430
- // Response tools (respond_general/analyze/plan/...) are the model's
431
- // TERMINAL structured answer (identity execute the payload lives in
432
- // the call args and is echoed in the result). The live stream yields
433
- // them as a `structured_response` chunk (message-processor.ts:2733),
434
- // which the UI renders as the answer block. Persisted history MUST
435
- // rebuild the SAME entry: finalizeActiveTurn (app.tsx) replaces the
436
- // live message list with getChatEntries() on every normal turn, so if
437
- // we rebuilt a respond_* result as a bare tool_result the rendered
438
- // answer would be silently dropped the instant streaming ended (live
439
- // repro session 9d3d371ca1bd: grok investigated, emitted a 10K-char
440
- // respond_general, the answer flashed then vanished on turn finalize —
441
- // the user saw only the "→ respond_general" indicator). AI SDK v5/v6
442
- // wraps tool outputs as `{type:"json", value:{...}}`; unwrap to expose
443
- // the schema-shaped payload to the renderer.
444
- if (isResponseTool(part.toolName)) {
445
- renderedResponseCallIds.add(part.toolCallId);
446
- const rawOutput = part.output;
447
- const unwrapped = rawOutput && typeof rawOutput === "object" && rawOutput.type === "json"
448
- ? (rawOutput.value ?? {})
449
- : (rawOutput ?? {});
545
+ if (message.role === "tool" && Array.isArray(message.content)) {
546
+ for (const part of message.content) {
547
+ if (part.type !== "tool-result")
548
+ continue;
549
+ if (isResponseTool(part.toolName)) {
550
+ renderedResponseCallIds.add(part.toolCallId);
551
+ const rawOutput = part.output;
552
+ const unwrapped = rawOutput && typeof rawOutput === "object" && rawOutput.type === "json"
553
+ ? (rawOutput.value ?? {})
554
+ : (rawOutput ?? {});
555
+ entries.push({
556
+ type: "structured_response",
557
+ content: "",
558
+ timestamp,
559
+ structuredResponse: {
560
+ taskType: getResponseTaskType(part.toolName) ?? part.toolName,
561
+ data: unwrapped,
562
+ },
563
+ });
564
+ continue;
565
+ }
566
+ const toolCall = callMap.get(part.toolCallId) ?? toFallbackToolCall(part.toolCallId, part.toolName);
567
+ const toolResult = toolResults.get(part.toolCallId) ??
568
+ extractToolResultFromOutput(part.output) ?? {
569
+ success: isOutputSuccess(part.output),
570
+ output: JSON.stringify(part.output),
571
+ };
450
572
  entries.push({
451
- type: "structured_response",
452
- content: "",
573
+ type: "tool_result",
574
+ content: toolResult.success ? toolResult.output || "Success" : toolResult.error || "Error",
453
575
  timestamp,
454
- structuredResponse: {
455
- taskType: getResponseTaskType(part.toolName) ?? part.toolName,
456
- data: unwrapped,
457
- },
576
+ toolCall,
577
+ toolResult,
458
578
  });
459
- continue;
460
579
  }
461
- const toolCall = callMap.get(part.toolCallId) ?? toFallbackToolCall(part.toolCallId, part.toolName);
462
- const toolResult = toolResults.get(part.toolCallId) ??
463
- extractToolResultFromOutput(part.output) ?? {
464
- success: isOutputSuccess(part.output),
465
- output: JSON.stringify(part.output),
466
- };
467
- entries.push({
468
- type: "tool_result",
469
- content: toolResult.success ? toolResult.output || "Success" : toolResult.error || "Error",
470
- timestamp,
471
- toolCall,
472
- toolResult,
473
- });
474
580
  }
475
581
  }
476
582
  }
@@ -501,6 +607,19 @@ export function buildChatEntries(sessionId) {
501
607
  },
502
608
  });
503
609
  }
610
+ // Adjust parent user message timestamps to match their corresponding child session creation time
611
+ for (const entry of entries) {
612
+ if (entry.type === "user") {
613
+ const content = entry.content.trim();
614
+ const childTime = childPrompts.get(content);
615
+ if (childTime) {
616
+ entry.timestamp = childTime;
617
+ }
618
+ }
619
+ }
620
+ // Sort entries chronologically by timestamp (stable sort)
621
+ entries.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
622
+ logger.debug("storage", "Built chat entries successfully", { sessionId, count: entries.length });
504
623
  return entries;
505
624
  }
506
625
  function getNextSequence(db, sessionId) {
@@ -579,20 +698,27 @@ function toFallbackToolCall(toolCallId, toolName) {
579
698
  };
580
699
  }
581
700
  export function getLastTodoWriteArgs(sessionId) {
582
- try {
583
- const row = getDatabase()
584
- .prepare(`
585
- SELECT args_json
586
- FROM tool_calls
587
- WHERE session_id = ? AND tool_name = 'todo_write'
588
- ORDER BY id DESC
589
- LIMIT 1
590
- `)
591
- .get(sessionId);
592
- return row?.args_json ?? null;
593
- }
594
- catch {
595
- return null;
701
+ const chain = getSessionChain(sessionId);
702
+ const db = getDatabase();
703
+ for (let i = chain.length - 1; i >= 0; i--) {
704
+ try {
705
+ const row = db
706
+ .prepare(`
707
+ SELECT args_json
708
+ FROM tool_calls
709
+ WHERE session_id = ? AND tool_name = 'todo_write'
710
+ ORDER BY id DESC
711
+ LIMIT 1
712
+ `)
713
+ .get(chain[i]);
714
+ if (row?.args_json) {
715
+ return row.args_json;
716
+ }
717
+ }
718
+ catch {
719
+ // ignore and check parent
720
+ }
596
721
  }
722
+ return null;
597
723
  }
598
724
  //# sourceMappingURL=transcript.js.map
@@ -1,5 +1,7 @@
1
1
  import { getModelInfo } from "../models/registry.js";
2
+ import { logger } from "../utils/logger.js";
2
3
  import { getDatabase } from "./db.js";
4
+ import { getSessionChain } from "./transcript.js";
3
5
  export function recordUsageEvent(sessionId, source, model, usage, messageSeq, pilActive = false, enrichmentDelta = 0, providerOptionsShape = null) {
4
6
  if (!usage)
5
7
  return;
@@ -12,33 +14,48 @@ export function recordUsageEvent(sessionId, source, model, usage, messageSeq, pi
12
14
  const cacheCreationTokens = usage.cacheCreationTokens ?? 0;
13
15
  const costMicros = estimateCostMicros(model, inputTokens, outputTokens, cacheReadTokens);
14
16
  getDatabase()
15
- .prepare(`
16
- INSERT INTO usage_events (
17
- session_id, message_seq, source, model, input_tokens, output_tokens, total_tokens, cost_micros, created_at,
18
- pil_active, enrichment_delta, cache_read_tokens, cache_creation_tokens, provider_options_shape
19
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
17
+ .prepare(`
18
+ INSERT INTO usage_events (
19
+ session_id, message_seq, source, model, input_tokens, output_tokens, total_tokens, cost_micros, created_at,
20
+ pil_active, enrichment_delta, cache_read_tokens, cache_creation_tokens, provider_options_shape
21
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
20
22
  `)
21
23
  .run(sessionId, messageSeq ?? null, source, model, inputTokens, outputTokens, totalTokens, costMicros, new Date().toISOString(), pilActive ? 1 : 0, enrichmentDelta, cacheReadTokens, cacheCreationTokens, providerOptionsShape);
22
24
  }
23
25
  export function getSessionTotalTokens(sessionId) {
26
+ const chain = getSessionChain(sessionId);
27
+ const placeholders = chain.map(() => "?").join(",");
24
28
  const row = getDatabase()
25
- .prepare(`
26
- SELECT COALESCE(SUM(total_tokens), 0) AS total_tokens
27
- FROM usage_events
28
- WHERE session_id = ?
29
+ .prepare(`
30
+ SELECT COALESCE(SUM(total_tokens), 0) AS total_tokens
31
+ FROM usage_events
32
+ WHERE session_id IN (${placeholders})
29
33
  `)
30
- .get(sessionId);
31
- return row?.total_tokens ?? 0;
34
+ .get(...chain);
35
+ const total = row?.total_tokens ?? 0;
36
+ logger.debug("storage", "Calculated session total tokens from chain", {
37
+ sessionId,
38
+ chainLength: chain.length,
39
+ totalTokens: total,
40
+ });
41
+ return total;
32
42
  }
33
43
  export function listSessionUsage(sessionId) {
44
+ const chain = getSessionChain(sessionId);
45
+ const placeholders = chain.map(() => "?").join(",");
34
46
  const rows = getDatabase()
35
- .prepare(`
36
- SELECT id, session_id, message_seq, source, model, input_tokens, output_tokens, total_tokens, cost_micros, created_at, cache_read_tokens, cache_creation_tokens
37
- FROM usage_events
38
- WHERE session_id = ?
39
- ORDER BY id ASC
47
+ .prepare(`
48
+ SELECT id, session_id, message_seq, source, model, input_tokens, output_tokens, total_tokens, cost_micros, created_at, cache_read_tokens, cache_creation_tokens
49
+ FROM usage_events
50
+ WHERE session_id IN (${placeholders})
51
+ ORDER BY id ASC
40
52
  `)
41
- .all(sessionId);
53
+ .all(...chain);
54
+ logger.debug("storage", "Retrieved session usage events from chain", {
55
+ sessionId,
56
+ chainLength: chain.length,
57
+ eventCount: rows.length,
58
+ });
42
59
  return rows.map((row) => ({
43
60
  id: row.id,
44
61
  sessionId: row.session_id,