amalgm 0.1.51 → 0.1.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/lib/tunnel-events.js +48 -23
  2. package/package.json +2 -2
  3. package/runtime/lib/harnesses.js +12 -4
  4. package/runtime/scripts/amalgm-mcp/agents/store.js +5 -5
  5. package/runtime/scripts/amalgm-mcp/{artifacts → apps}/advertise.js +39 -24
  6. package/runtime/scripts/amalgm-mcp/apps/rest.js +144 -0
  7. package/runtime/scripts/amalgm-mcp/apps/store.js +171 -0
  8. package/runtime/scripts/amalgm-mcp/apps/supervisor.js +439 -0
  9. package/runtime/scripts/amalgm-mcp/apps/tools.js +176 -0
  10. package/runtime/scripts/amalgm-mcp/automations/cell-references.js +237 -0
  11. package/runtime/scripts/amalgm-mcp/automations/context.js +41 -0
  12. package/runtime/scripts/amalgm-mcp/automations/rest.js +148 -0
  13. package/runtime/scripts/amalgm-mcp/automations/runner.js +613 -0
  14. package/runtime/scripts/amalgm-mcp/automations/scheduler.js +90 -0
  15. package/runtime/scripts/amalgm-mcp/automations/store.js +1125 -0
  16. package/runtime/scripts/amalgm-mcp/automations/tool-actions.js +177 -0
  17. package/runtime/scripts/amalgm-mcp/automations/tools.js +418 -0
  18. package/runtime/scripts/amalgm-mcp/automations/validator.js +225 -0
  19. package/runtime/scripts/amalgm-mcp/browser/agent-browser.js +505 -0
  20. package/runtime/scripts/amalgm-mcp/browser/electron-bridge.js +222 -0
  21. package/runtime/scripts/amalgm-mcp/browser/page.js +13 -631
  22. package/runtime/scripts/amalgm-mcp/browser/tools.js +9 -7
  23. package/runtime/scripts/amalgm-mcp/config.js +33 -48
  24. package/runtime/scripts/amalgm-mcp/deps.js +1 -31
  25. package/runtime/scripts/amalgm-mcp/events/ingress.js +50 -42
  26. package/runtime/scripts/amalgm-mcp/events/internal-workflows.js +169 -0
  27. package/runtime/scripts/amalgm-mcp/events/matcher.js +45 -14
  28. package/runtime/scripts/amalgm-mcp/events/store.js +106 -57
  29. package/runtime/scripts/amalgm-mcp/index.js +12 -14
  30. package/runtime/scripts/amalgm-mcp/lib/prefs.js +229 -65
  31. package/runtime/scripts/amalgm-mcp/lib/tool-result.js +13 -27
  32. package/runtime/scripts/amalgm-mcp/server/core-tools.js +2 -3
  33. package/runtime/scripts/amalgm-mcp/server/http.js +106 -56
  34. package/runtime/scripts/amalgm-mcp/slack/inbound.js +1 -1
  35. package/runtime/scripts/amalgm-mcp/state/db.js +119 -0
  36. package/runtime/scripts/amalgm-mcp/state/snapshot.js +16 -3
  37. package/runtime/scripts/amalgm-mcp/tasks/executor.js +1 -1
  38. package/runtime/scripts/amalgm-mcp/tests/automations-store-runner.test.js +348 -0
  39. package/runtime/scripts/amalgm-mcp/tests/events-matcher.test.js +23 -0
  40. package/runtime/scripts/amalgm-mcp/tests/workflows-store-runner.test.js +67 -0
  41. package/runtime/scripts/amalgm-mcp/toolbox/tools.js +16 -3
  42. package/runtime/scripts/amalgm-mcp/workflows/compiler.js +222 -0
  43. package/runtime/scripts/amalgm-mcp/workflows/runner.js +593 -0
  44. package/runtime/scripts/amalgm-mcp/workflows/store.js +237 -0
  45. package/runtime/scripts/chat-core/adapters/claude.js +2 -1
  46. package/runtime/scripts/chat-core/auth.js +82 -12
  47. package/runtime/scripts/chat-core/contract.js +5 -1
  48. package/runtime/scripts/chat-core/engine.js +103 -62
  49. package/runtime/scripts/chat-core/event-schema.js +8 -0
  50. package/runtime/scripts/chat-core/events.js +5 -0
  51. package/runtime/scripts/chat-core/normalizers/codex.js +13 -1
  52. package/runtime/scripts/chat-core/parts.js +21 -6
  53. package/runtime/scripts/chat-core/sse.js +3 -0
  54. package/runtime/scripts/chat-core/tests/auth.test.js +84 -6
  55. package/runtime/scripts/chat-core/tests/engine.test.js +312 -0
  56. package/runtime/scripts/chat-core/tests/native-config.test.js +23 -0
  57. package/runtime/scripts/chat-core/tool-shape.js +4 -4
  58. package/runtime/scripts/chat-core/tooling/active-memory.js +5 -4
  59. package/runtime/scripts/chat-core/tooling/native-config.js +34 -3
  60. package/runtime/scripts/local-gateway.js +34 -27
  61. package/runtime/scripts/platform-context.txt +76 -94
  62. package/runtime/scripts/amalgm-mcp/artifacts/rest.js +0 -103
  63. package/runtime/scripts/amalgm-mcp/artifacts/store.js +0 -157
  64. package/runtime/scripts/amalgm-mcp/artifacts/supervisor.js +0 -439
  65. package/runtime/scripts/amalgm-mcp/artifacts/tools.js +0 -176
  66. package/runtime/scripts/amalgm-mcp/events/executor.js +0 -258
  67. package/runtime/scripts/amalgm-mcp/events/rest.js +0 -214
  68. package/runtime/scripts/amalgm-mcp/events/tools.js +0 -323
  69. package/runtime/scripts/amalgm-mcp/tasks/rest.js +0 -110
  70. package/runtime/scripts/amalgm-mcp/tasks/tools.js +0 -416
