@nanobpm/agentic 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/README.md +2 -1
  2. package/dist/demand/model.d.ts +7 -4
  3. package/dist/demand/model.js +22 -4
  4. package/dist/demand/taskdef.d.ts +13 -1
  5. package/dist/demand/taskdef.js +20 -2
  6. package/dist/index.d.ts +1 -0
  7. package/dist/index.js +1 -0
  8. package/dist/protocol/conformance/frames.js +32 -4
  9. package/dist/protocol/index.d.ts +1 -1
  10. package/dist/protocol/payloads.d.ts +44 -0
  11. package/dist/protocol/payloads.js +61 -7
  12. package/dist/session/acp/client.d.ts +109 -0
  13. package/dist/session/acp/client.js +254 -0
  14. package/dist/session/acp/index.d.ts +27 -0
  15. package/dist/session/acp/index.js +27 -0
  16. package/dist/session/acp/jsonrpc.d.ts +25 -0
  17. package/dist/session/acp/jsonrpc.js +148 -0
  18. package/dist/session/acp/normalize.d.ts +48 -0
  19. package/dist/session/acp/normalize.js +162 -0
  20. package/dist/session/acp/protocol.d.ts +94 -0
  21. package/dist/session/acp/protocol.js +136 -0
  22. package/dist/session/acp/spawn.d.ts +36 -0
  23. package/dist/session/acp/spawn.js +68 -0
  24. package/dist/session/acp/transport.d.ts +62 -0
  25. package/dist/session/acp/transport.js +126 -0
  26. package/dist/session/adapter.d.ts +135 -0
  27. package/dist/session/adapter.js +24 -0
  28. package/dist/session/backend.d.ts +43 -0
  29. package/dist/session/backend.js +95 -0
  30. package/dist/session/events.d.ts +152 -0
  31. package/dist/session/events.js +192 -0
  32. package/dist/session/index.d.ts +31 -0
  33. package/dist/session/index.js +5 -0
  34. package/dist/session/log.d.ts +107 -0
  35. package/dist/session/log.js +351 -0
  36. package/dist/session/normalizer/claude.d.ts +23 -0
  37. package/dist/session/normalizer/claude.js +138 -0
  38. package/dist/session/normalizer/copilot.d.ts +27 -0
  39. package/dist/session/normalizer/copilot.js +105 -0
  40. package/dist/session/normalizer/deepseek.d.ts +11 -0
  41. package/dist/session/normalizer/deepseek.js +68 -0
  42. package/dist/session/normalizer/index.d.ts +36 -0
  43. package/dist/session/normalizer/index.js +29 -0
  44. package/dist/session/normalizer/kimi.d.ts +10 -0
  45. package/dist/session/normalizer/kimi.js +80 -0
  46. package/dist/session/normalizer/link.d.ts +36 -0
  47. package/dist/session/normalizer/link.js +56 -0
  48. package/dist/session/normalizer/pi.d.ts +13 -0
  49. package/dist/session/normalizer/pi.js +61 -0
  50. package/dist/session/normalizer/qwen.d.ts +11 -0
  51. package/dist/session/normalizer/qwen.js +65 -0
  52. package/dist/session/normalizer/record.d.ts +21 -0
  53. package/dist/session/normalizer/record.js +87 -0
  54. package/dist/session/normalizer/types.d.ts +139 -0
  55. package/dist/session/normalizer/types.js +31 -0
  56. package/dist/session/schema.d.ts +38 -0
  57. package/dist/session/schema.js +74 -0
  58. package/dist/transcript/index.d.ts +2 -2
  59. package/dist/transcript/index.js +1 -1
  60. package/dist/transcript/schema.d.ts +23 -1
  61. package/dist/transcript/schema.js +34 -1
  62. package/dist/transcript/store.d.ts +93 -5
  63. package/dist/transcript/store.js +287 -6
  64. package/package.json +17 -1
  65. package/src/demand/model.test.ts +82 -4
  66. package/src/demand/model.ts +30 -9
  67. package/src/demand/taskdef.test.ts +51 -6
  68. package/src/demand/taskdef.ts +31 -2
  69. package/src/index.ts +1 -0
  70. package/src/protocol/conformance/frames.ts +32 -4
  71. package/src/protocol/index.ts +4 -0
  72. package/src/protocol/payloads.test.ts +31 -1
  73. package/src/protocol/payloads.ts +110 -7
  74. package/src/session/acp/client.test.ts +222 -0
  75. package/src/session/acp/client.ts +356 -0
  76. package/src/session/acp/fake-agent.ts +71 -0
  77. package/src/session/acp/index.ts +68 -0
  78. package/src/session/acp/integration.test.ts +37 -0
  79. package/src/session/acp/jsonrpc.test.ts +75 -0
  80. package/src/session/acp/jsonrpc.ts +171 -0
  81. package/src/session/acp/normalize.test.ts +150 -0
  82. package/src/session/acp/normalize.ts +204 -0
  83. package/src/session/acp/protocol.ts +178 -0
  84. package/src/session/acp/spawn.test.ts +45 -0
  85. package/src/session/acp/spawn.ts +91 -0
  86. package/src/session/acp/transport.test.ts +82 -0
  87. package/src/session/acp/transport.ts +155 -0
  88. package/src/session/adapter.ts +159 -0
  89. package/src/session/backend.test.ts +198 -0
  90. package/src/session/backend.ts +128 -0
  91. package/src/session/events.test.ts +168 -0
  92. package/src/session/events.ts +347 -0
  93. package/src/session/index.ts +67 -0
  94. package/src/session/log.test.ts +215 -0
  95. package/src/session/log.ts +525 -0
  96. package/src/session/normalizer/backend-integration.test.ts +103 -0
  97. package/src/session/normalizer/claude.test.ts +68 -0
  98. package/src/session/normalizer/claude.ts +136 -0
  99. package/src/session/normalizer/copilot.test.ts +59 -0
  100. package/src/session/normalizer/copilot.ts +133 -0
  101. package/src/session/normalizer/deepseek.ts +80 -0
  102. package/src/session/normalizer/index.ts +61 -0
  103. package/src/session/normalizer/kimi.ts +82 -0
  104. package/src/session/normalizer/link.test.ts +24 -0
  105. package/src/session/normalizer/link.ts +81 -0
  106. package/src/session/normalizer/pi.ts +75 -0
  107. package/src/session/normalizer/probe.test.ts +49 -0
  108. package/src/session/normalizer/qwen.test.ts +20 -0
  109. package/src/session/normalizer/qwen.ts +77 -0
  110. package/src/session/normalizer/record.test.ts +68 -0
  111. package/src/session/normalizer/record.ts +88 -0
  112. package/src/session/normalizer/resume.test.ts +25 -0
  113. package/src/session/normalizer/types.ts +152 -0
  114. package/src/session/normalizer/vectors.test.ts +180 -0
  115. package/src/session/schema.test.ts +84 -0
  116. package/src/session/schema.ts +78 -0
  117. package/src/session/test-db.ts +56 -0
  118. package/src/transcript/index.ts +8 -0
  119. package/src/transcript/schema.test.ts +31 -4
  120. package/src/transcript/schema.ts +36 -1
  121. package/src/transcript/store.ts +438 -6
  122. package/src/transcript/turns.test.ts +334 -0
  123. package/dist/blackboard/test-db.d.ts +0 -5
  124. package/dist/blackboard/test-db.js +0 -42
  125. package/dist/presence/test-db.d.ts +0 -5
  126. package/dist/presence/test-db.js +0 -42
  127. package/dist/transcript/test-db.d.ts +0 -5
  128. 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
+ }