@mariozechner/pi-coding-agent 0.47.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +61 -1
  2. package/README.md +43 -3
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +10 -1
  5. package/dist/config.js.map +1 -1
  6. package/dist/core/agent-session.d.ts +7 -1
  7. package/dist/core/agent-session.d.ts.map +1 -1
  8. package/dist/core/agent-session.js +63 -9
  9. package/dist/core/agent-session.js.map +1 -1
  10. package/dist/core/compaction/branch-summarization.d.ts +2 -0
  11. package/dist/core/compaction/branch-summarization.d.ts.map +1 -1
  12. package/dist/core/compaction/branch-summarization.js +11 -4
  13. package/dist/core/compaction/branch-summarization.js.map +1 -1
  14. package/dist/core/compaction/compaction.d.ts +11 -0
  15. package/dist/core/compaction/compaction.d.ts.map +1 -1
  16. package/dist/core/compaction/compaction.js +50 -3
  17. package/dist/core/compaction/compaction.js.map +1 -1
  18. package/dist/core/extensions/index.d.ts +1 -1
  19. package/dist/core/extensions/index.d.ts.map +1 -1
  20. package/dist/core/extensions/index.js.map +1 -1
  21. package/dist/core/extensions/loader.d.ts.map +1 -1
  22. package/dist/core/extensions/loader.js +4 -0
  23. package/dist/core/extensions/loader.js.map +1 -1
  24. package/dist/core/extensions/runner.d.ts +5 -0
  25. package/dist/core/extensions/runner.d.ts.map +1 -1
  26. package/dist/core/extensions/runner.js +7 -0
  27. package/dist/core/extensions/runner.js.map +1 -1
  28. package/dist/core/extensions/types.d.ts +44 -5
  29. package/dist/core/extensions/types.d.ts.map +1 -1
  30. package/dist/core/extensions/types.js.map +1 -1
  31. package/dist/core/prompt-templates.d.ts +5 -1
  32. package/dist/core/prompt-templates.d.ts.map +1 -1
  33. package/dist/core/prompt-templates.js +18 -1
  34. package/dist/core/prompt-templates.js.map +1 -1
  35. package/dist/core/sdk.d.ts.map +1 -1
  36. package/dist/core/sdk.js +5 -1
  37. package/dist/core/sdk.js.map +1 -1
  38. package/dist/core/session-manager.d.ts +8 -0
  39. package/dist/core/session-manager.d.ts.map +1 -1
  40. package/dist/core/session-manager.js +43 -0
  41. package/dist/core/session-manager.js.map +1 -1
  42. package/dist/core/settings-manager.d.ts +12 -0
  43. package/dist/core/settings-manager.d.ts.map +1 -1
  44. package/dist/core/settings-manager.js +28 -0
  45. package/dist/core/settings-manager.js.map +1 -1
  46. package/dist/core/system-prompt.d.ts.map +1 -1
  47. package/dist/core/system-prompt.js +16 -21
  48. package/dist/core/system-prompt.js.map +1 -1
  49. package/dist/core/tools/bash.d.ts +2 -0
  50. package/dist/core/tools/bash.d.ts.map +1 -1
  51. package/dist/core/tools/bash.js +4 -1
  52. package/dist/core/tools/bash.js.map +1 -1
  53. package/dist/core/tools/index.d.ts +4 -1
  54. package/dist/core/tools/index.d.ts.map +1 -1
  55. package/dist/core/tools/index.js +8 -3
  56. package/dist/core/tools/index.js.map +1 -1
  57. package/dist/core/tools/path-utils.d.ts +0 -1
  58. package/dist/core/tools/path-utils.d.ts.map +1 -1
  59. package/dist/core/tools/path-utils.js +0 -7
  60. package/dist/core/tools/path-utils.js.map +1 -1
  61. package/dist/core/tools/read.d.ts.map +1 -1
  62. package/dist/core/tools/read.js +2 -12
  63. package/dist/core/tools/read.js.map +1 -1
  64. package/dist/index.d.ts +4 -3
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +4 -2
  67. package/dist/index.js.map +1 -1
  68. package/dist/main.d.ts.map +1 -1
  69. package/dist/main.js +52 -14
  70. package/dist/main.js.map +1 -1
  71. package/dist/modes/interactive/components/custom-editor.d.ts +2 -2
  72. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  73. package/dist/modes/interactive/components/custom-editor.js +2 -2
  74. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  75. package/dist/modes/interactive/components/extension-editor.d.ts +2 -2
  76. package/dist/modes/interactive/components/extension-editor.d.ts.map +1 -1
  77. package/dist/modes/interactive/components/extension-editor.js +3 -3
  78. package/dist/modes/interactive/components/extension-editor.js.map +1 -1
  79. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  80. package/dist/modes/interactive/components/session-selector.js +5 -3
  81. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  82. package/dist/modes/interactive/components/settings-selector.d.ts +4 -0
  83. package/dist/modes/interactive/components/settings-selector.d.ts.map +1 -1
  84. package/dist/modes/interactive/components/settings-selector.js +24 -0
  85. package/dist/modes/interactive/components/settings-selector.js.map +1 -1
  86. package/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  87. package/dist/modes/interactive/components/tool-execution.js +3 -1
  88. package/dist/modes/interactive/components/tool-execution.js.map +1 -1
  89. package/dist/modes/interactive/interactive-mode.d.ts +1 -0
  90. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  91. package/dist/modes/interactive/interactive-mode.js +168 -80
  92. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  93. package/dist/modes/interactive/theme/theme-schema.json +23 -3
  94. package/dist/modes/print-mode.d.ts.map +1 -1
  95. package/dist/modes/print-mode.js +22 -1
  96. package/dist/modes/print-mode.js.map +1 -1
  97. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  98. package/dist/modes/rpc/rpc-mode.js +22 -1
  99. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  100. package/dist/utils/image-convert.d.ts.map +1 -1
  101. package/dist/utils/image-convert.js +8 -2
  102. package/dist/utils/image-convert.js.map +1 -1
  103. package/dist/utils/image-resize.d.ts.map +1 -1
  104. package/dist/utils/image-resize.js +14 -1
  105. package/dist/utils/image-resize.js.map +1 -1
  106. package/dist/utils/photon.d.ts +20 -0
  107. package/dist/utils/photon.d.ts.map +1 -0
  108. package/dist/utils/photon.js +39 -0
  109. package/dist/utils/photon.js.map +1 -0
  110. package/docs/extensions.md +99 -5
  111. package/docs/rpc.md +2 -0
  112. package/docs/sdk.md +1 -1
  113. package/docs/tree.md +20 -2
  114. package/examples/extensions/README.md +1 -0
  115. package/examples/extensions/custom-header.ts +2 -1
  116. package/examples/extensions/trigger-compact.ts +40 -0
  117. package/examples/extensions/with-deps/package-lock.json +2 -2
  118. package/examples/extensions/with-deps/package.json +1 -1
  119. package/package.json +4 -4
