klypix-mcp 1.3.0 → 1.4.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.
package/A2A.md ADDED
@@ -0,0 +1,117 @@
1
+ # KLYPIX speaks A2A
2
+
3
+ KLYPIX is the **shared, human-owned memory node** for a multi-agent stack.
4
+ **A2A moves the messages between agents; `.klypix` holds the context they read
5
+ and write.** The two are complementary layers, not competitors:
6
+
7
+ | Layer | Protocol | KLYPIX's role |
8
+ |---|---|---|
9
+ | Agent ↔ tools / context | **MCP** (`klypix-mcp`) | one agent reaches your canvases as tools |
10
+ | Agent ↔ agent | **A2A** (`klypix-a2a`) | KLYPIX is a discoverable peer other agents delegate memory tasks to |
11
+ | The owned substrate | **`.klypix`** file | the portable, multimodal board *both* layers write to |
12
+
13
+ What makes this best-in-class for A2A specifically: most A2A agents return
14
+ **text**. KLYPIX returns a portable, multimodal **`.klypix` artifact** — the
15
+ spatial board itself, as a `FilePart` the human owns and any model can re-open.
16
+
17
+ ## Run it
18
+
19
+ ```bash
20
+ npx -p klypix-mcp klypix-a2a --vault ./canvases # default 127.0.0.1:41241
21
+ # or
22
+ KLYPIX_VAULT=./canvases KLYPIX_A2A_PORT=41241 npx -p klypix-mcp klypix-a2a
23
+ ```
24
+
25
+ Flags / env: `--vault` (`KLYPIX_VAULT`), `--port` (`KLYPIX_A2A_PORT`, default
26
+ `41241`), `--host` (`KLYPIX_A2A_HOST`, default `127.0.0.1`).
27
+
28
+ It is **local-first**: it binds loopback and needs no auth, because the file
29
+ lives on your disk. To expose it, set `--host 0.0.0.0` behind a reverse proxy
30
+ that terminates TLS and adds authentication.
31
+
32
+ ## Discover it
33
+
34
+ The Agent Card is published at the standard well-known path (RFC 8615):
35
+
36
+ ```
37
+ GET http://127.0.0.1:41241/.well-known/agent-card.json
38
+ ```
39
+
40
+ It advertises the `url` of the JSON-RPC endpoint, `capabilities.streaming`, and
41
+ the skills below.
42
+
43
+ ## Skills
44
+
45
+ | Skill `id` | Does | Returns |
46
+ |---|---|---|
47
+ | `make_board` | Create a new `.klypix` from cards + connections | a `.klypix` **FilePart** + summary |
48
+ | `remember` | Append cards/decisions to an existing canvas (positions preserved) | the updated `.klypix` |
49
+ | `recall` | Search card text/titles/`#tags` across the vault | matching cards (text) |
50
+ | `read_canvas` | Read one canvas (cards, graph, `[[links]]`) + its images | markdown + image FileParts |
51
+ | `list_canvases` | List canvases with counts | text |
52
+ | `brain_insights` | Hubs / orphans / stale questions in a brain | text |
53
+ | `search_all_brains` | Cross-project memory search (semantic + lexical) | text |
54
+
55
+ ## Talk to it (JSON-RPC 2.0)
56
+
57
+ Methods: `message/send`, `message/stream` (SSE), `tasks/get`, `tasks/cancel`.
58
+
59
+ **Deterministic invocation** — an orchestrator sends a `DataPart` naming the
60
+ skill and its args (this is the reliable contract):
61
+
62
+ ```jsonc
63
+ POST /
64
+ {
65
+ "jsonrpc": "2.0", "id": 1, "method": "message/send",
66
+ "params": {
67
+ "message": {
68
+ "kind": "message", "role": "user", "messageId": "1",
69
+ "parts": [{
70
+ "kind": "data",
71
+ "data": {
72
+ "skill": "make_board",
73
+ "args": {
74
+ "title": "Launch plan",
75
+ "cards": [{ "text": "Ship A2A face" }, { "text": "Seed MCP directory" }],
76
+ "connections": [{ "from": 0, "to": 1, "relationship": "leads_to" }]
77
+ }
78
+ }
79
+ }]
80
+ }
81
+ }
82
+ }
83
+ ```
84
+
85
+ The result is an A2A `Task` whose `artifacts[0].parts` contains a
86
+ `{ kind: "file", file: { mimeType: "application/vnd.klypix+zip", bytes } }` — the
87
+ board itself.
88
+
89
+ **Free-text invocation** — a plain message is routed by intent (a convenience
90
+ for chat-style callers):
91
+
92
+ - *"What do we know about auth?"* → `recall`
93
+ - *"What's in the roadmap canvas?"* → `read_canvas` (a named canvas reads, not lists)
94
+ - *"Remember that we chose Postgres."* → `remember` (one card on the brain)
95
+ - *"Summarize the canvas roadmap."* → `read_canvas`
96
+ - *"Make a board: Alpha; Beta; Gamma"* → `make_board` (a free-text brief is split
97
+ into one card per line/item; structured `cards` via a `DataPart` is preferred and
98
+ lossless). With neither, it returns `input-required` with the exact `DataPart` to send.
99
+
100
+ If a write (`make_board`/`remember`) returns `input-required`, reply with a message
101
+ carrying the same `taskId` plus the missing input to **continue that task** (the
102
+ server resumes it with a stable id and accumulated history).
103
+
104
+ ## Notes
105
+
106
+ - Tasks complete synchronously (the work is local file I/O), so `message/send`
107
+ returns a terminal `Task`. `message/stream` emits a **monotonic** lifecycle in
108
+ one burst — a non-terminal `Task` (`submitted`), then (for completed work) an
109
+ `artifact-update`, then exactly one terminal `status-update` with `final:true`.
110
+ - The server binds loopback and exposes no auth; the A2A face additionally
111
+ refuses any `canvas` reference that resolves **outside the vault** (absolute or
112
+ `..` paths), even though the underlying engine would allow it for the trusted
113
+ MCP/stdio caller.
114
+ - Provenance: writes are stamped with the calling agent's name when supplied via
115
+ `message.metadata.agentName` (or a `DataPart` `agentName`), else `a2a`.
116
+ - The A2A and MCP faces share one engine (`src/klypix-core.mjs`); neither can
117
+ corrupt the other, and both operate only on the `.klypix` files in the vault.
package/README.md CHANGED
@@ -14,6 +14,13 @@ messy project at once. `klypix-mcp` fixes that with a single portable file:
14
14
  the loop — read it with Claude today, GPT tomorrow, a local model next week.
