@aikoql/client 0.0.0-stage → 0.2.4
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/README.md +22 -2
- package/package.json +30 -4
- package/src/client.ts +548 -0
- package/src/error.ts +95 -0
- package/src/index.ts +20 -0
- package/src/pool.ts +297 -0
- package/src/sdk-conformance.ts +496 -0
- package/src/tcp.ts +147 -0
- package/src/tools.ts +72 -0
- package/src/tx.ts +83 -0
- package/tests/conformance.test.ts +60 -0
- package/tests/corpus-pin.test.ts +96 -0
- package/tests/fault-matrix.test.ts +298 -0
- package/tests/fuzz-estate-pin.test.ts +32 -0
- package/tests/fuzz-estate.test.ts +515 -0
- package/tests/helpers.ts +69 -0
- package/tests/identity.test.ts +26 -0
- package/tests/leader-hint.test.ts +50 -0
- package/tests/pool.test.ts +297 -0
- package/tests/tls.test.ts +74 -0
- package/tests/wire.test.ts +207 -0
- package/tsconfig.json +16 -0
package/README.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aikoql/client — TypeScript SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The first-party TypeScript client for the [aikoql](https://github.com/anckursingh/aikoql) knowledge database: the canonical Database API (remember, get, query, traverse, hybrid search, context compile, prepare/execute) over the MCP transport, with async/await, `AsyncIterable` streaming, `AbortSignal` cancellation, and a connection pool. Zero runtime dependencies.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { Client } from "@aikoql/client";
|
|
7
|
+
|
|
8
|
+
const client = (await Client.dial("127.0.0.1:9090")).withToken("s3cret");
|
|
9
|
+
// a server started with --tls-cert/--tls-key ({} for a public CA):
|
|
10
|
+
// await Client.dial("db.internal:9090", { tls: { ca: readFileSync("ca.pem") } })
|
|
11
|
+
const agent = await client.agent();
|
|
12
|
+
await agent.remember("person", { name: "ada" });
|
|
13
|
+
for await (const row of client.queryStream("MATCH person RETURN *")) { /* ... */ }
|
|
14
|
+
await client.close();
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @aikoql/client
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
- Runs on Node ≥ 24 (native type-stripping) with a browser-compatible core
|
|
22
|
+
- Tests: `AIKOQL_MCP_BIN=$PWD/target/release/aikoql-mcp node --test tests/`
|
|
23
|
+
- License: Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aikoql/client",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.4",
|
|
4
|
+
"description": "Aikoql — the TypeScript SDK for the agent-first knowledge database. Node + browser-compatible core, async/await, AsyncIterable streaming, AbortSignal, pool. Zero runtime dependencies.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Anckur Singh",
|
|
8
|
+
"url": "https://github.com/anckursingh"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/anckursingh/aikoql.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"aikoql",
|
|
16
|
+
"knowledge-graph",
|
|
17
|
+
"knowledge-database",
|
|
18
|
+
"mcp",
|
|
19
|
+
"sdk"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"main": "src/index.ts",
|
|
23
|
+
"scripts": {
|
|
24
|
+
"test": "node --test \"tests/**/*.test.ts\"",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^24.0.0",
|
|
29
|
+
"fast-check": "^4.10.2",
|
|
30
|
+
"typescript": "^5.8.0"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
// The remote client: MCP JSON-RPC over the Transport. Mirrors the Go
|
|
2
|
+
// SDK's wire layer (crates/sdk/go/aikoql.go) and the Rust reference
|
|
3
|
+
// (crates/sdk/rust/src/client.rs) — newline frames, id correlation, the
|
|
4
|
+
// tools/call envelope — and the frozen §3.3 semantics: a response with a
|
|
5
|
+
// smaller id is skipped, a larger id is PROTOCOL_ERROR, deadline/aborted
|
|
6
|
+
// reads map to the retryable TIMEOUT, and a call on a closed client is
|
|
7
|
+
// UNAVAILABLE before the transport is touched. The transport is private
|
|
8
|
+
// to the Client: the D-15 native protocol replacement touches only the
|
|
9
|
+
// Transport, not this file (the canonical API does not change when the
|
|
10
|
+
// transport changes).
|
|
11
|
+
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import type { ConnectionOptions } from "node:tls";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { McpError } from "./error.ts";
|
|
16
|
+
import { TcpTransport, type Transport } from "./tcp.ts";
|
|
17
|
+
import type {
|
|
18
|
+
FindSimilarParams,
|
|
19
|
+
KnowledgeObject,
|
|
20
|
+
Metrics,
|
|
21
|
+
RememberParams,
|
|
22
|
+
Remembered,
|
|
23
|
+
ScoredKO,
|
|
24
|
+
SessionParams,
|
|
25
|
+
} from "./tools.ts";
|
|
26
|
+
import { Tx } from "./tx.ts";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The oldest aikoql-mcp server this SDK will talk to (the ND-12 version
|
|
30
|
+
* contract, mirrored from the Go, Python and Rust SDKs).
|
|
31
|
+
*/
|
|
32
|
+
export const MIN_SERVER_VERSION = "0.2.4";
|
|
33
|
+
|
|
34
|
+
/** The SDK's own package version, advertised as the client identity:
|
|
35
|
+
* read from package.json so the manifest, not a literal, owns it. */
|
|
36
|
+
function packageVersion(): string {
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version?: string };
|
|
39
|
+
return pkg.version ?? "dev";
|
|
40
|
+
} catch {
|
|
41
|
+
return "dev"; // vendored without the manifest
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const CLIENT_VERSION = packageVersion();
|
|
45
|
+
|
|
46
|
+
const DIAL_TIMEOUT_MS = 5000;
|
|
47
|
+
|
|
48
|
+
interface ClientConfig {
|
|
49
|
+
token?: string;
|
|
50
|
+
name: string;
|
|
51
|
+
version: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Mirrors the Python SDK's int(seg) exactly (the Go Atoi agrees on the
|
|
55
|
+
* canonical cases): only an optionally-signed decimal integer segment is
|
|
56
|
+
* numeric — Number("") would read "" as 0 and "0x10" as 16 where int()
|
|
57
|
+
* refuses (base 10 by default) — everything else becomes -1 (never >=). */
|
|
58
|
+
export function parseVersion(v: string): number[] {
|
|
59
|
+
return v.split(".").map((seg) => {
|
|
60
|
+
const s = seg.trim();
|
|
61
|
+
return /^[+-]?\d+$/.test(s) ? Number(s) : -1;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Compares two dotted version tuples segment by segment. */
|
|
66
|
+
export function versionLess(a: number[], b: number[]): boolean {
|
|
67
|
+
for (let i = 0; i < Math.min(a.length, b.length); i++) {
|
|
68
|
+
if (a[i] !== b[i]) return a[i]! < b[i]!;
|
|
69
|
+
}
|
|
70
|
+
return a.length < b.length;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** An RPC-level error keeps only code/message; codes normalize to their
|
|
74
|
+
* string form (string-encoded and numeric codes both occur). */
|
|
75
|
+
export function rpcError(e: { code?: unknown; message?: string }): McpError {
|
|
76
|
+
const code = e.code === undefined || e.code === null ? "" : String(e.code);
|
|
77
|
+
return new McpError(code === "" ? "INTERNAL" : code, e.message ?? "", false, "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The frozen §3.3 correlation rules, restated: a smaller id is skipped
|
|
81
|
+
* (id-less, notification, duplicate, or late — never an error), a larger
|
|
82
|
+
* id is PROTOCOL_ERROR, equal ids match. */
|
|
83
|
+
export function classifyID(want: number, got: number): "skip" | "match" | "protocol" {
|
|
84
|
+
if (got < want) return "skip";
|
|
85
|
+
if (got > want) return "protocol";
|
|
86
|
+
return "match";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The stream loop's notify verdict primitive: the method gate, params
|
|
90
|
+
* extraction and stream-id filter in one place (the aikoqlStream loop and
|
|
91
|
+
* the §16 corpus pin share it). */
|
|
92
|
+
export function streamNotify(frame: Record<string, unknown>, streamId: string): { stream_id?: string; done?: boolean } | null {
|
|
93
|
+
if (frame["method"] !== "notifications/notify") return null;
|
|
94
|
+
const p = (frame["params"] ?? null) as {
|
|
95
|
+
stream_id?: string;
|
|
96
|
+
done?: boolean;
|
|
97
|
+
} | null;
|
|
98
|
+
if (p === null || p.stream_id !== streamId) return null;
|
|
99
|
+
return p;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One wire line as a JSON object, or undefined for noise: not JSON, or
|
|
103
|
+
* JSON that is not an object (null, a number, a string, an array) — never
|
|
104
|
+
* a response, never a notify. */
|
|
105
|
+
function parseFrame(line: string): Record<string, unknown> | undefined {
|
|
106
|
+
let v: unknown;
|
|
107
|
+
try {
|
|
108
|
+
v = JSON.parse(line);
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One MCP JSON-RPC connection to an aikoql-mcp server. One in-flight call
|
|
117
|
+
* per connection (a stream holds the connection for its whole life); calls
|
|
118
|
+
* serialize over a promise chain, the single-threaded stand-in for the
|
|
119
|
+
* Rust client's mutex.
|
|
120
|
+
*/
|
|
121
|
+
export class Client {
|
|
122
|
+
private transport: Transport;
|
|
123
|
+
private nextId = 0;
|
|
124
|
+
private closed = false;
|
|
125
|
+
private cfg: ClientConfig = { name: "aikoql-ts-sdk", version: CLIENT_VERSION };
|
|
126
|
+
private chain: Promise<unknown> = Promise.resolve();
|
|
127
|
+
// The open stream's release (null when none): close() cancels a
|
|
128
|
+
// mid-stream close through it (§17).
|
|
129
|
+
private streamGate: (() => void) | null = null;
|
|
130
|
+
// The TCP address dialed (undefined for other transports) — moved by a
|
|
131
|
+
// NOT_LEADER leader hint (MRFC-0080 R5b).
|
|
132
|
+
private addr: string | undefined;
|
|
133
|
+
// The TLS options a leader hint is re-dialed with too (none: plaintext).
|
|
134
|
+
private tls: ConnectionOptions | undefined;
|
|
135
|
+
|
|
136
|
+
private constructor(transport: Transport, addr?: string, tlsOptions?: ConnectionOptions) {
|
|
137
|
+
this.transport = transport;
|
|
138
|
+
this.addr = addr;
|
|
139
|
+
this.tls = tlsOptions;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Opens a TCP connection ("host:port"). The handshake is separate
|
|
143
|
+
* (initialize) so a pooled client can dial first and authenticate later;
|
|
144
|
+
* every aikoql-mcp TCP server requires the token. `opts.tls` (Node's
|
|
145
|
+
* tls.ConnectionOptions — e.g. `{ ca }` for a private CA; `{}` for the
|
|
146
|
+
* system roots) dials a server started with --tls-cert/--tls-key. */
|
|
147
|
+
static async dial(addr: string, opts: { tls?: ConnectionOptions } = {}): Promise<Client> {
|
|
148
|
+
const transport = await TcpTransport.connect(addr, DIAL_TIMEOUT_MS, opts.tls);
|
|
149
|
+
return new Client(transport, addr, opts.tls);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Sends the --tcp-token credential in the initialize handshake
|
|
153
|
+
* (required by every TCP server since P3-M1). */
|
|
154
|
+
withToken(token: string): this {
|
|
155
|
+
this.cfg.token = token;
|
|
156
|
+
return this;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Sets the MCP client identity advertised at initialize. */
|
|
160
|
+
withClientInfo(name: string, version: string): this {
|
|
161
|
+
this.cfg.name = name;
|
|
162
|
+
this.cfg.version = version;
|
|
163
|
+
return this;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Closes the connection. Safe to call more than once, and safe with an
|
|
167
|
+
* open stream: the stream gate is released first — otherwise close (and
|
|
168
|
+
* every call behind it) deadlocks on the chain (§17). */
|
|
169
|
+
async close(): Promise<void> {
|
|
170
|
+
this.closed = true; // refuse new calls before the gate is released
|
|
171
|
+
this.streamGate?.();
|
|
172
|
+
this.streamGate = null;
|
|
173
|
+
await this.withLock(async () => {
|
|
174
|
+
this.transport.close();
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Serializes one call over the connection (one in-flight at a time). */
|
|
179
|
+
private withLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
180
|
+
const run = this.chain.then(fn);
|
|
181
|
+
this.chain = run.then(
|
|
182
|
+
() => {},
|
|
183
|
+
() => {},
|
|
184
|
+
);
|
|
185
|
+
return run;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Sends one JSON-RPC request and returns its result frame, skipping
|
|
189
|
+
* pushed notifications by id correlation (§3.3). An aborted signal maps
|
|
190
|
+
* to the frozen retryable TIMEOUT; a late response afterwards is skipped
|
|
191
|
+
* by the next call (self-healing). */
|
|
192
|
+
private async request(
|
|
193
|
+
method: string,
|
|
194
|
+
params?: unknown,
|
|
195
|
+
signal?: AbortSignal,
|
|
196
|
+
): Promise<unknown> {
|
|
197
|
+
return this.withLock(async () => {
|
|
198
|
+
if (this.closed) throw McpError.unavailable();
|
|
199
|
+
const id = ++this.nextId;
|
|
200
|
+
const frame: Record<string, unknown> = { jsonrpc: "2.0", id, method };
|
|
201
|
+
if (params !== undefined) frame.params = params;
|
|
202
|
+
this.transport.write(JSON.stringify(frame));
|
|
203
|
+
for (;;) {
|
|
204
|
+
let line: string | null;
|
|
205
|
+
try {
|
|
206
|
+
line = await this.transport.readLine(signal);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// FRAME_TOO_LARGE poisons the stream — latch before rethrowing.
|
|
209
|
+
if (e instanceof McpError && e.code === "FRAME_TOO_LARGE") this.closed = true;
|
|
210
|
+
throw e;
|
|
211
|
+
}
|
|
212
|
+
if (line === null) {
|
|
213
|
+
// The server closed (or half-closed): latch — later calls fail
|
|
214
|
+
// fast with UNAVAILABLE instead of dialing a dead socket.
|
|
215
|
+
this.closed = true;
|
|
216
|
+
throw McpError.io("connection closed by the server");
|
|
217
|
+
}
|
|
218
|
+
const resp = parseFrame(line);
|
|
219
|
+
if (resp === undefined) continue; // non-JSON or non-object noise
|
|
220
|
+
const rid = resp["id"] ?? 0;
|
|
221
|
+
if (typeof rid !== "number") continue; // not our numeric correlation
|
|
222
|
+
switch (classifyID(id, rid)) {
|
|
223
|
+
case "skip":
|
|
224
|
+
continue; // id-less, notification, duplicate, or late — never an error
|
|
225
|
+
case "protocol":
|
|
226
|
+
throw McpError.protocolError(id, rid);
|
|
227
|
+
case "match":
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
if (resp["error"] !== undefined) {
|
|
231
|
+
throw rpcError(resp["error"] as { code?: unknown; message?: string });
|
|
232
|
+
}
|
|
233
|
+
return resp["result"] ?? null;
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Performs the MCP handshake (protocol version, client info, token)
|
|
239
|
+
* and enforces the ND-12 version contract: a server older than
|
|
240
|
+
* MIN_SERVER_VERSION fails fast with VERSION_MISMATCH. */
|
|
241
|
+
async initialize(opts: { signal?: AbortSignal } = {}): Promise<void> {
|
|
242
|
+
const params: Record<string, unknown> = {
|
|
243
|
+
protocolVersion: "2024-11-05",
|
|
244
|
+
capabilities: {},
|
|
245
|
+
clientInfo: { name: this.cfg.name, version: this.cfg.version },
|
|
246
|
+
};
|
|
247
|
+
if (this.cfg.token !== undefined) params.token = this.cfg.token;
|
|
248
|
+
const raw = (await this.request("initialize", params, opts.signal)) as {
|
|
249
|
+
serverInfo?: { version?: string };
|
|
250
|
+
};
|
|
251
|
+
const server = raw?.serverInfo?.version ?? "";
|
|
252
|
+
if (versionLess(parseVersion(server), parseVersion(MIN_SERVER_VERSION))) {
|
|
253
|
+
throw McpError.versionMismatch(server);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Establishes session identity (MRFC-0040); subsequent calls inherit
|
|
258
|
+
* it. On TCP the identity is server-assigned by --tcp-token, so
|
|
259
|
+
* agent_id must be omitted there — only run_id is per-session. */
|
|
260
|
+
async sessionInit(p: SessionParams): Promise<void> {
|
|
261
|
+
await this.request("session/init", p);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Calls any registered MCP tool by name and returns its data payload —
|
|
265
|
+
* the escape hatch for tools without a typed wrapper here.
|
|
266
|
+
*
|
|
267
|
+
* MRFC-0080 R5b: refused with NOT_LEADER and a leader hint, a TCP
|
|
268
|
+
* client reconnects to that leader, re-runs initialize and retries the
|
|
269
|
+
* call once; it then stays on the leader. sessionInit identity is not
|
|
270
|
+
* replayed. */
|
|
271
|
+
async callTool(
|
|
272
|
+
name: string,
|
|
273
|
+
args?: unknown,
|
|
274
|
+
opts: { signal?: AbortSignal } = {},
|
|
275
|
+
): Promise<unknown> {
|
|
276
|
+
try {
|
|
277
|
+
return await this.callToolOnce(name, args, opts);
|
|
278
|
+
} catch (e) {
|
|
279
|
+
if (!(e instanceof McpError) || e.code !== "NOT_LEADER" || !e.leader || this.addr === undefined) throw e;
|
|
280
|
+
await this.follow(e.leader, opts.signal);
|
|
281
|
+
return this.callToolOnce(name, args, opts);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Replaces the connection with one to the leader at `addr` and
|
|
286
|
+
* re-runs the handshake. */
|
|
287
|
+
private async follow(addr: string, signal?: AbortSignal): Promise<void> {
|
|
288
|
+
const next = await TcpTransport.connect(addr, DIAL_TIMEOUT_MS, this.tls);
|
|
289
|
+
await this.withLock(async () => {
|
|
290
|
+
this.transport.close();
|
|
291
|
+
this.transport = next;
|
|
292
|
+
this.addr = addr;
|
|
293
|
+
});
|
|
294
|
+
await this.initialize({ signal });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private async callToolOnce(
|
|
298
|
+
name: string,
|
|
299
|
+
args?: unknown,
|
|
300
|
+
opts: { signal?: AbortSignal } = {},
|
|
301
|
+
): Promise<unknown> {
|
|
302
|
+
const params: Record<string, unknown> = { name };
|
|
303
|
+
if (args !== undefined) params.arguments = args;
|
|
304
|
+
const raw = (await this.request("tools/call", params, opts.signal)) as {
|
|
305
|
+
content?: Array<{ text?: string }>;
|
|
306
|
+
};
|
|
307
|
+
const text = raw?.content?.[0]?.text ?? "";
|
|
308
|
+
let payload: Record<string, unknown>;
|
|
309
|
+
try {
|
|
310
|
+
payload = JSON.parse(text) as Record<string, unknown>;
|
|
311
|
+
} catch (e) {
|
|
312
|
+
throw McpError.json(String(e));
|
|
313
|
+
}
|
|
314
|
+
// Mirrors the Python SDK: an absent "ok" means success; the "data"
|
|
315
|
+
// field, when present, wraps the payload.
|
|
316
|
+
if (payload["ok"] === false) {
|
|
317
|
+
if (payload["error"] !== undefined) {
|
|
318
|
+
const e = payload["error"] as {
|
|
319
|
+
code?: string;
|
|
320
|
+
message?: string;
|
|
321
|
+
retryable?: boolean;
|
|
322
|
+
suggestion?: string;
|
|
323
|
+
leader?: string;
|
|
324
|
+
};
|
|
325
|
+
throw new McpError(
|
|
326
|
+
e.code ?? "INTERNAL",
|
|
327
|
+
e.message ?? "",
|
|
328
|
+
e.retryable ?? false,
|
|
329
|
+
e.suggestion ?? "",
|
|
330
|
+
e.leader,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
throw new McpError(
|
|
334
|
+
"INTERNAL",
|
|
335
|
+
`tool ${name} failed without an error envelope`,
|
|
336
|
+
false,
|
|
337
|
+
"",
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
if (payload["data"] !== undefined) return payload["data"];
|
|
341
|
+
return payload;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Runs a streaming query. Yields the response frame (the first data
|
|
345
|
+
* chunk — for total_chunks == 1 there is no notify at all), then each
|
|
346
|
+
* notify chunk until its done flag. Returning from the iterator cancels
|
|
347
|
+
* the read: the connection is released for the next call. The Client
|
|
348
|
+
* must not be shared while the stream is open (calls serialize on the
|
|
349
|
+
* same chain) — the Go SDK's caveat, inherited. */
|
|
350
|
+
async *queryStream(
|
|
351
|
+
query: string,
|
|
352
|
+
subject: string,
|
|
353
|
+
opts: { signal?: AbortSignal } = {},
|
|
354
|
+
): AsyncGenerator<unknown, void, unknown> {
|
|
355
|
+
const params: Record<string, unknown> = { query };
|
|
356
|
+
if (subject !== "") params.subject = subject;
|
|
357
|
+
// The stream holds the connection lock for its whole life: wait for
|
|
358
|
+
// the previous holder, then gate the next caller on our return.
|
|
359
|
+
let release!: () => void;
|
|
360
|
+
const gate = new Promise<void>((r) => {
|
|
361
|
+
release = r;
|
|
362
|
+
});
|
|
363
|
+
const prev = this.chain;
|
|
364
|
+
this.chain = prev.then(() => gate);
|
|
365
|
+
// Registered so close() can cancel a mid-stream close (§17); the
|
|
366
|
+
// generator's finally clears it (identity-checked against a newer
|
|
367
|
+
// stream's gate).
|
|
368
|
+
this.streamGate = release;
|
|
369
|
+
await prev;
|
|
370
|
+
try {
|
|
371
|
+
if (this.closed) throw McpError.unavailable();
|
|
372
|
+
const id = ++this.nextId;
|
|
373
|
+
this.transport.write(
|
|
374
|
+
JSON.stringify({ jsonrpc: "2.0", id, method: "aikoql/stream", params }),
|
|
375
|
+
);
|
|
376
|
+
let streamId: string | null = null;
|
|
377
|
+
let total = 0;
|
|
378
|
+
let received = 0;
|
|
379
|
+
for (;;) {
|
|
380
|
+
let line: string | null;
|
|
381
|
+
try {
|
|
382
|
+
line = await this.transport.readLine(opts.signal);
|
|
383
|
+
} catch (e) {
|
|
384
|
+
if (e instanceof McpError && e.code === "FRAME_TOO_LARGE") this.closed = true;
|
|
385
|
+
throw e;
|
|
386
|
+
}
|
|
387
|
+
if (line === null) {
|
|
388
|
+
this.closed = true;
|
|
389
|
+
throw McpError.io("connection closed by the server");
|
|
390
|
+
}
|
|
391
|
+
const resp = parseFrame(line);
|
|
392
|
+
if (resp === undefined) continue; // non-JSON or non-object noise
|
|
393
|
+
const rid = resp["id"] ?? 0;
|
|
394
|
+
if (typeof rid === "number" && rid === id) {
|
|
395
|
+
if (resp["error"] !== undefined) {
|
|
396
|
+
throw rpcError(resp["error"] as { code?: unknown; message?: string });
|
|
397
|
+
}
|
|
398
|
+
const head = (resp["result"] ?? {}) as {
|
|
399
|
+
stream_id?: string;
|
|
400
|
+
total_chunks?: number;
|
|
401
|
+
};
|
|
402
|
+
streamId = head.stream_id ?? null;
|
|
403
|
+
total = head.total_chunks ?? 0;
|
|
404
|
+
// The response frame IS the first chunk (it carries the data;
|
|
405
|
+
// for total_chunks == 1 there is no notify at all) — the Go,
|
|
406
|
+
// Python and Rust SDKs yield it too.
|
|
407
|
+
yield resp["result"] ?? null;
|
|
408
|
+
received += 1;
|
|
409
|
+
if (total > 0 && received >= total) return;
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (streamId === null) continue; // push before the response frame
|
|
413
|
+
const p = streamNotify(resp, streamId);
|
|
414
|
+
if (p === null) continue;
|
|
415
|
+
yield p;
|
|
416
|
+
received += 1;
|
|
417
|
+
if (p.done === true || (total > 0 && received >= total)) {
|
|
418
|
+
return; // the Go exit condition: done, or received == total_chunks
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
} finally {
|
|
422
|
+
if (this.streamGate === release) this.streamGate = null;
|
|
423
|
+
this.transport.cancelPendingRead();
|
|
424
|
+
release();
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// — Typed wrappers — the canonical Database API surface over the MCP
|
|
429
|
+
// tools (crates/sdk/go/tools.go is the schema source).
|
|
430
|
+
|
|
431
|
+
/** Commits a knowledge object (or a new version of one). */
|
|
432
|
+
async remember(p: RememberParams): Promise<Remembered> {
|
|
433
|
+
const raw = await this.callTool("remember", p);
|
|
434
|
+
return raw as Remembered;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Fetches a knowledge object by KOID. */
|
|
438
|
+
async get(koid: string, subject: string): Promise<KnowledgeObject> {
|
|
439
|
+
const args: Record<string, unknown> = { koid };
|
|
440
|
+
if (subject !== "") args.subject = subject;
|
|
441
|
+
const raw = await this.callTool("get", args);
|
|
442
|
+
return raw as KnowledgeObject;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Tombstones ("tombstone") or legally erases ("erase") a knowledge
|
|
446
|
+
* object, audit-preserving. */
|
|
447
|
+
async forget(koid: string, mode: string, subject: string): Promise<unknown> {
|
|
448
|
+
const args: Record<string, unknown> = { koid, mode };
|
|
449
|
+
if (subject !== "") args.subject = subject;
|
|
450
|
+
return this.callTool("forget", args);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Runs hybrid recall and returns the scored hits. */
|
|
454
|
+
async findSimilar(p: FindSimilarParams): Promise<ScoredKO[]> {
|
|
455
|
+
const raw = (await this.callTool("find_similar", p)) as { results?: ScoredKO[] };
|
|
456
|
+
return raw.results ?? [];
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Runs an AikoQL query and returns the raw result rows. */
|
|
460
|
+
async aikoql(query: string, subject: string): Promise<unknown> {
|
|
461
|
+
const args: Record<string, unknown> = { query };
|
|
462
|
+
if (subject !== "") args.subject = subject;
|
|
463
|
+
return this.callTool("aikoql", args);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Links two knowledge objects. */
|
|
467
|
+
async relate(
|
|
468
|
+
fromKoid: string,
|
|
469
|
+
toKoid: string,
|
|
470
|
+
relType: string,
|
|
471
|
+
subject: string,
|
|
472
|
+
): Promise<unknown> {
|
|
473
|
+
const args: Record<string, unknown> = { from: fromKoid, to: toKoid, rel_type: relType };
|
|
474
|
+
if (subject !== "") args.subject = subject;
|
|
475
|
+
return this.callTool("relate", args);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Walks the relationship graph from a KOID. */
|
|
479
|
+
async traverse(
|
|
480
|
+
koid: string,
|
|
481
|
+
relType: string,
|
|
482
|
+
subject: string,
|
|
483
|
+
depth: number,
|
|
484
|
+
): Promise<unknown> {
|
|
485
|
+
const args: Record<string, unknown> = { koid, depth };
|
|
486
|
+
if (relType !== "") args.rel_type = relType;
|
|
487
|
+
if (subject !== "") args.subject = subject;
|
|
488
|
+
return this.callTool("traverse", args);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Returns the server health payload. */
|
|
492
|
+
async health(): Promise<unknown> {
|
|
493
|
+
return this.callTool("health");
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Returns the schema-discovery payload. */
|
|
497
|
+
async discoverSchema(): Promise<unknown> {
|
|
498
|
+
return this.callTool("discover_schema");
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Returns the server's metrics. */
|
|
502
|
+
async metrics(): Promise<Metrics> {
|
|
503
|
+
const raw = await this.callTool("metrics");
|
|
504
|
+
return raw as Metrics;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Returns the full lineage of a fact (versions + events). */
|
|
508
|
+
async trace(koid: string, subject: string): Promise<unknown> {
|
|
509
|
+
const args: Record<string, unknown> = { koid };
|
|
510
|
+
if (subject !== "") args.subject = subject;
|
|
511
|
+
return this.callTool("trace", args);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Returns the explanation payload for a KO version. */
|
|
515
|
+
async explain(koid: string, subject: string, version?: number): Promise<unknown> {
|
|
516
|
+
const args: Record<string, unknown> = { koid };
|
|
517
|
+
if (version !== undefined) args.version = version;
|
|
518
|
+
if (subject !== "") args.subject = subject;
|
|
519
|
+
return this.callTool("explain", args);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Opens a transaction. With no id a random 32-hex id is generated;
|
|
523
|
+
* passing one retries the same begin idempotently (P5-M20). */
|
|
524
|
+
async begin(txnId?: string): Promise<Tx> {
|
|
525
|
+
const id = txnId ?? randomBytes(16).toString("hex");
|
|
526
|
+
await this.callTool("txn_begin", { txn_id: id });
|
|
527
|
+
return new Tx(this, id);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Runs `fn` under a response deadline: elapsed → the frozen retryable
|
|
532
|
+
* TIMEOUT. A late response afterwards is harmless — id correlation skips
|
|
533
|
+
* it on the next call (self-healing). */
|
|
534
|
+
export async function withDeadline<T>(
|
|
535
|
+
ms: number,
|
|
536
|
+
fn: (signal: AbortSignal) => Promise<T>,
|
|
537
|
+
): Promise<T> {
|
|
538
|
+
const ctrl = new AbortController();
|
|
539
|
+
const timer = setTimeout(() => ctrl.abort(), ms);
|
|
540
|
+
try {
|
|
541
|
+
return await fn(ctrl.signal);
|
|
542
|
+
} catch (e) {
|
|
543
|
+
if (ctrl.signal.aborted) throw McpError.deadline();
|
|
544
|
+
throw e;
|
|
545
|
+
} finally {
|
|
546
|
+
clearTimeout(timer);
|
|
547
|
+
}
|
|
548
|
+
}
|