@nanobpm/agentic 0.1.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 (228) hide show
  1. package/README.md +22 -0
  2. package/dist/blackboard/family.d.ts +40 -0
  3. package/dist/blackboard/family.js +151 -0
  4. package/dist/blackboard/index.d.ts +21 -0
  5. package/dist/blackboard/index.js +19 -0
  6. package/dist/blackboard/schema.d.ts +30 -0
  7. package/dist/blackboard/schema.js +42 -0
  8. package/dist/blackboard/store.d.ts +138 -0
  9. package/dist/blackboard/store.js +216 -0
  10. package/dist/blackboard/test-db.d.ts +5 -0
  11. package/dist/blackboard/test-db.js +42 -0
  12. package/dist/channel/auth.d.ts +41 -0
  13. package/dist/channel/auth.js +67 -0
  14. package/dist/channel/clock.d.ts +11 -0
  15. package/dist/channel/clock.js +4 -0
  16. package/dist/channel/connection.d.ts +75 -0
  17. package/dist/channel/connection.js +14 -0
  18. package/dist/channel/dispatch.d.ts +46 -0
  19. package/dist/channel/dispatch.js +86 -0
  20. package/dist/channel/hub.d.ts +78 -0
  21. package/dist/channel/hub.js +157 -0
  22. package/dist/channel/index.d.ts +27 -0
  23. package/dist/channel/index.js +20 -0
  24. package/dist/channel/registry.d.ts +68 -0
  25. package/dist/channel/registry.js +84 -0
  26. package/dist/channel/ws-transport.d.ts +23 -0
  27. package/dist/channel/ws-transport.js +178 -0
  28. package/dist/cockpit/boot.d.ts +68 -0
  29. package/dist/cockpit/boot.js +202 -0
  30. package/dist/cockpit/fake-dom.d.ts +37 -0
  31. package/dist/cockpit/fake-dom.js +73 -0
  32. package/dist/cockpit/index.d.ts +27 -0
  33. package/dist/cockpit/index.js +27 -0
  34. package/dist/cockpit/relay-client.d.ts +52 -0
  35. package/dist/cockpit/relay-client.js +192 -0
  36. package/dist/cockpit/render.d.ts +58 -0
  37. package/dist/cockpit/render.js +122 -0
  38. package/dist/cockpit/terminal-session.d.ts +95 -0
  39. package/dist/cockpit/terminal-session.js +123 -0
  40. package/dist/cockpit/view.d.ts +79 -0
  41. package/dist/cockpit/view.js +58 -0
  42. package/dist/demand/c8-rest.d.ts +77 -0
  43. package/dist/demand/c8-rest.js +123 -0
  44. package/dist/demand/index.d.ts +24 -0
  45. package/dist/demand/index.js +24 -0
  46. package/dist/demand/model.d.ts +68 -0
  47. package/dist/demand/model.js +118 -0
  48. package/dist/demand/taskdef.d.ts +40 -0
  49. package/dist/demand/taskdef.js +67 -0
  50. package/dist/index.d.ts +17 -0
  51. package/dist/index.js +17 -0
  52. package/dist/presence/family.d.ts +40 -0
  53. package/dist/presence/family.js +166 -0
  54. package/dist/presence/index.d.ts +19 -0
  55. package/dist/presence/index.js +17 -0
  56. package/dist/presence/schema.d.ts +20 -0
  57. package/dist/presence/schema.js +32 -0
  58. package/dist/presence/store.d.ts +130 -0
  59. package/dist/presence/store.js +191 -0
  60. package/dist/presence/test-db.d.ts +5 -0
  61. package/dist/presence/test-db.js +42 -0
  62. package/dist/protocol/conformance/frames.d.ts +24 -0
  63. package/dist/protocol/conformance/frames.js +116 -0
  64. package/dist/protocol/conformance/index.d.ts +13 -0
  65. package/dist/protocol/conformance/index.js +13 -0
  66. package/dist/protocol/conformance/malformed.d.ts +14 -0
  67. package/dist/protocol/conformance/malformed.js +44 -0
  68. package/dist/protocol/conformance/tokens.d.ts +19 -0
  69. package/dist/protocol/conformance/tokens.js +49 -0
  70. package/dist/protocol/conformance/vocab.d.ts +23 -0
  71. package/dist/protocol/conformance/vocab.js +97 -0
  72. package/dist/protocol/families.d.ts +32 -0
  73. package/dist/protocol/families.js +45 -0
  74. package/dist/protocol/frame.d.ts +45 -0
  75. package/dist/protocol/frame.js +114 -0
  76. package/dist/protocol/hex.d.ts +7 -0
  77. package/dist/protocol/hex.js +26 -0
  78. package/dist/protocol/index.d.ts +23 -0
  79. package/dist/protocol/index.js +23 -0
  80. package/dist/protocol/lanes.d.ts +36 -0
  81. package/dist/protocol/lanes.js +40 -0
  82. package/dist/protocol/payloads.d.ts +64 -0
  83. package/dist/protocol/payloads.js +122 -0
  84. package/dist/protocol/token.d.ts +34 -0
  85. package/dist/protocol/token.js +81 -0
  86. package/dist/protocol/vocab/schema.d.ts +51 -0
  87. package/dist/protocol/vocab/schema.js +218 -0
  88. package/dist/relay/incarnation.d.ts +17 -0
  89. package/dist/relay/incarnation.js +50 -0
  90. package/dist/relay/index.d.ts +25 -0
  91. package/dist/relay/index.js +22 -0
  92. package/dist/relay/relay-family.d.ts +68 -0
  93. package/dist/relay/relay-family.js +272 -0
  94. package/dist/relay/ring.d.ts +49 -0
  95. package/dist/relay/ring.js +105 -0
  96. package/dist/relay/scheduler.d.ts +72 -0
  97. package/dist/relay/scheduler.js +180 -0
  98. package/dist/relay/validate.d.ts +29 -0
  99. package/dist/relay/validate.js +39 -0
  100. package/dist/transcript/index.d.ts +18 -0
  101. package/dist/transcript/index.js +17 -0
  102. package/dist/transcript/schema.d.ts +32 -0
  103. package/dist/transcript/schema.js +48 -0
  104. package/dist/transcript/store.d.ts +192 -0
  105. package/dist/transcript/store.js +347 -0
  106. package/dist/transcript/test-db.d.ts +5 -0
  107. package/dist/transcript/test-db.js +41 -0
  108. package/dist/vocab/core-vocab.d.ts +26 -0
  109. package/dist/vocab/core-vocab.js +67 -0
  110. package/dist/vocab/diversity.d.ts +78 -0
  111. package/dist/vocab/diversity.js +89 -0
  112. package/dist/vocab/index.d.ts +22 -0
  113. package/dist/vocab/index.js +22 -0
  114. package/dist/vocab/merge.d.ts +9 -0
  115. package/dist/vocab/merge.js +104 -0
  116. package/dist/vocab/requires.d.ts +49 -0
  117. package/dist/vocab/requires.js +107 -0
  118. package/dist/vocab/resolver.d.ts +62 -0
  119. package/dist/vocab/resolver.js +149 -0
  120. package/dist/vocab/serve.d.ts +39 -0
  121. package/dist/vocab/serve.js +36 -0
  122. package/package.json +108 -0
  123. package/page/cockpit.css +114 -0
  124. package/page/cockpit.page.json +33 -0
  125. package/page/embed.html +40 -0
  126. package/page/mount.js +78 -0
  127. package/page/standalone.html +43 -0
  128. package/src/blackboard/family.test.ts +280 -0
  129. package/src/blackboard/family.ts +208 -0
  130. package/src/blackboard/index.ts +42 -0
  131. package/src/blackboard/schema.test.ts +60 -0
  132. package/src/blackboard/schema.ts +44 -0
  133. package/src/blackboard/store.test.ts +189 -0
  134. package/src/blackboard/store.ts +331 -0
  135. package/src/blackboard/test-db.ts +47 -0
  136. package/src/channel/auth.test.ts +64 -0
  137. package/src/channel/auth.ts +101 -0
  138. package/src/channel/clock.ts +14 -0
  139. package/src/channel/connection.ts +77 -0
  140. package/src/channel/dispatch.test.ts +83 -0
  141. package/src/channel/dispatch.ts +102 -0
  142. package/src/channel/hub.test.ts +335 -0
  143. package/src/channel/hub.ts +222 -0
  144. package/src/channel/index.ts +55 -0
  145. package/src/channel/registry.test.ts +73 -0
  146. package/src/channel/registry.ts +137 -0
  147. package/src/channel/ws-transport.test.ts +234 -0
  148. package/src/channel/ws-transport.ts +212 -0
  149. package/src/cockpit/boot.test.ts +374 -0
  150. package/src/cockpit/boot.ts +280 -0
  151. package/src/cockpit/fake-dom.ts +90 -0
  152. package/src/cockpit/index.ts +63 -0
  153. package/src/cockpit/relay-client.test.ts +359 -0
  154. package/src/cockpit/relay-client.ts +234 -0
  155. package/src/cockpit/render.test.ts +149 -0
  156. package/src/cockpit/render.ts +194 -0
  157. package/src/cockpit/terminal-session.test.ts +252 -0
  158. package/src/cockpit/terminal-session.ts +194 -0
  159. package/src/cockpit/view.test.ts +117 -0
  160. package/src/cockpit/view.ts +140 -0
  161. package/src/demand/c8-rest.test.ts +140 -0
  162. package/src/demand/c8-rest.ts +167 -0
  163. package/src/demand/index.ts +42 -0
  164. package/src/demand/model.test.ts +197 -0
  165. package/src/demand/model.ts +183 -0
  166. package/src/demand/taskdef.test.ts +85 -0
  167. package/src/demand/taskdef.ts +78 -0
  168. package/src/index.ts +17 -0
  169. package/src/presence/family.test.ts +252 -0
  170. package/src/presence/family.ts +205 -0
  171. package/src/presence/index.ts +26 -0
  172. package/src/presence/schema.test.ts +53 -0
  173. package/src/presence/schema.ts +34 -0
  174. package/src/presence/store.test.ts +190 -0
  175. package/src/presence/store.ts +287 -0
  176. package/src/presence/test-db.test.ts +57 -0
  177. package/src/presence/test-db.ts +47 -0
  178. package/src/protocol/conformance/corpus.test.ts +66 -0
  179. package/src/protocol/conformance/frames.ts +142 -0
  180. package/src/protocol/conformance/index.ts +29 -0
  181. package/src/protocol/conformance/malformed.ts +59 -0
  182. package/src/protocol/conformance/tokens.ts +70 -0
  183. package/src/protocol/conformance/vocab.ts +122 -0
  184. package/src/protocol/families.ts +54 -0
  185. package/src/protocol/frame.test.ts +116 -0
  186. package/src/protocol/frame.ts +171 -0
  187. package/src/protocol/hex.ts +28 -0
  188. package/src/protocol/index.ts +84 -0
  189. package/src/protocol/lanes.test.ts +82 -0
  190. package/src/protocol/lanes.ts +54 -0
  191. package/src/protocol/payloads.test.ts +91 -0
  192. package/src/protocol/payloads.ts +201 -0
  193. package/src/protocol/token.test.ts +57 -0
  194. package/src/protocol/token.ts +123 -0
  195. package/src/protocol/vocab/schema.test.ts +67 -0
  196. package/src/protocol/vocab/schema.ts +281 -0
  197. package/src/relay/incarnation.test.ts +53 -0
  198. package/src/relay/incarnation.ts +54 -0
  199. package/src/relay/index.ts +34 -0
  200. package/src/relay/integration.test.ts +135 -0
  201. package/src/relay/relay-family.test.ts +236 -0
  202. package/src/relay/relay-family.ts +336 -0
  203. package/src/relay/ring.test.ts +138 -0
  204. package/src/relay/ring.ts +136 -0
  205. package/src/relay/scheduler.test.ts +233 -0
  206. package/src/relay/scheduler.ts +208 -0
  207. package/src/relay/validate.test.ts +43 -0
  208. package/src/relay/validate.ts +44 -0
  209. package/src/transcript/index.ts +33 -0
  210. package/src/transcript/integration.test.ts +108 -0
  211. package/src/transcript/schema.test.ts +69 -0
  212. package/src/transcript/schema.ts +51 -0
  213. package/src/transcript/store.test.ts +285 -0
  214. package/src/transcript/store.ts +530 -0
  215. package/src/transcript/test-db.ts +46 -0
  216. package/src/vocab/core-vocab.test.ts +34 -0
  217. package/src/vocab/core-vocab.ts +88 -0
  218. package/src/vocab/diversity.test.ts +153 -0
  219. package/src/vocab/diversity.ts +169 -0
  220. package/src/vocab/index.ts +55 -0
  221. package/src/vocab/merge.test.ts +73 -0
  222. package/src/vocab/merge.ts +117 -0
  223. package/src/vocab/requires.test.ts +69 -0
  224. package/src/vocab/requires.ts +155 -0
  225. package/src/vocab/resolver.test.ts +118 -0
  226. package/src/vocab/resolver.ts +187 -0
  227. package/src/vocab/serve.test.ts +64 -0
  228. package/src/vocab/serve.ts +66 -0
