@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/channel.ts ADDED
@@ -0,0 +1,158 @@
1
+ import type { PermissionResolver } from './permission.js';
2
+ import type { ClientSession } from './client-session.js';
3
+
4
+ /**
5
+ * A Channel is a bidirectional surface that drives a Session: it feeds user
6
+ * prompts in, renders assistant chunks + tool activity out, and implements a
7
+ * PermissionResolver so it can interrupt tool execution to ask the user.
8
+ *
9
+ * The TUI (Ink) and Telegram are both Channels. Future Slack / Discord / HTTP
10
+ * channels implement this same interface so the moxxy CLI binary (or any
11
+ * embedded consumer) can dispatch to them uniformly.
12
+ *
13
+ * The generic `TStartOpts` is the concrete options shape a given channel
14
+ * accepts.
15
+ */
16
+ export interface Channel<TStartOpts = unknown> {
17
+ /** Stable name (lowercase, single word). Used by dispatchers to look up by string. */
18
+ readonly name: string;
19
+
20
+ /** The PermissionResolver this channel installs on the session. */
21
+ readonly permissionResolver: PermissionResolver;
22
+
23
+ /**
24
+ * Begin running the channel. Returns a handle whose `running` promise
25
+ * resolves when the channel exits gracefully.
26
+ */
27
+ start(opts: TStartOpts): Promise<ChannelHandle>;
28
+ }
29
+
30
+ export interface ChannelHandle {
31
+ /**
32
+ * Resolves when the channel exits cleanly (user quit, SIGINT caught,
33
+ * upstream disconnected). Rejects on fatal error.
34
+ */
35
+ readonly running: Promise<void>;
36
+
37
+ /** Request graceful shutdown. Implementations should abort any in-flight work. */
38
+ stop(reason?: string): Promise<void>;
39
+ }
40
+
41
+ /** Common base shape for channel start options. */
42
+ export interface ChannelStartOptsBase {
43
+ readonly model?: string;
44
+ readonly systemPrompt?: string;
45
+ }
46
+
47
+ /**
48
+ * Standard dependencies that a channel factory receives. Channels pick what
49
+ * they need from this bag. Production CLI populates all of these; tests may
50
+ * pass only a subset.
51
+ */
52
+ export interface ChannelFactoryDeps {
53
+ /** Working directory for the channel (matches the Session's cwd). */
54
+ readonly cwd: string;
55
+ /** Optional encrypted-secret store (typed loosely — plugins import the concrete VaultStore type when needed). */
56
+ readonly vault?: unknown;
57
+ /** Optional structured logger. */
58
+ readonly logger?: {
59
+ debug?(msg: string, meta?: Record<string, unknown>): void;
60
+ info?(msg: string, meta?: Record<string, unknown>): void;
61
+ warn?(msg: string, meta?: Record<string, unknown>): void;
62
+ error?(msg: string, meta?: Record<string, unknown>): void;
63
+ };
64
+ /** Free-form per-channel overrides forwarded from the CLI invocation. */
65
+ readonly options?: Record<string, unknown>;
66
+ }
67
+
68
+ /**
69
+ * A registered, named factory for a Channel. Plugins contribute these via
70
+ * `definePlugin({ channels: [defineChannel(...)] })`. The CLI looks up by name
71
+ * and dispatches: `moxxy <name>` calls `def.create(deps).start({session,...})`.
72
+ */
73
+ export interface ChannelDef<TStartOpts = unknown> {
74
+ readonly name: string;
75
+ readonly description: string;
76
+ create(deps: ChannelFactoryDeps): Channel<TStartOpts>;
77
+ /**
78
+ * Optional runtime gate. Lets a channel declare "I can only run if these
79
+ * preconditions are met" (e.g., Telegram needs a token in the vault; TUI
80
+ * needs a TTY). The dispatcher uses this to filter the visible channel list
81
+ * and to give the user a helpful error before construction.
82
+ *
83
+ * Default: always available.
84
+ */
85
+ isAvailable?(deps: ChannelFactoryDeps): Promise<ChannelAvailability>;
86
+ /**
87
+ * One-shot subcommands the channel exposes. Routed as
88
+ * `moxxy channels <name> <subcommand>` by the CLI. Use this for
89
+ * channel-specific maintenance commands that don't need to run the channel
90
+ * (e.g., Telegram's `unpair`, `status`) — or that want to nudge a start with
91
+ * a flag (e.g., `pair` -> start with options.pair=true).
92
+ */
93
+ readonly subcommands?: Readonly<Record<string, ChannelSubcommand>>;
94
+ /**
95
+ * Name of a subcommand to run for a bare `moxxy <name>` invocation on a TTY -
96
+ * e.g. an interactive setup wizard. When absent, a bare invocation just
97
+ * starts the channel.
98
+ */
99
+ readonly interactiveCommand?: string;
100
+ }
101
+
102
+ export interface ChannelAvailability {
103
+ readonly ok: boolean;
104
+ /** Human-readable explanation when ok=false. Shown by `moxxy channels list`. */
105
+ readonly reason?: string;
106
+ }
107
+
108
+ /** Positional + flag args handed to a channel subcommand by the CLI. */
109
+ export interface ChannelCommandArgs {
110
+ readonly positional: ReadonlyArray<string>;
111
+ readonly flags: Readonly<Record<string, string | boolean | undefined>>;
112
+ }
113
+
114
+ /**
115
+ * Context handed to a channel subcommand. The CLI builds `deps` exactly like
116
+ * it does for `Channel.create()` so subcommands can:
117
+ * - inspect `deps.vault` for one-shot ops (unpair, status)
118
+ * - mutate `deps.options` and call `startChannel()` to launch the channel
119
+ * with extra start opts (e.g., pair=true)
120
+ */
121
+ export interface ChannelSubcommandContext {
122
+ readonly deps: ChannelFactoryDeps;
123
+ readonly args: ChannelCommandArgs;
124
+ /**
125
+ * Boot a session and run the channel by name. Returns the process exit code
126
+ * (0 on clean shutdown). Subcommands that want to "start with twist" call
127
+ * this with overrides instead of duplicating the start-loop themselves.
128
+ */
129
+ startChannel(options?: Readonly<Record<string, unknown>>): Promise<number>;
130
+ /**
131
+ * The booted session, so a subcommand can do channel-instance work like
132
+ * pairing.
133
+ */
134
+ readonly session: ClientSession;
135
+ }
136
+
137
+ export interface ChannelSubcommand {
138
+ readonly description: string;
139
+ run(ctx: ChannelSubcommandContext): Promise<number>;
140
+ }
141
+
142
+ /**
143
+ * Read-only registry of channels available in a Session. Implementation lives
144
+ * in @moxxy/core.
145
+ */
146
+ export interface ChannelRegistry {
147
+ list(): ReadonlyArray<ChannelDef>;
148
+ get(name: string): ChannelDef | undefined;
149
+ has(name: string): boolean;
150
+ /**
151
+ * Returns every channel paired with its current availability. Channels
152
+ * without an `isAvailable` hook are treated as `{ok: true}`.
153
+ */
154
+ listWithAvailability(deps: ChannelFactoryDeps): Promise<ReadonlyArray<{
155
+ readonly def: ChannelDef;
156
+ readonly availability: ChannelAvailability;
157
+ }>>;
158
+ }
@@ -0,0 +1,88 @@
1
+ import type { AgentDef } from './agent.js';
2
+ import type { CommandDef } from './command.js';
3
+ import type { ModeDef } from './mode.js';
4
+ import type { LLMProvider, ProviderDef } from './provider.js';
5
+ import type { RequirementCheck, MoxxyRequirement } from './requirements.js';
6
+ import type { SessionLike, SessionLogReader } from './session-like.js';
7
+ import type { Skill } from './skill.js';
8
+ import type { ToolDef } from './tool.js';
9
+ import type { Transcriber } from './transcriber.js';
10
+
11
+ /**
12
+ * `ClientSession` widens {@link SessionLike} with the read-and-act registry
13
+ * surface a rich interactive channel (the Ink TUI) actually touches. Both the
14
+ * in-process `Session` (whose concrete registries are supersets of these
15
+ * views) and the `RemoteSession` proxy implement it, so the TUI is written
16
+ * once against `ClientSession` and runs unchanged either way.
17
+ *
18
+ * The views are intentionally narrow - only what the TUI calls. Methods are
19
+ * declared method-style (bivariant) so a concrete `Session` registry satisfies
20
+ * the view even where its real signature is slightly wider. On a remote
21
+ * client, the facades behind these views are backed by the runner's `getInfo`
22
+ * snapshot for reads and by RPCs for the few mutating calls; genuinely
23
+ * server-only capabilities (voice transcription, MCP admin) degrade rather
24
+ * than pretend.
25
+ */
26
+
27
+ export interface ProvidersClientView {
28
+ getActive(): LLMProvider;
29
+ getActiveName(): string | null;
30
+ list(): ReadonlyArray<ProviderDef>;
31
+ setActive(name: string, config?: Record<string, unknown>): LLMProvider;
32
+ replace(def: ProviderDef, instance?: LLMProvider): void;
33
+ }
34
+
35
+ export interface ModesClientView {
36
+ list(): ReadonlyArray<ModeDef>;
37
+ getActive(): ModeDef;
38
+ setActive(name: string): void;
39
+ }
40
+
41
+ export interface ToolsClientView {
42
+ list(): ReadonlyArray<ToolDef>;
43
+ get(name: string): ToolDef | undefined;
44
+ }
45
+
46
+ export interface CommandsClientView {
47
+ get(name: string): CommandDef | undefined;
48
+ listForChannel(channel: string): ReadonlyArray<CommandDef>;
49
+ }
50
+
51
+ export interface SkillsClientView {
52
+ list(): ReadonlyArray<Skill>;
53
+ }
54
+
55
+ export interface AgentsClientView {
56
+ list(): ReadonlyArray<AgentDef>;
57
+ }
58
+
59
+ export interface TranscribersClientView {
60
+ getActiveName(): string | null;
61
+ has(name: string): boolean;
62
+ getActive(): Transcriber;
63
+ /** Active transcriber or null. Channels guard on this for audio input. */
64
+ tryGetActive(): Transcriber | null;
65
+ setActive(name: string, config?: Record<string, unknown>): Transcriber;
66
+ }
67
+
68
+ export interface RequirementsClientView {
69
+ check(requirements: ReadonlyArray<MoxxyRequirement>): RequirementCheck;
70
+ }
71
+
72
+ export interface PermissionsClientView {
73
+ addAllow(rule: { readonly name: string; readonly reason?: string }): Promise<void>;
74
+ }
75
+
76
+ export interface ClientSession extends SessionLike {
77
+ /** The mirror/real log, plus `clear()` for `/new`. */
78
+ readonly log: SessionLogReader & { clear(): void };
79
+ readonly providers: ProvidersClientView;
80
+ readonly modes: ModesClientView;
81
+ readonly tools: ToolsClientView;
82
+ readonly commands: CommandsClientView;
83
+ readonly skills: SkillsClientView;
84
+ readonly agents: AgentsClientView;
85
+ readonly transcribers: TranscribersClientView;
86
+ readonly requirements: RequirementsClientView;
87
+ readonly permissions: PermissionsClientView;
88
+ }
package/src/command.ts ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Commands — channel-agnostic actions the user can trigger with a
3
+ * `/<name>` prefix. Live on the Session's CommandRegistry so every
4
+ * channel (TUI slash menu, Telegram bot command, future HTTP `/cmd`
5
+ * endpoint) reads the same set. Plugins contribute commands via
6
+ * `PluginSpec.commands`.
7
+ *
8
+ * Why a registry instead of channel-local switch statements:
9
+ * /info works the same way whether the user types it in the TUI or
10
+ * sends `/info` to the Telegram bot. The handler returns a typed
11
+ * CommandOutput; each channel decides how to render it (TUI as a
12
+ * systemNotice, Telegram as a chat message, HTTP as JSON).
13
+ */
14
+
15
+ import type { SessionId } from './ids.js';
16
+
17
+ export interface CommandDef {
18
+ /** Name without the leading `/`. */
19
+ readonly name: string;
20
+ /** One-line description shown in `/help` and channel pickers. */
21
+ readonly description: string;
22
+ /**
23
+ * Usage hint for the arguments, e.g. `set <name> <value>`. Interactive
24
+ * channels may render it as dimmed ghost text after the command name
25
+ * while the user is typing (autocomplete preview). Placeholders like
26
+ * `<name>` are illustrative — never inserted into the buffer literally.
27
+ */
28
+ readonly argumentHint?: string;
29
+ /** Alternative names (without leading `/`). */
30
+ readonly aliases?: ReadonlyArray<string>;
31
+ /**
32
+ * When set, the command only surfaces in these channels by name
33
+ * (e.g. `['tui']` for an overlay-style command that wouldn't make
34
+ * sense in Telegram). Omit for "all channels".
35
+ */
36
+ readonly channels?: ReadonlyArray<string>;
37
+ /** Optional short status shown by interactive channels while the command runs. */
38
+ readonly pendingNotice?: string;
39
+ /** Handler invoked by the channel. Receives raw args after the name. */
40
+ readonly handler: (ctx: CommandContext) => Promise<CommandOutput> | CommandOutput;
41
+ }
42
+
43
+ export interface CommandContext {
44
+ /** Channel that invoked the command — `'tui'`, `'telegram'`, etc. */
45
+ readonly channel: string;
46
+ /** Session id the command runs against. */
47
+ readonly sessionId: SessionId;
48
+ /** Raw text after the command name (`"hello"` for `/echo hello`). */
49
+ readonly args: string;
50
+ /**
51
+ * The active Session. Loosely typed (`unknown`) so the SDK doesn't
52
+ * pull in core; handlers cast to `Session` from `@moxxy/core` when
53
+ * they need registries. The plugin host always passes the real
54
+ * Session here.
55
+ */
56
+ readonly session: unknown;
57
+ }
58
+
59
+ /**
60
+ * What a command returns. Channels pick the appropriate UI for each
61
+ * variant. New variants are additive — channels that don't know a
62
+ * variant fall back to rendering its `text` field if present.
63
+ */
64
+ export type CommandOutput =
65
+ /** Simple text response. Every channel can render this. */
66
+ | { readonly kind: 'text'; readonly text: string }
67
+ /**
68
+ * Structural action that the host channel performs in its UI
69
+ * (`clear`/`new`/`exit`). Carries an optional message the channel
70
+ * may surface alongside the action.
71
+ */
72
+ | {
73
+ readonly kind: 'session-action';
74
+ readonly action: 'new' | 'clear' | 'exit';
75
+ readonly notice?: string;
76
+ }
77
+ /** No-op (handler decided nothing needs surfacing). */
78
+ | { readonly kind: 'noop' }
79
+ /**
80
+ * Handler errored. Channels render this distinctly (red text, etc.).
81
+ * Use this instead of throwing so the command pipeline never
82
+ * crashes the channel.
83
+ */
84
+ | { readonly kind: 'error'; readonly message: string };
85
+
86
+ export type CommandHandlerResult = CommandOutput;
@@ -0,0 +1,206 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ asEventId,
4
+ asSessionId,
5
+ asTurnId,
6
+ estimateContextTokens,
7
+ runCompactionIfNeeded,
8
+ type CompactorDef,
9
+ type EmittedEvent,
10
+ type EventLogReader,
11
+ type LLMProvider,
12
+ type ModeContext,
13
+ type MoxxyEvent,
14
+ type MoxxyEventOfType,
15
+ type MoxxyEventType,
16
+ type TurnId,
17
+ } from './index.js';
18
+
19
+ const sid = asSessionId('s1');
20
+ const tid = asTurnId('t1');
21
+
22
+ describe('estimateContextTokens', () => {
23
+ it('counts char/4 over events, honoring compaction', () => {
24
+ const log = reader([
25
+ event(0, { type: 'user_prompt', turnId: tid, source: 'user', text: 'x'.repeat(400) }),
26
+ event(1, {
27
+ type: 'compaction',
28
+ turnId: tid,
29
+ source: 'compactor',
30
+ compactor: 'summarize',
31
+ replacedRange: [0, 0],
32
+ summary: 'y'.repeat(40),
33
+ tokensSaved: 90,
34
+ }),
35
+ ]);
36
+ // 400-char user_prompt is covered by the compaction; only the 40-char
37
+ // summary should count.
38
+ expect(estimateContextTokens(log)).toBe(10);
39
+ });
40
+ });
41
+
42
+ describe('runCompactionIfNeeded', () => {
43
+ it('is a no-op when no compactor is active', async () => {
44
+ const ctx = makeCtx({ compactor: null });
45
+ await runCompactionIfNeeded(ctx);
46
+ expect(ctx.emitted).toHaveLength(0);
47
+ });
48
+
49
+ it('is a no-op when shouldCompact returns false', async () => {
50
+ const shouldCompact = vi.fn().mockReturnValue(false);
51
+ const compact = vi.fn();
52
+ const ctx = makeCtx({
53
+ compactor: { name: 'fake', shouldCompact, compact },
54
+ });
55
+ await runCompactionIfNeeded(ctx);
56
+ expect(shouldCompact).toHaveBeenCalledOnce();
57
+ expect(compact).not.toHaveBeenCalled();
58
+ expect(ctx.emitted).toHaveLength(0);
59
+ });
60
+
61
+ it('passes the real model contextWindow into the budget', async () => {
62
+ let observedWindow = 0;
63
+ const compactor: CompactorDef = {
64
+ name: 'inspect',
65
+ shouldCompact: (_log, budget) => {
66
+ observedWindow = budget.contextWindow;
67
+ return false;
68
+ },
69
+ compact: async () => {
70
+ throw new Error('should not run');
71
+ },
72
+ };
73
+ const ctx = makeCtx({ compactor, model: 'm-200k', contextWindow: 200_000 });
74
+ await runCompactionIfNeeded(ctx);
75
+ expect(observedWindow).toBe(200_000);
76
+ });
77
+
78
+ it('emits the compaction event when shouldCompact returns true', async () => {
79
+ const compactor: CompactorDef = {
80
+ name: 'fake',
81
+ shouldCompact: () => true,
82
+ compact: async () => ({
83
+ type: 'compaction',
84
+ sessionId: sid,
85
+ turnId: tid,
86
+ source: 'compactor',
87
+ compactor: 'fake',
88
+ replacedRange: [0, 0],
89
+ summary: 'compressed',
90
+ tokensSaved: 100,
91
+ }),
92
+ };
93
+ const ctx = makeCtx({ compactor });
94
+ await runCompactionIfNeeded(ctx);
95
+ expect(ctx.emitted).toHaveLength(1);
96
+ expect(ctx.emitted[0]).toMatchObject({ type: 'compaction', summary: 'compressed' });
97
+ });
98
+
99
+ it('skips the emit when compact returns an empty/no-op result', async () => {
100
+ const compactor: CompactorDef = {
101
+ name: 'fake',
102
+ shouldCompact: () => true,
103
+ compact: async () => ({
104
+ type: 'compaction',
105
+ sessionId: sid,
106
+ turnId: tid,
107
+ source: 'compactor',
108
+ compactor: 'fake',
109
+ replacedRange: [0, 0],
110
+ summary: '',
111
+ tokensSaved: 0,
112
+ }),
113
+ };
114
+ const ctx = makeCtx({ compactor });
115
+ await runCompactionIfNeeded(ctx);
116
+ expect(ctx.emitted).toHaveLength(0);
117
+ });
118
+
119
+ it('emits a non-fatal error event when compact throws — turn must continue', async () => {
120
+ const compactor: CompactorDef = {
121
+ name: 'broken',
122
+ shouldCompact: () => true,
123
+ compact: async () => {
124
+ throw new Error('boom');
125
+ },
126
+ };
127
+ const ctx = makeCtx({ compactor });
128
+ await runCompactionIfNeeded(ctx);
129
+ expect(ctx.emitted).toHaveLength(1);
130
+ expect(ctx.emitted[0]).toMatchObject({ type: 'error', kind: 'retryable' });
131
+ });
132
+ });
133
+
134
+ interface MakeCtxOpts {
135
+ readonly compactor: CompactorDef | null;
136
+ readonly model?: string;
137
+ readonly contextWindow?: number;
138
+ readonly events?: ReadonlyArray<MoxxyEvent>;
139
+ }
140
+
141
+ function makeCtx(opts: MakeCtxOpts): ModeContext & { emitted: EmittedEvent[] } {
142
+ const events = opts.events ?? [
143
+ event(0, { type: 'user_prompt', turnId: tid, source: 'user', text: 'hi' }),
144
+ ];
145
+ const log = reader(events);
146
+ const provider = {
147
+ name: 'fake',
148
+ models: [
149
+ {
150
+ id: opts.model ?? 'fake-model',
151
+ contextWindow: opts.contextWindow ?? 100_000,
152
+ supportsTools: true,
153
+ supportsStreaming: true,
154
+ },
155
+ ],
156
+ stream: async function* () { /* unused */ },
157
+ countTokens: async () => 0,
158
+ } as unknown as LLMProvider;
159
+
160
+ const emitted: EmittedEvent[] = [];
161
+ const ctx = {
162
+ sessionId: sid,
163
+ turnId: tid,
164
+ model: opts.model ?? 'fake-model',
165
+ provider,
166
+ tools: { list: () => [], get: () => undefined, execute: async () => undefined },
167
+ skills: { list: () => [], get: () => undefined, byName: () => undefined, filterByTriggers: () => [] },
168
+ log,
169
+ compactor: opts.compactor,
170
+ permissions: { decide: async () => ({ allow: true }) },
171
+ hooks: {} as ModeContext['hooks'],
172
+ pluginHost: { list: () => [], reload: async () => {} },
173
+ signal: new AbortController().signal,
174
+ emit: async (e: EmittedEvent) => {
175
+ emitted.push(e);
176
+ return { ...e, id: asEventId(`e${emitted.length}`), seq: emitted.length, ts: emitted.length, sessionId: sid } as MoxxyEvent;
177
+ },
178
+ } as unknown as ModeContext;
179
+
180
+ return Object.assign(ctx, { emitted });
181
+ }
182
+
183
+ function reader(events: ReadonlyArray<MoxxyEvent>): EventLogReader {
184
+ return {
185
+ length: events.length,
186
+ at: (seq) => events[seq],
187
+ slice: (from = 0, to = events.length) => events.slice(from, to),
188
+ ofType: <T extends MoxxyEventType>(type: T): ReadonlyArray<MoxxyEventOfType<T>> =>
189
+ events.filter((e): e is MoxxyEventOfType<T> => e.type === type),
190
+ byTurn: (turnId: TurnId) => events.filter((e) => e.turnId === turnId),
191
+ toJSON: () => events,
192
+ };
193
+ }
194
+
195
+ function event(
196
+ seq: number,
197
+ partial: Omit<MoxxyEvent, 'id' | 'seq' | 'ts' | 'sessionId'>,
198
+ ): MoxxyEvent {
199
+ return {
200
+ id: asEventId(`e${seq}`),
201
+ seq,
202
+ ts: seq,
203
+ sessionId: sid,
204
+ ...partial,
205
+ } as MoxxyEvent;
206
+ }
@@ -0,0 +1,163 @@
1
+ import {
2
+ computeElisionState,
3
+ conversationalStub,
4
+ conversationalStubbed,
5
+ toolResultBytes,
6
+ toolResultStub,
7
+ toolResultStubbed,
8
+ } from './elision-state.js';
9
+ import type { EmittedEvent, MoxxyEvent } from './events.js';
10
+ import type { EventLogReader } from './log.js';
11
+ import type { ModeContext } from './mode.js';
12
+
13
+ /**
14
+ * Cheap, no-network estimate of how many tokens the current event log
15
+ * would consume on the next provider request. Char-based (chars/4) with
16
+ * compaction events honored — events covered by a CompactionEvent.replacedRange
17
+ * count as the (much shorter) summary rather than their original bytes — and
18
+ * elision honored: old tool results (seq ≤ the elision high-water mark) count
19
+ * as their ~stub size rather than their full payload, matching what
20
+ * `projectMessagesFromLog` actually sends.
21
+ *
22
+ * Used by the auto-compact helper (see `runCompactionIfNeeded`) and by
23
+ * the TUI's context meter. For perfect accuracy callers can use the
24
+ * provider's `countTokens(req)`; this is the fast path that doesn't
25
+ * touch the network and is safe to run on every iteration.
26
+ */
27
+ export function estimateContextTokens(log: EventLogReader): number {
28
+ const events = log.slice();
29
+ // Share the exact stub decision with projection so the estimate matches what
30
+ // is actually sent — pinned recalls / never-elide / tiny turns counted full,
31
+ // not undercounted (which would let the context overflow before compaction).
32
+ const el = computeElisionState(events);
33
+ let chars = 0;
34
+ const compactedSeqs = new Set<number>();
35
+ for (const e of events) {
36
+ if (e.type === 'compaction') {
37
+ for (let seq = e.replacedRange[0]; seq <= e.replacedRange[1]; seq++) {
38
+ compactedSeqs.add(seq);
39
+ }
40
+ chars += e.summary.length;
41
+ }
42
+ }
43
+ for (const e of events) {
44
+ if (compactedSeqs.has(e.seq)) continue;
45
+ if (e.type === 'tool_result' && toolResultStubbed(e, el)) {
46
+ const recalled = el.recalledCallIds.has(e.callId) || el.recalledSeqs.has(e.seq);
47
+ chars += toolResultStub(e.callId, toolResultBytes(e.output), recalled).length;
48
+ continue;
49
+ }
50
+ if ((e.type === 'user_prompt' || e.type === 'assistant_message') && conversationalStubbed(e, el)) {
51
+ chars += conversationalStub(e.type === 'user_prompt' ? 'user' : 'assistant', e.seq).length;
52
+ continue;
53
+ }
54
+ chars += eventChars(e);
55
+ }
56
+ return Math.ceil(chars / 4);
57
+ }
58
+
59
+ function eventChars(e: MoxxyEvent): number {
60
+ switch (e.type) {
61
+ case 'user_prompt':
62
+ return e.text.length;
63
+ case 'assistant_message':
64
+ return e.content.length;
65
+ case 'tool_call_requested':
66
+ return e.name.length + safeJsonLen(e.input);
67
+ case 'tool_result':
68
+ if (e.error) return (e.error.message?.length ?? 0) + 12;
69
+ if (typeof e.output === 'string') return e.output.length;
70
+ return safeJsonLen(e.output);
71
+ default:
72
+ return 0;
73
+ }
74
+ }
75
+
76
+ function safeJsonLen(v: unknown): number {
77
+ try {
78
+ return JSON.stringify(v ?? '').length;
79
+ } catch {
80
+ return 0;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Auto-compaction hook every mode calls once per iteration, right
86
+ * before building messages for the next provider call. Reads the
87
+ * active model's real `contextWindow` (not a magic max-int sentinel),
88
+ * estimates current token use, and — if the configured compactor's
89
+ * `shouldCompact` returns true — runs `compact()` and emits the
90
+ * resulting CompactionEvent onto the log. `projectMessagesFromLog`
91
+ * already honors compaction events, so the next provider call sees
92
+ * the summarized prefix automatically.
93
+ *
94
+ * Designed to be tolerant: no compactor, no model descriptor, no
95
+ * contextWindow, or a compactor throw all degrade to a no-op so a
96
+ * compactor bug can't kill the turn. Failures emit a non-fatal
97
+ * `error` event for observability.
98
+ */
99
+ export async function runCompactionIfNeeded(ctx: ModeContext): Promise<void> {
100
+ const compactor = ctx.compactor;
101
+ if (!compactor) return;
102
+
103
+ // Resolve the active model's descriptor so we use the *real* context
104
+ // window. `Number.MAX_SAFE_INTEGER` (the old /compact behavior) made
105
+ // threshold-based compactors always look comfortable, even at 99%.
106
+ const descriptor = ctx.provider.models.find((m) => m.id === ctx.model);
107
+ const contextWindow = descriptor?.contextWindow;
108
+ if (!contextWindow || contextWindow <= 0) return;
109
+
110
+ const events = ctx.log.slice();
111
+ if (events.length === 0) return;
112
+
113
+ const budget = {
114
+ contextWindow,
115
+ estimatedTokens: estimateContextTokens(ctx.log),
116
+ reserveForOutput: descriptor?.maxOutputTokens ?? 0,
117
+ } as const;
118
+
119
+ let shouldRun = false;
120
+ try {
121
+ shouldRun = compactor.shouldCompact(ctx.log, budget);
122
+ } catch (err) {
123
+ await ctx.emit({
124
+ type: 'error',
125
+ sessionId: ctx.sessionId,
126
+ turnId: ctx.turnId,
127
+ source: 'system',
128
+ kind: 'retryable',
129
+ message: `compactor.shouldCompact threw: ${err instanceof Error ? err.message : String(err)}`,
130
+ });
131
+ return;
132
+ }
133
+ if (!shouldRun) return;
134
+
135
+ try {
136
+ const result = await compactor.compact(events, {
137
+ log: ctx.log,
138
+ budget,
139
+ signal: ctx.signal,
140
+ });
141
+ if (result.tokensSaved <= 0 || result.summary.trim().length === 0) return;
142
+ // `compactor.compact` declares `Omit<CompactionEvent, keyof EventBase>`,
143
+ // but every shipped compactor (and the SDK examples) fills sessionId /
144
+ // turnId / source. Defensive-fill from ctx so a compactor that obeyed
145
+ // the type contract literally still emits a valid event.
146
+ const emittable: EmittedEvent = {
147
+ sessionId: ctx.sessionId,
148
+ turnId: ctx.turnId,
149
+ source: 'compactor',
150
+ ...result,
151
+ } as EmittedEvent;
152
+ await ctx.emit(emittable);
153
+ } catch (err) {
154
+ await ctx.emit({
155
+ type: 'error',
156
+ sessionId: ctx.sessionId,
157
+ turnId: ctx.turnId,
158
+ source: 'system',
159
+ kind: 'retryable',
160
+ message: `compactor.compact threw: ${err instanceof Error ? err.message : String(err)}`,
161
+ });
162
+ }
163
+ }