15
15
  No vendor can take it away.
16
16
 
17
+ > **The shared memory layer for your multi-agent stack.** A2A moves the messages
18
+ > between agents; MCP connects an agent to its tools; **`.klypix` is the owned,
19
+ > multimodal context both layers read and write.** KLYPIX ships *two* faces over
20
+ > one engine — an **MCP server** (`klypix-mcp`) and an **A2A agent**
21
+ > (`klypix-a2a`) — so whichever protocol your stack speaks, the memory node is
22
+ > the same portable file you own. See **[A2A.md](A2A.md)**.
23
+
17
24
  ## Quick start (60 seconds)
18
25
 
19
26
  ```bash
@@ -48,6 +55,22 @@ notes into a board,"* or *"add a card with the decision we just made."*
48
55
  | `create_canvas` | Create a new `.klypix` from cards + connections |
49
56
  | `add_to_canvas` | Append cards/connections to an existing canvas (positions preserved) |
50
57
 
58
+ ## Also speaks A2A (Agent-to-Agent)
59
+
60
+ The same engine is exposed as an **A2A agent** so other agents and orchestrators
61
+ can delegate memory tasks to KLYPIX as a discoverable peer:
62
+
63
+ ```bash
64
+ npx -p klypix-mcp klypix-a2a --vault ./canvases # 127.0.0.1:41241
65
+ # Agent Card: http://127.0.0.1:41241/.well-known/agent-card.json
66
+ ```
67
+
68
+ Skills: `make_board`, `remember`, `recall`, `read_canvas`, `list_canvases`,
69
+ `brain_insights`, `search_all_brains`. Unlike a typical A2A agent that returns
70
+ text, KLYPIX returns the **`.klypix` board itself** as a multimodal artifact.
71
+ Full protocol details (JSON-RPC methods, message shapes, streaming) in
72
+ **[A2A.md](A2A.md)**.
73
+
51
74
  ## Use it as a library
52
75
 
53
76
  ```js
