@moxxy/sdk 0.1.2 → 0.2.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/README.md +73 -73
- package/dist/client-session.d.ts +10 -0
- package/dist/client-session.d.ts.map +1 -1
- package/dist/compactor-helpers.d.ts +4 -1
- package/dist/compactor-helpers.d.ts.map +1 -1
- package/dist/compactor-helpers.js +50 -21
- package/dist/compactor-helpers.js.map +1 -1
- package/dist/define.d.ts +6 -0
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +9 -0
- package/dist/define.js.map +1 -1
- package/dist/embedding.d.ts +17 -0
- package/dist/embedding.d.ts.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fs-utils.d.ts +27 -0
- package/dist/fs-utils.d.ts.map +1 -0
- package/dist/fs-utils.js +44 -0
- package/dist/fs-utils.js.map +1 -0
- package/dist/http-utils.d.ts +16 -0
- package/dist/http-utils.d.ts.map +1 -0
- package/dist/http-utils.js +40 -0
- package/dist/http-utils.js.map +1 -0
- package/dist/index.d.ts +15 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/mode-helpers.d.ts +58 -0
- package/dist/mode-helpers.d.ts.map +1 -1
- package/dist/mode-helpers.js +119 -0
- package/dist/mode-helpers.js.map +1 -1
- package/dist/mode.d.ts +34 -0
- package/dist/mode.d.ts.map +1 -1
- package/dist/mutex.d.ts +15 -0
- package/dist/mutex.d.ts.map +1 -0
- package/dist/mutex.js +15 -0
- package/dist/mutex.js.map +1 -0
- package/dist/plugin.d.ts +36 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/provider-utils.d.ts +6 -0
- package/dist/provider-utils.d.ts.map +1 -1
- package/dist/provider-utils.js +8 -0
- package/dist/provider-utils.js.map +1 -1
- package/dist/requirements.d.ts +1 -1
- package/dist/requirements.d.ts.map +1 -1
- package/dist/resolvers.d.ts +52 -0
- package/dist/resolvers.d.ts.map +1 -0
- package/dist/resolvers.js +123 -0
- package/dist/resolvers.js.map +1 -0
- package/dist/schemas.d.ts +28 -28
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +2 -0
- package/dist/schemas.js.map +1 -1
- package/dist/session-like.d.ts +98 -2
- package/dist/session-like.d.ts.map +1 -1
- package/dist/synthesizer.d.ts +63 -0
- package/dist/synthesizer.d.ts.map +1 -0
- package/dist/synthesizer.js +21 -0
- package/dist/synthesizer.js.map +1 -0
- package/dist/tool.d.ts +13 -0
- package/dist/tool.d.ts.map +1 -1
- package/dist/voice.d.ts +36 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +82 -0
- package/dist/voice.js.map +1 -0
- package/dist/workflow.d.ts +144 -0
- package/dist/workflow.d.ts.map +1 -0
- package/dist/workflow.js +14 -0
- package/dist/workflow.js.map +1 -0
- package/package.json +14 -14
- package/src/client-session.ts +11 -0
- package/src/compactor-helpers.test.ts +41 -0
- package/src/compactor-helpers.ts +53 -19
- package/src/define.ts +15 -0
- package/src/embedding.ts +18 -0
- package/src/errors.ts +3 -0
- package/src/fs-utils.test.ts +61 -0
- package/src/fs-utils.ts +55 -0
- package/src/http-utils.test.ts +36 -0
- package/src/http-utils.ts +40 -0
- package/src/index.ts +69 -3
- package/src/mode-helpers.ts +169 -0
- package/src/mode.ts +35 -0
- package/src/mutex.test.ts +36 -0
- package/src/mutex.ts +31 -0
- package/src/plugin.ts +36 -1
- package/src/provider-utils.ts +9 -0
- package/src/requirements.ts +1 -0
- package/src/resolvers.test.ts +45 -0
- package/src/resolvers.ts +164 -0
- package/src/schemas.ts +2 -0
- package/src/session-like.ts +100 -2
- package/src/stuck-loop.test.ts +42 -0
- package/src/synthesizer.ts +67 -0
- package/src/tool.ts +13 -0
- package/src/voice.ts +103 -0
- package/src/workflow.ts +165 -0
- package/dist/command-sync.d.ts +0 -23
- package/dist/command-sync.d.ts.map +0 -1
- package/dist/command-sync.js +0 -36
- package/dist/command-sync.js.map +0 -1
- package/dist/loop-helpers.d.ts +0 -68
- package/dist/loop-helpers.d.ts.map +0 -1
- package/dist/loop-helpers.js +0 -283
- package/dist/loop-helpers.js.map +0 -1
- package/dist/loop.d.ts +0 -106
- package/dist/loop.d.ts.map +0 -1
- package/dist/loop.js +0 -2
- package/dist/loop.js.map +0 -1
- package/dist/plugin-kind.d.ts +0 -10
- package/dist/plugin-kind.d.ts.map +0 -1
- package/dist/plugin-kind.js +0 -31
- package/dist/plugin-kind.js.map +0 -1
package/src/client-session.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { SessionLike, SessionLogReader } from './session-like.js';
|
|
|
7
7
|
import type { Skill } from './skill.js';
|
|
8
8
|
import type { ToolDef } from './tool.js';
|
|
9
9
|
import type { Transcriber } from './transcriber.js';
|
|
10
|
+
import type { Synthesizer } from './synthesizer.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* `ClientSession` widens {@link SessionLike} with the read-and-act registry
|
|
@@ -65,6 +66,15 @@ export interface TranscribersClientView {
|
|
|
65
66
|
setActive(name: string, config?: Record<string, unknown>): Transcriber;
|
|
66
67
|
}
|
|
67
68
|
|
|
69
|
+
export interface SynthesizersClientView {
|
|
70
|
+
getActiveName(): string | null;
|
|
71
|
+
has(name: string): boolean;
|
|
72
|
+
getActive(): Synthesizer;
|
|
73
|
+
/** Active synthesizer or null. Read-aloud surfaces guard on this for TTS. */
|
|
74
|
+
tryGetActive(): Synthesizer | null;
|
|
75
|
+
setActive(name: string, config?: Record<string, unknown>): Synthesizer;
|
|
76
|
+
}
|
|
77
|
+
|
|
68
78
|
export interface RequirementsClientView {
|
|
69
79
|
check(requirements: ReadonlyArray<MoxxyRequirement>): RequirementCheck;
|
|
70
80
|
}
|
|
@@ -83,6 +93,7 @@ export interface ClientSession extends SessionLike {
|
|
|
83
93
|
readonly skills: SkillsClientView;
|
|
84
94
|
readonly agents: AgentsClientView;
|
|
85
95
|
readonly transcribers: TranscribersClientView;
|
|
96
|
+
readonly synthesizers: SynthesizersClientView;
|
|
86
97
|
readonly requirements: RequirementsClientView;
|
|
87
98
|
readonly permissions: PermissionsClientView;
|
|
88
99
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
asSessionId,
|
|
5
5
|
asTurnId,
|
|
6
6
|
estimateContextTokens,
|
|
7
|
+
isContextOverflowError,
|
|
7
8
|
runCompactionIfNeeded,
|
|
8
9
|
type CompactorDef,
|
|
9
10
|
type EmittedEvent,
|
|
@@ -129,6 +130,46 @@ describe('runCompactionIfNeeded', () => {
|
|
|
129
130
|
expect(ctx.emitted).toHaveLength(1);
|
|
130
131
|
expect(ctx.emitted[0]).toMatchObject({ type: 'error', kind: 'retryable' });
|
|
131
132
|
});
|
|
133
|
+
|
|
134
|
+
it('force compacts even when shouldCompact returns false', async () => {
|
|
135
|
+
const compact = vi.fn(async () => ({
|
|
136
|
+
type: 'compaction' as const,
|
|
137
|
+
replacedRange: [0, 1] as [number, number],
|
|
138
|
+
summary: 'summary',
|
|
139
|
+
tokensSaved: 500,
|
|
140
|
+
}));
|
|
141
|
+
const compactor: CompactorDef = {
|
|
142
|
+
name: 'gated',
|
|
143
|
+
shouldCompact: () => false, // gate says no…
|
|
144
|
+
compact,
|
|
145
|
+
};
|
|
146
|
+
const ctx = makeCtx({ compactor });
|
|
147
|
+
const did = await runCompactionIfNeeded(ctx, { force: true }); // …but force overrides
|
|
148
|
+
expect(did).toBe(true);
|
|
149
|
+
expect(compact).toHaveBeenCalledOnce();
|
|
150
|
+
expect(ctx.emitted.some((e) => e.type === 'compaction')).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('isContextOverflowError', () => {
|
|
155
|
+
it('matches common provider context-overflow phrasings', () => {
|
|
156
|
+
for (const msg of [
|
|
157
|
+
'input exceeds context window',
|
|
158
|
+
"This model's maximum context length is 200000 tokens",
|
|
159
|
+
'context_length_exceeded',
|
|
160
|
+
'prompt is too long: 250000 tokens > 200000 maximum',
|
|
161
|
+
'Please reduce the length of the messages',
|
|
162
|
+
'too many input tokens',
|
|
163
|
+
]) {
|
|
164
|
+
expect(isContextOverflowError(msg)).toBe(true);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('does not match unrelated errors', () => {
|
|
169
|
+
for (const msg of ['rate limit exceeded', 'network timeout', '500 internal server error', 'invalid api key']) {
|
|
170
|
+
expect(isContextOverflowError(msg)).toBe(false);
|
|
171
|
+
}
|
|
172
|
+
});
|
|
132
173
|
});
|
|
133
174
|
|
|
134
175
|
interface MakeCtxOpts {
|
package/src/compactor-helpers.ts
CHANGED
|
@@ -96,19 +96,22 @@ function safeJsonLen(v: unknown): number {
|
|
|
96
96
|
* compactor bug can't kill the turn. Failures emit a non-fatal
|
|
97
97
|
* `error` event for observability.
|
|
98
98
|
*/
|
|
99
|
-
export async function runCompactionIfNeeded(
|
|
99
|
+
export async function runCompactionIfNeeded(
|
|
100
|
+
ctx: ModeContext,
|
|
101
|
+
opts: { readonly force?: boolean } = {},
|
|
102
|
+
): Promise<boolean> {
|
|
100
103
|
const compactor = ctx.compactor;
|
|
101
|
-
if (!compactor) return;
|
|
104
|
+
if (!compactor) return false;
|
|
102
105
|
|
|
103
106
|
// Resolve the active model's descriptor so we use the *real* context
|
|
104
107
|
// window. `Number.MAX_SAFE_INTEGER` (the old /compact behavior) made
|
|
105
108
|
// threshold-based compactors always look comfortable, even at 99%.
|
|
106
109
|
const descriptor = ctx.provider.models.find((m) => m.id === ctx.model);
|
|
107
110
|
const contextWindow = descriptor?.contextWindow;
|
|
108
|
-
if (!contextWindow || contextWindow <= 0) return;
|
|
111
|
+
if (!contextWindow || contextWindow <= 0) return false;
|
|
109
112
|
|
|
110
113
|
const events = ctx.log.slice();
|
|
111
|
-
if (events.length === 0) return;
|
|
114
|
+
if (events.length === 0) return false;
|
|
112
115
|
|
|
113
116
|
const budget = {
|
|
114
117
|
contextWindow,
|
|
@@ -116,21 +119,27 @@ export async function runCompactionIfNeeded(ctx: ModeContext): Promise<void> {
|
|
|
116
119
|
reserveForOutput: descriptor?.maxOutputTokens ?? 0,
|
|
117
120
|
} as const;
|
|
118
121
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
122
|
+
// `force` skips the threshold gate — used reactively after the provider
|
|
123
|
+
// rejects a request for being over the context window (our estimate can
|
|
124
|
+
// lag the provider's real tokenizer), so we compact and retry rather than
|
|
125
|
+
// failing the turn.
|
|
126
|
+
if (!opts.force) {
|
|
127
|
+
let shouldRun = false;
|
|
128
|
+
try {
|
|
129
|
+
shouldRun = compactor.shouldCompact(ctx.log, budget);
|
|
130
|
+
} catch (err) {
|
|
131
|
+
await ctx.emit({
|
|
132
|
+
type: 'error',
|
|
133
|
+
sessionId: ctx.sessionId,
|
|
134
|
+
turnId: ctx.turnId,
|
|
135
|
+
source: 'system',
|
|
136
|
+
kind: 'retryable',
|
|
137
|
+
message: `compactor.shouldCompact threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
138
|
+
});
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
if (!shouldRun) return false;
|
|
132
142
|
}
|
|
133
|
-
if (!shouldRun) return;
|
|
134
143
|
|
|
135
144
|
try {
|
|
136
145
|
const result = await compactor.compact(events, {
|
|
@@ -138,7 +147,7 @@ export async function runCompactionIfNeeded(ctx: ModeContext): Promise<void> {
|
|
|
138
147
|
budget,
|
|
139
148
|
signal: ctx.signal,
|
|
140
149
|
});
|
|
141
|
-
if (result.tokensSaved <= 0 || result.summary.trim().length === 0) return;
|
|
150
|
+
if (result.tokensSaved <= 0 || result.summary.trim().length === 0) return false;
|
|
142
151
|
// `compactor.compact` declares `Omit<CompactionEvent, keyof EventBase>`,
|
|
143
152
|
// but every shipped compactor (and the SDK examples) fills sessionId /
|
|
144
153
|
// turnId / source. Defensive-fill from ctx so a compactor that obeyed
|
|
@@ -150,6 +159,7 @@ export async function runCompactionIfNeeded(ctx: ModeContext): Promise<void> {
|
|
|
150
159
|
...result,
|
|
151
160
|
} as EmittedEvent;
|
|
152
161
|
await ctx.emit(emittable);
|
|
162
|
+
return true;
|
|
153
163
|
} catch (err) {
|
|
154
164
|
await ctx.emit({
|
|
155
165
|
type: 'error',
|
|
@@ -159,5 +169,29 @@ export async function runCompactionIfNeeded(ctx: ModeContext): Promise<void> {
|
|
|
159
169
|
kind: 'retryable',
|
|
160
170
|
message: `compactor.compact threw: ${err instanceof Error ? err.message : String(err)}`,
|
|
161
171
|
});
|
|
172
|
+
return false;
|
|
162
173
|
}
|
|
163
174
|
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Heuristic: does this provider error mean "the request was too big for the
|
|
178
|
+
* model's context window"? Providers phrase it many ways (OpenAI "maximum
|
|
179
|
+
* context length is N tokens", Anthropic "prompt is too long", the runner's
|
|
180
|
+
* own "input exceeds context window"), and it usually arrives as a
|
|
181
|
+
* non-retryable 400 — so the turn loop matches on it to compact + retry
|
|
182
|
+
* instead of dying.
|
|
183
|
+
*/
|
|
184
|
+
const CONTEXT_OVERFLOW_PATTERNS: ReadonlyArray<RegExp> = [
|
|
185
|
+
/context[\s_-]{0,2}(window|length)/i,
|
|
186
|
+
/maximum context/i,
|
|
187
|
+
/context_length_exceeded/i,
|
|
188
|
+
/exceeds?\b[^.]{0,24}context/i,
|
|
189
|
+
/input[^.]{0,24}(exceeds|too long|too large|too many)/i,
|
|
190
|
+
/too many (input )?tokens/i,
|
|
191
|
+
/prompt is too long/i,
|
|
192
|
+
/reduce the length/i,
|
|
193
|
+
];
|
|
194
|
+
|
|
195
|
+
export function isContextOverflowError(message: string): boolean {
|
|
196
|
+
return CONTEXT_OVERFLOW_PATTERNS.some((re) => re.test(message));
|
|
197
|
+
}
|
package/src/define.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { CacheStrategyDef } from './cache-strategy.js';
|
|
|
3
3
|
import type { ChannelDef } from './channel.js';
|
|
4
4
|
import type { CommandDef } from './command.js';
|
|
5
5
|
import type { CompactorDef } from './compactor.js';
|
|
6
|
+
import type { EmbedderDef } from './embedding.js';
|
|
6
7
|
import type { ModeDef } from './mode.js';
|
|
7
8
|
import type { PermissionRule } from './permission.js';
|
|
8
9
|
import type { Plugin, PluginSpec } from './plugin.js';
|
|
@@ -11,8 +12,10 @@ import type { SkillDef, SkillFrontmatter } from './skill.js';
|
|
|
11
12
|
import type { ToolCompactPresentation, ToolContext, ToolDef } from './tool.js';
|
|
12
13
|
import type { ToolIsolationSpec } from './isolation.js';
|
|
13
14
|
import type { TranscriberDef } from './transcriber.js';
|
|
15
|
+
import type { SynthesizerDef } from './synthesizer.js';
|
|
14
16
|
import type { ViewRendererDef } from './view-renderer.js';
|
|
15
17
|
import type { TunnelProviderDef } from './tunnel.js';
|
|
18
|
+
import type { WorkflowExecutorDef } from './workflow.js';
|
|
16
19
|
import type { z } from 'zod';
|
|
17
20
|
|
|
18
21
|
export function definePlugin(spec: PluginSpec): Plugin {
|
|
@@ -104,6 +107,14 @@ export function defineTranscriber(spec: TranscriberDef): TranscriberDef {
|
|
|
104
107
|
return Object.freeze(spec);
|
|
105
108
|
}
|
|
106
109
|
|
|
110
|
+
export function defineSynthesizer(spec: SynthesizerDef): SynthesizerDef {
|
|
111
|
+
return Object.freeze(spec);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function defineEmbedder(spec: EmbedderDef): EmbedderDef {
|
|
115
|
+
return Object.freeze(spec);
|
|
116
|
+
}
|
|
117
|
+
|
|
107
118
|
export function defineCommand(spec: CommandDef): CommandDef {
|
|
108
119
|
return Object.freeze(spec);
|
|
109
120
|
}
|
|
@@ -111,3 +122,7 @@ export function defineCommand(spec: CommandDef): CommandDef {
|
|
|
111
122
|
export function defineAgent(spec: AgentDef): AgentDef {
|
|
112
123
|
return Object.freeze(spec);
|
|
113
124
|
}
|
|
125
|
+
|
|
126
|
+
export function defineWorkflowExecutor(spec: WorkflowExecutorDef): WorkflowExecutorDef {
|
|
127
|
+
return Object.freeze(spec);
|
|
128
|
+
}
|
package/src/embedding.ts
CHANGED
|
@@ -20,3 +20,21 @@ export interface EmbeddingProvider {
|
|
|
20
20
|
/** Embed a batch of strings. Returns vectors in input order. */
|
|
21
21
|
embed(texts: ReadonlyArray<string>): Promise<ReadonlyArray<ReadonlyArray<number>>>;
|
|
22
22
|
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Plugin-side definition of an embedder. Mirrors `TranscriberDef` / `ProviderDef`:
|
|
26
|
+
* a `createClient(config)` factory the `EmbedderRegistry` calls when the user
|
|
27
|
+
* activates this embedder by name. Plugins contribute these via
|
|
28
|
+
* `PluginSpec.embedders`, so a user can install a new embedder package and
|
|
29
|
+
* select it by config without forking the host.
|
|
30
|
+
*
|
|
31
|
+
* `createClient` MUST be cheap — defer heavy model loading (onnx, transformers,
|
|
32
|
+
* network clients) into the returned provider's `embed()` so that merely
|
|
33
|
+
* registering a discovered embedder plugin never pulls its runtime in.
|
|
34
|
+
*/
|
|
35
|
+
export interface EmbedderDef {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
/** Optional human-readable label for UI surfaces. */
|
|
38
|
+
readonly displayName?: string;
|
|
39
|
+
createClient(config: Record<string, unknown>): EmbeddingProvider;
|
|
40
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -49,6 +49,9 @@ export type MoxxyErrorCode =
|
|
|
49
49
|
| 'CONFIG_INVALID'
|
|
50
50
|
| 'PLUGIN_LOAD_FAILED'
|
|
51
51
|
| 'UNKNOWN_COMMAND'
|
|
52
|
+
// --- Tool / runtime ---
|
|
53
|
+
| 'TOOL_ERROR' // a tool handler failed (bad input, not-found, exec error)
|
|
54
|
+
| 'ABORTED' // operation cancelled — turn abort signal or timeout kill
|
|
52
55
|
// --- Catch-all ---
|
|
53
56
|
| 'INTERNAL';
|
|
54
57
|
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { mkdtemp, readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { moxxyHome, moxxyPath, writeFileAtomic } from './fs-utils.js';
|
|
6
|
+
|
|
7
|
+
describe('writeFileAtomic', () => {
|
|
8
|
+
let dir: string;
|
|
9
|
+
beforeEach(async () => {
|
|
10
|
+
dir = await mkdtemp(join(tmpdir(), 'moxxy-fs-'));
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('writes content and creates missing parent dirs', async () => {
|
|
14
|
+
const target = join(dir, 'nested', 'deep', 'file.json');
|
|
15
|
+
await writeFileAtomic(target, '{"a":1}');
|
|
16
|
+
expect(await readFile(target, 'utf8')).toBe('{"a":1}');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('overwrites an existing file and leaves no temp file behind', async () => {
|
|
20
|
+
const target = join(dir, 'file.txt');
|
|
21
|
+
await writeFileAtomic(target, 'first');
|
|
22
|
+
await writeFileAtomic(target, 'second');
|
|
23
|
+
expect(await readFile(target, 'utf8')).toBe('second');
|
|
24
|
+
const leftovers = (await readdir(dir)).filter((n) => n.includes('.tmp'));
|
|
25
|
+
expect(leftovers).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('enforces the requested mode past umask', async () => {
|
|
29
|
+
const target = join(dir, 'secret.json');
|
|
30
|
+
await writeFileAtomic(target, 'shh', { mode: 0o600 });
|
|
31
|
+
const mode = (await stat(target)).mode & 0o777;
|
|
32
|
+
expect(mode).toBe(0o600);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('writes binary data unchanged', async () => {
|
|
36
|
+
const target = join(dir, 'bytes.bin');
|
|
37
|
+
const bytes = new Uint8Array([0, 1, 2, 255]);
|
|
38
|
+
await writeFileAtomic(target, bytes);
|
|
39
|
+
const read = await readFile(target);
|
|
40
|
+
expect(Array.from(read)).toEqual([0, 1, 2, 255]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('moxxyHome / moxxyPath', () => {
|
|
45
|
+
const original = process.env.MOXXY_HOME;
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
if (original === undefined) delete process.env.MOXXY_HOME;
|
|
48
|
+
else process.env.MOXXY_HOME = original;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('honors MOXXY_HOME when set', () => {
|
|
52
|
+
process.env.MOXXY_HOME = '/custom/moxxy';
|
|
53
|
+
expect(moxxyHome()).toBe('/custom/moxxy');
|
|
54
|
+
expect(moxxyPath('vault.json')).toBe('/custom/moxxy/vault.json');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('falls back to ~/.moxxy when unset', () => {
|
|
58
|
+
delete process.env.MOXXY_HOME;
|
|
59
|
+
expect(moxxyHome().endsWith('/.moxxy')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
});
|
package/src/fs-utils.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
|
|
6
|
+
export interface WriteFileAtomicOptions {
|
|
7
|
+
/** Mode for the final file, e.g. `0o600` for secrets. Enforced past umask. */
|
|
8
|
+
readonly mode?: number;
|
|
9
|
+
/** Encoding when `data` is a string. Defaults to `'utf8'`. Ignored for bytes. */
|
|
10
|
+
readonly encoding?: BufferEncoding;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Crash-atomic file write: write a unique sibling temp file, then `rename` it
|
|
15
|
+
* over the target. POSIX `rename` is atomic on the same filesystem, so a crash
|
|
16
|
+
* (or full disk) mid-write leaves the previous file intact rather than a
|
|
17
|
+
* truncated one. The temp name carries pid + a random UUID so concurrent
|
|
18
|
+
* writers to the same target never collide on the temp path.
|
|
19
|
+
*
|
|
20
|
+
* This is the single home for the framework's "persist atomically" invariant —
|
|
21
|
+
* every file-state writer (vault, memory, permissions, sessions, the Write/Edit
|
|
22
|
+
* tools) should call this instead of hand-rolling tmp+rename or writing in place.
|
|
23
|
+
*/
|
|
24
|
+
export async function writeFileAtomic(
|
|
25
|
+
target: string,
|
|
26
|
+
data: string | Uint8Array,
|
|
27
|
+
opts: WriteFileAtomicOptions = {},
|
|
28
|
+
): Promise<void> {
|
|
29
|
+
await mkdir(dirname(target), { recursive: true });
|
|
30
|
+
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
31
|
+
try {
|
|
32
|
+
await writeFile(tmp, data, { encoding: opts.encoding ?? 'utf8' });
|
|
33
|
+
// chmod explicitly: writeFile's mode option is masked by umask, but a
|
|
34
|
+
// 0o600 secret file must be exactly 0o600 regardless of the host umask.
|
|
35
|
+
if (opts.mode != null) await chmod(tmp, opts.mode);
|
|
36
|
+
await rename(tmp, target);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The moxxy home directory: `$MOXXY_HOME` when set, else `~/.moxxy`. Single
|
|
45
|
+
* source of truth so the env override is honored uniformly — previously half
|
|
46
|
+
* the plugins inlined `~/.moxxy` and ignored `MOXXY_HOME`.
|
|
47
|
+
*/
|
|
48
|
+
export function moxxyHome(): string {
|
|
49
|
+
return process.env.MOXXY_HOME ?? join(homedir(), '.moxxy');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Join path segments under {@link moxxyHome}. */
|
|
53
|
+
export function moxxyPath(...segments: string[]): string {
|
|
54
|
+
return join(moxxyHome(), ...segments);
|
|
55
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { bearerTokenMatches, readRequestBody } from './http-utils.js';
|
|
5
|
+
|
|
6
|
+
function fakeReq(body: string | Buffer): IncomingMessage {
|
|
7
|
+
return Readable.from([Buffer.from(body)]) as unknown as IncomingMessage;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('bearerTokenMatches', () => {
|
|
11
|
+
it('matches an identical token', () => {
|
|
12
|
+
expect(bearerTokenMatches('s3cret', 's3cret')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
it('rejects a different token of equal length', () => {
|
|
15
|
+
expect(bearerTokenMatches('aaaaaa', 'bbbbbb')).toBe(false);
|
|
16
|
+
});
|
|
17
|
+
it('rejects a length mismatch without throwing', () => {
|
|
18
|
+
expect(bearerTokenMatches('short', 'longer-token')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
it('rejects empty/missing presented tokens', () => {
|
|
21
|
+
expect(bearerTokenMatches(undefined, 'x')).toBe(false);
|
|
22
|
+
expect(bearerTokenMatches(null, 'x')).toBe(false);
|
|
23
|
+
expect(bearerTokenMatches('', 'x')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('readRequestBody', () => {
|
|
28
|
+
it('reads the full body', async () => {
|
|
29
|
+
const buf = await readRequestBody(fakeReq('hello world'), 1024);
|
|
30
|
+
expect(buf.toString('utf8')).toBe('hello world');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('rejects when the body exceeds maxBytes', async () => {
|
|
34
|
+
await expect(readRequestBody(fakeReq('x'.repeat(100)), 10)).rejects.toThrow(/exceeds 10 bytes/);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import type { IncomingMessage } from 'node:http';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Read a request body into a Buffer, rejecting (and destroying the socket) once
|
|
6
|
+
* `maxBytes` is exceeded. Bounds in-memory buffering so a malicious or runaway
|
|
7
|
+
* client can't exhaust the host's memory. Shared by every HTTP channel/listener.
|
|
8
|
+
*/
|
|
9
|
+
export async function readRequestBody(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
|
|
10
|
+
return new Promise<Buffer>((resolve, reject) => {
|
|
11
|
+
const chunks: Buffer[] = [];
|
|
12
|
+
let size = 0;
|
|
13
|
+
req.on('data', (chunk: Buffer) => {
|
|
14
|
+
size += chunk.length;
|
|
15
|
+
if (size > maxBytes) {
|
|
16
|
+
reject(new Error(`request body exceeds ${maxBytes} bytes`));
|
|
17
|
+
req.destroy();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
chunks.push(chunk);
|
|
21
|
+
});
|
|
22
|
+
req.on('end', () => resolve(Buffer.concat(chunks)));
|
|
23
|
+
req.on('error', reject);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Constant-time comparison of a presented bearer token against the expected
|
|
29
|
+
* value. A length mismatch returns false WITHOUT calling `timingSafeEqual`
|
|
30
|
+
* (which throws on unequal lengths and would leak the length); equal-length
|
|
31
|
+
* inputs are compared in constant time so an attacker can't recover the token
|
|
32
|
+
* byte-by-byte via response timing.
|
|
33
|
+
*/
|
|
34
|
+
export function bearerTokenMatches(presented: string | undefined | null, expected: string): boolean {
|
|
35
|
+
if (!presented) return false;
|
|
36
|
+
const a = Buffer.from(presented);
|
|
37
|
+
const b = Buffer.from(expected);
|
|
38
|
+
if (a.length !== b.length) return false;
|
|
39
|
+
return timingSafeEqual(a, b);
|
|
40
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,12 @@ export type {
|
|
|
47
47
|
ToolInfo,
|
|
48
48
|
SkillInfo,
|
|
49
49
|
CommandInfo,
|
|
50
|
+
CredentialResolver,
|
|
51
|
+
McpAdminView,
|
|
52
|
+
McpServerStatusView,
|
|
53
|
+
WorkflowsView,
|
|
54
|
+
WorkflowSummaryView,
|
|
55
|
+
WorkflowRunView,
|
|
50
56
|
} from './session-like.js';
|
|
51
57
|
|
|
52
58
|
export type {
|
|
@@ -58,6 +64,7 @@ export type {
|
|
|
58
64
|
SkillsClientView,
|
|
59
65
|
AgentsClientView,
|
|
60
66
|
TranscribersClientView,
|
|
67
|
+
SynthesizersClientView,
|
|
61
68
|
RequirementsClientView,
|
|
62
69
|
PermissionsClientView,
|
|
63
70
|
} from './client-session.js';
|
|
@@ -135,7 +142,22 @@ export type {
|
|
|
135
142
|
} from './view-renderer.js';
|
|
136
143
|
export { VIEW_PRIMITIVES, VIEW_COMPONENTS, DEFAULT_VIEW_TAGS } from './view-renderer.js';
|
|
137
144
|
export type { TunnelProviderDef, TunnelHandle, TunnelOpenOptions } from './tunnel.js';
|
|
138
|
-
export { isRetryableError, toFriendlyError, zodToJsonSchema, type StopReason } from './provider-utils.js';
|
|
145
|
+
export { isRetryableError, toFriendlyError, zodToJsonSchema, estimateTextTokens, type StopReason } from './provider-utils.js';
|
|
146
|
+
export { writeFileAtomic, moxxyHome, moxxyPath, type WriteFileAtomicOptions } from './fs-utils.js';
|
|
147
|
+
export { createMutex, type Mutex } from './mutex.js';
|
|
148
|
+
export { readRequestBody, bearerTokenMatches } from './http-utils.js';
|
|
149
|
+
export {
|
|
150
|
+
autoAllowResolver,
|
|
151
|
+
denyByDefaultResolver,
|
|
152
|
+
createCallbackResolver,
|
|
153
|
+
createAllowListResolver,
|
|
154
|
+
createDeferredPermissionResolver,
|
|
155
|
+
evaluateToolRule,
|
|
156
|
+
type CallbackResolverOptions,
|
|
157
|
+
type PermissionPromptHandler,
|
|
158
|
+
type DeferredPermissionResolver,
|
|
159
|
+
type DeferredPermissionResolverOptions,
|
|
160
|
+
} from './resolvers.js';
|
|
139
161
|
export {
|
|
140
162
|
MoxxyError,
|
|
141
163
|
classifyHttpStatus,
|
|
@@ -145,18 +167,27 @@ export {
|
|
|
145
167
|
} from './errors.js';
|
|
146
168
|
export {
|
|
147
169
|
collectProviderStream,
|
|
170
|
+
runSingleShotTurn,
|
|
148
171
|
projectMessagesFromLog,
|
|
149
172
|
projectMessages,
|
|
150
173
|
buildSystemPromptWithSkills,
|
|
174
|
+
createStuckLoopDetector,
|
|
175
|
+
stableHash,
|
|
151
176
|
type CollectedToolUse,
|
|
152
177
|
type StreamResult,
|
|
153
178
|
type ProjectMessagesOptions,
|
|
154
179
|
type ProjectedMessages,
|
|
180
|
+
type StuckLoopDetector,
|
|
181
|
+
type StuckSignal,
|
|
155
182
|
} from './mode-helpers.js';
|
|
156
183
|
export { dispatchToolCall } from './tool-dispatch.js';
|
|
157
184
|
|
|
158
185
|
export type { TokenBudget, CompactContext, CompactorDef } from './compactor.js';
|
|
159
|
-
export {
|
|
186
|
+
export {
|
|
187
|
+
estimateContextTokens,
|
|
188
|
+
runCompactionIfNeeded,
|
|
189
|
+
isContextOverflowError,
|
|
190
|
+
} from './compactor-helpers.js';
|
|
160
191
|
export {
|
|
161
192
|
runElisionIfNeeded,
|
|
162
193
|
resolveElisionSettings,
|
|
@@ -192,6 +223,23 @@ export {
|
|
|
192
223
|
|
|
193
224
|
export type { Skill, SkillDef, SkillFrontmatter, SkillScope, SkillSchedule } from './skill.js';
|
|
194
225
|
|
|
226
|
+
export type {
|
|
227
|
+
Workflow,
|
|
228
|
+
WorkflowStep,
|
|
229
|
+
WorkflowTrigger,
|
|
230
|
+
WorkflowStepErrorMode,
|
|
231
|
+
WorkflowInputSpec,
|
|
232
|
+
WorkflowDelivery,
|
|
233
|
+
WorkflowToolRunner,
|
|
234
|
+
WorkflowLookup,
|
|
235
|
+
WorkflowEventSubtype,
|
|
236
|
+
WorkflowRunDeps,
|
|
237
|
+
WorkflowStepStatus,
|
|
238
|
+
WorkflowStepResult,
|
|
239
|
+
WorkflowRunResult,
|
|
240
|
+
WorkflowExecutorDef,
|
|
241
|
+
} from './workflow.js';
|
|
242
|
+
|
|
195
243
|
export type { AgentDef } from './agent.js';
|
|
196
244
|
|
|
197
245
|
export type {
|
|
@@ -207,6 +255,7 @@ export type {
|
|
|
207
255
|
PluginHostHandle,
|
|
208
256
|
ModeContext,
|
|
209
257
|
ModeDef,
|
|
258
|
+
ModeBadge,
|
|
210
259
|
ElisionSettings,
|
|
211
260
|
ApprovalResolver,
|
|
212
261
|
ApprovalRequest,
|
|
@@ -245,7 +294,7 @@ export type {
|
|
|
245
294
|
ChannelSubcommandContext,
|
|
246
295
|
ChannelCommandArgs,
|
|
247
296
|
} from './channel.js';
|
|
248
|
-
export type { EmbeddingProvider } from './embedding.js';
|
|
297
|
+
export type { EmbeddingProvider, EmbedderDef } from './embedding.js';
|
|
249
298
|
export { CachedEmbeddingProvider } from './embedding-cache.js';
|
|
250
299
|
|
|
251
300
|
export type {
|
|
@@ -264,6 +313,14 @@ export type {
|
|
|
264
313
|
TranscribeOptions,
|
|
265
314
|
} from './transcriber.js';
|
|
266
315
|
|
|
316
|
+
export type {
|
|
317
|
+
Synthesizer,
|
|
318
|
+
SynthesizerDef,
|
|
319
|
+
SynthesizerCreateContext,
|
|
320
|
+
SynthesisResult,
|
|
321
|
+
SynthesizeOptions,
|
|
322
|
+
} from './synthesizer.js';
|
|
323
|
+
|
|
267
324
|
export interface PluginLoader {
|
|
268
325
|
load(manifest: import('./plugin.js').ResolvedPluginManifest): Promise<import('./plugin.js').Plugin>;
|
|
269
326
|
}
|
|
@@ -281,8 +338,11 @@ export {
|
|
|
281
338
|
definePermission,
|
|
282
339
|
defineSkill,
|
|
283
340
|
defineTranscriber,
|
|
341
|
+
defineSynthesizer,
|
|
342
|
+
defineEmbedder,
|
|
284
343
|
defineCommand,
|
|
285
344
|
defineAgent,
|
|
345
|
+
defineWorkflowExecutor,
|
|
286
346
|
} from './define.js';
|
|
287
347
|
|
|
288
348
|
export {
|
|
@@ -301,4 +361,10 @@ export {
|
|
|
301
361
|
type InstallTarget,
|
|
302
362
|
} from './install-hints.js';
|
|
303
363
|
|
|
364
|
+
export {
|
|
365
|
+
checkTranscriberReady,
|
|
366
|
+
pickFirstAvailableTranscriber,
|
|
367
|
+
resolveTranscriber,
|
|
368
|
+
} from './voice.js';
|
|
369
|
+
|
|
304
370
|
export { z } from 'zod';
|