@oh-my-pi/pi-coding-agent 12.4.0 → 12.5.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 (49) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/docs/custom-tools.md +21 -6
  3. package/docs/extensions.md +20 -0
  4. package/package.json +12 -12
  5. package/src/cli/setup-cli.ts +62 -2
  6. package/src/commands/setup.ts +1 -1
  7. package/src/config/keybindings.ts +4 -1
  8. package/src/config/settings-schema.ts +67 -4
  9. package/src/config/settings.ts +23 -9
  10. package/src/debug/index.ts +26 -19
  11. package/src/debug/log-formatting.ts +60 -0
  12. package/src/debug/log-viewer.ts +903 -0
  13. package/src/debug/report-bundle.ts +87 -8
  14. package/src/discovery/helpers.ts +131 -137
  15. package/src/extensibility/custom-tools/types.ts +44 -6
  16. package/src/extensibility/extensions/types.ts +60 -0
  17. package/src/extensibility/hooks/types.ts +60 -0
  18. package/src/extensibility/skills.ts +4 -2
  19. package/src/main.ts +7 -1
  20. package/src/modes/components/custom-editor.ts +8 -0
  21. package/src/modes/components/settings-selector.ts +29 -14
  22. package/src/modes/controllers/command-controller.ts +2 -0
  23. package/src/modes/controllers/event-controller.ts +7 -0
  24. package/src/modes/controllers/input-controller.ts +23 -2
  25. package/src/modes/controllers/selector-controller.ts +9 -7
  26. package/src/modes/interactive-mode.ts +84 -1
  27. package/src/modes/rpc/rpc-client.ts +7 -0
  28. package/src/modes/rpc/rpc-mode.ts +8 -0
  29. package/src/modes/rpc/rpc-types.ts +2 -0
  30. package/src/modes/theme/theme.ts +163 -7
  31. package/src/modes/types.ts +1 -0
  32. package/src/patch/hashline.ts +2 -1
  33. package/src/patch/shared.ts +44 -13
  34. package/src/prompts/system/plan-mode-approved.md +5 -0
  35. package/src/prompts/system/subagent-system-prompt.md +1 -0
  36. package/src/prompts/system/system-prompt.md +14 -0
  37. package/src/prompts/tools/hashline.md +63 -72
  38. package/src/prompts/tools/todo-write.md +3 -1
  39. package/src/sdk.ts +87 -9
  40. package/src/session/agent-session.ts +137 -29
  41. package/src/stt/downloader.ts +71 -0
  42. package/src/stt/index.ts +3 -0
  43. package/src/stt/recorder.ts +351 -0
  44. package/src/stt/setup.ts +52 -0
  45. package/src/stt/stt-controller.ts +160 -0
  46. package/src/stt/transcribe.py +70 -0
  47. package/src/stt/transcriber.ts +91 -0
  48. package/src/system-prompt.ts +4 -0
  49. package/src/task/executor.ts +10 -2
package/src/sdk.ts CHANGED
@@ -323,6 +323,7 @@ export interface BuildSystemPromptOptions {
323
323
  contextFiles?: Array<{ path: string; content: string }>;
324
324
  cwd?: string;
325
325
  appendPrompt?: string;
326
+ repeatToolDescriptions?: boolean;
326
327
  }
327
328
 
