@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,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code normalizer — ADR 0062 slice 3, reference dialect B.
|
|
3
|
+
*
|
|
4
|
+
* Driven with `claude -p --output-format stream-json` (paired with
|
|
5
|
+
* `--input-format stream-json` to feed it), Claude Code streams one JSON object
|
|
6
|
+
* per line. A turn arrives as an `assistant` frame whose `message.content` is an
|
|
7
|
+
* array of typed parts — `text`, `thinking` (with an encrypted `signature`),
|
|
8
|
+
* `tool_use` — and tool outputs come back as a `user` frame carrying
|
|
9
|
+
* `tool_result` parts. Restore is the native `--resume <id>` (`-c` continues the
|
|
10
|
+
* latest, `--from-pr` seeds from a PR — both resume-by-latest, not by-id, so the
|
|
11
|
+
* id-restore shim is `--resume`). Streaming + resume-by-id → `durable-resume`.
|
|
12
|
+
*
|
|
13
|
+
* ## Resume-critical fidelity: `thinking.signature`
|
|
14
|
+
*
|
|
15
|
+
* Claude's `thinking` parts carry a `signature`: the provider's opaque, encrypted
|
|
16
|
+
* reasoning-continuation token that must be replayed verbatim to continue
|
|
17
|
+
* extended thinking across a resume (ADR 0062 §5). We map it to the canonical
|
|
18
|
+
* `ReasoningEvent.providerContinuation`, so the native transcript remains the
|
|
19
|
+
* authoritative restore path for Claude just as it does for Copilot.
|
|
20
|
+
*/
|
|
21
|
+
import { type DraftEvent, type HarnessNormalizer, NormalizerDialectError, type ResumeShim } from "./types.ts";
|
|
22
|
+
import { asArray, asRecord, isRecord, optNumber, optString, reqString } from "./record.ts";
|
|
23
|
+
|
|
24
|
+
const HARNESS = "claude-code";
|
|
25
|
+
|
|
26
|
+
function messageContent(obj: Record<string, unknown>): readonly unknown[] {
|
|
27
|
+
const message = obj.message;
|
|
28
|
+
if (!isRecord(message)) {
|
|
29
|
+
throw new NormalizerDialectError(HARNESS, `${String(obj.type)} frame must carry a "message" object`);
|
|
30
|
+
}
|
|
31
|
+
const content = message.content;
|
|
32
|
+
// Claude also permits a bare-string message content for a plain text turn.
|
|
33
|
+
if (typeof content === "string") return [{ type: "text", text: content }];
|
|
34
|
+
return asArray(HARNESS, content, "message.content");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function usageDrafts(usage: unknown, model: string | undefined): DraftEvent[] {
|
|
38
|
+
if (!isRecord(usage)) return [];
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
type: "usage",
|
|
42
|
+
inputTokens: optNumber(HARNESS, usage, "input_tokens") ?? 0,
|
|
43
|
+
outputTokens: optNumber(HARNESS, usage, "output_tokens") ?? 0,
|
|
44
|
+
...(model !== undefined ? { model } : {}),
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toDrafts(record: unknown): readonly DraftEvent[] {
|
|
50
|
+
const obj = asRecord(HARNESS, record);
|
|
51
|
+
switch (obj.type) {
|
|
52
|
+
case "system":
|
|
53
|
+
// The `init` system frame is session metadata (tools, model, cwd), not a
|
|
54
|
+
// conversational system turn — it carries no canonical text.
|
|
55
|
+
return [];
|
|
56
|
+
case "assistant": {
|
|
57
|
+
const message = isRecord(obj.message) ? obj.message : {};
|
|
58
|
+
const model = optString(HARNESS, message, "model");
|
|
59
|
+
const drafts: DraftEvent[] = [];
|
|
60
|
+
for (const part of messageContent(obj)) {
|
|
61
|
+
if (!isRecord(part)) continue;
|
|
62
|
+
switch (part.type) {
|
|
63
|
+
case "text": {
|
|
64
|
+
const text = optString(HARNESS, part, "text");
|
|
65
|
+
if (text !== undefined && text.length > 0) drafts.push({ type: "assistant", text });
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
case "thinking": {
|
|
69
|
+
const text = optString(HARNESS, part, "thinking");
|
|
70
|
+
const signature = optString(HARNESS, part, "signature");
|
|
71
|
+
drafts.push({
|
|
72
|
+
type: "reasoning",
|
|
73
|
+
...(text !== undefined ? { text } : {}),
|
|
74
|
+
...(signature !== undefined ? { providerContinuation: signature } : {}),
|
|
75
|
+
});
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case "tool_use": {
|
|
79
|
+
const callId = reqString(HARNESS, part, "id");
|
|
80
|
+
drafts.push({
|
|
81
|
+
type: "tool-call",
|
|
82
|
+
id: `call:${callId}`,
|
|
83
|
+
callId,
|
|
84
|
+
name: reqString(HARNESS, part, "name"),
|
|
85
|
+
args: part.input,
|
|
86
|
+
});
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
default:
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// A turn's usage rides on its assistant message.
|
|
94
|
+
drafts.push(...usageDrafts(message.usage, model));
|
|
95
|
+
return drafts;
|
|
96
|
+
}
|
|
97
|
+
case "user": {
|
|
98
|
+
const drafts: DraftEvent[] = [];
|
|
99
|
+
for (const part of messageContent(obj)) {
|
|
100
|
+
if (!isRecord(part)) continue;
|
|
101
|
+
if (part.type === "tool_result") {
|
|
102
|
+
const callId = reqString(HARNESS, part, "tool_use_id");
|
|
103
|
+
drafts.push({
|
|
104
|
+
type: "tool-result",
|
|
105
|
+
id: `result:${callId}`,
|
|
106
|
+
callId,
|
|
107
|
+
ok: part.is_error !== true,
|
|
108
|
+
result: part.content,
|
|
109
|
+
});
|
|
110
|
+
} else if (part.type === "text") {
|
|
111
|
+
const text = optString(HARNESS, part, "text");
|
|
112
|
+
if (text !== undefined && text.length > 0) drafts.push({ type: "user", text });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return drafts;
|
|
116
|
+
}
|
|
117
|
+
case "result":
|
|
118
|
+
// Terminal accounting frame: fold its top-level usage in whenever present.
|
|
119
|
+
// Like every other frame's usage (and every other normalizer), this maps to
|
|
120
|
+
// its own canonical `usage` event — assistant frames emit their own
|
|
121
|
+
// per-message usage and the linker never dedupes; consumers aggregate.
|
|
122
|
+
return usageDrafts(obj.usage, optString(HARNESS, obj, "model"));
|
|
123
|
+
default:
|
|
124
|
+
return [];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The Claude Code fallback normalizer. Streaming + resume-by-id → durable. */
|
|
129
|
+
export const claudeNormalizer: HarnessNormalizer = {
|
|
130
|
+
harness: HARNESS,
|
|
131
|
+
capabilities: { streaming: true, resumeById: true },
|
|
132
|
+
toDrafts,
|
|
133
|
+
resume(sessionId: string): ResumeShim {
|
|
134
|
+
return { transport: "cli", sessionId, args: ["--resume", sessionId] };
|
|
135
|
+
},
|
|
136
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { copilotNormalizer } from "./copilot.ts";
|
|
4
|
+
import { normalizeSession } from "./link.ts";
|
|
5
|
+
import { NormalizerDialectError } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
test("copilot maps turn boundaries, system, and usage frames", () => {
|
|
8
|
+
const events = normalizeSession(copilotNormalizer, [
|
|
9
|
+
{ type: "system_message", text: "you are a coding agent" },
|
|
10
|
+
{ type: "turn_started", turn: 0 },
|
|
11
|
+
{ type: "assistant_message", text: "done" },
|
|
12
|
+
{ type: "turn_completed", turn: 0 },
|
|
13
|
+
{ type: "usage", input_tokens: 10, output_tokens: 3, model: "gpt" },
|
|
14
|
+
]);
|
|
15
|
+
assert.deepEqual(events.map((e) => e.type), ["system", "turn-start", "assistant", "turn-end", "usage"]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("copilot lifts a failed tool result (isError) to ok:false", () => {
|
|
19
|
+
const [call, result] = normalizeSession(copilotNormalizer, [
|
|
20
|
+
{ type: "tool_call", id: "c9", name: "run", arguments: { cmd: "ls" } },
|
|
21
|
+
{ type: "tool_result", id: "c9", isError: true, output: "boom" },
|
|
22
|
+
]);
|
|
23
|
+
assert.equal(call.type, "tool-call");
|
|
24
|
+
assert.equal(result.type, "tool-result");
|
|
25
|
+
if (result.type === "tool-result") {
|
|
26
|
+
assert.equal(result.ok, false);
|
|
27
|
+
assert.equal(result.result, "boom");
|
|
28
|
+
assert.equal(result.callId, "c9");
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("copilot correlates a tool call and its result by callId across generated ids", () => {
|
|
33
|
+
const events = normalizeSession(copilotNormalizer, [
|
|
34
|
+
{ type: "tool_call", id: "abc", name: "write", arguments: {} },
|
|
35
|
+
{ type: "tool_result", id: "abc", output: "ok" },
|
|
36
|
+
]);
|
|
37
|
+
assert.equal(events[0].type === "tool-call" && events[0].callId, "abc");
|
|
38
|
+
assert.equal(events[1].type === "tool-result" && events[1].callId, "abc");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("copilot preserves reasoningOpaque as the canonical providerContinuation", () => {
|
|
42
|
+
const [reasoning] = normalizeSession(copilotNormalizer, [
|
|
43
|
+
{ type: "reasoning", text: "thinking", reasoningOpaque: "ENCRYPTED-BLOB" },
|
|
44
|
+
]);
|
|
45
|
+
assert.ok(reasoning.type === "reasoning");
|
|
46
|
+
if (reasoning.type === "reasoning") {
|
|
47
|
+
assert.equal(reasoning.text, "thinking");
|
|
48
|
+
assert.equal(reasoning.providerContinuation, "ENCRYPTED-BLOB");
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("copilot ignores transport frames it does not model", () => {
|
|
53
|
+
assert.deepEqual(normalizeSession(copilotNormalizer, [{ type: "heartbeat" }, { type: "session_ready" }]), []);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a structurally invalid record fails loudly with a dialect error", () => {
|
|
57
|
+
assert.throws(() => copilotNormalizer.toDrafts({ type: "tool_call", name: "no-id" }), NormalizerDialectError);
|
|
58
|
+
assert.throws(() => copilotNormalizer.toDrafts(42), NormalizerDialectError);
|
|
59
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@github/copilot` normalizer — ADR 0062 slice 3, reference dialect A.
|
|
3
|
+
*
|
|
4
|
+
* Copilot runs in-process through `copilot-sdk`: the mind source is its own
|
|
5
|
+
* `SessionEvent` stream (`session.on(...)`, or the `events.jsonl` transcript it
|
|
6
|
+
* writes), and restore is the SDK's `resumeSession(id)` (the `sessionFsProvider`
|
|
7
|
+
* seam is the same call under a different persistence root, so the resume shim is
|
|
8
|
+
* identical). Copilot both streams and resumes-by-id → it advertises
|
|
9
|
+
* `durable-resume`.
|
|
10
|
+
*
|
|
11
|
+
* ## Resume-critical fidelity: `reasoningOpaque`
|
|
12
|
+
*
|
|
13
|
+
* ADR 0062 §5 lets a native adapter *prefer the native transcript for restore*
|
|
14
|
+
* when it carries more than the ACP models. Copilot's reasoning events carry a
|
|
15
|
+
* `reasoningOpaque` blob — the provider reasoning-continuation handle that must
|
|
16
|
+
* be replayed verbatim to continue the model's reasoning across a resume. The
|
|
17
|
+
* canonical `ReasoningEvent` has a home for exactly this (`providerContinuation`),
|
|
18
|
+
* so we map it straight through, untouched. Dropping it (as a lossy ACP
|
|
19
|
+
* projection might) would silently break reasoning continuation on resume — so
|
|
20
|
+
* this dialect is the authoritative ingestion path for Copilot.
|
|
21
|
+
*/
|
|
22
|
+
import type { DraftEvent, HarnessNormalizer, ResumeShim } from "./types.ts";
|
|
23
|
+
import { asRecord, contentText, optNumber, optString, reqString } from "./record.ts";
|
|
24
|
+
|
|
25
|
+
const HARNESS = "@github/copilot";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Map one `copilot-sdk` `SessionEvent` to canonical drafts. Copilot's event
|
|
29
|
+
* `type`s are close to ours but not identical (`assistant_message` vs
|
|
30
|
+
* `assistant`, `reasoningOpaque` vs `providerContinuation`, `turn_started` vs
|
|
31
|
+
* `turn-start`); this is exactly the per-dialect translation the slice exists to
|
|
32
|
+
* own.
|
|
33
|
+
*/
|
|
34
|
+
function toDrafts(record: unknown): readonly DraftEvent[] {
|
|
35
|
+
const obj = asRecord(HARNESS, record);
|
|
36
|
+
switch (obj.type) {
|
|
37
|
+
case "system":
|
|
38
|
+
case "system_message":
|
|
39
|
+
return [{ type: "system", text: reqString(HARNESS, obj, "text") }];
|
|
40
|
+
case "user":
|
|
41
|
+
case "user_message":
|
|
42
|
+
return [{ type: "user", text: reqString(HARNESS, obj, "text") }];
|
|
43
|
+
case "assistant":
|
|
44
|
+
case "assistant_message": {
|
|
45
|
+
const text = contentText(obj.text ?? obj.content);
|
|
46
|
+
return text === undefined ? [] : [{ type: "assistant", text }];
|
|
47
|
+
}
|
|
48
|
+
case "reasoning": {
|
|
49
|
+
const text = optString(HARNESS, obj, "text");
|
|
50
|
+
// Copilot names the continuation blob `reasoningOpaque`; accept the
|
|
51
|
+
// canonical spelling too so a pre-normalized feed round-trips.
|
|
52
|
+
const providerContinuation =
|
|
53
|
+
optString(HARNESS, obj, "reasoningOpaque") ?? optString(HARNESS, obj, "providerContinuation");
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
type: "reasoning",
|
|
57
|
+
...(text !== undefined ? { text } : {}),
|
|
58
|
+
...(providerContinuation !== undefined ? { providerContinuation } : {}),
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
case "tool_call": {
|
|
63
|
+
const callId = reqString(HARNESS, obj, "id");
|
|
64
|
+
return [
|
|
65
|
+
{
|
|
66
|
+
type: "tool-call",
|
|
67
|
+
id: `call:${callId}`,
|
|
68
|
+
callId,
|
|
69
|
+
name: reqString(HARNESS, obj, "name"),
|
|
70
|
+
args: obj.arguments ?? obj.args,
|
|
71
|
+
},
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
case "tool_result": {
|
|
75
|
+
const callId = reqString(HARNESS, obj, "id");
|
|
76
|
+
// Copilot marks failure with an `isError` flag; the payload lives in
|
|
77
|
+
// `output` either way.
|
|
78
|
+
const ok = obj.isError === true ? false : obj.error == null;
|
|
79
|
+
return [
|
|
80
|
+
{
|
|
81
|
+
type: "tool-result",
|
|
82
|
+
id: `result:${callId}`,
|
|
83
|
+
callId,
|
|
84
|
+
ok,
|
|
85
|
+
result: obj.output ?? obj.result ?? obj.error,
|
|
86
|
+
},
|
|
87
|
+
];
|
|
88
|
+
}
|
|
89
|
+
case "turn_started":
|
|
90
|
+
return [{ type: "turn-start", turn: turnIndex(obj) }];
|
|
91
|
+
case "turn_completed":
|
|
92
|
+
case "turn_ended":
|
|
93
|
+
return [{ type: "turn-end", turn: turnIndex(obj) }];
|
|
94
|
+
case "usage": {
|
|
95
|
+
const model = optString(HARNESS, obj, "model");
|
|
96
|
+
return [
|
|
97
|
+
{
|
|
98
|
+
type: "usage",
|
|
99
|
+
inputTokens: usageCount(obj, "inputTokens", "input_tokens"),
|
|
100
|
+
outputTokens: usageCount(obj, "outputTokens", "output_tokens"),
|
|
101
|
+
...(model !== undefined ? { model } : {}),
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
// Transport frames Copilot emits that carry no session-log meaning
|
|
107
|
+
// (heartbeats, session-ready acks) normalize to nothing.
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function turnIndex(obj: Record<string, unknown>): number {
|
|
113
|
+
return optNumber(HARNESS, obj, "turn") ?? 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function usageCount(obj: Record<string, unknown>, camel: string, snake: string): number {
|
|
117
|
+
return optNumber(HARNESS, obj, camel) ?? optNumber(HARNESS, obj, snake) ?? 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The `@github/copilot` fallback normalizer. `streaming`/`resumeById` are both
|
|
122
|
+
* true, so {@link capabilityProbe} derives `durable-resume: true`.
|
|
123
|
+
*/
|
|
124
|
+
export const copilotNormalizer: HarnessNormalizer = {
|
|
125
|
+
harness: HARNESS,
|
|
126
|
+
capabilities: { streaming: true, resumeById: true },
|
|
127
|
+
toDrafts,
|
|
128
|
+
resume(sessionId: string): ResumeShim {
|
|
129
|
+
// In-process SDK restore: `resumeSession(id)` (the sessionFsProvider seam is
|
|
130
|
+
// the same call under a different persistence root).
|
|
131
|
+
return { transport: "sdk", sessionId, call: "resumeSession", args: [sessionId] };
|
|
132
|
+
},
|
|
133
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek Harness normalizer — ADR 0062 slice 3.
|
|
3
|
+
*
|
|
4
|
+
* The DeepSeek Harness exposes a live `SessionEvent` feed rather than a spawned
|
|
5
|
+
* `stream-json` transport: its frames are already event-shaped (`kind`-tagged),
|
|
6
|
+
* so the dialect is a thin renaming onto the canonical union. Restore is the
|
|
7
|
+
* harness's in-process `seed`/`restore` pair, so the resume shim is the SDK
|
|
8
|
+
* `restore(id)` call. 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 = "deepseek";
|
|
14
|
+
|
|
15
|
+
function toDrafts(record: unknown): readonly DraftEvent[] {
|
|
16
|
+
const obj = asRecord(HARNESS, record);
|
|
17
|
+
switch (obj.kind) {
|
|
18
|
+
case "message": {
|
|
19
|
+
const text = optString(HARNESS, obj, "content") ?? optString(HARNESS, obj, "text");
|
|
20
|
+
if (text === undefined || text.length === 0) return [];
|
|
21
|
+
const role = optString(HARNESS, obj, "role");
|
|
22
|
+
return [{ type: role === "user" ? "user" : role === "system" ? "system" : "assistant", text }];
|
|
23
|
+
}
|
|
24
|
+
case "reasoning": {
|
|
25
|
+
const text = optString(HARNESS, obj, "content") ?? optString(HARNESS, obj, "text");
|
|
26
|
+
const providerContinuation = optString(HARNESS, obj, "continuation");
|
|
27
|
+
return [
|
|
28
|
+
{
|
|
29
|
+
type: "reasoning",
|
|
30
|
+
...(text !== undefined ? { text } : {}),
|
|
31
|
+
...(providerContinuation !== undefined ? { providerContinuation } : {}),
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
case "tool": {
|
|
36
|
+
const callId = reqString(HARNESS, obj, "id");
|
|
37
|
+
return [
|
|
38
|
+
{
|
|
39
|
+
type: "tool-call",
|
|
40
|
+
id: `call:${callId}`,
|
|
41
|
+
callId,
|
|
42
|
+
name: reqString(HARNESS, obj, "name"),
|
|
43
|
+
args: obj.arguments ?? obj.args,
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
case "tool_result": {
|
|
48
|
+
const callId = reqString(HARNESS, obj, "id");
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
type: "tool-result",
|
|
52
|
+
id: `result:${callId}`,
|
|
53
|
+
callId,
|
|
54
|
+
ok: obj.ok !== false,
|
|
55
|
+
result: obj.output ?? obj.result,
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
case "usage":
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
type: "usage",
|
|
63
|
+
inputTokens: optNumber(HARNESS, obj, "input") ?? optNumber(HARNESS, obj, "inputTokens") ?? 0,
|
|
64
|
+
outputTokens: optNumber(HARNESS, obj, "output") ?? optNumber(HARNESS, obj, "outputTokens") ?? 0,
|
|
65
|
+
...(typeof obj.model === "string" ? { model: obj.model } : {}),
|
|
66
|
+
},
|
|
67
|
+
];
|
|
68
|
+
default:
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const deepseekNormalizer: HarnessNormalizer = {
|
|
74
|
+
harness: HARNESS,
|
|
75
|
+
capabilities: { streaming: true, resumeById: true },
|
|
76
|
+
toDrafts,
|
|
77
|
+
resume(sessionId: string): ResumeShim {
|
|
78
|
+
return { transport: "sdk", sessionId, call: "restore", args: [sessionId] };
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@nanobpm/agentic/session/normalizer` — ADR 0062 slice 3 public surface.
|
|
3
|
+
*
|
|
4
|
+
* The `stream-json`/native-transcript fallback ingestion backend: the per-harness
|
|
5
|
+
* {@link HarnessNormalizer} contract, the shared causal-chain linker
|
|
6
|
+
* ({@link normalizeSession}/{@link linkDrafts}), the capability probe
|
|
7
|
+
* ({@link capabilityProbe}), and the current-fleet registry that enumerates the
|
|
8
|
+
* harnesses this slice ships an adapter for. A caller (the ingestion boundary,
|
|
9
|
+
* slice 5's enrolment gate) looks a harness up by id and gets its normalizer;
|
|
10
|
+
* {@link probeFleet} folds the whole registry into `durable-resume`
|
|
11
|
+
* advertisements in one call.
|
|
12
|
+
*
|
|
13
|
+
* The registry is derived from the normalizer modules themselves (each is keyed
|
|
14
|
+
* by its own `harness` id), so there is no second hand-maintained list of harness
|
|
15
|
+
* names to drift against.
|
|
16
|
+
*/
|
|
17
|
+
export type {
|
|
18
|
+
CapabilityAdvertisement,
|
|
19
|
+
DistributiveOmit,
|
|
20
|
+
DraftEvent,
|
|
21
|
+
HarnessCapabilities,
|
|
22
|
+
HarnessNormalizer,
|
|
23
|
+
ResumeShim,
|
|
24
|
+
} from "./types.ts";
|
|
25
|
+
export { capabilityProbe, NormalizerDialectError } from "./types.ts";
|
|
26
|
+
export type { LinkOptions } from "./link.ts";
|
|
27
|
+
export { linkDrafts, normalizeSession } from "./link.ts";
|
|
28
|
+
import { claudeNormalizer } from "./claude.ts";
|
|
29
|
+
import { copilotNormalizer } from "./copilot.ts";
|
|
30
|
+
import { deepseekNormalizer } from "./deepseek.ts";
|
|
31
|
+
import { kimiNormalizer } from "./kimi.ts";
|
|
32
|
+
import { piNormalizer } from "./pi.ts";
|
|
33
|
+
import { qwenNormalizer } from "./qwen.ts";
|
|
34
|
+
import { type CapabilityAdvertisement, capabilityProbe, type HarnessNormalizer } from "./types.ts";
|
|
35
|
+
|
|
36
|
+
export { claudeNormalizer, copilotNormalizer, deepseekNormalizer, kimiNormalizer, piNormalizer, qwenNormalizer };
|
|
37
|
+
|
|
38
|
+
/** Every fallback normalizer in the current fleet, in reference-dialect order. */
|
|
39
|
+
export const FLEET_NORMALIZERS: readonly HarnessNormalizer[] = [
|
|
40
|
+
copilotNormalizer,
|
|
41
|
+
claudeNormalizer,
|
|
42
|
+
qwenNormalizer,
|
|
43
|
+
kimiNormalizer,
|
|
44
|
+
piNormalizer,
|
|
45
|
+
deepseekNormalizer,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Registry keyed by `harness` id, derived from {@link FLEET_NORMALIZERS}. */
|
|
49
|
+
export const NORMALIZER_REGISTRY: ReadonlyMap<string, HarnessNormalizer> = new Map(
|
|
50
|
+
FLEET_NORMALIZERS.map((n) => [n.harness, n]),
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
/** Look a harness's normalizer up by id, or `undefined` when it is not in the fleet. */
|
|
54
|
+
export function normalizerFor(harness: string): HarnessNormalizer | undefined {
|
|
55
|
+
return NORMALIZER_REGISTRY.get(harness);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Probe every harness in the fleet for its derived `durable-resume` advertisement. */
|
|
59
|
+
export function probeFleet(): readonly CapabilityAdvertisement[] {
|
|
60
|
+
return FLEET_NORMALIZERS.map(capabilityProbe);
|
|
61
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kimi normalizer — ADR 0062 slice 3.
|
|
3
|
+
*
|
|
4
|
+
* Driven with `kimi -p --output-format stream-json`. Kimi tags each frame with an
|
|
5
|
+
* `event` discriminator and folds a tool call and its result under one `tool`
|
|
6
|
+
* event distinguished by `phase`. Restore is `-S [id]` (`-c` continues the
|
|
7
|
+
* latest). Streaming + resume-by-id → `durable-resume`.
|
|
8
|
+
*/
|
|
9
|
+
import { type DraftEvent, type HarnessNormalizer, NormalizerDialectError, type ResumeShim } from "./types.ts";
|
|
10
|
+
import { asRecord, optNumber, optString, reqString } from "./record.ts";
|
|
11
|
+
|
|
12
|
+
const HARNESS = "kimi";
|
|
13
|
+
|
|
14
|
+
function toDrafts(record: unknown): readonly DraftEvent[] {
|
|
15
|
+
const obj = asRecord(HARNESS, record);
|
|
16
|
+
switch (obj.event) {
|
|
17
|
+
case "text": {
|
|
18
|
+
const text = optString(HARNESS, obj, "text");
|
|
19
|
+
if (text === undefined || text.length === 0) return [];
|
|
20
|
+
const role = optString(HARNESS, obj, "role");
|
|
21
|
+
return [{ type: role === "user" ? "user" : role === "system" ? "system" : "assistant", text }];
|
|
22
|
+
}
|
|
23
|
+
case "reasoning": {
|
|
24
|
+
const text = optString(HARNESS, obj, "text");
|
|
25
|
+
const providerContinuation = optString(HARNESS, obj, "continuation");
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
type: "reasoning",
|
|
29
|
+
...(text !== undefined ? { text } : {}),
|
|
30
|
+
...(providerContinuation !== undefined ? { providerContinuation } : {}),
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
case "tool": {
|
|
35
|
+
const callId = reqString(HARNESS, obj, "id");
|
|
36
|
+
const phase = optString(HARNESS, obj, "phase");
|
|
37
|
+
if (phase === "result") {
|
|
38
|
+
return [
|
|
39
|
+
{
|
|
40
|
+
type: "tool-result",
|
|
41
|
+
id: `result:${callId}`,
|
|
42
|
+
callId,
|
|
43
|
+
ok: obj.ok !== false,
|
|
44
|
+
result: obj.output ?? obj.result,
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
if (phase === "call" || phase === undefined) {
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
type: "tool-call",
|
|
52
|
+
id: `call:${callId}`,
|
|
53
|
+
callId,
|
|
54
|
+
name: reqString(HARNESS, obj, "name"),
|
|
55
|
+
args: obj.arguments ?? obj.args,
|
|
56
|
+
},
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
throw new NormalizerDialectError(HARNESS, `unknown tool phase ${JSON.stringify(phase)}`);
|
|
60
|
+
}
|
|
61
|
+
case "usage":
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
type: "usage",
|
|
65
|
+
inputTokens: optNumber(HARNESS, obj, "prompt_tokens") ?? 0,
|
|
66
|
+
outputTokens: optNumber(HARNESS, obj, "completion_tokens") ?? 0,
|
|
67
|
+
...(typeof obj.model === "string" ? { model: obj.model } : {}),
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
default:
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export const kimiNormalizer: HarnessNormalizer = {
|
|
76
|
+
harness: HARNESS,
|
|
77
|
+
capabilities: { streaming: true, resumeById: true },
|
|
78
|
+
toDrafts,
|
|
79
|
+
resume(sessionId: string): ResumeShim {
|
|
80
|
+
return { transport: "cli", sessionId, args: ["-S", sessionId] };
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
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("the default newId is unique across repeated normalizeSession calls for one session", () => {
|
|
7
|
+
const records = [
|
|
8
|
+
{ type: "content", role: "user", text: "one" },
|
|
9
|
+
{ type: "content", role: "model", text: "two" },
|
|
10
|
+
];
|
|
11
|
+
const first = normalizeSession(qwenNormalizer, records);
|
|
12
|
+
const second = normalizeSession(qwenNormalizer, records);
|
|
13
|
+
const ids = new Set([...first, ...second].map((e) => e.id));
|
|
14
|
+
assert.equal(ids.size, first.length + second.length, "ids do not collide across calls");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("an injected newId still overrides the default for deterministic tests", () => {
|
|
18
|
+
const events = normalizeSession(
|
|
19
|
+
qwenNormalizer,
|
|
20
|
+
[{ type: "content", role: "user", text: "hi" }],
|
|
21
|
+
{ newId: (() => { let n = 0; return () => `d-${n++}`; })() },
|
|
22
|
+
);
|
|
23
|
+
assert.equal(events[0].id, "d-0");
|
|
24
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared causal-chain linker + stream driver every dialect reuses — ADR 0062
|
|
3
|
+
* slice 3.
|
|
4
|
+
*
|
|
5
|
+
* A {@link HarnessNormalizer} produces {@link DraftEvent}s (semantics only); the
|
|
6
|
+
* *chaining* — assigning each event a stable `id` and stamping `parentId` as the
|
|
7
|
+
* id of its predecessor — is identical across every harness dialect, so it lives
|
|
8
|
+
* here exactly once (derivation over duplication, AGENTS.md). {@link normalizeSession}
|
|
9
|
+
* is the one entry point a caller uses: it maps a whole native transcript through
|
|
10
|
+
* a normalizer, links the drafts into a causal chain, and validates every result
|
|
11
|
+
* against the slice-1 boundary ({@link parseSessionEvent}) so a dialect can never
|
|
12
|
+
* emit a shape that is not a canonical {@link SessionEvent}.
|
|
13
|
+
*/
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { parseSessionEvent, type SessionEvent } from "../events.ts";
|
|
16
|
+
import type { DraftEvent, HarnessNormalizer } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export interface LinkOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Id generator for drafts that do not carry a native id. Default is
|
|
21
|
+
* `crypto.randomUUID`, which is unique-safe across repeated
|
|
22
|
+
* `normalizeSession()` calls for one session (e.g. per chunk / per resume
|
|
23
|
+
* leg); inject a deterministic generator in tests when stable ids are needed.
|
|
24
|
+
* It is only consulted when a draft omits `id`.
|
|
25
|
+
*/
|
|
26
|
+
newId?: () => string;
|
|
27
|
+
/**
|
|
28
|
+
* The `parentId` the first linked event points at — the causal predecessor
|
|
29
|
+
* this transcript continues from. `null` (default) starts a fresh chain; on a
|
|
30
|
+
* resume the caller passes the last restored event's id so the new events
|
|
31
|
+
* continue the same chain across the resume boundary.
|
|
32
|
+
*/
|
|
33
|
+
parentId?: string | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Thread a flat list of {@link DraftEvent}s into a causal chain of canonical
|
|
38
|
+
* {@link SessionEvent}s: each event's `id` is its native id when it supplied one
|
|
39
|
+
* else a freshly generated id, and its `parentId` is the id of the event before
|
|
40
|
+
* it (or {@link LinkOptions.parentId} for the first). Offsets are *not* assigned
|
|
41
|
+
* here — the authoritative log owns those on `emit` (slice 1); this only records
|
|
42
|
+
* causality (`parentId`), which survives compaction.
|
|
43
|
+
*/
|
|
44
|
+
export function linkDrafts(drafts: readonly DraftEvent[], options: LinkOptions = {}): SessionEvent[] {
|
|
45
|
+
const newId = options.newId ?? randomUUID;
|
|
46
|
+
let parentId: string | null = options.parentId ?? null;
|
|
47
|
+
const linked: SessionEvent[] = [];
|
|
48
|
+
for (const draft of drafts) {
|
|
49
|
+
const { id: nativeId, ...rest } = draft;
|
|
50
|
+
const id = nativeId ?? newId();
|
|
51
|
+
// Re-validate the fully-formed event at the slice-1 boundary: `rest` carries
|
|
52
|
+
// the discriminant + payload, and we add the chain fields. parseSessionEvent
|
|
53
|
+
// *builds* the union member field-by-field (never an as-cast), so a dialect
|
|
54
|
+
// bug surfaces here as a loud SessionEventShapeError instead of a bad row.
|
|
55
|
+
const event = parseSessionEvent({ ...rest, id, parentId });
|
|
56
|
+
linked.push(event);
|
|
57
|
+
parentId = id;
|
|
58
|
+
}
|
|
59
|
+
return linked;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Normalize a whole native transcript through a harness normalizer: map every
|
|
64
|
+
* record to drafts, then {@link linkDrafts} the concatenation into one canonical
|
|
65
|
+
* causal chain. This is the fallback backend's public ingestion call — the
|
|
66
|
+
* mirror of the ACP backend's `session/update` stream, producing the same
|
|
67
|
+
* {@link SessionEvent}s a caller feeds to a slice-1 `SessionAdapter.emit`.
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeSession(
|
|
70
|
+
normalizer: HarnessNormalizer,
|
|
71
|
+
records: Iterable<unknown>,
|
|
72
|
+
options: LinkOptions = {},
|
|
73
|
+
): SessionEvent[] {
|
|
74
|
+
const drafts: DraftEvent[] = [];
|
|
75
|
+
for (const record of records) {
|
|
76
|
+
for (const draft of normalizer.toDrafts(record)) {
|
|
77
|
+
drafts.push(draft);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return linkDrafts(drafts, options);
|
|
81
|
+
}
|