melchizedek-agents 0.9.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 (111) hide show
  1. package/AGENT_SETUP.md +182 -0
  2. package/DOCUMENTATION.md +614 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +164 -0
  5. package/README.md +151 -0
  6. package/config/agents/README.md +27 -0
  7. package/config/agents/examples/ares.yaml +74 -0
  8. package/config/agents/examples/cartographers.yaml +115 -0
  9. package/config/agents/examples/claude.yaml +17 -0
  10. package/config/agents/examples/council.yaml +65 -0
  11. package/config/agents/examples/critic.yaml +74 -0
  12. package/config/agents/examples/delegation.yaml +29 -0
  13. package/config/agents/examples/hierarchical.yaml +26 -0
  14. package/config/agents/examples/image_production.yaml +112 -0
  15. package/config/agents/examples/librarian.yaml +75 -0
  16. package/config/agents/examples/model_zoo.yaml +81 -0
  17. package/config/agents/examples/patient_advocate.yaml +107 -0
  18. package/config/agents/examples/scriptorium.yaml +96 -0
  19. package/config/agents/examples/style_council.yaml +83 -0
  20. package/config/agents/examples/syndicate.yaml +34 -0
  21. package/config/agents/examples/tutor.yaml +66 -0
  22. package/config/agents/syndicateSchema.yaml +291 -0
  23. package/dist/lib/config.d.ts +108 -0
  24. package/dist/lib/config.js +110 -0
  25. package/dist/lib/dispatch.d.ts +133 -0
  26. package/dist/lib/dispatch.js +182 -0
  27. package/dist/lib/grounding.d.ts +28 -0
  28. package/dist/lib/grounding.js +61 -0
  29. package/dist/lib/index.d.ts +14 -0
  30. package/dist/lib/index.js +13 -0
  31. package/dist/lib/loadEnv.d.ts +21 -0
  32. package/dist/lib/loadEnv.js +58 -0
  33. package/dist/lib/loadSyndicate.d.ts +177 -0
  34. package/dist/lib/loadSyndicate.js +264 -0
  35. package/dist/lib/memory/supabaseMemoryService.d.ts +118 -0
  36. package/dist/lib/memory/supabaseMemoryService.js +499 -0
  37. package/dist/lib/models/claudeLlm.d.ts +93 -0
  38. package/dist/lib/models/claudeLlm.js +332 -0
  39. package/dist/lib/models/gptLlm.d.ts +110 -0
  40. package/dist/lib/models/gptLlm.js +497 -0
  41. package/dist/lib/models/grokLlm.d.ts +64 -0
  42. package/dist/lib/models/grokLlm.js +86 -0
  43. package/dist/lib/models/ollamaLlm.d.ts +105 -0
  44. package/dist/lib/models/ollamaLlm.js +134 -0
  45. package/dist/lib/models/openAiCompatibleLlm.d.ts +125 -0
  46. package/dist/lib/models/openAiCompatibleLlm.js +331 -0
  47. package/dist/lib/models/providerMap.d.ts +28 -0
  48. package/dist/lib/models/providerMap.js +45 -0
  49. package/dist/lib/models/registry.d.ts +71 -0
  50. package/dist/lib/models/registry.js +159 -0
  51. package/dist/lib/models/schemaNormalize.d.ts +22 -0
  52. package/dist/lib/models/schemaNormalize.js +58 -0
  53. package/dist/lib/observability/supabaseSpanExporter.d.ts +35 -0
  54. package/dist/lib/observability/supabaseSpanExporter.js +115 -0
  55. package/dist/lib/observability/tracer.d.ts +44 -0
  56. package/dist/lib/observability/tracer.js +242 -0
  57. package/dist/lib/persistence/supabaseProvider.d.ts +44 -0
  58. package/dist/lib/persistence/supabaseProvider.js +88 -0
  59. package/dist/lib/session/supabaseSessionService.d.ts +26 -0
  60. package/dist/lib/session/supabaseSessionService.js +193 -0
  61. package/dist/lib/session/transcript.d.ts +193 -0
  62. package/dist/lib/session/transcript.js +309 -0
  63. package/dist/lib/toolRegistry.d.ts +15 -0
  64. package/dist/lib/toolRegistry.js +58 -0
  65. package/dist/lib/tools/collectionsSearchTool.d.ts +55 -0
  66. package/dist/lib/tools/collectionsSearchTool.js +86 -0
  67. package/dist/lib/tools/generateImageTool.d.ts +16 -0
  68. package/dist/lib/tools/generateImageTool.js +119 -0
  69. package/dist/lib/tools/inspectImageTool.d.ts +21 -0
  70. package/dist/lib/tools/inspectImageTool.js +97 -0
  71. package/dist/lib/tools/mcpToolFactory.d.ts +2 -0
  72. package/dist/lib/tools/mcpToolFactory.js +116 -0
  73. package/dist/lib/tools/toolContract.d.ts +78 -0
  74. package/dist/lib/tools/toolContract.js +129 -0
  75. package/dist/lib/tools/webExtractTool.d.ts +70 -0
  76. package/dist/lib/tools/webExtractTool.js +461 -0
  77. package/dist/lib/tools/webSearchTool.d.ts +53 -0
  78. package/dist/lib/tools/webSearchTool.js +126 -0
  79. package/dist/lib/tools/wikiTools.d.ts +112 -0
  80. package/dist/lib/tools/wikiTools.js +697 -0
  81. package/dist/lib/tools/xSearchTool.d.ts +61 -0
  82. package/dist/lib/tools/xSearchTool.js +125 -0
  83. package/dist/lib/wiki/agentRun.d.ts +39 -0
  84. package/dist/lib/wiki/agentRun.js +79 -0
  85. package/dist/lib/wiki/builder.d.ts +71 -0
  86. package/dist/lib/wiki/builder.js +165 -0
  87. package/dist/lib/wiki/dive.d.ts +56 -0
  88. package/dist/lib/wiki/dive.js +208 -0
  89. package/dist/lib/wiki/entities.d.ts +220 -0
  90. package/dist/lib/wiki/entities.js +661 -0
  91. package/dist/lib/wiki/extract.d.ts +97 -0
  92. package/dist/lib/wiki/extract.js +305 -0
  93. package/dist/lib/wiki/fill.d.ts +37 -0
  94. package/dist/lib/wiki/fill.js +175 -0
  95. package/dist/lib/wiki/format.d.ts +132 -0
  96. package/dist/lib/wiki/format.js +177 -0
  97. package/dist/lib/wiki/graph.d.ts +80 -0
  98. package/dist/lib/wiki/graph.js +147 -0
  99. package/dist/lib/wiki/lint.d.ts +58 -0
  100. package/dist/lib/wiki/lint.js +267 -0
  101. package/dist/lib/wiki/markdown.d.ts +127 -0
  102. package/dist/lib/wiki/markdown.js +336 -0
  103. package/dist/lib/wiki/pipeline.d.ts +69 -0
  104. package/dist/lib/wiki/pipeline.js +119 -0
  105. package/dist/lib/wiki/vault.d.ts +84 -0
  106. package/dist/lib/wiki/vault.js +194 -0
  107. package/dist/scripts/a2a_server.d.ts +2 -0
  108. package/dist/scripts/a2a_server.js +997 -0
  109. package/dist/scripts/syndicate_chat.d.ts +2 -0
  110. package/dist/scripts/syndicate_chat.js +485 -0
  111. package/package.json +96 -0
