@emblemvault/hustle-react 1.1.0 → 1.1.2

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;
@@ -3444,6 +3621,21 @@ var buildPluginTool = {
3444
3621
  name: "build_plugin",
3445
3622
  description: `Build a Hustle plugin definition. Use this tool to construct a plugin based on user requirements.
3446
3623
 
3624
+ ## Testing Before Building (IMPORTANT)
3625
+
3626
+ If the execute_javascript tool is available, use it to prototype and test code BEFORE building the plugin. This allows rapid iteration without the overhead of building/installing/uninstalling.
3627
+
3628
+ **Workflow:**
3629
+ 1. When a user describes what they want, first test the core logic using execute_javascript
3630
+ 2. Iterate on the code until it works correctly
3631
+ 3. Ask the user: "Would you like to test this further, or should I build it into a plugin?"
3632
+ 4. Only call build_plugin once the code is validated and the user confirms
3633
+
3634
+ **Example:** If building a weather plugin, first test the API call:
3635
+ execute_javascript({ code: "fetch('https://api.example.com/weather?city=London').then(r => r.json()).then(console.log)" })
3636
+
3637
+ This catches errors early and lets users see results before committing to a plugin.
3638
+
3447
3639
  ## Plugin Structure
3448
3640
 
3449
3641
  A plugin consists of:
@@ -3482,21 +3674,127 @@ Write them as arrow function strings that will be eval'd:
3482
3674
  "async (args) => { const { city } = args; return { weather: 'sunny', city }; }"
3483
3675
 
3484
3676
  The function receives args as Record<string, unknown> and should return the result.
3485
- You can use fetch(), standard browser APIs, and async/await.
3677
+
3678
+ ## Available in Executor Scope
3679
+
3680
+ Executors run in the browser context with full access to:
3681
+
3682
+ ### Browser APIs
3683
+ - **fetch(url, options)** - HTTP requests (subject to CORS)
3684
+ - **localStorage / sessionStorage** - Persistent storage
3685
+ - **document** - Full DOM access (create elements, modals, forms, etc.)
3686
+ - **window** - Global window object
3687
+ - **console** - Logging (log, warn, error, etc.)
3688
+ - **setTimeout / setInterval / clearTimeout / clearInterval** - Timers
3689
+ - **JSON** - Parse and stringify
3690
+ - **Date** - Date/time operations
3691
+ - **URL / URLSearchParams** - URL manipulation
3692
+ - **FormData / Blob / File / FileReader** - File handling
3693
+ - **crypto** - Cryptographic operations (crypto.randomUUID(), etc.)
3694
+ - **navigator** - Browser info, clipboard, geolocation, etc.
3695
+ - **location** - Current URL info
3696
+ - **history** - Browser history navigation
3697
+ - **WebSocket** - Real-time bidirectional communication
3698
+ - **EventSource** - Server-sent events
3699
+ - **indexedDB** - Client-side database for large data
3700
+ - **Notification** - Browser notifications (requires permission)
3701
+ - **performance** - Performance timing
3702
+ - **atob / btoa** - Base64 encoding/decoding
3703
+ - **TextEncoder / TextDecoder** - Text encoding
3704
+ - **AbortController** - Cancel fetch requests
3705
+ - **IntersectionObserver / MutationObserver / ResizeObserver** - DOM observers
3706
+ - **requestAnimationFrame** - Animation timing
3707
+ - **speechSynthesis** - Text-to-speech
3708
+ - **Audio / Image / Canvas** - Media APIs
3709
+
3710
+ ### Hustle Plugin System Globals
3711
+ - **window.__hustleInstanceId** - Current Hustle instance ID
3712
+ - **window.__hustleRegisterPlugin(plugin, enabled)** - Install another plugin dynamically
3713
+ - **window.__hustleUnregisterPlugin(name)** - Uninstall a plugin by name
3714
+ - **window.__hustleUploadFile(file, fileName?)** - Upload a File/Blob to the server, returns { url, contentType }
3715
+ - **window.__hustleListPlugins()** - List all installed plugins
3716
+ - **window.__hustleGetPlugin(name)** - Get a specific plugin by name
3717
+
3718
+ ### DOM Manipulation Examples
3719
+ Create a modal: document.createElement('div'), style it, append to document.body
3720
+ Add event listeners: element.addEventListener('click', handler)
3721
+ Query elements: document.querySelector(), document.querySelectorAll()
3722
+
3723
+ ### Storage Patterns
3724
+ Store data: localStorage.setItem('key', JSON.stringify(data))
3725
+ Retrieve data: JSON.parse(localStorage.getItem('key') || '{}')
3726
+ Namespace your keys: Use plugin name prefix like "myplugin-settings"
3727
+
3728
+ ### Async Patterns
3729
+ All executors should be async. Use await for promises:
3730
+ "async (args) => { const res = await fetch(url); return await res.json(); }"
3486
3731
 
3487
3732
  ## Lifecycle Hooks (Optional)
3488
3733
 
3489
- - **beforeRequestCode**: Modify messages before sending. Receives request object, must return modified request.
3734
+ Hooks also have full access to the browser scope described above.
3735
+
3736
+ - **onRegisterCode**: Called once when plugin is registered/enabled. Good for initialization.
3737
+ **IMPORTANT: Always log when your plugin registers so users know it's active!**
3738
+ Example: "async () => { console.log('[MyPlugin] v1.0.0 registered'); }"
3739
+
3740
+ - **beforeRequestCode**: Modify messages before sending. Receives request object with { messages, model, ... }. Must return the modified request.
3490
3741
  Example: "async (req) => { req.messages = req.messages.map(m => ({...m, content: m.content.toUpperCase()})); return req; }"
3491
3742
 
3492
- - **afterResponseCode**: Process response after receiving. Receives response object.
3493
- Example: "async (res) => { console.log('Response:', res.content); }"
3743
+ - **afterResponseCode**: Process/modify response after receiving. Receives response object with { content, ... }.
3744
+ Example: "async (res) => { console.log('Response received:', res.content.substring(0, 100)); }"
3745
+
3746
+ - **onErrorCode**: Called on errors. Receives (error, context) where context has { phase: 'beforeRequest'|'execute'|'afterResponse' }.
3747
+ Example: "async (error, ctx) => { console.error('[MyPlugin] Error in', ctx.phase, ':', error.message); }"
3748
+
3749
+ ## Best Practices
3494
3750
 
3495
- - **onRegisterCode**: Called when plugin is registered.
3496
- Example: "async () => { console.log('Plugin loaded!'); }"
3751
+ 1. **Always add onRegisterCode** that logs the plugin name and version
3752
+ 2. **Namespace console logs** with [PluginName] prefix for easy identification
3753
+ 3. **Handle errors gracefully** in executors - return { error: message } instead of throwing
3497
3754
 
3498
- - **onErrorCode**: Called on errors. Receives (error, context).
3499
- Example: "async (error, ctx) => { console.error('Error in', ctx.phase, error); }"`,
3755
+ ## Building Plugin UI
3756
+
3757
+ Plugins can embed custom UI elements in two ways:
3758
+
3759
+ ### Persistent UI (via onRegister hook)
3760
+ Use the onRegister hook to embed UI that persists across the chat session. Check if your element already exists to avoid duplicates on re-registration:
3761
+
3762
+ "async () => {
3763
+ console.log('[MyPlugin] v1.0.0 registered');
3764
+ if (document.getElementById('my-plugin-panel')) return; // Already exists
3765
+
3766
+ const panel = document.createElement('div');
3767
+ panel.id = 'my-plugin-panel';
3768
+ Object.assign(panel.style, { position: 'fixed', bottom: '20px', right: '20px', padding: '10px', background: '#333', color: '#fff' });
3769
+ panel.textContent = 'My Panel';
3770
+ document.body.appendChild(panel);
3771
+ }"
3772
+
3773
+ ### On-Demand UI (via tool executors)
3774
+ Embed UI just-in-time when a tool is invoked. Useful for tools like show_clock, display_chart, etc.:
3775
+
3776
+ "async (args) => {
3777
+ const modal = document.createElement('div');
3778
+ modal.id = 'clock-modal';
3779
+ Object.assign(modal.style, { position: 'fixed', inset: '0', display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.5)' });
3780
+ modal.textContent = 'Current time: ' + new Date().toLocaleTimeString();
3781
+ modal.onclick = () => modal.remove(); // Click to dismiss
3782
+ document.body.appendChild(modal);
3783
+ return { success: true, message: 'Clock displayed' };
3784
+ }"
3785
+
3786
+ ### UI Best Practices
3787
+ - Always use unique IDs with your plugin name prefix (e.g., "myplugin-modal")
3788
+ - For persistent UI, check existence in onRegister before creating
3789
+ - Provide a way to dismiss/close UI elements (click handler, close button)
3790
+ - Use Object.assign(el.style, {...}) for inline styles or inject a <style> tag
3791
+ - Clean up UI in tool executors if appropriate (e.g., remove after timeout)
3792
+
3793
+ ## Security Notes
3794
+ - Code runs in browser sandbox with same-origin policy
3795
+ - fetch() is subject to CORS restrictions
3796
+ - No direct filesystem access (use File API with user interaction)
3797
+ - Sanitize any user-provided content before inserting into the DOM`,
3500
3798
  parameters: {
3501
3799
  type: "object",
3502
3800
  properties: {
@@ -3585,6 +3883,104 @@ var installPluginTool = {
3585
3883
  required: ["plugin"]
3586
3884
  }
3587
3885
  };
3886
+ var uninstallPluginTool = {
3887
+ name: "uninstall_plugin",
3888
+ description: "Uninstall a plugin by name, removing it from browser storage.",
3889
+ parameters: {
3890
+ type: "object",
3891
+ properties: {
3892
+ name: {
3893
+ type: "string",
3894
+ description: "The name of the plugin to uninstall"
3895
+ }
3896
+ },
3897
+ required: ["name"]
3898
+ }
3899
+ };
3900
+ var listPluginsTool = {
3901
+ name: "list_plugins",
3902
+ description: "List all installed plugins with their enabled/disabled status.",
3903
+ parameters: {
3904
+ type: "object",
3905
+ properties: {},
3906
+ required: []
3907
+ }
3908
+ };
3909
+ var modifyPluginTool = {
3910
+ name: "modify_plugin",
3911
+ description: `Modify an existing installed plugin. Can update version, description, tools, executors, and hooks.
3912
+
3913
+ Use list_plugins first to see installed plugins, then modify by name.
3914
+
3915
+ You can:
3916
+ - Add new tools (provide tools array with new tools to add)
3917
+ - Update existing tools (provide tool with same name)
3918
+ - Remove tools (set removeTool to the tool name)
3919
+ - Update hooks (provide hook code)
3920
+ - Update version/description
3921
+
3922
+ Example: Add a new tool to existing plugin:
3923
+ {
3924
+ "name": "my-plugin",
3925
+ "addTools": [{ "name": "new_tool", "description": "...", "parameters": {...} }],
3926
+ "addExecutorCode": { "new_tool": "async (args) => { ... }" }
3927
+ }`,
3928
+ parameters: {
3929
+ type: "object",
3930
+ properties: {
3931
+ name: {
3932
+ type: "string",
3933
+ description: "Name of the plugin to modify (required)"
3934
+ },
3935
+ version: {
3936
+ type: "string",
3937
+ description: "New version string"
3938
+ },
3939
+ description: {
3940
+ type: "string",
3941
+ description: "New description"
3942
+ },
3943
+ addTools: {
3944
+ type: "array",
3945
+ description: "Tools to add or update",
3946
+ items: {
3947
+ type: "object",
3948
+ properties: {
3949
+ name: { type: "string" },
3950
+ description: { type: "string" },
3951
+ parameters: { type: "object" }
3952
+ }
3953
+ }
3954
+ },
3955
+ addExecutorCode: {
3956
+ type: "object",
3957
+ description: "Executor code to add/update (tool name -> code string)"
3958
+ },
3959
+ removeTools: {
3960
+ type: "array",
3961
+ description: "Names of tools to remove",
3962
+ items: { type: "string" }
3963
+ },
3964
+ onRegisterCode: {
3965
+ type: "string",
3966
+ description: "New onRegister hook code"
3967
+ },
3968
+ beforeRequestCode: {
3969
+ type: "string",
3970
+ description: "New beforeRequest hook code"
3971
+ },
3972
+ afterResponseCode: {
3973
+ type: "string",
3974
+ description: "New afterResponse hook code"
3975
+ },
3976
+ onErrorCode: {
3977
+ type: "string",
3978
+ description: "New onError hook code"
3979
+ }
3980
+ },
3981
+ required: ["name"]
3982
+ }
3983
+ };
3588
3984
  var buildPluginExecutor = async (args2) => {
3589
3985
  const {
3590
3986
  name,
@@ -3641,12 +4037,51 @@ var buildPluginExecutor = async (args2) => {
3641
4037
  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
4038
  };
3643
4039
  };
4040
+ function normalizePlugin(input) {
4041
+ const plugin = input;
4042
+ const tools = plugin.tools;
4043
+ const hasEmbeddedExecutors = tools?.[0]?.executorCode !== void 0;
4044
+ if (hasEmbeddedExecutors) {
4045
+ return {
4046
+ ...plugin,
4047
+ enabled: plugin.enabled ?? true,
4048
+ installedAt: plugin.installedAt ?? (/* @__PURE__ */ new Date()).toISOString()
4049
+ };
4050
+ }
4051
+ const executorCode = plugin.executorCode;
4052
+ const rawTools = tools || [];
4053
+ const hookKeys = ["onRegisterCode", "beforeRequestCode", "afterResponseCode", "onErrorCode"];
4054
+ const hooksCode = {};
4055
+ for (const key of hookKeys) {
4056
+ if (executorCode?.[key]) {
4057
+ hooksCode[key] = executorCode[key];
4058
+ }
4059
+ if (plugin[key]) {
4060
+ hooksCode[key] = plugin[key];
4061
+ }
4062
+ }
4063
+ return {
4064
+ name: plugin.name,
4065
+ version: plugin.version,
4066
+ description: plugin.description,
4067
+ tools: rawTools.map((tool) => ({
4068
+ name: tool.name,
4069
+ description: tool.description,
4070
+ parameters: tool.parameters,
4071
+ executorCode: executorCode?.[tool.name]
4072
+ })),
4073
+ hooksCode: Object.keys(hooksCode).length > 0 ? hooksCode : void 0,
4074
+ enabled: true,
4075
+ installedAt: (/* @__PURE__ */ new Date()).toISOString()
4076
+ };
4077
+ }
3644
4078
  var savePluginExecutor = async (args2) => {
3645
- const { plugin, filename } = args2;
4079
+ const { plugin: rawPlugin, filename } = args2;
3646
4080
  if (typeof window === "undefined") {
3647
4081
  return { success: false, error: "Cannot save files in server environment" };
3648
4082
  }
3649
4083
  try {
4084
+ const plugin = normalizePlugin(rawPlugin);
3650
4085
  const json2 = JSON.stringify(plugin, null, 2);
3651
4086
  const blob = new Blob([json2], { type: "application/json" });
3652
4087
  const url = URL.createObjectURL(blob);
@@ -3669,31 +4104,23 @@ var savePluginExecutor = async (args2) => {
3669
4104
  }
3670
4105
  };
3671
4106
  var installPluginExecutor = async (args2) => {
3672
- const { plugin, enabled = true } = args2;
4107
+ const { plugin: rawPlugin, enabled = true } = args2;
3673
4108
  if (typeof window === "undefined") {
3674
4109
  return { success: false, error: "Cannot install plugins in server environment" };
3675
4110
  }
3676
4111
  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);
4112
+ const plugin = normalizePlugin(rawPlugin);
4113
+ const win = window;
4114
+ if (!win.__hustleRegisterPlugin) {
4115
+ return {
4116
+ success: false,
4117
+ error: "Plugin registration not available. Make sure HustleProvider is mounted."
4118
+ };
3691
4119
  }
3692
- localStorage.setItem(STORAGE_KEY, JSON.stringify(plugins));
4120
+ await win.__hustleRegisterPlugin(plugin, enabled);
3693
4121
  return {
3694
4122
  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"
4123
+ message: `Plugin "${plugin.name}" installed${enabled ? " and enabled" : ""}.`
3697
4124
  };
3698
4125
  } catch (error2) {
3699
4126
  return {
@@ -3702,15 +4129,168 @@ var installPluginExecutor = async (args2) => {
3702
4129
  };
3703
4130
  }
3704
4131
  };
4132
+ var uninstallPluginExecutor = async (args2) => {
4133
+ const { name } = args2;
4134
+ if (typeof window === "undefined") {
4135
+ return { success: false, error: "Cannot uninstall plugins in server environment" };
4136
+ }
4137
+ try {
4138
+ const win = window;
4139
+ if (!win.__hustleUnregisterPlugin) {
4140
+ return {
4141
+ success: false,
4142
+ error: "Plugin unregistration not available. Make sure HustleProvider is mounted."
4143
+ };
4144
+ }
4145
+ await win.__hustleUnregisterPlugin(name);
4146
+ return {
4147
+ success: true,
4148
+ message: `Plugin "${name}" has been uninstalled.`
4149
+ };
4150
+ } catch (error2) {
4151
+ return {
4152
+ success: false,
4153
+ error: `Failed to uninstall: ${error2 instanceof Error ? error2.message : "Unknown error"}`
4154
+ };
4155
+ }
4156
+ };
4157
+ var listPluginsExecutor = async () => {
4158
+ if (typeof window === "undefined") {
4159
+ return { success: false, error: "Cannot list plugins in server environment" };
4160
+ }
4161
+ try {
4162
+ const win = window;
4163
+ if (!win.__hustleListPlugins) {
4164
+ return {
4165
+ success: false,
4166
+ error: "Plugin listing not available. Make sure HustleProvider is mounted."
4167
+ };
4168
+ }
4169
+ const plugins = win.__hustleListPlugins();
4170
+ return {
4171
+ success: true,
4172
+ plugins,
4173
+ 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(", ")}`
4174
+ };
4175
+ } catch (error2) {
4176
+ return {
4177
+ success: false,
4178
+ error: `Failed to list plugins: ${error2 instanceof Error ? error2.message : "Unknown error"}`
4179
+ };
4180
+ }
4181
+ };
4182
+ var modifyPluginExecutor = async (args2) => {
4183
+ const {
4184
+ name,
4185
+ version,
4186
+ description,
4187
+ addTools,
4188
+ addExecutorCode,
4189
+ removeTools,
4190
+ onRegisterCode,
4191
+ beforeRequestCode,
4192
+ afterResponseCode,
4193
+ onErrorCode
4194
+ } = args2;
4195
+ if (typeof window === "undefined") {
4196
+ return { success: false, error: "Cannot modify plugins in server environment" };
4197
+ }
4198
+ try {
4199
+ const win = window;
4200
+ if (!win.__hustleGetPlugin || !win.__hustleRegisterPlugin) {
4201
+ return {
4202
+ success: false,
4203
+ error: "Plugin modification not available. Make sure HustleProvider is mounted."
4204
+ };
4205
+ }
4206
+ const existing = win.__hustleGetPlugin(name);
4207
+ if (!existing) {
4208
+ return {
4209
+ success: false,
4210
+ error: `Plugin "${name}" not found. Use list_plugins to see installed plugins.`
4211
+ };
4212
+ }
4213
+ let tools = existing.tools ? [...existing.tools] : [];
4214
+ if (removeTools && removeTools.length > 0) {
4215
+ tools = tools.filter((t) => !removeTools.includes(t.name));
4216
+ }
4217
+ if (addTools && addTools.length > 0) {
4218
+ for (const newTool of addTools) {
4219
+ const existingIndex = tools.findIndex((t) => t.name === newTool.name);
4220
+ const toolWithExecutor = {
4221
+ ...newTool,
4222
+ executorCode: addExecutorCode?.[newTool.name] || tools.find((t) => t.name === newTool.name)?.executorCode
4223
+ };
4224
+ if (existingIndex >= 0) {
4225
+ tools[existingIndex] = toolWithExecutor;
4226
+ } else {
4227
+ tools.push(toolWithExecutor);
4228
+ }
4229
+ }
4230
+ }
4231
+ if (addExecutorCode) {
4232
+ for (const [toolName, code2] of Object.entries(addExecutorCode)) {
4233
+ const tool = tools.find((t) => t.name === toolName);
4234
+ if (tool) {
4235
+ tool.executorCode = code2;
4236
+ }
4237
+ }
4238
+ }
4239
+ const modified = {
4240
+ ...existing,
4241
+ tools
4242
+ };
4243
+ if (version) modified.version = version;
4244
+ if (description) modified.description = description;
4245
+ if (onRegisterCode || beforeRequestCode || afterResponseCode || onErrorCode) {
4246
+ modified.hooksCode = modified.hooksCode || {};
4247
+ if (onRegisterCode) modified.hooksCode.onRegisterCode = onRegisterCode;
4248
+ if (beforeRequestCode) modified.hooksCode.beforeRequestCode = beforeRequestCode;
4249
+ if (afterResponseCode) modified.hooksCode.afterResponseCode = afterResponseCode;
4250
+ if (onErrorCode) modified.hooksCode.onErrorCode = onErrorCode;
4251
+ }
4252
+ const unregisterFn = window.__hustleUnregisterPlugin;
4253
+ if (unregisterFn) {
4254
+ await unregisterFn(name);
4255
+ }
4256
+ await win.__hustleRegisterPlugin(modified, existing.enabled);
4257
+ const changes = [];
4258
+ if (version) changes.push(`version \u2192 ${version}`);
4259
+ if (description) changes.push("description updated");
4260
+ if (removeTools?.length) changes.push(`removed ${removeTools.length} tool(s)`);
4261
+ if (addTools?.length) changes.push(`added/updated ${addTools.length} tool(s)`);
4262
+ if (onRegisterCode) changes.push("onRegister hook updated");
4263
+ if (beforeRequestCode) changes.push("beforeRequest hook updated");
4264
+ if (afterResponseCode) changes.push("afterResponse hook updated");
4265
+ if (onErrorCode) changes.push("onError hook updated");
4266
+ return {
4267
+ success: true,
4268
+ message: `Plugin "${name}" modified: ${changes.join(", ")}`,
4269
+ plugin: {
4270
+ name: modified.name,
4271
+ version: modified.version,
4272
+ toolCount: tools.length
4273
+ }
4274
+ };
4275
+ } catch (error2) {
4276
+ return {
4277
+ success: false,
4278
+ error: `Failed to modify plugin: ${error2 instanceof Error ? error2.message : "Unknown error"}`
4279
+ };
4280
+ }
4281
+ };
3705
4282
  var pluginBuilderPlugin = {
3706
4283
  name: "plugin-builder",
3707
- version: "1.0.0",
4284
+ version: "1.2.0",
3708
4285
  description: "Build custom plugins through conversation",
3709
- tools: [buildPluginTool, savePluginTool, installPluginTool],
4286
+ tools: [buildPluginTool, savePluginTool, installPluginTool, uninstallPluginTool, listPluginsTool, modifyPluginTool],
3710
4287
  executors: {
3711
4288
  build_plugin: buildPluginExecutor,
3712
4289
  save_plugin: savePluginExecutor,
3713
- install_plugin: installPluginExecutor
4290
+ install_plugin: installPluginExecutor,
4291
+ uninstall_plugin: uninstallPluginExecutor,
4292
+ list_plugins: listPluginsExecutor,
4293
+ modify_plugin: modifyPluginExecutor
3714
4294
  },
3715
4295
  hooks: {
3716
4296
  onRegister: () => {