@oh-my-pi/pi-coding-agent 15.12.4 → 15.13.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 (291) hide show
  1. package/CHANGELOG.md +304 -6
  2. package/dist/cli.js +1015 -881
  3. package/dist/types/async/job-manager.d.ts +15 -0
  4. package/dist/types/autolearn/controller.d.ts +25 -0
  5. package/dist/types/autolearn/managed-skills.d.ts +45 -0
  6. package/dist/types/autoresearch/state.d.ts +1 -1
  7. package/dist/types/autoresearch/types.d.ts +1 -1
  8. package/dist/types/cli/args.d.ts +19 -1
  9. package/dist/types/cli/session-picker.d.ts +1 -1
  10. package/dist/types/cli/setup-cli.d.ts +1 -1
  11. package/dist/types/cli/setup-model-picker.d.ts +14 -0
  12. package/dist/types/collab/protocol.d.ts +1 -1
  13. package/dist/types/commands/say.d.ts +24 -0
  14. package/dist/types/config/keybindings.d.ts +3 -3
  15. package/dist/types/config/model-registry.d.ts +10 -0
  16. package/dist/types/config/models-config-schema.d.ts +12 -0
  17. package/dist/types/config/models-config.d.ts +8 -2
  18. package/dist/types/config/settings-schema.d.ts +261 -58
  19. package/dist/types/export/html/index.d.ts +2 -1
  20. package/dist/types/extensibility/extensions/model-api.d.ts +17 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +47 -1
  23. package/dist/types/extensibility/hooks/index.d.ts +2 -1
  24. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +9 -0
  25. package/dist/types/extensibility/plugins/loader.d.ts +11 -0
  26. package/dist/types/extensibility/shared-events.d.ts +1 -1
  27. package/dist/types/extensibility/skills.d.ts +10 -0
  28. package/dist/types/goals/guided-setup.d.ts +18 -0
  29. package/dist/types/goals/state.d.ts +1 -1
  30. package/dist/types/hindsight/transcript.d.ts +1 -1
  31. package/dist/types/index.d.ts +5 -0
  32. package/dist/types/internal-urls/local-protocol.d.ts +4 -2
  33. package/dist/types/main.d.ts +4 -3
  34. package/dist/types/mcp/startup-events.d.ts +11 -0
  35. package/dist/types/memories/index.d.ts +7 -0
  36. package/dist/types/memory-backend/local-backend.d.ts +4 -3
  37. package/dist/types/mnemopi/config.d.ts +4 -4
  38. package/dist/types/modes/components/agent-hub.d.ts +6 -0
  39. package/dist/types/modes/components/assistant-message.d.ts +1 -2
  40. package/dist/types/modes/components/compaction-summary-message.d.ts +15 -1
  41. package/dist/types/modes/components/custom-editor.d.ts +39 -1
  42. package/dist/types/modes/components/custom-editor.test.d.ts +1 -0
  43. package/dist/types/modes/components/session-selector.d.ts +1 -1
  44. package/dist/types/modes/components/tool-execution.d.ts +26 -16
  45. package/dist/types/modes/components/transcript-container.d.ts +23 -2
  46. package/dist/types/modes/components/tree-selector.d.ts +1 -1
  47. package/dist/types/modes/components/usage-row.d.ts +3 -0
  48. package/dist/types/modes/controllers/command-controller.d.ts +2 -2
  49. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  50. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  51. package/dist/types/modes/gradient-highlight.d.ts +9 -4
  52. package/dist/types/modes/image-references.d.ts +6 -0
  53. package/dist/types/modes/interactive-mode.d.ts +27 -3
  54. package/dist/types/modes/magic-keywords.d.ts +13 -1
  55. package/dist/types/modes/rpc/rpc-mode.d.ts +35 -1
  56. package/dist/types/modes/rpc/rpc-types.d.ts +9 -1
  57. package/dist/types/modes/runtime-init.d.ts +4 -0
  58. package/dist/types/modes/theme/theme.d.ts +13 -2
  59. package/dist/types/modes/types.d.ts +8 -2
  60. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  61. package/dist/types/registry/agent-registry.d.ts +17 -0
  62. package/dist/types/secrets/obfuscator.d.ts +1 -1
  63. package/dist/types/session/agent-session.d.ts +14 -2
  64. package/dist/types/session/indexed-session-storage.d.ts +3 -4
  65. package/dist/types/session/session-context.d.ts +39 -0
  66. package/dist/types/session/session-entries.d.ts +159 -0
  67. package/dist/types/session/session-listing.d.ts +69 -0
  68. package/dist/types/session/session-loader.d.ts +16 -0
  69. package/dist/types/session/session-manager.d.ts +82 -474
  70. package/dist/types/session/session-migrations.d.ts +12 -0
  71. package/dist/types/session/session-paths.d.ts +25 -0
  72. package/dist/types/session/session-persistence.d.ts +8 -0
  73. package/dist/types/session/session-storage.d.ts +11 -12
  74. package/dist/types/session/snapcompact-inline.d.ts +12 -1
  75. package/dist/types/session/snapcompact-savings-journal.d.ts +46 -0
  76. package/dist/types/session/tool-choice-queue.d.ts +6 -6
  77. package/dist/types/stt/asr-client.d.ts +90 -0
  78. package/dist/types/stt/asr-protocol.d.ts +97 -0
  79. package/dist/types/stt/asr-worker.d.ts +2 -0
  80. package/dist/types/stt/downloader.d.ts +38 -0
  81. package/dist/types/stt/endpointer.d.ts +59 -0
  82. package/dist/types/stt/index.d.ts +5 -1
  83. package/dist/types/stt/models.d.ts +120 -0
  84. package/dist/types/stt/recorder.d.ts +17 -0
  85. package/dist/types/stt/stt-controller.d.ts +6 -0
  86. package/dist/types/stt/transcriber.d.ts +5 -7
  87. package/dist/types/stt/wav.d.ts +29 -0
  88. package/dist/types/system-prompt.d.ts +4 -0
  89. package/dist/types/task/executor.d.ts +2 -0
  90. package/dist/types/task/index.d.ts +9 -1
  91. package/dist/types/task/types.d.ts +36 -0
  92. package/dist/types/tools/bash.d.ts +2 -2
  93. package/dist/types/tools/eval-render.d.ts +1 -1
  94. package/dist/types/tools/index.d.ts +11 -1
  95. package/dist/types/tools/irc.d.ts +1 -0
  96. package/dist/types/tools/learn.d.ts +51 -0
  97. package/dist/types/tools/manage-skill.d.ts +40 -0
  98. package/dist/types/tools/plan-mode-guard.d.ts +10 -0
  99. package/dist/types/tools/renderers.d.ts +7 -11
  100. package/dist/types/tools/ssh.d.ts +1 -1
  101. package/dist/types/tools/todo.d.ts +1 -1
  102. package/dist/types/tools/tts.d.ts +25 -0
  103. package/dist/types/tools/write.d.ts +1 -1
  104. package/dist/types/tts/downloader.d.ts +20 -0
  105. package/dist/types/tts/index.d.ts +8 -0
  106. package/dist/types/tts/models.d.ts +82 -0
  107. package/dist/types/tts/player.d.ts +32 -0
  108. package/dist/types/tts/runtime.d.ts +6 -0
  109. package/dist/types/tts/streaming-player.d.ts +41 -0
  110. package/dist/types/tts/tts-client.d.ts +93 -0
  111. package/dist/types/tts/tts-protocol.d.ts +95 -0
  112. package/dist/types/tts/tts-worker.d.ts +2 -0
  113. package/dist/types/tts/vocalizer.d.ts +41 -0
  114. package/dist/types/tts/wav.d.ts +8 -0
  115. package/dist/types/utils/tool-choice.d.ts +8 -0
  116. package/dist/types/utils/tools-manager.d.ts +2 -1
  117. package/dist/types/utils/tools-manager.test.d.ts +1 -0
  118. package/dist/types/web/scrapers/github.d.ts +1 -1
  119. package/package.json +15 -14
  120. package/src/async/job-manager.ts +49 -0
  121. package/src/autolearn/controller.ts +139 -0
  122. package/src/autolearn/managed-skills.ts +257 -0
  123. package/src/autoresearch/state.ts +1 -1
  124. package/src/autoresearch/types.ts +1 -1
  125. package/src/cli/args.ts +56 -2
  126. package/src/cli/session-picker.ts +2 -1
  127. package/src/cli/setup-cli.ts +148 -47
  128. package/src/cli/setup-model-picker.ts +43 -0
  129. package/src/cli-commands.ts +1 -0
  130. package/src/cli.ts +45 -13
  131. package/src/collab/host.ts +1 -1
  132. package/src/collab/protocol.ts +1 -1
  133. package/src/commands/say.ts +102 -0
  134. package/src/commands/setup.ts +1 -1
  135. package/src/commit/agentic/tools/analyze-file.ts +3 -0
  136. package/src/config/keybindings.ts +2 -2
  137. package/src/config/model-discovery.ts +11 -5
  138. package/src/config/model-registry.ts +64 -9
  139. package/src/config/models-config-schema.ts +4 -1
  140. package/src/config/models-config.ts +2 -1
  141. package/src/config/settings-schema.ts +248 -32
  142. package/src/config/settings.ts +10 -0
  143. package/src/discovery/builtin.ts +23 -1
  144. package/src/discovery/claude-plugins.ts +44 -5
  145. package/src/discovery/helpers.ts +41 -1
  146. package/src/eval/__tests__/budget-bridge.test.ts +1 -1
  147. package/src/eval/js/shared/prelude.txt +69 -17
  148. package/src/export/html/index.ts +3 -6
  149. package/src/extensibility/extensions/model-api.ts +41 -0
  150. package/src/extensibility/extensions/runner.ts +4 -0
  151. package/src/extensibility/extensions/types.ts +52 -1
  152. package/src/extensibility/extensions/wrapper.ts +41 -5
  153. package/src/extensibility/hooks/index.ts +2 -1
  154. package/src/extensibility/plugins/legacy-pi-compat.ts +43 -13
  155. package/src/extensibility/plugins/loader.ts +30 -19
  156. package/src/extensibility/plugins/manager.ts +221 -90
  157. package/src/extensibility/shared-events.ts +1 -1
  158. package/src/extensibility/skills.ts +96 -15
  159. package/src/goals/guided-setup.ts +133 -0
  160. package/src/goals/state.ts +1 -1
  161. package/src/hindsight/transcript.ts +1 -1
  162. package/src/index.ts +5 -0
  163. package/src/internal-urls/docs-index.generated.ts +10 -10
  164. package/src/internal-urls/history-protocol.ts +1 -1
  165. package/src/internal-urls/local-protocol.ts +29 -7
  166. package/src/main.ts +27 -7
  167. package/src/mcp/startup-events.ts +21 -0
  168. package/src/mcp/transports/stdio.ts +2 -1
  169. package/src/memories/index.ts +146 -11
  170. package/src/memory-backend/local-backend.ts +11 -5
  171. package/src/mnemopi/backend.ts +1 -0
  172. package/src/mnemopi/config.ts +26 -10
  173. package/src/modes/acp/acp-agent.ts +3 -5
  174. package/src/modes/components/agent-hub.ts +49 -4
  175. package/src/modes/components/assistant-message.ts +4 -37
  176. package/src/modes/components/compaction-summary-message.ts +125 -26
  177. package/src/modes/components/custom-editor.test.ts +96 -0
  178. package/src/modes/components/custom-editor.ts +164 -8
  179. package/src/modes/components/session-selector.ts +1 -1
  180. package/src/modes/components/settings-defs.ts +7 -0
  181. package/src/modes/components/tool-execution.ts +82 -43
  182. package/src/modes/components/transcript-container.ts +70 -1
  183. package/src/modes/components/tree-selector.ts +1 -1
  184. package/src/modes/components/usage-row.ts +18 -0
  185. package/src/modes/components/user-message.ts +4 -2
  186. package/src/modes/controllers/command-controller.ts +14 -4
  187. package/src/modes/controllers/event-controller.ts +78 -11
  188. package/src/modes/controllers/extension-ui-controller.ts +6 -0
  189. package/src/modes/controllers/input-controller.ts +258 -27
  190. package/src/modes/controllers/selector-controller.ts +12 -2
  191. package/src/modes/gradient-highlight.ts +21 -9
  192. package/src/modes/image-references.ts +20 -0
  193. package/src/modes/interactive-mode.ts +286 -40
  194. package/src/modes/magic-keywords.ts +27 -5
  195. package/src/modes/rpc/rpc-mode.ts +146 -14
  196. package/src/modes/rpc/rpc-subagents.ts +2 -2
  197. package/src/modes/rpc/rpc-types.ts +8 -2
  198. package/src/modes/runtime-init.ts +28 -3
  199. package/src/modes/theme/theme.ts +98 -50
  200. package/src/modes/types.ts +6 -2
  201. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  202. package/src/modes/utils/ui-helpers.ts +34 -6
  203. package/src/priority.json +5 -1
  204. package/src/prompts/agents/task.md +1 -0
  205. package/src/prompts/goals/guided-goal-interview.md +8 -0
  206. package/src/prompts/goals/guided-goal-system.md +12 -0
  207. package/src/prompts/memories/read-path.md +6 -0
  208. package/src/prompts/system/autolearn-guidance-learn.md +1 -0
  209. package/src/prompts/system/autolearn-guidance.md +7 -0
  210. package/src/prompts/system/autolearn-nudge.md +3 -0
  211. package/src/prompts/system/eager-task.md +7 -0
  212. package/src/prompts/system/eager-todo.md +11 -6
  213. package/src/prompts/system/subagent-system-prompt.md +4 -0
  214. package/src/prompts/system/system-prompt.md +10 -5
  215. package/src/prompts/system/title-marker-instruction.md +1 -0
  216. package/src/prompts/system/title-system-marker.md +16 -0
  217. package/src/prompts/tools/job.md +1 -0
  218. package/src/prompts/tools/learn.md +7 -0
  219. package/src/prompts/tools/manage-skill.md +9 -0
  220. package/src/prompts/tools/task.md +3 -0
  221. package/src/registry/agent-registry.ts +30 -0
  222. package/src/sdk.ts +88 -24
  223. package/src/secrets/obfuscator.ts +1 -1
  224. package/src/session/agent-session.ts +209 -87
  225. package/src/session/history-storage.ts +2 -2
  226. package/src/session/indexed-session-storage.ts +7 -17
  227. package/src/session/session-context.ts +352 -0
  228. package/src/session/session-entries.ts +194 -0
  229. package/src/session/session-listing.ts +588 -0
  230. package/src/session/session-loader.ts +106 -0
  231. package/src/session/session-manager.ts +933 -3145
  232. package/src/session/session-migrations.ts +78 -0
  233. package/src/session/session-paths.ts +193 -0
  234. package/src/session/session-persistence.ts +131 -0
  235. package/src/session/session-storage.ts +91 -50
  236. package/src/session/snapcompact-inline.ts +21 -1
  237. package/src/session/snapcompact-savings-journal.ts +113 -0
  238. package/src/session/tool-choice-queue.ts +23 -11
  239. package/src/slash-commands/builtin-registry.ts +25 -3
  240. package/src/stt/asr-client.ts +520 -0
  241. package/src/stt/asr-protocol.ts +65 -0
  242. package/src/stt/asr-worker.ts +790 -0
  243. package/src/stt/downloader.ts +107 -47
  244. package/src/stt/endpointer.ts +259 -0
  245. package/src/stt/index.ts +5 -1
  246. package/src/stt/models.ts +150 -0
  247. package/src/stt/recorder.ts +247 -60
  248. package/src/stt/stt-controller.ts +201 -22
  249. package/src/stt/transcriber.ts +37 -68
  250. package/src/stt/wav.ts +173 -0
  251. package/src/system-prompt.ts +8 -0
  252. package/src/task/agents.ts +1 -2
  253. package/src/task/executor.ts +49 -15
  254. package/src/task/index.ts +60 -6
  255. package/src/task/render.ts +83 -8
  256. package/src/task/types.ts +53 -0
  257. package/src/tools/ask.ts +8 -0
  258. package/src/tools/bash.ts +4 -3
  259. package/src/tools/eval-render.ts +4 -3
  260. package/src/tools/index.ts +40 -4
  261. package/src/tools/irc.ts +10 -2
  262. package/src/tools/job.ts +14 -2
  263. package/src/tools/learn.ts +144 -0
  264. package/src/tools/manage-skill.ts +104 -0
  265. package/src/tools/plan-mode-guard.ts +53 -19
  266. package/src/tools/renderers.ts +7 -11
  267. package/src/tools/ssh.ts +4 -3
  268. package/src/tools/todo.ts +1 -1
  269. package/src/tools/tts.ts +203 -92
  270. package/src/tools/write.ts +18 -2
  271. package/src/tts/downloader.ts +64 -0
  272. package/src/tts/index.ts +8 -0
  273. package/src/tts/models.ts +137 -0
  274. package/src/tts/player.ts +137 -0
  275. package/src/tts/runtime.ts +21 -0
  276. package/src/tts/streaming-player.ts +266 -0
  277. package/src/tts/tts-client.ts +647 -0
  278. package/src/tts/tts-protocol.ts +60 -0
  279. package/src/tts/tts-worker.ts +497 -0
  280. package/src/tts/vocalizer.ts +162 -0
  281. package/src/tts/wav.ts +58 -0
  282. package/src/utils/title-generator.ts +48 -5
  283. package/src/utils/tool-choice.ts +16 -0
  284. package/src/utils/tools-manager.test.ts +25 -0
  285. package/src/utils/tools-manager.ts +19 -1
  286. package/src/web/scrapers/github.ts +96 -0
  287. package/src/web/search/index.ts +13 -0
  288. package/src/web/search/providers/searxng.ts +13 -1
  289. package/dist/types/stt/setup.d.ts +0 -18
  290. package/src/stt/setup.ts +0 -52
  291. package/src/stt/transcribe.py +0 -70
