@fleetagent/pi-coding-agent 0.0.6 → 0.0.7

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 (153) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli/file-processor.d.ts.map +1 -1
  3. package/dist/cli/file-processor.js +2 -3
  4. package/dist/cli/file-processor.js.map +1 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +15 -2
  7. package/dist/config.js.map +1 -1
  8. package/dist/core/agent-session.d.ts +9 -0
  9. package/dist/core/agent-session.d.ts.map +1 -1
  10. package/dist/core/agent-session.js +86 -15
  11. package/dist/core/agent-session.js.map +1 -1
  12. package/dist/core/export-html/template.js +6 -3
  13. package/dist/core/extensions/runner.d.ts +1 -1
  14. package/dist/core/extensions/runner.d.ts.map +1 -1
  15. package/dist/core/extensions/runner.js +8 -2
  16. package/dist/core/extensions/runner.js.map +1 -1
  17. package/dist/core/extensions/types.d.ts +4 -2
  18. package/dist/core/extensions/types.d.ts.map +1 -1
  19. package/dist/core/extensions/types.js.map +1 -1
  20. package/dist/core/model-registry.d.ts.map +1 -1
  21. package/dist/core/model-registry.js +65 -13
  22. package/dist/core/model-registry.js.map +1 -1
  23. package/dist/core/output-guard.d.ts +1 -0
  24. package/dist/core/output-guard.d.ts.map +1 -1
  25. package/dist/core/output-guard.js +52 -22
  26. package/dist/core/output-guard.js.map +1 -1
  27. package/dist/core/package-manager.d.ts.map +1 -1
  28. package/dist/core/package-manager.js +31 -12
  29. package/dist/core/package-manager.js.map +1 -1
  30. package/dist/core/pi-agent.d.ts.map +1 -1
  31. package/dist/core/pi-agent.js +12 -3
  32. package/dist/core/pi-agent.js.map +1 -1
  33. package/dist/core/resolve-config-value.d.ts +9 -1
  34. package/dist/core/resolve-config-value.d.ts.map +1 -1
  35. package/dist/core/resolve-config-value.js +134 -11
  36. package/dist/core/resolve-config-value.js.map +1 -1
  37. package/dist/core/session/jsonl-helpers.d.ts +2 -1
  38. package/dist/core/session/jsonl-helpers.d.ts.map +1 -1
  39. package/dist/core/session/jsonl-helpers.js +6 -3
  40. package/dist/core/session/jsonl-helpers.js.map +1 -1
  41. package/dist/core/session/local-session-manager.d.ts +1 -0
  42. package/dist/core/session/local-session-manager.d.ts.map +1 -1
  43. package/dist/core/session/local-session-manager.js +12 -4
  44. package/dist/core/session/local-session-manager.js.map +1 -1
  45. package/dist/core/session/session-manager.d.ts +1 -0
  46. package/dist/core/session/session-manager.d.ts.map +1 -1
  47. package/dist/core/session/session-manager.js.map +1 -1
  48. package/dist/core/session/stores/jsonl-session-store.d.ts +2 -1
  49. package/dist/core/session/stores/jsonl-session-store.d.ts.map +1 -1
  50. package/dist/core/session/stores/jsonl-session-store.js +105 -78
  51. package/dist/core/session/stores/jsonl-session-store.js.map +1 -1
  52. package/dist/core/settings-manager.d.ts +2 -0
  53. package/dist/core/settings-manager.d.ts.map +1 -1
  54. package/dist/core/settings-manager.js +14 -9
  55. package/dist/core/settings-manager.js.map +1 -1
  56. package/dist/core/tools/bash.d.ts.map +1 -1
  57. package/dist/core/tools/bash.js +73 -63
  58. package/dist/core/tools/bash.js.map +1 -1
  59. package/dist/core/tools/edit.d.ts.map +1 -1
  60. package/dist/core/tools/edit.js +45 -76
  61. package/dist/core/tools/edit.js.map +1 -1
  62. package/dist/core/tools/file-mutation-queue.d.ts.map +1 -1
  63. package/dist/core/tools/file-mutation-queue.js +27 -12
  64. package/dist/core/tools/file-mutation-queue.js.map +1 -1
  65. package/dist/core/tools/find.d.ts.map +1 -1
  66. package/dist/core/tools/find.js +11 -2
  67. package/dist/core/tools/find.js.map +1 -1
  68. package/dist/core/tools/grep.d.ts.map +1 -1
  69. package/dist/core/tools/grep.js +3 -3
  70. package/dist/core/tools/grep.js.map +1 -1
  71. package/dist/core/tools/ls.d.ts.map +1 -1
  72. package/dist/core/tools/ls.js +13 -4
  73. package/dist/core/tools/ls.js.map +1 -1
  74. package/dist/core/tools/path-utils.d.ts +1 -0
  75. package/dist/core/tools/path-utils.d.ts.map +1 -1
  76. package/dist/core/tools/path-utils.js +37 -0
  77. package/dist/core/tools/path-utils.js.map +1 -1
  78. package/dist/core/tools/read.d.ts.map +1 -1
  79. package/dist/core/tools/read.js +7 -6
  80. package/dist/core/tools/read.js.map +1 -1
  81. package/dist/core/tools/write.d.ts.map +1 -1
  82. package/dist/core/tools/write.js +24 -32
  83. package/dist/core/tools/write.js.map +1 -1
  84. package/dist/main.d.ts.map +1 -1
  85. package/dist/main.js +3 -2
  86. package/dist/main.js.map +1 -1
  87. package/dist/migrations.d.ts.map +1 -1
  88. package/dist/migrations.js +118 -1
  89. package/dist/migrations.js.map +1 -1
  90. package/dist/modes/interactive/components/footer.d.ts +1 -0
  91. package/dist/modes/interactive/components/footer.d.ts.map +1 -1
  92. package/dist/modes/interactive/components/footer.js +14 -5
  93. package/dist/modes/interactive/components/footer.js.map +1 -1
  94. package/dist/modes/interactive/components/user-message.d.ts.map +1 -1
  95. package/dist/modes/interactive/components/user-message.js +1 -1
  96. package/dist/modes/interactive/components/user-message.js.map +1 -1
  97. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  98. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  99. package/dist/modes/interactive/interactive-mode.js +30 -5
  100. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  101. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  102. package/dist/modes/interactive/theme/theme.js +10 -0
  103. package/dist/modes/interactive/theme/theme.js.map +1 -1
  104. package/dist/modes/rpc/rpc-client.d.ts +3 -0
  105. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  106. package/dist/modes/rpc/rpc-client.js +64 -7
  107. package/dist/modes/rpc/rpc-client.js.map +1 -1
  108. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  109. package/dist/modes/rpc/rpc-mode.js +15 -3
  110. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  111. package/dist/utils/clipboard-native.d.ts +3 -1
  112. package/dist/utils/clipboard-native.d.ts.map +1 -1
  113. package/dist/utils/clipboard-native.js +14 -8
  114. package/dist/utils/clipboard-native.js.map +1 -1
  115. package/dist/utils/deprecation.d.ts +4 -0
  116. package/dist/utils/deprecation.d.ts.map +1 -0
  117. package/dist/utils/deprecation.js +13 -0
  118. package/dist/utils/deprecation.js.map +1 -0
  119. package/dist/utils/image-resize-core.d.ts +30 -0
  120. package/dist/utils/image-resize-core.d.ts.map +1 -0
  121. package/dist/utils/image-resize-core.js +124 -0
  122. package/dist/utils/image-resize-core.js.map +1 -0
  123. package/dist/utils/image-resize-worker.d.ts +2 -0
  124. package/dist/utils/image-resize-worker.d.ts.map +1 -0
  125. package/dist/utils/image-resize-worker.js +31 -0
  126. package/dist/utils/image-resize-worker.js.map +1 -0
  127. package/dist/utils/image-resize.d.ts +6 -27
  128. package/dist/utils/image-resize.d.ts.map +1 -1
  129. package/dist/utils/image-resize.js +60 -116
  130. package/dist/utils/image-resize.js.map +1 -1
  131. package/dist/utils/json.d.ts +3 -0
  132. package/dist/utils/json.d.ts.map +1 -0
  133. package/dist/utils/json.js +7 -0
  134. package/dist/utils/json.js.map +1 -0
  135. package/docs/custom-provider.md +22 -9
  136. package/docs/extensions.md +4 -3
  137. package/docs/models.md +34 -12
  138. package/docs/packages.md +5 -4
  139. package/docs/providers.md +13 -5
  140. package/docs/sdk.md +56 -0
  141. package/docs/settings.md +3 -1
  142. package/docs/terminal-setup.md +6 -0
  143. package/docs/usage.md +2 -2
  144. package/examples/extensions/README.md +1 -0
  145. package/examples/extensions/custom-provider-anthropic/index.ts +1 -1
  146. package/examples/extensions/custom-provider-anthropic/package.json +1 -1
  147. package/examples/extensions/custom-provider-gitlab-duo/index.ts +54 -3
  148. package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
  149. package/examples/extensions/git-merge-and-resolve.ts +115 -0
  150. package/examples/extensions/sandbox/package.json +1 -1
  151. package/examples/extensions/with-deps/package.json +1 -1
  152. package/npm-shrinkwrap.json +13 -12
  153. package/package.json +5 -5
