@nanobpm/agentic 0.1.0 → 0.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/README.md +1 -0
- package/dist/demand/model.d.ts +7 -4
- package/dist/demand/model.js +22 -4
- package/dist/demand/taskdef.d.ts +13 -1
- package/dist/demand/taskdef.js +20 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol/conformance/frames.js +32 -4
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/payloads.d.ts +44 -0
- package/dist/protocol/payloads.js +61 -7
- package/dist/session/acp/client.d.ts +109 -0
- package/dist/session/acp/client.js +254 -0
- package/dist/session/acp/index.d.ts +27 -0
- package/dist/session/acp/index.js +27 -0
- package/dist/session/acp/jsonrpc.d.ts +25 -0
- package/dist/session/acp/jsonrpc.js +148 -0
- package/dist/session/acp/normalize.d.ts +48 -0
- package/dist/session/acp/normalize.js +162 -0
- package/dist/session/acp/protocol.d.ts +94 -0
- package/dist/session/acp/protocol.js +136 -0
- package/dist/session/acp/spawn.d.ts +36 -0
- package/dist/session/acp/spawn.js +68 -0
- package/dist/session/acp/transport.d.ts +62 -0
- package/dist/session/acp/transport.js +126 -0
- package/dist/session/adapter.d.ts +135 -0
- package/dist/session/adapter.js +24 -0
- package/dist/session/backend.d.ts +43 -0
- package/dist/session/backend.js +95 -0
- package/dist/session/events.d.ts +152 -0
- package/dist/session/events.js +192 -0
- package/dist/session/index.d.ts +31 -0
- package/dist/session/index.js +5 -0
- package/dist/session/log.d.ts +107 -0
- package/dist/session/log.js +351 -0
- package/dist/session/normalizer/claude.d.ts +23 -0
- package/dist/session/normalizer/claude.js +138 -0
- package/dist/session/normalizer/copilot.d.ts +27 -0
- package/dist/session/normalizer/copilot.js +105 -0
- package/dist/session/normalizer/deepseek.d.ts +11 -0
- package/dist/session/normalizer/deepseek.js +68 -0
- package/dist/session/normalizer/index.d.ts +36 -0
- package/dist/session/normalizer/index.js +29 -0
- package/dist/session/normalizer/kimi.d.ts +10 -0
- package/dist/session/normalizer/kimi.js +80 -0
- package/dist/session/normalizer/link.d.ts +36 -0
- package/dist/session/normalizer/link.js +56 -0
- package/dist/session/normalizer/pi.d.ts +13 -0
- package/dist/session/normalizer/pi.js +61 -0
- package/dist/session/normalizer/qwen.d.ts +11 -0
- package/dist/session/normalizer/qwen.js +65 -0
- package/dist/session/normalizer/record.d.ts +21 -0
- package/dist/session/normalizer/record.js +87 -0
- package/dist/session/normalizer/types.d.ts +139 -0
- package/dist/session/normalizer/types.js +31 -0
- package/dist/session/schema.d.ts +38 -0
- package/dist/session/schema.js +74 -0
- package/package.json +17 -1
- package/src/demand/model.test.ts +82 -4
- package/src/demand/model.ts +30 -9
- package/src/demand/taskdef.test.ts +51 -6
- package/src/demand/taskdef.ts +31 -2
- package/src/index.ts +1 -0
- package/src/protocol/conformance/frames.ts +32 -4
- package/src/protocol/index.ts +4 -0
- package/src/protocol/payloads.test.ts +31 -1
- package/src/protocol/payloads.ts +110 -7
- package/src/session/acp/client.test.ts +222 -0
- package/src/session/acp/client.ts +356 -0
- package/src/session/acp/fake-agent.ts +71 -0
- package/src/session/acp/index.ts +68 -0
- package/src/session/acp/integration.test.ts +37 -0
- package/src/session/acp/jsonrpc.test.ts +75 -0
- package/src/session/acp/jsonrpc.ts +171 -0
- package/src/session/acp/normalize.test.ts +150 -0
- package/src/session/acp/normalize.ts +204 -0
- package/src/session/acp/protocol.ts +178 -0
- package/src/session/acp/spawn.test.ts +45 -0
- package/src/session/acp/spawn.ts +91 -0
- package/src/session/acp/transport.test.ts +82 -0
- package/src/session/acp/transport.ts +155 -0
- package/src/session/adapter.ts +159 -0
- package/src/session/backend.test.ts +198 -0
- package/src/session/backend.ts +128 -0
- package/src/session/events.test.ts +168 -0
- package/src/session/events.ts +347 -0
- package/src/session/index.ts +67 -0
- package/src/session/log.test.ts +215 -0
- package/src/session/log.ts +525 -0
- package/src/session/normalizer/backend-integration.test.ts +103 -0
- package/src/session/normalizer/claude.test.ts +68 -0
- package/src/session/normalizer/claude.ts +136 -0
- package/src/session/normalizer/copilot.test.ts +59 -0
- package/src/session/normalizer/copilot.ts +133 -0
- package/src/session/normalizer/deepseek.ts +80 -0
- package/src/session/normalizer/index.ts +61 -0
- package/src/session/normalizer/kimi.ts +82 -0
- package/src/session/normalizer/link.test.ts +24 -0
- package/src/session/normalizer/link.ts +81 -0
- package/src/session/normalizer/pi.ts +75 -0
- package/src/session/normalizer/probe.test.ts +49 -0
- package/src/session/normalizer/qwen.test.ts +20 -0
- package/src/session/normalizer/qwen.ts +77 -0
- package/src/session/normalizer/record.test.ts +68 -0
- package/src/session/normalizer/record.ts +88 -0
- package/src/session/normalizer/resume.test.ts +25 -0
- package/src/session/normalizer/types.ts +152 -0
- package/src/session/normalizer/vectors.test.ts +180 -0
- package/src/session/schema.test.ts +84 -0
- package/src/session/schema.ts +78 -0
- package/src/session/test-db.ts +56 -0
- package/dist/blackboard/test-db.d.ts +0 -5
- package/dist/blackboard/test-db.js +0 -42
- package/dist/presence/test-db.d.ts +0 -5
- package/dist/presence/test-db.js +0 -42
- package/dist/transcript/test-db.d.ts +0 -5
- package/dist/transcript/test-db.js +0 -41
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi / little-coder normalizer — ADR 0062 slice 3.
|
|
3
|
+
*
|
|
4
|
+
* Driven with `pi -p --mode json` (or `--mode rpc`): a JSON-RPC 2.0 notification
|
|
5
|
+
* stream. Session content arrives as `method` notifications
|
|
6
|
+
* (`session/message`, `session/reasoning`, `session/toolCall`,
|
|
7
|
+
* `session/toolResult`, `session/usage`) with a `params` payload. Restore is
|
|
8
|
+
* `--session-id <id>` (create-if-missing — the same flag both resumes an
|
|
9
|
+
* existing session and starts one under a chosen id); `-r` is the short alias and
|
|
10
|
+
* `--fork` branches. Streaming + resume-by-id → `durable-resume`.
|
|
11
|
+
*/
|
|
12
|
+
import type { DraftEvent, HarnessNormalizer, ResumeShim } from "./types.ts";
|
|
13
|
+
import { asRecord, isRecord, optNumber, optString, reqString } from "./record.ts";
|
|
14
|
+
|
|
15
|
+
const HARNESS = "pi";
|
|
16
|
+
|
|
17
|
+
function params(obj: Record<string, unknown>): Record<string, unknown> {
|
|
18
|
+
return isRecord(obj.params) ? obj.params : {};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function toDrafts(record: unknown): readonly DraftEvent[] {
|
|
22
|
+
const obj = asRecord(HARNESS, record);
|
|
23
|
+
const method = obj.method;
|
|
24
|
+
if (typeof method !== "string") return []; // a JSON-RPC result/ack, not a notification
|
|
25
|
+
const p = params(obj);
|
|
26
|
+
switch (method) {
|
|
27
|
+
case "session/message": {
|
|
28
|
+
const text = optString(HARNESS, p, "text");
|
|
29
|
+
if (text === undefined || text.length === 0) return [];
|
|
30
|
+
const role = optString(HARNESS, p, "role");
|
|
31
|
+
return [{ type: role === "user" ? "user" : role === "system" ? "system" : "assistant", text }];
|
|
32
|
+
}
|
|
33
|
+
case "session/reasoning": {
|
|
34
|
+
const text = optString(HARNESS, p, "text");
|
|
35
|
+
const providerContinuation = optString(HARNESS, p, "continuation");
|
|
36
|
+
return [
|
|
37
|
+
{
|
|
38
|
+
type: "reasoning",
|
|
39
|
+
...(text !== undefined ? { text } : {}),
|
|
40
|
+
...(providerContinuation !== undefined ? { providerContinuation } : {}),
|
|
41
|
+
},
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
case "session/toolCall": {
|
|
45
|
+
const callId = reqString(HARNESS, p, "id");
|
|
46
|
+
return [{ type: "tool-call", id: `call:${callId}`, callId, name: reqString(HARNESS, p, "name"), args: p.args }];
|
|
47
|
+
}
|
|
48
|
+
case "session/toolResult": {
|
|
49
|
+
const callId = reqString(HARNESS, p, "id");
|
|
50
|
+
return [{ type: "tool-result", id: `result:${callId}`, callId, ok: p.ok !== false, result: p.result }];
|
|
51
|
+
}
|
|
52
|
+
case "session/usage":
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
type: "usage",
|
|
56
|
+
inputTokens: optNumber(HARNESS, p, "inputTokens") ?? 0,
|
|
57
|
+
outputTokens: optNumber(HARNESS, p, "outputTokens") ?? 0,
|
|
58
|
+
...(typeof p.model === "string" ? { model: p.model } : {}),
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
default:
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const piNormalizer: HarnessNormalizer = {
|
|
67
|
+
harness: HARNESS,
|
|
68
|
+
capabilities: { streaming: true, resumeById: true },
|
|
69
|
+
toDrafts,
|
|
70
|
+
resume(sessionId: string): ResumeShim {
|
|
71
|
+
// create-if-missing: `--session-id <id>` resumes it when it exists and starts
|
|
72
|
+
// it under that id when it does not.
|
|
73
|
+
return { transport: "cli", sessionId, args: ["--session-id", sessionId] };
|
|
74
|
+
},
|
|
75
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { capabilityProbe, type HarnessNormalizer } from "./types.ts";
|
|
4
|
+
import { FLEET_NORMALIZERS, normalizerFor, probeFleet } from "./index.ts";
|
|
5
|
+
|
|
6
|
+
function syntheticNormalizer(streaming: boolean, resumeById: boolean): HarnessNormalizer {
|
|
7
|
+
return {
|
|
8
|
+
harness: `synthetic(${streaming},${resumeById})`,
|
|
9
|
+
capabilities: { streaming, resumeById },
|
|
10
|
+
toDrafts: () => [],
|
|
11
|
+
resume: (sessionId) => ({ transport: "cli", sessionId, args: [] }),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
test("durable-resume is the AND of streaming and resume-by-id (derived, not declared)", () => {
|
|
16
|
+
assert.equal(capabilityProbe(syntheticNormalizer(true, true)).durableResume, true);
|
|
17
|
+
assert.equal(capabilityProbe(syntheticNormalizer(true, false)).durableResume, false, "a stream we cannot resume is not durable");
|
|
18
|
+
assert.equal(capabilityProbe(syntheticNormalizer(false, true)).durableResume, false, "a resume with no mind to replay is not durable");
|
|
19
|
+
assert.equal(capabilityProbe(syntheticNormalizer(false, false)).durableResume, false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("the probe echoes the raw capabilities alongside the derived bit", () => {
|
|
23
|
+
const ad = capabilityProbe(syntheticNormalizer(true, false));
|
|
24
|
+
assert.deepEqual(ad, {
|
|
25
|
+
harness: "synthetic(true,false)",
|
|
26
|
+
streaming: true,
|
|
27
|
+
resumeById: false,
|
|
28
|
+
durableResume: false,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("every harness in the current fleet advertises durable-resume", () => {
|
|
33
|
+
const fleet = probeFleet();
|
|
34
|
+
assert.equal(fleet.length, FLEET_NORMALIZERS.length);
|
|
35
|
+
for (const ad of fleet) {
|
|
36
|
+
assert.equal(ad.durableResume, true, `${ad.harness} must advertise durable-resume`);
|
|
37
|
+
assert.equal(ad.streaming, true);
|
|
38
|
+
assert.equal(ad.resumeById, true);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("the fleet registry covers exactly the documented harnesses, keyed by id", () => {
|
|
43
|
+
const harnesses = probeFleet().map((a) => a.harness).sort();
|
|
44
|
+
assert.deepEqual(harnesses, ["@github/copilot", "claude-code", "deepseek", "kimi", "pi", "qwen-code"]);
|
|
45
|
+
for (const n of FLEET_NORMALIZERS) {
|
|
46
|
+
assert.equal(normalizerFor(n.harness), n, "registry lookup returns the same instance");
|
|
47
|
+
}
|
|
48
|
+
assert.equal(normalizerFor("not-a-harness"), undefined);
|
|
49
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { normalizeSession } from "./link.ts";
|
|
4
|
+
import { qwenNormalizer } from "./qwen.ts";
|
|
5
|
+
|
|
6
|
+
test("qwen maps a system-role content frame to a system event", () => {
|
|
7
|
+
const events = normalizeSession(qwenNormalizer, [{ type: "content", role: "system", text: "you are nano" }]);
|
|
8
|
+
assert.equal(events.length, 1);
|
|
9
|
+
assert.equal(events[0].type, "system");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("qwen keeps the assistant fallback for an unknown role", () => {
|
|
13
|
+
const events = normalizeSession(qwenNormalizer, [{ type: "content", role: "model", text: "here is the plan" }]);
|
|
14
|
+
assert.equal(events[0].type, "assistant");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("qwen maps a user-role content frame to a user event", () => {
|
|
18
|
+
const events = normalizeSession(qwenNormalizer, [{ type: "content", role: "user", text: "please refactor" }]);
|
|
19
|
+
assert.equal(events[0].type, "user");
|
|
20
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Qwen Code normalizer — ADR 0062 slice 3.
|
|
3
|
+
*
|
|
4
|
+
* Driven with `qwen -p -o stream-json`. Qwen Code descends from the Gemini CLI,
|
|
5
|
+
* so its streaming dialect speaks that lineage: `content` frames tagged with a
|
|
6
|
+
* `role`, `thought` frames for reasoning, and `tool_call_request` /
|
|
7
|
+
* `tool_call_response` pairs. Restore is `-r <id>` (`-c` continues the latest).
|
|
8
|
+
* Streaming + resume-by-id → `durable-resume`.
|
|
9
|
+
*/
|
|
10
|
+
import type { DraftEvent, HarnessNormalizer, ResumeShim } from "./types.ts";
|
|
11
|
+
import { asRecord, optNumber, optString, reqString } from "./record.ts";
|
|
12
|
+
|
|
13
|
+
const HARNESS = "qwen-code";
|
|
14
|
+
|
|
15
|
+
function toDrafts(record: unknown): readonly DraftEvent[] {
|
|
16
|
+
const obj = asRecord(HARNESS, record);
|
|
17
|
+
switch (obj.type) {
|
|
18
|
+
case "content": {
|
|
19
|
+
const role = optString(HARNESS, obj, "role");
|
|
20
|
+
const text = optString(HARNESS, obj, "text");
|
|
21
|
+
if (text === undefined || text.length === 0) return [];
|
|
22
|
+
return [{ type: role === "user" ? "user" : role === "system" ? "system" : "assistant", text }];
|
|
23
|
+
}
|
|
24
|
+
case "thought": {
|
|
25
|
+
// Gemini-lineage reasoning: a bold `subject` + `description` body.
|
|
26
|
+
const subject = optString(HARNESS, obj, "subject");
|
|
27
|
+
const description = optString(HARNESS, obj, "description");
|
|
28
|
+
const text = [subject, description].filter((s): s is string => s !== undefined && s.length > 0).join(": ");
|
|
29
|
+
const providerContinuation = optString(HARNESS, obj, "thoughtSignature");
|
|
30
|
+
return [
|
|
31
|
+
{
|
|
32
|
+
type: "reasoning",
|
|
33
|
+
...(text.length > 0 ? { text } : {}),
|
|
34
|
+
...(providerContinuation !== undefined ? { providerContinuation } : {}),
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
}
|
|
38
|
+
case "tool_call_request": {
|
|
39
|
+
const callId = reqString(HARNESS, obj, "callId");
|
|
40
|
+
return [
|
|
41
|
+
{ type: "tool-call", id: `call:${callId}`, callId, name: reqString(HARNESS, obj, "name"), args: obj.args },
|
|
42
|
+
];
|
|
43
|
+
}
|
|
44
|
+
case "tool_call_response": {
|
|
45
|
+
const callId = reqString(HARNESS, obj, "callId");
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
type: "tool-result",
|
|
49
|
+
id: `result:${callId}`,
|
|
50
|
+
callId,
|
|
51
|
+
ok: obj.error == null,
|
|
52
|
+
result: obj.responseParts ?? obj.error,
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
case "usage_metadata":
|
|
57
|
+
return [
|
|
58
|
+
{
|
|
59
|
+
type: "usage",
|
|
60
|
+
inputTokens: optNumber(HARNESS, obj, "promptTokenCount") ?? 0,
|
|
61
|
+
outputTokens: optNumber(HARNESS, obj, "candidatesTokenCount") ?? 0,
|
|
62
|
+
...(typeof obj.model === "string" ? { model: obj.model } : {}),
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
default:
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const qwenNormalizer: HarnessNormalizer = {
|
|
71
|
+
harness: HARNESS,
|
|
72
|
+
capabilities: { streaming: true, resumeById: true },
|
|
73
|
+
toDrafts,
|
|
74
|
+
resume(sessionId: string): ResumeShim {
|
|
75
|
+
return { transport: "cli", sessionId, args: ["-r", sessionId] };
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { asArray, asRecord, optNumber, optString, reqString } from "./record.ts";
|
|
4
|
+
import { NormalizerDialectError } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
test("asRecord reports an array distinctly from a plain object", () => {
|
|
7
|
+
assert.throws(
|
|
8
|
+
() => asRecord("h", [1, 2, 3]),
|
|
9
|
+
(err: unknown) => err instanceof NormalizerDialectError && /got array$/.test(err.message),
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("asRecord reports null distinctly", () => {
|
|
14
|
+
assert.throws(
|
|
15
|
+
() => asRecord("h", null),
|
|
16
|
+
(err: unknown) => err instanceof NormalizerDialectError && /got null$/.test(err.message),
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("asRecord reports a primitive by its typeof", () => {
|
|
21
|
+
assert.throws(
|
|
22
|
+
() => asRecord("h", 7),
|
|
23
|
+
(err: unknown) => err instanceof NormalizerDialectError && /got number$/.test(err.message),
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("asArray reports null distinctly from a plain object", () => {
|
|
28
|
+
assert.throws(
|
|
29
|
+
() => asArray("h", null, "message.content"),
|
|
30
|
+
(err: unknown) =>
|
|
31
|
+
err instanceof NormalizerDialectError && /message\.content must be an array, got null$/.test(err.message),
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("asArray reports a primitive by its typeof", () => {
|
|
36
|
+
assert.throws(
|
|
37
|
+
() => asArray("h", "x", "message.content"),
|
|
38
|
+
(err: unknown) => err instanceof NormalizerDialectError && /got string$/.test(err.message),
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("reqString reports null distinctly from a plain object", () => {
|
|
43
|
+
assert.throws(
|
|
44
|
+
() => reqString("h", { f: null }, "f"),
|
|
45
|
+
(err: unknown) => err instanceof NormalizerDialectError && /got null$/.test(err.message),
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("reqString reports an array distinctly from a plain object", () => {
|
|
50
|
+
assert.throws(
|
|
51
|
+
() => reqString("h", { f: [1] }, "f"),
|
|
52
|
+
(err: unknown) => err instanceof NormalizerDialectError && /got array$/.test(err.message),
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("optString reports an array distinctly (present but wrong type)", () => {
|
|
57
|
+
assert.throws(
|
|
58
|
+
() => optString("h", { f: [1] }, "f"),
|
|
59
|
+
(err: unknown) => err instanceof NormalizerDialectError && /got array$/.test(err.message),
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("optNumber reports an array distinctly (present but wrong type)", () => {
|
|
64
|
+
assert.throws(
|
|
65
|
+
() => optNumber("h", { f: [1] }, "f"),
|
|
66
|
+
(err: unknown) => err instanceof NormalizerDialectError && /got array$/.test(err.message),
|
|
67
|
+
);
|
|
68
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small untyped-record inspection helpers shared by the per-harness dialect maps
|
|
3
|
+
* (ADR 0062 slice 3). A native record is untyped input (a `JSON.parse`d
|
|
4
|
+
* `stream-json` line, an SDK event object), so every dialect narrows it the same
|
|
5
|
+
* way: is-it-an-object, read-a-string, read-an-optional-string. Centralising the
|
|
6
|
+
* narrowing keeps the dialects declarative and, crucially, keeps them free of
|
|
7
|
+
* `as`-casts (AGENTS.md) — they narrow through these guards instead.
|
|
8
|
+
*/
|
|
9
|
+
import { NormalizerDialectError } from "./types.ts";
|
|
10
|
+
|
|
11
|
+
/** True for a plain (non-array) object; the shape every native record must have. */
|
|
12
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
13
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Describe a value for a dialect error: distinguishes `null` and `array` from the bare `typeof`. */
|
|
17
|
+
function describeType(value: unknown): string {
|
|
18
|
+
if (value === null) return "null";
|
|
19
|
+
if (Array.isArray(value)) return "array";
|
|
20
|
+
return typeof value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Narrow to a record or throw a dialect error attributing the harness. */
|
|
24
|
+
export function asRecord(harness: string, value: unknown): Record<string, unknown> {
|
|
25
|
+
if (!isRecord(value)) {
|
|
26
|
+
throw new NormalizerDialectError(harness, `record must be a plain object, got ${describeType(value)}`);
|
|
27
|
+
}
|
|
28
|
+
return value;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read a required string field or throw a dialect error. */
|
|
32
|
+
export function reqString(harness: string, obj: Record<string, unknown>, field: string): string {
|
|
33
|
+
const v = obj[field];
|
|
34
|
+
if (typeof v !== "string") {
|
|
35
|
+
throw new NormalizerDialectError(harness, `field "${field}" must be a string, got ${describeType(v)}`);
|
|
36
|
+
}
|
|
37
|
+
return v;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Read an optional string field (undefined when absent), throwing on a wrong type. */
|
|
41
|
+
export function optString(harness: string, obj: Record<string, unknown>, field: string): string | undefined {
|
|
42
|
+
const v = obj[field];
|
|
43
|
+
if (v === undefined || v === null) return undefined;
|
|
44
|
+
if (typeof v !== "string") {
|
|
45
|
+
throw new NormalizerDialectError(harness, `field "${field}" must be a string when present, got ${describeType(v)}`);
|
|
46
|
+
}
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Read an optional finite number field, coercing absent/null to `undefined`. */
|
|
51
|
+
export function optNumber(harness: string, obj: Record<string, unknown>, field: string): number | undefined {
|
|
52
|
+
const v = obj[field];
|
|
53
|
+
if (v === undefined || v === null) return undefined;
|
|
54
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
55
|
+
throw new NormalizerDialectError(harness, `field "${field}" must be a finite number when present, got ${describeType(v)}`);
|
|
56
|
+
}
|
|
57
|
+
return v;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Narrow to an array or throw a dialect error. */
|
|
61
|
+
export function asArray(harness: string, value: unknown, what: string): readonly unknown[] {
|
|
62
|
+
if (!Array.isArray(value)) {
|
|
63
|
+
throw new NormalizerDialectError(harness, `${what} must be an array, got ${describeType(value)}`);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Collapse a message-content value to plain text. Harnesses model an assistant
|
|
70
|
+
* message either as a bare string or as an array of typed content parts
|
|
71
|
+
* (`{ type: "text", text }`); we concatenate the text parts and ignore the rest
|
|
72
|
+
* (tool-use parts are lifted to their own events by the dialect). Returns
|
|
73
|
+
* `undefined` when there is no text to emit so the dialect can skip an empty
|
|
74
|
+
* message.
|
|
75
|
+
*/
|
|
76
|
+
export function contentText(value: unknown): string | undefined {
|
|
77
|
+
if (typeof value === "string") return value.length > 0 ? value : undefined;
|
|
78
|
+
if (!Array.isArray(value)) return undefined;
|
|
79
|
+
let text = "";
|
|
80
|
+
for (const part of value) {
|
|
81
|
+
if (typeof part === "string") {
|
|
82
|
+
text += part;
|
|
83
|
+
} else if (isRecord(part) && part.type === "text" && typeof part.text === "string") {
|
|
84
|
+
text += part.text;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return text.length > 0 ? text : undefined;
|
|
88
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { claudeNormalizer } from "./claude.ts";
|
|
4
|
+
import { copilotNormalizer } from "./copilot.ts";
|
|
5
|
+
import { deepseekNormalizer } from "./deepseek.ts";
|
|
6
|
+
import { kimiNormalizer } from "./kimi.ts";
|
|
7
|
+
import { piNormalizer } from "./pi.ts";
|
|
8
|
+
import { qwenNormalizer } from "./qwen.ts";
|
|
9
|
+
|
|
10
|
+
test("each harness resume shim maps a session id to its native restore invocation", () => {
|
|
11
|
+
assert.deepEqual(copilotNormalizer.resume("s1"), { transport: "sdk", sessionId: "s1", call: "resumeSession", args: ["s1"] });
|
|
12
|
+
assert.deepEqual(deepseekNormalizer.resume("s1"), { transport: "sdk", sessionId: "s1", call: "restore", args: ["s1"] });
|
|
13
|
+
assert.deepEqual(claudeNormalizer.resume("s1"), { transport: "cli", sessionId: "s1", args: ["--resume", "s1"] });
|
|
14
|
+
assert.deepEqual(qwenNormalizer.resume("s1"), { transport: "cli", sessionId: "s1", args: ["-r", "s1"] });
|
|
15
|
+
assert.deepEqual(kimiNormalizer.resume("s1"), { transport: "cli", sessionId: "s1", args: ["-S", "s1"] });
|
|
16
|
+
assert.deepEqual(piNormalizer.resume("s1"), { transport: "cli", sessionId: "s1", args: ["--session-id", "s1"] });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("cli resume shims append the id as its own argv token (no shell-injection seam)", () => {
|
|
20
|
+
const shim = claudeNormalizer.resume("id with spaces");
|
|
21
|
+
assert.equal(shim.transport, "cli");
|
|
22
|
+
if (shim.transport === "cli") {
|
|
23
|
+
assert.deepEqual(shim.args, ["--resume", "id with spaces"]);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@nanobpm/agentic/session/normalizer` contract — ADR 0062, slice 3 (the
|
|
3
|
+
* `stream-json`/native-transcript **fallback** ingestion backend).
|
|
4
|
+
*
|
|
5
|
+
* Slice 2 speaks ACP directly; this slice covers every harness that does *not*
|
|
6
|
+
* (yet) speak ACP. ADR 0062 §5 frames `stream-json` as a *transport with N
|
|
7
|
+
* vendor dialects*, so there is no single "stream-json backend": each harness
|
|
8
|
+
* gets a small **normalizer** that maps its native/streaming session output onto
|
|
9
|
+
* Nano's canonical {@link SessionEvent} (slice 1), plus a **resume shim** over
|
|
10
|
+
* that harness's native `--resume <id>` (or SDK equivalent).
|
|
11
|
+
*
|
|
12
|
+
* The three moving parts a harness normalizer exposes:
|
|
13
|
+
*
|
|
14
|
+
* - {@link HarnessNormalizer.toDrafts} — the dialect map: one native record →
|
|
15
|
+
* zero-or-more {@link DraftEvent}s (canonical events *minus* the causal-chain
|
|
16
|
+
* fields the shared {@link linkDrafts} threads in, so a per-harness dialect
|
|
17
|
+
* never re-implements chaining).
|
|
18
|
+
* - {@link HarnessNormalizer.resume} — the resume shim: given a native session
|
|
19
|
+
* id, the exact native invocation ({@link ResumeShim}) that restores it.
|
|
20
|
+
* - {@link HarnessNormalizer.capabilities} — the {@link HarnessCapabilities}
|
|
21
|
+
* the {@link capabilityProbe} folds into a `durable-resume` advertisement
|
|
22
|
+
* (slice 5's enrolment gate reads this).
|
|
23
|
+
*
|
|
24
|
+
* Nothing here interprets a harness schema as *ours*: the native shapes are
|
|
25
|
+
* ingestion details owned entirely by each dialect module; the union they all
|
|
26
|
+
* target is the stable slice-1 contract.
|
|
27
|
+
*/
|
|
28
|
+
import type { SessionEvent } from "../events.ts";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Distributive `Omit` over a discriminated union: applies `Omit` to *each* union
|
|
32
|
+
* member, preserving the `type` discriminant. A plain `Omit<Union, K>` collapses
|
|
33
|
+
* to the members' common properties (losing the per-member fields), so we cannot
|
|
34
|
+
* use it to describe "a session event without its chain fields".
|
|
35
|
+
*/
|
|
36
|
+
export type DistributiveOmit<T, K extends keyof T> = T extends unknown ? Omit<T, K> : never;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* A canonical {@link SessionEvent} as a dialect first produces it — the full
|
|
40
|
+
* semantic payload (`type` + type-specific fields) but *without* the causal-chain
|
|
41
|
+
* responsibilities (`parentId`, and an optional-only `id`). The shared
|
|
42
|
+
* {@link linkDrafts} threads `parentId` in emission order and fills any missing
|
|
43
|
+
* `id`, so an individual dialect never re-implements chain bookkeeping; it just
|
|
44
|
+
* says "here is the event this record means". A dialect that already knows a
|
|
45
|
+
* stable native id (a tool-call id, a provider message id) may supply it as
|
|
46
|
+
* `id` to preserve correlation across a resume.
|
|
47
|
+
*/
|
|
48
|
+
export type DraftEvent = DistributiveOmit<SessionEvent, "id" | "parentId"> & {
|
|
49
|
+
readonly id?: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The native resume invocation a harness's shim resolves for a session id. Two
|
|
54
|
+
* transports cover the fleet:
|
|
55
|
+
*
|
|
56
|
+
* - `cli` — a flag-driven harness: `args` are the argv tail to append to the
|
|
57
|
+
* harness command to resume that session (e.g. Claude's `["--resume", id]`,
|
|
58
|
+
* Qwen's `["-r", id]`). Nano spawns; it never parses the harness's output
|
|
59
|
+
* beyond the dialect map.
|
|
60
|
+
* - `sdk` — an in-process harness (Copilot's `copilot-sdk`, the DeepSeek live
|
|
61
|
+
* feed): `call` names the SDK method and `args` are its arguments (e.g.
|
|
62
|
+
* `resumeSession(id)`), so the host invokes it directly rather than spawning.
|
|
63
|
+
*
|
|
64
|
+
* `sessionId` echoes the id the shim resumed, so a caller that only kept the
|
|
65
|
+
* {@link ResumeShim} still knows which session it targets.
|
|
66
|
+
*/
|
|
67
|
+
export type ResumeShim =
|
|
68
|
+
| { readonly transport: "cli"; readonly sessionId: string; readonly args: readonly string[] }
|
|
69
|
+
| { readonly transport: "sdk"; readonly sessionId: string; readonly call: string; readonly args: readonly unknown[] };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* What a harness can do, from the perspective of durable resume. `streaming` is
|
|
73
|
+
* "exposes a machine-readable streaming/native mind source we can normalize";
|
|
74
|
+
* `resumeById` is "can restore a *specific* prior session by id" (not merely
|
|
75
|
+
* `--continue` the latest). {@link CapabilityAdvertisement.durableResume} is
|
|
76
|
+
* derived, never declared — see {@link capabilityProbe}.
|
|
77
|
+
*/
|
|
78
|
+
export interface HarnessCapabilities {
|
|
79
|
+
readonly streaming: boolean;
|
|
80
|
+
readonly resumeById: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The advertisement {@link capabilityProbe} produces: the raw capabilities plus
|
|
85
|
+
* the single **derived** `durableResume` bit slice 5's enrolment gate consumes.
|
|
86
|
+
* Keeping `durableResume` derived (never a hand-set field on a normalizer)
|
|
87
|
+
* eliminates the drift surface where a harness claims durability it can't honour.
|
|
88
|
+
*/
|
|
89
|
+
export interface CapabilityAdvertisement {
|
|
90
|
+
readonly harness: string;
|
|
91
|
+
readonly streaming: boolean;
|
|
92
|
+
readonly resumeById: boolean;
|
|
93
|
+
/** `true` iff the harness both streams a mind source AND resumes by id. */
|
|
94
|
+
readonly durableResume: boolean;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One harness's fallback ingestion adapter: a dialect map, a resume shim, and its
|
|
99
|
+
* capabilities. Independent per harness (they fan out in parallel), and all
|
|
100
|
+
* target the one canonical {@link SessionEvent} union.
|
|
101
|
+
*/
|
|
102
|
+
export interface HarnessNormalizer {
|
|
103
|
+
/** The harness id this normalizer speaks for, e.g. `"@github/copilot"`. */
|
|
104
|
+
readonly harness: string;
|
|
105
|
+
/** Raw capabilities; `durable-resume` is derived from these by {@link capabilityProbe}. */
|
|
106
|
+
readonly capabilities: HarnessCapabilities;
|
|
107
|
+
/**
|
|
108
|
+
* Map one native record (a parsed `stream-json` line, an SDK `SessionEvent`, a
|
|
109
|
+
* live-feed frame) to zero-or-more canonical {@link DraftEvent}s. Returns `[]`
|
|
110
|
+
* for records that carry no session-log meaning (transport keep-alives, init
|
|
111
|
+
* frames the canonical model does not represent). Throws
|
|
112
|
+
* {@link NormalizerDialectError} on a record that *should* map but is
|
|
113
|
+
* structurally invalid — a corrupt transcript fails loudly, it never
|
|
114
|
+
* fabricates an event.
|
|
115
|
+
*/
|
|
116
|
+
toDrafts(record: unknown): readonly DraftEvent[];
|
|
117
|
+
/** Resolve the native invocation that resumes `sessionId` for this harness. */
|
|
118
|
+
resume(sessionId: string): ResumeShim;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Derive a harness's `durable-resume` advertisement from its raw capabilities.
|
|
123
|
+
* A harness advertises `durable-resume` **iff** it both exposes a streaming mind
|
|
124
|
+
* source we can normalize AND can restore a specific session by id — either half
|
|
125
|
+
* alone is insufficient (a stream we can't resume, or a resume with no mind to
|
|
126
|
+
* replay). This is the single place the bit is computed.
|
|
127
|
+
*/
|
|
128
|
+
export function capabilityProbe(normalizer: HarnessNormalizer): CapabilityAdvertisement {
|
|
129
|
+
const { streaming, resumeById } = normalizer.capabilities;
|
|
130
|
+
return {
|
|
131
|
+
harness: normalizer.harness,
|
|
132
|
+
streaming,
|
|
133
|
+
resumeById,
|
|
134
|
+
durableResume: streaming && resumeById,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Raised when a native record that a dialect *should* map is structurally
|
|
140
|
+
* invalid (a missing tool-call id, a message with no content). Mirrors slice 1's
|
|
141
|
+
* `SessionEventShapeError` at the ingestion boundary: normalization is a trusted
|
|
142
|
+
* map, so a malformed native record surfaces loudly rather than silently
|
|
143
|
+
* dropping or fabricating a canonical event.
|
|
144
|
+
*/
|
|
145
|
+
export class NormalizerDialectError extends Error {
|
|
146
|
+
readonly harness: string;
|
|
147
|
+
constructor(harness: string, message: string) {
|
|
148
|
+
super(`[${harness}] ${message}`);
|
|
149
|
+
this.name = "NormalizerDialectError";
|
|
150
|
+
this.harness = harness;
|
|
151
|
+
}
|
|
152
|
+
}
|