@@ -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,qBAAqB,CAAC;AAGxD,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;AAoBD;;;;;;;;;;;;GAYG;AACH,wBAAsB,WAAW,CAAC,GAAG,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CAwJxG;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,GAAG,SAAS,CAO5E","sourcesContent":["import type { ImageContent } from \"@mariozechner/pi-ai\";\nimport photon from \"@silvia-odwyer/photon-node\";\n\nexport interface ImageResizeOptions {\n\tmaxWidth?: number; // Default: 2000\n\tmaxHeight?: number; // Default: 2000\n\tmaxBytes?: number; // Default: 4.5MB (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 - 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\n/** Helper to pick the smaller of two buffers */\nfunction pickSmaller(\n\ta: { buffer: Uint8Array; mimeType: string },\n\tb: { buffer: Uint8Array; mimeType: string },\n): { buffer: Uint8Array; mimeType: string } {\n\treturn a.buffer.length <= b.buffer.length ? a : b;\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and file size.\n * Returns the original image if it already fits within the limits.\n *\n * Uses Photon (Rust/WASM) for image processing. If Photon is not available,\n * returns the original image unchanged.\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\n */\nexport async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {\n\tconst opts = { ...DEFAULT_OPTIONS, ...options };\n\tconst inputBuffer = Buffer.from(img.data, \"base64\");\n\n\tlet image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;\n\ttry {\n\t\timage = photon.PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));\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 size)\n\t\tconst originalSize = inputBuffer.length;\n\t\tif (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= 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\t// Helper to resize and encode in both formats, returning the smaller one\n\t\tfunction tryBothFormats(\n\t\t\twidth: number,\n\t\t\theight: number,\n\t\t\tjpegQuality: number,\n\t\t): { buffer: Uint8Array; mimeType: string } {\n\t\t\tconst resized = photon.resize(image!, width, height, photon.SamplingFilter.Lanczos3);\n\n\t\t\ttry {\n\t\t\t\tconst pngBuffer = resized.get_bytes();\n\t\t\t\tconst jpegBuffer = resized.get_bytes_jpeg(jpegQuality);\n\n\t\t\t\treturn pickSmaller(\n\t\t\t\t\t{ buffer: pngBuffer, mimeType: \"image/png\" },\n\t\t\t\t\t{ buffer: jpegBuffer, mimeType: \"image/jpeg\" },\n\t\t\t\t);\n\t\t\t} finally {\n\t\t\t\tresized.free();\n\t\t\t}\n\t\t}\n\n\t\t// Try to produce an image under maxBytes\n\t\tconst qualitySteps = [85, 70, 55, 40];\n\t\tconst scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];\n\n\t\tlet best: { buffer: Uint8Array; mimeType: string };\n\t\tlet finalWidth = targetWidth;\n\t\tlet finalHeight = targetHeight;\n\n\t\t// First attempt: resize to target dimensions, try both formats\n\t\tbest = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);\n\n\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\tmimeType: best.mimeType,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: finalWidth,\n\t\t\t\theight: finalHeight,\n\t\t\t\twasResized: true,\n\t\t\t};\n\t\t}\n\n\t\t// Still too large - try JPEG with decreasing quality\n\t\tfor (const quality of qualitySteps) {\n\t\t\tbest = tryBothFormats(targetWidth, targetHeight, quality);\n\n\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\treturn {\n\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\toriginalWidth,\n\t\t\t\t\toriginalHeight,\n\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\theight: finalHeight,\n\t\t\t\t\twasResized: true,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Still too large - reduce dimensions progressively\n\t\tfor (const scale of scaleSteps) {\n\t\t\tfinalWidth = Math.round(targetWidth * scale);\n\t\t\tfinalHeight = Math.round(targetHeight * scale);\n\n\t\t\tif (finalWidth < 100 || finalHeight < 100) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tfor (const quality of qualitySteps) {\n\t\t\t\tbest = tryBothFormats(finalWidth, finalHeight, quality);\n\n\t\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\t\toriginalWidth,\n\t\t\t\t\t\toriginalHeight,\n\t\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\t\theight: finalHeight,\n\t\t\t\t\t\twasResized: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Last resort: return smallest version we produced\n\t\treturn {\n\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\tmimeType: best.mimeType,\n\t\t\toriginalWidth,\n\t\t\toriginalHeight,\n\t\t\twidth: finalWidth,\n\t\t\theight: finalHeight,\n\t\t\twasResized: true,\n\t\t};\n\t} catch {\n\t\t// Failed to load image\n\t\treturn {\n\t\t\tdata: img.data,\n\t\t\tmimeType: img.mimeType,\n\t\t\toriginalWidth: 0,\n\t\t\toriginalHeight: 0,\n\t\t\twidth: 0,\n\t\t\theight: 0,\n\t\t\twasResized: false,\n\t\t};\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":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGxD,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;AAoBD;;;;;;;;;;;;GAYG;AACH,wBAAsB,WAAW,CAAC,GAAG,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,kBAAkB,GAAG,OAAO,CAAC,YAAY,CAAC,CAsKxG;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,GAAG,MAAM,GAAG,SAAS,CAO5E","sourcesContent":["import type { ImageContent } from \"@mariozechner/pi-ai\";\nimport { loadPhoton } from \"./photon.js\";\n\nexport interface ImageResizeOptions {\n\tmaxWidth?: number; // Default: 2000\n\tmaxHeight?: number; // Default: 2000\n\tmaxBytes?: number; // Default: 4.5MB (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 - 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\n/** Helper to pick the smaller of two buffers */\nfunction pickSmaller(\n\ta: { buffer: Uint8Array; mimeType: string },\n\tb: { buffer: Uint8Array; mimeType: string },\n): { buffer: Uint8Array; mimeType: string } {\n\treturn a.buffer.length <= b.buffer.length ? a : b;\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and file size.\n * Returns the original image if it already fits within the limits.\n *\n * Uses Photon (Rust/WASM) for image processing. If Photon is not available,\n * returns the original image unchanged.\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\n */\nexport async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {\n\tconst opts = { ...DEFAULT_OPTIONS, ...options };\n\tconst inputBuffer = Buffer.from(img.data, \"base64\");\n\n\tconst photon = await loadPhoton();\n\tif (!photon) {\n\t\t// Photon not available, return original image\n\t\treturn {\n\t\t\tdata: img.data,\n\t\t\tmimeType: img.mimeType,\n\t\t\toriginalWidth: 0,\n\t\t\toriginalHeight: 0,\n\t\t\twidth: 0,\n\t\t\theight: 0,\n\t\t\twasResized: false,\n\t\t};\n\t}\n\n\tlet image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;\n\ttry {\n\t\timage = photon.PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));\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 size)\n\t\tconst originalSize = inputBuffer.length;\n\t\tif (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= 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\t// Helper to resize and encode in both formats, returning the smaller one\n\t\tfunction tryBothFormats(\n\t\t\twidth: number,\n\t\t\theight: number,\n\t\t\tjpegQuality: number,\n\t\t): { buffer: Uint8Array; mimeType: string } {\n\t\t\tconst resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);\n\n\t\t\ttry {\n\t\t\t\tconst pngBuffer = resized.get_bytes();\n\t\t\t\tconst jpegBuffer = resized.get_bytes_jpeg(jpegQuality);\n\n\t\t\t\treturn pickSmaller(\n\t\t\t\t\t{ buffer: pngBuffer, mimeType: \"image/png\" },\n\t\t\t\t\t{ buffer: jpegBuffer, mimeType: \"image/jpeg\" },\n\t\t\t\t);\n\t\t\t} finally {\n\t\t\t\tresized.free();\n\t\t\t}\n\t\t}\n\n\t\t// Try to produce an image under maxBytes\n\t\tconst qualitySteps = [85, 70, 55, 40];\n\t\tconst scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];\n\n\t\tlet best: { buffer: Uint8Array; mimeType: string };\n\t\tlet finalWidth = targetWidth;\n\t\tlet finalHeight = targetHeight;\n\n\t\t// First attempt: resize to target dimensions, try both formats\n\t\tbest = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);\n\n\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\tmimeType: best.mimeType,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: finalWidth,\n\t\t\t\theight: finalHeight,\n\t\t\t\twasResized: true,\n\t\t\t};\n\t\t}\n\n\t\t// Still too large - try JPEG with decreasing quality\n\t\tfor (const quality of qualitySteps) {\n\t\t\tbest = tryBothFormats(targetWidth, targetHeight, quality);\n\n\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\treturn {\n\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\toriginalWidth,\n\t\t\t\t\toriginalHeight,\n\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\theight: finalHeight,\n\t\t\t\t\twasResized: true,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Still too large - reduce dimensions progressively\n\t\tfor (const scale of scaleSteps) {\n\t\t\tfinalWidth = Math.round(targetWidth * scale);\n\t\t\tfinalHeight = Math.round(targetHeight * scale);\n\n\t\t\tif (finalWidth < 100 || finalHeight < 100) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tfor (const quality of qualitySteps) {\n\t\t\t\tbest = tryBothFormats(finalWidth, finalHeight, quality);\n\n\t\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\t\toriginalWidth,\n\t\t\t\t\t\toriginalHeight,\n\t\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\t\theight: finalHeight,\n\t\t\t\t\t\twasResized: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Last resort: return smallest version we produced\n\t\treturn {\n\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\tmimeType: best.mimeType,\n\t\t\toriginalWidth,\n\t\t\toriginalHeight,\n\t\t\twidth: finalWidth,\n\t\t\theight: finalHeight,\n\t\t\twasResized: true,\n\t\t};\n\t} catch {\n\t\t// Failed to load image\n\t\treturn {\n\t\t\tdata: img.data,\n\t\t\tmimeType: img.mimeType,\n\t\t\toriginalWidth: 0,\n\t\t\toriginalHeight: 0,\n\t\t\twidth: 0,\n\t\t\theight: 0,\n\t\t\twasResized: false,\n\t\t};\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,4 +1,4 @@
1
- import photon from "@silvia-odwyer/photon-node";
1
+ import { loadPhoton } from "./photon.js";
2
2
  // 4.5MB - provides headroom below Anthropic's 5MB limit
