@oh-my-pi/pi-coding-agent 12.3.0 → 12.5.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +66 -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 +6 -2
  8. package/src/config/settings-schema.ts +58 -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/lsp/render.ts +1 -1
  20. package/src/main.ts +7 -1
  21. package/src/memories/index.ts +11 -7
  22. package/src/modes/components/bash-execution.ts +16 -9
  23. package/src/modes/components/custom-editor.ts +8 -0
  24. package/src/modes/components/python-execution.ts +16 -7
  25. package/src/modes/components/settings-selector.ts +29 -14
  26. package/src/modes/components/tool-execution.ts +2 -1
  27. package/src/modes/controllers/command-controller.ts +3 -1
  28. package/src/modes/controllers/event-controller.ts +7 -0
  29. package/src/modes/controllers/input-controller.ts +23 -2
  30. package/src/modes/controllers/selector-controller.ts +9 -7
  31. package/src/modes/interactive-mode.ts +84 -1
  32. package/src/modes/rpc/rpc-client.ts +7 -0
  33. package/src/modes/rpc/rpc-mode.ts +8 -0
  34. package/src/modes/rpc/rpc-types.ts +2 -0
  35. package/src/modes/theme/theme.ts +163 -7
  36. package/src/modes/types.ts +1 -0
  37. package/src/patch/hashline.ts +2 -1
  38. package/src/patch/shared.ts +44 -13
  39. package/src/prompts/system/plan-mode-approved.md +5 -0
  40. package/src/prompts/system/subagent-system-prompt.md +1 -0
  41. package/src/prompts/system/system-prompt.md +10 -0
  42. package/src/prompts/tools/todo-write.md +3 -1
  43. package/src/sdk.ts +82 -9
  44. package/src/session/agent-session.ts +137 -29
  45. package/src/session/streaming-output.ts +1 -1
  46. package/src/stt/downloader.ts +71 -0
  47. package/src/stt/index.ts +3 -0
  48. package/src/stt/recorder.ts +351 -0
  49. package/src/stt/setup.ts +52 -0
  50. package/src/stt/stt-controller.ts +160 -0
  51. package/src/stt/transcribe.py +70 -0
  52. package/src/stt/transcriber.ts +91 -0
  53. package/src/task/executor.ts +10 -2
  54. package/src/tools/bash-interactive.ts +10 -6
  55. package/src/tools/fetch.ts +1 -1
  56. package/src/tools/output-meta.ts +6 -2
  57. package/src/web/scrapers/types.ts +1 -0
package/src/sdk.ts CHANGED
@@ -441,6 +441,58 @@ function createCustomToolsExtension(tools: CustomTool[]): ExtensionFactory {
441
441
  api.on("session_shutdown", async (_event, ctx) =>
442
442
  runOnSession({ reason: "shutdown", previousSessionFile: undefined }, ctx),
443
443
  );
444
+ api.on("auto_compaction_start", async (event, ctx) =>
445
+ runOnSession({ reason: "auto_compaction_start", trigger: event.reason }, ctx),
446
+ );
447
+ api.on("auto_compaction_end", async (event, ctx) =>
448
+ runOnSession(
449
+ {
450
+ reason: "auto_compaction_end",
451
+ result: event.result,
452
+ aborted: event.aborted,
453
+ willRetry: event.willRetry,
454
+ errorMessage: event.errorMessage,
455
+ },
456
+ ctx,
457
+ ),
458
+ );
459
+ api.on("auto_retry_start", async (event, ctx) =>
460
+ runOnSession(
461
+ {
462
+ reason: "auto_retry_start",
463
+ attempt: event.attempt,
464
+ maxAttempts: event.maxAttempts,
465
+ delayMs: event.delayMs,
466
+ errorMessage: event.errorMessage,
467
+ },
468
+ ctx,
469
+ ),
470
+ );
471
+ api.on("auto_retry_end", async (event, ctx) =>
472
+ runOnSession(
473
+ {
474
+ reason: "auto_retry_end",
475
+ success: event.success,
476
+ attempt: event.attempt,
477
+ finalError: event.finalError,
478
+ },
479
+ ctx,
480
+ ),
481
+ );
482
+ api.on("ttsr_triggered", async (event, ctx) =>
483
+ runOnSession({ reason: "ttsr_triggered", rules: event.rules }, ctx),
484
+ );
485
+ api.on("todo_reminder", async (event, ctx) =>
486
+ runOnSession(
487
+ {
488
+ reason: "todo_reminder",
489
+ todos: event.todos,
490
+ attempt: event.attempt,
491
+ maxAttempts: event.maxAttempts,
492
+ },
493
+ ctx,
494
+ ),
495
+ );
444
496
  };
