@moxxy/sdk 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 (211) hide show
  1. package/dist/agent.d.ts +39 -0
  2. package/dist/agent.d.ts.map +1 -0
  3. package/dist/agent.js +2 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/cache-strategy.d.ts +35 -0
  6. package/dist/cache-strategy.d.ts.map +1 -0
  7. package/dist/cache-strategy.js +2 -0
  8. package/dist/cache-strategy.js.map +1 -0
  9. package/dist/channel.d.ts +146 -0
  10. package/dist/channel.d.ts.map +1 -0
  11. package/dist/channel.js +2 -0
  12. package/dist/channel.js.map +1 -0
  13. package/dist/client-session.d.ts +83 -0
  14. package/dist/client-session.d.ts.map +1 -0
  15. package/dist/client-session.js +2 -0
  16. package/dist/client-session.js.map +1 -0
  17. package/dist/command.d.ts +90 -0
  18. package/dist/command.d.ts.map +1 -0
  19. package/dist/command.js +15 -0
  20. package/dist/command.js.map +1 -0
  21. package/dist/compactor-helpers.d.ts +34 -0
  22. package/dist/compactor-helpers.d.ts.map +1 -0
  23. package/dist/compactor-helpers.js +156 -0
  24. package/dist/compactor-helpers.js.map +1 -0
  25. package/dist/compactor.d.ts +20 -0
  26. package/dist/compactor.d.ts.map +1 -0
  27. package/dist/compactor.js +2 -0
  28. package/dist/compactor.js.map +1 -0
  29. package/dist/define.d.ts +51 -0
  30. package/dist/define.d.ts.map +1 -0
  31. package/dist/define.js +71 -0
  32. package/dist/define.js.map +1 -0
  33. package/dist/elision-helpers.d.ts +27 -0
  34. package/dist/elision-helpers.d.ts.map +1 -0
  35. package/dist/elision-helpers.js +109 -0
  36. package/dist/elision-helpers.js.map +1 -0
  37. package/dist/elision-state.d.ts +49 -0
  38. package/dist/elision-state.d.ts.map +1 -0
  39. package/dist/elision-state.js +144 -0
  40. package/dist/elision-state.js.map +1 -0
  41. package/dist/embedding-cache.d.ts +32 -0
  42. package/dist/embedding-cache.d.ts.map +1 -0
  43. package/dist/embedding-cache.js +0 -0
  44. package/dist/embedding-cache.js.map +1 -0
  45. package/dist/embedding.d.ts +21 -0
  46. package/dist/embedding.d.ts.map +1 -0
  47. package/dist/embedding.js +2 -0
  48. package/dist/embedding.js.map +1 -0
  49. package/dist/errors.d.ts +81 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +241 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/events.d.ts +193 -0
  54. package/dist/events.d.ts.map +1 -0
  55. package/dist/events.js +2 -0
  56. package/dist/events.js.map +1 -0
  57. package/dist/hooks.d.ts +52 -0
  58. package/dist/hooks.d.ts.map +1 -0
  59. package/dist/hooks.js +2 -0
  60. package/dist/hooks.js.map +1 -0
  61. package/dist/ids.d.ts +18 -0
  62. package/dist/ids.d.ts.map +1 -0
  63. package/dist/ids.js +7 -0
  64. package/dist/ids.js.map +1 -0
  65. package/dist/index.d.ts +45 -0
  66. package/dist/index.d.ts.map +1 -0
  67. package/dist/index.js +18 -0
  68. package/dist/index.js.map +1 -0
  69. package/dist/install-hints.d.ts +26 -0
  70. package/dist/install-hints.d.ts.map +1 -0
  71. package/dist/install-hints.js +15 -0
  72. package/dist/install-hints.js.map +1 -0
  73. package/dist/isolation.d.ts +125 -0
  74. package/dist/isolation.d.ts.map +1 -0
  75. package/dist/isolation.js +11 -0
  76. package/dist/isolation.js.map +1 -0
  77. package/dist/log.d.ts +11 -0
  78. package/dist/log.d.ts.map +1 -0
  79. package/dist/log.js +2 -0
  80. package/dist/log.js.map +1 -0
  81. package/dist/mode-helpers.d.ts +99 -0
  82. package/dist/mode-helpers.d.ts.map +1 -0
  83. package/dist/mode-helpers.js +368 -0
  84. package/dist/mode-helpers.js.map +1 -0
  85. package/dist/mode.d.ts +133 -0
  86. package/dist/mode.d.ts.map +1 -0
  87. package/dist/mode.js +2 -0
  88. package/dist/mode.js.map +1 -0
  89. package/dist/permission.d.ts +35 -0
  90. package/dist/permission.d.ts.map +1 -0
  91. package/dist/permission.js +2 -0
  92. package/dist/permission.js.map +1 -0
  93. package/dist/plugin.d.ts +88 -0
  94. package/dist/plugin.d.ts.map +1 -0
  95. package/dist/plugin.js +2 -0
  96. package/dist/plugin.js.map +1 -0
  97. package/dist/provider-utils.d.ts +40 -0
  98. package/dist/provider-utils.d.ts.map +1 -0
  99. package/dist/provider-utils.js +163 -0
  100. package/dist/provider-utils.js.map +1 -0
  101. package/dist/provider.d.ts +219 -0
  102. package/dist/provider.d.ts.map +1 -0
  103. package/dist/provider.js +2 -0
  104. package/dist/provider.js.map +1 -0
  105. package/dist/requirements.d.ts +22 -0
  106. package/dist/requirements.d.ts.map +1 -0
  107. package/dist/requirements.js +2 -0
  108. package/dist/requirements.js.map +1 -0
  109. package/dist/schemas.d.ts +228 -0
  110. package/dist/schemas.d.ts.map +1 -0
  111. package/dist/schemas.js +88 -0
  112. package/dist/schemas.js.map +1 -0
  113. package/dist/session-like.d.ts +111 -0
  114. package/dist/session-like.d.ts.map +1 -0
  115. package/dist/session-like.js +2 -0
  116. package/dist/session-like.js.map +1 -0
  117. package/dist/skill.d.ts +30 -0
  118. package/dist/skill.d.ts.map +1 -0
  119. package/dist/skill.js +2 -0
  120. package/dist/skill.js.map +1 -0
  121. package/dist/subagent.d.ts +45 -0
  122. package/dist/subagent.d.ts.map +1 -0
  123. package/dist/subagent.js +11 -0
  124. package/dist/subagent.js.map +1 -0
  125. package/dist/token-accounting.d.ts +73 -0
  126. package/dist/token-accounting.d.ts.map +1 -0
  127. package/dist/token-accounting.js +129 -0
  128. package/dist/token-accounting.js.map +1 -0
  129. package/dist/tool-dispatch.d.ts +19 -0
  130. package/dist/tool-dispatch.d.ts.map +1 -0
  131. package/dist/tool-dispatch.js +118 -0
  132. package/dist/tool-dispatch.js.map +1 -0
  133. package/dist/tool-gating.d.ts +32 -0
  134. package/dist/tool-gating.d.ts.map +1 -0
  135. package/dist/tool-gating.js +83 -0
  136. package/dist/tool-gating.js.map +1 -0
  137. package/dist/tool.d.ts +166 -0
  138. package/dist/tool.d.ts.map +1 -0
  139. package/dist/tool.js +2 -0
  140. package/dist/tool.js.map +1 -0
  141. package/dist/transcriber.d.ts +64 -0
  142. package/dist/transcriber.d.ts.map +1 -0
  143. package/dist/transcriber.js +21 -0
  144. package/dist/transcriber.js.map +1 -0
  145. package/dist/tunnel.d.ts +25 -0
  146. package/dist/tunnel.d.ts.map +1 -0
  147. package/dist/tunnel.js +2 -0
  148. package/dist/tunnel.js.map +1 -0
  149. package/dist/type-contracts.d.ts +2 -0
  150. package/dist/type-contracts.d.ts.map +1 -0
  151. package/dist/type-contracts.js +4 -0
  152. package/dist/type-contracts.js.map +1 -0
  153. package/dist/types.test-d.d.ts +2 -0
  154. package/dist/types.test-d.d.ts.map +1 -0
  155. package/dist/types.test-d.js +22 -0
  156. package/dist/types.test-d.js.map +1 -0
  157. package/dist/view-renderer.d.ts +97 -0
  158. package/dist/view-renderer.d.ts.map +1 -0
  159. package/dist/view-renderer.js +72 -0
  160. package/dist/view-renderer.js.map +1 -0
  161. package/package.json +38 -0
  162. package/src/agent.ts +38 -0
  163. package/src/cache-strategy.ts +39 -0
  164. package/src/channel.ts +158 -0
  165. package/src/client-session.ts +88 -0
  166. package/src/command.ts +86 -0
  167. package/src/compactor-helpers.test.ts +206 -0
  168. package/src/compactor-helpers.ts +163 -0
  169. package/src/compactor.ts +20 -0
  170. package/src/define.test.ts +76 -0
  171. package/src/define.ts +113 -0
  172. package/src/elision-helpers.ts +143 -0
  173. package/src/elision-state.ts +170 -0
  174. package/src/embedding-cache.ts +0 -0
  175. package/src/embedding.ts +22 -0
  176. package/src/errors.test.ts +176 -0
  177. package/src/errors.ts +308 -0
  178. package/src/events.ts +250 -0
  179. package/src/hooks.ts +54 -0
  180. package/src/ids.ts +16 -0
  181. package/src/index.ts +304 -0
  182. package/src/install-hints.ts +43 -0
  183. package/src/isolation.ts +148 -0
  184. package/src/log.ts +11 -0
  185. package/src/loop-helpers.test.ts +78 -0
  186. package/src/mode-helpers.ts +483 -0
  187. package/src/mode.ts +139 -0
  188. package/src/package-root.test.ts +40 -0
  189. package/src/permission.ts +37 -0
  190. package/src/plugin.ts +92 -0
  191. package/src/provider-utils.test.ts +84 -0
  192. package/src/provider-utils.ts +171 -0
  193. package/src/provider.ts +195 -0
  194. package/src/requirements.ts +35 -0
  195. package/src/schemas.test.ts +106 -0
  196. package/src/schemas.ts +97 -0
  197. package/src/session-like.ts +118 -0
  198. package/src/skill.ts +34 -0
  199. package/src/subagent.ts +46 -0
  200. package/src/token-accounting.test.ts +93 -0
  201. package/src/token-accounting.ts +202 -0
  202. package/src/token-efficiency.test.ts +459 -0
  203. package/src/tool-dispatch.ts +137 -0
  204. package/src/tool-gating.ts +107 -0
  205. package/src/tool.ts +175 -0
  206. package/src/transcriber.ts +71 -0
  207. package/src/tunnel.ts +26 -0
  208. package/src/type-contracts.ts +4 -0
  209. package/src/types.test-d.ts +29 -0
  210. package/src/view-renderer.test.ts +90 -0
  211. package/src/view-renderer.ts +156 -0