3
3
  const DEFAULT_MAX_BYTES = 4.5 * 1024 * 1024;
4
4
  const DEFAULT_OPTIONS = {
@@ -27,6 +27,19 @@ function pickSmaller(a, b) {
27
27
  export async function resizeImage(img, options) {
28
28
  const opts = { ...DEFAULT_OPTIONS, ...options };
29
29
  const inputBuffer = Buffer.from(img.data, "base64");
30
+ const photon = await loadPhoton();
31
+ if (!photon) {
32
+ // Photon not available, return original image
33
+ return {
34
+ data: img.data,
35
+ mimeType: img.mimeType,
36
+ originalWidth: 0,
37
+ originalHeight: 0,
38
+ width: 0,
39
+ height: 0,
40
+ wasResized: false,
41
+ };
42
+ }
30
43
  let image;
31
44
  try {
32
45
  image = photon.PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));
@@ -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 \"@mariozechner/pi-ai\";\nimport photon from \"@silvia-odwyer/photon-node\";\n\nexport interface ImageResizeOptions {\n\tmaxWidth?: number; // Default: 2000\n\tmaxHeight?: number; // Default: 2000\n\tmaxBytes?: number; // Default: 4.5MB (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 - 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\n/** Helper to pick the smaller of two buffers */\nfunction pickSmaller(\n\ta: { buffer: Uint8Array; mimeType: string },\n\tb: { buffer: Uint8Array; mimeType: string },\n): { buffer: Uint8Array; mimeType: string } {\n\treturn a.buffer.length <= b.buffer.length ? a : b;\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and file size.\n * Returns the original image if it already fits within the limits.\n *\n * Uses Photon (Rust/WASM) for image processing. If Photon is not available,\n * returns the original image unchanged.\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\n */\nexport async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {\n\tconst opts = { ...DEFAULT_OPTIONS, ...options };\n\tconst inputBuffer = Buffer.from(img.data, \"base64\");\n\n\tlet image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;\n\ttry {\n\t\timage = photon.PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));\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 size)\n\t\tconst originalSize = inputBuffer.length;\n\t\tif (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= 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\t// Helper to resize and encode in both formats, returning the smaller one\n\t\tfunction tryBothFormats(\n\t\t\twidth: number,\n\t\t\theight: number,\n\t\t\tjpegQuality: number,\n\t\t): { buffer: Uint8Array; mimeType: string } {\n\t\t\tconst resized = photon.resize(image!, width, height, photon.SamplingFilter.Lanczos3);\n\n\t\t\ttry {\n\t\t\t\tconst pngBuffer = resized.get_bytes();\n\t\t\t\tconst jpegBuffer = resized.get_bytes_jpeg(jpegQuality);\n\n\t\t\t\treturn pickSmaller(\n\t\t\t\t\t{ buffer: pngBuffer, mimeType: \"image/png\" },\n\t\t\t\t\t{ buffer: jpegBuffer, mimeType: \"image/jpeg\" },\n\t\t\t\t);\n\t\t\t} finally {\n\t\t\t\tresized.free();\n\t\t\t}\n\t\t}\n\n\t\t// Try to produce an image under maxBytes\n\t\tconst qualitySteps = [85, 70, 55, 40];\n\t\tconst scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];\n\n\t\tlet best: { buffer: Uint8Array; mimeType: string };\n\t\tlet finalWidth = targetWidth;\n\t\tlet finalHeight = targetHeight;\n\n\t\t// First attempt: resize to target dimensions, try both formats\n\t\tbest = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);\n\n\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\tmimeType: best.mimeType,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: finalWidth,\n\t\t\t\theight: finalHeight,\n\t\t\t\twasResized: true,\n\t\t\t};\n\t\t}\n\n\t\t// Still too large - try JPEG with decreasing quality\n\t\tfor (const quality of qualitySteps) {\n\t\t\tbest = tryBothFormats(targetWidth, targetHeight, quality);\n\n\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\treturn {\n\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\toriginalWidth,\n\t\t\t\t\toriginalHeight,\n\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\theight: finalHeight,\n\t\t\t\t\twasResized: true,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Still too large - reduce dimensions progressively\n\t\tfor (const scale of scaleSteps) {\n\t\t\tfinalWidth = Math.round(targetWidth * scale);\n\t\t\tfinalHeight = Math.round(targetHeight * scale);\n\n\t\t\tif (finalWidth < 100 || finalHeight < 100) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tfor (const quality of qualitySteps) {\n\t\t\t\tbest = tryBothFormats(finalWidth, finalHeight, quality);\n\n\t\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\t\toriginalWidth,\n\t\t\t\t\t\toriginalHeight,\n\t\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\t\theight: finalHeight,\n\t\t\t\t\t\twasResized: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Last resort: return smallest version we produced\n\t\treturn {\n\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\tmimeType: best.mimeType,\n\t\t\toriginalWidth,\n\t\t\toriginalHeight,\n\t\t\twidth: finalWidth,\n\t\t\theight: finalHeight,\n\t\t\twasResized: true,\n\t\t};\n\t} catch {\n\t\t// Failed to load image\n\t\treturn {\n\t\t\tdata: img.data,\n\t\t\tmimeType: img.mimeType,\n\t\t\toriginalWidth: 0,\n\t\t\toriginalHeight: 0,\n\t\t\twidth: 0,\n\t\t\theight: 0,\n\t\t\twasResized: false,\n\t\t};\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 type { ImageContent } from \"@mariozechner/pi-ai\";\nimport { loadPhoton } from \"./photon.js\";\n\nexport interface ImageResizeOptions {\n\tmaxWidth?: number; // Default: 2000\n\tmaxHeight?: number; // Default: 2000\n\tmaxBytes?: number; // Default: 4.5MB (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 - 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\n/** Helper to pick the smaller of two buffers */\nfunction pickSmaller(\n\ta: { buffer: Uint8Array; mimeType: string },\n\tb: { buffer: Uint8Array; mimeType: string },\n): { buffer: Uint8Array; mimeType: string } {\n\treturn a.buffer.length <= b.buffer.length ? a : b;\n}\n\n/**\n * Resize an image to fit within the specified max dimensions and file size.\n * Returns the original image if it already fits within the limits.\n *\n * Uses Photon (Rust/WASM) for image processing. If Photon is not available,\n * returns the original image unchanged.\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\n */\nexport async function resizeImage(img: ImageContent, options?: ImageResizeOptions): Promise<ResizedImage> {\n\tconst opts = { ...DEFAULT_OPTIONS, ...options };\n\tconst inputBuffer = Buffer.from(img.data, \"base64\");\n\n\tconst photon = await loadPhoton();\n\tif (!photon) {\n\t\t// Photon not available, return original image\n\t\treturn {\n\t\t\tdata: img.data,\n\t\t\tmimeType: img.mimeType,\n\t\t\toriginalWidth: 0,\n\t\t\toriginalHeight: 0,\n\t\t\twidth: 0,\n\t\t\theight: 0,\n\t\t\twasResized: false,\n\t\t};\n\t}\n\n\tlet image: ReturnType<typeof photon.PhotonImage.new_from_byteslice> | undefined;\n\ttry {\n\t\timage = photon.PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer));\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 size)\n\t\tconst originalSize = inputBuffer.length;\n\t\tif (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= 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\t// Helper to resize and encode in both formats, returning the smaller one\n\t\tfunction tryBothFormats(\n\t\t\twidth: number,\n\t\t\theight: number,\n\t\t\tjpegQuality: number,\n\t\t): { buffer: Uint8Array; mimeType: string } {\n\t\t\tconst resized = photon!.resize(image!, width, height, photon!.SamplingFilter.Lanczos3);\n\n\t\t\ttry {\n\t\t\t\tconst pngBuffer = resized.get_bytes();\n\t\t\t\tconst jpegBuffer = resized.get_bytes_jpeg(jpegQuality);\n\n\t\t\t\treturn pickSmaller(\n\t\t\t\t\t{ buffer: pngBuffer, mimeType: \"image/png\" },\n\t\t\t\t\t{ buffer: jpegBuffer, mimeType: \"image/jpeg\" },\n\t\t\t\t);\n\t\t\t} finally {\n\t\t\t\tresized.free();\n\t\t\t}\n\t\t}\n\n\t\t// Try to produce an image under maxBytes\n\t\tconst qualitySteps = [85, 70, 55, 40];\n\t\tconst scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];\n\n\t\tlet best: { buffer: Uint8Array; mimeType: string };\n\t\tlet finalWidth = targetWidth;\n\t\tlet finalHeight = targetHeight;\n\n\t\t// First attempt: resize to target dimensions, try both formats\n\t\tbest = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);\n\n\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\treturn {\n\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\tmimeType: best.mimeType,\n\t\t\t\toriginalWidth,\n\t\t\t\toriginalHeight,\n\t\t\t\twidth: finalWidth,\n\t\t\t\theight: finalHeight,\n\t\t\t\twasResized: true,\n\t\t\t};\n\t\t}\n\n\t\t// Still too large - try JPEG with decreasing quality\n\t\tfor (const quality of qualitySteps) {\n\t\t\tbest = tryBothFormats(targetWidth, targetHeight, quality);\n\n\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\treturn {\n\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\toriginalWidth,\n\t\t\t\t\toriginalHeight,\n\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\theight: finalHeight,\n\t\t\t\t\twasResized: true,\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Still too large - reduce dimensions progressively\n\t\tfor (const scale of scaleSteps) {\n\t\t\tfinalWidth = Math.round(targetWidth * scale);\n\t\t\tfinalHeight = Math.round(targetHeight * scale);\n\n\t\t\tif (finalWidth < 100 || finalHeight < 100) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tfor (const quality of qualitySteps) {\n\t\t\t\tbest = tryBothFormats(finalWidth, finalHeight, quality);\n\n\t\t\t\tif (best.buffer.length <= opts.maxBytes) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\t\t\t\tmimeType: best.mimeType,\n\t\t\t\t\t\toriginalWidth,\n\t\t\t\t\t\toriginalHeight,\n\t\t\t\t\t\twidth: finalWidth,\n\t\t\t\t\t\theight: finalHeight,\n\t\t\t\t\t\twasResized: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// Last resort: return smallest version we produced\n\t\treturn {\n\t\t\tdata: Buffer.from(best.buffer).toString(\"base64\"),\n\t\t\tmimeType: best.mimeType,\n\t\t\toriginalWidth,\n\t\t\toriginalHeight,\n\t\t\twidth: finalWidth,\n\t\t\theight: finalHeight,\n\t\t\twasResized: true,\n\t\t};\n\t} catch {\n\t\t// Failed to load image\n\t\treturn {\n\t\t\tdata: img.data,\n\t\t\tmimeType: img.mimeType,\n\t\t\toriginalWidth: 0,\n\t\t\toriginalHeight: 0,\n\t\t\twidth: 0,\n\t\t\theight: 0,\n\t\t\twasResized: false,\n\t\t};\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"]}
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Photon image processing wrapper.
3
+ *
4
+ * This module provides a unified interface to @silvia-odwyer/photon-node that works in:
5
+ * 1. Node.js (development, npm run build)
6
+ * 2. Bun compiled binaries (standalone distribution)
7
+ *
8
+ * The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm')
9
+ * which bakes the build machine's absolute path into Bun compiled binaries.
10
+ *
11
+ * Solution: Lazy-load photon via dynamic import and gracefully handle failures.
12
+ * Image processing functions have fallbacks that return original images when photon isn't available.
13
+ */
14
+ export type { PhotonImage as PhotonImageType } from "@silvia-odwyer/photon-node";
15
+ /**
16
+ * Load the photon module asynchronously.
17
+ * Returns cached module on subsequent calls.
18
+ */
19
+ export declare function loadPhoton(): Promise<typeof import("@silvia-odwyer/photon-node") | null>;
20
+ //# sourceMappingURL=photon.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"photon.d.ts","sourceRoot":"","sources":["../../src/utils/photon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,YAAY,EAAE,WAAW,IAAI,eAAe,EAAE,MAAM,4BAA4B,CAAC;AAMjF;;;GAGG;AACH,wBAAsB,UAAU,IAAI,OAAO,CAAC,cAAc,4BAA4B,CAAC,GAAG,IAAI,CAAC,CAmB9F","sourcesContent":["/**\n * Photon image processing wrapper.\n *\n * This module provides a unified interface to @silvia-odwyer/photon-node that works in:\n * 1. Node.js (development, npm run build)\n * 2. Bun compiled binaries (standalone distribution)\n *\n * The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm')\n * which bakes the build machine's absolute path into Bun compiled binaries.\n *\n * Solution: Lazy-load photon via dynamic import and gracefully handle failures.\n * Image processing functions have fallbacks that return original images when photon isn't available.\n */\n\n// Re-export types from the main package\nexport type { PhotonImage as PhotonImageType } from \"@silvia-odwyer/photon-node\";\n\n// Lazy-loaded photon module\nlet photonModule: typeof import(\"@silvia-odwyer/photon-node\") | null = null;\nlet loadPromise: Promise<typeof import(\"@silvia-odwyer/photon-node\") | null> | null = null;\n\n/**\n * Load the photon module asynchronously.\n * Returns cached module on subsequent calls.\n */\nexport async function loadPhoton(): Promise<typeof import(\"@silvia-odwyer/photon-node\") | null> {\n\tif (photonModule) {\n\t\treturn photonModule;\n\t}\n\n\tif (loadPromise) {\n\t\treturn loadPromise;\n\t}\n\n\tloadPromise = (async () => {\n\t\ttry {\n\t\t\tphotonModule = await import(\"@silvia-odwyer/photon-node\");\n\t\t} catch {\n\t\t\tphotonModule = null;\n\t\t}\n\t\treturn photonModule;\n\t})();\n\n\treturn loadPromise;\n}\n"]}
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Photon image processing wrapper.
3
+ *
4
+ * This module provides a unified interface to @silvia-odwyer/photon-node that works in:
5
+ * 1. Node.js (development, npm run build)
6
+ * 2. Bun compiled binaries (standalone distribution)
7
+ *
8
+ * The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm')
9
+ * which bakes the build machine's absolute path into Bun compiled binaries.
10
+ *
11
+ * Solution: Lazy-load photon via dynamic import and gracefully handle failures.
12
+ * Image processing functions have fallbacks that return original images when photon isn't available.
13
+ */
14
+ // Lazy-loaded photon module
15
+ let photonModule = null;
16
+ let loadPromise = null;
17
+ /**
18
+ * Load the photon module asynchronously.
19
+ * Returns cached module on subsequent calls.
20
+ */
21
+ export async function loadPhoton() {
22
+ if (photonModule) {
23
+ return photonModule;
24
+ }
25
+ if (loadPromise) {
26
+ return loadPromise;
27
+ }
28
+ loadPromise = (async () => {
29
+ try {
30
+ photonModule = await import("@silvia-odwyer/photon-node");
31
+ }
32
+ catch {
33
+ photonModule = null;
34
+ }
35
+ return photonModule;
36
+ })();
37
+ return loadPromise;
38
+ }
39
+ //# sourceMappingURL=photon.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"photon.js","sourceRoot":"","sources":["../../src/utils/photon.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAKH,4BAA4B;AAC5B,IAAI,YAAY,GAAuD,IAAI,CAAC;AAC5E,IAAI,WAAW,GAAuE,IAAI,CAAC;AAE3F;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU,GAAgE;IAC/F,IAAI,YAAY,EAAE,CAAC;QAClB,OAAO,YAAY,CAAC;IACrB,CAAC;IAED,IAAI,WAAW,EAAE,CAAC;QACjB,OAAO,WAAW,CAAC;IACpB,CAAC;IAED,WAAW,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC;QAC1B,IAAI,CAAC;YACJ,YAAY,GAAG,MAAM,MAAM,CAAC,4BAA4B,CAAC,CAAC;QAC3D,CAAC;QAAC,MAAM,CAAC;YACR,YAAY,GAAG,IAAI,CAAC;QACrB,CAAC;QACD,OAAO,YAAY,CAAC;IAAA,CACpB,CAAC,EAAE,CAAC;IAEL,OAAO,WAAW,CAAC;AAAA,CACnB","sourcesContent":["/**\n * Photon image processing wrapper.\n *\n * This module provides a unified interface to @silvia-odwyer/photon-node that works in:\n * 1. Node.js (development, npm run build)\n * 2. Bun compiled binaries (standalone distribution)\n *\n * The challenge: photon-node's CJS entry uses fs.readFileSync(__dirname + '/photon_rs_bg.wasm')\n * which bakes the build machine's absolute path into Bun compiled binaries.\n *\n * Solution: Lazy-load photon via dynamic import and gracefully handle failures.\n * Image processing functions have fallbacks that return original images when photon isn't available.\n */\n\n// Re-export types from the main package\nexport type { PhotonImage as PhotonImageType } from \"@silvia-odwyer/photon-node\";\n\n// Lazy-loaded photon module\nlet photonModule: typeof import(\"@silvia-odwyer/photon-node\") | null = null;\nlet loadPromise: Promise<typeof import(\"@silvia-odwyer/photon-node\") | null> | null = null;\n\n/**\n * Load the photon module asynchronously.\n * Returns cached module on subsequent calls.\n */\nexport async function loadPhoton(): Promise<typeof import(\"@silvia-odwyer/photon-node\") | null> {\n\tif (photonModule) {\n\t\treturn photonModule;\n\t}\n\n\tif (loadPromise) {\n\t\treturn loadPromise;\n\t}\n\n\tloadPromise = (async () => {\n\t\ttry {\n\t\t\tphotonModule = await import(\"@silvia-odwyer/photon-node\");\n\t\t} catch {\n\t\t\tphotonModule = null;\n\t\t}\n\t\treturn photonModule;\n\t})();\n\n\treturn loadPromise;\n}\n"]}
@@ -384,7 +384,7 @@ pi.on("session_compact", async (event, ctx) => {
384
384
  });
