@saifymatteo/pi-extensions 0.0.0-stage → 0.1.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 (183) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +151 -2
  4. package/inventory.json +73 -0
  5. package/package.json +74 -4
  6. package/packages/pi-bg-bash/LICENSE +661 -0
  7. package/packages/pi-bg-bash/NOTICE.md +3 -0
  8. package/packages/pi-bg-bash/README.md +41 -0
  9. package/packages/pi-bg-bash/examples/settings.example.json +6 -0
  10. package/packages/pi-bg-bash/package.json +38 -0
  11. package/packages/pi-bg-bash/src/index.ts +969 -0
  12. package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
  13. package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
  14. package/packages/pi-claude-usage/LICENSE +661 -0
  15. package/packages/pi-claude-usage/NOTICE.md +3 -0
  16. package/packages/pi-claude-usage/README.md +27 -0
  17. package/packages/pi-claude-usage/package.json +35 -0
  18. package/packages/pi-claude-usage/src/index.ts +130 -0
  19. package/packages/pi-clipboard-image/LICENSE +661 -0
  20. package/packages/pi-clipboard-image/NOTICE.md +3 -0
  21. package/packages/pi-clipboard-image/README.md +27 -0
  22. package/packages/pi-clipboard-image/package.json +36 -0
  23. package/packages/pi-clipboard-image/src/index.ts +527 -0
  24. package/packages/pi-codeblock-borders/LICENSE +661 -0
  25. package/packages/pi-codeblock-borders/NOTICE.md +3 -0
  26. package/packages/pi-codeblock-borders/README.md +30 -0
  27. package/packages/pi-codeblock-borders/package.json +35 -0
  28. package/packages/pi-codeblock-borders/src/index.ts +302 -0
  29. package/packages/pi-codegraph/LICENSE +661 -0
  30. package/packages/pi-codegraph/NOTICE.md +5 -0
  31. package/packages/pi-codegraph/README.md +35 -0
  32. package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
  33. package/packages/pi-codegraph/package.json +37 -0
  34. package/packages/pi-codegraph/src/index.ts +573 -0
  35. package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
  36. package/packages/pi-compact-tools/LICENSE +661 -0
  37. package/packages/pi-compact-tools/NOTICE.md +3 -0
  38. package/packages/pi-compact-tools/README.md +27 -0
  39. package/packages/pi-compact-tools/package.json +36 -0
  40. package/packages/pi-compact-tools/src/index.ts +1161 -0
  41. package/packages/pi-decisions/LICENSE +661 -0
  42. package/packages/pi-decisions/NOTICE.md +3 -0
  43. package/packages/pi-decisions/README.md +37 -0
  44. package/packages/pi-decisions/examples/service-models.example.json +27 -0
  45. package/packages/pi-decisions/package.json +37 -0
  46. package/packages/pi-decisions/src/index.ts +364 -0
  47. package/packages/pi-decisions/src/lib/service-models.ts +95 -0
  48. package/packages/pi-decisions/test/service-models.test.mjs +71 -0
  49. package/packages/pi-load-pi-dotenv/LICENSE +661 -0
  50. package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
  51. package/packages/pi-load-pi-dotenv/README.md +27 -0
  52. package/packages/pi-load-pi-dotenv/package.json +32 -0
  53. package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
  54. package/packages/pi-model-go/LICENSE +661 -0
  55. package/packages/pi-model-go/NOTICE.md +5 -0
  56. package/packages/pi-model-go/README.md +33 -0
  57. package/packages/pi-model-go/package.json +36 -0
  58. package/packages/pi-model-go/src/index.ts +171 -0
  59. package/packages/pi-model-go/src/lib/dialog.ts +31 -0
  60. package/packages/pi-model-go/src/lib/model-go.ts +239 -0
  61. package/packages/pi-model-go/test/behavior.test.mjs +17 -0
  62. package/packages/pi-prestart-working/LICENSE +661 -0
  63. package/packages/pi-prestart-working/NOTICE.md +3 -0
  64. package/packages/pi-prestart-working/README.md +25 -0
  65. package/packages/pi-prestart-working/package.json +35 -0
  66. package/packages/pi-prestart-working/src/index.ts +86 -0
  67. package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
  68. package/packages/pi-root-guard/LICENSE +661 -0
  69. package/packages/pi-root-guard/NOTICE.md +3 -0
  70. package/packages/pi-root-guard/README.md +27 -0
  71. package/packages/pi-root-guard/package.json +32 -0
  72. package/packages/pi-root-guard/src/index.ts +202 -0
  73. package/packages/pi-session-finder/LICENSE +661 -0
  74. package/packages/pi-session-finder/NOTICE.md +3 -0
  75. package/packages/pi-session-finder/README.md +33 -0
  76. package/packages/pi-session-finder/package.json +36 -0
  77. package/packages/pi-session-finder/src/index.ts +306 -0
  78. package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
  79. package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
  80. package/packages/pi-session-id/LICENSE +661 -0
  81. package/packages/pi-session-id/NOTICE.md +3 -0
  82. package/packages/pi-session-id/README.md +31 -0
  83. package/packages/pi-session-id/package.json +35 -0
  84. package/packages/pi-session-id/src/index.ts +20 -0
  85. package/packages/pi-session-id/test/behavior.test.mjs +25 -0
  86. package/packages/pi-session-nav/LICENSE +661 -0
  87. package/packages/pi-session-nav/NOTICE.md +3 -0
  88. package/packages/pi-session-nav/README.md +32 -0
  89. package/packages/pi-session-nav/package.json +36 -0
  90. package/packages/pi-session-nav/src/index.ts +203 -0
  91. package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
  92. package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
  93. package/packages/pi-session-rename/LICENSE +661 -0
  94. package/packages/pi-session-rename/NOTICE.md +3 -0
  95. package/packages/pi-session-rename/README.md +31 -0
  96. package/packages/pi-session-rename/package.json +35 -0
  97. package/packages/pi-session-rename/src/index.ts +456 -0
  98. package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
  99. package/packages/pi-session-retention/LICENSE +661 -0
  100. package/packages/pi-session-retention/NOTICE.md +3 -0
  101. package/packages/pi-session-retention/README.md +62 -0
  102. package/packages/pi-session-retention/package.json +36 -0
  103. package/packages/pi-session-retention/src/README.md +42 -0
  104. package/packages/pi-session-retention/src/core.ts +333 -0
  105. package/packages/pi-session-retention/src/dialog.ts +118 -0
  106. package/packages/pi-session-retention/src/index.ts +311 -0
  107. package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
  108. package/packages/pi-session-retention/src/safety.ts +50 -0
  109. package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
  110. package/packages/pi-skill-usage-log/LICENSE +661 -0
  111. package/packages/pi-skill-usage-log/NOTICE.md +3 -0
  112. package/packages/pi-skill-usage-log/README.md +32 -0
  113. package/packages/pi-skill-usage-log/package.json +36 -0
  114. package/packages/pi-skill-usage-log/src/index.ts +316 -0
  115. package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
  116. package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
  117. package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
  118. package/packages/pi-tool-usage-log/LICENSE +661 -0
  119. package/packages/pi-tool-usage-log/NOTICE.md +5 -0
  120. package/packages/pi-tool-usage-log/README.md +32 -0
  121. package/packages/pi-tool-usage-log/package.json +36 -0
  122. package/packages/pi-tool-usage-log/src/index.ts +233 -0
  123. package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
  124. package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
  125. package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
  126. package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
  127. package/packages/pi-tts/LICENSE +661 -0
  128. package/packages/pi-tts/NOTICE.md +3 -0
  129. package/packages/pi-tts/README.md +37 -0
  130. package/packages/pi-tts/examples/service-models.example.json +27 -0
  131. package/packages/pi-tts/package.json +37 -0
  132. package/packages/pi-tts/src/index.ts +135 -0
  133. package/packages/pi-tts/src/lib/service-models.ts +95 -0
  134. package/packages/pi-tts/test/tts-output.test.mjs +47 -0
  135. package/packages/pi-tts/test/tts.test.mjs +57 -0
  136. package/packages/pi-user-image-previews/LICENSE +661 -0
  137. package/packages/pi-user-image-previews/NOTICE.md +3 -0
  138. package/packages/pi-user-image-previews/README.md +27 -0
  139. package/packages/pi-user-image-previews/package.json +36 -0
  140. package/packages/pi-user-image-previews/src/index.ts +168 -0
  141. package/packages/pi-vision/LICENSE +661 -0
  142. package/packages/pi-vision/NOTICE.md +5 -0
  143. package/packages/pi-vision/README.md +43 -0
  144. package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
  145. package/packages/pi-vision/package.json +38 -0
  146. package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
  147. package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
  148. package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
  149. package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
  150. package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
  151. package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
  152. package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
  153. package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
  154. package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
  155. package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
  156. package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
  157. package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
  158. package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
  159. package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
  160. package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
  161. package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
  162. package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
  163. package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
  164. package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
  165. package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  166. package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
  167. package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
  168. package/packages/pi-vision/src/index.ts +728 -0
  169. package/packages/pi-vision/test/vision.test.mjs +30 -0
  170. package/packages/pi-wait/LICENSE +661 -0
  171. package/packages/pi-wait/NOTICE.md +3 -0
  172. package/packages/pi-wait/README.md +37 -0
  173. package/packages/pi-wait/package.json +37 -0
  174. package/packages/pi-wait/src/index.ts +88 -0
  175. package/packages/pi-wait/src/lib/async-work.ts +27 -0
  176. package/packages/pi-wait/test/wait.test.mjs +43 -0
  177. package/packages/pi-zvec/LICENSE +661 -0
  178. package/packages/pi-zvec/NOTICE.md +3 -0
  179. package/packages/pi-zvec/README.md +37 -0
  180. package/packages/pi-zvec/examples/zvec.example.json +5 -0
  181. package/packages/pi-zvec/package.json +37 -0
  182. package/packages/pi-zvec/src/index.ts +754 -0
  183. package/packages/pi-zvec/test/zvec.test.mjs +19 -0