@@ -2,17 +2,15 @@
2
2
  * Event triggers storage — ~/.amalgm/event-triggers.json
3
3
  */
4
4
 
5
+ const crypto = require('crypto');
5
6
  const { EVENT_TRIGGERS_FILE, STORAGE_DIR } = require('../config');
6
7
  const { ensureDir, readJson, writeJsonAtomic } = require('../lib/storage');
7
8
  const { openLocalDb } = require('../state/db');
8
- const {
9
- chatInputToLegacyFields,
10
- getChatInputText,
11
- normalizeChatInput,
12
- } = require('../../../lib/chatInput');
13
- const { DEFAULT_SELECTED_MODELS, getSelectedModel } = require('../lib/prefs');
14
- const credentialAdapter = require('../../credential-adapter');
15
9
  const { appendStateEvent, insertStateEvent, publishStateEvent } = require('../state/events');
10
+ const { compileWorkflowText } = require('../workflows/compiler');
11
+ const { syncWorkflowTriggerLinks, upsertWorkflowFromTrigger } = require('../workflows/store');
12
+ const { internalWorkflowSeeds } = require('./internal-workflows');
13
+ const { getWebhookUrl } = require('./webhook-url');
16
14
 
17
15
  const DEFAULT_RUN_LIMIT = 100;
18
16
 
@@ -29,66 +27,111 @@ function parseJson(value, fallback = null) {
29
27
  }
30
28
  }
31
29
 
