melchizedek-agents 1.3.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/.env.example +15 -0
  2. package/CHANGELOG.md +199 -0
  3. package/DOCUMENTATION.md +84 -6
  4. package/ONBOARDING.md +3 -3
  5. package/README.md +2 -1
  6. package/config/agents/examples/connectors.yaml +4 -1
  7. package/config/agents/examples/event_assistant.yaml +82 -0
  8. package/config/agents/syndicate.schema.json +80 -6
  9. package/db/hardening.sql +43 -14
  10. package/db/migrations/0015_model_pricing.sql +107 -0
  11. package/db/telemetry.sql +79 -1
  12. package/dist/lib/a2a/app.js +2 -1
  13. package/dist/lib/chatgpt/oauth.d.ts +4 -3
  14. package/dist/lib/chatgpt/oauth.js +36 -12
  15. package/dist/lib/chatgpt/state.js +4 -4
  16. package/dist/lib/compile.js +15 -2
  17. package/dist/lib/compileNative.js +1 -0
  18. package/dist/lib/index.d.ts +3 -1
  19. package/dist/lib/index.js +2 -0
  20. package/dist/lib/loadSyndicate.js +4 -0
  21. package/dist/lib/memory/providers.js +2 -1
  22. package/dist/lib/models/claudeAdapter.js +2 -1
  23. package/dist/lib/models/credentialSource.d.ts +28 -0
  24. package/dist/lib/models/credentialSource.js +49 -0
  25. package/dist/lib/models/endpoints.js +2 -1
  26. package/dist/lib/models/gateway.js +2 -1
  27. package/dist/lib/models/gatewayAdapter.js +2 -1
  28. package/dist/lib/models/geminiAdapter.js +2 -1
  29. package/dist/lib/models/gptAdapter.js +13 -2
  30. package/dist/lib/models/grokAdapter.js +2 -1
  31. package/dist/lib/models/kimiAdapter.js +2 -1
  32. package/dist/lib/models/providerMap.js +3 -2
  33. package/dist/lib/observability/embeddings.js +2 -1
  34. package/dist/lib/observability/lineage.js +11 -3
  35. package/dist/lib/observability/supabaseSpanExporter.d.ts +19 -1
  36. package/dist/lib/observability/supabaseSpanExporter.js +37 -1
  37. package/dist/lib/observability/tracer.d.ts +18 -0
  38. package/dist/lib/observability/tracer.js +60 -2
  39. package/dist/lib/paths.d.ts +31 -0
  40. package/dist/lib/paths.js +76 -0
  41. package/dist/lib/runtime/credentials.d.ts +21 -0
  42. package/dist/lib/runtime/credentials.js +32 -0
  43. package/dist/lib/runtime/native/agentLoop.d.ts +6 -0
  44. package/dist/lib/runtime/native/delegate.d.ts +2 -0
  45. package/dist/lib/runtime/native/delegate.js +39 -3
  46. package/dist/lib/runtime/native/interrupts.d.ts +33 -3
  47. package/dist/lib/runtime/native/interrupts.js +99 -9
  48. package/dist/lib/runtime/syndicateTurn.d.ts +14 -1
  49. package/dist/lib/runtime/syndicateTurn.js +45 -11
  50. package/dist/lib/runtime/turnControl.d.ts +27 -0
  51. package/dist/lib/storage/postgres/index.d.ts +25 -0
  52. package/dist/lib/storage/postgres/index.js +10 -1
  53. package/dist/lib/syndicateSchema.d.ts +26 -4
  54. package/dist/lib/syndicateSchema.js +63 -1
  55. package/dist/lib/tools/generateImageTool.js +6 -6
  56. package/dist/lib/tools/inspectImageTool.d.ts +2 -0
  57. package/dist/lib/tools/inspectImageTool.js +17 -10
  58. package/dist/lib/tools/mcpToolFactory.d.ts +34 -1
  59. package/dist/lib/tools/mcpToolFactory.js +200 -27
  60. package/dist/lib/tools/mcpTransports.d.ts +4 -2
  61. package/dist/lib/tools/mcpTransports.js +4 -2
  62. package/dist/lib/tools/taskTools.js +3 -3
  63. package/dist/lib/tools/xApiSearchTool.js +2 -1
  64. package/dist/lib/workflow/agentNode.d.ts +19 -3
  65. package/dist/lib/workflow/agentNode.js +106 -13
  66. package/dist/lib/workflow/resume.d.ts +8 -7
  67. package/dist/lib/workflow/resume.js +17 -10
  68. package/dist/lib/workflow/turn.d.ts +12 -2
  69. package/dist/lib/workflow/turn.js +22 -12
  70. package/dist/scripts/a2a_server.js +5 -0
  71. package/package.json +22 -15
package/db/hardening.sql CHANGED
@@ -30,6 +30,27 @@
30
30
  -- The A2A server checks at boot whether this file has been applied and
31
31
  -- prints a prominent warning if not (see lib/persistence/supabaseProvider.ts).
32
32
 
33
+ -- ── The API roles ────────────────────────────────────────────────────────
34
+ -- anon and authenticated are Supabase's PostgREST roles. Plain Postgres and
35
+ -- embedded Postgres (PGlite) have no REST API and normally no such roles;
36
+ -- there, the PUBLIC revokes and RLS below are the whole lockdown, and the
37
+ -- revokes by role name are skipped rather than failing. These two helpers
38
+ -- live in pg_temp: they exist for this session only and leave nothing behind.
39
+ CREATE OR REPLACE FUNCTION pg_temp.melchizedek_api_roles() RETURNS text
40
+ LANGUAGE sql AS $$
41
+ SELECT string_agg(quote_ident(rolname), ', ' ORDER BY rolname)
42
+ FROM pg_roles WHERE rolname IN ('anon', 'authenticated');
43
+ $$;
44
+
45
+ CREATE OR REPLACE FUNCTION pg_temp.melchizedek_revoke_api(target text) RETURNS void
46
+ LANGUAGE plpgsql AS $$
47
+ DECLARE roles text := pg_temp.melchizedek_api_roles();
48
+ BEGIN
49
+ IF roles IS NOT NULL THEN
50
+ EXECUTE format('REVOKE ALL ON %s FROM %s', target, roles);
51
+ END IF;
52
+ END $$;
53
+
33
54
  -- ── Tier 1: lock the public API paths ────────────────────────────────────