328
329
  /**
@@ -334,6 +335,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
334
335
  skills: options.skills,
335
336
  contextFiles: options.contextFiles,
336
337
  appendSystemPrompt: options.appendPrompt,
338
+ repeatToolDescriptions: options.repeatToolDescriptions,
337
339
  });
338
340
  }
339
341
 
@@ -441,6 +443,58 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
441
443
  api.on("session_shutdown", async (_event, ctx) =>
442
444
  runOnSession({ reason: "shutdown", previousSessionFile: undefined }, ctx),
443
445
  );
446
+ api.on("auto_compaction_start", async (event, ctx) =>
447
+ runOnSession({ reason: "auto_compaction_start", trigger: event.reason }, ctx),
448
+ );
449
+ api.on("auto_compaction_end", async (event, ctx) =>
450
+ runOnSession(
451
+ {
452
+ reason: "auto_compaction_end",
453
+ result: event.result,
454
+ aborted: event.aborted,
455
+ willRetry: event.willRetry,
456
+ errorMessage: event.errorMessage,
457
+ },
458
+ ctx,
459
+ ),
460
+ );
461
+ api.on("auto_retry_start", async (event, ctx) =>
462
+ runOnSession(
463
+ {
464
+ reason: "auto_retry_start",
465
+ attempt: event.attempt,
466
+ maxAttempts: event.maxAttempts,
467
+ delayMs: event.delayMs,
468
+ errorMessage: event.errorMessage,
469
+ },
470
+ ctx,
471
+ ),
472
+ );
473
+ api.on("auto_retry_end", async (event, ctx) =>
474
+ runOnSession(
475
+ {
476
+ reason: "auto_retry_end",
477
+ success: event.success,
478
+ attempt: event.attempt,
479
+ finalError: event.finalError,
480
+ },
481
+ ctx,
482
+ ),
483
+ );
484
+ api.on("ttsr_triggered", async (event, ctx) =>
485
+ runOnSession({ reason: "ttsr_triggered", rules: event.rules }, ctx),
486
+ );
487
+ api.on("todo_reminder", async (event, ctx) =>
488
+ runOnSession(
489
+ {
490
+ reason: "todo_reminder",
491
+ todos: event.todos,
492
+ attempt: event.attempt,
493
+ maxAttempts: event.maxAttempts,
494
+ },
495
+ ctx,
496
+ ),
497
+ );
444
498
  };
445
499
  }
446
500
 
@@ -496,6 +550,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
496
550
  time("settings");
497
551
  initializeWithSettings(settings);
498
552
  time("initializeWithSettings");
553
+ const skillsSettings = settings.getGroup("skills") as SkillsSettings;
554
+ const discoveredSkillsPromise =
555
+ options.skills === undefined ? discoverSkills(cwd, agentDir, skillsSettings) : undefined;
499
556
 
500
557
  // Initialize provider preferences from settings
501
558
  setPreferredSearchProvider(settings.get("providers.webSearch") ?? "auto");
@@ -504,6 +561,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
504
561
  const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
505
562
  time("sessionManager");
506
563
  const sessionId = sessionManager.getSessionId();
564
+ const modelApiKeyAvailability = new Map<string, boolean>();
565
+ const getModelAvailabilityKey = (candidate: Model): string =>
566
+ `${candidate.provider}\u0000${candidate.baseUrl ?? ""}`;
567
+ const hasModelApiKey = async (candidate: Model): Promise<boolean> => {
568
+ const availabilityKey = getModelAvailabilityKey(candidate);
569
+ const cached = modelApiKeyAvailability.get(availabilityKey);
570
+ if (cached !== undefined) {
571
+ return cached;
572
+ }
573
+
574
+ const hasKey = !!(await modelRegistry.getApiKey(candidate, sessionId));
575
+ modelApiKeyAvailability.set(availabilityKey, hasKey);
576
+ return hasKey;
577
+ };
507
578
 
508
579
  // Check if session has existing data to restore
509
580
  const existingSession = sessionManager.buildSessionContext();
@@ -521,7 +592,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
521
592
  const parsedModel = parseModelString(defaultModelStr);
522
593
  if (parsedModel) {
523
594
  const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
524
- if (restoredModel && (await modelRegistry.getApiKey(restoredModel, sessionId))) {
595
+ if (restoredModel && (await hasModelApiKey(restoredModel))) {
525
596
  model = restoredModel;
526
597
  }
527
598
  }
@@ -537,7 +608,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
537
608
  const parsedModel = parseModelString(settingsDefaultModel);
538
609
  if (parsedModel) {
539
610
  const settingsModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
540
- if (settingsModel && (await modelRegistry.getApiKey(settingsModel, sessionId))) {
611
+ if (settingsModel && (await hasModelApiKey(settingsModel))) {
541
612
  model = settingsModel;
542
613
  }
543
614
  }
@@ -547,10 +618,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
547
618
  // Fall back to first available model with a valid API key
548
619
  if (!model) {
549
620
  const allModels = modelRegistry.getAll();
550
- const keyResults = await Promise.all(
551
- allModels.map(async m => ({ model: m, hasKey: !!(await modelRegistry.getApiKey(m, sessionId)) })),
552
- );
553
- model = keyResults.find(r => r.hasKey)?.model;
621
+ for (const candidate of allModels) {
622
+ if (await hasModelApiKey(candidate)) {
623
+ model = candidate;
624
+ break;
625
+ }
626
+ }
554
627
  time("findAvailableModel");
555
628
  if (model) {
556
629
  if (modelFallbackMessage) {
@@ -563,6 +636,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
563
636
  }
564
637
  }
565
638
 
639
+ time("findModel");
640
+
566
641
  // For subagent sessions using GitHub Copilot, add X-Initiator header
567
642
  // to ensure proper billing (agent-initiated vs user-initiated)
568
643
  const taskDepth = options.taskDepth ?? 0;
@@ -604,12 +679,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
604
679
  skills = options.skills;
605
680
  skillWarnings = [];
606
681
  } else {
607
- const skillsSettings = settings.getGroup("skills") as SkillsSettings;
608
- const discovered = await discoverSkills(cwd, agentDir, skillsSettings);
682
+ const discovered = discoveredSkillsPromise ? await discoveredSkillsPromise : { skills: [], warnings: [] };
683
+ time("discoverSkills");
609
684
  skills = discovered.skills;
610
685
  skillWarnings = discovered.warnings;
611
686
  }
612
- time("discoverSkills");
687
+
613
688
  debugStartup("sdk:discoverSkills");
614
689
 
615
690
  // Discover rules
@@ -913,6 +988,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
913
988
  emitEvent: event => cursorEventEmitter?.(event),
914
989
  });
915
990
 
991
+ const repeatToolDescriptions = settings.get("repeatToolDescriptions");
916
992
  const rebuildSystemPrompt = async (toolNames: string[], tools: Map<string, AgentTool>): Promise<string> => {
917
993
  toolContextStore.setToolNames(toolNames);
918
994
  const memoryInstructions = await buildMemoryToolDeveloperInstructions(agentDir, settings);
@@ -926,6 +1002,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
926
1002
  rules: rulebookRules,
927
1003
  skillsSettings: settings.getGroup("skills") as SkillsSettings,
928
1004
  appendSystemPrompt: memoryInstructions,
1005
+ repeatToolDescriptions,
929
1006
  });
930
1007
 
931
1008
  if (options.systemPrompt === undefined) {
@@ -943,6 +1020,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
943
1020
  skillsSettings: settings.getGroup("skills") as SkillsSettings,
944
1021
  customPrompt: options.systemPrompt,
945
1022
  appendSystemPrompt: memoryInstructions,
1023
+ repeatToolDescriptions,
946
1024
  });
947
1025
  }
948
1026
  return options.systemPrompt(defaultPrompt);
@@ -66,7 +66,7 @@ import type { Skill, SkillWarning } from "../extensibility/skills";
66
66
  import { expandSlashCommand, type FileSlashCommand } from "../extensibility/slash-commands";
67
67
  import { resolvePlanUrlToPath } from "../internal-urls";
68
68
  import { executePython as executePythonCommand, type PythonResult } from "../ipy/executor";
69
- import { theme } from "../modes/theme/theme";
69
+ import { getCurrentThemeName, theme } from "../modes/theme/theme";
70
70
  import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../patch";
71
71
  import type { PlanModeState } from "../plan-mode/state";
72
72
  import planModeActivePrompt from "../prompts/system/plan-mode-active.md" with { type: "text" };
@@ -340,6 +340,7 @@ export class AgentSession {
340
340
  #streamingEditCheckedLineCounts = new Map<string, number>();
341
341
  #streamingEditFileCache = new Map<string, string>();
342
342
  #promptInFlight = false;
343
+ #promptGeneration = 0;
343
344
  #providerSessionState = new Map<string, ProviderSessionState>();
344
345
 
345
346
  constructor(config: AgentSessionConfig) {
@@ -400,6 +401,11 @@ export class AgentSession {
400
401
  }
401
402
  }
402
403
 
404
+ async #emitSessionEvent(event: AgentSessionEvent): Promise<void> {
405
+ await this.#emitExtensionEvent(event);
406
+ this.#emit(event);
407
+ }
408
+
403
409
  // Track last assistant message for auto-compaction check
404
410
  #lastAssistantMessage: AssistantMessage | undefined = undefined;
405
411
 
@@ -424,11 +430,7 @@ export class AgentSession {
424
430
  }
425
431
  }
426
432
 
427
- // Emit to extensions first
428
- await this.#emitExtensionEvent(event);
429
-
430
- // Notify all listeners
431
- this.#emit(event);
433
+ await this.#emitSessionEvent(event);
432
434
 
433
435
  if (event.type === "turn_start") {
434
436
  this.#resetStreamingEditState();
@@ -453,11 +455,11 @@ export class AgentSession {
453
455
  this.#ttsrManager.markInjected(matches);
454
456
  // Store for injection on retry
455
457
  this.#pendingTtsrInjections.push(...matches);
456
- // Emit TTSR event before aborting (so UI can handle it)
458
+ // Abort the stream immediately do not gate on extension callbacks
457
459
  this.#ttsrAbortPending = true;
458
- this.#emit({ type: "ttsr_triggered", rules: matches });
459
- // Abort the stream
460
460
  this.agent.abort();
461
+ // Notify extensions (fire-and-forget, does not block abort)
462
+ this.#emitSessionEvent({ type: "ttsr_triggered", rules: matches }).catch(() => {});
461
463
  // Schedule retry after a short delay
462
464
  setTimeout(async () => {
463
465
  this.#ttsrAbortPending = false;
@@ -525,8 +527,12 @@ export class AgentSession {
525
527
  // Reset retry counter immediately on successful assistant response
526
528
  // This prevents accumulation across multiple LLM calls within a turn
527
529
  const assistantMsg = event.message as AssistantMessage;
528
- if (assistantMsg.stopReason !== "error" && this.#retryAttempt > 0) {
529
- this.#emit({
530
+ if (
531
+ assistantMsg.stopReason !== "error" &&
532
+ assistantMsg.stopReason !== "aborted" &&
533
+ this.#retryAttempt > 0
534
+ ) {
535
+ await this.#emitSessionEvent({
530
536
  type: "auto_retry_end",
531
537
  success: true,
532
538
  attempt: this.#retryAttempt,
@@ -537,11 +543,13 @@ export class AgentSession {
537
543
  }
538
544
 
539
545
  if (event.message.role === "toolResult") {
540
- const { toolName, $normative, toolCallId, details } = event.message as {
546
+ const { toolName, $normative, toolCallId, details, isError, content } = event.message as {
541
547
  toolName?: string;
542
548
  toolCallId?: string;
543
549
  details?: { path?: string };
544
550
  $normative?: Record<string, unknown>;
551
+ isError?: boolean;
552
+ content?: Array<TextContent | ImageContent>;
545
553
  };
546
554
  if ($normative && toolCallId && this.settings.get("normativeRewrite")) {
547
555
  await this.#rewriteToolCallArgs(toolCallId, $normative);
@@ -550,6 +558,25 @@ export class AgentSession {
550
558
  if (toolName === "edit" && details?.path) {
551
559
  this.#invalidateFileCacheForPath(details.path);
552
560
  }
561
+ if (toolName === "todo_write" && isError) {
562
+ const errorText = content?.find(part => part.type === "text")?.text;
563
+ const reminderText = [
564
+ "<system_reminder>",
565
+ "todo_write failed, so todo progress is not visible to the user.",
566
+ errorText ? `Failure: ${errorText}` : "Failure: todo_write returned an error.",
567
+ "Fix the todo payload and call todo_write again before continuing.",
568
+ "</system_reminder>",
569
+ ].join("\n");
570
+ await this.sendCustomMessage(
571
+ {
572
+ customType: "todo-write-error-reminder",
573
+ content: reminderText,
574
+ display: false,
575
+ details: { toolName, errorText },
576
+ },
577
+ { deliverAs: "nextTurn" },
578
+ );
579
+ }
553
580
  }
554
581
  }
555
582
 
@@ -823,10 +850,9 @@ export class AgentSession {
823
850
  }
824
851
  }
825
852
 
826
- /** Emit extension events based on agent events */
827
- async #emitExtensionEvent(event: AgentEvent): Promise<void> {
853
+ /** Emit extension events based on session events */
854
+ async #emitExtensionEvent(event: AgentSessionEvent): Promise<void> {
828
855
  if (!this.#extensionRunner) return;
829
-
830
856
  if (event.type === "agent_start") {
831
857
  this.#turnIndex = 0;
832
858
  await this.#extensionRunner.emit({ type: "agent_start" });
@@ -848,6 +874,40 @@ export class AgentSession {
848
874
  };
849
875
  await this.#extensionRunner.emit(hookEvent);
850
876
  this.#turnIndex++;
877
+ } else if (event.type === "auto_compaction_start") {
878
+ await this.#extensionRunner.emit({ type: "auto_compaction_start", reason: event.reason });
879
+ } else if (event.type === "auto_compaction_end") {
880
+ await this.#extensionRunner.emit({
881
+ type: "auto_compaction_end",
882
+ result: event.result,
883
+ aborted: event.aborted,
884
+ willRetry: event.willRetry,
885
+ errorMessage: event.errorMessage,
886
+ });
887
+ } else if (event.type === "auto_retry_start") {
888
+ await this.#extensionRunner.emit({
889
+ type: "auto_retry_start",
890
+ attempt: event.attempt,
891
+ maxAttempts: event.maxAttempts,
892
+ delayMs: event.delayMs,
893
+ errorMessage: event.errorMessage,
894
+ });
895
+ } else if (event.type === "auto_retry_end") {
896
+ await this.#extensionRunner.emit({
897
+ type: "auto_retry_end",
898
+ success: event.success,
899
+ attempt: event.attempt,
900
+ finalError: event.finalError,
901
+ });
902
+ } else if (event.type === "ttsr_triggered") {
903
+ await this.#extensionRunner.emit({ type: "ttsr_triggered", rules: event.rules });
904
+ } else if (event.type === "todo_reminder") {
905
+ await this.#extensionRunner.emit({
906
+ type: "todo_reminder",
907
+ todos: event.todos,
908
+ attempt: event.attempt,
909
+ maxAttempts: event.maxAttempts,
910
+ });
851
911
  }
