@moxxy/sdk 0.1.2 → 0.1.3
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/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 +4 -0
- package/dist/define.d.ts.map +1 -1
- package/dist/define.js +6 -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 +12 -6
- 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 +8 -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 +27 -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/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 +24 -24
- package/dist/session-like.d.ts +85 -1
- package/dist/session-like.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/compactor-helpers.test.ts +41 -0
- package/src/compactor-helpers.ts +53 -19
- package/src/define.ts +10 -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 +58 -3
- package/src/mode-helpers.ts +169 -0
- package/src/mode.ts +8 -0
- package/src/mutex.test.ts +36 -0
- package/src/mutex.ts +31 -0
- package/src/plugin.ts +27 -1
- package/src/provider-utils.ts +9 -0
- package/src/resolvers.test.ts +45 -0
- package/src/resolvers.ts +164 -0
- package/src/session-like.ts +87 -1
- package/src/stuck-loop.test.ts +42 -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/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 {
|
|
@@ -135,7 +141,22 @@ export type {
|
|
|
135
141
|
} from './view-renderer.js';
|
|
136
142
|
export { VIEW_PRIMITIVES, VIEW_COMPONENTS, DEFAULT_VIEW_TAGS } from './view-renderer.js';
|
|
137
143
|
export type { TunnelProviderDef, TunnelHandle, TunnelOpenOptions } from './tunnel.js';
|
|
138
|
-
export { isRetryableError, toFriendlyError, zodToJsonSchema, type StopReason } from './provider-utils.js';
|
|
144
|
+
export { isRetryableError, toFriendlyError, zodToJsonSchema, estimateTextTokens, type StopReason } from './provider-utils.js';
|
|
145
|
+
export { writeFileAtomic, moxxyHome, moxxyPath, type WriteFileAtomicOptions } from './fs-utils.js';
|
|
146
|
+
export { createMutex, type Mutex } from './mutex.js';
|
|
147
|
+
export { readRequestBody, bearerTokenMatches } from './http-utils.js';
|
|
148
|
+
export {
|
|
149
|
+
autoAllowResolver,
|
|
150
|
+
denyByDefaultResolver,
|
|
151
|
+
createCallbackResolver,
|
|
152
|
+
createAllowListResolver,
|
|
153
|
+
createDeferredPermissionResolver,
|
|
154
|
+
evaluateToolRule,
|
|
155
|
+
type CallbackResolverOptions,
|
|
156
|
+
type PermissionPromptHandler,
|
|
157
|
+
type DeferredPermissionResolver,
|
|
158
|
+
type DeferredPermissionResolverOptions,
|
|
159
|
+
} from './resolvers.js';
|
|
139
160
|
export {
|
|
140
161
|
MoxxyError,
|
|
141
162
|
classifyHttpStatus,
|
|
@@ -145,18 +166,27 @@ export {
|
|
|
145
166
|
} from './errors.js';
|
|
146
167
|
export {
|
|
147
168
|
collectProviderStream,
|
|
169
|
+
runSingleShotTurn,
|
|
148
170
|
projectMessagesFromLog,
|
|
149
171
|
projectMessages,
|
|
150
172
|
buildSystemPromptWithSkills,
|
|
173
|
+
createStuckLoopDetector,
|
|
174
|
+
stableHash,
|
|
151
175
|
type CollectedToolUse,
|
|
152
176
|
type StreamResult,
|
|
153
177
|
type ProjectMessagesOptions,
|
|
154
178
|
type ProjectedMessages,
|
|
179
|
+
type StuckLoopDetector,
|
|
180
|
+
type StuckSignal,
|
|
155
181
|
} from './mode-helpers.js';
|
|
156
182
|
export { dispatchToolCall } from './tool-dispatch.js';
|
|
157
183
|
|
|
158
184
|
export type { TokenBudget, CompactContext, CompactorDef } from './compactor.js';
|
|
159
|
-
export {
|
|
185
|
+
export {
|
|
186
|
+
estimateContextTokens,
|
|
187
|
+
runCompactionIfNeeded,
|
|
188
|
+
isContextOverflowError,
|
|
189
|
+
} from './compactor-helpers.js';
|
|
160
190
|
export {
|
|
161
191
|
runElisionIfNeeded,
|
|
162
192
|
resolveElisionSettings,
|
|
@@ -192,6 +222,23 @@ export {
|
|
|
192
222
|
|
|
193
223
|
export type { Skill, SkillDef, SkillFrontmatter, SkillScope, SkillSchedule } from './skill.js';
|
|
194
224
|
|
|
225
|
+
export type {
|
|
226
|
+
Workflow,
|
|
227
|
+
WorkflowStep,
|
|
228
|
+
WorkflowTrigger,
|
|
229
|
+
WorkflowStepErrorMode,
|
|
230
|
+
WorkflowInputSpec,
|
|
231
|
+
WorkflowDelivery,
|
|
232
|
+
WorkflowToolRunner,
|
|
233
|
+
WorkflowLookup,
|
|
234
|
+
WorkflowEventSubtype,
|
|
235
|
+
WorkflowRunDeps,
|
|
236
|
+
WorkflowStepStatus,
|
|
237
|
+
WorkflowStepResult,
|
|
238
|
+
WorkflowRunResult,
|
|
239
|
+
WorkflowExecutorDef,
|
|
240
|
+
} from './workflow.js';
|
|
241
|
+
|
|
195
242
|
export type { AgentDef } from './agent.js';
|
|
196
243
|
|
|
197
244
|
export type {
|
|
@@ -245,7 +292,7 @@ export type {
|
|
|
245
292
|
ChannelSubcommandContext,
|
|
246
293
|
ChannelCommandArgs,
|
|
247
294
|
} from './channel.js';
|
|
248
|
-
export type { EmbeddingProvider } from './embedding.js';
|
|
295
|
+
export type { EmbeddingProvider, EmbedderDef } from './embedding.js';
|
|
249
296
|
export { CachedEmbeddingProvider } from './embedding-cache.js';
|
|
250
297
|
|
|
251
298
|
export type {
|
|
@@ -281,8 +328,10 @@ export {
|
|
|
281
328
|
definePermission,
|
|
282
329
|
defineSkill,
|
|
283
330
|
defineTranscriber,
|
|
331
|
+
defineEmbedder,
|
|
284
332
|
defineCommand,
|
|
285
333
|
defineAgent,
|
|
334
|
+
defineWorkflowExecutor,
|
|
286
335
|
} from './define.js';
|
|
287
336
|
|
|
288
337
|
export {
|
|
@@ -301,4 +350,10 @@ export {
|
|
|
301
350
|
type InstallTarget,
|
|
302
351
|
} from './install-hints.js';
|
|
303
352
|
|
|
353
|
+
export {
|
|
354
|
+
checkTranscriberReady,
|
|
355
|
+
pickFirstAvailableTranscriber,
|
|
356
|
+
resolveTranscriber,
|
|
357
|
+
} from './voice.js';
|
|
358
|
+
|
|
304
359
|
export { z } from 'zod';
|
package/src/mode-helpers.ts
CHANGED
|
@@ -12,6 +12,9 @@ import {
|
|
|
12
12
|
toolResultStubbed,
|
|
13
13
|
} from './elision-state.js';
|
|
14
14
|
import { applyLazyTools } from './tool-gating.js';
|
|
15
|
+
import { runCompactionIfNeeded } from './compactor-helpers.js';
|
|
16
|
+
import { runElisionIfNeeded } from './elision-helpers.js';
|
|
17
|
+
import { usageEventFields } from './token-accounting.js';
|
|
15
18
|
|
|
16
19
|
/**
|
|
17
20
|
* Shared bits used by every loop strategy: a typed tool-use struct and a
|
|
@@ -481,3 +484,169 @@ export async function collectProviderStream(
|
|
|
481
484
|
}
|
|
482
485
|
return { text, toolUses: finalToolUses, stopReason, error, ...(usage ? { usage } : {}) };
|
|
483
486
|
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Run a single-shot (no-tools) provider turn — the shape every planner /
|
|
490
|
+
* synthesis phase shares. Runs context management (compaction + elision),
|
|
491
|
+
* emits the `provider_request` bookend, streams the response with tools
|
|
492
|
+
* disabled, then emits either an `error` event (returning `null`) or the
|
|
493
|
+
* `provider_response` bookend (returning the collected text).
|
|
494
|
+
*
|
|
495
|
+
* Replaces the ~40-line block each mode phase used to inline; centralizing it
|
|
496
|
+
* keeps event emission uniform and means a fix here (e.g. always running
|
|
497
|
+
* elision) lands for every loop strategy at once.
|
|
498
|
+
*/
|
|
499
|
+
export async function runSingleShotTurn(
|
|
500
|
+
ctx: ModeContext,
|
|
501
|
+
messages: ReadonlyArray<ProviderMessage>,
|
|
502
|
+
opts: { maxTokens?: number } = {},
|
|
503
|
+
): Promise<string | null> {
|
|
504
|
+
await runCompactionIfNeeded(ctx);
|
|
505
|
+
await runElisionIfNeeded(ctx);
|
|
506
|
+
|
|
507
|
+
await ctx.emit({
|
|
508
|
+
type: 'provider_request',
|
|
509
|
+
sessionId: ctx.sessionId,
|
|
510
|
+
turnId: ctx.turnId,
|
|
511
|
+
source: 'system',
|
|
512
|
+
provider: ctx.provider.name,
|
|
513
|
+
model: ctx.model,
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
const { text, usage, error } = await collectProviderStream(ctx, messages, {
|
|
517
|
+
includeTools: false,
|
|
518
|
+
...(opts.maxTokens !== undefined ? { maxTokens: opts.maxTokens } : {}),
|
|
519
|
+
});
|
|
520
|
+
if (error) {
|
|
521
|
+
await ctx.emit({
|
|
522
|
+
type: 'error',
|
|
523
|
+
sessionId: ctx.sessionId,
|
|
524
|
+
turnId: ctx.turnId,
|
|
525
|
+
source: 'system',
|
|
526
|
+
kind: error.retryable ? 'retryable' : 'fatal',
|
|
527
|
+
message: error.message,
|
|
528
|
+
});
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
await ctx.emit({
|
|
533
|
+
type: 'provider_response',
|
|
534
|
+
sessionId: ctx.sessionId,
|
|
535
|
+
turnId: ctx.turnId,
|
|
536
|
+
source: 'system',
|
|
537
|
+
provider: ctx.provider.name,
|
|
538
|
+
model: ctx.model,
|
|
539
|
+
...usageEventFields(usage),
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
return text;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Sliding-window detector for "model keeps making the same tool call".
|
|
547
|
+
*
|
|
548
|
+
* When the same `(toolName, input)` pair appears `repeatThreshold` times in
|
|
549
|
+
* the last `windowSize` calls, the model is almost certainly stuck — polling a
|
|
550
|
+
* tool that returns the same thing, mis-handling an error, etc. Bail early
|
|
551
|
+
* instead of burning through the iteration cap.
|
|
552
|
+
*
|
|
553
|
+
* Shared across every loop strategy so detection is uniform — previously each
|
|
554
|
+
* mode re-rolled this, and one copy used a non-canonical `JSON.stringify`
|
|
555
|
+
* signature that silently missed key-reordered repeats.
|
|
556
|
+
*/
|
|
557
|
+
export interface StuckSignal {
|
|
558
|
+
/** True when the loop guard should trip. */
|
|
559
|
+
readonly stuck: boolean;
|
|
560
|
+
/** Repeat count behind the trip — for the error message. */
|
|
561
|
+
readonly count: number;
|
|
562
|
+
/**
|
|
563
|
+
* `exact` = the same (tool, full-input) repeated `repeatThreshold` times.
|
|
564
|
+
* `near` = the same (tool, identity arg — url / file_path / command / …)
|
|
565
|
+
* repeated `nearThreshold` times while only volatile args (maxBytes,
|
|
566
|
+
* timeoutMs) varied. Catches the "refetch the same URL with a bigger
|
|
567
|
+
* maxBytes over and over" loop the exact check sails past.
|
|
568
|
+
*/
|
|
569
|
+
readonly kind: 'exact' | 'near';
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
export interface StuckLoopDetector {
|
|
573
|
+
readonly windowSize: number;
|
|
574
|
+
readonly repeatThreshold: number;
|
|
575
|
+
/** Record the call and report whether the loop guard should trip. */
|
|
576
|
+
record(toolName: string, input: unknown): StuckSignal;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Identity arguments that pin "the same target" across volatile-arg variation,
|
|
580
|
+
* best-first. The first present string field wins. */
|
|
581
|
+
const IDENTITY_ARG_KEYS = ['url', 'file_path', 'path', 'command', 'cmd', 'query', 'pattern'];
|
|
582
|
+
|
|
583
|
+
function identityArg(input: unknown): string | null {
|
|
584
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
|
|
585
|
+
const o = input as Record<string, unknown>;
|
|
586
|
+
for (const k of IDENTITY_ARG_KEYS) {
|
|
587
|
+
const v = o[k];
|
|
588
|
+
if (typeof v === 'string' && v.trim().length > 0) return `${k}=${v}`;
|
|
589
|
+
}
|
|
590
|
+
return null;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export function createStuckLoopDetector(
|
|
594
|
+
opts: {
|
|
595
|
+
windowSize?: number;
|
|
596
|
+
repeatThreshold?: number;
|
|
597
|
+
nearWindowSize?: number;
|
|
598
|
+
nearThreshold?: number;
|
|
599
|
+
} = {},
|
|
600
|
+
): StuckLoopDetector {
|
|
601
|
+
const windowSize = opts.windowSize ?? 8;
|
|
602
|
+
const repeatThreshold = opts.repeatThreshold ?? 3;
|
|
603
|
+
// Near-dups need a higher count + a wider window (they're spread out across a
|
|
604
|
+
// burst of other calls), and tolerate a couple of legit "bigger refetch" tries.
|
|
605
|
+
const nearWindowSize = opts.nearWindowSize ?? Math.max(windowSize * 2, 16);
|
|
606
|
+
const nearThreshold = opts.nearThreshold ?? Math.max(repeatThreshold + 2, 5);
|
|
607
|
+
const recent: string[] = [];
|
|
608
|
+
const recentNear: string[] = [];
|
|
609
|
+
return {
|
|
610
|
+
windowSize,
|
|
611
|
+
repeatThreshold,
|
|
612
|
+
record(toolName, input): StuckSignal {
|
|
613
|
+
const key = `${toolName}|${stableHash(input)}`;
|
|
614
|
+
recent.push(key);
|
|
615
|
+
if (recent.length > windowSize) recent.shift();
|
|
616
|
+
const exactCount = recent.filter((k) => k === key).length;
|
|
617
|
+
if (exactCount >= repeatThreshold) return { stuck: true, count: exactCount, kind: 'exact' };
|
|
618
|
+
|
|
619
|
+
let nearCount = 0;
|
|
620
|
+
const id = identityArg(input);
|
|
621
|
+
if (id !== null) {
|
|
622
|
+
const nearKey = `${toolName}|${id}`;
|
|
623
|
+
recentNear.push(nearKey);
|
|
624
|
+
if (recentNear.length > nearWindowSize) recentNear.shift();
|
|
625
|
+
nearCount = recentNear.filter((k) => k === nearKey).length;
|
|
626
|
+
if (nearCount >= nearThreshold) return { stuck: true, count: nearCount, kind: 'near' };
|
|
627
|
+
}
|
|
628
|
+
return { stuck: false, count: Math.max(exactCount, nearCount), kind: 'exact' };
|
|
629
|
+
},
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Stable, key-order-canonical hash of a tool call's input, so `{a:1,b:2}` and
|
|
635
|
+
* `{b:2,a:1}` produce the same key. Use for any "have I seen this call before"
|
|
636
|
+
* comparison — a raw `JSON.stringify` is NOT order-stable.
|
|
637
|
+
*/
|
|
638
|
+
export function stableHash(input: unknown): string {
|
|
639
|
+
return canonicalize(input);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function canonicalize(value: unknown): string {
|
|
643
|
+
if (value === null || value === undefined) return 'null';
|
|
644
|
+
if (typeof value !== 'object') return JSON.stringify(value);
|
|
645
|
+
if (Array.isArray(value)) {
|
|
646
|
+
return '[' + value.map(canonicalize).join(',') + ']';
|
|
647
|
+
}
|
|
648
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(
|
|
649
|
+
([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
|
|
650
|
+
);
|
|
651
|
+
return '{' + entries.map(([k, v]) => JSON.stringify(k) + ':' + canonicalize(v)).join(',') + '}';
|
|
652
|
+
}
|
package/src/mode.ts
CHANGED
|
@@ -88,6 +88,14 @@ export interface ModeContext {
|
|
|
88
88
|
* Absent in synthetic test contexts that don't model a full Session.
|
|
89
89
|
*/
|
|
90
90
|
readonly subagents?: SubagentSpawner;
|
|
91
|
+
/**
|
|
92
|
+
* Request the session switch its active mode AFTER this turn fully drains.
|
|
93
|
+
* Used by terminal workflow modes (BMAD finishing its last phase) to hand
|
|
94
|
+
* control back to a normal mode so the next message isn't trapped in the
|
|
95
|
+
* workflow. The switch is applied post-turn by the runner; an unknown /
|
|
96
|
+
* unregistered mode name is ignored. Absent in synthetic test contexts.
|
|
97
|
+
*/
|
|
98
|
+
readonly requestModeSwitch?: (modeName: string) => void;
|
|
91
99
|
emit(event: EmittedEvent): Promise<MoxxyEvent>;
|
|
92
100
|
}
|
|
93
101
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { createMutex } from './mutex.js';
|
|
3
|
+
|
|
4
|
+
const tick = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
5
|
+
|
|
6
|
+
describe('createMutex', () => {
|
|
7
|
+
it('serializes overlapping runs (no interleave)', async () => {
|
|
8
|
+
const mutex = createMutex();
|
|
9
|
+
const order: string[] = [];
|
|
10
|
+
const slow = mutex.run(async () => {
|
|
11
|
+
order.push('a:start');
|
|
12
|
+
await tick(20);
|
|
13
|
+
order.push('a:end');
|
|
14
|
+
});
|
|
15
|
+
const fast = mutex.run(async () => {
|
|
16
|
+
order.push('b:start');
|
|
17
|
+
await tick(1);
|
|
18
|
+
order.push('b:end');
|
|
19
|
+
});
|
|
20
|
+
await Promise.all([slow, fast]);
|
|
21
|
+
expect(order).toEqual(['a:start', 'a:end', 'b:start', 'b:end']);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('returns the callback result', async () => {
|
|
25
|
+
const mutex = createMutex();
|
|
26
|
+
await expect(mutex.run(() => 42)).resolves.toBe(42);
|
|
27
|
+
await expect(mutex.run(async () => 'x')).resolves.toBe('x');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps the lock alive after a rejection', async () => {
|
|
31
|
+
const mutex = createMutex();
|
|
32
|
+
await expect(mutex.run(() => Promise.reject(new Error('boom')))).rejects.toThrow('boom');
|
|
33
|
+
// Next run must still execute — the chain isn't poisoned.
|
|
34
|
+
await expect(mutex.run(() => 'recovered')).resolves.toBe('recovered');
|
|
35
|
+
});
|
|
36
|
+
});
|
package/src/mutex.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-instance write mutex: a promise chain that serializes async mutators so
|
|
3
|
+
* two overlapping read-modify-write cycles can't both read the same snapshot
|
|
4
|
+
* and clobber each other.
|
|
5
|
+
*
|
|
6
|
+
* The single home for the framework's "serialize file-state mutators"
|
|
7
|
+
* invariant. Stores (vault, memory, permissions, scheduler, webhooks, …) hold
|
|
8
|
+
* one `Mutex` instance and run every mutation through {@link Mutex.run}.
|
|
9
|
+
*/
|
|
10
|
+
export interface Mutex {
|
|
11
|
+
/** Run `fn` after all previously-queued runs settle. */
|
|
12
|
+
run<T>(fn: () => Promise<T> | T): Promise<T>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createMutex(): Mutex {
|
|
16
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
17
|
+
return {
|
|
18
|
+
run<T>(fn: () => Promise<T> | T): Promise<T> {
|
|
19
|
+
// `.then(fn, fn)` runs `fn` whether the previous run resolved or
|
|
20
|
+
// rejected, so one failed mutation doesn't deadlock the queue.
|
|
21
|
+
const next = chain.then(fn as () => T, fn as () => T);
|
|
22
|
+
// Keep the chain alive across rejections — never let a rejected link
|
|
23
|
+
// permanently poison the lock.
|
|
24
|
+
chain = next.then(
|
|
25
|
+
() => undefined,
|
|
26
|
+
() => undefined,
|
|
27
|
+
);
|
|
28
|
+
return next;
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -3,6 +3,8 @@ 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';
|
|
7
|
+
import type { Isolator } from './isolation.js';
|
|
6
8
|
import type { LifecycleHooks } from './hooks.js';
|
|
7
9
|
import type { ModeDef } from './mode.js';
|
|
8
10
|
import type { ProviderDef } from './provider.js';
|
|
@@ -11,8 +13,9 @@ import type { ToolDef } from './tool.js';
|
|
|
11
13
|
import type { TranscriberDef } from './transcriber.js';
|
|
12
14
|
import type { ViewRendererDef } from './view-renderer.js';
|
|
13
15
|
import type { TunnelProviderDef } from './tunnel.js';
|
|
16
|
+
import type { WorkflowExecutorDef } from './workflow.js';
|
|
14
17
|
|
|
15
|
-
export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'hooks' | 'agent' | 'command' | 'transcriber';
|
|
18
|
+
export type PluginKind = 'tools' | 'provider' | 'mode' | 'compactor' | 'cache-strategy' | 'view-renderer' | 'tunnel-provider' | 'mcp' | 'cli' | 'channel' | 'hooks' | 'agent' | 'command' | 'transcriber' | 'embedder' | 'isolator' | 'workflow-executor';
|
|
16
19
|
|
|
17
20
|
export interface PluginSpec {
|
|
18
21
|
readonly name: string;
|
|
@@ -47,6 +50,22 @@ export interface PluginSpec {
|
|
|
47
50
|
* the active provider does not advertise `supportsAudio`.
|
|
48
51
|
*/
|
|
49
52
|
readonly transcribers?: ReadonlyArray<TranscriberDef>;
|
|
53
|
+
/**
|
|
54
|
+
* Text-embedding backends contributed by the plugin. Selected by name via
|
|
55
|
+
* `session.embedders.setActive(name, config)`; @moxxy/plugin-memory uses the
|
|
56
|
+
* active embedder for semantic recall. `createClient` is lazy so a discovered
|
|
57
|
+
* embedder plugin never pulls its (often heavy) runtime in until selected.
|
|
58
|
+
*/
|
|
59
|
+
readonly embedders?: ReadonlyArray<EmbedderDef>;
|
|
60
|
+
/**
|
|
61
|
+
* Capability isolators contributed by the plugin (worker_threads, subprocess,
|
|
62
|
+
* wasm, …). Registered into the active security layer's `IsolatorRegistry`
|
|
63
|
+
* and selected by name via `security.isolator` config. Registration alone is
|
|
64
|
+
* inert — a contributed isolator is NEVER auto-activated as the sandbox
|
|
65
|
+
* boundary; the user must opt in by name (so a rogue plugin can't silently
|
|
66
|
+
* weaken isolation).
|
|
67
|
+
*/
|
|
68
|
+
readonly isolators?: ReadonlyArray<Isolator>;
|
|
50
69
|
/**
|
|
51
70
|
* Typed subagent kinds the plugin contributes. Each becomes
|
|
52
71
|
* dispatchable as `dispatch_agent({ agentType: <name>, ... })`.
|
|
@@ -64,6 +83,13 @@ export interface PluginSpec {
|
|
|
64
83
|
* pickers, raw-mode toggles) inside the channel itself.
|
|
65
84
|
*/
|
|
66
85
|
readonly commands?: ReadonlyArray<CommandDef>;
|
|
86
|
+
/**
|
|
87
|
+
* Workflow-execution strategies contributed by the plugin. One is active per
|
|
88
|
+
* session (selected via `session.workflowExecutors.setActive(name)`); the
|
|
89
|
+
* active executor runs a workflow DAG. `@moxxy/plugin-workflows` ships the
|
|
90
|
+
* default `dag` executor.
|
|
91
|
+
*/
|
|
92
|
+
readonly workflowExecutors?: ReadonlyArray<WorkflowExecutorDef>;
|
|
67
93
|
readonly hooks?: LifecycleHooks;
|
|
68
94
|
readonly skillsDir?: string;
|
|
69
95
|
}
|
package/src/provider-utils.ts
CHANGED
|
@@ -8,6 +8,15 @@ import { classifyNetworkError } from './errors.js';
|
|
|
8
8
|
/** Canonical stop reasons emitted on `message_end` events. */
|
|
9
9
|
export type StopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'stop_sequence' | 'error';
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Rough token estimate for a blob of text: ~4 chars per token. The shared
|
|
13
|
+
* fallback for a provider's `countTokens` when no native tokenizer is wired —
|
|
14
|
+
* previously each provider hardcoded `Math.ceil(text.length / 4)` independently.
|
|
15
|
+
*/
|
|
16
|
+
export function estimateTextTokens(text: string): number {
|
|
17
|
+
return Math.ceil(text.length / 4);
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
/**
|
|
12
21
|
* Heuristic: should a provider request retry on this error? Used by stream
|
|
13
22
|
* modes to attach a `retryable: boolean` flag to the `error` event so callers
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { evaluateToolRule } from './resolvers.js';
|
|
3
|
+
import type { PendingToolCall, PermissionRule } from './permission.js';
|
|
4
|
+
|
|
5
|
+
function call(name: string, input: unknown = {}): PendingToolCall {
|
|
6
|
+
return { callId: 'c1', name, input } as unknown as PendingToolCall;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('evaluateToolRule', () => {
|
|
10
|
+
it('returns null when the tool declares no rule (defer to resolver)', () => {
|
|
11
|
+
expect(evaluateToolRule(undefined, call('reload_skills'))).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('allows a tool that declares action allow', () => {
|
|
15
|
+
const rule: PermissionRule = { action: 'allow' };
|
|
16
|
+
expect(evaluateToolRule(rule, call('reload_skills'))).toEqual({
|
|
17
|
+
mode: 'allow',
|
|
18
|
+
reason: 'tool-declared allow',
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('denies a tool that declares action deny', () => {
|
|
23
|
+
const rule: PermissionRule = { action: 'deny', reason: 'nope' };
|
|
24
|
+
expect(evaluateToolRule(rule, call('danger'))).toEqual({ mode: 'deny', reason: 'nope' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('defers (null) for action prompt so the interactive resolver decides', () => {
|
|
28
|
+
expect(evaluateToolRule({ action: 'prompt' }, call('self_update_verify'))).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('only applies when the name pattern matches', () => {
|
|
32
|
+
const rule: PermissionRule = { action: 'allow', pattern: { name: 'reload_skills' } };
|
|
33
|
+
expect(evaluateToolRule(rule, call('reload_skills'))).not.toBeNull();
|
|
34
|
+
expect(evaluateToolRule(rule, call('other_tool'))).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('matches on inputMatches (string + RegExp)', () => {
|
|
38
|
+
const rule: PermissionRule = {
|
|
39
|
+
action: 'allow',
|
|
40
|
+
pattern: { inputMatches: { path: /^\/tmp\// } },
|
|
41
|
+
};
|
|
42
|
+
expect(evaluateToolRule(rule, call('write', { path: '/tmp/ok.txt' }))).not.toBeNull();
|
|
43
|
+
expect(evaluateToolRule(rule, call('write', { path: '/etc/passwd' }))).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
});
|