385
385
  ```
386
386
 
387
- **Examples:** [custom-compaction.ts](../examples/extensions/custom-compaction.ts)
387
+ **Examples:** [custom-compaction.ts](../examples/extensions/custom-compaction.ts), [trigger-compact.ts](../examples/extensions/trigger-compact.ts)
388
388
 
389
389
  #### session_before_tree / session_tree
390
390
 
@@ -677,6 +677,33 @@ pi.on("tool_call", (event, ctx) => {
677
677
  });
678
678
  ```
679
679
 
680
+ ### ctx.getContextUsage()
681
+
682
+ Returns current context usage for the active model. Uses last assistant usage when available, then estimates tokens for trailing messages.
683
+
684
+ ```typescript
685
+ const usage = ctx.getContextUsage();
686
+ if (usage && usage.tokens > 100_000) {
687
+ // ...
688
+ }
689
+ ```
690
+
691
+ ### ctx.compact()
692
+
693
+ Trigger compaction without awaiting completion. Use `onComplete` and `onError` for follow-up actions.
694
+
695
+ ```typescript
696
+ ctx.compact({
697
+ customInstructions: "Focus on recent changes",
698
+ onComplete: (result) => {
699
+ ctx.ui.notify("Compaction completed", "info");
700
+ },
701
+ onError: (error) => {
702
+ ctx.ui.notify(`Compaction failed: ${error.message}`, "error");
703
+ },
704
+ });
705
+ ```
706
+
680
707
  ## ExtensionCommandContext