@@ -0,0 +1,70 @@
1
+ import type { RoutingToken, TokenParseErrorCode } from "../token.ts";
2
+
3
+ /**
4
+ * Routing-token vectors. Valid ones assert the parse decomposes exactly as
5
+ * `network[.subnetwork…].role[#seat]`; invalid ones assert the grammar rejects
6
+ * with a specific {@link TokenParseErrorCode}. Capability is never present in a
7
+ * token — there is deliberately no "capability" field to parse.
8
+ */
9
+ export interface ValidToken {
10
+ readonly name: string;
11
+ readonly token: string;
12
+ readonly parsed: RoutingToken;
13
+ }
14
+
15
+ export interface InvalidToken {
16
+ readonly name: string;
17
+ readonly token: string;
18
+ readonly expected: TokenParseErrorCode;
19
+ }
20
+
21
+ export const VALID_TOKENS: readonly ValidToken[] = [
22
+ {
23
+ name: "single-segment-role",
24
+ token: "decide",
25
+ parsed: { subnetworks: [], role: "decide" },
26
+ },
27
+ {
28
+ name: "network-and-role",
29
+ token: "planning.decide",
30
+ parsed: { network: "planning", subnetworks: [], role: "decide" },
31
+ },
32
+ {
33
+ name: "nested-subnetwork",
34
+ token: "implementation.ci.fix",
35
+ parsed: { network: "implementation", subnetworks: ["ci"], role: "fix" },
36
+ },
37
+ {
38
+ name: "role-with-named-seat",
39
+ token: "qa.review#red",
40
+ parsed: { network: "qa", subnetworks: [], role: "review", seat: "red" },
41
+ },
42
+ {
43
+ name: "role-with-numeric-seat",
44
+ token: "implementation.qa.red#1",
45
+ parsed: { network: "implementation", subnetworks: ["qa"], role: "red", seat: "1" },
46
+ },
47
+ {
48
+ name: "hyphenated-segments",
49
+ token: "code-review.fast-lane.senior-dev#blue",
50
+ parsed: {
51
+ network: "code-review",
52
+ subnetworks: ["fast-lane"],
53
+ role: "senior-dev",
54
+ seat: "blue",
55
+ },
56
+ },
57
+ ];
58
+
59
+ export const INVALID_TOKENS: readonly InvalidToken[] = [
60
+ { name: "empty", token: "", expected: "empty" },
61
+ { name: "whitespace", token: "planning .decide", expected: "whitespace" },
62
+ { name: "leading-dot", token: ".decide", expected: "empty-segment" },
63
+ { name: "trailing-dot", token: "planning.", expected: "empty-segment" },
64
+ { name: "double-dot", token: "planning..decide", expected: "empty-segment" },
65
+ { name: "uppercase-segment", token: "Planning.decide", expected: "bad-segment" },
66
+ { name: "segment-starts-with-digit", token: "1planning.decide", expected: "bad-segment" },
67
+ { name: "two-seat-markers", token: "qa.review#red#blue", expected: "multiple-seat-markers" },
68
+ { name: "empty-seat", token: "qa.review#", expected: "empty-seat" },
69
+ { name: "uppercase-seat", token: "qa.review#Red", expected: "bad-seat" },
70
+ ];
@@ -0,0 +1,122 @@
1
+ import type { VocabDocument } from "../vocab/schema.ts";
2
+
3
+ /**
4
+ * Valid vocab documents (core-shaped + an author extension) and invalid ones
5
+ * paired with the error code they must surface. Shared so the c8ctl client's
6
+ * resolver is held to the same schema this repo's validator enforces.
7
+ *
8
+ * NOTE: the FULL opinionated core vocabulary is S3's deliverable; the valid
9
+ * document below is a representative, schema-complete sample (it exercises
10
+ * `requires`, `weight`, numeric `seats`, named `seats`, and
11
+ * `seatsDistinctFamily`), not the canonical core artifact.
12
+ */
13
+ export interface ValidVocab {
14
+ readonly name: string;
15
+ readonly document: VocabDocument;
16
+ }
17
+
18
+ export interface InvalidVocab {
19
+ readonly name: string;
20
+ readonly document: unknown;
21
+ /** A `code` that MUST appear among the validation errors. */
22
+ readonly expectedCode: string;
23
+ }
24
+
25
+ export const VALID_VOCABS: readonly ValidVocab[] = [
26
+ {
27
+ name: "core-shaped-sample",
28
+ document: {
29
+ version: 1,
30
+ networks: {
31
+ planning: {
32
+ roles: {
33
+ decide: { requires: ["cognition=reasoning"], weight: 3, seats: 1 },
34
+ },
35
+ },
36
+ qa: {
37
+ roles: {
38
+ review: {
39
+ requires: ["cognition=reasoning"],
40
+ weight: 2,
41
+ seats: ["red", "blue"],
42
+ seatsDistinctFamily: true,
43
+ },
44
+ },
45
+ },
46
+ implementation: {
47
+ roles: { senior: { weight: 3, seats: 4 } },
48
+ subnetworks: {
49
+ ci: { roles: { fix: { weight: 1, seats: 2 } } },
50
+ },
51
+ },
52
+ },
53
+ },
54
+ },
55
+ {
56
+ name: "author-extension-merges-in-same-schema",
57
+ document: {
58
+ version: 2,
59
+ networks: {
60
+ support: {
61
+ roles: {
62
+ triage: { requires: ["host=on-call"], weight: 1, seats: 3 },
63
+ },
64
+ },
65
+ },
66
+ },
67
+ },
68
+ ];
69
+
70
+ export const INVALID_VOCABS: readonly InvalidVocab[] = [
71
+ { name: "not-an-object", document: 42, expectedCode: "not-object" },
72
+ { name: "missing-version", document: { networks: {} }, expectedCode: "bad-version" },
73
+ { name: "zero-version", document: { version: 0, networks: {} }, expectedCode: "bad-version" },
74
+ {
75
+ name: "missing-networks",
76
+ document: { version: 1 },
77
+ expectedCode: "bad-networks",
78
+ },
79
+ {
80
+ name: "unknown-document-field",
81
+ document: { version: 1, networks: {}, extra: true },
82
+ expectedCode: "unknown-document-field",
83
+ },
84
+ {
85
+ name: "bad-network-name",
86
+ document: { version: 1, networks: { "Bad Name": { roles: {} } } },
87
+ expectedCode: "bad-network-name",
88
+ },
89
+ {
90
+ name: "unknown-role-field",
91
+ document: { version: 1, networks: { planning: { roles: { decide: { bogus: 1 } } } } },
92
+ expectedCode: "unknown-role-field",
93
+ },
94
+ {
95
+ name: "bad-weight",
96
+ document: { version: 1, networks: { planning: { roles: { decide: { weight: "high" } } } } },
97
+ expectedCode: "bad-weight",
98
+ },
99
+ {
100
+ name: "bad-seats-count",
101
+ document: { version: 1, networks: { planning: { roles: { decide: { seats: -1 } } } } },
102
+ expectedCode: "bad-seats",
103
+ },
104
+ {
105
+ name: "bad-named-seat",
106
+ document: { version: 1, networks: { qa: { roles: { review: { seats: ["Red"] } } } } },
107
+ expectedCode: "bad-seat-label",
108
+ },
109
+ {
110
+ name: "bad-requires",
111
+ document: { version: 1, networks: { planning: { roles: { decide: { requires: "x" } } } } },
112
+ expectedCode: "bad-requires",
113
+ },
114
+ {
115
+ name: "bad-seats-distinct-family",
116
+ document: {
117
+ version: 1,
118
+ networks: { qa: { roles: { review: { seatsDistinctFamily: "yes" } } } },
119
+ },
120
+ expectedCode: "bad-seats-distinct-family",
121
+ },
122
+ ];
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Canonical message-family set for the Nano agentic protocol.
3
+ *
4
+ * This is the single source of truth every family module keys off — S1's
5
+ * `registerFamilyHandler(family, handler)` seam, the frame codec, and each
6
+ * per-family module all derive their family key from {@link MESSAGE_FAMILIES}.
7
+ * Do not fork this list; extend it here.
8
+ *
9
+ * Directions (informational — the codec is direction-agnostic):
10
+ * - `register` / `heartbeat` / `deregister` — worker → hub (presence)
11
+ * - `serve` — hub → worker (assigned tokens)
12
+ * - `demand` — hub → observers (demand×supply)
13
+ * - `blackboard` — both directions (coordination)
14
+ * - `relay` — both directions (terminal bytes)
15
+ */
16
+ export const MESSAGE_FAMILIES = [
17
+ "register",
18
+ "heartbeat",
19
+ "deregister",
20
+ "serve",
21
+ "demand",
22
+ "blackboard",
23
+ "relay",
24
+ ] as const;
25
+
26
+ export type MessageFamily = (typeof MESSAGE_FAMILIES)[number];
27
+
28
+ const FAMILY_SET: ReadonlySet<string> = new Set(MESSAGE_FAMILIES);
29
+
30
+ export function isMessageFamily(value: unknown): value is MessageFamily {
31
+ return typeof value === "string" && FAMILY_SET.has(value);
32
+ }
33
+
34
+ /**
35
+ * Stable on-wire codes for each family. Codes are part of the wire contract and
36
+ * MUST NOT be renumbered; only append new families with new, unused codes.
37
+ */
38
+ export const FAMILY_CODES = {
39
+ register: 1,
40
+ heartbeat: 2,
41
+ deregister: 3,
42
+ serve: 4,
43
+ demand: 5,
44
+ blackboard: 6,
45
+ relay: 7,
46
+ } as const satisfies Record<MessageFamily, number>;
47
+
48
+ const CODE_TO_FAMILY: ReadonlyMap<number, MessageFamily> = new Map(
49
+ MESSAGE_FAMILIES.map((family) => [FAMILY_CODES[family], family]),
50
+ );
51
+
52
+ export function familyForCode(code: number): MessageFamily | undefined {
53
+ return CODE_TO_FAMILY.get(code);
54
+ }
@@ -0,0 +1,116 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ decodeFrame,
5
+ encodeFrame,
6
+ FrameDecodeError,
7
+ FrameEncodeError,
8
+ MAX_SEQ,
9
+ type Frame,
10
+ } from "./frame.ts";
11
+ import { bytesToHex, hexToBytes } from "./hex.ts";
12
+ import { GOLDEN_FRAMES } from "./conformance/frames.ts";
13
+ import { MALFORMED_FRAMES } from "./conformance/malformed.ts";
14
+
15
+ test("golden frames: encode matches the committed wire hex", () => {
16
+ for (const golden of GOLDEN_FRAMES) {
17
+ assert.equal(bytesToHex(encodeFrame(golden.frame)), golden.hex, golden.name);
18
+ }
19
+ });
20
+
21
+ test("golden frames: decode reproduces the frame (round-trip both directions)", () => {
22
+ for (const golden of GOLDEN_FRAMES) {
23
+ const decoded = decodeFrame(hexToBytes(golden.hex));
24
+ assert.deepEqual(decoded, golden.frame, golden.name);
25
+ // And re-encoding the decoded frame is byte-identical.
26
+ assert.equal(bytesToHex(encodeFrame(decoded)), golden.hex, golden.name);
27
+ }
28
+ });
29
+
30
+ test("malformed frames: decode rejects with the specified error code", () => {
31
+ for (const vector of MALFORMED_FRAMES) {
32
+ assert.throws(
33
+ () => decodeFrame(hexToBytes(vector.hex)),
34
+ (error: unknown) => {
35
+ assert.ok(error instanceof FrameDecodeError, `${vector.name}: expected FrameDecodeError`);
36
+ assert.equal(error.code, vector.expected, vector.name);
37
+ return true;
38
+ },
39
+ vector.name,
40
+ );
41
+ }
42
+ });
43
+
44
+ test("decode: decodes a frame subarray with a non-zero byteOffset correctly", () => {
45
+ // Guards the DataView(byteOffset,length) wiring: a valid frame embedded in a
46
+ // larger buffer must decode from its subarray view, not from offset 0.
47
+ const golden = GOLDEN_FRAMES[0];
48
+ const bytes = hexToBytes(golden.hex);
49
+ const padded = new Uint8Array(bytes.length + 5);
50
+ padded.set(bytes, 5);
51
+ const view = padded.subarray(5);
52
+ assert.deepEqual(decodeFrame(view), golden.frame);
53
+ });
54
+
55
+ test("encode: rejects an invalid lane", () => {
56
+ const frame: Frame = JSON.parse('{"lane":"urgent","family":"relay","seq":0,"payload":null}');
57
+ assert.throws(
58
+ () => encodeFrame(frame),
59
+ (error: unknown) => error instanceof FrameEncodeError && error.code === "invalid-lane",
60
+ );
61
+ });
62
+
63
+ test("encode: rejects an invalid family", () => {
64
+ const frame: Frame = JSON.parse('{"lane":"control","family":"gossip","seq":0,"payload":null}');
65
+ assert.throws(
66
+ () => encodeFrame(frame),
67
+ (error: unknown) => error instanceof FrameEncodeError && error.code === "invalid-family",
68
+ );
69
+ });
70
+
71
+ test("encode: rejects out-of-range seq (boundary above uint32 max)", () => {
72
+ const frame: Frame = { lane: "control", family: "heartbeat", seq: MAX_SEQ + 1, payload: null };
73
+ assert.throws(
74
+ () => encodeFrame(frame),
75
+ (error: unknown) => error instanceof FrameEncodeError && error.code === "invalid-seq",
76
+ );
77
+ });
78
+
79
+ test("encode: rejects a non-integer seq", () => {
80
+ const frame: Frame = { lane: "control", family: "heartbeat", seq: 1.5, payload: null };
81
+ assert.throws(
82
+ () => encodeFrame(frame),
83
+ (error: unknown) => error instanceof FrameEncodeError && error.code === "invalid-seq",
84
+ );
85
+ });
86
+
87
+ test("encode: rejects an unserialisable payload (bigint)", () => {
88
+ const frame: Frame = { lane: "bulk", family: "relay", seq: 0, payload: { n: 1n } };
89
+ assert.throws(
90
+ () => encodeFrame(frame),
91
+ (error: unknown) => error instanceof FrameEncodeError && error.code === "unserialisable-payload",
92
+ );
93
+ });
94
+
95
+ test("encode: rejects a top-level undefined payload (not coerced to null)", () => {
96
+ // A top-level `undefined` is not valid JSON; the codec must reject it rather
97
+ // than silently coerce it to `null`, which would mask a caller that forgot to
98
+ // set a payload and make the encoding non-invertible.
99
+ const frame: Frame = { lane: "bulk", family: "relay", seq: 0, payload: undefined };
100
+ assert.throws(
101
+ () => encodeFrame(frame),
102
+ (error: unknown) => error instanceof FrameEncodeError && error.code === "unserialisable-payload",
103
+ );
104
+ });
105
+
106
+ test("encode: a null payload round-trips (null is valid JSON)", () => {
107
+ const frame: Frame = { lane: "control", family: "heartbeat", seq: 0, payload: null };
108
+ assert.deepEqual(decodeFrame(encodeFrame(frame)), frame);
109
+ });
110
+
111
+ test("encode: seq boundaries 0 and uint32 max round-trip", () => {
112
+ for (const seq of [0, MAX_SEQ]) {
113
+ const frame: Frame = { lane: "control", family: "heartbeat", seq, payload: { instance: "w" } };
114
+ assert.deepEqual(decodeFrame(encodeFrame(frame)), frame);
115
+ }
116
+ });
@@ -0,0 +1,171 @@
1
+ import {
2
+ FAMILY_CODES,
3
+ familyForCode,
4
+ isMessageFamily,
5
+ type MessageFamily,
6
+ } from "./families.ts";
7
+ import { LANE_CODES, laneForCode, isQosLane, type QosLane } from "./lanes.ts";
8
+
9
+ /**
10
+ * A single agentic-channel frame: the QoS lane it rides, the message family it
11
+ * belongs to, a monotonic sequence number (used by the relay for
12
+ * resume-from-offset), and a JSON-serialisable family payload.
13
+ *
14
+ * The codec is an ENVELOPE codec — it does not interpret `payload` beyond
15
+ * round-tripping it as JSON. Per-family payload shape is validated separately
16
+ * (see `payloads.ts`).
17
+ */
18
+ export interface Frame {
19
+ readonly lane: QosLane;
20
+ readonly family: MessageFamily;
21
+ readonly seq: number;
22
+ readonly payload: unknown;
23
+ }
24
+
25
+ /**
26
+ * Wire layout (all integers big-endian, unsigned):
27
+ *
28
+ * offset size field
29
+ * 0 2 magic 0x4E41 ("NA")
30
+ * 2 1 version = 1
31
+ * 3 1 lane code 0=control | 1=interactive | 2=bulk
32
+ * 4 1 family code 1..7 (see FAMILY_CODES)
33
+ * 5 4 seq uint32
34
+ * 9 4 payloadLen uint32 (bytes of UTF-8 JSON that follow)
35
+ * 13 N payload UTF-8 JSON
36
+ */
37
+ export const FRAME_MAGIC = 0x4e41;
38
+ export const FRAME_VERSION = 1;
39
+ export const FRAME_HEADER_BYTES = 13;
40
+ export const MAX_SEQ = 0xffffffff;
41
+
42
+ export type FrameDecodeErrorCode =
43
+ | "empty"
44
+ | "short-header"
45
+ | "bad-magic"
46
+ | "unsupported-version"
47
+ | "unknown-lane"
48
+ | "unknown-family"
49
+ | "truncated-payload"
50
+ | "trailing-bytes"
51
+ | "invalid-payload-json";
52
+
53
+ export class FrameDecodeError extends Error {
54
+ readonly code: FrameDecodeErrorCode;
55
+ constructor(code: FrameDecodeErrorCode, message: string) {
56
+ super(message);
57
+ this.name = "FrameDecodeError";
58
+ this.code = code;
59
+ }
60
+ }
61
+
62
+ export type FrameEncodeErrorCode =
63
+ | "invalid-lane"
64
+ | "invalid-family"
65
+ | "invalid-seq"
66
+ | "unserialisable-payload";
67
+
68
+ export class FrameEncodeError extends Error {
69
+ readonly code: FrameEncodeErrorCode;
70
+ constructor(code: FrameEncodeErrorCode, message: string) {
71
+ super(message);
72
+ this.name = "FrameEncodeError";
73
+ this.code = code;
74
+ }
75
+ }
76
+
77
+ export function encodeFrame(frame: Frame): Uint8Array {
78
+ if (!isQosLane(frame.lane)) {
79
+ throw new FrameEncodeError("invalid-lane", `unknown QoS lane: ${String(frame.lane)}`);
80
+ }
81
+ if (!isMessageFamily(frame.family)) {
82
+ throw new FrameEncodeError("invalid-family", `unknown message family: ${String(frame.family)}`);
83
+ }
84
+ if (!Number.isInteger(frame.seq) || frame.seq < 0 || frame.seq > MAX_SEQ) {
85
+ throw new FrameEncodeError("invalid-seq", `seq must be a uint32, got: ${String(frame.seq)}`);
86
+ }
87
+
88
+ // Encode `payload` as-is: a top-level `undefined` is NOT valid JSON
89
+ // (`JSON.stringify(undefined) === undefined`), so it is rejected rather than
90
+ // silently coerced to `null` — that keeps the codec invertible and surfaces a
91
+ // caller that forgot to set a payload. A `null` payload is valid JSON and
92
+ // round-trips.
93
+ let json: string | undefined;
94
+ try {
95
+ json = JSON.stringify(frame.payload);
96
+ } catch {
97
+ json = undefined;
98
+ }
99
+ if (json === undefined) {
100
+ throw new FrameEncodeError("unserialisable-payload", "payload is not JSON-serialisable");
101
+ }
102
+
103
+ const payloadBytes = new TextEncoder().encode(json);
104
+ const out = new Uint8Array(FRAME_HEADER_BYTES + payloadBytes.length);
105
+ const view = new DataView(out.buffer);
106
+ view.setUint16(0, FRAME_MAGIC);
107
+ view.setUint8(2, FRAME_VERSION);
108
+ view.setUint8(3, LANE_CODES[frame.lane]);
109
+ view.setUint8(4, FAMILY_CODES[frame.family]);
110
+ view.setUint32(5, frame.seq);
111
+ view.setUint32(9, payloadBytes.length);
112
+ out.set(payloadBytes, FRAME_HEADER_BYTES);
113
+ return out;
114
+ }
115
+
116
+ export function decodeFrame(bytes: Uint8Array): Frame {
117
+ if (bytes.length === 0) {
118
+ throw new FrameDecodeError("empty", "cannot decode an empty buffer");
119
+ }
120
+ if (bytes.length < FRAME_HEADER_BYTES) {
121
+ throw new FrameDecodeError(
122
+ "short-header",
123
+ `need at least ${FRAME_HEADER_BYTES} header bytes, got ${bytes.length}`,
124
+ );
125
+ }
126
+
127
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
128
+ if (view.getUint16(0) !== FRAME_MAGIC) {
129
+ throw new FrameDecodeError("bad-magic", "frame magic mismatch");
130
+ }
131
+ const version = view.getUint8(2);
132
+ if (version !== FRAME_VERSION) {
133
+ throw new FrameDecodeError("unsupported-version", `unsupported frame version: ${version}`);
134
+ }
135
+
136
+ const lane = laneForCode(view.getUint8(3));
137
+ if (lane === undefined) {
138
+ throw new FrameDecodeError("unknown-lane", `unknown lane code: ${view.getUint8(3)}`);
139
+ }
140
+ const family = familyForCode(view.getUint8(4));
141
+ if (family === undefined) {
142
+ throw new FrameDecodeError("unknown-family", `unknown family code: ${view.getUint8(4)}`);
143
+ }
144
+
145
+ const seq = view.getUint32(5);
146
+ const payloadLen = view.getUint32(9);
147
+ const end = FRAME_HEADER_BYTES + payloadLen;
148
+ if (end > bytes.length) {
149
+ throw new FrameDecodeError(
150
+ "truncated-payload",
151
+ `payload length ${payloadLen} exceeds available ${bytes.length - FRAME_HEADER_BYTES} bytes`,
152
+ );
153
+ }
154
+ if (end < bytes.length) {
155
+ throw new FrameDecodeError(
156
+ "trailing-bytes",
157
+ `${bytes.length - end} trailing byte(s) after payload`,
158
+ );
159
+ }
160
+
161
+ const payloadBytes = bytes.subarray(FRAME_HEADER_BYTES, end);
162
+ let payload: unknown;
163
+ try {
164
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(payloadBytes);
165
+ payload = JSON.parse(text);
166
+ } catch {
167
+ throw new FrameDecodeError("invalid-payload-json", "payload is not valid UTF-8 JSON");
168
+ }
169
+
170
+ return { lane, family, seq, payload };
171
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Language-neutral hex helpers so the conformance corpus can express golden
3
+ * frames as hex strings that any consumer (this repo, jwulf/c8ctl-plugin-nano,
4
+ * a future non-JS client) can decode identically.
5
+ */
6
+
7
+ export function bytesToHex(bytes: Uint8Array): string {
8
+ let out = "";
9
+ for (const byte of bytes) {
10
+ out += byte.toString(16).padStart(2, "0");
11
+ }
12
+ return out;
13
+ }
14
+
15
+ export function hexToBytes(hex: string): Uint8Array {
16
+ if (hex.length % 2 !== 0) {
17
+ throw new Error("hex string must have an even length");
18
+ }
19
+ const out = new Uint8Array(hex.length / 2);
20
+ for (let i = 0; i < out.length; i++) {
21
+ const pair = hex.slice(i * 2, i * 2 + 2);
22
+ if (!/^[0-9a-fA-F]{2}$/.test(pair)) {
23
+ throw new Error(`invalid hex byte at index ${i * 2}`);
24
+ }
25
+ out[i] = Number.parseInt(pair, 16);
26
+ }
27
+ return out;
28
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @nanobpm/agentic/protocol — the wire contract for the Nano agentic protocol
3
+ * (ADR 0056): one app-tier channel carrying agent presence/registry,
4
+ * demand×supply, the blackboard and live terminal relay.
5
+ *
6
+ * This package is the single source of truth every other slice keys off:
7
+ * - the canonical message-family set ({@link MESSAGE_FAMILIES}),
8
+ * - the three QoS lanes ({@link QOS_LANES}),
9
+ * - the frame codec ({@link encodeFrame} / {@link decodeFrame}),
10
+ * - the routing-token grammar ({@link parseToken}),
11
+ * - the vocab-artifact schema ({@link validateVocabDocument}), and
12
+ * - per-family payload contracts ({@link validatePayload}).
13
+ *
14
+ * The shared conformance corpus is published at the
15
+ * `@nanobpm/agentic/protocol/conformance` subpath.
16
+ */
17
+ export {
18
+ MESSAGE_FAMILIES,
19
+ FAMILY_CODES,
20
+ familyForCode,
21
+ isMessageFamily,
22
+ type MessageFamily,
23
+ } from "./families.ts";
24
+
25
+ export {
26
+ QOS_LANES,
27
+ LANE_CODES,
28
+ laneForCode,
29
+ lanePriority,
30
+ isQosLane,
31
+ compareFrameOrder,
32
+ type QosLane,
33
+ } from "./lanes.ts";
34
+
35
+ export {
36
+ encodeFrame,
37
+ decodeFrame,
38
+ FrameDecodeError,
39
+ FrameEncodeError,
40
+ FRAME_MAGIC,
41
+ FRAME_VERSION,
42
+ FRAME_HEADER_BYTES,
43
+ MAX_SEQ,
44
+ type Frame,
45
+ type FrameDecodeErrorCode,
46
+ type FrameEncodeErrorCode,
47
+ } from "./frame.ts";
48
+
49
+ export {
50
+ parseToken,
51
+ formatToken,
52
+ isValidToken,
53
+ isSegmentName,
54
+ isSeatLabel,
55
+ TokenParseError,
56
+ type RoutingToken,
57
+ type TokenParseErrorCode,
58
+ } from "./token.ts";
59
+
60
+ export {
61
+ validateVocabDocument,
62
+ type VocabDocument,
63
+ type VocabNetwork,
64
+ type VocabRole,
65
+ type VocabError,
66
+ type VocabValidationResult,
67
+ } from "./vocab/schema.ts";
68
+
69
+ export {
70
+ validatePayload,
71
+ type Capability,
72
+ type RegisterPayload,
73
+ type HeartbeatPayload,
74
+ type DeregisterPayload,
75
+ type ServePayload,
76
+ type DemandPayload,
77
+ type BlackboardPayload,
78
+ type BlackboardOp,
79
+ type RelayPayload,
80
+ type PayloadError,
81
+ type PayloadValidationResult,
82
+ } from "./payloads.ts";
83
+
84
+ export { bytesToHex, hexToBytes } from "./hex.ts";