package/src/tts/wav.ts ADDED
@@ -0,0 +1,58 @@
1
+ const WAV_HEADER_BYTES = 44;
2
+ const PCM16_FORMAT = 1;
3
+ const BITS_PER_SAMPLE = 16;
4
+ const INT16_MAX = 32_767;
5
+ const INT16_MIN = -32_768;
6
+
7
+ /**
8
+ * Assemble a mono PCM16 WAV byte buffer from Float32 PCM samples (the shape
9
+ * transformers.js `RawAudio` emits: normalized [-1, 1] amplitudes plus a sample
10
+ * rate). No external encoder is involved — we write a canonical 44-byte RIFF/
11
+ * WAVE header followed by little-endian signed 16-bit samples. Samples are
12
+ * clamped before quantization so out-of-range float values do not wrap.
13
+ */
14
+ export function encodeWav(samples: Float32Array, sampleRate: number): Uint8Array {
15
+ const channels = 1;
16
+ const byteRate = sampleRate * channels * (BITS_PER_SAMPLE / 8);
17
+ const blockAlign = channels * (BITS_PER_SAMPLE / 8);
18
+ const dataBytes = samples.length * (BITS_PER_SAMPLE / 8);
19
+ const buffer = new ArrayBuffer(WAV_HEADER_BYTES + dataBytes);
20
+ const view = new DataView(buffer);
21
+
22
+ // RIFF chunk descriptor
23
+ writeAscii(view, 0, "RIFF");
24
+ view.setUint32(4, WAV_HEADER_BYTES - 8 + dataBytes, true); // file size minus the first 8 bytes
25
+ writeAscii(view, 8, "WAVE");
26
+
27
+ // fmt sub-chunk
28
+ writeAscii(view, 12, "fmt ");
29
+ view.setUint32(16, 16, true); // PCM fmt chunk size
30
+ view.setUint16(20, PCM16_FORMAT, true);
31
+ view.setUint16(22, channels, true);
32
+ view.setUint32(24, sampleRate, true);
33
+ view.setUint32(28, byteRate, true);
34
+ view.setUint16(32, blockAlign, true);
35
+ view.setUint16(34, BITS_PER_SAMPLE, true);
36
+
37
+ // data sub-chunk
38
+ writeAscii(view, 36, "data");
39
+ view.setUint32(40, dataBytes, true);
40
+
41
+ let offset = WAV_HEADER_BYTES;
42
+ for (let i = 0; i < samples.length; i += 1) {
43
+ const sample = samples[i]!;
44
+ const clamped = sample > 1 ? 1 : sample < -1 ? -1 : sample;
45
+ const quantized =
46
+ clamped < 0
47
+ ? Math.max(INT16_MIN, Math.round(clamped * -INT16_MIN))
48
+ : Math.min(INT16_MAX, Math.round(clamped * INT16_MAX));
49
+ view.setInt16(offset, quantized, true);
50
+ offset += 2;
51
+ }
52
+
53
+ return new Uint8Array(buffer);
54
+ }
55
+
56
+ function writeAscii(view: DataView, offset: number, text: string): void {
57
+ for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
58
+ }
@@ -9,12 +9,16 @@ import type { ModelRegistry } from "../config/model-registry";
9
9
 