681
708
 
682
709
  Command handlers receive `ExtensionCommandContext`, which extends `ExtensionContext` with session control methods. These are only available in commands because they can deadlock if called from event handlers.
@@ -715,14 +742,14 @@ if (result.cancelled) {
715
742
  }
716
743
  ```
717
744
 
718
- ### ctx.branch(entryId)
745
+ ### ctx.fork(entryId)
719
746
 
720
- Branch from a specific entry:
747
+ Fork from a specific entry, creating a new session file:
721
748
 
722
749
  ```typescript
723
- const result = await ctx.branch("entry-id-123");
750
+ const result = await ctx.fork("entry-id-123");
724
751
  if (!result.cancelled) {
725
- // Now in the branched session
752
+ // Now in the forked session
726
753
  }
727
754
  ```
728
755
 
@@ -733,9 +760,18 @@ Navigate to a different point in the session tree:
733
760
  ```typescript
734
761
  const result = await ctx.navigateTree("entry-id-456", {
735
762
  summarize: true,
763
+ customInstructions: "Focus on error handling changes",
764
+ replaceInstructions: false, // true = replace default prompt entirely
765
+ label: "review-checkpoint",
736
766
  });
737
767
  ```
738
768
 
769
+ Options:
770
+ - `summarize`: Whether to generate a summary of the abandoned branch
771
+ - `customInstructions`: Custom instructions for the summarizer
772
+ - `replaceInstructions`: If true, `customInstructions` replaces the default prompt instead of being appended
773
+ - `label`: Label to attach to the branch summary entry (or target entry if not summarizing)
774
+
739
775
  ## ExtensionAPI Methods
740
776
 
741
777
  ### pi.on(event, handler)
@@ -868,6 +904,23 @@ if (name) {
868
904
  }
869
905
  ```