34
55
 
35
56
  -- Enable RLS. With RLS on and NO policies defined, anon/authenticated get
@@ -40,8 +61,11 @@ ALTER TABLE adk_sessions ENABLE ROW LEVEL SECURITY;
40
61
  -- Belt and suspenders: also revoke the default table privileges from the
41
62
  -- API roles, so even a future accidentally-created permissive policy
42
63
  -- cannot re-expose the tables.
43
- REVOKE ALL ON adk_memory_facts FROM anon, authenticated;
44
- REVOKE ALL ON adk_sessions FROM anon, authenticated;
64
+ DO $$
65
+ BEGIN
66
+ PERFORM pg_temp.melchizedek_revoke_api('adk_memory_facts');
67
+ PERFORM pg_temp.melchizedek_revoke_api('adk_sessions');
68
+ END $$;
45
69
 
46
70
  -- Optional observability ledger (db/telemetry.sql). Guarded: the tables only
47
71
  -- exist when the operator opted into TELEMETRY_SUPABASE. adk_turns holds
@@ -51,17 +75,18 @@ DO $$
51
75
  DECLARE t text;
52
76
  BEGIN
53
77
  FOREACH t IN ARRAY ARRAY['adk_telemetry', 'adk_turns', 'adk_payloads', 'adk_verdicts', 'adk_labels',
54
- 'adk_session_events', 'adk_a2a_tasks', 'melchizedek_usage', 'melchizedek_audit'] LOOP
78
+ 'adk_session_events', 'adk_a2a_tasks', 'melchizedek_usage', 'melchizedek_audit',
79
+ 'model_pricing'] LOOP
55
80
  IF to_regclass('public.' || t) IS NOT NULL THEN
56
81
  EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', t);
57
- EXECUTE format('REVOKE ALL ON %I FROM anon, authenticated', t);
82
+ PERFORM pg_temp.melchizedek_revoke_api(format('%I', t));
58
83
  END IF;
59
84
  END LOOP;
60
85
  -- The production view and the retention function read/delete those
61
86
  -- tables on behalf of the caller; neither belongs on the public API.
62
87
  FOREACH t IN ARRAY ARRAY['adk_turns_production', 'adk_kpi_daily', 'adk_kpi_routes_daily', 'adk_kpi_judges_daily', 'adk_kpi_hourly'] LOOP
63
88
  IF to_regclass('public.' || t) IS NOT NULL THEN
64
- EXECUTE format('REVOKE ALL ON %I FROM anon, authenticated', t);
89
+ PERFORM pg_temp.melchizedek_revoke_api(format('%I', t));
65
90
  END IF;
66
91
  END LOOP;
67
92
  -- Function privileges for these are handled in the FUNCTIONS block below.
@@ -73,7 +98,7 @@ DO $$
73
98
  BEGIN
74
99
  IF to_regclass('public.melchizedek_schema_version') IS NOT NULL THEN
75
100
  EXECUTE 'ALTER TABLE melchizedek_schema_version ENABLE ROW LEVEL SECURITY';
76
- EXECUTE 'REVOKE ALL ON melchizedek_schema_version FROM anon, authenticated';
101
+ PERFORM pg_temp.melchizedek_revoke_api('melchizedek_schema_version');
77
102
  END IF;
78
103
  END $$;
79
104
 
@@ -84,29 +109,29 @@ DO $$
84
109
  BEGIN
85
110
  IF to_regclass('public.adk_agent_registry') IS NOT NULL THEN
86
111
  EXECUTE 'ALTER TABLE adk_agent_registry ENABLE ROW LEVEL SECURITY';
87
- EXECUTE 'REVOKE ALL ON adk_agent_registry FROM anon, authenticated';
112
+ PERFORM pg_temp.melchizedek_revoke_api('adk_agent_registry');
88
113
  END IF;
89
114
  -- The task tools' lists and job queue (migration 0009).
90
115
  IF to_regclass('public.melchizedek_tasks') IS NOT NULL THEN
91
116
  EXECUTE 'ALTER TABLE melchizedek_tasks ENABLE ROW LEVEL SECURITY';
92
117
  EXECUTE 'ALTER TABLE melchizedek_task_owners ENABLE ROW LEVEL SECURITY';
93
- EXECUTE 'REVOKE ALL ON melchizedek_tasks, melchizedek_task_owners FROM anon, authenticated';
118
+ PERFORM pg_temp.melchizedek_revoke_api('melchizedek_tasks, melchizedek_task_owners');
94
119
  END IF;
95
120
  -- The memory processed marker (migration 0007): identifiers only.
96
121
  IF to_regclass('public.melchizedek_memory_ingest') IS NOT NULL THEN
97
122
  EXECUTE 'ALTER TABLE melchizedek_memory_ingest ENABLE ROW LEVEL SECURITY';
98
- EXECUTE 'REVOKE ALL ON melchizedek_memory_ingest FROM anon, authenticated';
123
+ PERFORM pg_temp.melchizedek_revoke_api('melchizedek_memory_ingest');
99
124
  END IF;
100
125
  -- Third-party tokens held for tools (migration 0013): ciphertext only,
101
126
  -- still nothing an API role should read.
102
127
  IF to_regclass('public.melchizedek_tool_credentials') IS NOT NULL THEN
103
128
  EXECUTE 'ALTER TABLE melchizedek_tool_credentials ENABLE ROW LEVEL SECURITY';
104
- EXECUTE 'REVOKE ALL ON melchizedek_tool_credentials FROM anon, authenticated';
129
+ PERFORM pg_temp.melchizedek_revoke_api('melchizedek_tool_credentials');
105
130
  END IF;
