clementine-agent 1.0.72 → 1.0.74

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.
@@ -17,6 +17,27 @@
17
17
  * {{fieldKey}} — user-supplied value
18
18
  * {{slug}} — the feed's computed slug (used for folder + dedup)
19
19
  */
20
+ export interface RecipeFieldPicker {
21
+ /** Full tool name, e.g. "mcp__claude_ai_Google_Drive__search_files" */
22
+ tool: string;
23
+ /** Natural-language instruction to the probe agent — becomes the body of
24
+ * "Call the tool X to {intent}, return a JSON array of {id, label}".
25
+ * For typeahead pickers, may include the placeholder `{{query}}` which
26
+ * the server substitutes with the user's typed search string. */
27
+ intent: string;
28
+ /** When set, the picker renders as a typeahead: user types at least
29
+ * `minQueryLength` chars (default 2), we debounce ~400ms, then fire the
30
+ * probe with `{{query}}` replaced. Use for tools whose list operation
31
+ * requires a query argument (e.g. search_contacts, Gmail search). */
32
+ queryArg?: string;
33
+ /** Minimum characters the user must type before firing the probe. Ignored
34
+ * when queryArg is unset. Default 2. */
35
+ minQueryLength?: number;
36
+ /** If true, user can type a custom value instead of picking from the list
37
+ * (falls back to the raw text). Useful when the source allows queries
38
+ * that aren't enumerable. */
39
+ allowCustom?: boolean;
40
+ }
20
41
  export interface RecipeField {
21
42
  key: string;
22
43
  label: string;
@@ -24,6 +45,8 @@ export interface RecipeField {
24
45
  help?: string;
25
46
  required?: boolean;
26
47
  defaultValue?: string;
48
+ /** When present, render as a searchable picker populated by /api/brain/mcp/probe. */
49
+ picker?: RecipeFieldPicker;
27
50
  }
28
51
  export interface ConnectorRecipe {
29
52
  /** Stable id, used as the job-name prefix. */
@@ -40,8 +40,17 @@ export const RECIPES = [
40
40
  integration: 'Google_Drive',
41
41
  requiredTools: ['search_files', 'read_file_content'],
42
42
  fields: [
43
- { key: 'folder', label: 'Folder name or partial path', placeholder: 'Active Projects', required: true,
44
- help: 'The wizard searches Drive for folders matching this text. Exact name is safest.' },
43
+ {
44
+ key: 'folder',
45
+ label: 'Folder',
46
+ required: true,
47
+ help: 'Pick a folder from your Google Drive. You can also type a name to filter the list.',
48
+ picker: {
49
+ tool: 'mcp__claude_ai_Google_Drive__search_files',
50
+ intent: 'find up to 20 Google Drive folders. Call search_files exactly once with mimeType filter "application/vnd.google-apps.folder". For each returned folder, output {id: folder.id, label: folder.name, sublabel: "Modified " + folder.modifiedTime}.',
51
+ allowCustom: true,
52
+ },
53
+ },
45
54
  ],
46
55
  defaultSchedule: '0 8 * * *',
47
56
  tier: 2,
@@ -163,7 +172,17 @@ Steps:
163
172
  integration: 'Microsoft_365',
164
173
  requiredTools: ['sharepoint_folder_search', 'read_resource'],
165
174
  fields: [
166
- { key: 'folder', label: 'Folder path or name', placeholder: 'Shared Documents/Proposals', required: true },
175
+ {
176
+ key: 'folder',
177
+ label: 'Folder',
178
+ required: true,
179
+ help: 'Pick a SharePoint folder. Type to filter.',
180
+ picker: {
181
+ tool: 'mcp__claude_ai_Microsoft_365__sharepoint_folder_search',
182
+ intent: 'list available SharePoint folders and document libraries. Use the folder\'s drive-item id as id, the folder name as label, and the site/library path as sublabel.',
183
+ allowCustom: true,
184
+ },
185
+ },
167
186
  ],
168
187
  defaultSchedule: '0 8 * * *',
169
188
  tier: 2,
@@ -177,6 +196,42 @@ Steps:
177
196
  2. For each file, use \`read_resource\` to fetch content.
178
197
  3. For each record: \`externalId\` = the SharePoint item id, \`title\` = the file name, \`content\` = the extracted text, \`metadata\` = \`{path, modifiedAt, author, size}\`.
179
198
  4. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
199
+ `,
200
+ },
201
+ {
202
+ id: 'imessage-thread',
203
+ label: 'iMessage: watch a conversation',
204
+ description: 'Ingest recent messages from a specific iMessage contact into the brain.',
205
+ icon: '💬',
206
+ integration: 'imessage',
207
+ requiredTools: ['search_contacts', 'read_imessages'],
208
+ fields: [
209
+ {
210
+ key: 'contact',
211
+ label: 'Contact',
212
+ required: true,
213
+ help: 'Pick an iMessage contact. Their phone number or email becomes the thread id.',
214
+ picker: {
215
+ tool: 'mcp__imessage__search_contacts',
216
+ intent: 'call search_contacts with query "{{query}}". For each match, output {id: the contact\'s phone number or email handle, label: the display name (or the handle if no name), sublabel: "handle: " + handle}. Return up to 15 results.',
217
+ queryArg: 'query',
218
+ minQueryLength: 2,
219
+ allowCustom: true,
220
+ },
221
+ },
222
+ { key: 'limit', label: 'Max messages per run', placeholder: '50', defaultValue: '50' },
223
+ ],
224
+ defaultSchedule: '0 */6 * * *',
225
+ tier: 2,
226
+ slugFromValues: (v) => `imessage-${slugify(v.contact || 'thread')}`,
227
+ buildPrompt: (v, ctx) => `You are running the iMessage feed for contact "${v.contact}".
228
+
229
+ Goal: ingest up to ${v.limit || '50'} recent iMessage messages from the thread with ${v.contact} into the brain.
230
+
231
+ Steps:
232
+ 1. Call \`read_imessages\` (iMessage) for contact "${v.contact}" with limit ${v.limit || '50'}.
233
+ 2. For each message: \`externalId\` = the message id (or a stable hash of contact + timestamp + text if no id), \`title\` = first 60 chars of the text or "[attachment]", \`content\` = "From: <handle>\\nDate: <iso>\\n\\n<text>", \`metadata\` = \`{contact, sender, timestamp, isFromMe, service}\`.
234
+ 3. ${COMMIT_INSTRUCTIONS.replace(/{{slug}}/g, ctx.slug).replace(/{{targetFolder}}/g, ctx.targetFolder)}
180
235
  `,
181
236
  },
182
237
  {
@@ -2560,17 +2560,61 @@ export async function cmdDashboard(opts) {
2560
2560
  // calls brain_ingest_folder to commit them.
2561
2561
  app.get('/api/brain/connectors', async (_req, res) => {
2562
2562
  try {
2563
- const { getClaudeIntegrations } = await import('../agent/mcp-bridge.js');
2563
+ const { getClaudeIntegrations, loadToolInventory } = await import('../agent/mcp-bridge.js');
2564
2564
  const { RECIPES } = await import('../brain/connector-recipes.js');
2565
- const integrations = getClaudeIntegrations();
2566
- // A connector is "useful" for feeds only if it has surfaced some
2567
- // substantive tools (not just the stubbed authenticate/complete pair).
2568
- const meaningful = integrations.map((i) => ({
2569
- ...i,
2570
- hasFeedReadyTools: i.tools.some((t) => !/^(authenticate|complete_authentication)$/.test(t)),
2571
- }));
2565
+ // Claude Desktop integrations carry richer metadata (label, connected
2566
+ // state, firstSeen/lastUsed). Everything else we infer from the SDK
2567
+ // tool inventory by grouping mcp__<server>__<tool> by server prefix.
2568
+ const claudeDesktop = getClaudeIntegrations();
2569
+ const claudeDesktopByName = new Map(claudeDesktop.map((c) => [c.name, c]));
2570
+ const inv = loadToolInventory();
2571
+ const mcpByServer = new Map();
2572
+ for (const toolName of (inv?.tools ?? [])) {
2573
+ if (!toolName.startsWith('mcp__'))
2574
+ continue;
2575
+ const body = toolName.slice(5); // strip "mcp__"
2576
+ const idx = body.indexOf('__');
2577
+ if (idx < 1)
2578
+ continue;
2579
+ const server = body.slice(0, idx);
2580
+ const tool = body.slice(idx + 2);
2581
+ if (!mcpByServer.has(server))
2582
+ mcpByServer.set(server, []);
2583
+ mcpByServer.get(server).push(tool);
2584
+ }
2585
+ const integrations = [];
2586
+ // First, Claude Desktop integrations (match claude_ai_<Name>)
2587
+ for (const [server, tools] of mcpByServer.entries()) {
2588
+ if (!server.startsWith('claude_ai_'))
2589
+ continue;
2590
+ const name = server.slice('claude_ai_'.length);
2591
+ const cd = claudeDesktopByName.get(name);
2592
+ integrations.push({
2593
+ name,
2594
+ label: cd?.label ?? name.replace(/_/g, ' '),
2595
+ kind: 'claude-desktop',
2596
+ tools,
2597
+ connected: cd?.connected ?? true,
2598
+ hasFeedReadyTools: tools.some((t) => !/^(authenticate|complete_authentication)$/.test(t)),
2599
+ });
2600
+ }
2601
+ // Then, every other MCP server. These are directly reachable through
2602
+ // the Agent SDK because probeAvailableTools() saw them in init.tools.
2603
+ for (const [server, tools] of mcpByServer.entries()) {
2604
+ if (server.startsWith('claude_ai_'))
2605
+ continue;
2606
+ integrations.push({
2607
+ name: server,
2608
+ label: server.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
2609
+ kind: 'mcp-server',
2610
+ tools,
2611
+ connected: true, // if it's in the inventory, the SDK can call it
2612
+ hasFeedReadyTools: tools.length > 0,
2613
+ });
2614
+ }
2615
+ integrations.sort((a, b) => a.label.localeCompare(b.label));
2572
2616
  res.json({
2573
- integrations: meaningful,
2617
+ integrations,
2574
2618
  recipes: RECIPES.map((r) => ({
2575
2619
  id: r.id, label: r.label, description: r.description, icon: r.icon,
2576
2620
  integration: r.integration, fields: r.fields,
@@ -2582,6 +2626,85 @@ export async function cmdDashboard(opts) {
2582
2626
  res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
2583
2627
  }
2584
2628
  });
2629
+ // In-memory probe cache. Each entry is (tool, intent) → {items, expiresAt}.
2630
+ // 10-minute TTL so repeated wizard opens don't repeatedly hit Haiku, but
2631
+ // fresh enough that newly-created folders / labels appear within minutes.
2632
+ const brainProbeCache = new Map();
2633
+ const BRAIN_PROBE_TTL_MS = 10 * 60 * 1000;
2634
+ app.post('/api/brain/mcp/probe', async (req, res) => {
2635
+ try {
2636
+ const body = (req.body ?? {});
2637
+ const tool = String(body.tool ?? '').trim();
2638
+ const rawIntent = String(body.intent ?? '').trim();
2639
+ const userQuery = String(body.query ?? '').trim();
2640
+ if (!tool) {
2641
+ res.status(400).json({ error: 'tool is required' });
2642
+ return;
2643
+ }
2644
+ if (!rawIntent) {
2645
+ res.status(400).json({ error: 'intent is required' });
2646
+ return;
2647
+ }
2648
+ // Typeahead pickers include a {{query}} placeholder. Substitute (and
2649
+ // escape any JSON-breakers) before cache key + prompt.
2650
+ const safeQuery = userQuery.replace(/"/g, '\\"').slice(0, 200);
2651
+ const intent = rawIntent.replace(/\{\{query\}\}/g, safeQuery);
2652
+ const cacheKey = `${tool}::${intent}`;
2653
+ if (!body.force) {
2654
+ const cached = brainProbeCache.get(cacheKey);
2655
+ if (cached && cached.expiresAt > Date.now()) {
2656
+ res.json({ items: cached.items, cached: true });
2657
+ return;
2658
+ }
2659
+ }
2660
+ const { query } = await import('@anthropic-ai/claude-agent-sdk');
2661
+ const { parseJsonResponse } = await import('../brain/llm-client.js');
2662
+ const { MODELS } = await import('../config.js');
2663
+ // Strict prompt: force JSON-only output. The tool lookup goes through
2664
+ // the SDK so claude_ai_* and regular MCP servers work uniformly.
2665
+ const stream = query({
2666
+ prompt: `Call the tool \`${tool}\` to ${intent}.
2667
+ Return ONLY a JSON array. Each element must be an object with shape \`{"id": string, "label": string, "sublabel"?: string}\`. Use the source system's stable id for \`id\` (so the feed can reference it later). Use a short human-readable title for \`label\`. Optional \`sublabel\` can include path, email, date, or any disambiguating detail.
2668
+ Do NOT include prose, markdown, code fences, or explanation. Just the JSON array.
2669
+ If the tool returns nothing or errors, return an empty array \`[]\`.`,
2670
+ options: {
2671
+ model: MODELS.haiku,
2672
+ maxTurns: 3,
2673
+ systemPrompt: 'You are a data enumerator. You call the given tool once, extract the items from its response, and emit a strict JSON array. No commentary.',
2674
+ allowedTools: [tool],
2675
+ permissionMode: 'bypassPermissions',
2676
+ settingSources: [],
2677
+ },
2678
+ });
2679
+ let text = '';
2680
+ for await (const msg of stream) {
2681
+ if (msg.type === 'assistant') {
2682
+ const content = msg.message?.content ?? [];
2683
+ for (const block of content) {
2684
+ if (block.type === 'text' && typeof block.text === 'string')
2685
+ text += block.text;
2686
+ }
2687
+ }
2688
+ else if (msg.type === 'result') {
2689
+ break;
2690
+ }
2691
+ }
2692
+ const parsed = parseJsonResponse(text);
2693
+ const items = Array.isArray(parsed)
2694
+ ? parsed
2695
+ .filter((p) => p && typeof p.id === 'string' && typeof p.label === 'string')
2696
+ .map((p) => ({ id: p.id, label: p.label, sublabel: typeof p.sublabel === 'string' ? p.sublabel : undefined }))
2697
+ : [];
2698
+ brainProbeCache.set(cacheKey, { items, expiresAt: Date.now() + BRAIN_PROBE_TTL_MS });
2699
+ // Always include a short preview of the agent's raw output so the UI
2700
+ // can explain why a picker is empty (common causes: tool errored, tool
2701
+ // returned nothing matching, agent refused in prose).
2702
+ res.json({ items, cached: false, rawPreview: text.slice(0, 400) });
2703
+ }
2704
+ catch (err) {
2705
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
2706
+ }
2707
+ });
2585
2708
  app.get('/api/brain/feeds', (_req, res) => {
2586
2709
  try {
2587
2710
  const { parsed, jobs } = readCronFileAt(CRON_FILE);
@@ -10666,6 +10789,156 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
10666
10789
  brainFeedWizardRender();
10667
10790
  }
10668
10791
 
10792
+ // Per-field typeahead state: { [fieldKey]: { timer, lastQuery } }
10793
+ const brainPickerTypeahead = {};
10794
+
10795
+ async function brainRenderFieldPicker(field, values) {
10796
+ const container = document.querySelector('[data-field-picker="' + field.key + '"]');
10797
+ const hidden = document.querySelector('input[type=hidden][data-field="' + field.key + '"]');
10798
+ if (!container || !hidden) return;
10799
+ const picker = field.picker;
10800
+
10801
+ // Typeahead mode: render a search box. User types → debounce → probe.
10802
+ if (picker.queryArg) {
10803
+ const minLen = picker.minQueryLength || 2;
10804
+ const currentVal = hidden.value || values[field.key] || '';
10805
+ container.innerHTML =
10806
+ '<input type="text" id="brain-picker-search-' + field.key + '" placeholder="Type to search (min ' + minLen + ' chars)…" style="width:100%" value="">' +
10807
+ '<div id="brain-picker-results-' + field.key + '" style="margin-top:6px;max-height:240px;overflow-y:auto"></div>' +
10808
+ (currentVal
10809
+ ? '<div style="font-size:12px;margin-top:6px">Selected: <code>' + escapeHtml(currentVal) + '</code></div>'
10810
+ : '');
10811
+ const searchEl = document.getElementById('brain-picker-search-' + field.key);
10812
+ searchEl.oninput = function() {
10813
+ const q = this.value.trim();
10814
+ if (brainPickerTypeahead[field.key] && brainPickerTypeahead[field.key].timer) {
10815
+ clearTimeout(brainPickerTypeahead[field.key].timer);
10816
+ }
10817
+ if (q.length < minLen) {
10818
+ document.getElementById('brain-picker-results-' + field.key).innerHTML =
10819
+ '<div style="color:var(--muted);font-size:12px;padding:6px">Type at least ' + minLen + ' characters…</div>';
10820
+ return;
10821
+ }
10822
+ brainPickerTypeahead[field.key] = {
10823
+ timer: setTimeout(function() { brainFireTypeaheadProbe(field, q); }, 400),
10824
+ };
10825
+ };
10826
+ return;
10827
+ }
10828
+
10829
+ try {
10830
+ const resp = await apiFetch('/api/brain/mcp/probe', {
10831
+ method: 'POST', headers: { 'content-type': 'application/json' },
10832
+ body: JSON.stringify({ tool: picker.tool, intent: picker.intent }),
10833
+ });
10834
+ const data = await resp.json();
10835
+ if (!resp.ok) throw new Error(data.error || 'probe failed');
10836
+
10837
+ const items = data.items || [];
10838
+ const currentVal = hidden.value || values[field.key] || '';
10839
+
10840
+ if (!items.length) {
10841
+ // No items returned. Fall back to a plain text input so the user
10842
+ // can still set the value manually.
10843
+ container.innerHTML =
10844
+ '<input type="text" value="' + escapeHtml(currentVal) + '" placeholder="(type a value)" style="width:100%" oninput="(document.querySelector(\\'input[type=hidden][data-field=' + field.key + ']\\')||{}).value=this.value">' +
10845
+ '<div style="color:#8a5a00;font-size:11px;margin-top:4px">Nothing returned from the probe — type a value manually' + (data.rawPreview ? ' (probe output: ' + escapeHtml(data.rawPreview.slice(0, 120)) + '…)' : '') + '</div>';
10846
+ return;
10847
+ }
10848
+
10849
+ const options = items.map(function(it) {
10850
+ const selected = it.id === currentVal ? ' selected' : '';
10851
+ const lbl = it.label + (it.sublabel ? ' — ' + it.sublabel : '');
10852
+ return '<option value="' + escapeHtml(it.id) + '"' + selected + '>' + escapeHtml(lbl) + '</option>';
10853
+ }).join('');
10854
+
10855
+ const customAllowed = picker.allowCustom;
10856
+ container.innerHTML =
10857
+ '<select style="width:100%;padding:6px" onchange="(document.querySelector(\\'input[type=hidden][data-field=' + field.key + ']\\')||{}).value=this.value">' +
10858
+ (currentVal && !items.find(function(i) { return i.id === currentVal; }) ? '<option value="' + escapeHtml(currentVal) + '" selected>' + escapeHtml(currentVal) + ' (custom)</option>' : '') +
10859
+ '<option value="">— pick one —</option>' +
10860
+ options +
10861
+ '</select>' +
10862
+ '<div style="font-size:11px;color:var(--muted);margin-top:4px">' +
10863
+ items.length + ' result' + (items.length === 1 ? '' : 's') + (data.cached ? ' (cached)' : ' from ' + escapeHtml(picker.tool)) +
10864
+ (customAllowed ? ' · <a href="#" onclick="brainFieldPickerToggleCustom(\\'' + field.key + '\\', \\'' + encodeURIComponent(JSON.stringify(picker)) + '\\');return false">type a custom value instead</a>' : '') +
10865
+ '</div>';
10866
+ // Ensure the hidden input matches the initial selection
10867
+ if (!hidden.value && items[0]) hidden.value = '';
10868
+ } catch (err) {
10869
+ container.innerHTML =
10870
+ '<input type="text" value="' + escapeHtml(values[field.key] || '') + '" placeholder="(probe failed — type manually)" style="width:100%" oninput="(document.querySelector(\\'input[type=hidden][data-field=' + field.key + ']\\')||{}).value=this.value">' +
10871
+ '<div style="color:#e66;font-size:11px;margin-top:4px">Picker failed: ' + escapeHtml(String(err && err.message ? err.message : err)) + ' — type a value manually.</div>';
10872
+ }
10873
+ }
10874
+
10875
+ async function brainFireTypeaheadProbe(field, query) {
10876
+ const resultsEl = document.getElementById('brain-picker-results-' + field.key);
10877
+ const hidden = document.querySelector('input[type=hidden][data-field="' + field.key + '"]');
10878
+ if (!resultsEl || !hidden) return;
10879
+ resultsEl.innerHTML = '<div style="color:var(--muted);font-size:12px;padding:6px">Searching…</div>';
10880
+ try {
10881
+ const resp = await apiFetch('/api/brain/mcp/probe', {
10882
+ method: 'POST', headers: { 'content-type': 'application/json' },
10883
+ body: JSON.stringify({ tool: field.picker.tool, intent: field.picker.intent, query }),
10884
+ });
10885
+ const data = await resp.json();
10886
+ if (!resp.ok) throw new Error(data.error || 'probe failed');
10887
+ const items = data.items || [];
10888
+ if (!items.length) {
10889
+ resultsEl.innerHTML = '<div style="color:#8a5a00;font-size:12px;padding:6px">No matches for "' + escapeHtml(query) + '"' + (data.rawPreview ? ' (' + escapeHtml(data.rawPreview.slice(0, 100)) + ')' : '') + '</div>';
10890
+ return;
10891
+ }
10892
+ resultsEl.innerHTML = items.map(function(it) {
10893
+ const lbl = escapeHtml(it.label) + (it.sublabel ? ' <span style="color:var(--muted);font-size:11px">— ' + escapeHtml(it.sublabel) + '</span>' : '');
10894
+ return '<button class="btn" onclick="brainPickTypeaheadItem(\\'' + field.key + '\\', \\'' + encodeURIComponent(it.id) + '\\', \\'' + encodeURIComponent(it.label) + '\\')" style="display:block;width:100%;text-align:left;padding:6px 10px;margin-bottom:2px">' + lbl + '</button>';
10895
+ }).join('') +
10896
+ '<div style="font-size:11px;color:var(--muted);margin-top:4px">' + items.length + ' result' + (items.length === 1 ? '' : 's') + (data.cached ? ' (cached)' : '') + '</div>';
10897
+ } catch (err) {
10898
+ resultsEl.innerHTML = '<div style="color:#e66;font-size:12px;padding:6px">' + escapeHtml(String(err && err.message ? err.message : err)) + '</div>';
10899
+ }
10900
+ }
10901
+
10902
+ function brainPickTypeaheadItem(fieldKey, encodedId, encodedLabel) {
10903
+ const id = decodeURIComponent(encodedId);
10904
+ const label = decodeURIComponent(encodedLabel);
10905
+ const hidden = document.querySelector('input[type=hidden][data-field="' + fieldKey + '"]');
10906
+ if (hidden) hidden.value = id;
10907
+ // Show a confirmation chip and collapse the results.
10908
+ const container = document.querySelector('[data-field-picker="' + fieldKey + '"]');
10909
+ if (!container) return;
10910
+ const searchEl = document.getElementById('brain-picker-search-' + fieldKey);
10911
+ if (searchEl) searchEl.value = label;
10912
+ const resultsEl = document.getElementById('brain-picker-results-' + fieldKey);
10913
+ if (resultsEl) resultsEl.innerHTML = '';
10914
+ // Inject a "selected" line
10915
+ let sel = container.querySelector('.brain-picker-selected');
10916
+ if (!sel) {
10917
+ sel = document.createElement('div');
10918
+ sel.className = 'brain-picker-selected';
10919
+ sel.style.cssText = 'font-size:12px;margin-top:6px';
10920
+ container.appendChild(sel);
10921
+ }
10922
+ sel.innerHTML = '✓ Selected: <b>' + escapeHtml(label) + '</b> <code style="font-size:11px;color:var(--muted)">' + escapeHtml(id) + '</code>';
10923
+ }
10924
+
10925
+ function brainFieldPickerToggleCustom(fieldKey, encodedPicker) {
10926
+ const container = document.querySelector('[data-field-picker="' + fieldKey + '"]');
10927
+ const hidden = document.querySelector('input[type=hidden][data-field="' + fieldKey + '"]');
10928
+ if (!container || !hidden) return;
10929
+ container.innerHTML =
10930
+ '<input type="text" value="' + escapeHtml(hidden.value || '') + '" placeholder="(type a value)" style="width:100%" oninput="(document.querySelector(\\'input[type=hidden][data-field=' + fieldKey + ']\\')||{}).value=this.value">' +
10931
+ '<div style="font-size:11px;margin-top:4px"><a href="#" onclick="brainFieldPickerReload(\\'' + fieldKey + '\\', \\'' + encodedPicker + '\\');return false">← back to picker</a></div>';
10932
+ }
10933
+
10934
+ async function brainFieldPickerReload(fieldKey, encodedPicker) {
10935
+ const picker = JSON.parse(decodeURIComponent(encodedPicker));
10936
+ const s = brainFeedWizardState;
10937
+ if (!s) return;
10938
+ const field = (s.recipe.fields || []).find(function(f) { return f.key === fieldKey; });
10939
+ if (field) await brainRenderFieldPicker(field, s.values);
10940
+ }
10941
+
10669
10942
  function brainFeedWizardRender() {
10670
10943
  if (!brainFeedWizardState) return;
10671
10944
  const s = brainFeedWizardState;
@@ -10711,13 +10984,30 @@ if('serviceWorker' in navigator){navigator.serviceWorker.getRegistrations().then
10711
10984
  if (!fields.length) {
10712
10985
  html = '<div style="color:var(--muted)">This recipe has no fields. Click Next to pick a schedule.</div>';
10713
10986
  } else {
10714
- html = '<div style="display:grid;grid-template-columns:180px 1fr;gap:10px;align-items:center">' +
10987
+ html = '<div style="display:grid;grid-template-columns:180px 1fr;gap:10px;align-items:start">' +
10715
10988
  fields.map(function(f) {
10716
10989
  const val = s.values[f.key] != null ? s.values[f.key] : (f.defaultValue || '');
10717
10990
  const help = f.help ? '<div style="font-size:11px;color:var(--muted);margin-top:2px">' + escapeHtml(f.help) + '</div>' : '';
10718
- return '<label style="font-weight:500">' + escapeHtml(f.label) + (f.required ? ' <span style="color:#e66">*</span>' : '') + '</label>' +
10719
- '<div><input type="text" data-field="' + f.key + '" value="' + escapeHtml(val) + '" placeholder="' + escapeHtml(f.placeholder || '') + '" style="width:100%">' + help + '</div>';
10991
+ let control;
10992
+ if (f.picker) {
10993
+ // Pickers render their own container; fetchPicker fills it.
10994
+ control = '<div data-field-picker="' + f.key + '" style="width:100%">' +
10995
+ '<div style="color:var(--muted);font-size:13px;padding:6px">Loading choices…</div>' +
10996
+ '</div>' +
10997
+ '<input type="hidden" data-field="' + f.key + '" value="' + escapeHtml(val) + '">';
10998
+ } else {
10999
+ control = '<input type="text" data-field="' + f.key + '" value="' + escapeHtml(val) + '" placeholder="' + escapeHtml(f.placeholder || '') + '" style="width:100%">';
11000
+ }
11001
+ return '<label style="font-weight:500;padding-top:6px">' + escapeHtml(f.label) + (f.required ? ' <span style="color:#e66">*</span>' : '') + '</label>' +
11002
+ '<div>' + control + help + '</div>';
10720
11003
  }).join('') + '</div>';
11004
+
11005
+ // After render, fire probes for any picker fields.
11006
+ setTimeout(function() {
11007
+ for (const f of fields) {
11008
+ if (f.picker) brainRenderFieldPicker(f, s.values);
11009
+ }
11010
+ }, 0);
10721
11011
  }
10722
11012
  } else if (s.step === 3) {
10723
11013
  const chips = brainScheduleChips();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clementine-agent",
3
- "version": "1.0.72",
3
+ "version": "1.0.74",
4
4
  "description": "Clementine — Personal AI Assistant (TypeScript)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",