10
10
  import { resolveRoleSelection } from "../config/model-resolver";
11
11
  import type { Settings } from "../config/settings";
12
+ import titleMarkerInstruction from "../prompts/system/title-marker-instruction.md" with { type: "text" };
12
13
  import titleSystemPrompt from "../prompts/system/title-system.md" with { type: "text" };
14
+ import titleMarkerSystemPrompt from "../prompts/system/title-system-marker.md" with { type: "text" };
13
15
  import { ONLINE_TINY_TITLE_MODEL_KEY } from "../tiny/models";
14
16
  import { formatTitleUserMessage, isLowSignalTitleInput, normalizeGeneratedTitle } from "../tiny/text";
15
17
  import { tinyTitleClient } from "../tiny/title-client";
16
18
 
17
19
  const TITLE_SYSTEM_PROMPT = prompt.render(titleSystemPrompt);
20
+ const TITLE_MARKER_SYSTEM_PROMPT = prompt.render(titleMarkerSystemPrompt);
21
+ const TITLE_MARKER_INSTRUCTION = prompt.render(titleMarkerInstruction);
18
22
 
19
23
  const DEFAULT_TERMINAL_TITLE = "π";
20
24
  const TERMINAL_TITLE_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f]/g;
@@ -41,6 +45,30 @@ const setTitleTool: Tool = {
41
45
  },