106
131
  -- Its history (migration 0005): every definition an id has held.
107
132
  IF to_regclass('public.adk_agent_registry_versions') IS NOT NULL THEN
108
133
  EXECUTE 'ALTER TABLE adk_agent_registry_versions ENABLE ROW LEVEL SECURITY';
109
- EXECUTE 'REVOKE ALL ON adk_agent_registry_versions FROM anon, authenticated';
134
+ PERFORM pg_temp.melchizedek_revoke_api('adk_agent_registry_versions');
110
135
  END IF;
111
136
  END $$;
112
137
 
@@ -139,9 +164,12 @@ BEGIN
139
164
  'melchizedek_registry_versions_immutable', 'melchizedek_registry_publish',
140
165
  'melchizedek_registry_activate', 'melchizedek_memory_commit',
141
166
  'melchizedek_memory_dimensions', 'melchizedek_prune_memory_ingest',
142
- 'melchizedek_prune_memory', 'melchizedek_prune_audit')
167
+ 'melchizedek_prune_memory', 'melchizedek_prune_audit',
168
+ 'melchizedek_cost_usd', 'melchizedek_usage_cost', 'melchizedek_usage_text_cost',
169
+ 'melchizedek_turns_cost', 'melchizedek_telemetry_cost')
143
170
  LOOP
144
- EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC, anon, authenticated', fn);
171
+ EXECUTE format('REVOKE ALL ON FUNCTION %s FROM PUBLIC', fn);
172
+ PERFORM pg_temp.melchizedek_revoke_api(format('FUNCTION %s', fn));
145
173
  IF has_service_role THEN
146
174
  EXECUTE format('GRANT EXECUTE ON FUNCTION %s TO service_role', fn);
147
175
  END IF;
@@ -170,9 +198,10 @@ AS $$
170
198
  'melchizedek_audit', 'melchizedek_tool_credentials');
171
199
  $$;
172
200
 
173
- REVOKE ALL ON FUNCTION melchizedek_rls_status() FROM PUBLIC, anon, authenticated;
201
+ REVOKE ALL ON FUNCTION melchizedek_rls_status() FROM PUBLIC;
174
202
  DO $$
175
203
  BEGIN
204
+ PERFORM pg_temp.melchizedek_revoke_api('FUNCTION melchizedek_rls_status()');
176
205
  IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') THEN
177
206
  GRANT EXECUTE ON FUNCTION melchizedek_rls_status() TO service_role;
178
207
  END IF;
