@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.
- package/dist/agent.d.ts +39 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +2 -0
- package/dist/agent.js.map +1 -0
- package/dist/cache-strategy.d.ts +35 -0
- package/dist/cache-strategy.d.ts.map +1 -0
- package/dist/cache-strategy.js +2 -0
- package/dist/cache-strategy.js.map +1 -0
- package/dist/channel.d.ts +146 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +2 -0
- package/dist/channel.js.map +1 -0
- package/dist/client-session.d.ts +83 -0
- package/dist/client-session.d.ts.map +1 -0
- package/dist/client-session.js +2 -0
- package/dist/client-session.js.map +1 -0
- package/dist/command.d.ts +90 -0
- package/dist/command.d.ts.map +1 -0
- package/dist/command.js +15 -0
- package/dist/command.js.map +1 -0
- package/dist/compactor-helpers.d.ts +34 -0
- package/dist/compactor-helpers.d.ts.map +1 -0
- package/dist/compactor-helpers.js +156 -0
- package/dist/compactor-helpers.js.map +1 -0
- package/dist/compactor.d.ts +20 -0
- package/dist/compactor.d.ts.map +1 -0
- package/dist/compactor.js +2 -0
- package/dist/compactor.js.map +1 -0
- package/dist/define.d.ts +51 -0
- package/dist/define.d.ts.map +1 -0
- package/dist/define.js +71 -0
- package/dist/define.js.map +1 -0
- package/dist/elision-helpers.d.ts +27 -0
- package/dist/elision-helpers.d.ts.map +1 -0
- package/dist/elision-helpers.js +109 -0
- package/dist/elision-helpers.js.map +1 -0
- package/dist/elision-state.d.ts +49 -0
- package/dist/elision-state.d.ts.map +1 -0
- package/dist/elision-state.js +144 -0
- package/dist/elision-state.js.map +1 -0
- package/dist/embedding-cache.d.ts +32 -0
- package/dist/embedding-cache.d.ts.map +1 -0
- package/dist/embedding-cache.js +0 -0
- package/dist/embedding-cache.js.map +1 -0
- package/dist/embedding.d.ts +21 -0
- package/dist/embedding.d.ts.map +1 -0
- package/dist/embedding.js +2 -0
- package/dist/embedding.js.map +1 -0
- package/dist/errors.d.ts +81 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +241 -0
- package/dist/errors.js.map +1 -0
- package/dist/events.d.ts +193 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +2 -0
- package/dist/events.js.map +1 -0
- package/dist/hooks.d.ts +52 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +2 -0
- package/dist/hooks.js.map +1 -0
- package/dist/ids.d.ts +18 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +7 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -0
- package/dist/install-hints.d.ts +26 -0
- package/dist/install-hints.d.ts.map +1 -0
- package/dist/install-hints.js +15 -0
- package/dist/install-hints.js.map +1 -0
- package/dist/isolation.d.ts +125 -0
- package/dist/isolation.d.ts.map +1 -0
- package/dist/isolation.js +11 -0
- package/dist/isolation.js.map +1 -0
- package/dist/log.d.ts +11 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +2 -0
- package/dist/log.js.map +1 -0
- package/dist/mode-helpers.d.ts +99 -0
- package/dist/mode-helpers.d.ts.map +1 -0
- package/dist/mode-helpers.js +368 -0
- package/dist/mode-helpers.js.map +1 -0
- package/dist/mode.d.ts +133 -0
- package/dist/mode.d.ts.map +1 -0
- package/dist/mode.js +2 -0
- package/dist/mode.js.map +1 -0
- package/dist/permission.d.ts +35 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +2 -0
- package/dist/permission.js.map +1 -0
- package/dist/plugin.d.ts +88 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +2 -0
- package/dist/plugin.js.map +1 -0
- package/dist/provider-utils.d.ts +40 -0
- package/dist/provider-utils.d.ts.map +1 -0
- package/dist/provider-utils.js +163 -0
- package/dist/provider-utils.js.map +1 -0
- package/dist/provider.d.ts +219 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +2 -0
- package/dist/provider.js.map +1 -0
- package/dist/requirements.d.ts +22 -0
- package/dist/requirements.d.ts.map +1 -0
- package/dist/requirements.js +2 -0
- package/dist/requirements.js.map +1 -0
- package/dist/schemas.d.ts +228 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +88 -0
- package/dist/schemas.js.map +1 -0
- package/dist/session-like.d.ts +111 -0
- package/dist/session-like.d.ts.map +1 -0
- package/dist/session-like.js +2 -0
- package/dist/session-like.js.map +1 -0
- package/dist/skill.d.ts +30 -0
- package/dist/skill.d.ts.map +1 -0
- package/dist/skill.js +2 -0
- package/dist/skill.js.map +1 -0
- package/dist/subagent.d.ts +45 -0
- package/dist/subagent.d.ts.map +1 -0
- package/dist/subagent.js +11 -0
- package/dist/subagent.js.map +1 -0
- package/dist/token-accounting.d.ts +73 -0
- package/dist/token-accounting.d.ts.map +1 -0
- package/dist/token-accounting.js +129 -0
- package/dist/token-accounting.js.map +1 -0
- package/dist/tool-dispatch.d.ts +19 -0
- package/dist/tool-dispatch.d.ts.map +1 -0
- package/dist/tool-dispatch.js +118 -0
- package/dist/tool-dispatch.js.map +1 -0
- package/dist/tool-gating.d.ts +32 -0
- package/dist/tool-gating.d.ts.map +1 -0
- package/dist/tool-gating.js +83 -0
- package/dist/tool-gating.js.map +1 -0
- package/dist/tool.d.ts +166 -0
- package/dist/tool.d.ts.map +1 -0
- package/dist/tool.js +2 -0
- package/dist/tool.js.map +1 -0
- package/dist/transcriber.d.ts +64 -0
- package/dist/transcriber.d.ts.map +1 -0
- package/dist/transcriber.js +21 -0
- package/dist/transcriber.js.map +1 -0
- package/dist/tunnel.d.ts +25 -0
- package/dist/tunnel.d.ts.map +1 -0
- package/dist/tunnel.js +2 -0
- package/dist/tunnel.js.map +1 -0
- package/dist/type-contracts.d.ts +2 -0
- package/dist/type-contracts.d.ts.map +1 -0
- package/dist/type-contracts.js +4 -0
- package/dist/type-contracts.js.map +1 -0
- package/dist/types.test-d.d.ts +2 -0
- package/dist/types.test-d.d.ts.map +1 -0
- package/dist/types.test-d.js +22 -0
- package/dist/types.test-d.js.map +1 -0
- package/dist/view-renderer.d.ts +97 -0
- package/dist/view-renderer.d.ts.map +1 -0
- package/dist/view-renderer.js +72 -0
- package/dist/view-renderer.js.map +1 -0
- package/package.json +38 -0
- package/src/agent.ts +38 -0
- package/src/cache-strategy.ts +39 -0
- package/src/channel.ts +158 -0
- package/src/client-session.ts +88 -0
- package/src/command.ts +86 -0
- package/src/compactor-helpers.test.ts +206 -0
- package/src/compactor-helpers.ts +163 -0
- package/src/compactor.ts +20 -0
- package/src/define.test.ts +76 -0
- package/src/define.ts +113 -0
- package/src/elision-helpers.ts +143 -0
- package/src/elision-state.ts +170 -0
- package/src/embedding-cache.ts +0 -0
- package/src/embedding.ts +22 -0
- package/src/errors.test.ts +176 -0
- package/src/errors.ts +308 -0
- package/src/events.ts +250 -0
- package/src/hooks.ts +54 -0
- package/src/ids.ts +16 -0
- package/src/index.ts +304 -0
- package/src/install-hints.ts +43 -0
- package/src/isolation.ts +148 -0
- package/src/log.ts +11 -0
- package/src/loop-helpers.test.ts +78 -0
- package/src/mode-helpers.ts +483 -0
- package/src/mode.ts +139 -0
- package/src/package-root.test.ts +40 -0
- package/src/permission.ts +37 -0
- package/src/plugin.ts +92 -0
- package/src/provider-utils.test.ts +84 -0
- package/src/provider-utils.ts +171 -0
- package/src/provider.ts +195 -0
- package/src/requirements.ts +35 -0
- package/src/schemas.test.ts +106 -0
- package/src/schemas.ts +97 -0
- package/src/session-like.ts +118 -0
- package/src/skill.ts +34 -0
- package/src/subagent.ts +46 -0
- package/src/token-accounting.test.ts +93 -0
- package/src/token-accounting.ts +202 -0
- package/src/token-efficiency.test.ts +459 -0
- package/src/tool-dispatch.ts +137 -0
- package/src/tool-gating.ts +107 -0
- package/src/tool.ts +175 -0
- package/src/transcriber.ts +71 -0
- package/src/tunnel.ts +26 -0
- package/src/type-contracts.ts +4 -0
- package/src/types.test-d.ts +29 -0
- package/src/view-renderer.test.ts +90 -0
- 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
|
+
}
|