870
906
 
907
+ ### pi.setLabel(entryId, label)
908
+
909
+ Set or clear a label on an entry. Labels are user-defined markers for bookmarking and navigation (shown in `/tree` selector).
910
+
911
+ ```typescript
912
+ // Set a label
913
+ pi.setLabel(entryId, "checkpoint-before-refactor");
914
+
915
+ // Clear a label
916
+ pi.setLabel(entryId, undefined);
917
+
918
+ // Read labels via sessionManager
919
+ const label = ctx.sessionManager.getLabel(entryId);
920
+ ```
921
+
922
+ Labels persist in the session and survive restarts. Use them to mark important points (turns, checkpoints) in the conversation tree.
923
+
871
924
  ### pi.registerCommand(name, options)
872
925
 
873
926
  Register a command.
@@ -882,6 +935,25 @@ pi.registerCommand("stats", {
882
935
  });
883
936
  ```
884
937
 
938
+ Optional: add argument auto-completion for `/command ...`:
939
+
940
+ ```typescript
941
+ import type { AutocompleteItem } from "@mariozechner/pi-tui";
942
+
943
+ pi.registerCommand("deploy", {
944
+ description: "Deploy to an environment",
945
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
946
+ const envs = ["dev", "staging", "prod"];
947
+ const items = envs.map((e) => ({ value: e, label: e }));
948
+ const filtered = items.filter((i) => i.value.startsWith(prefix));
949
+ return filtered.length > 0 ? filtered : null;
950
+ },
951
+ handler: async (args, ctx) => {
952
+ ctx.ui.notify(`Deploying: ${args}`, "info");
953
+ },
954
+ });
955
+ ```
956
+
885
957
  **Examples:** [custom-footer.ts](../examples/extensions/custom-footer.ts), [custom-header.ts](../examples/extensions/custom-header.ts), [handoff.ts](../examples/extensions/handoff.ts), [pirate.ts](../examples/extensions/pirate.ts), [plan-mode/index.ts](../examples/extensions/plan-mode/index.ts), [preset.ts](../examples/extensions/preset.ts), [qna.ts](../examples/extensions/qna.ts), [send-user-message.ts](../examples/extensions/send-user-message.ts), [snake.ts](../examples/extensions/snake.ts), [summarize.ts](../examples/extensions/summarize.ts), [todo.ts](../examples/extensions/todo.ts), [tools.ts](../examples/extensions/tools.ts)
886
958
 
887
959
  ### pi.registerMessageRenderer(customType, renderer)
@@ -1248,6 +1320,28 @@ renderResult(result, { expanded, isPartial }, theme) {
1248
1320
  }
1249
1321
  ```