@@ -1 +1 @@
1
- {"version":3,"file":"image-resize.d.ts","sourceRoot":"","sources":["../../src/utils/image-resize.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAItD,MAAM,WAAW,kBAAkB;IAClC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,YAAY;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,aAAa,EAAE,MAAM,CAAC;IACtB,cAAc,EAAE,MAAM,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,OAAO,CAAC;CACpB;AA2BD;;;;;;;;;;;;GAYG;AACH,wBAAsB,WAAW,CAAC,GAAG,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAuG/G;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,GAAG,SAAS,CAO5E","sourcesContent":["import type { ImageContent } from \"@fleetagent/pi-ai\";\nimport { applyExifOrientation } from \"./exif-orientation.ts\";\nimport { loadPhoton } from \"./photon.ts\";\n\nexport interface ImageResizeOptions {\n\tmaxWidth?: number; // Default: 2000\n\tmaxHeight?: number; // Default: 2000\n\tmaxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)\n\tjpegQuality?: number; // Default: 80\n}\n\nexport interface ResizedImage {\n\tdata: string; // base64\n\tmimeType: string;\n\toriginalWidth: number;\n\toriginalHeight: number;\n\twidth: number;\n\theight: number;\n\twasResized: boolean;\n}\n\n// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.\nconst DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;\n\nconst DEFAULT_OPTIONS: Required<ImageResizeOptions> = {\n\tmaxWidth: 2000,\n\tmaxHeight: 2000,\n\tmaxBytes: DEFAULT_MAX_BYTES,\n\tjpegQuality: 80,\n};\n\ninterface EncodedCandidate {\n\tdata: string;\n\tencodedSize: number;\n\tmimeType: string;\n}\n\nfunction encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {\n\tconst data = Buffer.from(buffer).toString(\"base64\");\n\treturn {\n\t\tdata,\n\t\tencodedSize: Buffer.byteLength(data, \"utf-8\"),\n\t\tmimeType,\n\t};\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and encoded file size.\n * Returns null if the image cannot be resized below maxBytes.\n *\n * Uses Photon (Rust/WASM) for image processing. If Photon is not available,\n * returns null.\n *\n * Strategy for staying under maxBytes:\n * 1. First resize to maxWidth/maxHeight\n * 2. Try both PNG and JPEG formats, pick the smaller one\n * 3. If still too large, try JPEG with decreasing quality\n * 4. If still too large, progressively reduce dimensions until 1x1\n */\nexport async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage | null> {\n\tconst opts = { ...DEFAULT_OPTIONS, ...options };\n\tconst inputBuffer = Buffer.from(img.data, \"base64\");\n\tconst inputBase64Size = Buffer.byteLength(img.data, \"utf-8\");\n\n\tconst photon = await loadPhoton();\n\tif (!photon) {\n\t\treturn null;\n\t}\n\n\tlet image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;\n\ttry {\n\t\tconst inputBytes = new Uint8Array(inputBuffer);\n\t\tconst rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);\n\t\timage = applyExifOrientation(photon, rawImage, inputBytes);\n\t\tif (image !== rawImage) rawImage.free();\n\n\t\tconst originalWidth = image.get_width();\n\t\tconst originalHeight = image.get_height();\n\t\tconst format = img.mimeType?.split(\"/\")[1] ?? \"png\";\n\n\t\t// Check if already within all limits (dimensions AND encoded size)\n\t\tif (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: img.data,\n\t\t\t\tmimeType: img.mimeType ?? `image/${format}`,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: originalWidth,\n\t\t\t\theight: originalHeight,\n\t\t\t\twasResized: false,\n\t\t\t};\n\t\t}\n\n\t\t// Calculate initial dimensions respecting max limits\n\t\tlet targetWidth = originalWidth;\n\t\tlet targetHeight = originalHeight;\n\n\t\tif (targetWidth > opts.maxWidth) {\n\t\t\ttargetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);\n\t\t\ttargetWidth = opts.maxWidth;\n\t\t}\n\t\tif (targetHeight > opts.maxHeight) {\n\t\t\ttargetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);\n\t\t\ttargetHeight = opts.maxHeight;\n\t\t}\n\n\t\tfunction tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {\n\t\t\tconst resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);\n\n\t\t\ttry {\n\t\t\t\tconst candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), \"image/png\")];\n\t\t\t\tfor (const quality of jpegQualities) {\n\t\t\t\t\tcandidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), \"image/jpeg\"));\n\t\t\t\t}\n\t\t\t\treturn candidates;\n\t\t\t} finally {\n\t\t\t\tresized.free();\n\t\t\t}\n\t\t}\n\n\t\tconst qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));\n\t\tlet currentWidth = targetWidth;\n\t\tlet currentHeight = targetHeight;\n\n\t\twhile (true) {\n\t\t\tconst candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);\n\t\t\tfor (const candidate of candidates) {\n\t\t\t\tif (candidate.encodedSize < opts.maxBytes) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdata: candidate.data,\n\t\t\t\t\t\tmimeType: candidate.mimeType,\n\t\t\t\t\t\toriginalWidth,\n\t\t\t\t\t\toriginalHeight,\n\t\t\t\t\t\twidth: currentWidth,\n\t\t\t\t\t\theight: currentHeight,\n\t\t\t\t\t\twasResized: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (currentWidth === 1 && currentHeight === 1) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tconst nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));\n\t\t\tconst nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));\n\t\t\tif (nextWidth === currentWidth && nextHeight === currentHeight) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcurrentWidth = nextWidth;\n\t\t\tcurrentHeight = nextHeight;\n\t\t}\n\n\t\treturn null;\n\t} catch {\n\t\treturn null;\n\t} finally {\n\t\tif (image) {\n\t\t\timage.free();\n\t\t}\n\t}\n}\n\n/**\n * Format a dimension note for resized images.\n * This helps the model understand the coordinate mapping.\n */\nexport function formatDimensionNote(result: ResizedImage): string | undefined {\n\tif (!result.wasResized) {\n\t\treturn undefined;\n\t}\n\n\tconst scale = result.originalWidth / result.width;\n\treturn `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;\n}\n"]}
1
+ {"version":3,"file":"image-resize.d.ts","sourceRoot":"","sources":["../../src/utils/image-resize.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAE/E,YAAY,EAAE,kBAAkB,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AA8E/E;;;;;GAKG;AACH,wBAAsB,WAAW,CAChC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,kBAAkB,GAC1B,OAAO,CAAC,YAAY,GAAG,IAAI,CAAC,CAE9B;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,GAAG,SAAS,CAO5E","sourcesContent":["import { Worker } from \"node:worker_threads\";\nimport type { ImageResizeOptions, ResizedImage } from \"./image-resize-core.ts\";\n\nexport type { ImageResizeOptions, ResizedImage } from \"./image-resize-core.ts\";\n\ninterface ResizeImageWorkerResponse {\n\tresult?: ResizedImage | null;\n\terror?: string;\n}\n\nfunction toTransferableBytes(input: Uint8Array): Uint8Array<ArrayBuffer> {\n\t// Transfer detaches the buffer, so transfer a worker-owned copy and leave the\n\t// caller's bytes intact.\n\treturn new Uint8Array(input);\n}\n\nfunction isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse {\n\treturn value !== null && typeof value === \"object\";\n}\n\nfunction createResizeWorker(): Worker {\n\tconst isTypeScriptRuntime = import.meta.url.endsWith(\".ts\");\n\tconst workerUrl = new URL(\n\t\tisTypeScriptRuntime ? \"./image-resize-worker.ts\" : \"./image-resize-worker.js\",\n\t\timport.meta.url,\n\t);\n\treturn new Worker(workerUrl);\n}\n\nasync function resizeImageInWorker(\n\tinputBytes: Uint8Array,\n\tmimeType: string,\n\toptions?: ImageResizeOptions,\n): Promise<ResizedImage | null> {\n\tconst worker = createResizeWorker();\n\ttry {\n\t\tconst inputBytesForWorker = toTransferableBytes(inputBytes);\n\t\treturn await new Promise<ResizedImage | null>((resolve, reject) => {\n\t\t\tlet settled = false;\n\t\t\tconst settle = (result: ResizedImage | null): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tresolve(result);\n\t\t\t};\n\t\t\tconst fail = (error: Error): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\treject(error);\n\t\t\t};\n\n\t\t\tworker.once(\"message\", (message: unknown) => {\n\t\t\t\tif (!isResizeImageWorkerResponse(message)) {\n\t\t\t\t\tfail(new Error(\"Invalid image resize worker response\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (message.error) {\n\t\t\t\t\tfail(new Error(message.error));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsettle(message.result ?? null);\n\t\t\t});\n\t\t\tworker.once(\"error\", fail);\n\t\t\tworker.once(\"exit\", (code) => {\n\t\t\t\tif (!settled) {\n\t\t\t\t\tfail(new Error(`Image resize worker exited with code ${code}`));\n\t\t\t\t}\n\t\t\t});\n\t\t\tworker.postMessage(\n\t\t\t\t{\n\t\t\t\t\tinputBytes: inputBytesForWorker,\n\t\t\t\t\tmimeType,\n\t\t\t\t\toptions,\n\t\t\t\t},\n\t\t\t\t[inputBytesForWorker.buffer],\n\t\t\t);\n\t\t});\n\t} finally {\n\t\tvoid worker.terminate().catch(() => undefined);\n\t}\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and encoded file size.\n * Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not\n * block the TUI event loop. Worker failures are propagated instead of retried on\n * the main thread.\n */\nexport async function resizeImage(\n\tinputBytes: Uint8Array,\n\tmimeType: string,\n\toptions?: ImageResizeOptions,\n): Promise<ResizedImage | null> {\n\treturn resizeImageInWorker(inputBytes, mimeType, options);\n}\n\n/**\n * Format a dimension note for resized images.\n * This helps the model understand the coordinate mapping.\n */\nexport function formatDimensionNote(result: ResizedImage): string | undefined {\n\tif (!result.wasResized) {\n\t\treturn undefined;\n\t}\n\n\tconst scale = result.originalWidth / result.width;\n\treturn `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;\n}\n"]}
@@ -1,128 +1,72 @@
1
- import { applyExifOrientation } from "./exif-orientation.js";
2
- import { loadPhoton } from "./photon.js";
3
- // 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.
4
- const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
5
- const DEFAULT_OPTIONS = {
6
- maxWidth: 2000,
7
- maxHeight: 2000,
8
- maxBytes: DEFAULT_MAX_BYTES,
9
- jpegQuality: 80,
10
- };
11
- function encodeCandidate(buffer, mimeType) {
12
- const data = Buffer.from(buffer).toString("base64");
13
- return {
14
- data,
15
- encodedSize: Buffer.byteLength(data, "utf-8"),
16
- mimeType,
17
- };
1
+ import { Worker } from "node:worker_threads";
2
+ function toTransferableBytes(input) {
3
+ // Transfer detaches the buffer, so transfer a worker-owned copy and leave the
4
+ // caller's bytes intact.
5
+ return new Uint8Array(input);
18
6
  }
19
- /**
20
- * Resize an image to fit within the specified max dimensions and encoded file size.
21
- * Returns null if the image cannot be resized below maxBytes.
22
- *
23
- * Uses Photon (Rust/WASM) for image processing. If Photon is not available,
24
- * returns null.
25
- *
26
- * Strategy for staying under maxBytes:
27
- * 1. First resize to maxWidth/maxHeight
28
- * 2. Try both PNG and JPEG formats, pick the smaller one
29
- * 3. If still too large, try JPEG with decreasing quality
30
- * 4. If still too large, progressively reduce dimensions until 1x1
31
- */
32
- export async function resizeImage(img, options) {
33
- const opts = { ...DEFAULT_OPTIONS, ...options };
34
- const inputBuffer = Buffer.from(img.data, "base64");
35
- const inputBase64Size = Buffer.byteLength(img.data, "utf-8");
36
- const photon = await loadPhoton();
37
- if (!photon) {
38
- return null;
39
- }
40
- let image;
7
+ function isResizeImageWorkerResponse(value) {
8
+ return value !== null && typeof value === "object";
9
+ }
10
+ function createResizeWorker() {
11
+ const isTypeScriptRuntime = import.meta.url.endsWith(".ts");
12
+ const workerUrl = new URL(isTypeScriptRuntime ? "./image-resize-worker.ts" : "./image-resize-worker.js", import.meta.url);
13
+ return new Worker(workerUrl);
14
+ }
15
+ async function resizeImageInWorker(inputBytes, mimeType, options) {
16
+ const worker = createResizeWorker();
41
17
  try {
42
- const inputBytes = new Uint8Array(inputBuffer);
43
- const rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);
44
- image = applyExifOrientation(photon, rawImage, inputBytes);
45
- if (image !== rawImage)
46
- rawImage.free();
47
- const originalWidth = image.get_width();
48
- const originalHeight = image.get_height();
49
- const format = img.mimeType?.split("/")[1] ?? "png";
50
- // Check if already within all limits (dimensions AND encoded size)
51
- if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {
52
- return {
53
- data: img.data,
54
- mimeType: img.mimeType ?? `image/${format}`,
55
- originalWidth,
56
- originalHeight,
57
- width: originalWidth,
58
- height: originalHeight,
59
- wasResized: false,
18
+ const inputBytesForWorker = toTransferableBytes(inputBytes);
19
+ return await new Promise((resolve, reject) => {
20
+ let settled = false;
21
+ const settle = (result) => {
22
+ if (settled)
23
+ return;
24
+ settled = true;
25
+ resolve(result);
26
+ };
27
+ const fail = (error) => {
28
+ if (settled)
29
+ return;
30
+ settled = true;
31
+ reject(error);
60
32
  };
61
- }
62
- // Calculate initial dimensions respecting max limits
63
- let targetWidth = originalWidth;
64
- let targetHeight = originalHeight;
65
- if (targetWidth > opts.maxWidth) {
66
- targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
67
- targetWidth = opts.maxWidth;
68
- }
69
- if (targetHeight > opts.maxHeight) {
70
- targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
71
- targetHeight = opts.maxHeight;
72
- }
73
- function tryEncodings(width, height, jpegQualities) {
74
- const resized = photon.resize(image, width, height, photon.SamplingFilter.Lanczos3);
75
- try {
76
- const candidates = [encodeCandidate(resized.get_bytes(), "image/png")];
77
- for (const quality of jpegQualities) {
78
- candidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), "image/jpeg"));
33
+ worker.once("message", (message) => {
34
+ if (!isResizeImageWorkerResponse(message)) {
35
+ fail(new Error("Invalid image resize worker response"));
36
+ return;
79
37
  }
80
- return candidates;
81
- }
82
- finally {
83
- resized.free();
84
- }
85
- }
86
- const qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));
87
- let currentWidth = targetWidth;
88
- let currentHeight = targetHeight;
89
- while (true) {
90
- const candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);
91
- for (const candidate of candidates) {
92
- if (candidate.encodedSize < opts.maxBytes) {
93
- return {
94
- data: candidate.data,
95
- mimeType: candidate.mimeType,
96
- originalWidth,
97
- originalHeight,
98
- width: currentWidth,
99
- height: currentHeight,
100
- wasResized: true,
101
- };
38
+ if (message.error) {
39
+ fail(new Error(message.error));
40
+ return;
102
41
  }
103
- }
104
- if (currentWidth === 1 && currentHeight === 1) {
105
- break;
106
- }
107
- const nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));
108
- const nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));
109
- if (nextWidth === currentWidth && nextHeight === currentHeight) {
110
- break;
111
- }
112
- currentWidth = nextWidth;
113
- currentHeight = nextHeight;
114
- }
115
- return null;
116
- }
117
- catch {
118
- return null;
42
+ settle(message.result ?? null);
43
+ });
44
+ worker.once("error", fail);
45
+ worker.once("exit", (code) => {
46
+ if (!settled) {
47
+ fail(new Error(`Image resize worker exited with code ${code}`));
48
+ }
49
+ });
50
+ worker.postMessage({
51
+ inputBytes: inputBytesForWorker,
52
+ mimeType,
53
+ options,
54
+ }, [inputBytesForWorker.buffer]);
55
+ });
119
56
  }