@@ -0,0 +1,614 @@
1
+ # melchizedek-agents — reference documentation
2
+
3
+ The framework in one sentence: **a syndicate is a YAML file describing an
4
+ agent graph; the runtime compiles it into live Google-ADK agents with
5
+ tools, sessions, and memory attached.** This document is the reference
6
+ for that file format and the machinery around it. For a guided first run,
7
+ see [`QUICKSTART.md`](./QUICKSTART.md).
8
+
9
+ ## Contents
10
+
11
+ 1. [Architecture](#1-architecture)
12
+ 2. [The syndicate YAML](#2-the-syndicate-yaml)
13
+ 3. [Tools](#3-tools)
14
+ 4. [Sessions & long-term memory](#4-sessions--long-term-memory)
15
+ 5. [Multi-model support](#5-multi-model-support)
16
+ 6. [A2A service mode](#6-a2a-service-mode)
17
+ 7. [Extending the framework](#7-extending-the-framework)
18
+ 8. [Security notes](#8-security-notes)
19
+ 9. [The knowledge bundle (wiki/)](#9-the-knowledge-bundle-wiki)
20
+
21
+ ---
22
+
23
+ ## 1. Architecture
24
+
25
+ ```
26
+ config/agents/ YOUR syndicate definitions (the engine's input)
27
+ config/agents/examples/ the starter pack — shipped example syndicates
28
+ lib/loadSyndicate.ts YAML → validated config (+ variable binding)
29
+ lib/dispatch.ts plan-dispatch route resolution (§6, A2A-only)
30
+ lib/toolRegistry.ts tool name → live ADK tool instance
31
+ lib/models/claudeLlm.ts Claude adapter registered into the ADK registry
32
+ lib/models/ollamaLlm.ts open-weight local adapter (Ollama, keyless)
33
+ lib/tools/mcpToolFactory.ts MCP client: remote tools → live ADK tools
34
+ scripts/demo_mcp_server.ts demo MCP server (library catalog, SSE)
35
+ lib/session/… Supabase-backed session service
36
+ lib/memory/… pgvector long-term memory service
37
+ lib/observability/… OpenTelemetry run tracing
38
+ scripts/syndicate_chat.ts CLI REPL / one-shot runner (compiles the graph)
39
+ scripts/a2a_server.ts HTTP JSON-RPC service mode (same compiler)
40
+ db/hardening.sql deny-by-default RLS for the Supabase tables
41
+ tests/agents.test.ts compiles every shipped syndicate; opt-in live check
42
+ ```
43
+
44
+ Execution flow: `loadSyndicate` reads and validates the YAML and binds
45
+ `{{variables}}` → the runner builds an `LlmAgent` per agent, wiring
46
+ subagents as `AgentTool`s and tool names through the registry → the ADK
47
+ `Runner` executes the turn, persisting events to the session service →
48
+ on session end, the memory service distills the transcript into tagged
49
+ facts, embeds them (768-d), and stores them for future recall.
50
+
51
+ ## 2. The syndicate YAML
52
+
53
+ Minimal complete example:
54
+
55
+ ```yaml
56
+ syndicate_name: "My Council"
57
+ memory_system: "session-only" # internal-only | session-only | long-term
58
+
59
+ variables: # bound into {{placeholders}} at load
60
+ headline_count: 5 # current_date is injected automatically
61
+
62
+ orchestrator:
63
+ name: "Conductor"
64
+ model: "gemini-3.7-flash"
65
+ instruction: |
66
+ You are the Conductor… (persona, objective, workflow contract)
67
+ tools:
68
+ - "google_search" # names resolved via lib/toolRegistry.ts
69
+ generateContentConfig:
70
+ temperature: 0.7
71
+ maxOutputTokens: 4096
72
+ thinkingConfig:
73
+ thinkingLevel: "MEDIUM" # or thinkingBudget on older models
74
+ includeThoughts: true
75
+
76
+ subagents:
77
+ - name: "Researcher"
78
+ description: "Use this subagent to… Pass it one focused query."
79
+ model: "gemini-3.7-flash"
80
+ instruction: |
81
+ You are the Researcher…
82
+ tools: ["google_search"]
83
+ ```
84
+
85
+ Field reference:
86
+
87
+ | Field | Where | Meaning |
88
+ |---|---|---|
89
+ | `syndicate_name` | root | Display name; also namespaces memory user keys. |
90
+ | `memory_system` | root | `internal-only` (nothing persists), `session-only` (transcript persists in Supabase), `long-term` (adds fact distillation + vector recall). |
91
+ | `variables` | root | Key/values bound into `{{placeholders}}` anywhere in instructions. `current_date` is always injected; CLI `--bind key=value` overrides. |
92
+ | `memory_extraction_rules` | root | Domain rules appended to the shared fact-extraction prompt for THIS syndicate only (requires `memory_system: "long-term"`). The extraction prompt is global — anything domain-specific belongs here, never edited into it. Unset, the prompt renders byte-identical to before the slot existed (§4). |
93
+ | `dispatch` | root | Switches the syndicate from DELEGATE to PLAN-DISPATCH routing (§6). `default_route` (required) names the fail-static subagent; `route_key` / `reason_key` name the router's JSON properties (defaults `route` / `reason`). A2A-only. |
94
+ | `name` / `model` / `instruction` | agent | The agent triple. Any Gemini id, `claude-*`, or `ollama/*` for open-weight local models (see §5). |
95
+ | `description` | subagent | **The delegation API.** The orchestrator reads this when deciding to hand off — write it like a function signature ("Use this subagent to…, pass it…"). |
96
+ | `tools` | agent | Names resolved by the tool registry (§3). Long-term memory agents add `preload_memory` / `load_memory`. |
97
+ | `generateContentConfig` | agent | Temperature, output caps, thinking budget/level. |
98
+ | `outputSchema` | agent | Structured-JSON contract. **Constraint:** an agent holding `outputSchema` cannot also hold transfer powers — the ADK deadlocks it. Keep schema-holders as leaf agents (see `critic.yaml`'s header comment for the war story). |
99
+ | `yaml_reference` | subagent | Mount another syndicate file as a nested subagent. |
100
+ | `mcp_server_url` | subagent | Discover this subagent's tools from a remote MCP server at load time (§3). SSRF-guarded; `ALLOW_PRIVATE_MCP=true` permits localhost for development. |
101
+
102
+ Validation happens at load: missing names, legacy option blocks, and
103
+ malformed agents fail with pointed errors before any model is called.
104
+ Tool names are deliberately *not* strictly validated — unknown names are
105
+ skipped with a warning at compile time.
106
+
107
+ ## 3. Tools
108
+
109
+ Registered in `lib/toolRegistry.ts` — one map from YAML name to ADK tool
110
+ instance:
111
+
112
+ | Name | Kind | Does |
113
+ |---|---|---|
114
+ | `web_search` | Provider-agnostic | Live web search via the agent model's NATIVE search: Gemini grounding, Anthropic `web_search` server tool, OpenAI Responses `web_search`, xAI Agent Tools `web_search`. On local `ollama/*` models the tool is omitted with a one-time warning (keyless stays keyless). On grok-* agents, optional server-side domain filters via `XAI_WEB_SEARCH_ALLOWED_DOMAINS` / `_EXCLUDED_DOMAINS` in `.env` (max 5, mutually exclusive; xAI accepts no date bounds — those are `x_search`-only). Prefer this in new YAMLs. |
115
+ | `web_extract` | FunctionTool | Deterministic page reading: fetches 1–5 agent-chosen URLs and returns clean page text (no LLM summarization). Keyless — works on every provider including local `ollama/*`. Per-page char budget (default 15k, `WEB_EXTRACT_CHAR_LIMIT`); long pages return a head+tail window with an `offset` continuation call served from a 15-minute cache. SSRF-guarded (http(s) only, private/link-local hosts refused, redirects re-checked). Block pages (bot checks, paywall stubs, JS shells) are detected code-side and returned as labeled `Error:` blocks, never as content. Pair with `web_search`: search to find, extract to read past the headline. |
116
+ | `x_search` | xAI-only | Live search over X (Twitter) posts via xAI Agent Tools. Self-gates to `grok-*` agents; a silent no-op on every other provider, so mixed-provider YAMLs stay safe. Optional server-side constraints in `.env`: `XAI_X_SEARCH_FROM_DATE`/`_TO_DATE` (inclusive `YYYY-MM-DD`) and `_ALLOWED_HANDLES`/`_EXCLUDED_HANDLES` (max 20, mutually exclusive — allowlist wins). |
117
+ | `collections_search` | xAI-only | Semantic search over xAI **Collections** — hosted document stores (PDFs/text/CSVs) uploaded at console.x.ai — server-side RAG with `collections://…` citations. Which collections: `XAI_COLLECTION_IDS` in `.env` (optional `XAI_COLLECTIONS_MAX_RESULTS`). Declared with no ids → omitted with a warning; non-xAI providers → silent no-op. |
118
+ | `google_search` | ADK built-in | Live web search — Gemini agents only (legacy alias; use `web_search`). |
119
+ | `preload_memory` | ADK built-in | Silently injects similarity-matched facts into every request (ambient recall). |
120
+ | `load_memory` | ADK built-in | Explicit tool call to search the fact store (deliberate recall). |
121
+ | `generate_image` | FunctionTool | Calls the Gemini image model directly, saves the result under `outputs/`, returns the path. A FunctionTool because binary `inlineData` cannot survive the AgentTool text boundary. |
122
+ | `inspect_image` | FunctionTool | **Blind visual inventory** of a file under `outputs/`: subjects with exact counts, composition, light, palette, medium cues, artifacts — zero quality judgments. Its signature accepts *only* a file path, so an orchestrator cannot leak expectations into the observation (see `image_production.yaml`). |
123
+
124
+ **MCP tools** are the exception to the registry: a subagent with
125
+ `mcp_server_url:` in its YAML gets its tools from a remote MCP server at
126
+ load time. `lib/tools/mcpToolFactory.ts` dials the server over SSE,
127
+ lists its tools, and wraps each one as a live `FunctionTool` — the
128
+ agent's reach is decided by the server, not compiled in.
129
+ `config/agents/examples/librarian.yaml` plus the demo catalog server
130
+ (`npm run mcp:demo`, `scripts/demo_mcp_server.ts`) are the worked
131
+ example: read tools *and* write tools, so the agent demonstrably
132
+ modifies data on the far side of the protocol. The factory refuses
133
+ loopback/private hosts unless `ALLOW_PRIVATE_MCP=true` (SSRF guard);
134
+ treat any remote MCP server as an untrusted tool vendor whose results
135
+ are data, never instructions.
136
+
137
+ > **Schema dialects, handled for you.** The factory emits Gemini-style
138
+ > UPPERCASE schema types (`'OBJECT'`, `'STRING'`, …) because the ADK is
139
+ > Gemini-native; every non-Gemini adapter normalizes them back to
140
+ > standard lowercase JSON-Schema at request-build time
141
+ > (`lib/models/schemaNormalize.ts`). MCP tools therefore work on any
142
+ > provider's agents — Gemini, Claude, GPT, or Grok.
143
+
144
+ ## 4. Sessions & long-term memory
145
+
146
+ Two Supabase tables carry the two kinds of remembering:
147
+
148
+ - **`adk_sessions`** — the running transcript (events + state), so a
149
+ conversation survives process restarts.
150
+ - **`adk_memory_facts`** — distilled structured records: each carries
151
+ its 768-d embedding plus the date it is about, the source who asserted
152
+ it, active/superseded status, and entity index keys. Written at
153
+ session end (`exit`, SIGINT, or one-shot completion), keyed per
154
+ syndicate + user. Corrections supersede old rows (kept as linked
155
+ history); recall is cosine similarity re-ranked by keys and dates.
156
+ Full pipeline: [`lib/memory/README.md`](./lib/memory/README.md).
157
+
158
+ Provision both in the Supabase SQL Editor:
159
+
160
+ ```sql
161
+ -- 1. Enable pgvector
162
+ CREATE EXTENSION IF NOT EXISTS vector;
163
+
164
+ -- 2. Sessions
165
+ CREATE TABLE adk_sessions (
166
+ id TEXT PRIMARY KEY,
167
+ app_name TEXT NOT NULL,
168
+ user_id TEXT NOT NULL,
169
+ state JSONB DEFAULT '{}'::jsonb,
170
+ events JSONB DEFAULT '[]'::jsonb,
171
+ last_update_time BIGINT,
172
+ expire_at TIMESTAMPTZ,
173
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
174
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
175
+ );
176
+
177
+ -- 3. Memory facts (structured records: date, source, status, index keys
178
+ -- live beside the embedding — see lib/memory/README.md)
179
+ CREATE TABLE adk_memory_facts (
180
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
181
+ user_key TEXT NOT NULL,
182
+ fact TEXT NOT NULL,
183
+ embedding vector(768),
184
+ tag TEXT,
185
+ fact_date DATE,
186
+ source TEXT,
187
+ status TEXT NOT NULL DEFAULT 'active',
188
+ keys TEXT[] NOT NULL DEFAULT '{}',
189
+ superseded_by UUID,
190
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
191
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
192
+ );
193
+
194
+ -- 4. Indexes: similarity, entity keys, dates
195
+ CREATE INDEX ON adk_memory_facts USING ivfflat (embedding vector_cosine_ops)
196
+ WITH (lists = 100);
197
+ CREATE INDEX adk_memory_facts_keys_idx ON adk_memory_facts USING gin (keys);
198
+ CREATE INDEX adk_memory_facts_date_idx ON adk_memory_facts (user_key, fact_date);
199
+
200
+ -- 5. Cosine-similarity RPC (returns the structured columns so the
201
+ -- service can re-rank by keys/dates and relabel superseded records).
202
+ -- filter_user_key is REQUIRED: a NULL key would return every user's facts.
203
+ CREATE OR REPLACE FUNCTION match_memory_facts (
204
+ query_embedding vector(768),
205
+ filter_user_key text,
206
+ match_count int DEFAULT 10
207
+ ) RETURNS TABLE (
208
+ id UUID,
209
+ user_key TEXT,
210
+ fact TEXT,
211
+ tag TEXT,
212
+ fact_date DATE,
213
+ source TEXT,
214
+ status TEXT,
215
+ keys TEXT[],
216
+ created_at TIMESTAMPTZ,
217
+ similarity float
218
+ )
219
+ LANGUAGE plpgsql
220
+ AS $$
221
+ BEGIN
222
+ IF filter_user_key IS NULL THEN
223
+ RAISE EXCEPTION 'filter_user_key is required';
224
+ END IF;
225
+ RETURN QUERY
226
+ SELECT
227
+ adk_memory_facts.id,
228
+ adk_memory_facts.user_key,
229
+ adk_memory_facts.fact,
230
+ adk_memory_facts.tag,
231
+ adk_memory_facts.fact_date,
232
+ adk_memory_facts.source,
233
+ adk_memory_facts.status,
234
+ adk_memory_facts.keys,
235
+ adk_memory_facts.created_at,
236
+ 1 - (adk_memory_facts.embedding <=> query_embedding) AS similarity
237
+ FROM adk_memory_facts
238
+ WHERE adk_memory_facts.user_key = filter_user_key
239
+ ORDER BY adk_memory_facts.embedding <=> query_embedding
240
+ LIMIT match_count;
241
+ END;
242
+ $$;
243
+ ```
244
+
245
+ Then run [`db/hardening.sql`](./db/hardening.sql) (RLS deny-by-default;
246
+ see §8). Upgrading an existing project to the structured columns:
247
+ [`db/memory_v2.sql`](./db/memory_v2.sql). `npm run db:purge` clears both
248
+ tables; for per-record inspection and hand-clearing, `npm run memory`
249
+ (`scripts/memory_admin.ts`) lists silos, groups likely restatements
250
+ (`--dupes`), and deletes chosen records — a dry run unless `--yes`, with
251
+ every delete scoped to its `--silo`. Cleanup means DELETE, not
252
+ `status='superseded'`: `match_memory_facts` filters on `user_key` alone
253
+ and applies status afterwards in the re-rank, so a retired row still
254
+ consumes a candidate slot (see `lib/memory/README.md`).
255
+
256
+ **Ingestion is incremental, and dedup is semantic.** A stateless service
257
+ (the A2A server, §6) ingests after *every* completed task with the whole
258
+ session — so an N-turn conversation would be distilled N times over a
259
+ growing transcript, and byte-equality dedup never catches it because the
260
+ extraction model rephrases on each pass. Two mechanisms in
261
+ `lib/memory/supabaseMemoryService.ts` prevent the store from filling
262
+ with restatements:
263
+
264
+ - A per-session **high-water mark** (`eventsToIngest`) distils only the
265
+ turns added since the last ingestion. It is in-process on purpose — a
266
+ restart re-reads one session once, absorbed by the check below, which
267
+ is why it needs no table of its own.
268
+ - A **similarity probe** before each insert (`isSemanticDuplicate`,
269
+ `MEMORY_DEDUP_SIMILARITY = 0.93`) drops restatements, reusing the same
270
+ `match_memory_facts` RPC the supersession path calls. It requires the
271
+ same tag — an `[EPISODE]` about a topic must never suppress the
272
+ `[FACT]` it mentions — and retired rows never suppress a new one. A
273
+ probe that errors **fails open and stores**: a duplicate costs a
274
+ shortlist slot, a lost fact costs the user something they said.
275
+
276
+ **Per-consumer extraction rules.** The fact-extraction prompt is shared
277
+ by every long-term consumer, so domain rules arrive through the
278
+ `memory_extraction_rules` field on the syndicate (§2) rather than being
279
+ edited into the prompt. It fills a `{domain_rules}` slot that renders
280
+ empty when unset — a syndicate declaring none gets byte-identical
281
+ behaviour to before the slot existed. Use it to say what is worth
282
+ remembering in your domain: what the user asserted, decided, or
283
+ committed to is usually worth storing; a value that goes stale on its
284
+ own usually is not, because a stored copy can never be used, only crowd
285
+ out records that can. `config/agents/syndicateSchema.yaml` shows the
286
+ shape.
287
+
288
+ A memory store is a PII store: key facts to users, honor deletion, set
289
+ retention deliberately. Vectorization is not anonymization — the
290
+ plain-text fact sits beside its embedding.
291
+
292
+ ## 5. Multi-model support
293
+
294
+ The agent's `model:` id names its provider, and the framework routes
295
+ accordingly — model optionality is a single YAML line per agent:
296
+
297
+ | Model id | Provider | Adapter | Key | Native `web_search` |
298
+ |---|---|---|---|---|
299
+ | `gemini-*` | Google Gemini | ADK-native (`TracedGemini`) | `GOOGLE_GENAI_API_KEY` | ✅ grounding |
300
+ | `claude-*` | Anthropic | `lib/models/claudeLlm.ts` | `ANTHROPIC_API_KEY` | ✅ server tool |
301
+ | `gpt-*`, o-series | OpenAI | `lib/models/gptLlm.ts` (Responses API) | `OPENAI_API_KEY` | ✅ web_search tool |
302
+ | `grok-*` | xAI | `lib/models/grokLlm.ts` (Responses API) | `XAI_API_KEY` | ✅ Agent Tools search |
303
+
304
+ The xAI adapter carries the deepest capability surface: `grok-4.5`
305
+ requests pin `reasoning.effort: "medium"` (`lib/config.ts`), SSE
306
+ streaming works end-to-end (`runConfig: { streamingMode: 'sse' }` —
307
+ partial delta events stream, one aggregated event persists with usage),
308
+ structured outputs ride `outputSchema` → `text.format`, and two
309
+ xAI-only tools — `x_search` and `collections_search` (§3) — turn on
310
+ live X search and hosted-document RAG. All verified live on grok-4.5.
311
+ | `ollama/*` | Local Ollama | `lib/models/ollamaLlm.ts` | none | ⚠ omitted + warning |
312
+
313
+ `lib/models/registry.ts` is the single routing seam:
314
+ `registerAvailableProviders()` registers every adapter whose key is
315
+ present (Ollama needs none) into the ADK's LLM registry, so the YAML
316
+ string finds its provider; missing keys produce clear skip messages,
317
+ and only the providers a syndicate actually declares are required.
318
+ Mixed graphs are supported — each agent picks its own provider, one
319
+ line each. `config/agents/examples/claude.yaml` is the minimal Claude example;
320
+ `config/agents/examples/model_zoo.yaml` declares one lightweight agent per
321
+ provider, and `npm run demo:models` proves the whole surface: one
322
+ prompt to every available provider, printing input, thinking (qwen3
323
+ `<think>` blocks, Claude extended thinking, GPT reasoning summaries,
324
+ Grok reasoning), output, and a per-request token/latency trace; add
325
+ `-- --search` to watch four native web searches plus the local
326
+ omission. Providers without keys are skipped, never fatal.
327
+
328
+ Reasoning/thinking: scratchpads from every provider are surfaced as
329
+ dimmed THINKING output and kept out of session history. On Claude,
330
+ `generateContentConfig.thinkingConfig.thinkingBudget` enables
331
+ Anthropic extended thinking (thinking + tool use on the same Claude
332
+ agent is not supported yet).
333
+
334
+ Every model request also emits an `llm.request` OpenTelemetry span
335
+ (provider, model, input/output/thinking tokens, latency) printed as an
336
+ `[OTEL_SPAN_JSON]` line; set `TELEMETRY_SUPABASE=true` to persist
337
+ spans to the `adk_telemetry` table (run `db/telemetry.sql`, then
338
+ re-run `db/hardening.sql` — schema below):
339
+
340
+ ```sql
341
+ CREATE TABLE adk_telemetry (
342
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
343
+ ts TIMESTAMPTZ NOT NULL DEFAULT now(),
344
+ trace_id TEXT NOT NULL,
345
+ span_id TEXT NOT NULL,
346
+ span_name TEXT NOT NULL, -- 'llm.request' | 'Syndicate Execution: <name>'
347
+ syndicate TEXT,
348
+ agent TEXT,
349
+ provider TEXT, -- 'gemini' | 'anthropic' | 'openai' | 'xai' | 'ollama'
350
+ model TEXT,
351
+ input_tokens INTEGER,
352
+ output_tokens INTEGER,
353
+ thinking_tokens INTEGER,
354
+ latency_ms DOUBLE PRECISION,
355
+ span JSONB NOT NULL
356
+ );
357
+
358
+ CREATE INDEX idx_adk_telemetry_ts ON adk_telemetry (ts DESC);
359
+ CREATE INDEX idx_adk_telemetry_trace ON adk_telemetry (trace_id);
360
+ ```
361
+
362
+ **Open-weight local models**: `ollama/*` ids (e.g. `ollama/qwen3:8b`)
363
+ route through `lib/models/ollamaLlm.ts` to a local Ollama daemon over
364
+ its OpenAI-compatible API (`OLLAMA_BASE_URL`, default
365
+ `http://localhost:11434/v1`). No key is required, and a syndicate whose
366
+ *every* agent is `ollama/*` runs with no `.env` at all —
367
+ `config/agents/examples/tutor.yaml` (single agent) and `council.yaml` (council)
368
+ are the worked examples. The adapter translates ADK content to
369
+ OpenAI-style messages, including tool calls (so delegation works),
370
+ image parts as data URIs (so `ollama/qwen3-vl:8b` can see), and JSON
371
+ response mode; reasoning models' `<think>…</think>` scratchpads are
372
+ stripped from replies. Choose models by capability: qwen3:8b is the
373
+ smallest pulled model with reliable tool calling; qwen3-vl:8b adds
374
+ vision.
375
+
376
+ Model floor: agent transfer (subagent delegation) requires
377
+ `gemini-3.7-flash` or newer — older flash models reject it with
378
+ `[400] Tool call context circulation is not enabled`. For `ollama/*`
379
+ agents the equivalent floor is tool-calling support in the model
380
+ itself; delegation is exercised through AgentTool function calls.
381
+
382
+ ## 6. A2A service mode
383
+
384
+ `npm run start:a2a` serves a syndicate as a stateless JSON-RPC
385
+ agent-to-agent endpoint (Express): an agent card describing the service,
386
+ `message/send` for turns, bearer-token auth via `A2A_SERVER_SECRET`, and
387
+ rate limiting. With `PUBLIC_URL` set (a real deployment), the server
388
+ refuses to start without the secret and refuses to run against an
389
+ unhardened database unless explicitly overridden. `demo/a2a_demo.mjs` is
390
+ a complete client.
391
+
392
+ ### Plan-dispatch routing (`dispatch:`) — the second orchestration method
393
+
394
+ A syndicate that declares a `dispatch:` block stops delegating and
395
+ starts **dispatching**. The two methods differ in who speaks to the
396
+ user:
397
+
398
+ | | DELEGATE (default) | PLAN-DISPATCH (`dispatch:` present) |
399
+ |---|---|---|
400
+ | Subagents are | `AgentTool`s on the orchestrator | plain configs the server selects from |
401
+ | Orchestrator holds | subagent tools, no `outputSchema` | an `outputSchema`, no subagent tools |
402
+ | Routing decision is | implicit in which tool it calls | an explicit value in code |
403
+ | The final answer comes from | the orchestrator re-emitting the answer | **the specialist itself** |
404
+ | LLM calls per request | classify + specialist + relay | classify + specialist |
405
+
406
+ ```yaml
407
+ dispatch:
408
+ default_route: "Generalist" # fail-static target; must be a declared subagent
409
+
410
+ orchestrator:
411
+ name: "Triage"
412
+ model: "gemini-3.5-flash-lite"
413
+ instruction: |
414
+ Name exactly ONE specialist for this message. ...
415
+ outputSchema: # a leaf holding a schema — no subagent tools
416
+ type: "OBJECT"
417
+ properties:
418
+ route: { type: "STRING", description: "Exact specialist name" }
419
+ reason: { type: "STRING", description: "≤8 plain words for the waiting user" }
420
+ required: ["route"]
421
+ generateContentConfig:
422
+ responseMimeType: "application/json"
423
+ ```
424
+
425
+ **Why it exists.** In DELEGATE mode the orchestrator receives the
426
+ specialist's answer as a tool response and must re-emit it to close the
427
+ turn. That relay is a full LLM call whose only job is copying text it is
428
+ forbidden to edit, and it is the least reliable step in the chain — in
429
+ production it has finished with zero output tokens (a blank reply) and
430
+ emitted the bare tool name in place of a 2,599-character answer.
431
+ Plan-dispatch has no relay turn to fail, and the classifier's output
432
+ shrinks from a whole relayed answer to ~15 tokens of JSON.
433
+
434
+ **Why the classifier is tool-less.** ADK refuses to combine
435
+ `outputSchema` with AgentTool delegation on one agent (see
436
+ `config/agents/examples/critic.yaml` — an orchestrator holding both deadlocks).
437
+ That constraint shapes the method: the classifier is a leaf, and the
438
+ hand-off happens in code, where it can be logged, traced, and streamed
439
+ to the user as progress.
440
+
441
+ **Sessions.** Every *route* runs in the shared `<contextId>` session, so
442
+ one transcript accumulates across routes and long-term memory ingests
443
+ real answers instead of a relay copy of them. Sharing the session is
444
+ necessary but not sufficient: ADK renders an event by comparing
445
+ `event.author` against the agent now running, and under plan-dispatch
446
+ every route is its own root agent, so the whole history fails that
447
+ comparison and `convertForeignEvent` rewrites it to `role: "user"`
448
+ prefixed "For context:". A route reading the raw shared session
449
+ therefore receives the thread as one undifferentiated user monologue —
450
+ with the previous route's private `thought:` reasoning cloned in as user
451
+ speech and raw tool payloads inlined beside it. Routes read the session
452
+ through `ProjectedSessionService` (`lib/session/transcript.ts`) instead:
453
+ past agent turns are re-authored to the running agent so they survive as
454
+ real `role: "model"` turns, labelled `[XScout]` when another desk spoke,
455
+ with thoughts and tool traffic dropped. Writes still land on the real
456
+ session, which keeps everything.
457
+
458
+ Both ceilings on that projection are bounds the prompt cannot escape:
459
+ 40,000 characters of history AND 16 turns, whichever binds first. The turn
460
+ cap is not redundant — a thread of short exchanges fits 43 turns inside the
461
+ character budget, and 43 turns of history to answer one question is
462
+ attention cost no byte budget describes.
463
+
464
+ The stored row is trimmed on the way out, by `trimEventForStorage`. It has
465
+ exactly two readers and neither touches Gemini's opaque `thoughtSignature`
466
+ blobs or tool-result payloads: the projection drops thought parts and tool
467
+ traffic before any prompt, and the memory service's `serializeEvents` walks
468
+ `part.text` alone. Measured across 128 live sessions those two fields were
469
+ ~90% of every byte stored (`thoughtSignature` alone 73.3%), so they are
470
+ stripped from the SERIALIZED COPY — the live in-memory session keeps them,
471
+ or the agent's own tool loop breaks mid-turn. An elided tool result keeps
472
+ its `id` and `name` and gains a size marker, because ADK pairs calls to
473
+ responses by id and throws on a widowed half. 21.72 MB → 4.88 MB; existing
474
+ rows shrink retroactively on their next write. Note the constraint this
475
+ rests on: dropping the signature is safe only because stored events are
476
+ never replayed to a model. The quadratic upload is untouched — `appendEvent`
477
+ still rewrites the whole array per event — and is the next lever if row size
478
+ returns.
479
+
480
+ The classifier does not write to that session — its JSON verdicts would
481
+ be read as conversation by the next specialist — but it must still SEE
482
+ it, or it cannot tell a follow-up from a standalone remark. It runs in a
483
+ per-request in-memory session and receives `renderTranscriptDigest`, a
484
+ compact both-sides summary of the exchange, above a
485
+ `--- MESSAGE TO CLASSIFY ---` marker. It used to get a durable
486
+ `<contextId>::route` lane of its own instead, holding only the user's
487
+ messages and its own verdicts; that made every rule about follow-ups and
488
+ redos unusable, and left holes wherever a `route_overrides` hit skipped
489
+ the classifier entirely.
490
+
491
+ **Fail-static.** Malformed JSON, an unknown route name, an empty
492
+ payload, or a classifier that errors outright all resolve to
493
+ `default_route` and still answer the user — routing is an optimisation,
494
+ answering is the contract. `default_route` must therefore name a
495
+ specialist that can handle anything. The whole resolver is pure; the
496
+ contract lives in `lib/dispatch.ts`.
497
+
498
+ **Telemetry.** The chosen route is published as a
499
+ `[STATUS] Routed to <Name> — <reason>` progress event before the
500
+ specialist runs, so an A2A client can show a waiting user what is
501
+ happening. The `reason` field is written for that reader, not for logs.
502
+
503
+ Implemented in `scripts/a2a_server.ts`; like `yaml_reference`, it is
504
+ A2A-only — the CLI runner (`scripts/syndicate_chat.ts`) still compiles
505
+ every syndicate in DELEGATE mode.
506
+
507
+ ## 7. Extending the framework
508
+
509
+ **Call ADK directly (no syndicate)**: the YAML layer is a convenience,
510
+ never a requirement. `scripts/direct_call.ts` (`npm run demo:direct`) is
511
+ the canonical minimal block — `LlmAgent` + `Runner` +
512
+ `InMemorySessionService` straight from `@google/adk`, ~30 lines you can
513
+ copy into any repo that has `@google/adk` installed. Add
514
+ `registerAvailableProviders()` from `lib/models/registry.ts` and the
515
+ same block runs `claude-*` / `gpt-*` / `grok-*` / `ollama/*` ids too.
516
+
517
+ **Add a syndicate**: create `config/agents/<name>.yaml` — copy the
518
+ closest starter-pack file from `config/agents/examples/` or start from
519
+ `syndicateSchema.yaml` — then `npm run chat:syndicate -- --syndicate
520
+ <name>`. No code changes. The loader checks the root first, then
521
+ `examples/`, so your syndicate and the starter pack never collide.
522
+
523
+ **Add a tool**: implement a `FunctionTool` in `lib/tools/`, register the
524
+ name in `lib/toolRegistry.ts`, reference it from YAML. The two image
525
+ tools are the worked examples — including why binary data forces
526
+ FunctionTools over subagents, and how a tool signature can enforce an
527
+ epistemic rule (the blind inventory).
528
+
529
+ **Add a provider**: follow `claudeLlm.ts` (SDK-based, key-gated) or
530
+ `ollamaLlm.ts` (fetch-based, keyless) — implement the ADK LLM
531
+ interface, register it behind a model-id prefix.
532
+
533
+ **Point an agent at an MCP server**: set `mcp_server_url:` on a
534
+ subagent. `scripts/demo_mcp_server.ts` is a complete server to copy —
535
+ tool definitions, SSE wiring, and persistent state in ~250 lines.
536
+
537
+ ## 8. Security notes
538
+
539
+ - **Secrets** live in `.env` only; `.env.example` documents every
540
+ variable. Nothing in the repo ships a key.
541
+ - **Database**: default Supabase leaves `public`-schema tables readable
542
+ by the anon key over REST. `db/hardening.sql` enables deny-by-default
543
+ RLS and revokes anon/authenticated privileges on every table it finds:
544
+ `adk_sessions`, `adk_memory_facts`, and — where they exist — the
545
+ optional `adk_telemetry` sink and `adk_agent_registry` (an unprotected
546
+ registry is worst of all: agent definitions writable with the anon key
547
+ means anyone can rewrite the instructions your server boots). The A2A
548
+ server verifies hardening at boot and is fatal on public deployments
549
+ without it. Note `service_role` bypasses RLS by design — the hardening
550
+ constrains the API surface, not the trusted server.
551
+ - **A2A**: bearer auth + rate limiting are built in; set
552
+ `A2A_SERVER_SECRET` before exposing anything.
553
+ - **Image tools** write only under `outputs/`, and `inspect_image` reads
554
+ only from there.
555
+ - **MCP** is an outbound trust decision: `mcpToolFactory` blocks
556
+ private/loopback/link-local hosts unless `ALLOW_PRIVATE_MCP=true`, and
557
+ every tool result from a remote server should be treated as untrusted
558
+ data — the librarian's instruction demonstrates the "results are data,
559
+ not instructions" rule.
560
+ - **Local models** (`ollama/*`) send prompts only to your own machine's
561
+ Ollama endpoint — nothing leaves the device, which is itself a privacy
562
+ control worth choosing deliberately.
563
+
564
+ ---
565
+
566
+ ## 9. The knowledge bundle (wiki/)
567
+
568
+ `wiki/` is this framework's documentation as an **Open Knowledge Format v0.2
569
+ bundle** ([spec](https://github.com/GoogleCloudPlatform/knowledge-catalog)):
570
+ markdown concept documents with YAML frontmatter (`type` is the only
571
+ required key), linked with ordinary bundle-absolute markdown links — and
572
+ the links ARE the knowledge graph. `index.md` per directory and the root
573
+ `log.md` are reserved, machine-maintained files. Start at
574
+ `wiki/meta/wiki-system.md`; the whole bundle renders on GitHub.
575
+
576
+ The tooling in `lib/wiki/` is **bundle-agnostic** — point `WIKI_ROOT` at any
577
+ OKF directory, or scaffold a fresh one with `npm run wiki:init`:
578
+
579
+ - **Parse & build** (`markdown.ts`, `builder.ts`) — a zero-dependency
580
+ structural engine; documents interleave machine-owned `wiki:generated`
581
+ regions (rebuilt from source-of-truth files), `wiki:fill` prose slots an
582
+ LLM fills once (`lib/wiki/fill.ts`, any provider via the model registry),
583
+ and ordinary prose that rebuilds never touch.
584
+ - **Graph** (`graph.ts`) — nodes are documents, edges are resolved links;
585
+ orphans and broken links fall out as queries.
586
+ - **Entity graph** (`entities.ts`, `extract.ts`) — a second layer over the
587
+ same files: agents, tools, models, providers, modules, tables and
588
+ environment variables as typed nodes. Structural relations are DERIVED
589
+ from repo truth on every build (zero-dependency scanners for imports,
590
+ `process.env` reads, DDL and npm scripts) into `.graph/graph.json`;
591
+ judgments that only prose carries are ASSERTED with evidence and an actor
592
+ into `.graph/relations.json`, which the build never rewrites.
593
+ - **Lint** (`lint.ts`, `npm run wiki:check`) — OKF conformance, link
594
+ integrity, index coverage, staleness, and the private-subtree closure
595
+ rule; errors gate every write.
596
+ - **Navigate & garden** (`lib/tools/wikiTools.ts`) — tool contracts (§3
597
+ pattern) in three tiers: navigation (`wiki_map`, `wiki_search`,
598
+ `wiki_read`, `wiki_links`, `wiki_dive` — a "repo dive" returns an ordered
599
+ reading plan for a task — and `wiki_graph`, which answers the relational
600
+ questions documents cannot: who calls this tool, what needs this key, how
601
+ do these two connect), the gated writes (`wiki_save` — lint-validated,
602
+ path-jailed, auto-updates the directory index and `log.md`; `wiki_relate`
603
+ — one evidenced relation, refusing anything the build derives), and
604
+ agentic composites (`wiki_query`, `wiki_garden` — one-shot agents with
605
+ citations and honest actor attribution in frontmatter provenance).
606
+
607
+ Serving: syndicate agents declare the navigation/write tools by name
608
+ (`config/agents/examples/scriptorium.yaml` works the prose,
609
+ `config/agents/examples/cartographers.yaml` the graph — `npm run syndicate:scriptorium`,
610
+ `npm run syndicate:cartographers`); outside MCP clients get all ten from
611
+ `npm run mcp:wiki` (loopback SSE on `:8933`). Trust is explicit in
612
+ frontmatter: `generated.by` records who wrote a document (`human:<id>`,
613
+ `process:<id>`, or `<producer>/<model>`), and only a `human:` entry in
614
+ `verified` makes it human-reviewed.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 James Wadman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.