@@ -0,0 +1,433 @@
1
+ #!/usr/bin/env node
2
+ // klypix-a2a — the A2A (Agent2Agent) FACE of KLYPIX.
3
+ //
4
+ // Where the MCP server (bin/klypix-mcp.mjs) lets ONE agent reach your canvases
5
+ // as tools, this exposes the SAME engine as an A2A peer: a discoverable remote
6
+ // agent that other agents (and A2A orchestrators) can delegate tasks to over the
7
+ // open A2A protocol. KLYPIX's role in a multi-agent stack is the shared, owned,
8
+ // multimodal MEMORY node — A2A moves the messages; `.klypix` holds the context.
9
+ //
10
+ // What makes this best-in-class for A2A specifically: most A2A agents return
11
+ // text. KLYPIX returns a portable, multimodal `.klypix` ARTIFACT (a FilePart the
12
+ // human owns and any model can re-open) — the spatial board itself, not a
13
+ // transcript of one.
14
+ //
15
+ // Spec surface (JSON-RPC 2.0 over HTTP, the interoperable A2A binding):
16
+ // GET /.well-known/agent-card.json → the Agent Card (RFC 8615 discovery)
17
+ // POST / → message/send · message/stream (SSE) ·
18
+ // tasks/get · tasks/cancel
19
+ //
20
+ // Local-first: binds 127.0.0.1 by default (the file lives on your disk; no auth
21
+ // needed on loopback). Set --host 0.0.0.0 + a reverse proxy to expose it.
22
+ //
23
+ // Run: node bin/klypix-a2a.mjs --vault "C:\\path\\to\\canvases" [--port 41241] [--host 127.0.0.1]
24
+ // env: KLYPIX_VAULT, KLYPIX_A2A_PORT, KLYPIX_A2A_HOST
25
+
26
+ import http from 'http';
27
+ import fs from 'fs';
28
+ import path from 'path';
29
+ import crypto from 'crypto';
30
+ import { fileURLToPath } from 'url';
31
+ import { z } from 'zod';
32
+ import {
33
+ resolveVault, getEmbedder, cardSchema, connSchema,
34
+ opListCanvases, opReadCanvas, opSearchCanvases, opSearchAllBrains,
35
+ opBrainInsights, opBrainConnect, opCreateCanvas, opAddToCanvas,
36
+ } from '../src/klypix-core.mjs';
37
+
38
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
39
+ const PKG = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
40
+ const log = (...a) => console.error('[klypix-a2a]', ...a);
41
+
42
+ const arg = (flag) => { const i = process.argv.indexOf(flag); return i >= 0 ? process.argv[i + 1] : undefined; };
43
+ const VAULT = resolveVault(arg('--vault'));
44
+ const HOST = arg('--host') || process.env.KLYPIX_A2A_HOST || '127.0.0.1';
45
+ const PORT = parseInt(arg('--port') || process.env.KLYPIX_A2A_PORT || '41241', 10);
46
+
47
+ const KLYPIX_MIME = 'application/vnd.klypix+zip';
48
+ const now = () => new Date().toISOString();
49
+ const uuid = () => crypto.randomUUID();
50
+
51
+ // ── Agent Card ───────────────────────────────────────────────────────────────
52
+ // Published at /.well-known/agent-card.json so any A2A client can discover what
53
+ // KLYPIX can do and where to delegate. `url` is the JSON-RPC service endpoint.
54
+ function agentCard(publicUrl) {
55
+ return {
56
+ protocolVersion: '0.3.0',
57
+ name: 'KLYPIX Canvas — agent-neutral spatial memory',
58
+ description:
59
+ 'The shared, human-owned memory node for a multi-agent stack. Delegate tasks to read, search, ' +
60
+ 'and write a portable .klypix canvas (cards + a connection graph + images), and get back the ' +
61
+ 'spatial board itself as a multimodal artifact — local-first, model-neutral, no lab in the loop.',
62
+ url: publicUrl,
63
+ version: PKG.version,
64
+ provider: { organization: 'Dahshan Labs', url: 'https://klypix.com' },
65
+ capabilities: { streaming: true, pushNotifications: false, stateTransitionHistory: false },
66
+ defaultInputModes: ['text/plain', 'application/json'],
67
+ defaultOutputModes: ['text/plain', 'application/json', KLYPIX_MIME, 'image/png'],
68
+ skills: [
69
+ {
70
+ id: 'make_board',
71
+ name: 'Turn a brief into a spatial board',
72
+ description: 'Create a new .klypix canvas from cards + connections and return the board as a portable file artifact the human owns and any model can re-open.',
73
+ tags: ['canvas', 'create', 'memory', 'spatial', 'artifact'],
74
+ examples: ['Turn this plan into a board', 'Make a mind-map of these notes'],
75
+ inputModes: ['text/plain', 'application/json'],
76
+ outputModes: [KLYPIX_MIME, 'text/plain'],
77
+ },
78
+ {
79
+ id: 'remember',
80
+ name: 'Remember into the canvas / brain',
81
+ description: 'Append a decision or cards (with optional connections) to an existing .klypix, preserving every existing item and position. The durable, cross-session memory a multi-agent run keeps writing to.',
82
+ tags: ['memory', 'append', 'write', 'brain'],
83
+ examples: ['Remember that we chose Postgres over Mongo', 'Add a card with this finding to the roadmap canvas'],
84
+ inputModes: ['text/plain', 'application/json'],
85
+ outputModes: [KLYPIX_MIME, 'text/plain'],
86
+ },
87
+ {
88
+ id: 'recall',
89
+ name: 'Recall context from the canvases',
90
+ description: 'Search card text, titles, and #tags across every canvas in the vault and return the matching cards — the shared blackboard a delegating agent reads before acting.',
91
+ tags: ['memory', 'search', 'recall', 'read'],
92
+ examples: ['What do we know about the auth design?', 'Find cards mentioning rate limits'],
93
+ inputModes: ['text/plain'],
94
+ outputModes: ['text/plain'],
95
+ },
96
+ {
97
+ id: 'read_canvas',
98
+ name: 'Read a canvas (cards, graph, and images)',
99
+ description: 'Read one canvas as structured markdown — every card, the connection graph, [[wikilinks]], #tags — plus its images returned as file parts so a vision model SEES them.',
100
+ tags: ['read', 'canvas', 'multimodal', 'vision'],
101
+ examples: ['Summarize the canvas roadmap', 'Read the board called SS2'],
102
+ inputModes: ['text/plain'],
103
+ outputModes: ['text/plain', 'image/png'],
104
+ },
105
+ {
106
+ id: 'list_canvases',
107
+ name: 'List the canvases in the vault',
108
+ description: 'List every .klypix / .any canvas with card and connection counts.',
109
+ tags: ['list', 'discover'],
110
+ examples: ['What canvases are available?'],
111
+ inputModes: ['text/plain'],
112
+ outputModes: ['text/plain'],
113
+ },
114
+ {
115
+ id: 'brain_insights',
116
+ name: 'What matters in the brain',
117
+ description: 'Structural read of a brain.klypix: load-bearing hub cards, orphaned decisions, stale open questions, and area sizes. Use to orient before a planning task.',
118
+ tags: ['insights', 'brain', 'review'],
119
+ examples: ['What am I forgetting in the project brain?'],
120
+ inputModes: ['text/plain'],
121
+ outputModes: ['text/plain'],
122
+ },
123
+ {
124
+ id: 'search_all_brains',
125
+ name: 'Search every project brain on this machine',
126
+ description: 'Cross-project memory search across every registered brain.klypix (semantic + lexical). Optional as_of date for a point-in-time query.',
127
+ tags: ['memory', 'search', 'cross-project'],
128
+ examples: ['What did I decide about auth in any project?'],
129
+ inputModes: ['text/plain'],
130
+ outputModes: ['text/plain'],
131
+ },
132
+ {
133
+ id: 'brain_connect',
134
+ name: 'Densify the brain graph (connect related cards)',
135
+ description: 'Find genuinely related but unlinked cards and propose (or, with apply, draw) connections — semantic when the on-device model is present, else shared tags + [[mentions]]. Additive; never deletes.',
136
+ tags: ['brain', 'graph', 'connect', 'memory'],
137
+ examples: ['Connect the related cards in my project brain'],
138
+ inputModes: ['text/plain', 'application/json'],
139
+ outputModes: ['text/plain'],
140
+ },
141
+ ],
142
+ securitySchemes: {},
143
+ security: [],
144
+ supportsAuthenticatedExtendedCard: false,
145
+ };
146
+ }
147
+
148
+ // ── Skill execution ──────────────────────────────────────────────────────────
149
+ // Each skill maps to one core op. Args come from a structured DataPart
150
+ // (deterministic — what an orchestrator sends) or are inferred from text.
151
+ const cardsArg = z.array(cardSchema).min(1);
152
+ const connsArg = z.array(connSchema).optional();
153
+
154
+ async function runSkill(skill, args, text, via) {
155
+ // Network trust boundary: resolveCanvas honors absolute / `..` refs (fine for
156
+ // the trusted MCP stdio face) — but THIS is an HTTP listener, so refuse any
157
+ // canvas ref that resolves outside the vault before it reaches the engine.
158
+ if (args && args.canvas != null && !vaultContained(args.canvas)) {
159
+ return { blocks: [{ kind: 'text', text: `Refused: canvas "${args.canvas}" resolves outside the vault. The A2A face only serves canvases inside ${VAULT}.` }], isError: true };
160
+ }
161
+ switch (skill) {
162
+ case 'list_canvases':
163
+ return await opListCanvases({ vault: VAULT });
164
+ case 'read_canvas':
165
+ return await opReadCanvas({ vault: VAULT, canvas: args.canvas ?? extractCanvas(text) ?? text.trim() });
166
+ case 'recall':
167
+ case 'search_canvases':
168
+ return await opSearchCanvases({ vault: VAULT, query: args.query ?? text.trim() });
169
+ case 'search_all_brains':
170
+ return await opSearchAllBrains({ vault: VAULT, query: args.query ?? text.trim(), as_of: args.as_of, log });
171
+ case 'brain_insights':
172
+ return await opBrainInsights({ vault: VAULT, canvas: args.canvas, staleDays: args.staleDays });
173
+ case 'brain_connect':
174
+ return await opBrainConnect({ vault: VAULT, canvas: args.canvas, apply: args.apply, max: args.max, threshold: args.threshold, log });
175
+ case 'make_board':
176
+ case 'create_canvas': {
177
+ // Structured cards (DataPart) are preferred; otherwise split a free-text
178
+ // brief into one card per line/item so NL "make a board: a; b; c" works.
179
+ const cards = args.cards ?? (text.trim() ? briefToCards(text) : null);
180
+ const parsed = cardsArg.safeParse(cards && cards.length ? cards : null);
181
+ if (!parsed.success) {
182
+ return needInput('make_board needs cards. Send a DataPart `{"skill":"make_board","args":{"title":"…","cards":[{"text":"…"}],"connections":[{"from":0,"to":1}]}}`, or a brief with one item per line.');
183
+ }
184
+ const conns = connsArg.safeParse(args.connections);
185
+ if (!conns.success) return needInput('connections must be `[{ "from": <index|title>, "to": <index|title> }]`.');
186
+ return await opCreateCanvas({ vault: VAULT, title: args.title ?? 'Untitled board', cards: parsed.data, connections: conns.data, filename: args.filename });
187
+ }
188
+ case 'remember':
189
+ case 'add_to_canvas': {
190
+ // NL convenience: a bare "remember: X" becomes a single card on the brain.
191
+ const cards = args.cards ?? (text.trim() ? [{ text: stripVerb(text) }] : null);
192
+ const parsed = cardsArg.safeParse(cards);
193
+ if (!parsed.success) {
194
+ return needInput('remember needs at least one card. Send text to capture, or a DataPart `{"skill":"remember","args":{"canvas":"brain","cards":[{"text":"…"}]}}`.');
195
+ }
196
+ const conns = connsArg.safeParse(args.connections);
197
+ if (!conns.success) return needInput('connections must be `[{ "from": <index|title>, "to": <index|title> }]`.');
198
+ return await opAddToCanvas({ vault: VAULT, canvas: args.canvas ?? 'brain', cards: parsed.data, connections: conns.data, via });
199
+ }
200
+ default:
201
+ return { blocks: [{ kind: 'text', text: `Unknown skill: ${skill}` }], isError: true };
202
+ }
203
+ }
204
+ const needInput = (msg) => ({ inputRequired: true, blocks: [{ kind: 'text', text: msg }] });
205
+
206
+ // The network face refuses canvas refs that resolve outside the vault (absolute
207
+ // paths or `..` traversal). A bare title resolves under the vault → allowed.
208
+ function vaultContained(ref) {
209
+ if (ref == null || ref === '') return true;
210
+ const root = path.resolve(VAULT);
211
+ const resolved = path.resolve(VAULT, String(ref));
212
+ return resolved === root || resolved.toLowerCase().startsWith(root.toLowerCase() + path.sep);
213
+ }
214
+
215
+ // ── Intent routing (fallback when no structured skill is given) ──────────────
216
+ const STOP = /^(the|a|an|this|that|my|our|on|in|of|to|what|whats|it|is|are|all)$/i;
217
+ function extractCanvas(text) {
218
+ const s = String(text || '');
219
+ // "canvas roadmap" / "board called X" / "named X"
220
+ let m = /(?:canvas|board|called|named)\s+["“]?([\w.\-]{2,40})["”]?/i.exec(s);
221
+ if (m && !STOP.test(m[1])) return m[1].trim();
222
+ // "roadmap canvas" / "the X board" — the name BEFORE the keyword
223
+ m = /["“]?([\w.\-]{2,40})["”]?\s+(?:canvas|board)\b/i.exec(s);
224
+ if (m && !STOP.test(m[1])) return m[1].trim();
225
+ return null;
226
+ }
227
+ function stripVerb(text) {
228
+ return String(text || '').replace(/^\s*(please\s+)?(remember|note|capture|log|add a card[:,]?|record)\b[:,]?\s*/i, '').trim() || String(text || '').trim();
229
+ }
230
+ // Split a free-text brief into atomic cards (one per line / `;` / bullet, or a
231
+ // comma list when there are no line breaks). Lossy — a DataPart is preferred.
232
+ function briefToCards(text) {
233
+ let body = String(text || '').replace(/^.*?\b(board|canvas|mind ?map|map|diagram)\b[:\-—\s]*/i, '').trim();
234
+ if (!body) body = String(text || '').trim();
235
+ let parts = body.split(/\r?\n|;|·|•|•/).map(s => s.trim()).filter(s => s.length > 1);
236
+ if (parts.length < 2 && /,/.test(body)) parts = body.split(',').map(s => s.trim()).filter(s => s.length > 1);
237
+ return parts.map(s => ({ text: s.replace(/^[-*\d.)\]\s]+/, '').trim() })).filter(c => c.text);
238
+ }
239
+ function routeIntent(text, dataArgs) {
240
+ if (dataArgs && dataArgs.skill) return { skill: dataArgs.skill, args: dataArgs.args || dataArgs };
241
+ const t = String(text || '').toLowerCase();
242
+ const named = extractCanvas(text);
243
+ if (/\b(make|build|create|draw|turn .* into).{0,30}(board|canvas|mind ?map|map|diagram)\b/.test(t)) return { skill: 'make_board', args: {} };
244
+ if (/\b(remember|note this|capture this|log that|record that|add a card)\b/.test(t)) return { skill: 'remember', args: {} };
245
+ // An explicit read verb OR a specific named canvas → read it. Checked BEFORE
246
+ // list so "what's on the roadmap canvas" reads that canvas, not the vault index.
247
+ if (named || /\b(read|open|summari[sz]e|show)\b.{0,30}\b(canvas|board)\b/.test(t)) return { skill: 'read_canvas', args: {} };
248
+ // list = discovery intent with NO specific canvas named.
249
+ if (/\b(list|all|available|which|what)\b.{0,20}\bcanvas(es)?\b/.test(t)) return { skill: 'list_canvases', args: {} };
250
+ if (/\b(insight|what matters|hubs?|orphan|stale|forgetting|review the brain)\b/.test(t)) return { skill: 'brain_insights', args: {} };
251
+ if (/\b(across|all brains|other projects?|any project)\b/.test(t)) return { skill: 'search_all_brains', args: {} };
252
+ return { skill: 'recall', args: {} }; // safest default: "what do we know about X"
253
+ }
254
+
255
+ // ── Message / Part / Task helpers (A2A shapes) ───────────────────────────────
256
+ function partsToText(parts) {
257
+ return (parts || []).filter(p => p.kind === 'text' || typeof p.text === 'string').map(p => p.text).join('\n').trim();
258
+ }
259
+ function dataPart(parts) {
260
+ const p = (parts || []).find(p => p.kind === 'data' && p.data);
261
+ return p ? p.data : null;
262
+ }
263
+ // Core block[] (+ optional file) → A2A Part[].
264
+ function blocksToParts(result) {
265
+ const parts = [];
266
+ for (const b of result.blocks || []) {
267
+ if (b.kind === 'image') parts.push({ kind: 'file', file: { name: b.name || 'image.png', mimeType: b.mime || 'image/png', bytes: b.data } });
268
+ else parts.push({ kind: 'text', text: b.text });
269
+ }
270
+ if (result.file) {
271
+ parts.push({ kind: 'file', file: { name: result.file.name, mimeType: KLYPIX_MIME, bytes: Buffer.from(result.file.buffer).toString('base64') } });
272
+ }
273
+ return parts;
274
+ }
275
+
276
+ const tasks = new Map(); // id → task (in-memory; local-first single user)
277
+
278
+ async function buildTask(userMessage) {
279
+ // A2A multi-turn continuation: if the client replies with the taskId of an
280
+ // input-required task, resume it (stable id + contextId + accumulated history)
281
+ // instead of minting an unrelated new task.
282
+ const prior = userMessage.taskId ? tasks.get(userMessage.taskId) : null;
283
+ const resuming = !!prior && prior.status.state === 'input-required';
284
+ const id = resuming ? prior.id : uuid();
285
+ const contextId = userMessage.contextId || prior?.contextId || uuid();
286
+ const text = partsToText(userMessage.parts);
287
+ const data = dataPart(userMessage.parts);
288
+ const via = userMessage.metadata?.agentName || data?.agentName || 'a2a';
289
+ const { skill, args } = routeIntent(text, data);
290
+
291
+ let result;
292
+ try { result = await runSkill(skill, args || {}, text, via); }
293
+ catch (e) { result = { blocks: [{ kind: 'text', text: `Skill "${skill}" failed: ${e.message}` }], isError: true }; }
294
+
295
+ const state = result.isError ? 'failed' : result.inputRequired ? 'input-required' : 'completed';
296
+ const task = {
297
+ kind: 'task',
298
+ id,
299
+ contextId,
300
+ status: {
301
+ state,
302
+ timestamp: now(),
303
+ message: {
304
+ kind: 'message', role: 'agent', messageId: uuid(), taskId: id, contextId,
305
+ parts: state === 'completed'
306
+ ? [{ kind: 'text', text: `Done via skill "${skill}".` }]
307
+ : blocksToParts(result),
308
+ },
309
+ },
310
+ artifacts: state === 'completed'
311
+ ? [{ artifactId: uuid(), name: result.file?.name || `${skill}-result`, parts: blocksToParts(result) }]
312
+ : [],
313
+ history: resuming ? [...prior.history, userMessage] : [userMessage],
314
+ metadata: { skill },
315
+ };
316
+ tasks.set(id, task);
317
+ return task;
318
+ }
319
+
320
+ // ── JSON-RPC dispatch ────────────────────────────────────────────────────────
321
+ const rpcOk = (id, result) => ({ jsonrpc: '2.0', id, result });
322
+ const rpcErr = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
323
+
324
+ function statusUpdate(task, final) {
325
+ const status = { state: final ? task.status.state : 'working', timestamp: now() };
326
+ // Surface the terminal message (error text / input-required prompt / done note)
327
+ // on the final event so a client that only reads the last update still sees it.
328
+ if (final && task.status.message) status.message = task.status.message;
329
+ return { kind: 'status-update', taskId: task.id, contextId: task.contextId, status, final };
330
+ }
331
+ function artifactUpdate(task) {
332
+ return { kind: 'artifact-update', taskId: task.id, contextId: task.contextId, artifact: task.artifacts[0], append: false, lastChunk: true };
333
+ }
334
+
335
+ async function handleRpc(body, res) {
336
+ const { id, method, params } = body || {};
337
+ if (!method) return sendJson(res, 200, rpcErr(id ?? null, -32600, 'Invalid Request: missing method'));
338
+
339
+ if (method === 'message/send') {
340
+ const msg = params?.message;
341
+ if (!msg || !Array.isArray(msg.parts)) return sendJson(res, 200, rpcErr(id, -32602, 'Invalid params: message.parts required'));
342
+ const task = await buildTask(msg);
343
+ return sendJson(res, 200, rpcOk(id, task));
344
+ }
345
+
346
+ if (method === 'message/stream') {
347
+ const msg = params?.message;
348
+ if (!msg || !Array.isArray(msg.parts)) return sendJson(res, 200, rpcErr(id, -32602, 'Invalid params: message.parts required'));
349
+ // SSE with a MONOTONIC A2A lifecycle: a non-terminal Task first, then (only
350
+ // for completed work) the artifact, then exactly ONE terminal status-update
351
+ // with final:true. No backward state transitions. Each event is wrapped as a
352
+ // JSON-RPC response carrying the request id (the A2A streaming binding).
353
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'Access-Control-Allow-Origin': '*' });
354
+ const send = (event) => res.write(`data: ${JSON.stringify(rpcOk(id, event))}\n\n`);
355
+ const task = await buildTask(msg);
356
+ send({ ...task, status: { state: 'submitted', timestamp: now() }, artifacts: [] }); // 1. non-terminal Task
357
+ if (task.status.state === 'completed' && task.artifacts.length) send(artifactUpdate(task)); // 2. artifact (completed only)
358
+ send(statusUpdate(task, true)); // 3. single terminal status (final:true)
359
+ return res.end();
360
+ }
361
+
362
+ if (method === 'tasks/get') {
363
+ const t = tasks.get(params?.id);
364
+ return t ? sendJson(res, 200, rpcOk(id, t)) : sendJson(res, 200, rpcErr(id, -32001, 'Task not found'));
365
+ }
366
+
367
+ if (method === 'tasks/cancel') {
368
+ const t = tasks.get(params?.id);
369
+ if (!t) return sendJson(res, 200, rpcErr(id, -32001, 'Task not found'));
370
+ // Terminal tasks can't be canceled; ours complete synchronously.
371
+ if (['completed', 'failed', 'canceled'].includes(t.status.state)) return sendJson(res, 200, rpcErr(id, -32002, `Task not cancelable (state: ${t.status.state})`));
372
+ t.status = { state: 'canceled', timestamp: now() };
373
+ return sendJson(res, 200, rpcOk(id, t));
374
+ }
375
+
376
+ return sendJson(res, 200, rpcErr(id, -32601, `Method not found: ${method}`));
377
+ }
378
+
379
+ // ── HTTP plumbing ────────────────────────────────────────────────────────────
380
+ function sendJson(res, code, obj) {
381
+ const buf = Buffer.from(JSON.stringify(obj));
382
+ res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': buf.length, 'Access-Control-Allow-Origin': '*' });
383
+ res.end(buf);
384
+ }
385
+
386
+ const server = http.createServer((req, res) => {
387
+ const url = new URL(req.url, `http://${req.headers.host || HOST + ':' + PORT}`);
388
+ const publicUrl = `http://${req.headers.host || `${HOST}:${PORT}`}/`;
389
+
390
+ if (req.method === 'OPTIONS') {
391
+ res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization' });
392
+ return res.end();
393
+ }
394
+
395
+ // Agent Card discovery (RFC 8615). Serve the current + legacy well-known paths.
396
+ if (req.method === 'GET' && (url.pathname === '/.well-known/agent-card.json' || url.pathname === '/.well-known/agent.json')) {
397
+ return sendJson(res, 200, agentCard(publicUrl));
398
+ }
399
+ if (req.method === 'GET' && (url.pathname === '/' || url.pathname === '/health')) {
400
+ return sendJson(res, 200, { name: 'klypix-a2a', version: PKG.version, vault: VAULT, agentCard: `${publicUrl}.well-known/agent-card.json` });
401
+ }
402
+
403
+ if (req.method === 'POST') {
404
+ // Byte-accurate body cap (count bytes, not UTF-16 code units) + bail before
405
+ // buffering an over-size chunk.
406
+ let size = 0; const chunks = [];
407
+ req.on('data', (c) => { size += c.length; if (size > 50_000_000) return req.destroy(); chunks.push(c); });
408
+ req.on('end', async () => {
409
+ const raw = Buffer.concat(chunks).toString('utf8');
410
+ let body;
411
+ try { body = JSON.parse(raw); } catch { return sendJson(res, 200, rpcErr(null, -32700, 'Parse error')); }
412
+ try { await handleRpc(body, res); }
413
+ catch (e) { log('rpc error', e); if (!res.headersSent) sendJson(res, 200, rpcErr(body?.id ?? null, -32603, `Internal error: ${e.message}`)); else res.end(); }
414
+ });
415
+ return;
416
+ }
417
+
418
+ sendJson(res, 404, rpcErr(null, -32601, 'Not found'));
419
+ });
420
+
421
+ // Defense-in-depth socket timeouts (cheap; matters if anyone exposes this past
422
+ // the loopback default without a fronting proxy — guards slow-loris).
423
+ server.requestTimeout = 30_000;
424
+ server.headersTimeout = 15_000;
425
+ server.keepAliveTimeout = 5_000;
426
+
427
+ server.listen(PORT, HOST, () => {
428
+ log(`ready · vault=${VAULT}`);
429
+ log(`agent card: http://${HOST}:${PORT}/.well-known/agent-card.json`);
430
+ log(`A2A endpoint (JSON-RPC): http://${HOST}:${PORT}/`);
431
+ // Pre-warm the on-device embedder so the first cross-project search is semantic.
432
+ getEmbedder(log).then(p => log(p ? 'semantic ready (pre-warmed)' : 'semantic unavailable — lexical only')).catch(() => {});
433
+ });