@@ -0,0 +1,37 @@
1
+ # @saifymatteo/pi-tts
2
+
3
+ ## Purpose and registrations
4
+
5
+ Registers `text_to_speech` when the registry has a `type: "tts"` model; no slash commands or playback. Sends narration to a service and saves a new WAV, never overwriting an existing output. Uses standard fetch/streams and Pi tool registration; requires filesystem hard-link support for atomic publication.
6
+
7
+ ## Configuration and service
8
+
9
+ Reads the shared `<agent-dir>/extensions/service-models.json` registry and uses every model with `type: "tts"` and `api: "openai-audio-speech"`. A missing registry or no TTS model leaves the tool absent; no automatic config creation. A model may set default `voice` and `language` (requests can override them) and an optional one-line `description`, shown to the agent as a strength hint in the `model` parameter. Opt in by manually creating the registry from `examples/service-models.example.json`.
10
+
11
+ Requires an OpenAI-compatible speech server (POST `audio/speech` relative to the versioned base URL), not an installed CLI. Keys accept `$ENV` / `${ENV}`; omission falls back to agent `auth.json`, explicit `""` permits keyless TTS. Use environment references rather than embedded credentials.
12
+
13
+ ## Security and limits
14
+
15
+ All narration is transmitted to the selected service. Do not send secrets. 120s timeout, no retries/fallback, redirects rejected, 20000 characters/100 MiB maximum. Requires a new `.wav` path with an existing parent; writes private staging files beside output and hard-links atomically. Parent symlinks are not prohibited by the inherited implementation; only request trusted output paths. Output is local and never played automatically.
16
+
17
+ ## Tests
18
+
19
+ From the monorepo root with its existing test dependencies available:
20
+
21
+ ```sh
22
+ node --test packages/pi-tts/test/*.test.mjs
23
+ ```
24
+
25
+ Tests use the shared test-only TypeScript/VM loader, fixtures, and host/service stubs. They do not prove compatibility with a live host, CLI, or model service.
26
+
27
+ ## Running locally
28
+
29
+ From the monorepo root:
30
+
31
+ ```sh
32
+ pi -e ./packages/pi-tts
33
+ ```
34
+
35
+ Node >=24; host imports are peers, not bundled dependencies. Load only one copy: for an isolated session use `--no-extensions` with explicit `-e` paths so the original live extension is not also discovered. Set `PI_CODING_AGENT_DIR` to a disposable agent directory before launch; unset means the normal `~/.pi/agent` fallback. Extension config continues to resolve there, never relative to this package.
36
+
37
+ Examples are manually authored, sanitized reference documents, not runtime defaults. Review and manually create only the needed files in the isolated agent directory; do not copy live credentials or endpoints. No example is automatically loaded or copied. Loading an extension grants Pi-process OS permissions. Licensed AGPL-3.0-only; see [NOTICE.md](NOTICE.md).
@@ -0,0 +1,27 @@
1
+ {
2
+ "providers": {
3
+ "example-services": {
4
+ "baseUrl": "http://localhost:8000/v1",
5
+ "apiKey": "$PI_SERVICE_API_KEY",
6
+ "models": [
7
+ {
8
+ "id": "example-classifier",
9
+ "type": "classifier",
10
+ "api": "openai-decisions",
11
+ "contextWindow": 8192,
12
+ "input": [
13
+ "text",
14
+ "image"
15
+ ],
16
+ "description": "Example strength hint shown to the agent."
17
+ },
18
+ {
19
+ "id": "example-tts",
20
+ "type": "tts",
21
+ "api": "openai-audio-speech",
22
+ "description": "Example strength hint shown to the agent."
23
+ }
24
+ ]
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@saifymatteo/pi-tts",
3
+ "version": "0.1.1",
4
+ "license": "AGPL-3.0-only",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "files": [
10
+ "src",
11
+ "examples",
12
+ "README.md",
13
+ "NOTICE.md",
14
+ "LICENSE"
15
+ ],
16
+ "pi": {
17
+ "extensions": [
18
+ "./src/index.ts"
19
+ ]
20
+ },
21
+ "peerDependencies": {
22
+ "@earendil-works/pi-coding-agent": "*",
23
+ "typebox": "*"
24
+ },
25
+ "engines": {
26
+ "node": ">=24"
27
+ },
28
+ "description": "Generate WAV files through an OpenAI-compatible speech endpoint.",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/saifymatteo/pi-extensions.git",
35
+ "directory": "packages/pi-tts"
36
+ }
37
+ }
@@ -0,0 +1,135 @@
1
+ /** Generate WAV files through an OpenAI-compatible speech endpoint; never play audio. */
2
+ import { loadServiceConfig, serviceApiKey, modelOptions } from './lib/service-models'
3
+ import { link, lstat, mkdtemp, open, rm, stat } from 'node:fs/promises'
4
+ import { dirname, extname, join, resolve } from 'node:path'
5
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
6
+ import { Type } from 'typebox'
7
+
8
+ const MAX_AUDIO_BYTES = 100 * 1024 * 1024
9
+ const MAX_TEXT_LENGTH = 20_000
10
+
11
+ async function readAudio(response: Response, signal: AbortSignal): Promise<Buffer> {
12
+ if (Number(response.headers.get('content-length')) > MAX_AUDIO_BYTES) {
13
+ await response.body?.cancel().catch(() => {})
14
+ throw new Error('TTS audio exceeds 100 MiB')
15
+ }
16
+ if (!response.body) throw new Error('TTS API returned no audio')
17
+ const reader = response.body.getReader()
18
+ // Also interrupt non-fetch/custom streams that do not observe the request signal.
19
+ const abort = () => { void reader.cancel(signal.reason).catch(() => {}) }
20
+ signal.addEventListener('abort', abort, { once: true })
21
+ const chunks: Uint8Array[] = []
22
+ let size = 0
23
+ try {
24
+ while (true) {
25
+ signal.throwIfAborted()
26
+ const { done, value } = await reader.read()
27
+ if (done) break
28
+ size += value.byteLength
29
+ if (size > MAX_AUDIO_BYTES) throw new Error('TTS audio exceeds 100 MiB')
30
+ chunks.push(value)
31
+ }
32
+ } finally {
33
+ signal.removeEventListener('abort', abort)
34
+ await reader.cancel().catch(() => {})
35
+ reader.releaseLock()
36
+ }
37
+ signal.throwIfAborted()
38
+ const audio = Buffer.concat(chunks, size)
39
+ if (audio.length < 44 || audio.toString('ascii', 0, 4) !== 'RIFF' || audio.toString('ascii', 8, 12) !== 'WAVE' || audio.readUInt32LE(4) !== audio.length - 8) {
40
+ throw new Error('TTS API did not return a WAV file')
41
+ }
42
+ return audio
43
+ }
44
+
45
+ export default function ttsExtension(pi: ExtensionAPI) {
46
+ const piDir = process.env.PI_CODING_AGENT_DIR || join(process.env.HOME || process.env.USERPROFILE || '', '.pi', 'agent')
47
+ const config = loadServiceConfig(piDir, 'tts')
48
+ if (!config) return
49
+ const models = Object.entries(config.providers).flatMap(([provider, cfg]) =>
50
+ cfg.models.map(model => ({ spec: `${provider}/${model.id}`, provider, config: cfg, model })))
51
+
52
+ pi.registerTool({
53
+ name: 'text_to_speech',
54
+ label: 'Text to speech',
55
+ description: 'Generate a local WAV audio file from narration using a configured OpenAI-compatible TTS server. Sends the supplied text to that server. Never plays audio or overwrites files. 120-second timeout; no retries or fallback. Maximum 20,000 characters and 100 MiB output.',
56
+ promptSnippet: 'Generate spoken narration as a WAV file with a configured TTS model',
57
+ promptGuidelines: [
58
+ 'Use text_to_speech when the user requests spoken narration or an audio file; do not generate or play audio automatically.',
59
+ 'Write listener-friendly narration rather than reading Markdown syntax or code verbatim, unless the user requests exact text. Return the saved file path.',
60
+ 'Use only models configured in service-models.json (type tts). Do not send secrets or silently switch providers after a failure.',
61
+ ],
62
+ parameters: Type.Object({
63
+ text: Type.String({ minLength: 1, maxLength: MAX_TEXT_LENGTH, description: 'Narration to synthesize.' }),
64
+ output_file: Type.String({ minLength: 1, description: 'New .wav file, relative to cwd or absolute. Parent directory must exist. Existing files are never overwritten.' }),
65
+ model: Type.Optional(Type.String({ description: `Configured provider/model-id; required when several are configured. Pick by strength:${modelOptions(config)}` })),
66
+ voice: Type.Optional(Type.String({ minLength: 1, description: 'Voice supported by the selected model; otherwise uses the configured/server default.' })),
67
+ language: Type.Optional(Type.String({ minLength: 1, description: 'Language supported by the selected model; otherwise uses the configured/server default.' })),
68
+ }, { additionalProperties: false }),
69
+ outputSchema: Type.Object({ provider: Type.String(), model: Type.String(), output_file: Type.String(), bytes: Type.Number(), format: Type.Literal('wav') }),
70
+ async execute(_id, params, signal, _onUpdate, ctx) {
71
+ signal?.throwIfAborted()
72
+ if (typeof params.text !== 'string' || !params.text.trim() || params.text.length > MAX_TEXT_LENGTH) throw new Error('Narration must contain 1..20000 characters')
73
+ if (typeof params.output_file !== 'string' || !params.output_file.trim() || params.output_file.includes('\0') || extname(params.output_file).toLowerCase() !== '.wav') throw new Error('output_file must be a new .wav file')
74
+ for (const value of [params.voice, params.language]) {
75
+ if (value !== undefined && (typeof value !== 'string' || !value.trim())) throw new Error('voice and language must be non-empty strings')
76
+ }
77
+ const spec = params.model ?? (models.length === 1 ? models[0].spec : undefined)
78
+ const selected = models.find(m => m.spec === spec)
79
+ if (!selected) throw new Error(`Specify a model configured in service-models.json (type tts). Options:${modelOptions(config)}`)
80
+ const output = resolve(ctx.cwd, params.output_file.startsWith('~/') ? join(process.env.HOME || process.env.USERPROFILE || '', params.output_file.slice(2)) : params.output_file)
81
+ try {
82
+ await lstat(output)
83
+ throw new Error('output_file already exists; choose a new path')
84
+ } catch (error: any) {
85
+ if (error.code !== 'ENOENT') throw error
86
+ }
87
+ // Fail before a potentially expensive synthesis request if the parent is absent.
88
+ const parent = await stat(dirname(output))
89
+ if (!parent.isDirectory()) throw new Error('output_file parent must be a directory')
90
+ const key = serviceApiKey(piDir, selected.provider, selected.config.apiKey)
91
+ const requestSignal = AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(120_000)])
92
+ try {
93
+ const response = await fetch(new URL('audio/speech', `${selected.config.baseUrl.replace(/\/+$/, '')}/`), {
94
+ method: 'POST',
95
+ headers: { 'Content-Type': 'application/json', ...(key ? { Authorization: `Bearer ${key}` } : {}) },
96
+ body: JSON.stringify({
97
+ model: selected.model.id,
98
+ input: params.text,
99
+ response_format: 'wav',
100
+ voice: params.voice ?? selected.model.voice,
101
+ language: params.language ?? selected.model.language,
102
+ }),
103
+ signal: requestSignal,
104
+ redirect: 'error',
105
+ })
106
+ // Do not echo server response bodies: they may contain credentials or narration.
107
+ if (!response.ok) {
108
+ await response.body?.cancel().catch(() => {})
109
+ throw new Error(`TTS API returned HTTP ${response.status}`)
110
+ }
111
+ const audio = await readAudio(response, requestSignal)
112
+ requestSignal.throwIfAborted()
113
+ // Stage privately on the same filesystem, then publish atomically without replacing anything.
114
+ // Cleanup never touches output: it may have been replaced by another process.
115
+ const staging = await mkdtemp(join(dirname(output), '.pi-tts-'))
116
+ try {
117
+ const staged = join(staging, 'audio.wav')
118
+ const file = await open(staged, 'wx', 0o600)
119
+ try { await file.writeFile(audio, { signal: requestSignal }) } finally { await file.close() }
120
+ requestSignal.throwIfAborted()
121
+ await link(staged, output)
122
+ } finally {
123
+ await rm(staging, { recursive: true, force: true }).catch(() => {})
124
+ }
125
+ const result = { provider: selected.provider, model: selected.model.id, output_file: output, bytes: audio.length, format: 'wav' as const }
126
+ return { content: [{ type: 'text', text: JSON.stringify(result) }], details: result, structuredContent: result }
127
+ } catch (error: any) {
128
+ if (signal?.aborted) throw new Error('TTS generation aborted')
129
+ if (requestSignal.aborted && requestSignal.reason?.name === 'TimeoutError') throw new Error('TTS request timed out after 120000ms')
130
+ const message = error.message || String(error)
131
+ throw new Error((key ? message.replaceAll(key, '<redacted>') : message).slice(0, 1100))
132
+ }
133
+ },
134
+ })
135
+ }
@@ -0,0 +1,95 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ export interface ServiceModel {
5
+ id: string
6
+ type: 'classifier' | 'tts'
7
+ api: 'openai-decisions' | 'openai-audio-speech'
8
+ name?: string
9
+ /** One-line strength hint shown in the tool's model options. */
10
+ description?: string
11
+ contextWindow?: number
12
+ input?: ('text' | 'image')[]
13
+ cost?: { input: number; output: number; cacheRead?: number; cacheWrite?: number }
14
+ voice?: string
15
+ language?: string
16
+ }
17
+ export interface ServiceProvider {
18
+ baseUrl: string
19
+ apiKey?: string
20
+ models: ServiceModel[]
21
+ }
22
+ const object = (value: any): value is Record<string, any> => !!value && typeof value === 'object' && !Array.isArray(value)
23
+ const text = (value: any): value is string => typeof value === 'string' && !!value.trim()
24
+
25
+ /** Each extension uses every registry model of its `type`; a missing registry or no matching models disables the tool. */
26
+ export function loadServiceConfig(piDir: string, type: ServiceModel['type']): { providers: Record<string, ServiceProvider> } | undefined {
27
+ const path = join(piDir, 'extensions', 'service-models.json')
28
+ if (!existsSync(path)) return undefined
29
+ try {
30
+ let registry: any
31
+ try { registry = JSON.parse(readFileSync(path, 'utf8')) }
32
+ catch { throw new Error('Malformed JSON') }
33
+ if (!object(registry) || !object(registry.providers)) throw new Error('requires a providers object')
34
+ const providers: Record<string, ServiceProvider> = Object.create(null)
35
+ for (const [id, provider] of Object.entries(registry.providers)) {
36
+ if (!text(id) || id.includes('/') || !object(provider) || !text(provider.baseUrl) ||
37
+ (provider.apiKey !== undefined && typeof provider.apiKey !== 'string') || !Array.isArray(provider.models)) {
38
+ throw new Error(`Invalid provider: ${id}`)
39
+ }
40
+ let url: URL
41
+ try { url = new URL(provider.baseUrl) } catch { throw new Error(`Invalid baseUrl for provider: ${id}`) }
42
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error(`Invalid baseUrl for provider: ${id}`)
43
+ const ids = new Set<string>()
44
+ for (const model of provider.models) {
45
+ if (!object(model) || !text(model.id) || ids.has(model.id) ||
46
+ !['tts', 'classifier'].includes(model.type) || model.api !== (model.type === 'tts' ? 'openai-audio-speech' : 'openai-decisions') ||
47
+ (model.name !== undefined && !text(model.name)) ||
48
+ (model.description !== undefined && !text(model.description)) ||
49
+ ['voice', 'language'].some(key => model[key] !== undefined && (model.type !== 'tts' || !text(model[key]))) ||
50
+ (model.type === 'classifier' && (model.contextWindow === undefined || model.input === undefined)) ||
51
+ (model.contextWindow !== undefined && (!Number.isInteger(model.contextWindow) || model.contextWindow <= 0)) ||
52
+ (model.input !== undefined && (!Array.isArray(model.input) || !model.input.length ||
53
+ new Set(model.input).size !== model.input.length || model.input.some((input: any) => !['text', 'image'].includes(input)))) ||
54
+ (model.cost !== undefined && (!object(model.cost) || !['input', 'output'].every(key => typeof model.cost[key] === 'number') ||
55
+ Object.entries(model.cost).some(([key, value]) => !['input', 'output', 'cacheRead', 'cacheWrite'].includes(key) || typeof value !== 'number' || !Number.isFinite(value) || value < 0)))) {
56
+ throw new Error(`Invalid or duplicate model in provider: ${id}`)
57
+ }
58
+ ids.add(model.id)
59
+ if (model.type !== type) continue
60
+ providers[id] ??= { baseUrl: provider.baseUrl, ...(provider.apiKey !== undefined ? { apiKey: provider.apiKey } : {}), models: [] }
61
+ providers[id].models.push({ ...model } as ServiceModel)
62
+ }
63
+ }
64
+ return Object.keys(providers).length ? { providers } : undefined
65
+ } catch (error: any) {
66
+ // Never echo JSON parser messages, URLs or credential values from configuration.
67
+ throw new Error(`Failed to load service-models.json: ${error.message}`)
68
+ }
69
+ }
70
+
71
+ /** Lists each `provider/model-id` with its description, for a tool's model parameter. */
72
+ export function modelOptions(config: { providers: Record<string, ServiceProvider> }): string {
73
+ return Object.entries(config.providers).flatMap(([provider, cfg]) =>
74
+ cfg.models.map(model => `\n- ${provider}/${model.id}${model.description ? `: ${model.description}` : ''}`)).join('')
75
+ }
76
+
77
+ export function configuredApiKey(value: string | undefined): string | undefined {
78
+ if (value === undefined) return undefined
79
+ const envName = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(value)?.[1]
80
+ ?? /^\$\{([A-Za-z_][A-Za-z0-9_]*)\}$/.exec(value)?.[1]
81
+ return envName ? process.env[envName] : value
82
+ }
83
+
84
+ export function serviceApiKey(piDir: string, id: string, configured: string | undefined): string {
85
+ if (configured !== undefined) {
86
+ const key = configuredApiKey(configured)
87
+ if (key === undefined || (configured !== '' && !key)) throw new Error('No API key: Missing API key environment variable for configured service provider')
88
+ return key
89
+ }
90
+ let auth: any
91
+ try { auth = JSON.parse(readFileSync(join(piDir, 'auth.json'), 'utf8')) } catch {}
92
+ const key = auth?.[id]?.key ?? auth?.[id]?.access
93
+ if (typeof key !== 'string' || !key) throw new Error(`No API key configured for provider "${id}"; use auth.json or service-models.json apiKey ("" for keyless access)`)
94
+ return key
95
+ }
@@ -0,0 +1,47 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { loadExtension } from '../../../test/load-extension.mjs';
8
+
9
+ test('TTS saves mock WAV atomically and does not replace existing output', async t => {
10
+ const dir = mkdtempSync(join(tmpdir(), 'pi-tts-output-test-'));
11
+ mkdirSync(join(dir, 'extensions'));
12
+ const old = process.env.PI_CODING_AGENT_DIR;
13
+ process.env.PI_CODING_AGENT_DIR = dir;
14
+ t.after(() => {
15
+ if (old === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = old;
16
+ rmSync(dir, { recursive: true, force: true });
17
+ });
18
+ writeFileSync(join(dir, 'extensions', 'service-models.json'), JSON.stringify({ providers: { local: {
19
+ baseUrl: 'http://localhost:8000/v1', apiKey: '', models: [{ id: 'voice', type: 'tts', api: 'openai-audio-speech' }],
20
+ } } }));
21
+ const wav = Buffer.alloc(44);
22
+ wav.write('RIFF'); wav.writeUInt32LE(36, 4); wav.write('WAVE', 8);
23
+ let requests = 0;
24
+ const fetchStub = async (url, options) => {
25
+ requests++;
26
+ assert.equal(String(url), 'http://localhost:8000/v1/audio/speech');
27
+ assert.equal(options.redirect, 'error');
28
+ assert.equal(options.headers.Authorization, undefined);
29
+ assert.equal(JSON.parse(options.body).input, 'fixture narration');
30
+ let sent = false;
31
+ return { ok: true, headers: { get: () => '44' }, body: { getReader: () => ({
32
+ read: async () => { if (sent) return { done: true }; sent = true; return { done: false, value: wav }; },
33
+ cancel: async () => {}, releaseLock() {},
34
+ }) } };
35
+ };
36
+ const Type = new Proxy({}, { get: () => () => ({}) });
37
+ const { default: extension } = loadExtension(fileURLToPath(new URL('../src/index.ts', import.meta.url)), { typebox: { Type } }, { fetch: fetchStub });
38
+ let tool;
39
+ extension({ registerTool: value => { tool = value; } });
40
+ const params = { text: 'fixture narration', output_file: 'result.wav' };
41
+ const run = () => tool.execute('fixture', params, undefined, undefined, { cwd: dir });
42
+ const result = await run();
43
+ assert.equal(result.structuredContent.bytes, 44);
44
+ assert.equal(readFileSync(join(dir, 'result.wav')).equals(wav), true);
45
+ await assert.rejects(run(), /already exists/);
46
+ assert.equal(requests, 1);
47
+ });
@@ -0,0 +1,57 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { loadExtension } from '../../../test/load-extension.mjs';
8
+
9
+ const schema = new Proxy({}, { get: () => (..._args) => ({}) });
10
+ function setup(t) {
11
+ const dir = mkdtempSync(join(tmpdir(), 'pi-tts-test-'));
12
+ mkdirSync(join(dir, 'extensions'));
13
+ const old = process.env.PI_CODING_AGENT_DIR;
14
+ process.env.PI_CODING_AGENT_DIR = dir;
15
+ t.after(() => {
16
+ if (old === undefined) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = old;
17
+ rmSync(dir, { recursive: true, force: true });
18
+ });
19
+ const save = (name, data) => writeFileSync(join(dir, 'extensions', name), JSON.stringify(data));
20
+ save('service-models.json', { providers: { local: { baseUrl: 'http://localhost:8000/v1', apiKey: '', models: [
21
+ { id: 'voice', type: 'tts', api: 'openai-audio-speech' },
22
+ ] } } });
23
+ return { dir, save };
24
+ }
25
+
26
+ const registry = (model = {}) => ({ providers: { local: { baseUrl: 'http://localhost:8000/v1', apiKey: '', models: [
27
+ { id: 'voice', type: 'tts', api: 'openai-audio-speech', ...model },
28
+ ] } } });
29
+
30
+ test('TTS registry model carries voice/language defaults; empty values are rejected', t => {
31
+ const { dir, save } = setup(t);
32
+ const { loadServiceConfig } = loadExtension(fileURLToPath(new URL('../src/lib/service-models.ts', import.meta.url)));
33
+ save('service-models.json', registry({ voice: 'example', language: 'en' }));
34
+ const model = loadServiceConfig(dir, 'tts').providers.local.models[0];
35
+ assert.equal(model.voice, 'example');
36
+ assert.equal(model.language, 'en');
37
+ save('service-models.json', registry({ voice: '' }));
38
+ assert.throws(() => loadServiceConfig(dir, 'tts'), /Invalid or duplicate model/);
39
+ });
40
+
41
+ test('TTS tool stays absent without a tts registry model; validation rejects invalid and existing outputs before request', async t => {
42
+ const { dir, save } = setup(t);
43
+ const tools = [];
44
+ const { default: extension } = loadExtension(fileURLToPath(new URL('../src/index.ts', import.meta.url)), { typebox: { Type: schema } });
45
+ save('service-models.json', { providers: {} });
46
+ extension({ registerTool: tool => tools.push(tool) });
47
+ assert.equal(tools.length, 0);
48
+ save('service-models.json', registry());
49
+ extension({ registerTool: tool => tools.push(tool) });
50
+ assert.equal(tools[0].name, 'text_to_speech');
51
+ const run = params => tools[0].execute('test', params, undefined, undefined, { cwd: dir });
52
+ await assert.rejects(run({ text: '', output_file: 'out.wav' }), /Narration/);
53
+ await assert.rejects(run({ text: 'hello', output_file: 'out.mp3' }), /new .wav/);
54
+ await assert.rejects(run({ text: 'hello', output_file: 'out.wav', model: 'other/voice' }), /Specify a model/);
55
+ writeFileSync(join(dir, 'exists.wav'), 'unchanged');
56
+ await assert.rejects(run({ text: 'hello', output_file: 'exists.wav' }), /already exists/);
57
+ });