120
57
  finally {
121
- if (image) {
122
- image.free();
123
- }
58
+ void worker.terminate().catch(() => undefined);
124
59
  }
125
60
  }
61
+ /**
62
+ * Resize an image to fit within the specified max dimensions and encoded file size.
63
+ * Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not
64
+ * block the TUI event loop. Worker failures are propagated instead of retried on
65
+ * the main thread.
66
+ */
67
+ export async function resizeImage(inputBytes, mimeType, options) {
68
+ return resizeImageInWorker(inputBytes, mimeType, options);
69
+ }
126
70
  /**
127
71
  * Format a dimension note for resized images.
128
72
  * This helps the model understand the coordinate mapping.
@@ -1 +1 @@
1
- {"version":3,"file":"image-resize.js","sourceRoot":"","sources":["../../src/utils/image-resize.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ImageContent } from \"@fleetagent/pi-ai\";\nimport { applyExifOrientation } from \"./exif-orientation.ts\";\nimport { loadPhoton } from \"./photon.ts\";\n\nexport interface ImageResizeOptions {\n\tmaxWidth?: number; // Default: 2000\n\tmaxHeight?: number; // Default: 2000\n\tmaxBytes?: number; // Default: 4.5MB of base64 payload (below Anthropic's 5MB limit)\n\tjpegQuality?: number; // Default: 80\n}\n\nexport interface ResizedImage {\n\tdata: string; // base64\n\tmimeType: string;\n\toriginalWidth: number;\n\toriginalHeight: number;\n\twidth: number;\n\theight: number;\n\twasResized: boolean;\n}\n\n// 4.5MB of base64 payload. Provides headroom below Anthropic's 5MB limit.\nconst DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;\n\nconst DEFAULT_OPTIONS: Required<ImageResizeOptions> = {\n\tmaxWidth: 2000,\n\tmaxHeight: 2000,\n\tmaxBytes: DEFAULT_MAX_BYTES,\n\tjpegQuality: 80,\n};\n\ninterface EncodedCandidate {\n\tdata: string;\n\tencodedSize: number;\n\tmimeType: string;\n}\n\nfunction encodeCandidate(buffer: Uint8Array, mimeType: string): EncodedCandidate {\n\tconst data = Buffer.from(buffer).toString(\"base64\");\n\treturn {\n\t\tdata,\n\t\tencodedSize: Buffer.byteLength(data, \"utf-8\"),\n\t\tmimeType,\n\t};\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and encoded file size.\n * Returns null if the image cannot be resized below maxBytes.\n *\n * Uses Photon (Rust/WASM) for image processing. If Photon is not available,\n * returns null.\n *\n * Strategy for staying under maxBytes:\n * 1. First resize to maxWidth/maxHeight\n * 2. Try both PNG and JPEG formats, pick the smaller one\n * 3. If still too large, try JPEG with decreasing quality\n * 4. If still too large, progressively reduce dimensions until 1x1\n */\nexport async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage | null> {\n\tconst opts = { ...DEFAULT_OPTIONS, ...options };\n\tconst inputBuffer = Buffer.from(img.data, \"base64\");\n\tconst inputBase64Size = Buffer.byteLength(img.data, \"utf-8\");\n\n\tconst photon = await loadPhoton();\n\tif (!photon) {\n\t\treturn null;\n\t}\n\n\tlet image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;\n\ttry {\n\t\tconst inputBytes = new Uint8Array(inputBuffer);\n\t\tconst rawImage = photon.PhotonImage.new_from_byteslice(inputBytes);\n\t\timage = applyExifOrientation(photon, rawImage, inputBytes);\n\t\tif (image !== rawImage) rawImage.free();\n\n\t\tconst originalWidth = image.get_width();\n\t\tconst originalHeight = image.get_height();\n\t\tconst format = img.mimeType?.split(\"/\")[1] ?? \"png\";\n\n\t\t// Check if already within all limits (dimensions AND encoded size)\n\t\tif (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && inputBase64Size < opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: img.data,\n\t\t\t\tmimeType: img.mimeType ?? `image/${format}`,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: originalWidth,\n\t\t\t\theight: originalHeight,\n\t\t\t\twasResized: false,\n\t\t\t};\n\t\t}\n\n\t\t// Calculate initial dimensions respecting max limits\n\t\tlet targetWidth = originalWidth;\n\t\tlet targetHeight = originalHeight;\n\n\t\tif (targetWidth > opts.maxWidth) {\n\t\t\ttargetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);\n\t\t\ttargetWidth = opts.maxWidth;\n\t\t}\n\t\tif (targetHeight > opts.maxHeight) {\n\t\t\ttargetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);\n\t\t\ttargetHeight = opts.maxHeight;\n\t\t}\n\n\t\tfunction tryEncodings(width: number, height: number, jpegQualities: number[]): EncodedCandidate[] {\n\t\t\tconst resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);\n\n\t\t\ttry {\n\t\t\t\tconst candidates: EncodedCandidate[] = [encodeCandidate(resized.get_bytes(), \"image/png\")];\n\t\t\t\tfor (const quality of jpegQualities) {\n\t\t\t\t\tcandidates.push(encodeCandidate(resized.get_bytes_jpeg(quality), \"image/jpeg\"));\n\t\t\t\t}\n\t\t\t\treturn candidates;\n\t\t\t} finally {\n\t\t\t\tresized.free();\n\t\t\t}\n\t\t}\n\n\t\tconst qualitySteps = Array.from(new Set([opts.jpegQuality, 85, 70, 55, 40]));\n\t\tlet currentWidth = targetWidth;\n\t\tlet currentHeight = targetHeight;\n\n\t\twhile (true) {\n\t\t\tconst candidates = tryEncodings(currentWidth, currentHeight, qualitySteps);\n\t\t\tfor (const candidate of candidates) {\n\t\t\t\tif (candidate.encodedSize < opts.maxBytes) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdata: candidate.data,\n\t\t\t\t\t\tmimeType: candidate.mimeType,\n\t\t\t\t\t\toriginalWidth,\n\t\t\t\t\t\toriginalHeight,\n\t\t\t\t\t\twidth: currentWidth,\n\t\t\t\t\t\theight: currentHeight,\n\t\t\t\t\t\twasResized: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (currentWidth === 1 && currentHeight === 1) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tconst nextWidth = currentWidth === 1 ? 1 : Math.max(1, Math.floor(currentWidth * 0.75));\n\t\t\tconst nextHeight = currentHeight === 1 ? 1 : Math.max(1, Math.floor(currentHeight * 0.75));\n\t\t\tif (nextWidth === currentWidth && nextHeight === currentHeight) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tcurrentWidth = nextWidth;\n\t\t\tcurrentHeight = nextHeight;\n\t\t}\n\n\t\treturn null;\n\t} catch {\n\t\treturn null;\n\t} finally {\n\t\tif (image) {\n\t\t\timage.free();\n\t\t}\n\t}\n}\n\n/**\n * Format a dimension note for resized images.\n * This helps the model understand the coordinate mapping.\n */\nexport function formatDimensionNote(result: ResizedImage): string | undefined {\n\tif (!result.wasResized) {\n\t\treturn undefined;\n\t}\n\n\tconst scale = result.originalWidth / result.width;\n\treturn `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;\n}\n"]}
1
+ {"version":3,"file":"image-resize.js","sourceRoot":"","sources":["../../src/utils/image-resize.ts"],"names":[],"mappings":"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","sourcesContent":["import { Worker } from \"node:worker_threads\";\nimport type { ImageResizeOptions, ResizedImage } from \"./image-resize-core.ts\";\n\nexport type { ImageResizeOptions, ResizedImage } from \"./image-resize-core.ts\";\n\ninterface ResizeImageWorkerResponse {\n\tresult?: ResizedImage | null;\n\terror?: string;\n}\n\nfunction toTransferableBytes(input: Uint8Array): Uint8Array<ArrayBuffer> {\n\t// Transfer detaches the buffer, so transfer a worker-owned copy and leave the\n\t// caller's bytes intact.\n\treturn new Uint8Array(input);\n}\n\nfunction isResizeImageWorkerResponse(value: unknown): value is ResizeImageWorkerResponse {\n\treturn value !== null && typeof value === \"object\";\n}\n\nfunction createResizeWorker(): Worker {\n\tconst isTypeScriptRuntime = import.meta.url.endsWith(\".ts\");\n\tconst workerUrl = new URL(\n\t\tisTypeScriptRuntime ? \"./image-resize-worker.ts\" : \"./image-resize-worker.js\",\n\t\timport.meta.url,\n\t);\n\treturn new Worker(workerUrl);\n}\n\nasync function resizeImageInWorker(\n\tinputBytes: Uint8Array,\n\tmimeType: string,\n\toptions?: ImageResizeOptions,\n): Promise<ResizedImage | null> {\n\tconst worker = createResizeWorker();\n\ttry {\n\t\tconst inputBytesForWorker = toTransferableBytes(inputBytes);\n\t\treturn await new Promise<ResizedImage | null>((resolve, reject) => {\n\t\t\tlet settled = false;\n\t\t\tconst settle = (result: ResizedImage | null): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\tresolve(result);\n\t\t\t};\n\t\t\tconst fail = (error: Error): void => {\n\t\t\t\tif (settled) return;\n\t\t\t\tsettled = true;\n\t\t\t\treject(error);\n\t\t\t};\n\n\t\t\tworker.once(\"message\", (message: unknown) => {\n\t\t\t\tif (!isResizeImageWorkerResponse(message)) {\n\t\t\t\t\tfail(new Error(\"Invalid image resize worker response\"));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (message.error) {\n\t\t\t\t\tfail(new Error(message.error));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tsettle(message.result ?? null);\n\t\t\t});\n\t\t\tworker.once(\"error\", fail);\n\t\t\tworker.once(\"exit\", (code) => {\n\t\t\t\tif (!settled) {\n\t\t\t\t\tfail(new Error(`Image resize worker exited with code ${code}`));\n\t\t\t\t}\n\t\t\t});\n\t\t\tworker.postMessage(\n\t\t\t\t{\n\t\t\t\t\tinputBytes: inputBytesForWorker,\n\t\t\t\t\tmimeType,\n\t\t\t\t\toptions,\n\t\t\t\t},\n\t\t\t\t[inputBytesForWorker.buffer],\n\t\t\t);\n\t\t});\n\t} finally {\n\t\tvoid worker.terminate().catch(() => undefined);\n\t}\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and encoded file size.\n * Runs Photon in a worker thread so WASM decoding, resizing, and encoding do not\n * block the TUI event loop. Worker failures are propagated instead of retried on\n * the main thread.\n */\nexport async function resizeImage(\n\tinputBytes: Uint8Array,\n\tmimeType: string,\n\toptions?: ImageResizeOptions,\n): Promise<ResizedImage | null> {\n\treturn resizeImageInWorker(inputBytes, mimeType, options);\n}\n\n/**\n * Format a dimension note for resized images.\n * This helps the model understand the coordinate mapping.\n */\nexport function formatDimensionNote(result: ResizedImage): string | undefined {\n\tif (!result.wasResized) {\n\t\treturn undefined;\n\t}\n\n\tconst scale = result.originalWidth / result.width;\n\treturn `[Image: original ${result.originalWidth}x${result.originalHeight}, displayed at ${result.width}x${result.height}. Multiply coordinates by ${scale.toFixed(2)} to map to original image.]`;\n}\n"]}
@@ -0,0 +1,3 @@
1
+ /** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */
2
+ export declare function stripJsonComments(input: string): string;
3
+ //# sourceMappingURL=json.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json.d.ts","sourceRoot":"","sources":["../../src/utils/json.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAIvD","sourcesContent":["/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */\nexport function stripJsonComments(input: string): string {\n\treturn input\n\t\t.replace(/\"(?:\\\\.|[^\"\\\\])*\"|\\/\\/[^\\n]*/g, (m) => (m[0] === '\"' ? m : \"\"))\n\t\t.replace(/\"(?:\\\\.|[^\"\\\\])*\"|,(\\s*[}\\]])/g, (m, tail) => tail ?? (m[0] === '\"' ? m : \"\"));\n}\n"]}
@@ -0,0 +1,7 @@
1
+ /** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */
2
+ export function stripJsonComments(input) {
3
+ return input
4
+ .replace(/"(?:\\.|[^"\\])*"|\/\/[^\n]*/g, (m) => (m[0] === '"' ? m : ""))
5
+ .replace(/"(?:\\.|[^"\\])*"|,(\s*[}\]])/g, (m, tail) => tail ?? (m[0] === '"' ? m : ""));
6
+ }
7
+ //# sourceMappingURL=json.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"json.js","sourceRoot":"","sources":["../../src/utils/json.ts"],"names":[],"mappings":"AAAA,iGAAiG;AACjG,MAAM,UAAU,iBAAiB,CAAC,KAAa,EAAU;IACxD,OAAO,KAAK;SACV,OAAO,CAAC,+BAA+B,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC;SACxE,OAAO,CAAC,gCAAgC,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;AAAA,CAC1F","sourcesContent":["/** Strip `//` line comments and trailing commas from JSON, leaving string literals untouched. */\nexport function stripJsonComments(input: string): string {\n\treturn input\n\t\t.replace(/\"(?:\\\\.|[^\"\\\\])*\"|\\/\\/[^\\n]*/g, (m) => (m[0] === '\"' ? m : \"\"))\n\t\t.replace(/\"(?:\\\\.|[^\"\\\\])*\"|,(\\s*[}\\]])/g, (m, tail) => tail ?? (m[0] === '\"' ? m : \"\"));\n}\n"]}
@@ -43,7 +43,7 @@ export default function (pi: ExtensionAPI) {
43
43
  pi.registerProvider("my-provider", {
44
44
  name: "My Provider",
45
45
  baseUrl: "https://api.example.com",
46
- apiKey: "MY_API_KEY",
46
+ apiKey: "$MY_API_KEY",
47
47
  api: "openai-completions",
48
48
  models: [
49
49
  {
@@ -83,7 +83,7 @@ pi.registerProvider("openai", {
83
83
  pi.registerProvider("google", {
84
84
  baseUrl: "https://ai-gateway.corp.com/google",
85
85
  headers: {
86
- "X-Corp-Auth": "CORP_AUTH_TOKEN" // env var or literal
86
+ "X-Corp-Auth": "$CORP_AUTH_TOKEN" // env var or literal
87
87
  }
88
88
  });
89
89
  ```
@@ -112,7 +112,7 @@ export default async function (pi: ExtensionAPI) {
112
112
 
113
113
  pi.registerProvider("local-openai", {
114
114
  baseUrl: "http://localhost:1234/v1",
115
- apiKey: "LOCAL_OPENAI_API_KEY",
115
+ apiKey: "$LOCAL_OPENAI_API_KEY",
116
116
  api: "openai-completions",
117
117
  models: payload.data.map((model) => ({
118
118
  id: model.id,
@@ -132,7 +132,7 @@ This registers the fetched models before startup finishes.
132
132
  ```typescript
133
133
  pi.registerProvider("my-llm", {
134
134
  baseUrl: "https://api.my-llm.com/v1",
135
- apiKey: "MY_LLM_API_KEY", // env var name or literal value
135
+ apiKey: "$MY_LLM_API_KEY", // env var reference
136
136
  api: "openai-completions", // which streaming API to use
137
137
  models: [
138
138
  {
@@ -155,6 +155,8 @@ pi.registerProvider("my-llm", {
155
155
 
156
156
  When `models` is provided, it **replaces** all existing models for that provider.
157
157
 
158
+ `apiKey` and custom header values use the same config value syntax as `models.json`: `!command` at the start executes a command for the whole value, `$ENV_VAR` and `${ENV_VAR}` interpolate environment variables, `$$` emits a literal `$`, and `$!` emits a literal `!`.
159
+
158
160
  ## Unregister Provider
159
161
 
160
162
  Use `pi.unregisterProvider(name)` to remove a provider that was previously registered via `pi.registerProvider(name, ...)`:
@@ -163,7 +165,7 @@ Use `pi.unregisterProvider(name)` to remove a provider that was previously regis
163
165
  // Register
164
166
  pi.registerProvider("my-llm", {
165
167
  baseUrl: "https://api.my-llm.com/v1",
166
- apiKey: "MY_LLM_API_KEY",
168
+ apiKey: "$MY_LLM_API_KEY",
167
169
  api: "openai-completions",
168
170
  models: [
169
171
  {
@@ -230,6 +232,9 @@ models: [{
230
232
  Use `openrouter` for OpenRouter-style `reasoning: { effort }` controls. Use `together` for Together-style `reasoning: { enabled }` controls; with `supportsReasoningEffort`, it also sends `reasoning_effort`. Use `qwen-chat-template` instead for local Qwen-compatible servers that read `chat_template_kwargs.enable_thinking`.
231
233
  Use `cacheControlFormat: "anthropic"` for OpenAI-compatible providers that expose Anthropic-style prompt caching via `cache_control` on the system prompt, last tool definition, and last user/assistant text content.
232
234
 
235
+ For Anthropic-compatible providers using `api: "anthropic-messages"`, set `compat.forceAdaptiveThinking: true` on models or providers whose upstream model requires adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`). Built-in adaptive Claude models set this automatically. Set `compat.allowEmptySignature: true` only for providers that emit empty thinking signatures and expect `signature: ""` on replay.
236
+
237
+
233
238
  > Migration note: Mistral moved from `openai-completions` to `mistral-conversations`.
234
239
  > Use `mistral-conversations` for native Mistral models.
235
240
  > If you intentionally route Mistral-compatible/custom endpoints through `openai-completions`, set `compat` flags explicitly as needed.
@@ -241,7 +246,7 @@ If your provider expects `Authorization: Bearer <key>` but doesn't use a standar
241
246
  ```typescript
242
247
  pi.registerProvider("custom-api", {
243
248
  baseUrl: "https://api.example.com",
244
- apiKey: "MY_API_KEY",
249
+ apiKey: "$MY_API_KEY",
245
250
  authHeader: true, // adds Authorization: Bearer header
246
251
  api: "openai-completions",
247
252
  models: [...]
@@ -568,7 +573,7 @@ Register your stream function:
568
573
  ```typescript
569
574
  pi.registerProvider("my-provider", {
570
575
  baseUrl: "https://api.example.com",
571
- apiKey: "MY_API_KEY",
576
+ apiKey: "$MY_API_KEY",
572
577
  api: "my-custom-api",
573
578
  models: [...],
574
579
  streamSimple: streamMyProvider
@@ -605,7 +610,7 @@ interface ProviderConfig {
605
610
  /** API endpoint URL. Required when defining models. */
606
611
  baseUrl?: string;
607
612
 
608
- /** API key or environment variable name. Required when defining models (unless oauth). */
613
+ /** API key literal, env interpolation ($ENV_VAR or ${ENV_VAR}), or !command. Required when defining models (unless oauth). */
609
614
  apiKey?: string;
610
615
 
611
616
  /** API type for streaming. Required at provider or model level when defining models. */
@@ -618,7 +623,7 @@ interface ProviderConfig {
618
623
  options?: SimpleStreamOptions
619
624
  ) => AssistantMessageEventStream;
620
625
 
621
- /** Custom headers to include in requests. Values can be env var names. */
626
+ /** Custom headers to include in requests. Values use the same resolution syntax as apiKey. */
622
627
  headers?: Record<string, string>;
623
628
 
624
629
  /** If true, adds Authorization: Bearer header with the resolved API key. */
@@ -693,6 +698,14 @@ interface ProviderModelConfig {
693
698
  requiresReasoningContentOnAssistantMessages?: boolean;
694
699
  thinkingFormat?: "openai" | "openrouter" | "deepseek" | "together" | "zai" | "qwen" | "qwen-chat-template";
695
700
  cacheControlFormat?: "anthropic";
701
+ // anthropic-messages
702
+ supportsEagerToolInputStreaming?: boolean;
703
+ supportsLongCacheRetention?: boolean;
704
+ sendSessionAffinityHeaders?: boolean;
705
+ supportsCacheControlOnTools?: boolean;
706
+ forceAdaptiveThinking?: boolean;
707
+ allowEmptySignature?: boolean;
708
+
696
709
  };
697
710
  }
698
711
  ```
@@ -199,7 +199,7 @@ export default async function (pi: ExtensionAPI) {
199
199
 
200
200
  pi.registerProvider("local-openai", {
201
201
  baseUrl: "http://localhost:1234/v1",
202
- apiKey: "LOCAL_OPENAI_API_KEY",
202
+ apiKey: "$LOCAL_OPENAI_API_KEY",
203
203
  api: "openai-completions",
204
204
  models: payload.data.map((model) => ({
205
205
  id: model.id,
@@ -1555,7 +1555,7 @@ If you need to discover models from a remote endpoint, prefer an async extension
1555
1555
  pi.registerProvider("my-proxy", {
1556
1556
  name: "My Proxy",
1557
1557
  baseUrl: "https://proxy.example.com",
1558
- apiKey: "PROXY_API_KEY", // env var name or literal
1558
+ apiKey: "$PROXY_API_KEY", // env var reference
1559
1559
  api: "anthropic-messages",
1560
1560
  models: [
1561
1561
  {
@@ -1602,7 +1602,7 @@ pi.registerProvider("corporate-ai", {
1602
1602
  **Config options:**
1603
1603
  - `name` - Display name for the provider in UI such as `/login`.
1604
1604
  - `baseUrl` - API endpoint URL. Required when defining models.
1605
- - `apiKey` - API key or environment variable name. Required when defining models (unless `oauth` provided).
1605
+ - `apiKey` - API key literal, environment interpolation (`$ENV_VAR` or `${ENV_VAR}`), or leading `!command`. Required when defining models (unless `oauth` provided). `$$` escapes `$`, and `$!` escapes a literal `!` without triggering command execution.
1606
1606
  - `api` - API type: `"anthropic-messages"`, `"openai-completions"`, `"openai-responses"`, etc.
1607
1607
  - `headers` - Custom headers to include in requests.
1608
1608
  - `authHeader` - If true, adds `Authorization: Bearer` header automatically.
@@ -2557,6 +2557,7 @@ All examples in [examples/extensions/](../examples/extensions/).
2557
2557
  | `custom-compaction.ts` | Custom compaction summary | `on("session_before_compact")` |
2558
2558
  | `trigger-compact.ts` | Trigger compaction manually | `compact()` |
2559
2559
  | `git-checkpoint.ts` | Git stash on turns | `on("turn_start")`, `on("session_before_fork")`, `exec` |
2560
+ | `git-merge-and-resolve.ts` | Fetch, merge, and resolve conflicts | `on("agent_end")`, `exec`, `sendUserMessage` |
2560
2561
  | `auto-commit-on-exit.ts` | Commit on shutdown | `on("session_shutdown")`, `exec` |
2561
2562
  | **UI Components** |||
2562
2563
  | `status-line.ts` | Footer status indicator | `setStatus`, session events |
package/docs/models.md CHANGED
@@ -101,7 +101,7 @@ Use `google-generative-ai` with a `baseUrl` to add models from Google AI Studio,
101
101
  "my-google": {
102
102
  "baseUrl": "https://generativelanguage.googleapis.com/v1beta",
103
103
  "api": "google-generative-ai",
104
- "apiKey": "GEMINI_API_KEY",
104
+ "apiKey": "$GEMINI_API_KEY",
105
105
  "models": [
106
106
  {
107
107
  "id": "gemma-4-31b-it",
@@ -143,22 +143,31 @@ Set `api` at provider level (default for all models) or model level (override pe
143
143
 
144
144
  ### Value Resolution
145
145
 
146
- The `apiKey` and `headers` fields support three formats:
146
+ The `apiKey` and `headers` fields support command execution, environment interpolation, and literals:
147
147
 
148
- - **Shell command:** `"!command"` executes and uses stdout
148
+ - **Shell command:** `"!command"` at the start executes the whole value as a command and uses stdout
149
149
  ```json
150
150
  "apiKey": "!security find-generic-password -ws 'anthropic'"
151
151
  "apiKey": "!op read 'op://vault/item/credential'"
152
152
  ```
153
- - **Environment variable:** Uses the value of the named variable
153
+ - **Environment interpolation:** `"$ENV_VAR"` or `"${ENV_VAR}"` uses the value of the named variable. Interpolation works inside larger literals.
154
154
  ```json
155
- "apiKey": "MY_API_KEY"
155
+ "apiKey": "$MY_API_KEY"
156
+ "apiKey": "${KEY_PREFIX}_${KEY_SUFFIX}"
157
+ ```
158
+ `$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved.
159
+ - **Escapes:** `"$$"` emits a literal `"$"`; `"$!"` emits a literal `"!"` without triggering command execution.
160
+ ```json
161
+ "apiKey": "$$literal-dollar-prefix"
162
+ "apiKey": "$!literal-bang-prefix"
156
163
  ```
157
164
  - **Literal value:** Used directly
158
165
  ```json
159
166
  "apiKey": "sk-..."
160
167
  ```
161
168
 
169
+ Legacy uppercase env-var-like values such as `MY_API_KEY` are migrated to `$MY_API_KEY` on startup.
170
+
162
171
  For `models.json`, shell commands are resolved at request time. pi intentionally does not apply built-in TTL, stale reuse, or recovery logic for arbitrary commands. Different commands need different caching and failure strategies, and pi cannot infer the right one.
163
172
 
164
173
  If your command is slow, expensive, rate-limited, or should keep using a previous value on transient failures, wrap it in your own script or command that implements the caching or TTL behavior you want.
@@ -172,10 +181,10 @@ If your command is slow, expensive, rate-limited, or should keep using a previou
172
181
  "providers": {
173
182
  "custom-proxy": {
174
183
  "baseUrl": "https://proxy.example.com/v1",
175
- "apiKey": "MY_API_KEY",
184
+ "apiKey": "$MY_API_KEY",
176
185
  "api": "anthropic-messages",
177
186
  "headers": {
178
- "x-portkey-api-key": "PORTKEY_API_KEY",
187
+ "x-portkey-api-key": "$PORTKEY_API_KEY",
179
188
  "x-secret": "!op read 'op://vault/item/secret'"
180
189
  },
181
190
  "models": [...]
@@ -268,7 +277,7 @@ To merge custom models into a built-in provider, include the `models` array:
268
277
  "providers": {
269
278
  "anthropic": {
270
279
  "baseUrl": "https://my-proxy.example.com/v1",
271
- "apiKey": "ANTHROPIC_API_KEY",
280
+ "apiKey": "$ANTHROPIC_API_KEY",
272
281
  "api": "anthropic-messages",
273
282
  "models": [...]
274
283
  }
@@ -319,16 +328,24 @@ For providers or proxies using `api: "anthropic-messages"`, use `compat.supports
319
328
 
320
329
  By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthropic-compatible backend rejects that field, set `supportsEagerToolInputStreaming` to `false`. Pi will omit `tools[].eager_input_streaming` and send the legacy `fine-grained-tool-streaming-2025-05-14` beta header for tool-enabled requests instead.
321
330
 
331
+ Some Anthropic models require adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) instead of the legacy budget-based thinking payload. Built-in models set this automatically. For custom providers or aliases that route to those models, set `forceAdaptiveThinking` to `true`.
332
+
333
+ Some Anthropic-compatible providers emit thinking blocks with empty signatures and still expect them on replay. Set `allowEmptySignature` to `true` only for those providers; real Anthropic rejects empty thinking signatures.
334
+
335
+
322
336
  ```json
323
337
  {
324
338
  "providers": {
325
339
  "anthropic-proxy": {
326
340
  "baseUrl": "https://proxy.example.com",
327
341
  "api": "anthropic-messages",
328
- "apiKey": "ANTHROPIC_PROXY_KEY",
342
+ "apiKey": "$ANTHROPIC_PROXY_KEY",
329
343
  "compat": {
330
344
  "supportsEagerToolInputStreaming": false,
331
- "supportsLongCacheRetention": true
345
+ "supportsLongCacheRetention": true,
346
+ "forceAdaptiveThinking": true,
347
+ "allowEmptySignature": true
348
+
332
349
  },
333
350
  "models": [
334
351
  {
@@ -346,6 +363,11 @@ By default pi sends per-tool `eager_input_streaming: true`. If a proxy or Anthro
346
363
  |-------|-------------|
347
364
  | `supportsEagerToolInputStreaming` | Whether the provider accepts per-tool `eager_input_streaming`. Default: `true`. Set to `false` to omit that field and use the legacy fine-grained tool streaming beta header on tool-enabled requests. |
348
365
  | `supportsLongCacheRetention` | Whether the provider accepts Anthropic long cache retention (`cache_control.ttl: "1h"`) when cache retention is `long`. Default: `true`. |
366
+ | `sendSessionAffinityHeaders` | Whether to send `x-session-affinity` from the session id when caching is enabled. Default: auto-detected for known providers. |
367
+ | `supportsCacheControlOnTools` | Whether the provider accepts Anthropic-style `cache_control` markers on tool definitions. Default: `true`. |
368
+ | `forceAdaptiveThinking` | Whether to send adaptive thinking (`thinking.type: "adaptive"` plus `output_config.effort`) for this model. Built-in adaptive models set this automatically. Default: `false`. |
369
+ | `allowEmptySignature` | Whether to replay empty thinking signatures as `signature: ""` instead of converting thinking to text. Default: `false`. |
370
+
349
371
 
350
372
  ## OpenAI Compatibility
351
373
 
@@ -399,7 +421,7 @@ Example:
399
421
  "providers": {
400
422
  "openrouter": {
401
423
  "baseUrl": "https://openrouter.ai/api/v1",
402
- "apiKey": "OPENROUTER_API_KEY",
424
+ "apiKey": "$OPENROUTER_API_KEY",
403
425
  "api": "openai-completions",
404
426
  "models": [
405
427
  {
@@ -449,7 +471,7 @@ Vercel AI Gateway example:
449
471
  "providers": {
450
472
  "vercel-ai-gateway": {
451
473
  "baseUrl": "https://ai-gateway.vercel.sh/v1",
452
- "apiKey": "AI_GATEWAY_API_KEY",
474
+ "apiKey": "$AI_GATEWAY_API_KEY",
453
475
  "api": "openai-completions",
454
476
  "models": [
455
477
  {
package/docs/packages.md CHANGED
@@ -28,8 +28,8 @@ pi install ./relative/path/to/package
28
28
 
29
29
  pi remove npm:@foo/bar
30
30
  pi list # show installed packages from settings
31
- pi update # update pi and all non-pinned packages
32
- pi update --extensions # update all non-pinned packages only
31
+ pi update # update pi, update packages, and reconcile pinned git refs
32
+ pi update --extensions # update packages and reconcile pinned git refs only
33
33
  pi update --self # update pi only
34
34
  pi update --self --force # reinstall pi even if current
35
35
  pi update npm:@foo/bar # update one package
@@ -85,9 +85,10 @@ ssh://git@github.com/user/repo@v1
85
85
  - HTTPS and SSH URLs are both supported.
86
86
  - SSH URLs use your configured SSH keys automatically (respects `~/.ssh/config`).
87
87
  - For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` to disable credential prompts and set `GIT_SSH_COMMAND` (for example `ssh -o BatchMode=yes -o ConnectTimeout=5`) to fail fast.
88
- - Refs are pinned tags or commits and skip package updates (`pi update`, `pi update --extensions`). Use `pi install git:host/user/repo@new-ref` to move an existing package to a new pinned ref.
88
+ - Refs are pinned tags or commits. `pi update` and `pi update --extensions` do not move them to newer refs, but they do reconcile an existing clone to the configured ref.
89
+ - Use `pi install git:host/user/repo@new-ref` to update settings and move an existing package to a new pinned ref.
89
90
  - Cloned to `~/.pi/agent/git/<host>/<path>` (global) or `.pi/git/<host>/<path>` (project).
90
- - Runs `npm install` after clone, pull, or pinned ref change if `package.json` exists.
91
+ - When reconciliation changes the checkout, pi resets and cleans the clone, then runs `npm install` if `package.json` exists.
91
92
 
92
93
  **SSH examples:**
93
94
  ```bash
package/docs/providers.md CHANGED
@@ -101,23 +101,31 @@ The file is created with `0600` permissions (user read/write only). Auth file cr
101
101
 
102
102
  ### Key Resolution
103
103
 
104
- The `key` field supports three formats:
104
+ The `key` field supports command execution, environment interpolation, and literals:
105
105
 
106
- - **Shell command:** `"!command"` executes and uses stdout (cached for process lifetime)
106
+ - **Shell command:** `"!command"` at the start executes the whole value as a command and uses stdout (cached for process lifetime)
107
107
  ```json
108
108
  { "type": "api_key", "key": "!security find-generic-password -ws 'anthropic'" }
109
109
  { "type": "api_key", "key": "!op read 'op://vault/item/credential'" }
110
110
  ```
111
- - **Environment variable:** Uses the value of the named variable
111
+ - **Environment interpolation:** `"$ENV_VAR"` or `"${ENV_VAR}"` uses the value of the named variable. Interpolation works inside larger literals.
112
112
  ```json
113
- { "type": "api_key", "key": "MY_ANTHROPIC_KEY" }
113
+ { "type": "api_key", "key": "$MY_ANTHROPIC_KEY" }
114
+ { "type": "api_key", "key": "${KEY_PREFIX}_${KEY_SUFFIX}" }
115
+ ```
116
+ `$FOO_BAR` is the variable `FOO_BAR`; use `${FOO}_BAR` when `BAR` is literal text. Missing environment variables make the value unresolved.
117
+ - **Escapes:** `"$$"` emits a literal `"$"`; `"$!"` emits a literal `"!"` without triggering command execution.
118
+ ```json
119
+ { "type": "api_key", "key": "$$literal-dollar-prefix" }
120
+ { "type": "api_key", "key": "$!literal-bang-prefix" }
114
121
  ```
115
122
  - **Literal value:** Used directly
116
123
  ```json
117
124
  { "type": "api_key", "key": "sk-ant-..." }
125
+ { "type": "api_key", "key": "public" }
118
126
  ```
119
127
 
120
- OAuth credentials are also stored here after `/login` and managed automatically.
128
+ Legacy uppercase env-var-like values such as `MY_API_KEY` are migrated to `$MY_API_KEY` on startup. OAuth credentials are also stored here after `/login` and managed automatically.
121
129
 
122
130
  ## Cloud Providers
123
131