32
- function normalizeStoredTrigger(trigger) {
33
- const harness =
34
- (trigger.chatInput && trigger.chatInput.agent && trigger.chatInput.agent.harness)
35
- || trigger.harness
36
- || 'claude_code';
37
- const model =
38
- (trigger.chatInput && trigger.chatInput.agent && trigger.chatInput.agent.model)
39
- || trigger.model
40
- || getSelectedModel(harness)
41
- || DEFAULT_SELECTED_MODELS[harness]
42
- || null;
43
- const authMethod =
44
- (trigger.chatInput && trigger.chatInput.agent && trigger.chatInput.agent.authMethod)
45
- || trigger.authMethod
46
- || (credentialAdapter.VALID_HARNESS_IDS.includes(harness)
47
- ? credentialAdapter.getPersistedAuthMode(harness)
48
- : 'amalgm');
49
- const chatInput = normalizeChatInput(trigger.chatInput, {
50
- prompt: trigger.agent_prompt,
51
- harness,
52
- modelId: model,
53
- authMethod,
54
- cwd: trigger.projectPath,
55
- projectPath: trigger.projectPath,
56
- });
57
- const legacy = chatInputToLegacyFields(chatInput, {
58
- prompt: trigger.agent_prompt,
59
- harness,
60
- modelId: model,
61
- authMethod,
62
- cwd: trigger.projectPath,
63
- projectPath: trigger.projectPath,
64
- });
30
+ function normalizeStringList(values) {
31
+ if (!Array.isArray(values)) return [];
32
+ return [...new Set(values
33
+ .filter((value) => typeof value === 'string')
34
+ .map((value) => value.trim())
35
+ .filter(Boolean))];
36
+ }
37
+
38
+ function normalizeWorkflowTrigger(trigger) {
39
+ const workflowText = String(trigger.workflowText || trigger.workflow || '').trim();
40
+ let workflowIr = trigger.workflowIr && trigger.workflowIr.version === 1 ? trigger.workflowIr : null;
41
+ let compilerErrors = null;
42
+
43
+ if (workflowText) {
44
+ try {
45
+ workflowIr = compileWorkflowText(workflowText);
46
+ } catch (error) {
47
+ workflowIr = null;
48
+ compilerErrors = [error.message || String(error)];
49
+ }
50
+ }
51
+
52
+ const irTrigger = workflowIr?.trigger || {};
53
+ const allowlist = {
54
+ ...(workflowIr?.allowlist || {}),
55
+ ...(trigger.allowlist && typeof trigger.allowlist === 'object' ? trigger.allowlist : {}),
56
+ };
57
+ const limits = {
58
+ ...(workflowIr?.limits || {}),
59
+ ...(trigger.limits && typeof trigger.limits === 'object' ? trigger.limits : {}),
60
+ };
65
61
 
66
62
  return {
67
- ...trigger,
68
- chatInput,
69
- agent_prompt: getChatInputText(chatInput, trigger.agent_prompt) || legacy.prompt,
70
- harness: legacy.harness || harness,
71
- model: legacy.modelId || model,
72
- authMethod: legacy.authMethod || authMethod,
73
- projectPath: legacy.cwd || trigger.projectPath || null,
63
+ id: trigger.id,
64
+ name: trigger.name || 'Untitled workflow',
65
+ description: trigger.description || '',
66
+ source: irTrigger.source || trigger.source || '*',
67
+ event: irTrigger.event || trigger.event || '*',
68
+ enabled: trigger.enabled !== false,
69
+ webhookUrl: trigger.webhookUrl || getWebhookUrl(),
70
+ secret: trigger.secret || crypto.randomUUID().replace(/-/g, ''),
71
+ projectPath: trigger.projectPath || null,
72
+ workflowIds: normalizeStringList(
73
+ trigger.workflowIds || (trigger.workflowId ? [trigger.workflowId] : []),
74
+ ),
75
+ workflowText,
76
+ workflowIr,
77
+ compilerErrors,
78
+ allowlist,
79
+ limits,
80
+ internal: trigger.internal === true,
81
+ createdAt: trigger.createdAt || nowIso(),
82
+ updatedAt: trigger.updatedAt || null,
83
+ lastFiredAt: trigger.lastFiredAt || null,
74
84
  };
75
85
  }
76
86
 
77
87
  function migrateTriggersData(data) {
78
88
  let changed = false;
79
- const triggers = Array.isArray(data.triggers)
80
- ? data.triggers.map((trigger) => {
81
- const normalizedTrigger = normalizeStoredTrigger(trigger);
82
- if (JSON.stringify(normalizedTrigger) !== JSON.stringify(trigger)) {
83
- changed = true;
84
- }
85
- return normalizedTrigger;
86
- })
87
- : [];
89
+ const rawTriggers = Array.isArray(data.triggers) ? data.triggers : [];
90
+ const triggers = rawTriggers
91
+ .filter((trigger) => trigger && (
92
+ trigger.workflowText
93
+ || trigger.workflow
94
+ || trigger.workflowIr
95
+ || trigger.workflowId
96
+ || (Array.isArray(trigger.workflowIds) && trigger.workflowIds.length > 0)
97
+ ))
98
+ .map((trigger) => {
99
+ const embeddedWorkflow = upsertWorkflowFromTrigger(trigger);
100
+ const normalizedTrigger = normalizeWorkflowTrigger({
101
+ ...trigger,
102
+ workflowIds: normalizeStringList([
103
+ ...(Array.isArray(trigger.workflowIds) ? trigger.workflowIds : []),
104
+ ...(trigger.workflowId ? [trigger.workflowId] : []),
105
+ ...(embeddedWorkflow?.id ? [embeddedWorkflow.id] : []),
106
+ ]),
107
+ });
108
+ if (JSON.stringify(normalizedTrigger) !== JSON.stringify(trigger)) {
109
+ changed = true;
110
+ }
111
+ return normalizedTrigger;
112
+ });
113
+
114
+ if (triggers.length !== rawTriggers.length) changed = true;
115
+
116
+ const existingIds = new Set(triggers.map((trigger) => trigger.id));
117
+ for (const seed of internalWorkflowSeeds()) {
118
+ if (existingIds.has(seed.id)) continue;
119
+ triggers.push(normalizeWorkflowTrigger({
120
+ ...seed,
121
+ webhookUrl: seed.webhookUrl || null,
122
+ secret: seed.secret || null,
123
+ createdAt: nowIso(),
124
+ lastFiredAt: null,
125
+ }));
126
+ changed = true;
127
+ }
88
128
 
89
129
  return {
90
130
  changed,
91
- data: { triggers },
131
+ data: {
132
+ version: 2,
133
+ triggers,
134
+ },
92
135
  };
93
136
  }
94
137
 
@@ -124,6 +167,7 @@ function publishEventTriggersChange(data, source = 'event-triggers') {
124
167
 
125
168
  function saveEventTriggers(data, options = {}) {
126
169
  writeJsonAtomic(EVENT_TRIGGERS_FILE, data);
170
+ syncWorkflowTriggerLinks(data.triggers);
127
171
  publishEventTriggersChange(data, options.source || 'event-triggers:save');
128
172
  }
129
173
 
@@ -240,6 +284,8 @@ function mergeEventRunEntry(existing, triggerId, entry) {
240
284
  runId: entry.runId || entry.id || existing?.id || entry.sessionId || null,
241
285
  triggerId,
242
286
  triggerName: entry.triggerName || existing?.triggerName || null,
287
+ workflowId: entry.workflowId || existing?.workflowId || null,
288
+ workflowName: entry.workflowName || existing?.workflowName || null,
243
289
  status: entry.status || existing?.status || 'running',
244
290
  startedAt,
245
291
  finishedAt,
@@ -248,6 +294,8 @@ function mergeEventRunEntry(existing, triggerId, entry) {
248
294
  projectPath: entry.projectPath || existing?.projectPath || null,
249
295
  error: entry.error || existing?.error || null,
250
296
  durationMs: entry.durationMs ?? existing?.durationMs ?? null,
297
+ workflow: entry.workflow ?? existing?.workflow ?? null,
298
+ output: entry.output ?? existing?.output ?? null,
251
299
  outputLength: entry.outputLength ?? existing?.outputLength ?? null,
252
300
  prompt: entry.prompt || existing?.prompt || null,
253
301
  events,
@@ -308,6 +356,7 @@ module.exports = {
308
356
  ensureTriggersDirs,
309
357
  listEventRuns,
310
358
  loadEventTriggers,
359
+ normalizeWorkflowTrigger,
311
360
  recordEventRun,
312
361
  saveEventTriggers,
313
362
  };
@@ -11,13 +11,13 @@
11
11
  *
12
12
  * Modules:
13
13
  * config.js env + paths
14
- * deps.js cron-parser + playwright loaders
14
+ * deps.js optional runtime dependency loaders
15
15
  * lib/ generic utilities (storage, chat-runner,
16
16
  * supabase, prefs, email-md, tool-result)
17
- * tasks/ scheduled tasks (8 MCP tools + poll loop)
18
- * events/ event triggers (5 MCP tools + /events ingress)
17
+ * automations/ triggers + workflows (MCP tools + scheduler)
18
+ * events/ webhook ingress runtime for event triggers
19
19
  * agents/ agents CRUD + talk_to_agent
20
- * browser/ visible Electron browser + Playwright fallback tools
20
+ * browser/ visible Electron browser tools + agent-browser fallback
21
21
  * notify/ notify_user (email via proxy)
22
22
  * server/ MCP JSON-RPC dispatch + HTTP route table
23
23
  */
@@ -75,23 +75,21 @@ async function ensureProxyToken() {
75
75
  async function boot() {
76
76
  await ensureProxyToken();
77
77
 
78
- const { ensureTasksDirs } = require('./tasks/store');
79
- const { ensureTriggersDirs } = require('./events/store');
78
+ const { ensureAutomationsStore } = require('./automations/store');
80
79
  const { ensureAgentsDirs } = require('./agents/store');
81
- const { ensureArtifactsDirs } = require('./artifacts/store');
82
- const { startArtifactRouteSyncLoop } = require('./artifacts/advertise');
83
- const { startRegisteredArtifacts } = require('./artifacts/supervisor');
80
+ const { ensureAppsDirs } = require('./apps/store');
81
+ const { startAppRouteSyncLoop } = require('./apps/advertise');
82
+ const { startRegisteredApps } = require('./apps/supervisor');
84
83
  const { listen } = require('./server/http');
85
84
 
86
85
  // Make sure every storage dir/file exists before we accept traffic.
87
- ensureTasksDirs();
88
- ensureTriggersDirs();
86
+ ensureAutomationsStore();
89
87
  ensureAgentsDirs();
90
- ensureArtifactsDirs();
88
+ ensureAppsDirs();
91
89
 
92
90
  await listen();
93
- await startRegisteredArtifacts();
94
- startArtifactRouteSyncLoop();
91
+ await startRegisteredApps();
92
+ startAppRouteSyncLoop();
95
93
  }
96
94
 
97
95
  boot().catch((err) => {
@@ -1,17 +1,17 @@
1
1
  /**
2
2
  * User model preferences — mirrors the Zustand harnessPreferences store.
3
3
  *
4
- * Hydrates on boot (non-blocking) from Supabase. Used to pick the right model
4
+ * Hydrates on boot (non-blocking) from local SQLite. Used to pick the right model
5
5
  * for task/event/agent runs without requiring the caller to specify it.
6
6
  */
7
7
 
8
- const { AMALGM_USER_ID } = require('../config');
9
- const { hasSupabase, supabaseSelect } = require('./supabase');
8
+ const { openLocalDb } = require('../state/db');
9
+ const { insertStateEvent, publishStateEvent } = require('../state/events');
10
10
 
11
11
  const DEFAULT_SELECTED_MODELS = {
12
12
  claude_code: 'anthropic/claude-opus-4.7',
13
- codex: 'openai/gpt-5.4-thinking-xhigh',
14
- opencode: 'opencode-moonshotai-kimi-k2.5',
13
+ codex: 'openai/gpt-5.5',
14
+ opencode: 'opencode-deepseek-deepseek-v4-pro',
15
15
  pi: 'pi-anthropic-claude-sonnet-4.6',
16
16
  amp: 'amp-default',
17
17
  cursor: 'cursor/composer-2-fast',
@@ -124,10 +124,16 @@ const CURSOR_MODEL_ALIASES = {
124
124
  'cursor-moonshotai/kimi-k2.5': 'moonshotai/kimi-k2.5',
125
125
  };
126
126
 
127
+ const PREFERENCES_META_KEY = 'user_preferences';
128
+ const PREFERENCES_RESOURCE = 'user_preferences';
129
+ const PREFERENCES_RESOURCE_ID = 'current';
130
+
127
131
  const _selectedModels = { ...DEFAULT_SELECTED_MODELS };
128
- const _authMethods = {};
129
- let _pinnedHarness = null;
130
- let _pinnedModel = null;
132
+ const _authMethods = { ...DEFAULT_AUTH_METHODS };
133
+ let _lastUsedHarness = 'claude_code';
134
+ let _lastUsedModel = DEFAULT_SELECTED_MODELS.claude_code;
135
+ let _lastUsedCwd = null;
136
+ let _modelSettings = {};
131
137
  let _prefsHydrated = false;
132
138
  let _prefsHydratePromise = null;
133
139
 
@@ -294,26 +300,212 @@ function normalizeModelSelection(harnessId, modelId) {
294
300
  return { modelId, reasoningEffort: null };
295
301
  }
296
302
 
297
- function inferHarnessForModel(modelId, fallbackHarnessId) {
298
- const cleaned = cleanModelId(modelId);
299
- if (cleaned.startsWith('cursor-') || cleaned.startsWith('cursor/')) return 'cursor';
300
- if (cleaned.startsWith('amp-')) return 'amp';
301
- if (cleaned.startsWith('opencode-')) return 'opencode';
302
- if (cleaned.startsWith('pi-')) return 'pi';
303
- if (
304
- cleaned.startsWith('claude-') ||
305
- cleaned.startsWith('anthropic/claude-') ||
306
- ['opus', 'opus1m', 'opus[1m]', 'sonnet', 'sonnet[1m]', 'haiku'].includes(cleaned)
307
- ) {
308
- return 'claude_code';
303
+ function isPlainObject(value) {
304
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
305
+ }
306
+
307
+ function cleanString(value) {
308
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
309
+ }
310
+
311
+ function coerceHarnessId(value, fallback = 'claude_code') {
312
+ return Object.prototype.hasOwnProperty.call(DEFAULT_SELECTED_MODELS, value)
313
+ ? value
314
+ : fallback;
315
+ }
316
+
317
+ function normalizeModelSettings(value) {
318
+ if (!isPlainObject(value)) return {};
319
+ const normalized = {};
320
+ for (const [key, settings] of Object.entries(value)) {
321
+ if (!key || !isPlainObject(settings)) continue;
322
+ if (Object.keys(settings).length === 0) continue;
323
+ normalized[key] = settings;
324
+ }
325
+ return normalized;
326
+ }
327
+
328
+ function normalizeReasoningEffort(value) {
329
+ if (typeof value !== 'string') return null;
330
+ const normalized = value.trim().toLowerCase().replace(/_/g, '-');
331
+ if (normalized === 'low') return 'low';
332
+ if (normalized === 'medium') return 'medium';
333
+ if (normalized === 'high') return 'high';
334
+ if (normalized === 'xhigh' || normalized === 'extra-high' || normalized === 'extra high') {
335
+ return 'xhigh';
336
+ }
337
+ return null;
338
+ }
339
+
340
+ function codexModelSettingsKey(modelId) {
341
+ return codexGatewayModelId(String(modelId || ''));
342
+ }
343
+
344
+ function reasoningEffortForSelection(harnessId, modelId, normalizedEffort) {
345
+ const explicitEffort = normalizeReasoningEffort(normalizedEffort);
346
+ if (explicitEffort) return explicitEffort;
347
+ if (harnessId !== 'codex') return null;
348
+ const settingsKey = codexModelSettingsKey(modelId);
349
+ const settings = _modelSettings[settingsKey] || _modelSettings[modelId] || {};
350
+ return normalizeReasoningEffort(settings.effort || settings.reasoningEffort || settings.reasoning) || 'xhigh';
351
+ }
352
+
353
+ function normalizePreferences(input) {
354
+ const raw = isPlainObject(input) ? input : {};
355
+ const rawRecentPrefs = isPlainObject(raw.recent_prefs) ? raw.recent_prefs : {};
356
+ const recentPrefs = { ...DEFAULT_SELECTED_MODELS };
357
+ for (const [harnessId, modelId] of Object.entries(rawRecentPrefs)) {
358
+ if (!modelId) continue;
359
+ const harness = coerceHarnessId(harnessId, null);
360
+ if (!harness) continue;
361
+ recentPrefs[harness] = normalizeModelSelection(harness, modelId).modelId || modelId;
362
+ }
363
+
364
+ const rawUsage = isPlainObject(raw.usage) ? raw.usage : {};
365
+ const usage = { ...DEFAULT_AUTH_METHODS };
366
+ for (const harnessId of Object.keys(DEFAULT_AUTH_METHODS)) {
367
+ usage[harnessId] = coerceAuthMethodForHarness(harnessId, rawUsage[harnessId] || usage[harnessId]);
368
+ }
369
+
370
+ const rawLastUsed = isPlainObject(raw.last_used) ? raw.last_used : {};
371
+ const lastUsedHarness = coerceHarnessId(rawLastUsed.harness, 'claude_code');
372
+ const rawLastModel =
373
+ rawLastUsed.model ||
374
+ recentPrefs[lastUsedHarness] ||
375
+ DEFAULT_SELECTED_MODELS[lastUsedHarness];
376
+ const lastUsedModel =
377
+ normalizeModelSelection(lastUsedHarness, rawLastModel).modelId ||
378
+ rawLastModel;
379
+ recentPrefs[lastUsedHarness] = lastUsedModel;
380
+
381
+ return {
382
+ last_used: {
383
+ harness: lastUsedHarness,
384
+ model: lastUsedModel,
385
+ cwd: cleanString(rawLastUsed.cwd ?? raw.last_cwd ?? raw.selected_cwd),
386
+ },
387
+ usage,
388
+ recent_prefs: recentPrefs,
389
+ model_settings: normalizeModelSettings(raw.model_settings),
390
+ };
391
+ }
392
+
393
+ function applyPreferenceCache(preferences) {
394
+ const normalized = normalizePreferences(preferences);
395
+ _lastUsedHarness = normalized.last_used.harness;
396
+ _lastUsedModel = normalized.last_used.model;
397
+ _lastUsedCwd = normalized.last_used.cwd;
398
+ _modelSettings = normalized.model_settings;
399
+
400
+ for (const key of Object.keys(_selectedModels)) delete _selectedModels[key];
401
+ Object.assign(_selectedModels, normalized.recent_prefs);
402
+
403
+ for (const key of Object.keys(_authMethods)) delete _authMethods[key];
404
+ Object.assign(_authMethods, normalized.usage);
405
+
406
+ return normalized;
407
+ }
408
+
409
+ function parseStoredPreferences(value) {
410
+ if (typeof value !== 'string' || !value) return {};
411
+ try {
412
+ return JSON.parse(value);
413
+ } catch {
414
+ return {};
309
415
  }
310
- if (cleaned.startsWith('codex-') || cleaned.startsWith('openai/') || /^(gpt-|gpt\d|o-|o\d)/i.test(cleaned)) {
311
- return 'codex';
416
+ }
417
+
418
+ function readUserPreferences() {
419
+ const row = openLocalDb()
420
+ .prepare('SELECT value FROM local_meta WHERE key = ?')
421
+ .get(PREFERENCES_META_KEY);
422
+ _prefsHydrated = true;
423
+ return applyPreferenceCache(normalizePreferences(parseStoredPreferences(row?.value)));
424
+ }
425
+
426
+ function writeUserPreferences(preferences, options = {}) {
427
+ const normalized = normalizePreferences(preferences);
428
+ const db = openLocalDb();
429
+ const event = db.transaction(() => {
430
+ db.prepare(`
431
+ INSERT INTO local_meta (key, value, updated_at)
432
+ VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
433
+ ON CONFLICT(key) DO UPDATE SET
434
+ value = excluded.value,
435
+ updated_at = excluded.updated_at
436
+ `).run(PREFERENCES_META_KEY, JSON.stringify(normalized));
437
+
438
+ return insertStateEvent(db, {
439
+ resource: PREFERENCES_RESOURCE,
440
+ op: 'replace',
441
+ id: PREFERENCES_RESOURCE_ID,
442
+ value: normalized,
443
+ source: options.source || 'user-preferences',
444
+ });
445
+ })();
446
+
447
+ _prefsHydrated = true;
448
+ applyPreferenceCache(normalized);
449
+ publishStateEvent(event);
450
+ return normalized;
451
+ }
452
+
453
+ function mergePreferencesPatch(current, patch) {
454
+ const body = isPlainObject(patch) ? patch : {};
455
+ const bodyLastUsed = isPlainObject(body.last_used) ? body.last_used : {};
456
+ const lastUsedHarness = coerceHarnessId(
457
+ bodyLastUsed.harness,
458
+ current.last_used.harness,
459
+ );
460
+ const bodyRecentPrefs = isPlainObject(body.recent_prefs) ? body.recent_prefs : {};
461
+ const recentPrefs = {
462
+ ...current.recent_prefs,
463
+ ...bodyRecentPrefs,
464
+ };
465
+ const lastUsedModel =
466
+ bodyLastUsed.model ||
467
+ recentPrefs[lastUsedHarness] ||
468
+ current.last_used.model;
469
+ const cwd = cleanString(
470
+ bodyLastUsed.cwd ??
471
+ body.selectedCwd ??
472
+ body.cwd ??
473
+ current.last_used.cwd,
474
+ );
475
+
476
+ return normalizePreferences({
477
+ last_used: {
478
+ harness: lastUsedHarness,
479
+ model: lastUsedModel,
480
+ cwd,
481
+ },
482
+ usage: isPlainObject(body.usage)
483
+ ? { ...current.usage, ...body.usage }
484
+ : current.usage,
485
+ recent_prefs: recentPrefs,
486
+ model_settings: mergeModelSettingsPatch(current.model_settings, body.model_settings),
487
+ });
488
+ }
489
+
490
+ function mergeModelSettingsPatch(currentModelSettings, patchModelSettings) {
491
+ if (!isPlainObject(patchModelSettings)) return currentModelSettings;
492
+ const next = { ...currentModelSettings };
493
+ for (const [key, value] of Object.entries(patchModelSettings)) {
494
+ if (!key || !isPlainObject(value) || Object.keys(value).length === 0) {
495
+ delete next[key];
496
+ continue;
497
+ }
498
+ next[key] = value;
312
499
  }
313
- return fallbackHarnessId;
500
+ return next;
314
501
  }
315
502
 
316
- function resolveCliModel(harnessId, modelId) {
503
+ function updateUserPreferences(patch, options = {}) {
504
+ const current = readUserPreferences();
505
+ return writeUserPreferences(mergePreferencesPatch(current, patch), options);
506
+ }
507
+
508
+ function resolveCliModel(harnessId, modelId, reasoningEffort = null) {
317
509
  const selection = normalizeModelSelection(harnessId, modelId);
318
510
  const selectedModelId = selection.modelId || modelId;
319
511
  if (harnessId === 'claude_code') {
@@ -321,7 +513,10 @@ function resolveCliModel(harnessId, modelId) {
321
513
  selectedModelId.replace(/^anthropic\//, '').replace(/-(\d+)\.(\d+)(?=$|-)/, '-$1-$2');
322
514
  }
323
515
  if (harnessId === 'codex') {
324
- return codexHarnessModelId(selectedModelId, selection.reasoningEffort);
516
+ return codexHarnessModelId(
517
+ selectedModelId,
518
+ reasoningEffortForSelection(harnessId, selectedModelId, reasoningEffort || selection.reasoningEffort),
519
+ );
325
520
  }
326
521
  if (harnessId === 'cursor') {
327
522
  return cursorCanonicalModelIdToCliModel(selectedModelId);
@@ -347,50 +542,16 @@ function resolveCliModel(harnessId, modelId) {
347
542
 
348
543
  async function hydrateModelPreferences() {
349
544
  if (_prefsHydratePromise) return _prefsHydratePromise;
350
- if (_prefsHydrated || !hasSupabase()) return;
545
+ if (_prefsHydrated) return;
351
546
  _prefsHydratePromise = (async () => {
352
- _prefsHydrated = true;
353
547
  try {
354
- const rows = await supabaseSelect(
355
- 'user_preferences',
356
- `user_id=eq.${AMALGM_USER_ID}&select=preferences`,
357
- );
358
- if (!rows) return;
359
- const preferences = rows?.[0]?.preferences;
360
- const recentPrefs = preferences?.recent_prefs;
361
- if (recentPrefs && typeof recentPrefs === 'object') {
362
- for (const [h, m] of Object.entries(recentPrefs)) {
363
- if (m) {
364
- const normalized = normalizeModelSelection(h, m).modelId || m;
365
- if (_selectedModels[h] !== normalized) _selectedModels[h] = normalized;
366
- }
367
- }
368
- }
369
- const usagePrefs = preferences?.usage;
370
- if (usagePrefs && typeof usagePrefs === 'object') {
371
- for (const [h, method] of Object.entries(usagePrefs)) {
372
- if (method) _authMethods[h] = coerceAuthMethodForHarness(h, method);
373
- }
374
- }
375
- _pinnedHarness = preferences?.autopilot?.harness || null;
376
- const rawPinnedModel = preferences?.autopilot?.model || null;
377
- if (rawPinnedModel) {
378
- const harnessForPinned =
379
- _pinnedHarness ||
380
- inferHarnessForModel(rawPinnedModel, preferences?.last_used?.harness || null);
381
- _pinnedModel = harnessForPinned
382
- ? normalizeModelSelection(harnessForPinned, rawPinnedModel).modelId || rawPinnedModel
383
- : rawPinnedModel;
384
- } else {
385
- _pinnedModel = null;
386
- }
548
+ const preferences = readUserPreferences();
387
549
  console.log(
388
550
  '[AmalgmMCP] Hydrated user prefs:',
389
551
  JSON.stringify({
390
552
  selectedModels: _selectedModels,
391
553
  authMethods: _authMethods,
392
- pinnedHarness: _pinnedHarness,
393
- pinnedModel: _pinnedModel,
554
+ lastUsed: preferences.last_used,
394
555
  }),
395
556
  );
396
557
  } catch (err) {
@@ -403,7 +564,6 @@ async function hydrateModelPreferences() {
403
564
  }
404
565
 
405
566
  function getSelectedModel(harnessId) {
406
- if (_pinnedHarness === harnessId && _pinnedModel) return _pinnedModel;
407
567
  return _selectedModels[harnessId] || DEFAULT_SELECTED_MODELS[harnessId] || null;
408
568
  }
409
569
 
@@ -425,16 +585,20 @@ function resolveModelSelection(harnessId, uiModelId) {
425
585
  }
426
586
  const normalized = normalizeModelSelection(harnessId, selectedModelId);
427
587
  const modelId = normalized.modelId || selectedModelId;
588
+ const reasoningEffort = reasoningEffortForSelection(harnessId, modelId, normalized.reasoningEffort);
428
589
  return {
429
590
  modelId,
430
- cliModel: resolveCliModel(harnessId, modelId),
431
- reasoningEffort: normalized.reasoningEffort || null,
591
+ cliModel: resolveCliModel(harnessId, modelId, reasoningEffort),
592
+ reasoningEffort,
432
593
  };
433
594
  }
434
595
 
435
596
  module.exports = {
436
597
  DEFAULT_SELECTED_MODELS,
437
598
  hydrateModelPreferences,
599
+ readUserPreferences,
600
+ writeUserPreferences,
601
+ updateUserPreferences,
438
602
  getSelectedModel,
439
603
  getPreferredAuthMethod,
440
604
  resolveModelSelection,