@moxxy/core 0.3.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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/events/factory.d.ts +6 -0
- package/dist/events/factory.d.ts.map +1 -0
- package/dist/events/factory.js +14 -0
- package/dist/events/factory.js.map +1 -0
- package/dist/events/log.d.ts +92 -0
- package/dist/events/log.d.ts.map +1 -0
- package/dist/events/log.js +244 -0
- package/dist/events/log.js.map +1 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +16 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +27 -0
- package/dist/logger.js.map +1 -0
- package/dist/permissions/engine.d.ts +94 -0
- package/dist/permissions/engine.d.ts.map +1 -0
- package/dist/permissions/engine.js +203 -0
- package/dist/permissions/engine.js.map +1 -0
- package/dist/permissions/resolvers.d.ts +8 -0
- package/dist/permissions/resolvers.d.ts.map +1 -0
- package/dist/permissions/resolvers.js +8 -0
- package/dist/permissions/resolvers.js.map +1 -0
- package/dist/plugins/discovery.d.ts +9 -0
- package/dist/plugins/discovery.d.ts.map +1 -0
- package/dist/plugins/discovery.js +112 -0
- package/dist/plugins/discovery.js.map +1 -0
- package/dist/plugins/host-options.d.ts +72 -0
- package/dist/plugins/host-options.d.ts.map +1 -0
- package/dist/plugins/host-options.js +2 -0
- package/dist/plugins/host-options.js.map +1 -0
- package/dist/plugins/host.d.ts +53 -0
- package/dist/plugins/host.d.ts.map +1 -0
- package/dist/plugins/host.js +269 -0
- package/dist/plugins/host.js.map +1 -0
- package/dist/plugins/lifecycle.d.ts +31 -0
- package/dist/plugins/lifecycle.d.ts.map +1 -0
- package/dist/plugins/lifecycle.js +100 -0
- package/dist/plugins/lifecycle.js.map +1 -0
- package/dist/plugins/loader.d.ts +7 -0
- package/dist/plugins/loader.d.ts.map +1 -0
- package/dist/plugins/loader.js +68 -0
- package/dist/plugins/loader.js.map +1 -0
- package/dist/plugins/package-requirements.d.ts +16 -0
- package/dist/plugins/package-requirements.d.ts.map +1 -0
- package/dist/plugins/package-requirements.js +52 -0
- package/dist/plugins/package-requirements.js.map +1 -0
- package/dist/plugins/registry-kinds.d.ts +58 -0
- package/dist/plugins/registry-kinds.d.ts.map +1 -0
- package/dist/plugins/registry-kinds.js +139 -0
- package/dist/plugins/registry-kinds.js.map +1 -0
- package/dist/plugins/toposort.d.ts +24 -0
- package/dist/plugins/toposort.d.ts.map +1 -0
- package/dist/plugins/toposort.js +67 -0
- package/dist/plugins/toposort.js.map +1 -0
- package/dist/preferences.d.ts +40 -0
- package/dist/preferences.d.ts.map +1 -0
- package/dist/preferences.js +58 -0
- package/dist/preferences.js.map +1 -0
- package/dist/registries/active-backend-registry.d.ts +65 -0
- package/dist/registries/active-backend-registry.d.ts.map +1 -0
- package/dist/registries/active-backend-registry.js +108 -0
- package/dist/registries/active-backend-registry.js.map +1 -0
- package/dist/registries/active-def-registry.d.ts +59 -0
- package/dist/registries/active-def-registry.d.ts.map +1 -0
- package/dist/registries/active-def-registry.js +63 -0
- package/dist/registries/active-def-registry.js.map +1 -0
- package/dist/registries/agents.d.ts +16 -0
- package/dist/registries/agents.d.ts.map +1 -0
- package/dist/registries/agents.js +17 -0
- package/dist/registries/agents.js.map +1 -0
- package/dist/registries/cache-strategies.d.ts +12 -0
- package/dist/registries/cache-strategies.d.ts.map +1 -0
- package/dist/registries/cache-strategies.js +13 -0
- package/dist/registries/cache-strategies.js.map +1 -0
- package/dist/registries/channels.d.ts +16 -0
- package/dist/registries/channels.d.ts.map +1 -0
- package/dist/registries/channels.js +35 -0
- package/dist/registries/channels.js.map +1 -0
- package/dist/registries/commands.d.ts +29 -0
- package/dist/registries/commands.d.ts.map +1 -0
- package/dist/registries/commands.js +72 -0
- package/dist/registries/commands.js.map +1 -0
- package/dist/registries/compactors.d.ts +12 -0
- package/dist/registries/compactors.d.ts.map +1 -0
- package/dist/registries/compactors.js +13 -0
- package/dist/registries/compactors.js.map +1 -0
- package/dist/registries/def-map-registry.d.ts +33 -0
- package/dist/registries/def-map-registry.d.ts.map +1 -0
- package/dist/registries/def-map-registry.js +36 -0
- package/dist/registries/def-map-registry.js.map +1 -0
- package/dist/registries/embedders.d.ts +16 -0
- package/dist/registries/embedders.d.ts.map +1 -0
- package/dist/registries/embedders.js +17 -0
- package/dist/registries/embedders.js.map +1 -0
- package/dist/registries/isolators.d.ts +21 -0
- package/dist/registries/isolators.d.ts.map +1 -0
- package/dist/registries/isolators.js +32 -0
- package/dist/registries/isolators.js.map +1 -0
- package/dist/registries/modes.d.ts +28 -0
- package/dist/registries/modes.d.ts.map +1 -0
- package/dist/registries/modes.js +81 -0
- package/dist/registries/modes.js.map +1 -0
- package/dist/registries/providers.d.ts +44 -0
- package/dist/registries/providers.d.ts.map +1 -0
- package/dist/registries/providers.js +95 -0
- package/dist/registries/providers.js.map +1 -0
- package/dist/registries/skills.d.ts +24 -0
- package/dist/registries/skills.d.ts.map +1 -0
- package/dist/registries/skills.js +81 -0
- package/dist/registries/skills.js.map +1 -0
- package/dist/registries/surfaces.d.ts +13 -0
- package/dist/registries/surfaces.d.ts.map +1 -0
- package/dist/registries/surfaces.js +14 -0
- package/dist/registries/surfaces.js.map +1 -0
- package/dist/registries/synthesizers.d.ts +26 -0
- package/dist/registries/synthesizers.d.ts.map +1 -0
- package/dist/registries/synthesizers.js +34 -0
- package/dist/registries/synthesizers.js.map +1 -0
- package/dist/registries/tools.d.ts +49 -0
- package/dist/registries/tools.d.ts.map +1 -0
- package/dist/registries/tools.js +98 -0
- package/dist/registries/tools.js.map +1 -0
- package/dist/registries/transcribers.d.ts +17 -0
- package/dist/registries/transcribers.d.ts.map +1 -0
- package/dist/registries/transcribers.js +18 -0
- package/dist/registries/transcribers.js.map +1 -0
- package/dist/registries/tunnel-providers.d.ts +12 -0
- package/dist/registries/tunnel-providers.d.ts.map +1 -0
- package/dist/registries/tunnel-providers.js +13 -0
- package/dist/registries/tunnel-providers.js.map +1 -0
- package/dist/registries/view-renderers.d.ts +13 -0
- package/dist/registries/view-renderers.d.ts.map +1 -0
- package/dist/registries/view-renderers.js +14 -0
- package/dist/registries/view-renderers.js.map +1 -0
- package/dist/registries/workflow-executors.d.ts +13 -0
- package/dist/registries/workflow-executors.d.ts.map +1 -0
- package/dist/registries/workflow-executors.js +14 -0
- package/dist/registries/workflow-executors.js.map +1 -0
- package/dist/requirements.d.ts +59 -0
- package/dist/requirements.d.ts.map +1 -0
- package/dist/requirements.js +149 -0
- package/dist/requirements.js.map +1 -0
- package/dist/run-turn.d.ts +6 -0
- package/dist/run-turn.d.ts.map +1 -0
- package/dist/run-turn.js +141 -0
- package/dist/run-turn.js.map +1 -0
- package/dist/session-runtime.d.ts +67 -0
- package/dist/session-runtime.d.ts.map +1 -0
- package/dist/session-runtime.js +2 -0
- package/dist/session-runtime.js.map +1 -0
- package/dist/session.d.ts +203 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +399 -0
- package/dist/session.js.map +1 -0
- package/dist/sessions/persistence.d.ts +184 -0
- package/dist/sessions/persistence.d.ts.map +1 -0
- package/dist/sessions/persistence.js +442 -0
- package/dist/sessions/persistence.js.map +1 -0
- package/dist/setup-agent.d.ts +91 -0
- package/dist/setup-agent.d.ts.map +1 -0
- package/dist/setup-agent.js +138 -0
- package/dist/setup-agent.js.map +1 -0
- package/dist/skills/index.d.ts +4 -0
- package/dist/skills/index.d.ts.map +1 -0
- package/dist/skills/index.js +4 -0
- package/dist/skills/index.js.map +1 -0
- package/dist/skills/loader.d.ts +16 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/loader.js +99 -0
- package/dist/skills/loader.js.map +1 -0
- package/dist/skills/parse.d.ts +5 -0
- package/dist/skills/parse.d.ts.map +1 -0
- package/dist/skills/parse.js +8 -0
- package/dist/skills/parse.js.map +1 -0
- package/dist/skills/synthesize-draft.d.ts +8 -0
- package/dist/skills/synthesize-draft.d.ts.map +1 -0
- package/dist/skills/synthesize-draft.js +32 -0
- package/dist/skills/synthesize-draft.js.map +1 -0
- package/dist/skills/synthesize.d.ts +33 -0
- package/dist/skills/synthesize.d.ts.map +1 -0
- package/dist/skills/synthesize.js +241 -0
- package/dist/skills/synthesize.js.map +1 -0
- package/dist/subagents/events.d.ts +20 -0
- package/dist/subagents/events.d.ts.map +1 -0
- package/dist/subagents/events.js +146 -0
- package/dist/subagents/events.js.map +1 -0
- package/dist/subagents/registry.d.ts +40 -0
- package/dist/subagents/registry.d.ts.map +1 -0
- package/dist/subagents/registry.js +42 -0
- package/dist/subagents/registry.js.map +1 -0
- package/dist/subagents/run-child.d.ts +43 -0
- package/dist/subagents/run-child.d.ts.map +1 -0
- package/dist/subagents/run-child.js +276 -0
- package/dist/subagents/run-child.js.map +1 -0
- package/dist/subagents/spawn.d.ts +8 -0
- package/dist/subagents/spawn.d.ts.map +1 -0
- package/dist/subagents/spawn.js +8 -0
- package/dist/subagents/spawn.js.map +1 -0
- package/dist/subagents/tools.d.ts +4 -0
- package/dist/subagents/tools.d.ts.map +1 -0
- package/dist/subagents/tools.js +13 -0
- package/dist/subagents/tools.js.map +1 -0
- package/dist/subagents.d.ts +3 -0
- package/dist/subagents.d.ts.map +1 -0
- package/dist/subagents.js +3 -0
- package/dist/subagents.js.map +1 -0
- package/dist/surfaces/host.d.ts +53 -0
- package/dist/surfaces/host.d.ts.map +1 -0
- package/dist/surfaces/host.js +177 -0
- package/dist/surfaces/host.js.map +1 -0
- package/dist/tunnel/localhost.d.ts +7 -0
- package/dist/tunnel/localhost.d.ts.map +1 -0
- package/dist/tunnel/localhost.js +21 -0
- package/dist/tunnel/localhost.js.map +1 -0
- package/dist/usage-stats.d.ts +42 -0
- package/dist/usage-stats.d.ts.map +1 -0
- package/dist/usage-stats.js +83 -0
- package/dist/usage-stats.js.map +1 -0
- package/dist/view/default-renderer.d.ts +7 -0
- package/dist/view/default-renderer.d.ts.map +1 -0
- package/dist/view/default-renderer.js +14 -0
- package/dist/view/default-renderer.js.map +1 -0
- package/dist/view/parse.d.ts +7 -0
- package/dist/view/parse.d.ts.map +1 -0
- package/dist/view/parse.js +479 -0
- package/dist/view/parse.js.map +1 -0
- package/package.json +61 -0
- package/src/events/factory.ts +27 -0
- package/src/events/log.test.ts +470 -0
- package/src/events/log.ts +256 -0
- package/src/index.ts +102 -0
- package/src/logger.ts +46 -0
- package/src/permissions/engine.test.ts +206 -0
- package/src/permissions/engine.ts +247 -0
- package/src/permissions/resolvers.test.ts +40 -0
- package/src/permissions/resolvers.ts +17 -0
- package/src/plugins/discovery.test.ts +115 -0
- package/src/plugins/discovery.ts +124 -0
- package/src/plugins/host-options.ts +73 -0
- package/src/plugins/host-view-tunnel.test.ts +86 -0
- package/src/plugins/host.test.ts +434 -0
- package/src/plugins/host.ts +327 -0
- package/src/plugins/lifecycle.test.ts +187 -0
- package/src/plugins/lifecycle.ts +136 -0
- package/src/plugins/loader.ts +79 -0
- package/src/plugins/package-requirements.ts +50 -0
- package/src/plugins/registry-kinds.test.ts +162 -0
- package/src/plugins/registry-kinds.ts +210 -0
- package/src/plugins/toposort.test.ts +66 -0
- package/src/plugins/toposort.ts +69 -0
- package/src/preferences.test.ts +105 -0
- package/src/preferences.ts +84 -0
- package/src/registries/active-backend-registry.ts +142 -0
- package/src/registries/active-def-registry.test.ts +103 -0
- package/src/registries/active-def-registry.ts +97 -0
- package/src/registries/agents.ts +18 -0
- package/src/registries/cache-strategies.ts +14 -0
- package/src/registries/channels.ts +42 -0
- package/src/registries/commands.test.ts +71 -0
- package/src/registries/commands.ts +78 -0
- package/src/registries/compactors.ts +14 -0
- package/src/registries/def-map-registry.test.ts +67 -0
- package/src/registries/def-map-registry.ts +59 -0
- package/src/registries/embedders.test.ts +54 -0
- package/src/registries/embedders.ts +18 -0
- package/src/registries/isolators.test.ts +43 -0
- package/src/registries/isolators.ts +38 -0
- package/src/registries/modes.test.ts +66 -0
- package/src/registries/modes.ts +80 -0
- package/src/registries/providers.test.ts +50 -0
- package/src/registries/providers.ts +97 -0
- package/src/registries/semantics.test.ts +117 -0
- package/src/registries/skills.ts +87 -0
- package/src/registries/surfaces.ts +18 -0
- package/src/registries/synthesizers.test.ts +63 -0
- package/src/registries/synthesizers.ts +36 -0
- package/src/registries/tools.test.ts +142 -0
- package/src/registries/tools.ts +145 -0
- package/src/registries/transcribers.test.ts +70 -0
- package/src/registries/transcribers.ts +19 -0
- package/src/registries/tunnel-providers.test.ts +30 -0
- package/src/registries/tunnel-providers.ts +14 -0
- package/src/registries/view-renderers.test.ts +68 -0
- package/src/registries/view-renderers.ts +15 -0
- package/src/registries/workflow-executors.test.ts +53 -0
- package/src/registries/workflow-executors.ts +15 -0
- package/src/requirements.targetinfo.test.ts +70 -0
- package/src/requirements.test.ts +111 -0
- package/src/requirements.ts +262 -0
- package/src/run-turn.test.ts +178 -0
- package/src/run-turn.ts +156 -0
- package/src/session-runtime.ts +75 -0
- package/src/session.test.ts +216 -0
- package/src/session.ts +491 -0
- package/src/sessions/persistence.flush.test.ts +100 -0
- package/src/sessions/persistence.ready.test.ts +91 -0
- package/src/sessions/persistence.test.ts +274 -0
- package/src/sessions/persistence.ts +498 -0
- package/src/setup-agent.test.ts +72 -0
- package/src/setup-agent.ts +189 -0
- package/src/skills/index.ts +14 -0
- package/src/skills/loader.test.ts +102 -0
- package/src/skills/loader.ts +114 -0
- package/src/skills/parse.test.ts +59 -0
- package/src/skills/parse.ts +11 -0
- package/src/skills/synthesize-draft.test.ts +79 -0
- package/src/skills/synthesize-draft.ts +46 -0
- package/src/skills/synthesize.test.ts +170 -0
- package/src/skills/synthesize.ts +304 -0
- package/src/subagents/events.test.ts +56 -0
- package/src/subagents/events.ts +194 -0
- package/src/subagents/registry.ts +62 -0
- package/src/subagents/run-child.test.ts +185 -0
- package/src/subagents/run-child.ts +425 -0
- package/src/subagents/spawn.ts +12 -0
- package/src/subagents/tools.ts +19 -0
- package/src/subagents.ts +2 -0
- package/src/surfaces/host.test.ts +330 -0
- package/src/surfaces/host.ts +192 -0
- package/src/tunnel/localhost.test.ts +22 -0
- package/src/tunnel/localhost.ts +21 -0
- package/src/usage-stats.test.ts +77 -0
- package/src/usage-stats.ts +119 -0
- package/src/view/default-renderer.test.ts +29 -0
- package/src/view/default-renderer.ts +14 -0
- package/src/view/parse-extended.test.ts +255 -0
- package/src/view/parse.test.ts +232 -0
- package/src/view/parse.ts +520 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { CompactorDef, ModeDef, ProviderDef } from '@moxxy/sdk';
|
|
3
|
+
import { ProviderRegistry } from './providers.js';
|
|
4
|
+
import { ModeRegistry } from './modes.js';
|
|
5
|
+
import { CompactorRegistry } from './compactors.js';
|
|
6
|
+
import { SkillRegistryImpl } from './skills.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* PR3-1 regression suite: every registry behaves the same way on
|
|
10
|
+
* duplicate register (throws), exposes an explicit `replace()`, and
|
|
11
|
+
* has predictable active-slot behavior on unregister.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fakeProvider = (name: string): ProviderDef => ({
|
|
15
|
+
name,
|
|
16
|
+
models: [],
|
|
17
|
+
createClient: () => ({ name, models: [], stream: async function* () {}, countTokens: async () => 0 }),
|
|
18
|
+
});
|
|
19
|
+
const fakeLoop = (name: string): ModeDef => ({ name, run: async function* () {} });
|
|
20
|
+
const fakeCompactor = (name: string): CompactorDef => ({
|
|
21
|
+
name,
|
|
22
|
+
shouldCompact: () => false,
|
|
23
|
+
compact: async () => ({}) as never,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('Registry consistency (PR3-1)', () => {
|
|
27
|
+
it('ProviderRegistry: throws on duplicate, replace() overrides + drops cached instance', () => {
|
|
28
|
+
const r = new ProviderRegistry();
|
|
29
|
+
r.register(fakeProvider('a'));
|
|
30
|
+
expect(() => r.register(fakeProvider('a'))).toThrow(/already registered/);
|
|
31
|
+
|
|
32
|
+
r.setActive('a'); // caches an instance
|
|
33
|
+
r.replace(fakeProvider('a'));
|
|
34
|
+
// Replacing should drop the old instance so the new createClient runs on next setActive
|
|
35
|
+
let constructed = 0;
|
|
36
|
+
r.replace({
|
|
37
|
+
name: 'a',
|
|
38
|
+
models: [],
|
|
39
|
+
createClient: () => {
|
|
40
|
+
constructed++;
|
|
41
|
+
return { name: 'a', models: [], stream: async function* () {}, countTokens: async () => 0 };
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
r.setActive('a');
|
|
45
|
+
expect(constructed).toBe(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('ProviderRegistry: unregister clears active', () => {
|
|
49
|
+
const r = new ProviderRegistry();
|
|
50
|
+
r.register(fakeProvider('a'));
|
|
51
|
+
r.setActive('a');
|
|
52
|
+
r.unregister('a');
|
|
53
|
+
expect(r.getActiveName()).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('ModeRegistry: throws on duplicate, auto-activates first, unregister clears active', () => {
|
|
57
|
+
const r = new ModeRegistry();
|
|
58
|
+
r.register(fakeLoop('first'));
|
|
59
|
+
r.register(fakeLoop('second'));
|
|
60
|
+
expect(() => r.register(fakeLoop('first'))).toThrow(/already registered/);
|
|
61
|
+
expect(r.getActive().name).toBe('first');
|
|
62
|
+
|
|
63
|
+
r.unregister('first');
|
|
64
|
+
// Don't silently pick "second" — caller must setActive explicitly.
|
|
65
|
+
expect(() => r.getActive()).toThrow(/no active/i);
|
|
66
|
+
|
|
67
|
+
r.setActive('second');
|
|
68
|
+
expect(r.getActive().name).toBe('second');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('CompactorRegistry: throws on duplicate, auto-activates first, unregister clears active', () => {
|
|
72
|
+
const r = new CompactorRegistry();
|
|
73
|
+
r.register(fakeCompactor('first'));
|
|
74
|
+
r.register(fakeCompactor('second'));
|
|
75
|
+
expect(() => r.register(fakeCompactor('first'))).toThrow(/already registered/);
|
|
76
|
+
expect(r.getActive()?.name).toBe('first');
|
|
77
|
+
|
|
78
|
+
r.unregister('first');
|
|
79
|
+
// getActive returns null after the active is gone (no arbitrary fallback).
|
|
80
|
+
expect(r.getActive()).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('SkillRegistryImpl: throws on duplicate id, replace() overrides', () => {
|
|
84
|
+
const r = new SkillRegistryImpl();
|
|
85
|
+
const skill = {
|
|
86
|
+
id: 'project/foo' as never,
|
|
87
|
+
path: '/a.md',
|
|
88
|
+
scope: 'project' as const,
|
|
89
|
+
frontmatter: { name: 'foo', description: 'd' },
|
|
90
|
+
body: 'one',
|
|
91
|
+
};
|
|
92
|
+
r.register(skill);
|
|
93
|
+
expect(() => r.register(skill)).toThrow(/already registered/);
|
|
94
|
+
r.replace({ ...skill, body: 'two' });
|
|
95
|
+
expect(r.get('project/foo' as never)?.body).toBe('two');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('SkillRegistryImpl: replace() drops the old name index entry when frontmatter.name changes', () => {
|
|
99
|
+
const r = new SkillRegistryImpl();
|
|
100
|
+
const skill = {
|
|
101
|
+
id: 'project/x' as never,
|
|
102
|
+
path: '/a.md',
|
|
103
|
+
scope: 'project' as const,
|
|
104
|
+
frontmatter: { name: 'foo', description: 'd' },
|
|
105
|
+
body: 'one',
|
|
106
|
+
};
|
|
107
|
+
r.register(skill);
|
|
108
|
+
expect(r.byName('foo')?.id).toBe('project/x');
|
|
109
|
+
// Same id, new frontmatter.name (an edited skill re-registered).
|
|
110
|
+
r.replace({ ...skill, frontmatter: { name: 'bar', description: 'd' }, body: 'two' });
|
|
111
|
+
// The stale old-name index entry must be reclaimed — two live names for
|
|
112
|
+
// one id was the bug.
|
|
113
|
+
expect(r.byName('foo')).toBeUndefined();
|
|
114
|
+
expect(r.byName('bar')?.body).toBe('two');
|
|
115
|
+
expect(r.list()).toHaveLength(1);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Skill, SkillRegistry } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
export class SkillRegistryImpl implements SkillRegistry {
|
|
4
|
+
private readonly byId = new Map<string, Skill>();
|
|
5
|
+
private readonly byNameIdx = new Map<string, Skill>();
|
|
6
|
+
|
|
7
|
+
list(): ReadonlyArray<Skill> {
|
|
8
|
+
return [...this.byId.values()];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
get(id: string): Skill | undefined {
|
|
12
|
+
return this.byId.get(id);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
byName(name: string): Skill | undefined {
|
|
16
|
+
return this.byNameIdx.get(name);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
filterByTriggers(prompt: string): ReadonlyArray<Skill> {
|
|
20
|
+
const lower = prompt.toLowerCase();
|
|
21
|
+
const matches: Skill[] = [];
|
|
22
|
+
for (const skill of this.byId.values()) {
|
|
23
|
+
const triggers = skill.frontmatter.triggers ?? [];
|
|
24
|
+
if (triggers.some((t) => lower.includes(t.toLowerCase()))) matches.push(skill);
|
|
25
|
+
}
|
|
26
|
+
return matches;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Register a skill. Throws on duplicate id — use `replace()` for
|
|
31
|
+
* overwrite (or `replaceAll()` for atomic bulk swap).
|
|
32
|
+
*/
|
|
33
|
+
register(skill: Skill): void {
|
|
34
|
+
if (this.byId.has(skill.id)) {
|
|
35
|
+
throw new Error(`Skill already registered: ${skill.id}`);
|
|
36
|
+
}
|
|
37
|
+
this.byId.set(skill.id, skill);
|
|
38
|
+
this.byNameIdx.set(skill.frontmatter.name, skill);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
replace(skill: Skill): void {
|
|
42
|
+
// Drop the prior definition's name from the index when it changed —
|
|
43
|
+
// otherwise an edited skill re-registered under the same id but a new
|
|
44
|
+
// frontmatter.name leaves the OLD name pointing at the stale skill
|
|
45
|
+
// forever (two live names for one id). Mirrors CommandRegistry.replace's
|
|
46
|
+
// alias cleanup.
|
|
47
|
+
const prior = this.byId.get(skill.id);
|
|
48
|
+
if (prior && prior.frontmatter.name !== skill.frontmatter.name) {
|
|
49
|
+
this.byNameIdx.delete(prior.frontmatter.name);
|
|
50
|
+
}
|
|
51
|
+
this.byId.set(skill.id, skill);
|
|
52
|
+
this.byNameIdx.set(skill.frontmatter.name, skill);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
unregister(id: string): void {
|
|
56
|
+
const skill = this.byId.get(id);
|
|
57
|
+
if (!skill) return;
|
|
58
|
+
this.byId.delete(id);
|
|
59
|
+
this.byNameIdx.delete(skill.frontmatter.name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
clear(): void {
|
|
63
|
+
this.byId.clear();
|
|
64
|
+
this.byNameIdx.clear();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Replace the registry's entire contents atomically (no observable empty
|
|
69
|
+
* window). Used by `reload_skills` so a concurrent lookup never sees
|
|
70
|
+
* zero skills while a rescan is in flight.
|
|
71
|
+
*/
|
|
72
|
+
replaceAll(skills: ReadonlyArray<Skill>): void {
|
|
73
|
+
const nextById = new Map<string, Skill>();
|
|
74
|
+
const nextByName = new Map<string, Skill>();
|
|
75
|
+
for (const s of skills) {
|
|
76
|
+
nextById.set(s.id, s);
|
|
77
|
+
nextByName.set(s.frontmatter.name, s);
|
|
78
|
+
}
|
|
79
|
+
// Synchronous swap: callers between these lines still see the OLD
|
|
80
|
+
// registry (Maps aren't replaced, only repopulated). Clear+set is the
|
|
81
|
+
// shortest synchronous window we can achieve in JS for Map fields.
|
|
82
|
+
this.byId.clear();
|
|
83
|
+
this.byNameIdx.clear();
|
|
84
|
+
for (const [k, v] of nextById) this.byId.set(k, v);
|
|
85
|
+
for (const [k, v] of nextByName) this.byNameIdx.set(k, v);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { SurfaceDef, SurfaceKind, SurfaceRegistry } from '@moxxy/sdk';
|
|
2
|
+
import { DefMapRegistry } from './def-map-registry.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Registry of surface defs contributed by plugins (terminal, browser, …),
|
|
6
|
+
* keyed by `kind`. A flat name→def map ({@link DefMapRegistry}) with
|
|
7
|
+
* register/unregister so the PluginHost can add and remove a plugin's
|
|
8
|
+
* surfaces on load/unload. The live, open instances are managed separately by
|
|
9
|
+
* the {@link SurfaceHostImpl}; this registry only knows the available kinds.
|
|
10
|
+
*/
|
|
11
|
+
export class SurfaceRegistryImpl
|
|
12
|
+
extends DefMapRegistry<SurfaceDef, SurfaceKind>
|
|
13
|
+
implements SurfaceRegistry
|
|
14
|
+
{
|
|
15
|
+
constructor() {
|
|
16
|
+
super({ noun: 'Surface', keyOf: (def) => def.kind });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { Synthesizer, SynthesizerDef } from '@moxxy/sdk';
|
|
3
|
+
import { SynthesizerRegistry } from './synthesizers.js';
|
|
4
|
+
|
|
5
|
+
function def(name: string, onCreate?: (ctx: { getSecret?: (n: string) => Promise<string | null> }) => void): SynthesizerDef {
|
|
6
|
+
return {
|
|
7
|
+
name,
|
|
8
|
+
create: (ctx): Synthesizer => {
|
|
9
|
+
onCreate?.(ctx);
|
|
10
|
+
return {
|
|
11
|
+
name,
|
|
12
|
+
synthesize: async () => ({ audio: new Uint8Array([1, 2, 3]), mimeType: 'audio/mpeg' }),
|
|
13
|
+
};
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('SynthesizerRegistry', () => {
|
|
19
|
+
it('auto-activates the first registered synthesizer', () => {
|
|
20
|
+
const reg = new SynthesizerRegistry();
|
|
21
|
+
expect(reg.getActiveName()).toBeNull();
|
|
22
|
+
reg.register(def('elevenlabs'));
|
|
23
|
+
expect(reg.getActiveName()).toBe('elevenlabs');
|
|
24
|
+
// A second registration does NOT steal the active slot.
|
|
25
|
+
reg.register(def('openai'));
|
|
26
|
+
expect(reg.getActiveName()).toBe('elevenlabs');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('setActive / clearActive switch the active backend', () => {
|
|
30
|
+
const reg = new SynthesizerRegistry();
|
|
31
|
+
reg.register(def('elevenlabs'));
|
|
32
|
+
reg.register(def('openai'));
|
|
33
|
+
reg.setActive('openai');
|
|
34
|
+
expect(reg.getActiveName()).toBe('openai');
|
|
35
|
+
reg.clearActive();
|
|
36
|
+
expect(reg.getActiveName()).toBeNull();
|
|
37
|
+
expect(reg.tryGetActive()).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('unregistering the active backend clears active', () => {
|
|
41
|
+
const reg = new SynthesizerRegistry();
|
|
42
|
+
reg.register(def('elevenlabs'));
|
|
43
|
+
reg.unregister('elevenlabs');
|
|
44
|
+
expect(reg.getActiveName()).toBeNull();
|
|
45
|
+
expect(reg.has('elevenlabs')).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('passes the vault-backed getSecret into create()', async () => {
|
|
49
|
+
let seen: ((n: string) => Promise<string | null>) | undefined;
|
|
50
|
+
const reg = new SynthesizerRegistry({
|
|
51
|
+
secretResolver: async (n) => (n === 'ELEVENLABS_API_KEY' ? 'sk-1' : null),
|
|
52
|
+
});
|
|
53
|
+
reg.register(def('elevenlabs', (ctx) => (seen = ctx.getSecret)));
|
|
54
|
+
reg.getActive(); // forces create()
|
|
55
|
+
expect(typeof seen).toBe('function');
|
|
56
|
+
expect(await seen!('ELEVENLABS_API_KEY')).toBe('sk-1');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('getActive throws when none is active', () => {
|
|
60
|
+
const reg = new SynthesizerRegistry();
|
|
61
|
+
expect(() => reg.getActive()).toThrow(/No active synthesizer/);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Synthesizer, SynthesizerDef } from '@moxxy/sdk';
|
|
2
|
+
import { ActiveBackendRegistry } from './active-backend-registry.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Registry of text-to-speech backends:
|
|
6
|
+
* - plugins call `register(def)` at load time
|
|
7
|
+
* - read-aloud surfaces read `tryGetActive()` to turn text into audio
|
|
8
|
+
* - the agent switches/deactivates via the `set_voice` tool
|
|
9
|
+
*
|
|
10
|
+
* Unlike transcribers/embedders (where the host picks a backend explicitly),
|
|
11
|
+
* TTS has a sensible default: the first synthesizer registered auto-becomes
|
|
12
|
+
* active (`autoAdoptFirst`), so a user who asks the agent to author an
|
|
13
|
+
* ElevenLabs plugin gets read-aloud through it immediately — no extra activate
|
|
14
|
+
* step. Because `active` can thus be set without a built instance, the active
|
|
15
|
+
* synthesizer is (re)built lazily on read (`buildOnRead`). `getActive()` throws
|
|
16
|
+
* when none is active so the desktop falls back to the OS `speechSynthesis`.
|
|
17
|
+
*
|
|
18
|
+
* `secretResolver` is the vault-backed `getSecret` (the same one wired into
|
|
19
|
+
* tool contexts), handed to each synthesizer's `create(ctx)` so a TTS plugin
|
|
20
|
+
* can read its API key without touching `process.env`.
|
|
21
|
+
*/
|
|
22
|
+
export class SynthesizerRegistry extends ActiveBackendRegistry<SynthesizerDef, Synthesizer> {
|
|
23
|
+
constructor(opts: { secretResolver?: (name: string) => Promise<string | null> } = {}) {
|
|
24
|
+
const { secretResolver } = opts;
|
|
25
|
+
super({
|
|
26
|
+
noun: 'Synthesizer',
|
|
27
|
+
autoAdoptFirst: true,
|
|
28
|
+
buildOnRead: true,
|
|
29
|
+
build: (def, config) =>
|
|
30
|
+
def.create({
|
|
31
|
+
config,
|
|
32
|
+
...(secretResolver ? { getSecret: secretResolver } : {}),
|
|
33
|
+
}),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { defineTool } from '@moxxy/sdk';
|
|
4
|
+
import { silentLogger } from '../logger.js';
|
|
5
|
+
import { ToolRegistryImpl } from './tools.js';
|
|
6
|
+
|
|
7
|
+
describe('ToolRegistryImpl', () => {
|
|
8
|
+
const make = () => new ToolRegistryImpl({ logger: silentLogger, cwd: '/tmp' });
|
|
9
|
+
|
|
10
|
+
it('registers, lists, and looks up tools', () => {
|
|
11
|
+
const reg = make();
|
|
12
|
+
const tool = defineTool({
|
|
13
|
+
name: 'echo',
|
|
14
|
+
description: 'e',
|
|
15
|
+
inputSchema: z.object({ msg: z.string() }),
|
|
16
|
+
handler: (i) => i.msg,
|
|
17
|
+
});
|
|
18
|
+
reg.register(tool);
|
|
19
|
+
expect(reg.list()).toHaveLength(1);
|
|
20
|
+
expect(reg.has('echo')).toBe(true);
|
|
21
|
+
expect(reg.get('echo')?.name).toBe('echo');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects duplicate registration', () => {
|
|
25
|
+
const reg = make();
|
|
26
|
+
const tool = defineTool({
|
|
27
|
+
name: 'echo',
|
|
28
|
+
description: 'e',
|
|
29
|
+
inputSchema: z.string(),
|
|
30
|
+
handler: (s) => s,
|
|
31
|
+
});
|
|
32
|
+
reg.register(tool);
|
|
33
|
+
expect(() => reg.register(tool)).toThrow(/already registered/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('throws on unknown tool', async () => {
|
|
37
|
+
const reg = make();
|
|
38
|
+
await expect(reg.execute('nope', {}, new AbortController().signal)).rejects.toThrow(/Unknown tool/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('validates input via schema', async () => {
|
|
42
|
+
const reg = make();
|
|
43
|
+
reg.register(
|
|
44
|
+
defineTool({
|
|
45
|
+
name: 'echo',
|
|
46
|
+
description: 'e',
|
|
47
|
+
inputSchema: z.object({ msg: z.string() }),
|
|
48
|
+
handler: (i) => i.msg.toUpperCase(),
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
await expect(
|
|
52
|
+
reg.execute('echo', { msg: 123 }, new AbortController().signal),
|
|
53
|
+
).rejects.toThrow(/Expected string/);
|
|
54
|
+
const result = await reg.execute('echo', { msg: 'hi' }, new AbortController().signal);
|
|
55
|
+
expect(result).toBe('HI');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('validates output if outputSchema present, formatting the issues into one line', async () => {
|
|
59
|
+
const reg = make();
|
|
60
|
+
reg.register(
|
|
61
|
+
defineTool({
|
|
62
|
+
name: 'badShape',
|
|
63
|
+
description: 'e',
|
|
64
|
+
inputSchema: z.object({}),
|
|
65
|
+
outputSchema: z.object({ ok: z.literal(true) }),
|
|
66
|
+
handler: () => ({ ok: false }) as never,
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
// Mirrors the input path: names the tool + offending field on a single
|
|
70
|
+
// line instead of dumping the raw multi-line ZodError.
|
|
71
|
+
await expect(
|
|
72
|
+
reg.execute('badShape', {}, new AbortController().signal),
|
|
73
|
+
).rejects.toThrow(/Tool badShape produced invalid output: ok: /);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns the parsed output when outputSchema passes', async () => {
|
|
77
|
+
const reg = make();
|
|
78
|
+
reg.register(
|
|
79
|
+
defineTool({
|
|
80
|
+
name: 'goodShape',
|
|
81
|
+
description: 'e',
|
|
82
|
+
inputSchema: z.object({}),
|
|
83
|
+
outputSchema: z.object({ ok: z.literal(true) }),
|
|
84
|
+
handler: () => ({ ok: true, extra: 'stripped' }) as never,
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
// outputSchema.safeParse strips unknown keys exactly like the prior .parse did.
|
|
88
|
+
const result = await reg.execute('goodShape', {}, new AbortController().signal);
|
|
89
|
+
expect(result).toEqual({ ok: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('unregisters', () => {
|
|
93
|
+
const reg = make();
|
|
94
|
+
const tool = defineTool({
|
|
95
|
+
name: 'echo',
|
|
96
|
+
description: 'e',
|
|
97
|
+
inputSchema: z.string(),
|
|
98
|
+
handler: (s) => s,
|
|
99
|
+
});
|
|
100
|
+
reg.register(tool);
|
|
101
|
+
reg.unregister('echo');
|
|
102
|
+
expect(reg.has('echo')).toBe(false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('exposes ctx.getSecret backed by the secretResolver', async () => {
|
|
106
|
+
const secrets: Record<string, string> = { ELEVENLABS_API_KEY: 'sk-123' };
|
|
107
|
+
const reg = new ToolRegistryImpl({
|
|
108
|
+
logger: silentLogger,
|
|
109
|
+
cwd: '/tmp',
|
|
110
|
+
secretResolver: async (name) => secrets[name] ?? null,
|
|
111
|
+
});
|
|
112
|
+
reg.register(
|
|
113
|
+
defineTool({
|
|
114
|
+
name: 'reads_secret',
|
|
115
|
+
description: 'reads a secret',
|
|
116
|
+
inputSchema: z.object({ name: z.string() }),
|
|
117
|
+
handler: async ({ name }, ctx) => ({
|
|
118
|
+
hasGetSecret: typeof ctx.getSecret === 'function',
|
|
119
|
+
value: (await ctx.getSecret?.(name)) ?? null,
|
|
120
|
+
}),
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
const hit = await reg.execute('reads_secret', { name: 'ELEVENLABS_API_KEY' }, new AbortController().signal);
|
|
124
|
+
expect(hit).toEqual({ hasGetSecret: true, value: 'sk-123' });
|
|
125
|
+
const miss = await reg.execute('reads_secret', { name: 'NOPE' }, new AbortController().signal);
|
|
126
|
+
expect(miss).toEqual({ hasGetSecret: true, value: null });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('omits ctx.getSecret when no secretResolver is wired', async () => {
|
|
130
|
+
const reg = make();
|
|
131
|
+
reg.register(
|
|
132
|
+
defineTool({
|
|
133
|
+
name: 'no_secret',
|
|
134
|
+
description: 'no secret resolver',
|
|
135
|
+
inputSchema: z.object({}),
|
|
136
|
+
handler: async (_i, ctx) => ({ hasGetSecret: typeof ctx.getSecret === 'function' }),
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
const result = await reg.execute('no_secret', {}, new AbortController().signal);
|
|
140
|
+
expect(result).toEqual({ hasGetSecret: false });
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type { EventLogReader, SubagentSpawner, ToolContext, ToolDef } from '@moxxy/sdk';
|
|
2
|
+
import type { Logger } from '../logger.js';
|
|
3
|
+
import { asToolCallId, asSessionId, asTurnId } from '@moxxy/sdk';
|
|
4
|
+
|
|
5
|
+
export interface ToolRegistry {
|
|
6
|
+
list(): ReadonlyArray<ToolDef>;
|
|
7
|
+
get(name: string): ToolDef | undefined;
|
|
8
|
+
has(name: string): boolean;
|
|
9
|
+
register(tool: ToolDef): void;
|
|
10
|
+
unregister(name: string): void;
|
|
11
|
+
execute(name: string, input: unknown, signal: AbortSignal, opts?: ExecuteOptions): Promise<unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ExecuteOptions {
|
|
15
|
+
readonly callId?: string;
|
|
16
|
+
readonly sessionId?: string;
|
|
17
|
+
readonly turnId?: string;
|
|
18
|
+
readonly log?: EventLogReader;
|
|
19
|
+
readonly logger?: Logger;
|
|
20
|
+
readonly cwd?: string;
|
|
21
|
+
/**
|
|
22
|
+
* Optional spawner — passed by run-turn so multi-agent tools (e.g.
|
|
23
|
+
* `dispatch_agent`) can fan work out from inside the tool-use loop.
|
|
24
|
+
* Plain `tools.execute()` callers (tests, one-off scripts) may omit it.
|
|
25
|
+
*/
|
|
26
|
+
readonly subagents?: SubagentSpawner;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class ToolRegistryImpl implements ToolRegistry {
|
|
30
|
+
private readonly tools = new Map<string, ToolDef>();
|
|
31
|
+
private readonly defaultLogger: Logger;
|
|
32
|
+
private readonly defaultCwd: string;
|
|
33
|
+
/**
|
|
34
|
+
* Vault-backed secret resolver, when the host wires one. Surfaced to
|
|
35
|
+
* every tool handler as `ctx.getSecret(name)` so plugins can read an
|
|
36
|
+
* API key at call time without the value ever entering the model's
|
|
37
|
+
* context or `process.env`.
|
|
38
|
+
*/
|
|
39
|
+
private readonly secretResolver?: (name: string) => Promise<string | null>;
|
|
40
|
+
|
|
41
|
+
constructor(opts: {
|
|
42
|
+
logger: Logger;
|
|
43
|
+
cwd: string;
|
|
44
|
+
secretResolver?: (name: string) => Promise<string | null>;
|
|
45
|
+
}) {
|
|
46
|
+
this.defaultLogger = opts.logger;
|
|
47
|
+
this.defaultCwd = opts.cwd;
|
|
48
|
+
this.secretResolver = opts.secretResolver;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
list(): ReadonlyArray<ToolDef> {
|
|
52
|
+
return [...this.tools.values()];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get(name: string): ToolDef | undefined {
|
|
56
|
+
return this.tools.get(name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
has(name: string): boolean {
|
|
60
|
+
return this.tools.has(name);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
register(tool: ToolDef): void {
|
|
64
|
+
if (this.tools.has(tool.name)) {
|
|
65
|
+
throw new Error(`Tool already registered: ${tool.name}`);
|
|
66
|
+
}
|
|
67
|
+
this.tools.set(tool.name, tool);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
unregister(name: string): void {
|
|
71
|
+
this.tools.delete(name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async execute(
|
|
75
|
+
name: string,
|
|
76
|
+
input: unknown,
|
|
77
|
+
signal: AbortSignal,
|
|
78
|
+
opts: ExecuteOptions = {},
|
|
79
|
+
): Promise<unknown> {
|
|
80
|
+
const tool = this.tools.get(name);
|
|
81
|
+
if (!tool) throw new Error(`Unknown tool: ${name}`);
|
|
82
|
+
// Use safeParse so a validation failure surfaces as a clean,
|
|
83
|
+
// single-line error in the tool_result instead of the raw ZodError
|
|
84
|
+
// (which JSON-stringifies into 20+ lines of red noise — observed
|
|
85
|
+
// with memory_save and synthesize_skill). The formatted message tells
|
|
86
|
+
// the model exactly which fields are off and why, so it can retry.
|
|
87
|
+
const parseResult = tool.inputSchema.safeParse(input);
|
|
88
|
+
if (!parseResult.success) {
|
|
89
|
+
throw new Error(`Invalid input for ${name}: ${formatZodIssues(parseResult.error)}`);
|
|
90
|
+
}
|
|
91
|
+
const parsed = parseResult.data;
|
|
92
|
+
|
|
93
|
+
const ctx: ToolContext = {
|
|
94
|
+
sessionId: asSessionId(opts.sessionId ?? 'no-session'),
|
|
95
|
+
turnId: asTurnId(opts.turnId ?? 'no-turn'),
|
|
96
|
+
callId: asToolCallId(opts.callId ?? 'no-call'),
|
|
97
|
+
cwd: opts.cwd ?? this.defaultCwd,
|
|
98
|
+
signal,
|
|
99
|
+
log: opts.log ?? emptyLog(),
|
|
100
|
+
logger: opts.logger ?? this.defaultLogger,
|
|
101
|
+
...(opts.subagents ? { subagents: opts.subagents } : {}),
|
|
102
|
+
...(this.secretResolver ? { getSecret: this.secretResolver } : {}),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const result = await tool.handler(parsed, ctx);
|
|
106
|
+
if (tool.outputSchema) {
|
|
107
|
+
// Mirror the input path: format an output-schema mismatch (a plugin bug)
|
|
108
|
+
// into the same single-line message instead of letting the raw 20-line
|
|
109
|
+
// ZodError surface, and name the offending tool + fields.
|
|
110
|
+
const outResult = tool.outputSchema.safeParse(result);
|
|
111
|
+
if (!outResult.success) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`Tool ${name} produced invalid output: ${formatZodIssues(outResult.error)}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return outResult.data;
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Format a ZodError's issues into a single-line, model-friendly string
|
|
124
|
+
* (`path: message; path: message`). Used by both input and output validation
|
|
125
|
+
* so the raw multi-line ZodError JSON noise never reaches the model.
|
|
126
|
+
*/
|
|
127
|
+
function formatZodIssues(error: { issues: ReadonlyArray<{ path: ReadonlyArray<PropertyKey>; message: string }> }): string {
|
|
128
|
+
return error.issues
|
|
129
|
+
.map((iss) => {
|
|
130
|
+
const path = iss.path.length ? iss.path.join('.') : '(root)';
|
|
131
|
+
return `${path}: ${iss.message}`;
|
|
132
|
+
})
|
|
133
|
+
.join('; ');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function emptyLog(): EventLogReader {
|
|
137
|
+
return {
|
|
138
|
+
length: 0,
|
|
139
|
+
at: () => undefined,
|
|
140
|
+
slice: () => [],
|
|
141
|
+
ofType: () => [],
|
|
142
|
+
byTurn: () => [],
|
|
143
|
+
toJSON: () => [],
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { defineTranscriber } from '@moxxy/sdk';
|
|
3
|
+
import { TranscriberRegistry } from './transcribers.js';
|
|
4
|
+
|
|
5
|
+
const fake = (name: string, echo = '') =>
|
|
6
|
+
defineTranscriber({
|
|
7
|
+
name,
|
|
8
|
+
createClient: (cfg) => ({
|
|
9
|
+
name,
|
|
10
|
+
transcribe: async () => ({ text: String(cfg.echo ?? echo) }),
|
|
11
|
+
}),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('TranscriberRegistry', () => {
|
|
15
|
+
it('registers, lists, and looks up', () => {
|
|
16
|
+
const reg = new TranscriberRegistry();
|
|
17
|
+
reg.register(fake('whisper'));
|
|
18
|
+
expect(reg.has('whisper')).toBe(true);
|
|
19
|
+
expect(reg.list()).toHaveLength(1);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('rejects duplicate registration', () => {
|
|
23
|
+
const reg = new TranscriberRegistry();
|
|
24
|
+
const t = fake('whisper');
|
|
25
|
+
reg.register(t);
|
|
26
|
+
expect(() => reg.register(t)).toThrow(/already registered/);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('setActive instantiates lazily and getActive returns the instance', async () => {
|
|
30
|
+
const reg = new TranscriberRegistry();
|
|
31
|
+
reg.register(fake('whisper'));
|
|
32
|
+
expect(reg.getActiveName()).toBeNull();
|
|
33
|
+
const inst = reg.setActive('whisper', { echo: 'hello' });
|
|
34
|
+
expect(reg.getActiveName()).toBe('whisper');
|
|
35
|
+
expect(reg.getActive()).toBe(inst);
|
|
36
|
+
const out = await inst.transcribe(new Uint8Array());
|
|
37
|
+
expect(out.text).toBe('hello');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('tryGetActive returns null without an active one', () => {
|
|
41
|
+
const reg = new TranscriberRegistry();
|
|
42
|
+
expect(reg.tryGetActive()).toBeNull();
|
|
43
|
+
expect(() => reg.getActive()).toThrow(/No active transcriber/);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('unregister clears active when it matches', () => {
|
|
47
|
+
const reg = new TranscriberRegistry();
|
|
48
|
+
reg.register(fake('whisper'));
|
|
49
|
+
reg.setActive('whisper');
|
|
50
|
+
reg.unregister('whisper');
|
|
51
|
+
expect(reg.getActiveName()).toBeNull();
|
|
52
|
+
expect(reg.tryGetActive()).toBeNull();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('setActive throws for unknown name', () => {
|
|
56
|
+
const reg = new TranscriberRegistry();
|
|
57
|
+
expect(() => reg.setActive('nope')).toThrow(/not registered/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('replace overwrites def and drops cached instance', () => {
|
|
61
|
+
const reg = new TranscriberRegistry();
|
|
62
|
+
reg.register(fake('whisper', 'old'));
|
|
63
|
+
reg.setActive('whisper');
|
|
64
|
+
reg.replace(fake('whisper', 'new'));
|
|
65
|
+
const inst = reg.setActive('whisper');
|
|
66
|
+
return inst.transcribe(new Uint8Array()).then((r) => {
|
|
67
|
+
expect(r.text).toBe('new');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
});
|