@@ -0,0 +1,107 @@
1
+ -- ============================================================================
2
+ -- 0015_model_pricing — what a model call costs, so the ledger records dollars
3
+ -- (ADR 0136)
4
+ -- ============================================================================
5
+ -- model_pricing: USD per million tokens, one row per model id as the YAML
6
+ -- declares it (`claude-opus-5`, `gemini-3.8-flash`, `grok-4.7`). A row whose
7
+ -- id ends in `/*` prices every id under that prefix (`ollama/*`: local
8
+ -- models cost nothing). `thinking_per_mtok` NULL means thinking bills at the
9
+ -- output rate, as every provider here bills it. `verified_at` NULL means
10
+ -- nobody has checked the row against the vendor's price list: spend built on
11
+ -- it is a steering estimate (the convention ymir_model_pricing set).
12
+ --
13
+ -- Seeded from melch-obs's pricing.toml (2026-10). The seed never overwrites a
14
+ -- row: an operator's own rates survive a re-run. A rate change ships as a
15
+ -- later migration that UPDATEs the row and its verified_at.
16
+ --
17
+ -- The cost functions below are what db/telemetry.sql's ledger trigger calls
18
+ -- to fill adk_turns.cost_usd and adk_telemetry.cost_usd at write time:
19
+ -- melchizedek_cost_usd(model, input, output, thinking) one call's cost,
20
+ -- NULL for a model with no row
21
+ -- melchizedek_usage_cost(usage) a turn's cost from its per-model usage
22
+ -- ({"<model>": {"input": n, "output": n, "thinking": n}}), NULL when
23
+ -- any model in it has no row: a partial sum would read as the total.
24
+ -- Ledger output_tokens exclude thinking (genaiMapping's usage meaning), so
25
+ -- thinking is priced on top of output.
26
+ --
27
+ -- Nothing secret, nothing personal; hardening.sql closes it to the API roles
28
+ -- like every other table. Idempotent; safe to re-run.
29
+ -- ============================================================================
30
+
31
+ CREATE TABLE IF NOT EXISTS model_pricing (
32
+ model TEXT PRIMARY KEY,
33
+ input_per_mtok NUMERIC NOT NULL CHECK (input_per_mtok >= 0),
34
+ output_per_mtok NUMERIC NOT NULL CHECK (output_per_mtok >= 0),
35
+ thinking_per_mtok NUMERIC CHECK (thinking_per_mtok >= 0),
36
+ verified_at TIMESTAMPTZ,
37
+ source TEXT,
38
+ note TEXT,
39
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
40
+ );
41
+
42
+ INSERT INTO model_pricing (model, input_per_mtok, output_per_mtok, verified_at, source, note) VALUES
43
+ -- Anthropic first-party rates; pricing.toml dates the check to the month (2026-06).
44
+ ('claude-fable-5-1', 10.00, 50.00, '2026-06-01', 'melch-obs/pricing.toml', 'Anthropic first-party, checked 2026-06'),
45
+ ('claude-fable-5', 10.00, 50.00, '2026-06-01', 'melch-obs/pricing.toml', 'Anthropic first-party, checked 2026-06'),
46
+ ('claude-opus-5', 5.00, 25.00, '2026-06-01', 'melch-obs/pricing.toml', 'Anthropic first-party, checked 2026-06'),
47
+ ('claude-opus-4-8', 5.00, 25.00, '2026-06-01', 'melch-obs/pricing.toml', 'Anthropic first-party, checked 2026-06'),
48
+ ('claude-opus-4-7', 5.00, 25.00, '2026-06-01', 'melch-obs/pricing.toml', 'Anthropic first-party, checked 2026-06'),
49
+ ('claude-opus-4-6', 5.00, 25.00, '2026-06-01', 'melch-obs/pricing.toml', 'Anthropic first-party, checked 2026-06'),
50
+ ('claude-sonnet-5', 2.00, 10.00, '2026-06-01', 'melch-obs/pricing.toml', 'Anthropic first-party, checked 2026-06'),
51
+ ('claude-sonnet-4-6', 3.00, 15.00, '2026-06-01', 'melch-obs/pricing.toml', 'Anthropic first-party, checked 2026-06'),
52
+ ('claude-haiku-4-5', 1.00, 5.00, '2026-06-01', 'melch-obs/pricing.toml', 'Anthropic first-party, checked 2026-06'),
53
+ ('gemini-3.5-flash-lite', 0.30, 2.50, '2026-10-01', 'melch-obs/pricing.toml', 'Gemini API standard tier, checked 2026-10-01'),
54
+ -- Carried over from ymir_model_pricing, never checked against a vendor list.
55
+ ('gemini-3.1-flash-lite', 0.10, 0.40, NULL, 'melch-obs/pricing.toml', 'unverified estimate'),
56
+ ('gemini-3.7-flash', 0.30, 2.50, NULL, 'melch-obs/pricing.toml', 'unverified estimate'),
57
+ ('gemini-3.8-flash', 0.30, 2.50, NULL, 'melch-obs/pricing.toml', 'placeholder at 3.7''s rate, unverified'),
58
+ ('grok-4.5', 3.00, 15.00, NULL, 'melch-obs/pricing.toml', 'unverified estimate'),
59
+ ('grok-4.7', 3.00, 15.00, NULL, 'melch-obs/pricing.toml', 'unverified estimate'),
60
+ -- A local model bills nothing; that needs no vendor list.
61
+ ('ollama/*', 0, 0, '2026-10-15', 'engine', 'local models: no provider charge')
62
+ ON CONFLICT (model) DO NOTHING;
63
+
64
+ -- The row that prices `model`: its own, else its prefix's `<prefix>/*`.
65
+ CREATE OR REPLACE FUNCTION melchizedek_cost_usd(model TEXT, input BIGINT, output BIGINT, thinking BIGINT)
66
+ RETURNS NUMERIC
67
+ LANGUAGE sql
68
+ STABLE
69
+ SET search_path = public
70
+ AS $$
71
+ SELECT (coalesce(input, 0) * p.input_per_mtok
72
+ + coalesce(output, 0) * p.output_per_mtok
73
+ + coalesce(thinking, 0) * coalesce(p.thinking_per_mtok, p.output_per_mtok)) / 1000000
74
+ FROM model_pricing p
75
+ WHERE p.model = melchizedek_cost_usd.model
76
+ OR (position('/' IN melchizedek_cost_usd.model) > 0
77
+ AND p.model = split_part(melchizedek_cost_usd.model, '/', 1) || '/*')
78
+ ORDER BY (p.model = melchizedek_cost_usd.model) DESC
79
+ LIMIT 1
80
+ $$;
81
+
82
+ CREATE OR REPLACE FUNCTION melchizedek_usage_cost(usage JSONB)
83
+ RETURNS NUMERIC
84
+ LANGUAGE sql
85
+ STABLE
86
+ SET search_path = public
87
+ AS $$
88
+ SELECT CASE
89
+ WHEN usage IS NULL OR jsonb_typeof(usage) <> 'object' THEN NULL
90
+ -- bool_and over no rows is NULL: a turn that made no model call costs nothing.
91
+ WHEN coalesce(bool_and(c.cost IS NOT NULL), true) THEN coalesce(sum(c.cost), 0)
92
+ ELSE NULL
93
+ END
94
+ FROM (
95
+ SELECT melchizedek_cost_usd(
96
+ e.key,
97
+ CASE WHEN jsonb_typeof(e.value->'input') = 'number' THEN (e.value->>'input')::numeric::bigint END,
98
+ CASE WHEN jsonb_typeof(e.value->'output') = 'number' THEN (e.value->>'output')::numeric::bigint END,
99
+ CASE WHEN jsonb_typeof(e.value->'thinking') = 'number' THEN (e.value->>'thinking')::numeric::bigint END
100
+ ) AS cost
101
+ FROM jsonb_each(CASE WHEN jsonb_typeof(usage) = 'object' THEN usage ELSE '{}'::jsonb END) e
102
+ ) c
103
+ $$;
104
+
105
+ INSERT INTO melchizedek_schema_version (version, name)
106
+ VALUES (15, '0015_model_pricing')
107
+ ON CONFLICT (version) DO NOTHING;
package/db/telemetry.sql CHANGED
@@ -163,6 +163,83 @@ CREATE INDEX IF NOT EXISTS idx_adk_turns_surface_user
163
163
  -- at CREATE time. It does not gain these columns until it is re-created, so
164
164
  -- run the whole file, in order.
165
165
 
166
+ -- ── Cost: dollars per turn and per model call (ADR 0136) ─────────────────
167
+ -- cost_usd is filled at write time by the trigger below from model_pricing
168
+ -- (db/migrations/0015_model_pricing.sql), so both sinks and a replayed
169
+ -- dead-letter spool get it, and the price table is the one source:
170
+ -- adk_telemetry an llm.request row: its model and tokens. A root row: the
171
+ -- turn's per-model usage on the span (syndicate.usage).
172
+ -- adk_turns the turn's per-model usage (attributes->'syndicate.usage',
173
+ -- the tracer sums each model's calls), so a turn that used
174
+ -- two models is priced per model, input summed per call.
175
+ -- NULL when a model has no price row (a partial sum would read as the
176
+ -- total), or when the row carries no usage (a row written before this
177
+ -- column). A value the writer set itself is kept. The rate in force at
178
+ -- write time is the rate recorded: a later price change does not rewrite
179
+ -- history. Re-price on purpose with an UPDATE ... SET cost_usd = ... .
180
+ ALTER TABLE adk_turns ADD COLUMN IF NOT EXISTS cost_usd NUMERIC;
181
+ ALTER TABLE adk_telemetry ADD COLUMN IF NOT EXISTS cost_usd NUMERIC;
182
+
183
+ -- SECURITY DEFINER: the inserting role (service_role, or an application's
184
+ -- own role) need not be able to execute the pricing functions or read
185
+ -- model_pricing. Each reads only NEW and the price table. Unreadable usage
186
+ -- leaves the cost NULL; it never fails the insert.
187
+ CREATE OR REPLACE FUNCTION melchizedek_usage_text_cost(usage_text TEXT)
188
+ RETURNS NUMERIC
189
+ LANGUAGE plpgsql
190
+ STABLE
191
+ SET search_path = public
192
+ AS $$
193
+ BEGIN
194
+ IF usage_text IS NULL OR to_regclass('model_pricing') IS NULL THEN
195
+ RETURN NULL;
196
+ END IF;
197
+ RETURN melchizedek_usage_cost(usage_text::jsonb);
198
+ EXCEPTION WHEN others THEN
199
+ RETURN NULL;
200
+ END;
201
+ $$;
202
+
203
+ CREATE OR REPLACE FUNCTION melchizedek_turns_cost()
204
+ RETURNS trigger
205
+ LANGUAGE plpgsql
206
+ SECURITY DEFINER
207
+ SET search_path = public
208
+ AS $$
209
+ BEGIN
210
+ IF NEW.cost_usd IS NULL THEN
211
+ NEW.cost_usd := melchizedek_usage_text_cost(NEW.attributes->>'syndicate.usage');
212
+ END IF;
213
+ RETURN NEW;
214
+ END;
215
+ $$;
216
+
217
+ CREATE OR REPLACE FUNCTION melchizedek_telemetry_cost()
218
+ RETURNS trigger
219
+ LANGUAGE plpgsql
220
+ SECURITY DEFINER
221
+ SET search_path = public
222
+ AS $$
223
+ BEGIN
224
+ IF NEW.cost_usd IS NOT NULL OR to_regclass('model_pricing') IS NULL THEN
225
+ RETURN NEW;
226
+ END IF;
227
+ IF NEW.model IS NOT NULL THEN
228
+ NEW.cost_usd := melchizedek_cost_usd(NEW.model, NEW.input_tokens, NEW.output_tokens, NEW.thinking_tokens);
229
+ ELSE
230
+ NEW.cost_usd := melchizedek_usage_text_cost(NEW.span->'attributes'->>'syndicate.usage');
231
+ END IF;
232
+ RETURN NEW;
233
+ END;
234
+ $$;
235
+
236
+ DROP TRIGGER IF EXISTS melchizedek_turns_cost ON adk_turns;
237
+ CREATE TRIGGER melchizedek_turns_cost BEFORE INSERT ON adk_turns
238
+ FOR EACH ROW EXECUTE FUNCTION melchizedek_turns_cost();
239
+ DROP TRIGGER IF EXISTS melchizedek_telemetry_cost ON adk_telemetry;
240
+ CREATE TRIGGER melchizedek_telemetry_cost BEFORE INSERT ON adk_telemetry
241
+ FOR EACH ROW EXECUTE FUNCTION melchizedek_telemetry_cost();
242
+
166
243
  -- ── adk_payloads: full prompt/response per model call, by policy, expiring ─
167
244
  CREATE TABLE IF NOT EXISTS adk_payloads (
168
245
  id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
@@ -360,7 +437,8 @@ CREATE OR REPLACE VIEW adk_kpi_daily AS
360
437
  sum(output_tokens) AS output_tokens,
361
438
  sum(thinking_tokens) AS thinking_tokens,
362
439
  avg(llm_calls) AS avg_llm_calls,
363
- avg(tool_calls) AS avg_tool_calls
440
+ avg(tool_calls) AS avg_tool_calls,
441
+ sum(cost_usd) AS cost_usd
364
442
  FROM adk_turns_production
365
443
  GROUP BY 1, 2;
366
444
 
@@ -68,6 +68,7 @@ import { setOAuthHosts } from '../tools/oauthHosts.js';
68
68
  import { setCredentialHosts } from '../tools/credentialHosts.js';
69
69
  import { syndicateCredentialHostProblems } from '../tools/credentialUses.js';
70
70
  import { syndicateOAuthHostProblems } from '../tools/oauthTools.js';
71
+ import { credentialValue } from '../models/credentialSource.js';
71
72
  const SURFACE_HEADERS = [
72
73
  ['x-surface', 'name'],
73
74
  ['x-surface-guild', 'guild'],
@@ -335,7 +336,7 @@ export async function createA2AApp(options) {
335
336
  // Embeddings and fact extraction use the SERVER's Gemini key: memory is
336
337
  // operator infrastructure, like tools.
337
338
  const services = await createSupabaseServices({
338
- apiKey: process.env.GOOGLE_GENAI_API_KEY || process.env.GEMINI_API_KEY || '',
339
+ apiKey: credentialValue('GOOGLE_GENAI_API_KEY') || credentialValue('GEMINI_API_KEY') || '',
339
340
  withMemory: true,
340
341
  extractor: options.memory?.extractor,
341
342
  embedder: options.memory?.embedder,
@@ -75,9 +75,10 @@ export interface SignInResult {
75
75
  planUsage: boolean;
76
76
  }
77
77
  /**
78
- * The browser sign-in. Stores the registration (host id, issued client id)
79
- * before the code exchange, and the tokens after the ID token verifies and
80
- * the plan scope is confirmed.
78
+ * The browser sign-in. Stores the host id before the browser opens when no
79
+ * file exists yet, the registration (host id, issued client id) before the
80
+ * code exchange, and the tokens after the ID token verifies and the plan
81
+ * scope is confirmed.
81
82
  */
82
83
  export declare function signIn(opts: SignInOptions): Promise<SignInResult>;
83
84
  export interface AccessTokenOptions extends OAuthOptions {
@@ -50,6 +50,14 @@ const UNUSABLE_REFRESH = new Set([
50
50
  'refresh_token_invalidated',
51
51
  'refresh_token_reused',
52
52
  ]);
53
+ /**
54
+ * Callback error codes that mean the consent page refused or the person
55
+ * stopped there: OpenAI's page for a plan or workspace without plan usage for
56
+ * apps offers no way on, so leaving it is the usual way these arrive.
57
+ */
58
+ const REFUSED_AT_CONSENT = new Set(['access_denied', 'unauthorized_client', 'consent_required', 'interaction_required']);
59
+ /** One line for a sign-in OpenAI refused at the consent page or the code exchange. */
60
+ const signInRefused = (code) => `ChatGPT refused the sign-in (${code}); the most common cause is a ChatGPT plan or workspace that does not include plan usage for apps. Retry with \`npm run setup -- --chatgpt-signin\` (\`npx melchizedek-setup --chatgpt-signin\` from an installed package).`;
53
61
  /** A sign-in failure: a code for the program, a message for the person, never a token. */
54
62
  export class SignInError extends Error {
55
63
  code;
@@ -236,7 +244,7 @@ async function listen(port, state, savedClientId, timeoutMs, signal) {
236
244
  res.writeHead(200, PAGE_HEADERS).end(page('Sign-in cancelled', 'You can close this tab and return to the terminal.'));
237
245
  const raw = url.searchParams.get('error') ?? '';
238
246
  const code = /^[a-z0-9_.-]{1,64}$/i.test(raw) ? raw : 'authorization_failed';
239
- settle(new SignInError(code, `ChatGPT did not authorize the sign-in (${code}).`));
247
+ settle(new SignInError(code, REFUSED_AT_CONSENT.has(code) ? signInRefused(code) : `ChatGPT did not authorize the sign-in (${code}).`));
240
248
  return;
241
249
  }
242
250
  const codes = url.searchParams.getAll('code');
@@ -284,9 +292,10 @@ export async function openInBrowser(url) {
284
292
  });
285
293
  }
286
294
  /**
287
- * The browser sign-in. Stores the registration (host id, issued client id)
288
- * before the code exchange, and the tokens after the ID token verifies and
289
- * the plan scope is confirmed.
295
+ * The browser sign-in. Stores the host id before the browser opens when no
296
+ * file exists yet, the registration (host id, issued client id) before the
297
+ * code exchange, and the tokens after the ID token verifies and the plan
298
+ * scope is confirmed.
290
299
  */
291
300
  export async function signIn(opts) {
292
301
  const issuer = issuerOf(opts);
@@ -295,6 +304,11 @@ export async function signIn(opts) {
295
304
  const sameIssuer = previous?.issuer === issuer;
296
305
  const hostId = previous?.hostId ?? `urn:uuid:${randomUUID()}`;
297
306
  const savedClientId = sameIssuer ? previous?.clientId : undefined;
307
+ // OpenAI asks for the host id to be chosen and persisted before the first
308
+ // sign-in, so a timed-out or cancelled attempt does not send a new one next
309
+ // time. Only written when nothing is stored: a stored registration is kept.
310
+ if (!previous)
311
+ writeStoredSignIn(opts.file, { version: 1, issuer, hostId });
298
312
  const state = randomValue();
299
313
  const nonce = randomValue();
300
314
  const verifier = randomValue();
@@ -326,14 +340,24 @@ export async function signIn(opts) {
326
340
  ? { ...previous, clientId: cb.clientId, hostId }
327
341
  : { version: 1, issuer, hostId, clientId: cb.clientId };
328
342
  writeStoredSignIn(opts.file, registration);
329
- const data = await tokenRequest(d.token_endpoint, {
330
- grant_type: 'authorization_code',
331
- client_id: cb.clientId,
332
- code: cb.code,
333
- code_verifier: verifier,
334
- redirect_uri: listener.redirectUri,
335
- resource: OPENAI_API_RESOURCE,
336
- });
343
+ let data;
344
+ try {
345
+ data = await tokenRequest(d.token_endpoint, {
346
+ grant_type: 'authorization_code',
347
+ client_id: cb.clientId,
348
+ code: cb.code,
349
+ code_verifier: verifier,
350
+ redirect_uri: listener.redirectUri,
351
+ resource: OPENAI_API_RESOURCE,
352
+ });
353
+ }
354
+ catch (err) {
355
+ // OpenAI's consent page refuses a plan or workspace without plan usage
356
+ // for apps, and the exchange then answers invalid_grant.
357
+ if (err instanceof SignInError && err.code === 'invalid_grant')
358
+ throw new SignInError('invalid_grant', signInRefused('invalid_grant'));
359
+ throw err;
360
+ }
337
361
  const subject = await verifyIdToken(data.id_token, d, cb.clientId, nonce);
338
362
  const fields = tokenFields(data);
339
363
  const planUsage = fields.scopes.includes(PLAN_SCOPE);
@@ -21,8 +21,9 @@
21
21
  * times and the file's path.
22
22
  */
23
23
  import { existsSync, readFileSync, statSync } from 'node:fs';
24
- import { homedir } from 'node:os';
25
24
  import { dirname, join, resolve } from 'node:path';
25
+ import { chatGptSignInFile } from '../paths.js';
26
+ import { credentialEnv } from '../models/credentialSource.js';
26
27
  /** Where the credential file lives, when not the default (a path; never a value). */
27
28
  export const CHATGPT_SIGNIN_FILE_ENV = 'MELCHIZEDEK_CHATGPT_SIGNIN_FILE';
28
29
  /** `off` ignores a stored sign-in (a served process on a machine that also signed in). */
@@ -31,8 +32,7 @@ export const CHATGPT_SIGNIN_ENV = 'MELCHIZEDEK_CHATGPT_SIGNIN';
31
32
  export const PLAN_SCOPE = 'chatgpt.tokens.use.direct';
32
33
  /** The credential file: MELCHIZEDEK_CHATGPT_SIGNIN_FILE, else ~/.melchizedek/chatgpt-signin.json. */
33
34
  export function signInFile(env = process.env) {
34
- const set = env[CHATGPT_SIGNIN_FILE_ENV]?.trim();
35
- return set ? resolve(set) : join(homedir(), '.melchizedek', 'chatgpt-signin.json');
35
+ return chatGptSignInFile(env);
36
36
  }
37
37
  /** The git work tree a path sits inside, if any (an ancestor holding `.git`). */
38
38
  export function insideGitWorkTree(file) {
@@ -100,7 +100,7 @@ export function chatGptSignInStatus(env = process.env) {
100
100
  * OPENAI_PLATFORM other than direct, no OPENAI_BASE_URL proxy, which must
101
101
  * never receive the token).
102
102
  */
103
- export function chatGptSignInRoutesOpenAi(env = process.env) {
103
+ export function chatGptSignInRoutesOpenAi(env = credentialEnv()) {
104
104
  if (env.OPENAI_API_KEY?.trim())
105
105
  return false;
106
106
  const platform = env.OPENAI_PLATFORM?.trim().toLowerCase();
@@ -42,7 +42,7 @@ import { REASONING_OLDER_SPELLING } from './syndicateSchema.js';
42
42
  import { reasoningConfig } from './models/reasoning.js';
43
43
  import { instructionFor, toEngineAgent } from './agentDialect.js';
44
44
  import { resolveTools as resolveNamedTools } from './toolRegistry.js';
45
- import { createMcpTools } from './tools/mcpToolFactory.js';
45
+ import { createMcpTools, loadStdioMcpTools } from './tools/mcpToolFactory.js';
46
46
  import { requireApproval, toolOf } from './tools/tool.js';
47
47
  import { examplesInstructionTool } from './tools/examples.js';
48
48
  import { capabilitySummary, describeCapabilities } from './models/capabilities.js';
@@ -145,6 +145,17 @@ export function examplesTool(examples) {
145
145
  const tool = examplesInstructionTool(examples);
146
146
  return tool ? [tool] : [];
147
147
  }
148
+ /** A stdio server's tools (ADR 0132), narrowed to the names the entry lists. */
149
+ async function stdioOfferedBy(server, opts) {
150
+ const label = `${server.name} (stdio: ${server.command})`;
151
+ opts.log?.(`Loading MCP tools: ${label}`);
152
+ const offered = await loadStdioMcpTools(server.name, { command: server.command, args: server.args, env: server.env, pass_env: server.pass_env, cwd: server.cwd });
153
+ const allowed = new Set(server.tools);
154
+ const missing = server.tools.filter((n) => !offered.some((t) => t.name === n));
155
+ if (missing.length)
156
+ opts.log?.(`MCP ${label} does not offer ${missing.map((n) => `'${n}'`).join(', ')}`);
157
+ return offered.filter((t) => allowed.has(t.name));
158
+ }
148
159
  async function resolveAgentTools(agent, opts) {
149
160
  const tools = [...resolveNamedTools(agent.tools, opts.onUnknownTool), ...examplesTool(agent.examples)];
150
161
  // OpenAPI operations become tools here, so require_approval can name them.
@@ -185,7 +196,9 @@ async function resolveAgentTools(agent, opts) {
185
196
  // mcp_servers (ADR 0124): the loader refused a declared name twice; a
186
197
  // server that answers with a name already taken is refused here too, so
187
198
  // no server's tool can stand in for another's or the agent's own.
188
- const servers = await Promise.all((agent.mcp_servers ?? []).map((server) => offeredBy(`${server.name} (${server.url})`, server.url, server.tools, server.auth?.oauth2, server.transport)));
199
+ const servers = await Promise.all((agent.mcp_servers ?? []).map((server) => server.command !== undefined
200
+ ? stdioOfferedBy(server, opts)
201
+ : offeredBy(`${server.name} (${server.url})`, server.url, server.tools, server.auth?.oauth2, server.transport)));
189
202
  for (const [i, offered] of servers.entries()) {
190
203
  const server = agent.mcp_servers[i];
191
204
  for (const mcpTool of offered) {
@@ -215,6 +215,7 @@ export function workflowSubagentOf(workflow) {
215
215
  ...(run.memory ? { memory: run.memory } : {}),
216
216
  ...(run.log ? { log: run.log } : {}),
217
217
  ...(run.credentials ? { credentials: run.credentials } : {}),
218
+ ...(run.consent ? { consent: run.consent } : {}),
218
219
  }),
219
220
  };
220
221
  }
@@ -24,7 +24,7 @@ export type { MemoryEntry, MemoryIngestOptions, MemorySearchRequest, MemorySearc
24
24
  export type { TurnEvent } from './runtime/events.ts';
25
25
  export { approvalResponsePart, pendingApproval, APPROVAL_REQUEST } from './runtime/approvals.ts';
26
26
  export type { PendingApproval } from './runtime/approvals.ts';
27
- export type { DrainedRun, MessagePart, TurnUsage, RouteDecision, SyndicateTurnOptions, SyndicateTurnResult, TraceOptions, TurnEvents, TurnStage, } from './runtime/syndicateTurn.ts';
27
+ export type { AgentBranch, DrainedRun, MessagePart, TurnUsage, RouteDecision, SyndicateTurnOptions, SyndicateTurnResult, TraceOptions, TurnEvents, TurnStage, } from './runtime/syndicateTurn.ts';
28
28
  export type { CompileOptions } from './compile.ts';
29
29
  export { isWorkflowSyndicate, describeInput } from './workflow.ts';
30
30
  export type { WorkflowConfig, WorkflowNodeYaml, EdgeElement, PendingInput } from './workflow.ts';
@@ -57,3 +57,5 @@ export type { MemoryStore } from './memory/store.ts';
57
57
  export { validateSyndicateConfig, syndicateJsonSchema, SyndicateValidationError } from './syndicateSchema.ts';
58
58
  export { PROVIDERS, providerForModel, providerKeyPresent, providerStatuses, logProviderStatuses, resolveModel, describeCapabilities, capabilitySummary, planTransport, gatewayConfig, gatewayProblem, gatewayUsable, GATEWAYS, } from './models/registry.ts';
59
59
  export { loadEnv } from './loadEnv.ts';
60
+ export { setCredentialSource, credentialValue } from './models/credentialSource.ts';
61
+ export type { CredentialSource } from './models/credentialSource.ts';
package/dist/lib/index.js CHANGED
@@ -48,3 +48,5 @@ export { PROVIDERS, providerForModel, providerKeyPresent, providerStatuses, logP
48
48
  // subpath until 0.16.0.
49
49
  describeCapabilities, capabilitySummary, planTransport, gatewayConfig, gatewayProblem, gatewayUsable, GATEWAYS, } from './models/registry.js';
50
50
  export { loadEnv } from './loadEnv.js';
51
+ // Where credentials are read, by environment variable name (ADR 0133).
52
+ export { setCredentialSource, credentialValue } from './models/credentialSource.js';
@@ -261,6 +261,10 @@ function anchorSpecPaths(config, dir) {
261
261
  for (const entry of agent?.openapi ?? [])
262
262
  if (!path.isAbsolute(entry.spec))
263
263
  entry.spec = path.resolve(dir, entry.spec);
264
+ // A stdio server's working directory, like a spec, is the YAML's (ADR 0132).
265
+ for (const entry of agent?.mcp_servers ?? [])
266
+ if (entry.cwd && !path.isAbsolute(entry.cwd))
267
+ entry.cwd = path.resolve(dir, entry.cwd);
264
268
  };
265
269
  anchor(config.orchestrator);
266
270
  for (const sub of config.subagents ?? [])
@@ -37,6 +37,7 @@ import { providerForModel } from '../models/providerMap.js';
37
37
  import { resolveModel } from '../models/registry.js';
38
38
  import { startEngineSpan, traceLlmGeneration } from '../observability/tracer.js';
39
39
  import { trimTrailingSlashes } from '../models/urls.js';
40
+ import { credentialEnv } from '../models/credentialSource.js';
40
41
  /**
41
42
  * Extraction on any model id. The key passed here reaches only a model of
42
43
  * the matching provider (resolveModel's BYOK rule); other providers read
@@ -194,7 +195,7 @@ export const DEFAULT_EMBEDDING_MODELS = {
194
195
  * already passes for memory; it is used only when the configured extraction
195
196
  * model or embedder is Gemini.
196
197
  */
197
- export function memoryProvidersFromEnv(env = process.env, geminiKey) {
198
+ export function memoryProvidersFromEnv(env = credentialEnv(), geminiKey) {
198
199
  const extractionModel = env.MEMORY_EXTRACTION_MODEL?.trim() || MEMORY_EXTRACTION_MODEL;
199
200
  const extractor = modelExtractor({
200
201
  model: extractionModel,
@@ -47,6 +47,7 @@ import { currentTurnStart, providerStateOf, withProviderState } from './provider
47
47
  import { errorDecision, errorText } from './errorResponse.js';
48
48
  import { adaptiveThinkingFor, claudeGeneration, claudeReasoningOf, claudeUrlImagesOn, THINKING_BINDING_BETA } from './claudeModels.js';
49
49
  import { isAboveHigh } from './reasoning.js';
50
+ import { credentialValue } from './credentialSource.js';
50
51
  /** The provider id this adapter reports and writes its state under (lib/models/providerMap.ts). */
51
52
  export const ANTHROPIC_PROVIDER = 'anthropic';
52
53
  /** The providerState kind this adapter writes: the signed blocks that preceded a part. */
@@ -217,7 +218,7 @@ export class ClaudeAdapter {
217
218
  yield setupFailure('ENDPOINT_MISCONFIGURED', messageOf(err));
218
219
  return;
219
220
  }
220
- const spec = claudeClientSpec(endpoint, this.#apiKey || endpoint.apiKey || process.env.ANTHROPIC_API_KEY);
221
+ const spec = claudeClientSpec(endpoint, this.#apiKey || endpoint.apiKey || credentialValue('ANTHROPIC_API_KEY'));
221
222
  if ('error' in spec) {
222
223
  yield setupFailure(endpoint.platform === 'direct' ? 'MISSING_API_KEY' : 'ENDPOINT_MISCONFIGURED', spec.error);
223
224
  return;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * lib/models/credentialSource.ts — where the engine reads a credential by
3
+ * its environment variable name (ADR 0133).
4
+ *
5
+ * By default every provider key, gateway key and embedding key is read from
6
+ * `process.env`. An embedding application that holds keys elsewhere (an OS
7
+ * keychain, a secret manager) sets a source once:
8
+ *
9
+ * setCredentialSource((name) => keychain.get(name));
10
+ *
11
+ * and every read below asks the source first, by the same names the
12
+ * environment uses (ANTHROPIC_API_KEY, MODEL_GATEWAY_API_KEY, …), at the
13
+ * moment of use: nothing is cached, so a key the user removes stops working
14
+ * on the next call. A source that returns undefined for a name falls back to
15
+ * the environment. A dependency-free leaf, so providerMap.ts stays one.
16
+ */
17
+ /** Returns the credential held under an environment variable name, or undefined. Must not throw. */
18
+ export type CredentialSource = (name: string) => string | undefined;
19
+ /** Sets (or, with undefined, clears) the process's credential source. */
20
+ export declare function setCredentialSource(next: CredentialSource | undefined): void;
21
+ /** The credential named `name`: the source's, else `env`'s. A source that throws counts as holding nothing. */
22
+ export declare function credentialValue(name: string, env?: NodeJS.ProcessEnv): string | undefined;
23
+ /**
24
+ * `env` as the engine's env-reading helpers see it: a read by name asks the
25
+ * source first. For functions that take an `env` parameter; enumeration and
26
+ * spreading see only `env`'s own variables.
27
+ */
28
+ export declare function credentialEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;