1250
1322
 
1323
+ #### Keybinding Hints
1324
+
1325
+ Use `keyHint()` to display keybinding hints that respect user's keybinding configuration:
1326
+
1327
+ ```typescript
1328
+ import { keyHint } from "@mariozechner/pi-coding-agent";
1329
+
1330
+ renderResult(result, { expanded }, theme) {
1331
+ let text = theme.fg("success", "✓ Done");
1332
+ if (!expanded) {
1333
+ text += ` (${keyHint("expandTools", "to expand")})`;
1334
+ }
1335
+ return new Text(text, 0, 0);
1336
+ }
1337
+ ```
1338
+
1339
+ Available functions:
1340
+ - `keyHint(action, description)` - Editor actions (e.g., `"expandTools"`, `"selectConfirm"`)
1341
+ - `appKeyHint(keybindings, action, description)` - App actions (requires `KeybindingsManager`)
1342
+ - `editorKey(action)` - Get raw key string for editor action
1343
+ - `rawKeyHint(key, description)` - Format a raw key string
1344
+
1251
1345
  #### Best Practices
1252
1346
 
1253
1347
  - Use `Text` with padding `(0, 0)` - the Box handles padding
package/docs/rpc.md CHANGED
@@ -795,6 +795,8 @@ If `reason` was `"overflow"` and compaction succeeds, `willRetry` is `true` and
795
795
 
796
796
  If compaction was aborted, `result` is `null` and `aborted` is `true`.
797
797
 
798
+ If compaction failed (e.g., API quota exceeded), `result` is `null`, `aborted` is `false`, and `errorMessage` contains the error description.
799
+
798
800
  ### auto_retry_start / auto_retry_end
799
801
 
800
802
  Emitted when automatic retry is triggered after a transient error (overloaded, rate limit, 5xx).