445
497
  }
446
498
 
@@ -496,6 +548,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
496
548
  time("settings");
497
549
  initializeWithSettings(settings);
498
550
  time("initializeWithSettings");
551
+ const skillsSettings = settings.getGroup("skills") as SkillsSettings;
552
+ const discoveredSkillsPromise =
553
+ options.skills === undefined ? discoverSkills(cwd, agentDir, skillsSettings) : undefined;
499
554
 
500
555
  // Initialize provider preferences from settings
501
556
  setPreferredSearchProvider(settings.get("providers.webSearch") ?? "auto");
@@ -504,6 +559,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
504
559
  const sessionManager = options.sessionManager ?? SessionManager.create(cwd);
505
560
  time("sessionManager");
506
561
  const sessionId = sessionManager.getSessionId();
562
+ const modelApiKeyAvailability = new Map<string, boolean>();
563
+ const getModelAvailabilityKey = (candidate: Model): string =>
564
+ `${candidate.provider}\u0000${candidate.baseUrl ?? ""}`;
565
+ const hasModelApiKey = async (candidate: Model): Promise<boolean> => {
566
+ const availabilityKey = getModelAvailabilityKey(candidate);
567
+ const cached = modelApiKeyAvailability.get(availabilityKey);
568
+ if (cached !== undefined) {
569
+ return cached;
570
+ }
571
+
572
+ const hasKey = !!(await modelRegistry.getApiKey(candidate, sessionId));
573
+ modelApiKeyAvailability.set(availabilityKey, hasKey);
574
+ return hasKey;
575
+ };
507
576
 
508
577
  // Check if session has existing data to restore
509
578
  const existingSession = sessionManager.buildSessionContext();
@@ -521,7 +590,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
521
590
  const parsedModel = parseModelString(defaultModelStr);
522
591
  if (parsedModel) {
523
592
  const restoredModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
524
- if (restoredModel && (await modelRegistry.getApiKey(restoredModel, sessionId))) {
593
+ if (restoredModel && (await hasModelApiKey(restoredModel))) {
525
594
  model = restoredModel;
526
595
  }
527
596
  }
@@ -537,7 +606,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
537
606
  const parsedModel = parseModelString(settingsDefaultModel);
538
607
  if (parsedModel) {
539
608
  const settingsModel = modelRegistry.find(parsedModel.provider, parsedModel.id);
540
- if (settingsModel && (await modelRegistry.getApiKey(settingsModel, sessionId))) {
609
+ if (settingsModel && (await hasModelApiKey(settingsModel))) {
541
610
  model = settingsModel;
542
611
  }
543
612
  }
@@ -547,10 +616,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
547
616
  // Fall back to first available model with a valid API key
548
617
  if (!model) {
549
618
  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;
619
+ for (const candidate of allModels) {
620
+ if (await hasModelApiKey(candidate)) {
621
+ model = candidate;
622
+ break;
623
+ }
624
+ }
554
625
  time("findAvailableModel");
555
626
  if (model) {
556
627
  if (modelFallbackMessage) {
@@ -563,6 +634,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
563
634
  }
564
635
  }
565
636
 
637
+ time("findModel");
638
+
566
639
  // For subagent sessions using GitHub Copilot, add X-Initiator header
567
640
  // to ensure proper billing (agent-initiated vs user-initiated)
568
641
  const taskDepth = options.taskDepth ?? 0;
@@ -604,12 +677,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
604
677
  skills = options.skills;
605
678
  skillWarnings = [];
606
679
  } else {
607
- const skillsSettings = settings.getGroup("skills") as SkillsSettings;
608
- const discovered = await discoverSkills(cwd, agentDir, skillsSettings);
680
+ const discovered = discoveredSkillsPromise ? await discoveredSkillsPromise : { skills: [], warnings: [] };
681
+ time("discoverSkills");
609
682
  skills = discovered.skills;
610
683
  skillWarnings = discovered.warnings;
611
684
  }
612
- time("discoverSkills");
685
+
613
686
  debugStartup("sdk:discoverSkills");
614
687
 
615
688
  // Discover rules
@@ -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
 
@@ -1,4 +1,4 @@
1
- import { sanitizeText } from "@oh-my-pi/pi-utils";
1
+ import { sanitizeText } from "@oh-my-pi/pi-natives";
2
2
  import { DEFAULT_MAX_BYTES } from "../tools/truncate";
3
3
 
4
4
  export interface OutputSummary {
@@ -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";