@emblemvault/hustle-react 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1820,35 +1820,52 @@ var pluginRegistry = new PluginRegistry();
1820
1820
  function getStorageKey(instanceId) {
1821
1821
  return `hustle-plugins-${instanceId}`;
1822
1822
  }
1823
- function usePlugins(instanceId = "default") {
1823
+ function getInstanceId(providedId) {
1824
+ if (providedId) return providedId;
1825
+ if (typeof window !== "undefined") {
1826
+ const globalId = window.__hustleInstanceId;
1827
+ if (globalId) return globalId;
1828
+ }
1829
+ return "default";
1830
+ }
1831
+ function usePlugins(instanceId) {
1832
+ const [resolvedInstanceId] = useState(() => getInstanceId(instanceId));
1824
1833
  const [plugins, setPlugins] = useState([]);
1825
1834
  useEffect(() => {
1826
- setPlugins(pluginRegistry.loadFromStorage(instanceId));
1827
- const unsubscribe = pluginRegistry.onChange(setPlugins, instanceId);
1828
- const storageKey = getStorageKey(instanceId);
1835
+ setPlugins(pluginRegistry.loadFromStorage(resolvedInstanceId));
1836
+ const unsubscribe = pluginRegistry.onChange(setPlugins, resolvedInstanceId);
1837
+ const storageKey = getStorageKey(resolvedInstanceId);
1829
1838
  const handleStorage = (e) => {
1830
1839
  if (e.key === storageKey) {
1831
- setPlugins(pluginRegistry.loadFromStorage(instanceId));
1840
+ setPlugins(pluginRegistry.loadFromStorage(resolvedInstanceId));
1832
1841
  }
1833
1842
  };
1834
1843
  window.addEventListener("storage", handleStorage);
1844
+ const handlePluginInstalled = (e) => {
1845
+ const customEvent = e;
1846
+ if (customEvent.detail.instanceId === resolvedInstanceId) {
1847
+ setPlugins(pluginRegistry.loadFromStorage(resolvedInstanceId));
1848
+ }
1849
+ };
1850
+ window.addEventListener("hustle-plugin-installed", handlePluginInstalled);
1835
1851
  return () => {
1836
1852
  unsubscribe();
1837
1853
  window.removeEventListener("storage", handleStorage);
1854
+ window.removeEventListener("hustle-plugin-installed", handlePluginInstalled);
1838
1855
  };
1839
- }, [instanceId]);
1856
+ }, [resolvedInstanceId]);
1840
1857
  const registerPlugin = useCallback((plugin) => {
1841
- pluginRegistry.register(plugin, true, instanceId);
1842
- }, [instanceId]);
1858
+ pluginRegistry.register(plugin, true, resolvedInstanceId);
1859
+ }, [resolvedInstanceId]);
1843
1860
  const unregisterPlugin = useCallback((name) => {
1844
- pluginRegistry.unregister(name, instanceId);
1845
- }, [instanceId]);
1861
+ pluginRegistry.unregister(name, resolvedInstanceId);
1862
+ }, [resolvedInstanceId]);
1846
1863
  const enablePlugin = useCallback((name) => {
1847
- pluginRegistry.setEnabled(name, true, instanceId);
1848
- }, [instanceId]);
1864
+ pluginRegistry.setEnabled(name, true, resolvedInstanceId);
1865
+ }, [resolvedInstanceId]);
1849
1866
  const disablePlugin = useCallback((name) => {
1850
- pluginRegistry.setEnabled(name, false, instanceId);
1851
- }, [instanceId]);
1867
+ pluginRegistry.setEnabled(name, false, resolvedInstanceId);
1868
+ }, [resolvedInstanceId]);
1852
1869
  const isRegistered = useCallback(
1853
1870
  (name) => plugins.some((p) => p.name === name),
1854
1871
  [plugins]
@@ -1903,6 +1920,42 @@ function HustleProvider({
1903
1920
  }
1904
1921
  };
1905
1922
  }, [isAutoInstance, resolvedInstanceId]);
1923
+ useEffect(() => {
1924
+ if (typeof window !== "undefined") {
1925
+ const win = window;
1926
+ win.__hustleInstanceId = resolvedInstanceId;
1927
+ win.__hustleRegisterPlugin = async (plugin, enabled = true) => {
1928
+ const hydrated = hydratePlugin(plugin);
1929
+ pluginRegistry.register(hydrated, enabled, resolvedInstanceId);
1930
+ };
1931
+ win.__hustleUnregisterPlugin = async (name) => {
1932
+ pluginRegistry.unregister(name, resolvedInstanceId);
1933
+ };
1934
+ win.__hustleListPlugins = () => {
1935
+ const plugins = pluginRegistry.loadFromStorage(resolvedInstanceId);
1936
+ return plugins.map((p) => ({
1937
+ name: p.name,
1938
+ version: p.version,
1939
+ description: p.description || "",
1940
+ enabled: p.enabled
1941
+ }));
1942
+ };
1943
+ win.__hustleGetPlugin = (name) => {
1944
+ const plugins = pluginRegistry.loadFromStorage(resolvedInstanceId);
1945
+ return plugins.find((p) => p.name === name) || null;
1946
+ };
1947
+ }
1948
+ return () => {
1949
+ if (typeof window !== "undefined") {
1950
+ const win = window;
1951
+ delete win.__hustleInstanceId;
1952
+ delete win.__hustleRegisterPlugin;
1953
+ delete win.__hustleUnregisterPlugin;
1954
+ delete win.__hustleListPlugins;
1955
+ delete win.__hustleGetPlugin;
1956
+ }
1957
+ };
1958
+ }, [resolvedInstanceId]);
1906
1959
  const isApiKeyMode = Boolean(apiKey && vaultId);