package/docs/sdk.md CHANGED
@@ -110,7 +110,7 @@ interface AgentSession {
110
110
 
111
111
  // Forking
112
112
  fork(entryId: string): Promise<{ selectedText: string; cancelled: boolean }>; // Creates new session file
113
- navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation
113
+ navigateTree(targetId: string, options?: { summarize?: boolean; customInstructions?: string; replaceInstructions?: boolean; label?: string }): Promise<{ editorText?: string; cancelled: boolean }>; // In-place navigation
114
114
 
115
115
  // Hook message injection
116
116
  sendHookMessage(message: HookMessage, triggerTurn?: boolean): Promise<void>;
package/docs/tree.md CHANGED
@@ -110,10 +110,21 @@ interface BranchSummaryEntry {
110
110
  ```typescript
111
111
  async navigateTree(
112
112
  targetId: string,
113
- options?: { summarize?: boolean; customInstructions?: string }
113
+ options?: {
114
+ summarize?: boolean;
115
+ customInstructions?: string;
116
+ replaceInstructions?: boolean;
117
+ label?: string;
118
+ }
114
119
  ): Promise<{ editorText?: string; cancelled: boolean }>
115
120
  ```
116
121
 
122
+ Options:
123
+ - `summarize`: Whether to generate a summary of the abandoned branch
124
+ - `customInstructions`: Custom instructions for the summarizer
125
+ - `replaceInstructions`: If true, `customInstructions` replaces the default prompt instead of being appended
126
+ - `label`: Label to attach to the branch summary entry (or target entry if not summarizing)
127
+
117
128
  Flow:
118
129
  1. Validate target, check no-op (target === current leaf)
119
130
  2. Find common ancestor between old leaf and target
@@ -153,21 +164,28 @@ interface TreePreparation {
153
164
  commonAncestorId: string | null;
154
165
  entriesToSummarize: SessionEntry[];
155
166
  userWantsSummary: boolean;
167
+ customInstructions?: string;
168
+ replaceInstructions?: boolean;
169
+ label?: string;
156
170
  }
157
171
 
158
172
  interface SessionBeforeTreeEvent {
159
173
  type: "session_before_tree";
160
174
  preparation: TreePreparation;
161
- model: Model;
162
175
  signal: AbortSignal;
163
176
  }
164
177
 
165
178
  interface SessionBeforeTreeResult {
166
179
  cancel?: boolean;
167
180
  summary?: { summary: string; details?: unknown };
181
+ customInstructions?: string; // Override custom instructions
182
+ replaceInstructions?: boolean; // Override replace mode
183
+ label?: string; // Override label
168
184
  }
169
185
  ```
170
186
 
187
+ Extensions can override `customInstructions`, `replaceInstructions`, and `label` by returning them from the `session_before_tree` handler.
188
+
171
189
  ### `session_tree`
172
190
 
173
191
  ```typescript
@@ -77,6 +77,7 @@ cp permission-gate.ts ~/.pi/agent/extensions/
77
77
  | `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt |
78
78
  | `claude-rules.ts` | Scans `.claude/rules/` folder and lists rules in system prompt |
79
79
  | `custom-compaction.ts` | Custom compaction that summarizes entire conversation |
80
+ | `trigger-compact.ts` | Triggers compaction when context usage exceeds 100k tokens and adds `/trigger-compact` command |
80
81
 
81
82
  ### System Integration
82
83
 
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
9
+ import { VERSION } from "@mariozechner/pi-coding-agent";
9
10
 
10
11
  // --- PI MASCOT ---
11
12
  // Based on pi_mascot.ts - the pi agent character
@@ -52,7 +53,7 @@ export default function (pi: ExtensionAPI) {
52
53
  render(_width: number): string[] {
53
54
  const mascotLines = getPiMascot(theme);
54
55
  // Add a subtitle with hint
55
- const subtitle = theme.fg("muted", " shitty coding agent");
56
+ const subtitle = `${theme.fg("muted", " shitty coding agent")}${theme.fg("dim", ` v${VERSION}`)}`;
56
57
  return [...mascotLines, subtitle];
57
58
  },
58
59
  invalidate() {},
@@ -0,0 +1,40 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+
3
+ const COMPACT_THRESHOLD_TOKENS = 100_000;
4
+
5
+ export default function (pi: ExtensionAPI) {
6
+ const triggerCompaction = (ctx: ExtensionContext, customInstructions?: string) => {
7
+ if (ctx.hasUI) {
8
+ ctx.ui.notify("Compaction started", "info");
9
+ }
10
+ ctx.compact({
11
+ customInstructions,
12
+ onComplete: () => {
13
+ if (ctx.hasUI) {
14
+ ctx.ui.notify("Compaction completed", "info");
15
+ }
16
+ },
17
+ onError: (error) => {
18
+ if (ctx.hasUI) {
19
+ ctx.ui.notify(`Compaction failed: ${error.message}`, "error");
20
+ }
21
+ },
22
+ });
23
+ };
24
+
25
+ pi.on("turn_end", (_event, ctx) => {
26
+ const usage = ctx.getContextUsage();
27
+ if (!usage || usage.tokens <= COMPACT_THRESHOLD_TOKENS) {
28
+ return;
29
+ }
30
+ triggerCompaction(ctx);
31
+ });
32
+
33
+ pi.registerCommand("trigger-compact", {
34
+ description: "Trigger compaction immediately",
35
+ handler: async (args, ctx) => {
36
+ const instructions = args.trim() || undefined;
37
+ triggerCompaction(ctx, instructions);
38
+ },
39
+ });
40
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pi-extension-with-deps",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pi-extension-with-deps",
9
- "version": "1.11.0",
9
+ "version": "1.13.0",
10
10
  "dependencies": {
11
11
  "ms": "^2.1.3"
12
12
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-extension-with-deps",
3
3
  "private": true,
4
- "version": "1.11.0",
4
+ "version": "1.13.0",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "clean": "echo 'nothing to clean'",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mariozechner/pi-coding-agent",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
5
5
  "type": "module",
6
6
  "piConfig": {
@@ -40,9 +40,9 @@
40
40
  "dependencies": {
41
41
  "@mariozechner/clipboard": "^0.3.0",
42
42
  "@mariozechner/jiti": "^2.6.2",
43
- "@mariozechner/pi-agent-core": "^0.47.0",
44
- "@mariozechner/pi-ai": "^0.47.0",
45
- "@mariozechner/pi-tui": "^0.47.0",
43
+ "@mariozechner/pi-agent-core": "^0.49.0",
44
+ "@mariozechner/pi-ai": "^0.49.0",
45
+ "@mariozechner/pi-tui": "^0.49.0",
46
46
  "@silvia-odwyer/photon-node": "^0.3.4",
47
47
  "chalk": "^5.5.0",
48
48
  "cli-highlight": "^2.1.11",