852
912
  }
853
913
 
@@ -1342,6 +1402,7 @@ export class AgentSession {
1342
1402
  options?: Pick<PromptOptions, "toolChoice" | "images">,
1343
1403
  ): Promise<void> {
1344
1404
  this.#promptInFlight = true;
1405
+ const generation = this.#promptGeneration;
1345
1406
  try {
1346
1407
  // Flush any pending bash messages before the new prompt
1347
1408
  this.#flushPendingBashMessages();
@@ -1387,6 +1448,12 @@ export class AgentSession {
1387
1448
 
1388
1449
  messages.push(message);
1389
1450
 
1451
+ // Early bail-out: if a newer abort/prompt cycle started during setup,
1452
+ // return before mutating shared state (nextTurn messages, system prompt).
1453
+ if (this.#promptGeneration !== generation) {
1454
+ return;
1455
+ }
1456
+
1390
1457
  // Inject any pending "nextTurn" messages as context alongside the user message
1391
1458
  for (const msg of this.#pendingNextTurnMessages) {
1392
1459
  messages.push(msg);
@@ -1430,6 +1497,11 @@ export class AgentSession {
1430
1497
  }
1431
1498
  }
1432
1499
 
1500
+ // Bail out if a newer abort/prompt cycle has started since we began setup
1501
+ if (this.#promptGeneration !== generation) {
1502
+ return;
1503
+ }
1504
+
1433
1505
  const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
1434
1506
  await this.agent.prompt(messages, agentPromptOptions);
1435
1507
  await this.#waitForRetry();
@@ -1797,8 +1869,14 @@ export class AgentSession {
1797
1869
  */
1798
1870
  async abort(): Promise<void> {
1799
1871
  this.abortRetry();
1872
+ this.#promptGeneration++;
1800
1873
  this.agent.abort();
1801
1874
  await this.agent.waitForIdle();
1875
+ // Clear promptInFlight: waitForIdle resolves when the agent loop's finally
1876
+ // block runs (#resolveRunningPrompt), but #promptWithMessage's finally
1877
+ // (#promptInFlight = false) fires on a later microtask. Without this,
1878
+ // isStreaming stays true and a subsequent prompt() throws.
1879
+ this.#promptInFlight = false;
1802
1880
  }
1803
1881
 
1804
1882
  /**
@@ -2672,7 +2750,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
2672
2750
  });
2673
2751
 
2674
2752
  // Emit event for UI to render notification
2675
- this.#emit({
2753
+ await this.#emitSessionEvent({
2676
2754
  type: "todo_reminder",
2677
2755
  todos: incomplete,
2678
2756
  attempt: this.#todoReminderCount,
@@ -2743,7 +2821,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
2743
2821
  async #runAutoCompaction(reason: "overflow" | "threshold", willRetry: boolean): Promise<void> {
2744
2822
  const compactionSettings = this.settings.getGroup("compaction");
2745
2823
 
2746
- this.#emit({ type: "auto_compaction_start", reason });
2824
+ await this.#emitSessionEvent({ type: "auto_compaction_start", reason });
2747
2825
  // Properly abort and null existing controller before replacing
2748
2826
  if (this.#autoCompactionAbortController) {
2749
2827
  this.#autoCompactionAbortController.abort();
@@ -2752,13 +2830,23 @@ Be thorough - include exact file paths, function names, error messages, and tech
2752
2830
 
2753
2831
  try {
2754
2832
  if (!this.model) {
2755
- this.#emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
2833
+ await this.#emitSessionEvent({
2834
+ type: "auto_compaction_end",
2835
+ result: undefined,
2836
+ aborted: false,
2837
+ willRetry: false,
2838
+ });
2756
2839
  return;
2757
2840
  }
2758
2841
 
2759
2842
  const availableModels = this.#modelRegistry.getAvailable();
2760
2843
  if (availableModels.length === 0) {
2761
- this.#emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
2844
+ await this.#emitSessionEvent({
2845
+ type: "auto_compaction_end",
2846
+ result: undefined,
2847
+ aborted: false,
2848
+ willRetry: false,
2849
+ });
2762
2850
  return;
2763
2851
  }
2764
2852
 
@@ -2766,7 +2854,12 @@ Be thorough - include exact file paths, function names, error messages, and tech
2766
2854
 
2767
2855
  const preparation = prepareCompaction(pathEntries, compactionSettings);
2768
2856
  if (!preparation) {
2769
- this.#emit({ type: "auto_compaction_end", result: undefined, aborted: false, willRetry: false });
2857
+ await this.#emitSessionEvent({
2858
+ type: "auto_compaction_end",
2859
+ result: undefined,
2860
+ aborted: false,
2861
+ willRetry: false,
2862
+ });
2770
2863
  return;
2771
2864
  }
2772
2865
 
@@ -2786,7 +2879,12 @@ Be thorough - include exact file paths, function names, error messages, and tech
2786
2879
  })) as SessionBeforeCompactResult | undefined;
2787
2880
 
2788
2881
  if (hookResult?.cancel) {
2789
- this.#emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
2882
+ await this.#emitSessionEvent({
2883
+ type: "auto_compaction_end",
2884
+ result: undefined,
2885
+ aborted: true,
2886
+ willRetry: false,
2887
+ });
2790
2888
  return;
2791
2889
  }
2792
2890
 
@@ -2914,7 +3012,12 @@ Be thorough - include exact file paths, function names, error messages, and tech
2914
3012
  }
2915
3013
 
2916
3014
  if (this.#autoCompactionAbortController.signal.aborted) {
2917
- this.#emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
3015
+ await this.#emitSessionEvent({
3016
+ type: "auto_compaction_end",
3017
+ result: undefined,
3018
+ aborted: true,
3019
+ willRetry: false,
3020
+ });
2918
3021
  return;
2919
3022
  }
2920
3023
 
@@ -2952,7 +3055,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
2952
3055
  details,
2953
3056
  preserveData,
2954
3057
  };
2955
- this.#emit({ type: "auto_compaction_end", result, aborted: false, willRetry });
3058
+ await this.#emitSessionEvent({ type: "auto_compaction_end", result, aborted: false, willRetry });
2956
3059
 
2957
3060
  if (!willRetry && compactionSettings.autoContinue !== false) {
2958
3061
  await this.prompt("Continue if you have next steps.", {
@@ -2980,11 +3083,16 @@ Be thorough - include exact file paths, function names, error messages, and tech
2980
3083
  }
2981
3084
  } catch (error) {
2982
3085
  if (this.#autoCompactionAbortController?.signal.aborted) {
2983
- this.#emit({ type: "auto_compaction_end", result: undefined, aborted: true, willRetry: false });
3086
+ await this.#emitSessionEvent({
3087
+ type: "auto_compaction_end",
3088
+ result: undefined,
3089
+ aborted: true,
3090
+ willRetry: false,
3091
+ });
2984
3092
  return;
2985
3093
  }
2986
3094
  const errorMessage = error instanceof Error ? error.message : "compaction failed";
2987
- this.#emit({
3095
+ await this.#emitSessionEvent({
2988
3096
  type: "auto_compaction_end",
2989
3097
  result: undefined,
2990
3098
  aborted: false,
@@ -3106,7 +3214,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3106
3214
 
3107
3215
  if (this.#retryAttempt > retrySettings.maxRetries) {
3108
3216
  // Max retries exceeded, emit final failure and reset
3109
- this.#emit({
3217
+ await this.#emitSessionEvent({
3110
3218
  type: "auto_retry_end",
3111
3219
  success: false,
3112
3220
  attempt: this.#retryAttempt - 1,
@@ -3135,7 +3243,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3135
3243
  }
3136
3244
  }
3137
3245
 
3138
- this.#emit({
3246
+ await this.#emitSessionEvent({
3139
3247
  type: "auto_retry_start",
3140
3248
  attempt: this.#retryAttempt,
3141
3249
  maxAttempts: retrySettings.maxRetries,
@@ -3162,7 +3270,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3162
3270
  const attempt = this.#retryAttempt;
3163
3271
  this.#retryAttempt = 0;
3164
3272
  this.#retryAbortController = undefined;
3165
- this.#emit({
3273
+ await this.#emitSessionEvent({
3166
3274
  type: "auto_retry_end",
3167
3275
  success: false,
3168
3276
  attempt,
@@ -3932,7 +4040,7 @@ Be thorough - include exact file paths, function names, error messages, and tech
3932
4040
  * @returns Path to exported file
3933
4041
  */
3934
4042
  async exportToHtml(outputPath?: string): Promise<string> {
3935
- const themeName = this.settings.get("theme");
4043
+ const themeName = getCurrentThemeName();
3936
4044
  return exportSessionToHtml(this.sessionManager, this.state, { outputPath, themeName });
3937
4045
  }
3938
4046
 
@@ -0,0 +1,71 @@
1
+ import { logger } from "@oh-my-pi/pi-utils";
2
+ import { $ } from "bun";
3
+ import { resolvePython } from "./transcriber";
4
+
5
+ export interface DownloadProgress {
6
+ stage: string;
7
+ percent?: number;
8
+ }
9
+
10
+ export interface EnsureOptions {
11
+ modelName?: string;
12
+ onProgress?: (progress: DownloadProgress) => void;
13
+ }
14
+
15
+ // ── Recording tool ─────────────────────────────────────────────────
16
+
17
+ async function ensureRecordingTool(options?: EnsureOptions): Promise<void> {
18
+ if (Bun.which("sox")) return;
19
+ if (Bun.which("ffmpeg")) return;
20
+ if (process.platform === "linux" && Bun.which("arecord")) return;
21
+
22
+ // Windows: PowerShell mciSendString is always available as fallback
23
+ if (process.platform === "win32") {
24
+ // Try to get ffmpeg for better quality, but don't block on failure
25
+ options?.onProgress?.({ stage: "Trying to install FFmpeg via winget..." });
26
+ const result = await $`winget install --id Gyan.FFmpeg -e --accept-source-agreements --accept-package-agreements`
27
+ .quiet()
28
+ .nothrow();
29
+ if (result.exitCode === 0) {
30
+ logger.debug("FFmpeg installed via winget");
31
+ }
32
+ return;
33
+ }
34
+
35
+ throw new Error(
36
+ "No audio recording tool found. Install SoX: sudo apt install sox, or FFmpeg: sudo apt install ffmpeg",
37
+ );
38
+ }
39
+
40
+ // ── Python whisper ─────────────────────────────────────────────────
41
+
42
+ async function ensurePythonWhisper(options?: EnsureOptions): Promise<void> {
43
+ const pythonCmd = resolvePython();
44
+ if (!pythonCmd) {
45
+ throw new Error("Python not found. Install Python 3.8+ from https://python.org");
46
+ }
47
+
48
+ // Check if whisper module is already importable
49
+ const check = Bun.spawnSync([pythonCmd, "-c", "import whisper"], {
50
+ stdout: "pipe",
51
+ stderr: "pipe",
52
+ });
53
+ if (check.exitCode === 0) return;
54
+
55
+ options?.onProgress?.({ stage: "Installing openai-whisper (this may take a few minutes)..." });
56
+ logger.debug("Installing openai-whisper via pip");
57
+
58
+ const install = await $`${pythonCmd} -m pip install -q openai-whisper`.quiet().nothrow();
59
+ if (install.exitCode !== 0) {
60
+ const stderr = install.stderr.toString().trim();
61
+ throw new Error(`Failed to install openai-whisper: ${stderr.split("\n").pop()}`);
62
+ }
63
+ logger.debug("openai-whisper installed successfully");
64
+ }
65
+
66
+ // ── Public API ─────────────────────────────────────────────────────
67
+
68
+ export async function ensureSTTDependencies(options?: EnsureOptions): Promise<void> {
69
+ await ensureRecordingTool(options);
70
+ await ensurePythonWhisper(options);
71
+ }
@@ -0,0 +1,3 @@
1
+ export { type DownloadProgress, ensureSTTDependencies } from "./downloader";
2
+ export { checkDependencies, formatDependencyStatus, type STTDependencyStatus } from "./setup";
3
+ export { STTController, type SttState } from "./stt-controller";