package/src/tool.ts ADDED
@@ -0,0 +1,175 @@
1
+ import type { z } from 'zod';
2
+ import type { EventLogReader } from './log.js';
3
+ import type { PermissionRule } from './permission.js';
4
+ import type { SessionId, ToolCallId, TurnId } from './ids.js';
5
+ import type { SubagentSpawner } from './subagent.js';
6
+ import type { ToolIsolationSpec } from './isolation.js';
7
+
8
+ /**
9
+ * Capability-mediated filesystem operations injected by isolators that
10
+ * support brokering. Handlers can opt in by checking `ctx.fs` at runtime
11
+ * and using these instead of `node:fs`; the broker validates each call
12
+ * against the tool's declared `caps.fs` spec on the parent side before
13
+ * executing.
14
+ *
15
+ * When `ctx.fs` is undefined (the `none`/`inproc` paths today, or any
16
+ * isolator that hasn't implemented a broker yet), handlers fall back to
17
+ * unmediated direct fs access.
18
+ */
19
+ export interface BrokeredFs {
20
+ /** Read a UTF-8 file. Throws if the path is outside `caps.fs.read`. */
21
+ readFile(filePath: string, opts?: { encoding?: BufferEncoding }): Promise<string>;
22
+ /** Write a UTF-8 file (creates parent dirs). Throws if outside `caps.fs.write`. */
23
+ writeFile(filePath: string, data: string): Promise<void>;
24
+ /** List entries in a directory. Throws if outside `caps.fs.read`. */
25
+ readdir(dirPath: string): Promise<ReadonlyArray<string>>;
26
+ /** Stat a path. Throws if outside `caps.fs.read`. */
27
+ stat(filePath: string): Promise<BrokeredStat>;
28
+ }
29
+
30
+ export interface BrokeredStat {
31
+ readonly size: number;
32
+ readonly mtimeMs: number;
33
+ readonly isFile: boolean;
34
+ readonly isDirectory: boolean;
35
+ }
36
+
37
+ /**
38
+ * Capability-mediated `fetch`. Same shape as the global, but validated
39
+ * against `caps.net` on the parent side before the socket is opened.
40
+ *
41
+ * Returns a plain JSON-serializable response shape rather than the
42
+ * standard `Response` object — that's the price of crossing a process
43
+ * boundary cleanly.
44
+ */
45
+ export interface BrokeredFetch {
46
+ (url: string, init?: BrokeredFetchInit): Promise<BrokeredFetchResponse>;
47
+ }
48
+
49
+ export interface BrokeredFetchInit {
50
+ readonly method?: 'GET' | 'HEAD' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
51
+ readonly headers?: Readonly<Record<string, string>>;
52
+ readonly body?: string;
53
+ }
54
+
55
+ export interface BrokeredFetchResponse {
56
+ readonly status: number;
57
+ readonly statusText: string;
58
+ readonly headers: Readonly<Record<string, string>>;
59
+ readonly body: string;
60
+ }
61
+
62
+ /**
63
+ * Capability-mediated subprocess `exec`. Validated against `caps.subprocess`
64
+ * (must be true) and optional `caps.commands` allowlist on the parent side.
65
+ * Collects stdout/stderr and the exit code; for streaming use cases the
66
+ * broker layer will grow a separate streaming variant later.
67
+ */
68
+ export interface BrokeredExec {
69
+ (
70
+ command: string,
71
+ args?: ReadonlyArray<string>,
72
+ opts?: BrokeredExecOpts,
73
+ ): Promise<BrokeredExecResult>;
74
+ }
75
+
76
+ export interface BrokeredExecOpts {
77
+ readonly cwd?: string;
78
+ readonly env?: Readonly<Record<string, string>>;
79
+ readonly timeoutMs?: number;
80
+ }
81
+
82
+ export interface BrokeredExecResult {
83
+ readonly stdout: string;
84
+ readonly stderr: string;
85
+ readonly exitCode: number | null;
86
+ }
87
+
88
+ export interface ToolContext {
89
+ readonly sessionId: SessionId;
90
+ readonly turnId: TurnId;
91
+ readonly callId: ToolCallId;
92
+ readonly cwd: string;
93
+ readonly signal: AbortSignal;
94
+ readonly log: EventLogReader;
95
+ readonly logger: {
96
+ debug(msg: string, meta?: Record<string, unknown>): void;
97
+ info(msg: string, meta?: Record<string, unknown>): void;
98
+ warn(msg: string, meta?: Record<string, unknown>): void;
99
+ error(msg: string, meta?: Record<string, unknown>): void;
100
+ };
101
+ /**
102
+ * Spawner for child agents — present when the tool was invoked inside
103
+ * a run-turn loop (the normal case). Tools that fan work out (e.g.
104
+ * `dispatch_agent`) call `subagents.spawn(...)` to start a focused
105
+ * child loop and stream its events back to the parent log.
106
+ */
107
+ readonly subagents?: SubagentSpawner;
108
+ /**
109
+ * Capability-mediated filesystem ops. Present only when the active
110
+ * isolator implements a broker (currently `@moxxy/isolator-worker`).
111
+ * Handlers that use this get their fs access checked against
112
+ * `caps.fs` at every call, regardless of whether the path appeared
113
+ * in the validated input. Absent → handler is on its own for fs
114
+ * access (today's behavior).
115
+ */
116
+ readonly fs?: BrokeredFs;
117
+ /**
118
+ * Capability-mediated network. Present only when the active
119
+ * isolator implements a broker. Validates every URL against
120
+ * `caps.net` on the parent side. Returns a serializable response
121
+ * shape so the same value crosses a process boundary intact.
122
+ */
123
+ readonly fetch?: BrokeredFetch;
124
+ /**
125
+ * Capability-mediated subprocess execution. Present only when the
126
+ * active isolator implements a broker. Validated against
127
+ * `caps.subprocess` + optional `caps.commands` allowlist.
128
+ */
129
+ readonly exec?: BrokeredExec;
130
+ }
131
+
132
+ /**
133
+ * Optional presentation hint for compact rendering in TUI/chat surfaces.
134
+ * When present, the channel may aggregate consecutive calls of this tool
135
+ * into one "live block" with a verb+count summary, rather than rendering
136
+ * each call separately. Opting in is per-tool: noisy small-output tools
137
+ * (Read, Grep, Glob, Edit) benefit; tools with rich output (Bash,
138
+ * dispatch_agent) generally don't.
139
+ *
140
+ * Channels MAY ignore this hint — it's purely presentational. The event
141
+ * log and provider serialization don't see it.
142
+ */
143
+ export interface ToolCompactPresentation {
144
+ /** Present-participle verb used in summary, e.g. "Reading", "Searching for". */
145
+ readonly verb: string;
146
+ /** Noun for the count, pluralized e.g. `{ one: 'file', other: 'files' }`. */
147
+ readonly noun: { readonly one: string; readonly other: string };
148
+ /** Input field whose value previews the latest call (the line under the summary).
149
+ * e.g. `"file_path"` for Read so the preview shows the file just read. */
150
+ readonly previewKey?: string;
151
+ }
152
+
153
+ export interface ToolDef {
154
+ readonly name: string;
155
+ readonly description: string;
156
+ readonly inputSchema: z.ZodTypeAny;
157
+ /**
158
+ * Optional native JSON Schema. When present, providers serializing tools to
159
+ * their API should use this instead of converting `inputSchema` via zod.
160
+ * Useful for tools originating from external systems (e.g., MCP) that already
161
+ * carry a JSON Schema and where zod conversion would be lossy.
162
+ */
163
+ readonly inputJsonSchema?: unknown;
164
+ readonly outputSchema?: z.ZodTypeAny;
165
+ readonly permission?: PermissionRule;
166
+ readonly handler: (input: unknown, ctx: ToolContext) => Promise<unknown> | unknown;
167
+ /** Opt-in presentation hint. See `ToolCompactPresentation`. */
168
+ readonly compact?: ToolCompactPresentation;
169
+ /**
170
+ * Optional capability declaration. Advisory unless the user enables
171
+ * `@moxxy/plugin-security`, at which point the active `Isolator`
172
+ * enforces these bounds at every call. See `ToolIsolationSpec`.
173
+ */
174
+ readonly isolation?: ToolIsolationSpec;
175
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Transcribers convert audio bytes into text. They are a separate capability
3
+ * from `LLMProvider` because (a) most providers (Anthropic today) do not yet
4
+ * accept native audio, (b) users frequently want to pair a text provider with
5
+ * a dedicated STT backend (Whisper, Deepgram, AssemblyAI, local whisper.cpp),
6
+ * and (c) the same transcript may be used in many places — channels feeding
7
+ * voice notes into a turn, skills processing recorded meetings, etc.
8
+ *
9
+ * Plugins register a `TranscriberDef` via `PluginSpec.transcribers`. The
10
+ * core `TranscriberRegistry` holds them; channels call
11
+ * `session.transcribers.getActive()` (or `.get(name)`) to use one.
12
+ *
13
+ * Audio input is represented in two places once a `Transcriber` is wired:
14
+ * - `UserPromptAttachment` with `kind: 'audio'` — channel-level handoff,
15
+ * consumed by the channel and either transcribed or sent through as a
16
+ * native audio block (provider-permitting).
17
+ * - `ContentBlock` with `type: 'audio'` — provider-level wire format, only
18
+ * used by models that advertise `supportsAudio: true`.
19
+ */
20
+
21
+ export interface TranscriptionSegment {
22
+ /** Segment start, in seconds from the start of the clip. */
23
+ readonly start: number;
24
+ /** Segment end, in seconds. */
25
+ readonly end: number;
26
+ readonly text: string;
27
+ /** Optional speaker label when the backend supports diarization. */
28
+ readonly speaker?: string;
29
+ }
30
+
31
+ export interface TranscriptionResult {
32
+ /** Full concatenated transcript. */
33
+ readonly text: string;
34
+ /** BCP-47 language tag when the backend reports one (e.g. `en`, `pl`). */
35
+ readonly language?: string;
36
+ /** Clip length in seconds when known. */
37
+ readonly durationSec?: number;
38
+ /** Optional per-segment breakdown for richer downstream use. */
39
+ readonly segments?: ReadonlyArray<TranscriptionSegment>;
40
+ }
41
+
42
+ export interface TranscribeOptions {
43
+ /** MIME type of the audio bytes (e.g. `audio/ogg`, `audio/webm`). */
44
+ readonly mimeType?: string;
45
+ /** Hint the recognizer with a BCP-47 language tag. */
46
+ readonly language?: string;
47
+ /** Vocabulary / context hint passed to backends that support it (Whisper). */
48
+ readonly prompt?: string;
49
+ /** Cancellation signal — channels propagate the turn's abort here. */
50
+ readonly signal?: AbortSignal;
51
+ }
52
+
53
+ export interface Transcriber {
54
+ /** Short stable name, e.g. `openai-whisper-1`. */
55
+ readonly name: string;
56
+ transcribe(
57
+ audio: Uint8Array | ArrayBuffer,
58
+ opts?: TranscribeOptions,
59
+ ): Promise<TranscriptionResult>;
60
+ }
61
+
62
+ /**
63
+ * Plugin-side definition. Mirrors `ProviderDef`: a `createClient(config)`
64
+ * factory the registry calls when the user activates this transcriber.
65
+ */
66
+ export interface TranscriberDef {
67
+ readonly name: string;
68
+ /** Optional human-readable label for UI surfaces. */
69
+ readonly displayName?: string;
70
+ createClient(config: Record<string, unknown>): Transcriber;
71
+ }
package/src/tunnel.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * A swappable way to expose a locally-bound surface (the web channel) to the
3
+ * user over the public internet — so an agent on Telegram/TUI can hand the user
4
+ * a URL they can open. One provider is active per session (registered via
5
+ * plugins, like every other block); core seeds a `localhost` no-op provider so
6
+ * `getActive()` is non-null.
7
+ */
8
+ export interface TunnelOpenOptions {
9
+ readonly port: number;
10
+ readonly host: string;
11
+ }
12
+
13
+ export interface TunnelHandle {
14
+ /** The publicly reachable base URL (e.g. https://abc.trycloudflare.com). */
15
+ readonly url: string;
16
+ /** Tear the tunnel down (kill the subprocess, close the connection). */
17
+ close(): Promise<void>;
18
+ }
19
+
20
+ export interface TunnelProviderDef {
21
+ readonly name: string;
22
+ /** Open a tunnel to `http://host:port`, resolving once the public URL is known. */
23
+ open(opts: TunnelOpenOptions): Promise<TunnelHandle>;
24
+ /** Optional readiness gate (e.g. the `cloudflared` binary is installed). */
25
+ isAvailable?(): Promise<boolean>;
26
+ }
@@ -0,0 +1,4 @@
1
+ import { definePlugin } from './define.js';
2
+
3
+ // @ts-expect-error dependsOn was replaced by requirements and is no longer part of PluginSpec.
4
+ definePlugin({ name: 'legacy-dependency-plugin', dependsOn: ['base'] });
@@ -0,0 +1,29 @@
1
+ import { describe, expectTypeOf, it } from 'vitest';
2
+ import type {
3
+ MoxxyEvent,
4
+ MoxxyEventOfType,
5
+ ToolCallRequestedEvent,
6
+ UserPromptEvent,
7
+ } from './events.js';
8
+
9
+ describe('event union narrowing', () => {
10
+ it('MoxxyEventOfType narrows correctly', () => {
11
+ expectTypeOf<MoxxyEventOfType<'user_prompt'>>().toEqualTypeOf<UserPromptEvent>();
12
+ expectTypeOf<MoxxyEventOfType<'tool_call_requested'>>().toEqualTypeOf<ToolCallRequestedEvent>();
13
+ });
14
+
15
+ it('switch on type narrows the union', () => {
16
+ const handle = (e: MoxxyEvent) => {
17
+ if (e.type === 'user_prompt') {
18
+ expectTypeOf(e).toEqualTypeOf<UserPromptEvent>();
19
+ return e.text;
20
+ }
21
+ if (e.type === 'tool_call_requested') {
22
+ expectTypeOf(e).toEqualTypeOf<ToolCallRequestedEvent>();
23
+ return e.name;
24
+ }
25
+ return null;
26
+ };
27
+ expectTypeOf(handle).returns.toEqualTypeOf<string | null>();
28
+ });
29
+ });
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ VIEW_PRIMITIVES,
4
+ VIEW_COMPONENTS,
5
+ DEFAULT_VIEW_TAGS,
6
+ defineViewRenderer,
7
+ defineTunnelProvider,
8
+ type ViewTagSpec,
9
+ } from './index.js';
10
+
11
+ describe('view vocabulary integrity', () => {
12
+ const all = DEFAULT_VIEW_TAGS;
13
+
14
+ it('DEFAULT_VIEW_TAGS is primitives + components', () => {
15
+ expect(all.length).toBe(VIEW_PRIMITIVES.length + VIEW_COMPONENTS.length);
16
+ });
17
+
18
+ it('has no duplicate tag names', () => {
19
+ const names = all.map((t) => t.tag);
20
+ expect(new Set(names).size).toBe(names.length);
21
+ });
22
+
23
+ it('every tag has a valid allowedChildren', () => {
24
+ for (const t of all) {
25
+ const ac = t.allowedChildren;
26
+ const valid = ac === 'any' || ac === 'none' || Array.isArray(ac);
27
+ expect(valid, `${t.tag} allowedChildren`).toBe(true);
28
+ }
29
+ });
30
+
31
+ it('every array allowedChildren references known tags', () => {
32
+ const known = new Set(all.map((t) => t.tag));
33
+ for (const t of all) {
34
+ if (Array.isArray(t.allowedChildren)) {
35
+ for (const child of t.allowedChildren) {
36
+ expect(known.has(child), `${t.tag} -> ${child}`).toBe(true);
37
+ }
38
+ }
39
+ }
40
+ });
41
+
42
+ it('enum attrs declare their allowed values; number attrs may declare bounds', () => {
43
+ for (const t of all) {
44
+ for (const [name, spec] of Object.entries(t.attrs)) {
45
+ if (spec.type === 'enum') {
46
+ expect(Array.isArray(spec.values) && spec.values.length > 0, `${t.tag}.${name}`).toBe(true);
47
+ }
48
+ }
49
+ }
50
+ });
51
+
52
+ it('marks interactive elements and rich components correctly', () => {
53
+ const byTag = (tag: string): ViewTagSpec => all.find((t) => t.tag === tag)!;
54
+ expect(byTag('form').interactive).toBe(true);
55
+ expect(byTag('button').interactive).toBe(true);
56
+ expect(byTag('text').interactive).toBeUndefined();
57
+ expect(VIEW_COMPONENTS.every((c) => c.component)).toBe(true);
58
+ expect(VIEW_PRIMITIVES.every((p) => !p.component)).toBe(true);
59
+ });
60
+
61
+ it('declares the expected required attributes', () => {
62
+ const required = (tag: string) =>
63
+ Object.entries(DEFAULT_VIEW_TAGS.find((t) => t.tag === tag)!.attrs)
64
+ .filter(([, s]) => s.required)
65
+ .map(([n]) => n)
66
+ .sort();
67
+ expect(required('input')).toEqual(['name']);
68
+ expect(required('form')).toEqual(['action']);
69
+ // action is optional on button now (a button may navigate via `to` instead).
70
+ expect(required('button')).toEqual(['label']);
71
+ expect(required('image')).toEqual(['src']);
72
+ expect(required('option')).toEqual(['value']);
73
+ expect(required('result')).toEqual(['title']);
74
+ });
75
+ });
76
+
77
+ describe('define factories freeze their specs', () => {
78
+ it('defineViewRenderer', () => {
79
+ const def = defineViewRenderer({ name: 'x', allowList: [], parse: () => ({ ok: false, errors: [] }), validate: () => [] });
80
+ expect(Object.isFrozen(def)).toBe(true);
81
+ expect(() => {
82
+ (def as { name: string }).name = 'y';
83
+ }).toThrow();
84
+ });
85
+
86
+ it('defineTunnelProvider', () => {
87
+ const def = defineTunnelProvider({ name: 't', open: () => Promise.resolve({ url: 'http://x', close: () => Promise.resolve() }) });
88
+ expect(Object.isFrozen(def)).toBe(true);
89
+ });
90
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Agent-authored UI view-spec — the contract shared by the parser (core) and
3
+ * the renderer (web channel frontend).
4
+ *
5
+ * An agent emits a small JSX/XML-like document; a {@link ViewRendererDef} parses
6
+ * it into a validated {@link ViewDoc} AST against a strict tag/attribute
7
+ * **allow-list** (no raw HTML / script passthrough). One renderer is active per
8
+ * session, registered via plugins exactly like compactors / cache-strategies.
9
+ *
10
+ * The tag vocabulary ({@link VIEW_PRIMITIVES} / {@link VIEW_COMPONENTS}) is plain
11
+ * data so the browser frontend can import the SAME allow-list the parser
12
+ * validates against — preventing the two-list drift that would open an XSS gap.
13
+ */
14
+
15
+ /** A node in the validated view AST. After parsing, only primitives remain. */
16
+ export type ViewNode =
17
+ | {
18
+ readonly kind: 'element';
19
+ readonly tag: string;
20
+ readonly props: Readonly<Record<string, string | number | boolean>>;
21
+ readonly children: ReadonlyArray<ViewNode>;
22
+ /** Present on interactive elements (`form`, `button`) — drives an agent turn. */
23
+ readonly action?: ViewAction;
24
+ /**
25
+ * Client-side navigation target (a `view` `name`). Set from a `to` attr on
26
+ * `link`/`button`. The frontend switches to the named cached view WITHOUT an
27
+ * agent turn; if it isn't cached yet it falls back to a `navigate:<name>` turn.
28
+ */
29
+ readonly nav?: string;
30
+ }
31
+ | { readonly kind: 'text'; readonly value: string };
32
+
33
+ /** Declared on an interactive element; the transport turns it into a turn. */
34
+ export interface ViewAction {
35
+ /** Opaque name the agent chose, e.g. `search_flights`, `select:UA42`. */
36
+ readonly name: string;
37
+ /** Input `name`s whose current values this action carries back. */
38
+ readonly fields: ReadonlyArray<string>;
39
+ }
40
+
41
+ export interface ViewDoc {
42
+ readonly root: ViewNode;
43
+ readonly title?: string;
44
+ }
45
+
46
+ export interface ViewParseError {
47
+ readonly message: string;
48
+ readonly line?: number;
49
+ readonly col?: number;
50
+ }
51
+
52
+ export type ViewParseResult =
53
+ | { readonly ok: true; readonly doc: ViewDoc }
54
+ | { readonly ok: false; readonly errors: ReadonlyArray<ViewParseError> };
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Shared vocabulary — plain data, the single source of truth.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ export type AttrType = 'string' | 'number' | 'boolean' | 'enum';
61
+
62
+ export interface AttrSpec {
63
+ readonly type: AttrType;
64
+ readonly required?: boolean;
65
+ /** Allowed values when `type === 'enum'`. */
66
+ readonly values?: ReadonlyArray<string>;
67
+ /** Inclusive bounds when `type === 'number'`. */
68
+ readonly min?: number;
69
+ readonly max?: number;
70
+ }
71
+
72
+ export interface ViewTagSpec {
73
+ readonly tag: string;
74
+ readonly attrs: Readonly<Record<string, AttrSpec>>;
75
+ /** Allowed child tags; `'any'` = any element + text, `'none'` = void. */
76
+ readonly allowedChildren: ReadonlyArray<string> | 'any' | 'none';
77
+ /** Carries a {@link ViewAction} (the parser synthesizes `node.action`). */
78
+ readonly interactive?: boolean;
79
+ /** A rich component the renderer expands into primitives at parse time. */
80
+ readonly component?: boolean;
81
+ }
82
+
83
+ /**
84
+ * A swappable view-spec renderer. `parse` turns source into a validated AST
85
+ * (rich components already expanded to primitives); `validate` re-checks an
86
+ * already-built AST against {@link allowList} — note it runs on EXPANDED
87
+ * primitives, a contract replacement renderers must preserve.
88
+ */
89
+ export interface ViewRendererDef {
90
+ readonly name: string;
91
+ readonly allowList: ReadonlyArray<ViewTagSpec>;
92
+ parse(source: string): ViewParseResult;
93
+ validate(doc: ViewDoc): ReadonlyArray<ViewParseError>;
94
+ }
95
+
96
+ const TONE = ['default', 'muted', 'success', 'warn', 'danger'] as const;
97
+ const GAP = ['none', 'sm', 'md', 'lg'] as const;
98
+
99
+ /** Primitives — the frontend renders these directly (keymap === these tags). */
100
+ export const VIEW_PRIMITIVES: ReadonlyArray<ViewTagSpec> = [
101
+ // layout / containers
102
+ { tag: 'view', attrs: { title: { type: 'string' }, name: { type: 'string' } }, allowedChildren: 'any' },
103
+ { tag: 'stack', attrs: { gap: { type: 'enum', values: GAP }, align: { type: 'enum', values: ['start', 'center', 'end', 'stretch'] } }, allowedChildren: 'any' },
104
+ { tag: 'row', attrs: { gap: { type: 'enum', values: GAP }, align: { type: 'enum', values: ['start', 'center', 'end', 'stretch'] }, justify: { type: 'enum', values: ['start', 'center', 'end', 'between'] } }, allowedChildren: 'any' },
105
+ { tag: 'grid', attrs: { cols: { type: 'number', required: true, min: 1, max: 6 }, gap: { type: 'enum', values: GAP } }, allowedChildren: 'any' },
106
+ { tag: 'card', attrs: { title: { type: 'string' }, accent: { type: 'enum', values: TONE } }, allowedChildren: 'any' },
107
+ { tag: 'divider', attrs: {}, allowedChildren: 'none' },
108
+ // loading states (show while fetching real data, then replace with results)
109
+ { tag: 'spinner', attrs: { label: { type: 'string' } }, allowedChildren: 'none' },
110
+ { tag: 'skeleton', attrs: { rows: { type: 'number', min: 1, max: 12 } }, allowedChildren: 'none' },
111
+ // display
112
+ { tag: 'heading', attrs: { level: { type: 'number', min: 1, max: 3 } }, allowedChildren: 'any' },
113
+ { tag: 'text', attrs: { tone: { type: 'enum', values: TONE }, weight: { type: 'enum', values: ['normal', 'bold'] } }, allowedChildren: 'any' },
114
+ { tag: 'badge', attrs: { tone: { type: 'enum', values: TONE } }, allowedChildren: 'any' },
115
+ { tag: 'image', attrs: { src: { type: 'string', required: true }, alt: { type: 'string' }, w: { type: 'number' }, h: { type: 'number' } }, allowedChildren: 'none' },
116
+ { tag: 'link', attrs: { href: { type: 'string' }, to: { type: 'string' } }, allowedChildren: 'any' },
117
+ { tag: 'list', attrs: { ordered: { type: 'boolean' } }, allowedChildren: ['item'] },
118
+ { tag: 'item', attrs: {}, allowedChildren: 'any' },
119
+ { tag: 'table', attrs: {}, allowedChildren: ['tr'] },
120
+ { tag: 'tr', attrs: {}, allowedChildren: ['th', 'td'] },
121
+ { tag: 'th', attrs: { align: { type: 'enum', values: ['left', 'center', 'right'] } }, allowedChildren: 'any' },
122
+ { tag: 'td', attrs: { align: { type: 'enum', values: ['left', 'center', 'right'] } }, allowedChildren: 'any' },
123
+ // inputs (only meaningful inside `form`)
124
+ { tag: 'form', attrs: { action: { type: 'string', required: true }, submit: { type: 'string' } }, allowedChildren: 'any', interactive: true },
125
+ { tag: 'input', attrs: { name: { type: 'string', required: true }, type: { type: 'enum', values: ['text', 'number', 'date', 'email'] }, label: { type: 'string' }, placeholder: { type: 'string' }, value: { type: 'string' }, required: { type: 'boolean' } }, allowedChildren: 'none' },
126
+ { tag: 'select', attrs: { name: { type: 'string', required: true }, label: { type: 'string' }, value: { type: 'string' }, required: { type: 'boolean' } }, allowedChildren: ['option'] },
127
+ { tag: 'option', attrs: { value: { type: 'string', required: true }, selected: { type: 'boolean' } }, allowedChildren: 'any' },
128
+ { tag: 'checkbox', attrs: { name: { type: 'string', required: true }, label: { type: 'string' }, checked: { type: 'boolean' } }, allowedChildren: 'none' },
129
+ // `action` drives an agent turn; `to` navigates client-side to a named view.
130
+ { tag: 'button', attrs: { action: { type: 'string' }, to: { type: 'string' }, label: { type: 'string', required: true }, variant: { type: 'enum', values: ['primary', 'secondary', 'danger'] }, fields: { type: 'string' } }, allowedChildren: 'none', interactive: true },
131
+ ];
132
+
133
+ /**
134
+ * Rich components — accepted by the parser, expanded to primitives, never reach the
135
+ * frontend. Generic + domain-agnostic: a `results` list of selectable `result`s is
136
+ * the backbone of any "search engine / platform for X" the agent builds.
137
+ */
138
+ export const VIEW_COMPONENTS: ReadonlyArray<ViewTagSpec> = [
139
+ { tag: 'results', attrs: {}, allowedChildren: ['result'], component: true },
140
+ {
141
+ tag: 'result',
142
+ attrs: {
143
+ title: { type: 'string', required: true },
144
+ subtitle: { type: 'string' },
145
+ badge: { type: 'string' },
146
+ id: { type: 'string' },
147
+ action: { type: 'string' }, // agent turn on select (default: open:<id>)
148
+ to: { type: 'string' }, // or client-side nav to a named view
149
+ },
150
+ allowedChildren: 'none',
151
+ component: true,
152
+ },
153
+ ];
154
+
155
+ /** The default renderer's full allow-list (primitives + components). */
156
+ export const DEFAULT_VIEW_TAGS: ReadonlyArray<ViewTagSpec> = [...VIEW_PRIMITIVES, ...VIEW_COMPONENTS];