1907
1960
  const authContext = useEmblemAuthOptional();
1908
1961
  const authSDK = isApiKeyMode ? null : authContext?.authSDK ?? null;
@@ -2050,6 +2103,19 @@ function HustleProvider({
2050
2103
  };
2051
2104
  registerPlugins();
2052
2105
  }, [client, enabledPlugins, log]);
2106
+ useEffect(() => {
2107
+ if (typeof window !== "undefined" && client) {
2108
+ const win = window;
2109
+ win.__hustleUploadFile = async (file, fileName) => {
2110
+ return await client.uploadFile(file, fileName);
2111
+ };
2112
+ }
2113
+ return () => {
2114
+ if (typeof window !== "undefined") {
2115
+ delete window.__hustleUploadFile;
2116
+ }
2117
+ };
2118
+ }, [client]);
2053
2119
  const loadModels = useCallback(async () => {
2054
2120
  if (!client) {
2055
2121
  log("Cannot load models - client not ready");
@@ -2930,12 +2996,16 @@ function ensureModalStyles() {
2930
2996
  left: 0;
2931
2997
  right: 0;
2932
2998
  bottom: 0;
2933
- background: rgba(0, 0, 0, 0.6);
2999
+ background: transparent;
2934
3000
  display: flex;
2935
3001
  align-items: center;
2936
3002
  justify-content: center;
2937
3003
  z-index: 10000;
2938
- animation: uqFadeIn 0.2s ease-out;
3004
+ pointer-events: none;
3005
+ }
3006
+
3007
+ .user-question-modal {
3008
+ pointer-events: auto;
2939
3009
  }
2940
3010
 
2941
3011
  @keyframes uqFadeIn {
@@ -3048,6 +3118,27 @@ function ensureModalStyles() {
3048
3118
  color: #666;
3049
3119
  cursor: not-allowed;
3050
3120
  }
3121
+
3122
+ .user-question-custom-input {
3123
+ width: 100%;
3124
+ padding: 8px 12px;
3125
+ margin-top: 8px;
3126
+ background: #1a1a2e;
3127
+ border: 1px solid #444;
3128
+ border-radius: 6px;
3129
+ color: #e0e0e0;
3130
+ font-size: 14px;
3131
+ box-sizing: border-box;
3132
+ }
3133
+
3134
+ .user-question-custom-input:focus {
3135
+ outline: none;
3136
+ border-color: #4a7aff;
3137
+ }
3138
+
3139
+ .user-question-custom-input::placeholder {
3140
+ color: #666;
3141
+ }
3051
3142
  `;
3052
3143
  document.head.appendChild(styles2);
3053
3144
  }
@@ -3069,13 +3160,17 @@ var askUserTool = {
3069
3160
  allowMultiple: {
3070
3161
  type: "boolean",
3071
3162
  description: "If true, user can select multiple choices. Default: false"
3163
+ },
3164
+ allowCustom: {
3165
+ type: "boolean",
3166
+ description: 'If true, adds an "Other" option where user can type a custom response. Default: false'
3072
3167
  }
3073
3168
  },
3074
3169
  required: ["question", "choices"]
3075
3170
  }
3076
3171
  };
3077
3172
  var askUserExecutor = async (args2) => {
3078
- const { question, choices, allowMultiple = false } = args2;
3173
+ const { question, choices, allowMultiple = false, allowCustom = false } = args2;
3079
3174
  if (!question || !choices || !Array.isArray(choices) || choices.length === 0) {
3080
3175
  return {
3081
3176
  question: question || "",
@@ -3107,6 +3202,17 @@ var askUserExecutor = async (args2) => {
3107
3202
  choicesDiv.className = "user-question-choices";
3108
3203
  const inputType = allowMultiple ? "checkbox" : "radio";
3109
3204
  const inputName = `uq-${Date.now()}`;
3205
+ let customInput = null;
3206
+ let isCustomSelected = false;
3207
+ const submitBtn = document.createElement("button");
3208
+ submitBtn.className = "user-question-btn user-question-btn-submit";
3209
+ submitBtn.textContent = "Submit";
3210
+ submitBtn.disabled = true;
3211
+ const updateSubmitButton = () => {
3212
+ const hasSelection = selected.size > 0;
3213
+ const hasCustomValue = isCustomSelected && customInput && customInput.value.trim().length > 0;
3214
+ submitBtn.disabled = !hasSelection && !hasCustomValue;
3215
+ };
3110
3216
  choices.forEach((choice, index) => {
3111
3217
  const choiceDiv = document.createElement("div");
3112
3218
  choiceDiv.className = "user-question-choice";
@@ -3131,6 +3237,8 @@ var askUserExecutor = async (args2) => {
3131
3237
  }
3132
3238
  } else {
3133
3239
  selected.clear();
3240
+ isCustomSelected = false;
3241
+ if (customInput) customInput.value = "";
3134
3242
  selected.add(choice);
3135
3243
  choicesDiv.querySelectorAll(".user-question-choice").forEach((c) => c.classList.remove("selected"));
3136
3244
  choiceDiv.classList.add("selected");
@@ -3146,9 +3254,62 @@ var askUserExecutor = async (args2) => {
3146
3254
  });
3147
3255
  choicesDiv.appendChild(choiceDiv);
3148
3256
  });
3257
+ if (allowCustom) {
3258
+ const customChoiceDiv = document.createElement("div");
3259
+ customChoiceDiv.className = "user-question-choice";
3260
+ const customRadio = document.createElement("input");
3261
+ customRadio.type = inputType;
3262
+ customRadio.name = inputName;
3263
+ customRadio.id = `${inputName}-custom`;
3264
+ customRadio.value = "__custom__";
3265
+ const customLabel = document.createElement("label");
3266
+ customLabel.htmlFor = customRadio.id;
3267
+ customLabel.textContent = "Other:";
3268
+ customLabel.style.flexShrink = "0";
3269
+ customInput = document.createElement("input");
3270
+ customInput.type = "text";
3271
+ customInput.className = "user-question-custom-input";
3272
+ customInput.placeholder = "Type your answer...";
3273
+ customInput.style.marginTop = "0";
3274
+ customInput.style.marginLeft = "8px";
3275
+ customInput.style.flex = "1";
3276
+ customChoiceDiv.appendChild(customRadio);
3277
+ customChoiceDiv.appendChild(customLabel);
3278
+ customChoiceDiv.appendChild(customInput);
3279
+ const handleCustomSelect = () => {
3280
+ if (!allowMultiple) {
3281
+ selected.clear();
3282
+ choicesDiv.querySelectorAll(".user-question-choice").forEach((c) => c.classList.remove("selected"));
3283
+ }
3284
+ isCustomSelected = true;
3285
+ customChoiceDiv.classList.add("selected");
3286
+ customInput?.focus();
3287
+ updateSubmitButton();
3288
+ };
3289
+ customRadio.addEventListener("change", handleCustomSelect);
3290
+ customChoiceDiv.addEventListener("click", (e) => {
3291
+ if (e.target !== customRadio && e.target !== customInput) {
3292
+ customRadio.checked = true;
3293
+ handleCustomSelect();
3294
+ }
3295
+ });
3296
+ customInput.addEventListener("focus", () => {
3297
+ if (!customRadio.checked) {
3298
+ customRadio.checked = true;
3299
+ handleCustomSelect();
3300
+ }
3301
+ });
3302
+ customInput.addEventListener("input", updateSubmitButton);
3303
+ choicesDiv.appendChild(customChoiceDiv);
3304
+ }
3149
3305
  modal.appendChild(choicesDiv);
3150
3306
  const actions = document.createElement("div");
3151
3307
  actions.className = "user-question-actions";
3308
+ overlay.appendChild(modal);
3309
+ document.body.appendChild(overlay);
3310
+ const cleanup = () => {
3311
+ overlay.remove();
3312
+ };
3152
3313
  const cancelBtn = document.createElement("button");
3153
3314
  cancelBtn.className = "user-question-btn user-question-btn-cancel";
3154
3315
  cancelBtn.textContent = "Skip";
@@ -3160,29 +3321,21 @@ var askUserExecutor = async (args2) => {
3160
3321
  answered: false
3161
3322
  });
3162
3323
  };
3163
- const submitBtn = document.createElement("button");
3164
- submitBtn.className = "user-question-btn user-question-btn-submit";
3165
- submitBtn.textContent = "Submit";
3166
- submitBtn.disabled = true;
3167
3324
  submitBtn.onclick = () => {
3168
3325
  cleanup();
3326
+ const results = Array.from(selected);
3327
+ if (isCustomSelected && customInput && customInput.value.trim()) {
3328
+ results.push(customInput.value.trim());
3329
+ }
3169
3330
  resolve({
3170
3331
  question,
3171
- selectedChoices: Array.from(selected),
3332
+ selectedChoices: results,
3172
3333
  answered: true
3173
3334
  });
3174
3335
  };
3175
- const updateSubmitButton = () => {
3176
- submitBtn.disabled = selected.size === 0;
3177
- };
3178
3336
  actions.appendChild(cancelBtn);
3179
3337
  actions.appendChild(submitBtn);
3180
3338
  modal.appendChild(actions);
3181
- overlay.appendChild(modal);
3182
- document.body.appendChild(overlay);
3183
- const cleanup = () => {
3184
- overlay.remove();
3185
- };
3186
3339
  const handleEscape = (e) => {
3187
3340
  if (e.key === "Escape") {
3188
3341
  document.removeEventListener("keydown", handleEscape);
@@ -3351,6 +3504,11 @@ var screenshotTool = {
3351
3504
 
3352
3505
  The screenshot captures the visible viewport of the page. The image is uploaded to the server and a permanent URL is returned.
3353
3506
 
3507
+ IMPORTANT: Before taking a screenshot, use the ask_user tool (if available) to ask which size they prefer:
3508
+ - "full" (100%) - highest quality, larger file
3509
+ - "half" (50%) - good balance of quality and size
3510
+ - "quarter" (25%) - smallest file, faster upload
3511
+
3354
3512
  Use this when:
3355
3513
  - User asks to see what's on their screen
3356
3514
  - You need to analyze the current page visually
@@ -3361,12 +3519,24 @@ Use this when:
3361
3519
  selector: {
3362
3520
  type: "string",
3363
3521
  description: "Optional CSS selector to capture a specific element instead of the full page. Leave empty for full page screenshot."
3522
+ },
3523
+ size: {
3524
+ type: "string",
3525
+ enum: ["full", "half", "quarter"],
3526
+ description: 'Image size: "full" (100%), "half" (50%), or "quarter" (25%). Ask the user which size they prefer before capturing.'
3364
3527
  }
3365
3528
  }
3366
3529
  }
3367
3530
  };
3368
3531
  var screenshotExecutor = async (args2) => {
3369
3532
  const selector = args2.selector;
3533
+ const size = args2.size || "full";
3534
+ const scaleMap = {
3535
+ full: 1,
3536
+ half: 0.5,
3537
+ quarter: 0.25
3538
+ };
3539
+ const scale = scaleMap[size] || 1;
3370
3540
  if (typeof window === "undefined" || typeof document === "undefined") {
3371
3541
  return {
3372
3542
  success: false,
@@ -3387,34 +3557,41 @@ var screenshotExecutor = async (args2) => {
3387
3557
  if (!target) {
3388
3558
  return { success: false, error: "Element not found: " + selector };
3389
3559
  }
3390
- const canvas = await window.html2canvas(target, {
3560
+ const fullCanvas = await window.html2canvas(target, {
3391
3561
  useCORS: true,
3392
3562
  allowTaint: true,
3393
3563
  backgroundColor: "#000000",
3394
3564
  scale: 1
3395
3565
  });
3566
+ let finalCanvas = fullCanvas;
3567
+ if (scale < 1) {
3568
+ const resizedCanvas = document.createElement("canvas");
3569
+ resizedCanvas.width = Math.round(fullCanvas.width * scale);
3570
+ resizedCanvas.height = Math.round(fullCanvas.height * scale);
3571
+ const ctx = resizedCanvas.getContext("2d");
3572
+ if (ctx) {
3573
+ ctx.drawImage(fullCanvas, 0, 0, resizedCanvas.width, resizedCanvas.height);
3574
+ finalCanvas = resizedCanvas;
3575
+ }
3576
+ }
3396
3577
  const blob = await new Promise((resolve) => {
3397
- canvas.toBlob(resolve, "image/png", 0.9);
3578
+ finalCanvas.toBlob(resolve, "image/png", 0.9);
3398
3579
  });
3399
3580
  if (!blob) {
3400
3581
  return { success: false, error: "Failed to create image blob" };
3401
3582
  }
3402
- const formData = new FormData();
3403
- formData.append("file", blob, "screenshot-" + Date.now() + ".png");
3404
- const response = await fetch("/api/files/upload", {
3405
- method: "POST",
3406
- body: formData
3407
- });
3408
- if (!response.ok) {
3409
- const errorData = await response.json().catch(() => ({}));
3410
- return { success: false, error: errorData.error || "Upload failed" };
3583
+ const uploadFn = window.__hustleUploadFile;
3584
+ if (!uploadFn) {
3585
+ return { success: false, error: "Upload not available. Make sure HustleProvider is mounted and client is ready." };
3411
3586
  }
3412
- const data = await response.json();
3587
+ const fileName = "screenshot-" + Date.now() + ".png";
3588
+ const attachment = await uploadFn(blob, fileName);
3589
+ const sizeLabel = size === "full" ? "100%" : size === "half" ? "50%" : "25%";
3413
3590
  return {
3414
3591
  success: true,
3415
- url: data.url,
3416
- contentType: data.contentType,
3417
- message: "Screenshot captured and uploaded successfully"
3592
+ url: attachment.url,
3593
+ contentType: attachment.contentType || "image/png",
3594
+ message: `Screenshot captured at ${sizeLabel} size (${finalCanvas.width}x${finalCanvas.height}) and uploaded successfully`
3418
3595
  };
3419
3596
  } catch (e) {
3420
3597
  const err = e;
@@ -3482,21 +3659,89 @@ Write them as arrow function strings that will be eval'd:
3482
3659
  "async (args) => { const { city } = args; return { weather: 'sunny', city }; }"
3483
3660
 
3484
3661
  The function receives args as Record<string, unknown> and should return the result.
3485
- You can use fetch(), standard browser APIs, and async/await.
3662
+
3663
+ ## Available in Executor Scope
3664
+
3665
+ Executors run in the browser context with full access to:
3666
+
3667
+ ### Browser APIs
3668
+ - **fetch(url, options)** - HTTP requests (subject to CORS)
3669
+ - **localStorage / sessionStorage** - Persistent storage
3670
+ - **document** - Full DOM access (create elements, modals, forms, etc.)
3671
+ - **window** - Global window object
3672
+ - **console** - Logging (log, warn, error, etc.)
3673
+ - **setTimeout / setInterval / clearTimeout / clearInterval** - Timers
3674
+ - **JSON** - Parse and stringify
3675
+ - **Date** - Date/time operations
3676
+ - **URL / URLSearchParams** - URL manipulation
3677
+ - **FormData / Blob / File / FileReader** - File handling
3678
+ - **crypto** - Cryptographic operations (crypto.randomUUID(), etc.)
3679
+ - **navigator** - Browser info, clipboard, geolocation, etc.
3680
+ - **location** - Current URL info
3681
+ - **history** - Browser history navigation
3682
+ - **WebSocket** - Real-time bidirectional communication
3683
+ - **EventSource** - Server-sent events
3684
+ - **indexedDB** - Client-side database for large data
3685
+ - **Notification** - Browser notifications (requires permission)
3686
+ - **performance** - Performance timing
3687
+ - **atob / btoa** - Base64 encoding/decoding
3688
+ - **TextEncoder / TextDecoder** - Text encoding
3689
+ - **AbortController** - Cancel fetch requests
3690
+ - **IntersectionObserver / MutationObserver / ResizeObserver** - DOM observers
3691
+ - **requestAnimationFrame** - Animation timing
3692
+ - **speechSynthesis** - Text-to-speech
3693
+ - **Audio / Image / Canvas** - Media APIs
3694
+
3695
+ ### Hustle Plugin System Globals
3696
+ - **window.__hustleInstanceId** - Current Hustle instance ID
3697
+ - **window.__hustleRegisterPlugin(plugin, enabled)** - Install another plugin dynamically
3698
+ - **window.__hustleUnregisterPlugin(name)** - Uninstall a plugin by name
3699
+ - **window.__hustleUploadFile(file, fileName?)** - Upload a File/Blob to the server, returns { url, contentType }
3700
+ - **window.__hustleListPlugins()** - List all installed plugins
3701
+ - **window.__hustleGetPlugin(name)** - Get a specific plugin by name
3702
+
3703
+ ### DOM Manipulation Examples
3704
+ Create a modal: document.createElement('div'), style it, append to document.body
3705
+ Add event listeners: element.addEventListener('click', handler)
3706
+ Query elements: document.querySelector(), document.querySelectorAll()
3707
+
3708
+ ### Storage Patterns
3709
+ Store data: localStorage.setItem('key', JSON.stringify(data))
3710
+ Retrieve data: JSON.parse(localStorage.getItem('key') || '{}')
3711
+ Namespace your keys: Use plugin name prefix like "myplugin-settings"
3712
+
3713
+ ### Async Patterns
3714
+ All executors should be async. Use await for promises:
3715
+ "async (args) => { const res = await fetch(url); return await res.json(); }"
3486
3716
 
3487
3717
  ## Lifecycle Hooks (Optional)
3488
3718
 
3489
- - **beforeRequestCode**: Modify messages before sending. Receives request object, must return modified request.
3719
+ Hooks also have full access to the browser scope described above.
3720
+
3721
+ - **onRegisterCode**: Called once when plugin is registered/enabled. Good for initialization.
3722
+ **IMPORTANT: Always log when your plugin registers so users know it's active!**
3723
+ Example: "async () => { console.log('[MyPlugin] v1.0.0 registered'); }"
3724
+
3725
+ - **beforeRequestCode**: Modify messages before sending. Receives request object with { messages, model, ... }. Must return the modified request.
3490
3726
  Example: "async (req) => { req.messages = req.messages.map(m => ({...m, content: m.content.toUpperCase()})); return req; }"
3491
3727
 
3492
- - **afterResponseCode**: Process response after receiving. Receives response object.
3493
- Example: "async (res) => { console.log('Response:', res.content); }"
3728
+ - **afterResponseCode**: Process/modify response after receiving. Receives response object with { content, ... }.
3729
+ Example: "async (res) => { console.log('Response received:', res.content.substring(0, 100)); }"
3730
+
3731
+ - **onErrorCode**: Called on errors. Receives (error, context) where context has { phase: 'beforeRequest'|'execute'|'afterResponse' }.
3732
+ Example: "async (error, ctx) => { console.error('[MyPlugin] Error in', ctx.phase, ':', error.message); }"
3733
+
3734
+ ## Best Practices
3494
3735
 
3495
- - **onRegisterCode**: Called when plugin is registered.
3496
- Example: "async () => { console.log('Plugin loaded!'); }"
3736
+ 1. **Always add onRegisterCode** that logs the plugin name and version
3737
+ 2. **Namespace console logs** with [PluginName] prefix for easy identification
3738
+ 3. **Handle errors gracefully** in executors - return { error: message } instead of throwing
3497
3739
 
3498
- - **onErrorCode**: Called on errors. Receives (error, context).
3499
- Example: "async (error, ctx) => { console.error('Error in', ctx.phase, error); }"`,
3740
+ ## Security Notes
3741
+ - Code runs in browser sandbox with same-origin policy
3742
+ - fetch() is subject to CORS restrictions
3743
+ - No direct filesystem access (use File API with user interaction)
3744
+ - Be careful with eval() on user input`,
3500
3745
  parameters: {
3501
3746
  type: "object",
3502
3747
  properties: {
@@ -3585,6 +3830,104 @@ var installPluginTool = {
3585
3830
  required: ["plugin"]
3586
3831
  }
3587
3832
  };
3833
+ var uninstallPluginTool = {
3834
+ name: "uninstall_plugin",
3835
+ description: "Uninstall a plugin by name, removing it from browser storage.",
3836
+ parameters: {
3837
+ type: "object",
3838
+ properties: {
3839
+ name: {
3840
+ type: "string",
3841
+ description: "The name of the plugin to uninstall"
3842
+ }
3843
+ },
3844
+ required: ["name"]
3845
+ }
3846
+ };
3847
+ var listPluginsTool = {
3848
+ name: "list_plugins",
3849
+ description: "List all installed plugins with their enabled/disabled status.",
3850
+ parameters: {
3851
+ type: "object",
3852
+ properties: {},
3853
+ required: []
3854
+ }
3855
+ };
3856
+ var modifyPluginTool = {
3857
+ name: "modify_plugin",
3858
+ description: `Modify an existing installed plugin. Can update version, description, tools, executors, and hooks.
3859
+
3860
+ Use list_plugins first to see installed plugins, then modify by name.
3861
+
3862
+ You can:
3863
+ - Add new tools (provide tools array with new tools to add)
3864
+ - Update existing tools (provide tool with same name)
3865
+ - Remove tools (set removeTool to the tool name)
3866
+ - Update hooks (provide hook code)
3867
+ - Update version/description
3868
+
3869
+ Example: Add a new tool to existing plugin:
3870
+ {
3871
+ "name": "my-plugin",
3872
+ "addTools": [{ "name": "new_tool", "description": "...", "parameters": {...} }],
3873
+ "addExecutorCode": { "new_tool": "async (args) => { ... }" }
3874
+ }`,
3875
+ parameters: {
3876
+ type: "object",
3877
+ properties: {
3878
+ name: {
3879
+ type: "string",
3880
+ description: "Name of the plugin to modify (required)"
3881
+ },
3882
+ version: {
3883
+ type: "string",
3884
+ description: "New version string"
3885
+ },
3886
+ description: {
3887
+ type: "string",
3888
+ description: "New description"
3889
+ },
3890
+ addTools: {
3891
+ type: "array",
3892
+ description: "Tools to add or update",
3893
+ items: {
3894
+ type: "object",
3895
+ properties: {
3896
+ name: { type: "string" },
3897
+ description: { type: "string" },
3898
+ parameters: { type: "object" }
3899
+ }
3900
+ }
3901
+ },
3902
+ addExecutorCode: {
3903
+ type: "object",
3904
+ description: "Executor code to add/update (tool name -> code string)"
3905
+ },
3906
+ removeTools: {
3907
+ type: "array",
3908
+ description: "Names of tools to remove",
3909
+ items: { type: "string" }
3910
+ },
3911
+ onRegisterCode: {
3912
+ type: "string",
3913
+ description: "New onRegister hook code"
3914
+ },
3915
+ beforeRequestCode: {
3916
+ type: "string",
3917
+ description: "New beforeRequest hook code"
3918
+ },
3919
+ afterResponseCode: {
3920
+ type: "string",
3921
+ description: "New afterResponse hook code"
3922
+ },
3923
+ onErrorCode: {
3924
+ type: "string",
3925
+ description: "New onError hook code"
3926
+ }
3927
+ },
3928
+ required: ["name"]
3929
+ }
3930
+ };
3588
3931
  var buildPluginExecutor = async (args2) => {
3589
3932
  const {
3590
3933
  name,
@@ -3641,12 +3984,51 @@ var buildPluginExecutor = async (args2) => {
3641
3984
  message: `Plugin "${name}" v${version} built successfully with ${tools.length} tool(s). Use save_plugin to download or install_plugin to add to browser storage.`
3642
3985
  };
3643
3986
  };
3987
+ function normalizePlugin(input) {
3988
+ const plugin = input;
3989
+ const tools = plugin.tools;
3990
+ const hasEmbeddedExecutors = tools?.[0]?.executorCode !== void 0;
3991
+ if (hasEmbeddedExecutors) {
3992
+ return {
3993
+ ...plugin,
3994
+ enabled: plugin.enabled ?? true,
3995
+ installedAt: plugin.installedAt ?? (/* @__PURE__ */ new Date()).toISOString()
3996
+ };
3997
+ }
3998
+ const executorCode = plugin.executorCode;
3999
+ const rawTools = tools || [];
4000
+ const hookKeys = ["onRegisterCode", "beforeRequestCode", "afterResponseCode", "onErrorCode"];
4001
+ const hooksCode = {};
4002
+ for (const key of hookKeys) {
4003
+ if (executorCode?.[key]) {
4004
+ hooksCode[key] = executorCode[key];
4005
+ }
4006
+ if (plugin[key]) {
4007
+ hooksCode[key] = plugin[key];
4008
+ }
4009
+ }
4010
+ return {
4011
+ name: plugin.name,
4012
+ version: plugin.version,
4013
+ description: plugin.description,
4014
+ tools: rawTools.map((tool) => ({
4015
+ name: tool.name,
4016
+ description: tool.description,
4017
+ parameters: tool.parameters,
4018
+ executorCode: executorCode?.[tool.name]
4019
+ })),
4020
+ hooksCode: Object.keys(hooksCode).length > 0 ? hooksCode : void 0,
4021
+ enabled: true,
4022
+ installedAt: (/* @__PURE__ */ new Date()).toISOString()
4023
+ };
4024
+ }
3644
4025
  var savePluginExecutor = async (args2) => {
3645
- const { plugin, filename } = args2;
4026
+ const { plugin: rawPlugin, filename } = args2;
3646
4027
  if (typeof window === "undefined") {
3647
4028
  return { success: false, error: "Cannot save files in server environment" };
3648
4029
  }
3649
4030
  try {
4031
+ const plugin = normalizePlugin(rawPlugin);
3650
4032
  const json2 = JSON.stringify(plugin, null, 2);
3651
4033
  const blob = new Blob([json2], { type: "application/json" });
3652
4034
  const url = URL.createObjectURL(blob);
@@ -3669,31 +4051,23 @@ var savePluginExecutor = async (args2) => {
3669
4051
  }
3670
4052
  };
3671
4053
  var installPluginExecutor = async (args2) => {
3672
- const { plugin, enabled = true } = args2;
4054
+ const { plugin: rawPlugin, enabled = true } = args2;
3673
4055
  if (typeof window === "undefined") {
3674
4056
  return { success: false, error: "Cannot install plugins in server environment" };
3675
4057
  }
3676
4058
  try {
3677
- const instanceId = window.__hustleInstanceId || "global-demo";
3678
- const STORAGE_KEY = `hustle-plugins-${instanceId}`;
3679
- const stored = localStorage.getItem(STORAGE_KEY);
3680
- const plugins = stored ? JSON.parse(stored) : [];
3681
- const existingIndex = plugins.findIndex((p) => p.name === plugin.name);
3682
- const pluginToStore = {
3683
- ...plugin,
3684
- enabled,
3685
- installedAt: (/* @__PURE__ */ new Date()).toISOString()
3686
- };
3687
- if (existingIndex >= 0) {
3688
- plugins[existingIndex] = pluginToStore;
3689
- } else {
3690
- plugins.push(pluginToStore);
4059
+ const plugin = normalizePlugin(rawPlugin);
4060
+ const win = window;
4061
+ if (!win.__hustleRegisterPlugin) {
4062
+ return {
4063
+ success: false,
4064
+ error: "Plugin registration not available. Make sure HustleProvider is mounted."
4065
+ };
3691
4066
  }
3692
- localStorage.setItem(STORAGE_KEY, JSON.stringify(plugins));
4067
+ await win.__hustleRegisterPlugin(plugin, enabled);
3693
4068
  return {
3694
4069
  success: true,
3695
- message: `Plugin "${plugin.name}" installed${enabled ? " and enabled" : ""}. Refresh the page or re-register plugins to activate.`,
3696
- action: existingIndex >= 0 ? "updated" : "installed"
4070
+ message: `Plugin "${plugin.name}" installed${enabled ? " and enabled" : ""}.`
3697
4071
  };
3698
4072
  } catch (error2) {
3699
4073
  return {
@@ -3702,15 +4076,168 @@ var installPluginExecutor = async (args2) => {
3702
4076
  };
3703
4077
  }
3704
4078
  };
4079
+ var uninstallPluginExecutor = async (args2) => {
4080
+ const { name } = args2;
4081
+ if (typeof window === "undefined") {
4082
+ return { success: false, error: "Cannot uninstall plugins in server environment" };
4083
+ }
4084
+ try {
4085
+ const win = window;
4086
+ if (!win.__hustleUnregisterPlugin) {
4087
+ return {
4088
+ success: false,
4089
+ error: "Plugin unregistration not available. Make sure HustleProvider is mounted."
4090
+ };
4091
+ }
4092
+ await win.__hustleUnregisterPlugin(name);
4093
+ return {
4094
+ success: true,
4095
+ message: `Plugin "${name}" has been uninstalled.`
4096
+ };
4097
+ } catch (error2) {
4098
+ return {
4099
+ success: false,
4100
+ error: `Failed to uninstall: ${error2 instanceof Error ? error2.message : "Unknown error"}`
4101
+ };
4102
+ }
4103
+ };
4104
+ var listPluginsExecutor = async () => {
4105
+ if (typeof window === "undefined") {
4106
+ return { success: false, error: "Cannot list plugins in server environment" };
4107
+ }
4108
+ try {
4109
+ const win = window;
4110
+ if (!win.__hustleListPlugins) {
4111
+ return {
4112
+ success: false,
4113
+ error: "Plugin listing not available. Make sure HustleProvider is mounted."
4114
+ };
4115
+ }
4116
+ const plugins = win.__hustleListPlugins();
4117
+ return {
4118
+ success: true,
4119
+ plugins,
4120
+ summary: plugins.length === 0 ? "No plugins installed." : `${plugins.length} plugin(s) installed: ${plugins.map((p) => `${p.name} v${p.version} (${p.enabled ? "enabled" : "disabled"})`).join(", ")}`
4121
+ };
4122
+ } catch (error2) {
4123
+ return {
4124
+ success: false,
4125
+ error: `Failed to list plugins: ${error2 instanceof Error ? error2.message : "Unknown error"}`
4126
+ };
4127
+ }
4128
+ };
4129
+ var modifyPluginExecutor = async (args2) => {
4130
+ const {
4131
+ name,
4132
+ version,
4133
+ description,
4134
+ addTools,
4135
+ addExecutorCode,
4136
+ removeTools,
4137
+ onRegisterCode,
4138
+ beforeRequestCode,
4139
+ afterResponseCode,
4140
+ onErrorCode
4141
+ } = args2;
4142
+ if (typeof window === "undefined") {
4143
+ return { success: false, error: "Cannot modify plugins in server environment" };
4144
+ }
4145
+ try {
4146
+ const win = window;
4147
+ if (!win.__hustleGetPlugin || !win.__hustleRegisterPlugin) {
4148
+ return {
4149
+ success: false,
4150
+ error: "Plugin modification not available. Make sure HustleProvider is mounted."
4151
+ };
4152
+ }
4153
+ const existing = win.__hustleGetPlugin(name);
4154
+ if (!existing) {
4155
+ return {
4156
+ success: false,
4157
+ error: `Plugin "${name}" not found. Use list_plugins to see installed plugins.`
4158
+ };
4159
+ }
4160
+ let tools = existing.tools ? [...existing.tools] : [];
4161
+ if (removeTools && removeTools.length > 0) {
4162
+ tools = tools.filter((t) => !removeTools.includes(t.name));
4163
+ }
4164
+ if (addTools && addTools.length > 0) {
4165
+ for (const newTool of addTools) {
4166
+ const existingIndex = tools.findIndex((t) => t.name === newTool.name);
4167
+ const toolWithExecutor = {
4168
+ ...newTool,
4169
+ executorCode: addExecutorCode?.[newTool.name] || tools.find((t) => t.name === newTool.name)?.executorCode
4170
+ };
4171
+ if (existingIndex >= 0) {
4172
+ tools[existingIndex] = toolWithExecutor;
4173
+ } else {
4174
+ tools.push(toolWithExecutor);
4175
+ }
4176
+ }
4177
+ }
4178
+ if (addExecutorCode) {
4179
+ for (const [toolName, code2] of Object.entries(addExecutorCode)) {
4180
+ const tool = tools.find((t) => t.name === toolName);
4181
+ if (tool) {
4182
+ tool.executorCode = code2;
4183
+ }
4184
+ }
4185
+ }
4186
+ const modified = {
4187
+ ...existing,
4188
+ tools
4189
+ };
4190
+ if (version) modified.version = version;
4191
+ if (description) modified.description = description;
4192
+ if (onRegisterCode || beforeRequestCode || afterResponseCode || onErrorCode) {
4193
+ modified.hooksCode = modified.hooksCode || {};
4194
+ if (onRegisterCode) modified.hooksCode.onRegisterCode = onRegisterCode;
4195
+ if (beforeRequestCode) modified.hooksCode.beforeRequestCode = beforeRequestCode;
4196
+ if (afterResponseCode) modified.hooksCode.afterResponseCode = afterResponseCode;
4197
+ if (onErrorCode) modified.hooksCode.onErrorCode = onErrorCode;
4198
+ }
4199
+ const unregisterFn = window.__hustleUnregisterPlugin;
4200
+ if (unregisterFn) {
4201
+ await unregisterFn(name);
4202
+ }
4203
+ await win.__hustleRegisterPlugin(modified, existing.enabled);
4204
+ const changes = [];
4205
+ if (version) changes.push(`version \u2192 ${version}`);
4206
+ if (description) changes.push("description updated");
4207
+ if (removeTools?.length) changes.push(`removed ${removeTools.length} tool(s)`);
4208
+ if (addTools?.length) changes.push(`added/updated ${addTools.length} tool(s)`);
4209
+ if (onRegisterCode) changes.push("onRegister hook updated");
4210
+ if (beforeRequestCode) changes.push("beforeRequest hook updated");
4211
+ if (afterResponseCode) changes.push("afterResponse hook updated");
4212
+ if (onErrorCode) changes.push("onError hook updated");
4213
+ return {
4214
+ success: true,
4215
+ message: `Plugin "${name}" modified: ${changes.join(", ")}`,
4216
+ plugin: {
4217
+ name: modified.name,
4218
+ version: modified.version,
4219
+ toolCount: tools.length
4220
+ }
4221
+ };
4222
+ } catch (error2) {
4223
+ return {
4224
+ success: false,
4225
+ error: `Failed to modify plugin: ${error2 instanceof Error ? error2.message : "Unknown error"}`
4226
+ };
4227
+ }
4228
+ };
3705
4229
  var pluginBuilderPlugin = {
3706
4230
  name: "plugin-builder",
3707
- version: "1.0.0",
4231
+ version: "1.2.0",
3708
4232
  description: "Build custom plugins through conversation",
3709
- tools: [buildPluginTool, savePluginTool, installPluginTool],
4233
+ tools: [buildPluginTool, savePluginTool, installPluginTool, uninstallPluginTool, listPluginsTool, modifyPluginTool],
3710
4234
  executors: {
3711
4235
  build_plugin: buildPluginExecutor,
3712
4236
  save_plugin: savePluginExecutor,
3713
- install_plugin: installPluginExecutor
4237
+ install_plugin: installPluginExecutor,
4238
+ uninstall_plugin: uninstallPluginExecutor,
4239
+ list_plugins: listPluginsExecutor,
4240
+ modify_plugin: modifyPluginExecutor
3714
4241
  },
3715
4242
  hooks: {
3716
4243
  onRegister: () => {