42
46
  };
43
47
 
48
+ /** Matches the title a tool-choice-less model wraps in `<title>...</title>`. */
49
+ const TITLE_MARKER_RE = /<title>([\s\S]*?)<\/title>/i;
50
+
51
+ /**
52
+ * Whether the model honors a forced `tool_choice` so the `set_title` tool can be
53
+ * required. Providers/models that reject forced tool calls (chat-completions
54
+ * hosts without `tool_choice` support, Claude Fable/Mythos) can't be made to
55
+ * emit a structured call, so the caller falls back to marker-wrapped text.
56
+ */
57
+ function modelSupportsForcedToolChoice(model: Model<Api>): boolean {
58
+ // `compat` is a union across APIs and `supportsToolChoice` lives only on the
59
+ // OpenAI-completions variant, so read both flags through a structural view.
60
+ const compat = model.compat as { supportsToolChoice?: boolean; supportsForcedToolChoice?: boolean } | undefined;
61
+ if (!compat) return true;
62
+ // A forced tool call first requires sending `tool_choice` at all. Hosts that
63
+ // drop the parameter entirely (`supportsToolChoice: false`, e.g. direct
64
+ // DeepSeek reasoning) can never be forced even when they otherwise accept
65
+ // forced values, so this veto wins over `supportsForcedToolChoice`.
66
+ if (compat.supportsToolChoice === false) return false;
67
+ if (typeof compat.supportsForcedToolChoice === "boolean") return compat.supportsForcedToolChoice;
68
+ if (typeof compat.supportsToolChoice === "boolean") return compat.supportsToolChoice;
69
+ return true;
70
+ }
71
+
44
72
  function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel?: Model<Api>): Model<Api> | undefined {
45
73
  const availableModels = registry.getAvailable();
46
74
  if (availableModels.length === 0) return undefined;
@@ -221,7 +249,16 @@ export async function generateTitleOnline(
221
249
  }
222
250
 
223
251
  const titleSystemPrompt = customSystemPrompt?.trim() || undefined;
224
- const systemPrompt = titleSystemPrompt ?? TITLE_SYSTEM_PROMPT;
252
+ // Some providers can't be forced to call a tool — chat-completions hosts
253
+ // without `tool_choice` support, Claude Fable/Mythos — so a required
254
+ // `set_title` call never arrives. For those, ask the model to wrap the title
255
+ // in `<title>...</title>` markers and parse it from text instead.
256
+ const useForcedTool = modelSupportsForcedToolChoice(model);
257
+ const systemPrompt = useForcedTool
258
+ ? [titleSystemPrompt ?? TITLE_SYSTEM_PROMPT]
259
+ : titleSystemPrompt
260
+ ? [titleSystemPrompt, TITLE_MARKER_INSTRUCTION]
261
+ : [TITLE_MARKER_SYSTEM_PROMPT];
225
262
  const userMessage = formatTitleUserMessage(firstMessage);
226
263
  const modelName = `${model.provider}/${model.id}`;
227
264
  const modelContext = {
@@ -253,15 +290,15 @@ export async function generateTitleOnline(
253
290
  const response = await completeSimple(
254
291
  model,
255
292
  {
256
- systemPrompt: [systemPrompt],
293
+ systemPrompt,
257
294
  messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
258
- tools: [setTitleTool],
295
+ tools: useForcedTool ? [setTitleTool] : undefined,
259
296
  },
260
297
  {
261
298
  apiKey: registry.resolver(model, sessionId),
262
299
  maxTokens,
263
300
  disableReasoning: true,
264
- toolChoice: { type: "tool", name: SET_TITLE_TOOL_NAME },
301
+ toolChoice: useForcedTool ? { type: "tool", name: SET_TITLE_TOOL_NAME } : undefined,
265
302
  metadata,
266
303
  signal,
267
304
  },
@@ -319,7 +356,13 @@ function extractGeneratedTitle(contentBlocks: AssistantMessage["content"]): stri
319
356
  textTitle += content.text;
320
357
  }
321
358
  }
322
- return textTitle.trim();
359
+ // Tool-choice-less models are asked to wrap the title in <title>...</title>,
360
+ // but stay lenient: prefer the marker when the model closed it, otherwise
361
+ // accept a plain sentence after stripping any stray/unclosed tag fragment
362
+ // (e.g. output truncated before the closing tag).
363
+ const marker = TITLE_MARKER_RE.exec(textTitle);
364
+ if (marker) return marker[1].trim();
365
+ return textTitle.replace(/<\/?title>/gi, "").trim();
323
366
  }
324
367
 
325
368
  /**
@@ -31,3 +31,19 @@ export function buildNamedToolChoice(toolName: string, model?: Model<Api>): Tool
31
31
 
32
32
  return undefined;
33
33
  }
34
+
35
+ /**
36
+ * Whether the given tool choice can be satisfied by the active tool set for the
37
+ * upcoming turn. Non-named choices (`"none"`, `"required"`, etc.) do not name a
38
+ * specific tool and are therefore always active.
39
+ */
40
+ export function isToolChoiceActive(toolChoice: ToolChoice | undefined, tools: readonly { name: string }[]): boolean {
41
+ if (!toolChoice || typeof toolChoice === "string") return true;
42
+ const name =
43
+ toolChoice.type === "tool"
44
+ ? toolChoice.name
45
+ : "function" in toolChoice
46
+ ? toolChoice.function.name
47
+ : toolChoice.name;
48
+ return tools.some(tool => tool.name === name);
49
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { ffmpegAssetName } from "./tools-manager";
3
+
4
+ describe("ffmpegAssetName", () => {
5
+ it("maps supported platform/arch pairs to direct-binary asset names", () => {
6
+ expect(ffmpegAssetName("b6.1.1", "darwin", "arm64")).toBe("ffmpeg-darwin-arm64");
7
+ expect(ffmpegAssetName("b6.1.1", "darwin", "x64")).toBe("ffmpeg-darwin-x64");
8
+ expect(ffmpegAssetName("b6.1.1", "linux", "arm64")).toBe("ffmpeg-linux-arm64");
9
+ expect(ffmpegAssetName("b6.1.1", "linux", "x64")).toBe("ffmpeg-linux-x64");
10
+ expect(ffmpegAssetName("b6.1.1", "win32", "x64")).toBe("ffmpeg-win32-x64");
11
+ });
12
+
13
+ it("returns null for win32 on arm64 (no static asset published)", () => {
14
+ expect(ffmpegAssetName("b6.1.1", "win32", "arm64")).toBeNull();
15
+ });
16
+
17
+ it("returns null for unsupported arch", () => {
18
+ expect(ffmpegAssetName("b6.1.1", "darwin", "ia32")).toBeNull();
19
+ expect(ffmpegAssetName("b6.1.1", "linux", "ppc64")).toBeNull();
20
+ });
21
+
22
+ it("returns null for unsupported platform", () => {
23
+ expect(ffmpegAssetName("b6.1.1", "freebsd", "x64")).toBeNull();
24
+ });
25
+ });
@@ -16,6 +16,16 @@ interface ToolConfig {
16
16
  getAssetName: (version: string, plat: string, architecture: string) => string | null;
17
17
  }
18
18
 
19
+ // ffmpeg static-binary asset names (eugeneware/ffmpeg-static direct binaries).
20
+ // Maps node arch (arm64|x64) only; everything else is unsupported.
21
+ export function ffmpegAssetName(_version: string, plat: string, architecture: string): string | null {
22
+ if (architecture !== "arm64" && architecture !== "x64") return null;
23
+ if (plat === "darwin") return `ffmpeg-darwin-${architecture}`;
24
+ if (plat === "linux") return `ffmpeg-linux-${architecture}`;
25
+ if (plat === "win32") return architecture === "x64" ? "ffmpeg-win32-x64" : null;
26
+ return null;
27
+ }
28
+
19
29
  const TOOLS: Record<string, ToolConfig> = {
20
30
  sd: {
21
31
  name: "sd",
@@ -72,6 +82,14 @@ const TOOLS: Record<string, ToolConfig> = {
72
82
  return null;
73
83
  },
74
84
  },
85
+ ffmpeg: {
86
+ name: "ffmpeg",
87
+ repo: "eugeneware/ffmpeg-static",
88
+ binaryName: "ffmpeg",
89
+ tagPrefix: "",
90
+ isDirectBinary: true,
91
+ getAssetName: ffmpegAssetName,
92
+ },
75
93
  };
76
94
 
77
95
  // CLI packages installed via uv/pip
@@ -89,7 +107,7 @@ const PYTHON_TOOLS: Record<string, PythonPackageToolConfig> = {
89
107
  },
90
108
  };
91
109
 
92
- export type ToolName = "sd" | "sg" | "yt-dlp" | "trafilatura";
110
+ export type ToolName = "sd" | "sg" | "yt-dlp" | "trafilatura" | "ffmpeg";
93
111
 
94
112
  // Get the path to a tool (system-wide or in our tools dir)
95
113
  export function getToolPath(tool: ToolName): string | null {
@@ -7,6 +7,7 @@ interface GitHubUrl {
7
7
  | "blob"
8
8
  | "tree"
9
9
  | "repo"
10
+ | "commit"
10
11
  | "issue"
11
12
  | "issues"
12
13
  | "pull"
@@ -56,6 +57,11 @@ export function parseGitHubUrl(url: string): GitHubUrl | null {
56
57
  const [ref, ...pathParts] = subParts;
57
58
  return { type: section, owner, repo, ref, path: pathParts.join("/") };
58
59
  }
60
+ case "commit":
61
+ if (subParts.length > 0 && subParts[0]) {
62
+ return { type: "commit", owner, repo, ref: subParts[0] };
63
+ }
64
+ return { type: "other", owner, repo };
59
65
  case "issues":
60
66
  if (subParts.length > 0 && /^\d+$/.test(subParts[0])) {
61
67
  return { type: "issue", owner, repo, number: parseInt(subParts[0], 10) };
@@ -233,6 +239,87 @@ async function renderGitHubIssue(
233
239
  return { content: md, ok: true };
234
240
  }
235
241
 
242
+ interface GitHubCommitFile {
243
+ filename: string;
244
+ status: string;
245
+ additions: number;
246
+ deletions: number;
247
+ changes: number;
248
+ patch?: string;
249
+ previous_filename?: string;
250
+ }
251
+
252
+ /**
253
+ * Render a GitHub commit (metadata, message, and per-file diff) to markdown.
254
+ *
255
+ * The commits API (`/repos/{owner}/{repo}/commits/{ref}`) returns the full
256
+ * unified diff inline via `files[].patch`, so a single request yields both the
257
+ * summary and the diff. Binary files have no `patch` and are flagged instead.
258
+ */
259
+ async function renderGitHubCommit(
260
+ gh: GitHubUrl,
261
+ timeout: number,
262
+ signal?: AbortSignal,
263
+ ): Promise<{ content: string; ok: boolean }> {
264
+ const result = await fetchGitHubApi(`/repos/${gh.owner}/${gh.repo}/commits/${gh.ref}`, timeout, signal);
265
+ if (!result.ok || !result.data) return { content: "", ok: false };
266
+
267
+ const commit = result.data as {
268
+ sha: string;
269
+ html_url: string;
270
+ commit: {
271
+ author?: { name?: string; date?: string } | null;
272
+ committer?: { name?: string; date?: string } | null;
273
+ message: string;
274
+ };
275
+ author?: { login: string } | null;
276
+ committer?: { login: string } | null;
277
+ parents?: Array<{ sha: string }>;
278
+ stats?: { total?: number; additions?: number; deletions?: number };
279
+ files?: GitHubCommitFile[];
280
+ };
281
+
282
+ const message = commit.commit.message ?? "";
283
+ const [subject, ...bodyLines] = message.split("\n");
284
+ const authorName = commit.author?.login ? `@${commit.author.login}` : (commit.commit.author?.name ?? "unknown");
285
+ const authoredAt = commit.commit.author?.date ?? "";
286
+
287
+ let md = `# ${subject || commit.sha.slice(0, 7)}\n\n`;
288
+ md += `**${commit.sha.slice(0, 12)}** · authored by ${authorName}`;
289
+ if (authoredAt) md += ` · ${authoredAt}`;
290
+ md += `\n`;
291
+ if (commit.stats) {
292
+ const { additions = 0, deletions = 0 } = commit.stats;
293
+ const fileCount = commit.files?.length ?? 0;
294
+ md += `${fileCount} file${fileCount === 1 ? "" : "s"} changed · +${additions} −${deletions}\n`;
295
+ }
296
+ if (commit.parents && commit.parents.length > 0) {
297
+ md += `Parents: ${commit.parents.map(p => p.sha.slice(0, 12)).join(", ")}\n`;
298
+ }
299
+
300
+ const body = bodyLines.join("\n").trim();
301
+ if (body) {
302
+ md += `\n${body}\n`;
303
+ }
304
+
305
+ const files = commit.files ?? [];
306
+ if (files.length > 0) {
307
+ md += `\n---\n\n## Files (${files.length})\n\n`;
308
+ for (const file of files) {
309
+ const name = file.previous_filename ? `${file.previous_filename} → ${file.filename}` : file.filename;
310
+ md += `### ${name}\n\n`;
311
+ md += `${file.status} · +${file.additions} −${file.deletions}\n\n`;
312
+ if (file.patch) {
313
+ md += `\`\`\`diff\n${file.patch}\n\`\`\`\n\n`;
314
+ } else {
315
+ md += `*No textual diff (binary or too large).*\n\n`;
316
+ }
317
+ }
318
+ }
319
+
320
+ return { content: md, ok: true };
321
+ }
322
+
236
323
  /**
237
324
  * Render GitHub issues list to markdown
238
325
  */
@@ -647,6 +734,15 @@ export const handleGitHub: SpecialHandler = async (
647
734
  break;
648
735
  }
649
736
 
737
+ case "commit": {
738
+ notes.push(`Fetched via GitHub API`);
739
+ const result = await renderGitHubCommit(gh, timeout, signal);
740
+ if (result.ok) {
741
+ return buildResult(result.content, { url, method: "github-commit", fetchedAt, notes });
742
+ }
743
+ break;
744
+ }
745
+
650
746
  case "issue":
651
747
  case "pull": {
652
748
  notes.push(`Fetched via GitHub API`);
@@ -115,6 +115,15 @@ function formatForLLM(response: SearchResponse): string {
115
115
  return parts.join("\n");
116
116
  }
117
117
 
118
+ function hasRenderableSearchContent(response: SearchResponse): boolean {
119
+ if (response.answer?.trim()) return true;
120
+ if (response.sources.length > 0) return true;
121
+ if (response.citations?.length) return true;
122
+ if (response.relatedQuestions?.some(question => question.trim())) return true;
123
+ if (response.searchQueries?.some(query => query.trim())) return true;
124
+ return false;
125
+ }
126
+
118
127
  interface ExecuteSearchOptions {
119
128
  authStorage: AuthStorage;
120
129
  sessionId?: string;
@@ -162,6 +171,10 @@ async function executeSearch(
162
171
  sessionId,
163
172
  });
164
173
 
174
+ if (!hasRenderableSearchContent(response)) {
175
+ throw new SearchProviderError(provider.id, `${provider.label} returned no renderable search content.`, 204);
176
+ }
177
+
165
178
  const text = formatForLLM(response);
166
179
 
167
180
  return {
@@ -281,9 +281,21 @@ export async function searchSearXNG(params: {
281
281
  });
282
282
  }
283
283
 
284
+ const limitedSources = sources.slice(0, numResults);
285
+ if (limitedSources.length === 0 && response.unresponsive_engines?.length) {
286
+ const upstreamFailures = response.unresponsive_engines
287
+ .map(([engine, reason]) => `${engine}: ${reason}`)
288
+ .join("; ");
289
+ throw new SearchProviderError(
290
+ "searxng",
291
+ `SearXNG returned no usable results; upstream engines failed: ${upstreamFailures}`,
292
+ 503,
293
+ );
294
+ }
295
+
284
296
  return {
285
297
  provider: "searxng",
286
- sources: sources.slice(0, numResults),
298
+ sources: limitedSources,
287
299
  relatedQuestions: response.suggestions?.length ? response.suggestions : undefined,
288
300
  };
289
301
  }
@@ -1,18 +0,0 @@
1
- export interface STTDependencyStatus {
2
- recorder: {
3
- available: boolean;
4
- tool: string | null;
5
- installHint: string;
6
- };
7
- python: {
8
- available: boolean;
9
- path: string | null;
10
- installHint: string;
11
- };
12
- whisper: {
13
- available: boolean;
14
- installHint: string;
15
- };
16
- }
17
- export declare function checkDependencies(): Promise<STTDependencyStatus>;
18
- export declare function formatDependencyStatus(status: STTDependencyStatus): string;
package/src/stt/setup.ts DELETED
@@ -1,52 +0,0 @@
1
- import { detectRecordingTools } from "./recorder";
2
- import { resolvePython } from "./transcriber";
3
-
4
- const isWindows = process.platform === "win32";
5
-
6
- export interface STTDependencyStatus {
7
- recorder: { available: boolean; tool: string | null; installHint: string };
8
- python: { available: boolean; path: string | null; installHint: string };
9
- whisper: { available: boolean; installHint: string };
10
- }
11
-
12
- export async function checkDependencies(): Promise<STTDependencyStatus> {
13
- const recorderTools = detectRecordingTools();
14
- const recorderHint = isWindows
15
- ? "PowerShell fallback available. For better quality: install SoX or FFmpeg."
16
- : "Install SoX: sudo apt install sox, or FFmpeg: sudo apt install ffmpeg";
17
-
18
- const pythonCmd = resolvePython();
19
- const pythonHint = "Install Python 3.8+ from https://python.org";
20
-
21
- let whisperAvailable = false;
22
- if (pythonCmd) {
23
- const check = Bun.spawnSync([pythonCmd, "-c", "import whisper"], {
24
- stdout: "pipe",
25
- stderr: "pipe",
26
- });
27
- whisperAvailable = check.exitCode === 0;
28
- }
29
- const whisperHint = "Run 'omp setup stt' to auto-install, or: pip install openai-whisper";
30
-
31
- return {
32
- recorder: { available: recorderTools.length > 0, tool: recorderTools[0] ?? null, installHint: recorderHint },
33
- python: { available: pythonCmd !== null, path: pythonCmd, installHint: pythonHint },
34
- whisper: { available: whisperAvailable, installHint: whisperHint },
35
- };
36
- }
37
-
38
- export function formatDependencyStatus(status: STTDependencyStatus): string {
39
- const lines: string[] = ["STT Dependencies:"];
40
- const check = (ok: boolean) => (ok ? "[ok]" : "[missing]");
41
-
42
- lines.push(` Recorder: ${check(status.recorder.available)} ${status.recorder.tool ?? "none"}`);
43
- if (!status.recorder.available) lines.push(` -> ${status.recorder.installHint}`);
44
-
45
- lines.push(` Python: ${check(status.python.available)} ${status.python.path ?? "none"}`);
46
- if (!status.python.available) lines.push(` -> ${status.python.installHint}`);
47
-
48
- lines.push(` Whisper: ${check(status.whisper.available)}`);
49
- if (!status.whisper.available) lines.push(` -> ${status.whisper.installHint}`);
50
-
51
- return lines.join("\n");
52
- }
@@ -1,70 +0,0 @@
1
- """Transcribe a WAV file using openai-whisper.
2
-
3
- Reads WAV directly via Python's wave module (no ffmpeg needed).
4
- Resamples to 16kHz mono float32 and passes to whisper as a numpy array.
5
-
6
- Usage: python transcribe.py <audio.wav> <model_name> <language>
7
- Prints transcribed text to stdout.
8
- """
9
-
10
- import sys
11
- import wave
12
- import re
13
-
14
-
15
- import numpy as np
16
- import whisper
17
-
18
-
19
- def load_wav(path: str) -> np.ndarray:
20
- with wave.open(path, "rb") as wf:
21
- rate = wf.getframerate()
22
- channels = wf.getnchannels()
23
- width = wf.getsampwidth()
24
- n_frames = wf.getnframes()
25
- raw = wf.readframes(n_frames)
26
-
27
- if width == 2:
28
- audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0
29
- elif width == 1:
30
- audio = (np.frombuffer(raw, dtype=np.uint8).astype(np.float32) - 128.0) / 128.0
31
- elif width == 4:
32
- audio = np.frombuffer(raw, dtype=np.int32).astype(np.float32) / 2147483648.0
33
- else:
34
- raise ValueError(f"Unsupported sample width: {width}")
35
-
36
- # Mix to mono
37
- if channels > 1:
38
- audio = audio.reshape(-1, channels).mean(axis=1)
39
-
40
- # Resample to 16 kHz
41
- if rate != 16000:
42
- target_len = int(len(audio) * 16000 / rate)
43
- audio = np.interp(
44
- np.linspace(0, len(audio) - 1, target_len),
45
- np.arange(len(audio)),
46
- audio,
47
- ).astype(np.float32)
48
-
49
- return audio
50
-
51
-
52
- def main() -> None:
53
- if len(sys.argv) < 2:
54
- print("Usage: python transcribe.py <audio.wav> <model_name> <language>", file=sys.stderr)
55
- sys.exit(1)
56
- audio_path = sys.argv[1]
57
- model_name = sys.argv[2] if len(sys.argv) > 2 else "base.en"
58
- language = sys.argv[3] if len(sys.argv) > 3 else "en"
59
- if not re.fullmatch(r"[A-Za-z]{2,3}(-[A-Za-z]{2})?", language):
60
- print(f"Invalid language code: {language}", file=sys.stderr)
61
- sys.exit(1)
62
-
63
- audio = load_wav(audio_path)
64
- model = whisper.load_model(model_name)
65
- result = model.transcribe(audio, language=language)
66
- print(result["text"].strip())
67
-
68
-
69
- if __name__ == "__main__":
70
- main()