@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,105 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
import { loadPreferences, preferencesPath, savePreferences } from './preferences.js';
|
|
6
|
+
|
|
7
|
+
// preferencesPath() resolves ~/.moxxy/preferences.json via os.homedir(), which
|
|
8
|
+
// derives from HOME (POSIX) / USERPROFILE (Windows). Point both at a tmpdir per
|
|
9
|
+
// test so the suite never touches the developer's real preferences file.
|
|
10
|
+
let tmpHome: string;
|
|
11
|
+
let savedHome: string | undefined;
|
|
12
|
+
let savedUserProfile: string | undefined;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
tmpHome = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-prefs-'));
|
|
16
|
+
savedHome = process.env.HOME;
|
|
17
|
+
savedUserProfile = process.env.USERPROFILE;
|
|
18
|
+
process.env.HOME = tmpHome;
|
|
19
|
+
process.env.USERPROFILE = tmpHome;
|
|
20
|
+
await fs.mkdir(path.join(tmpHome, '.moxxy'), { recursive: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(async () => {
|
|
24
|
+
if (savedHome === undefined) delete process.env.HOME;
|
|
25
|
+
else process.env.HOME = savedHome;
|
|
26
|
+
if (savedUserProfile === undefined) delete process.env.USERPROFILE;
|
|
27
|
+
else process.env.USERPROFILE = savedUserProfile;
|
|
28
|
+
await fs.rm(tmpHome, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const readRaw = async (): Promise<unknown> =>
|
|
32
|
+
JSON.parse(await fs.readFile(preferencesPath(), 'utf8'));
|
|
33
|
+
|
|
34
|
+
describe('preferences store', () => {
|
|
35
|
+
it('resolves the path under the (overridden) home dir', () => {
|
|
36
|
+
expect(preferencesPath()).toBe(path.join(tmpHome, '.moxxy', 'preferences.json'));
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns an empty object when the file is missing', async () => {
|
|
40
|
+
expect(await loadPreferences()).toEqual({});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns an empty object when the file is corrupt', async () => {
|
|
44
|
+
await fs.writeFile(preferencesPath(), '{ not json', 'utf8');
|
|
45
|
+
expect(await loadPreferences()).toEqual({});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('round-trips a patch through disk', async () => {
|
|
49
|
+
await savePreferences({ model: 'gpt-5.4-mini', providerName: 'openai' });
|
|
50
|
+
const loaded = await loadPreferences();
|
|
51
|
+
expect(loaded.model).toBe('gpt-5.4-mini');
|
|
52
|
+
expect(loaded.providerName).toBe('openai');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('merges a second patch without clobbering unrelated fields', async () => {
|
|
56
|
+
await savePreferences({ providerName: 'openai' });
|
|
57
|
+
await savePreferences({ model: 'gpt-5.4-mini' });
|
|
58
|
+
const loaded = await loadPreferences();
|
|
59
|
+
expect(loaded.providerName).toBe('openai');
|
|
60
|
+
expect(loaded.model).toBe('gpt-5.4-mini');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('migrates legacy mode names on load', async () => {
|
|
64
|
+
// "tool-use" is the legacy id that migrateModeName maps to "default".
|
|
65
|
+
await fs.writeFile(
|
|
66
|
+
preferencesPath(),
|
|
67
|
+
JSON.stringify({ mode: 'tool-use' }) + '\n',
|
|
68
|
+
'utf8',
|
|
69
|
+
);
|
|
70
|
+
const loaded = await loadPreferences();
|
|
71
|
+
expect(loaded.mode).toBe('default');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('writes a trailing newline atomically (no partial file)', async () => {
|
|
75
|
+
await savePreferences({ model: 'm1' });
|
|
76
|
+
const raw = await fs.readFile(preferencesPath(), 'utf8');
|
|
77
|
+
expect(raw.endsWith('\n')).toBe(true);
|
|
78
|
+
expect(JSON.parse(raw)).toEqual({ model: 'm1' });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('serializes overlapping saves so no update is lost (invariant #5)', async () => {
|
|
82
|
+
// Two patches firing concurrently each read-merge-write the same file.
|
|
83
|
+
// Without the mutex the later writer clobbers the earlier writer's field.
|
|
84
|
+
await Promise.all([
|
|
85
|
+
savePreferences({ model: 'a' }),
|
|
86
|
+
savePreferences({ mode: 'goal' }),
|
|
87
|
+
]);
|
|
88
|
+
const loaded = await loadPreferences();
|
|
89
|
+
expect(loaded.model).toBe('a');
|
|
90
|
+
expect(loaded.mode).toBe('goal');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('keeps ALL distinct keys present under many overlapping writers', async () => {
|
|
94
|
+
const patches: Array<Partial<Record<`k${number}`, string>>> = [];
|
|
95
|
+
for (let i = 0; i < 25; i++) patches.push({ [`k${i}`]: `v${i}` });
|
|
96
|
+
// Fire all saves at once; each merges a distinct key.
|
|
97
|
+
await Promise.all(
|
|
98
|
+
patches.map((p) => savePreferences(p as Record<string, string>)),
|
|
99
|
+
);
|
|
100
|
+
const loaded = (await readRaw()) as Record<string, string>;
|
|
101
|
+
for (let i = 0; i < 25; i++) {
|
|
102
|
+
expect(loaded[`k${i}`]).toBe(`v${i}`);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import * as os from 'node:os';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import { createMutex, migrateModeName } from '@moxxy/sdk';
|
|
5
|
+
import { writeFileAtomic } from '@moxxy/sdk/server';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* User-level runtime preferences persisted at ~/.moxxy/preferences.json.
|
|
9
|
+
* Distinct from `~/.moxxy/config.yaml` (user-edited, source of truth for
|
|
10
|
+
* provider/plugin wiring) — this file is mutated by the TUI as the user
|
|
11
|
+
* picks a model / loop strategy via slash commands, so it survives across
|
|
12
|
+
* CLI invocations. A missing file or unreadable contents is a no-op:
|
|
13
|
+
* preferences are best-effort, never load-blocking.
|
|
14
|
+
*/
|
|
15
|
+
export interface MoxxyPreferences {
|
|
16
|
+
/** Active provider name (e.g. "openai", "anthropic"). */
|
|
17
|
+
readonly providerName?: string;
|
|
18
|
+
/** Active model id under that provider (e.g. "gpt-5.4-mini"). */
|
|
19
|
+
readonly model?: string;
|
|
20
|
+
/** Active loop strategy name (e.g. "default", "research", "goal"). */
|
|
21
|
+
readonly mode?: string;
|
|
22
|
+
/**
|
|
23
|
+
* Providers the user disabled (desktop Settings → Providers toggle).
|
|
24
|
+
* Seeded into the ProviderRegistry at boot; a listed provider stays
|
|
25
|
+
* registered but can't be activated until re-enabled.
|
|
26
|
+
*/
|
|
27
|
+
readonly disabledProviders?: ReadonlyArray<string>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function preferencesPath(): string {
|
|
31
|
+
return path.join(os.homedir(), '.moxxy', 'preferences.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read preferences from disk. Returns an empty object when the file
|
|
36
|
+
* doesn't exist or fails to parse — preferences are an optional layer,
|
|
37
|
+
* never a hard dependency of session bootstrap.
|
|
38
|
+
*/
|
|
39
|
+
export async function loadPreferences(): Promise<MoxxyPreferences> {
|
|
40
|
+
try {
|
|
41
|
+
const raw = await fs.readFile(preferencesPath(), 'utf8');
|
|
42
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
43
|
+
if (parsed && typeof parsed === 'object') {
|
|
44
|
+
const prefs = parsed as MoxxyPreferences;
|
|
45
|
+
return prefs.mode ? { ...prefs, mode: migrateModeName(prefs.mode) } : prefs;
|
|
46
|
+
}
|
|
47
|
+
return {};
|
|
48
|
+
} catch {
|
|
49
|
+
return {};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Serializes the read-modify-write in `savePreferences` so two concurrent
|
|
54
|
+
// saves (e.g. a /model pick and a provider-disable toggle firing close
|
|
55
|
+
// together) can't both read the same snapshot and have the second write
|
|
56
|
+
// clobber the first's field. This is the single write path for the file —
|
|
57
|
+
// other packages (CLI, runner, channels) route their fire-and-forget saves
|
|
58
|
+
// through it, and any load-modify-save must go through it too.
|
|
59
|
+
const writeMutex = createMutex();
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Merge-and-write preferences. Reads the current file (if any), merges
|
|
63
|
+
* the patch on top so we don't blow away unrelated fields, and writes
|
|
64
|
+
* the result back atomically. The whole read-merge-write runs inside a
|
|
65
|
+
* module-level mutex so overlapping saves serialize (invariant #5) and
|
|
66
|
+
* never lose an update. Best-effort: a write failure logs to stderr but
|
|
67
|
+
* does not throw — the user's pick still takes effect in this session,
|
|
68
|
+
* just won't persist across invocations.
|
|
69
|
+
*/
|
|
70
|
+
export async function savePreferences(patch: Partial<MoxxyPreferences>): Promise<void> {
|
|
71
|
+
return writeMutex.run(async () => {
|
|
72
|
+
const current = await loadPreferences();
|
|
73
|
+
const next: MoxxyPreferences = { ...current, ...patch };
|
|
74
|
+
const target = preferencesPath();
|
|
75
|
+
try {
|
|
76
|
+
await writeFileAtomic(target, JSON.stringify(next, null, 2) + '\n');
|
|
77
|
+
} catch (err) {
|
|
78
|
+
process.stderr.write(
|
|
79
|
+
`moxxy: failed to persist preferences to ${target}: ` +
|
|
80
|
+
`${err instanceof Error ? err.message : String(err)}\n`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared base for the "single active backend" registries (speech-to-text,
|
|
3
|
+
* text-to-speech, embeddings). Each one is a Map of named defs with at most one
|
|
4
|
+
* *active* at a time:
|
|
5
|
+
* - plugins call `register(def)` at load time
|
|
6
|
+
* - the host / CLI / agent calls `setActive(name, config)` to pick one
|
|
7
|
+
* - call sites read `getActive()` / `tryGetActive()` to use it (degrading
|
|
8
|
+
* gracefully when none is active)
|
|
9
|
+
*
|
|
10
|
+
* The three concrete registries differ only along a few axes, expressed via
|
|
11
|
+
* {@link BackendRegistryOptions}:
|
|
12
|
+
* - how a def becomes an instance (`build`) — `createClient(config)` for
|
|
13
|
+
* STT/embeddings, `create({ config, getSecret })` for TTS
|
|
14
|
+
* - whether the first registered def auto-becomes active (`autoAdoptFirst`,
|
|
15
|
+
* TTS only — STT/embeddings require an explicit `setActive`)
|
|
16
|
+
* - whether the active instance is (re)built lazily on read (`buildOnRead`,
|
|
17
|
+
* TTS only — needed because `autoAdoptFirst` can set `active` without ever
|
|
18
|
+
* building an instance; it also makes `setActive` rebuild when handed a
|
|
19
|
+
* fresh config)
|
|
20
|
+
*
|
|
21
|
+
* `ProviderRegistry` deliberately stays separate: it is the core LLM path and
|
|
22
|
+
* carries its own ready-state semantics.
|
|
23
|
+
*/
|
|
24
|
+
export interface BackendRegistryOptions<TDef extends { name: string }, TInstance> {
|
|
25
|
+
/** Capitalised singular noun for error messages, e.g. `'Synthesizer'`. */
|
|
26
|
+
readonly noun: string;
|
|
27
|
+
/** Turn a def + (possibly empty) config into a runtime instance. */
|
|
28
|
+
readonly build: (def: TDef, config: Record<string, unknown>) => TInstance;
|
|
29
|
+
/** Adopt the first registered def as active when nothing is active yet. */
|
|
30
|
+
readonly autoAdoptFirst?: boolean;
|
|
31
|
+
/** (Re)build the active instance lazily on read rather than requiring it to
|
|
32
|
+
* have been built at `setActive` time. Also makes `setActive` rebuild when a
|
|
33
|
+
* config is supplied. Pair with {@link autoAdoptFirst}. */
|
|
34
|
+
readonly buildOnRead?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class ActiveBackendRegistry<TDef extends { name: string }, TInstance> {
|
|
38
|
+
private readonly defs = new Map<string, TDef>();
|
|
39
|
+
private readonly instances = new Map<string, TInstance>();
|
|
40
|
+
private active: string | null = null;
|
|
41
|
+
|
|
42
|
+
constructor(private readonly opts: BackendRegistryOptions<TDef, TInstance>) {}
|
|
43
|
+
|
|
44
|
+
register(def: TDef, instance?: TInstance): void {
|
|
45
|
+
if (this.defs.has(def.name)) {
|
|
46
|
+
throw new Error(`${this.opts.noun} already registered: ${def.name}`);
|
|
47
|
+
}
|
|
48
|
+
this.defs.set(def.name, def);
|
|
49
|
+
if (instance) this.instances.set(def.name, instance);
|
|
50
|
+
if (this.opts.autoAdoptFirst && this.active === null) this.active = def.name;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Overwrite an existing def, dropping the cached instance so the next build
|
|
54
|
+
* uses the new def. */
|
|
55
|
+
replace(def: TDef, instance?: TInstance): void {
|
|
56
|
+
this.defs.set(def.name, def);
|
|
57
|
+
this.instances.delete(def.name);
|
|
58
|
+
if (instance) this.instances.set(def.name, instance);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
unregister(name: string): void {
|
|
62
|
+
this.defs.delete(name);
|
|
63
|
+
this.instances.delete(name);
|
|
64
|
+
if (this.active === name) this.active = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
list(): ReadonlyArray<TDef> {
|
|
68
|
+
return [...this.defs.values()];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
has(name: string): boolean {
|
|
72
|
+
return this.defs.has(name);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
setActive(name: string, config?: Record<string, unknown>): TInstance {
|
|
76
|
+
if (!this.defs.has(name)) throw new Error(`${this.opts.noun} not registered: ${name}`);
|
|
77
|
+
if (this.opts.buildOnRead) {
|
|
78
|
+
// Supplying a config (re)builds with it; otherwise reuse / lazily build.
|
|
79
|
+
if (config) this.instances.set(name, this.buildInstance(name, config));
|
|
80
|
+
this.active = name;
|
|
81
|
+
return this.instantiate(name);
|
|
82
|
+
}
|
|
83
|
+
// Build once, on first activation; later activations reuse the cache and
|
|
84
|
+
// ignore `config`.
|
|
85
|
+
let instance = this.instances.get(name);
|
|
86
|
+
if (!instance) {
|
|
87
|
+
instance = this.buildInstance(name, config);
|
|
88
|
+
this.instances.set(name, instance);
|
|
89
|
+
}
|
|
90
|
+
this.active = name;
|
|
91
|
+
return instance;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Deactivate the current backend (callers fall back to their default). */
|
|
95
|
+
clearActive(): void {
|
|
96
|
+
this.active = null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
getActive(): TInstance {
|
|
100
|
+
if (!this.active) {
|
|
101
|
+
throw new Error(`No active ${this.opts.noun.toLowerCase()}. Call setActive(name) first.`);
|
|
102
|
+
}
|
|
103
|
+
if (this.opts.buildOnRead) return this.instantiate(this.active);
|
|
104
|
+
const inst = this.instances.get(this.active);
|
|
105
|
+
if (!inst) throw new Error(`Active ${this.opts.noun.toLowerCase()} has no instance: ${this.active}`);
|
|
106
|
+
return inst;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Active backend, or null when none is configured. Lets call sites degrade
|
|
110
|
+
* gracefully (keyword recall, the OS voice, "no transcriber configured"). */
|
|
111
|
+
tryGetActive(): TInstance | null {
|
|
112
|
+
if (!this.active) return null;
|
|
113
|
+
if (this.opts.buildOnRead) {
|
|
114
|
+
try {
|
|
115
|
+
return this.instantiate(this.active);
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return this.instances.get(this.active) ?? null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
getActiveName(): string | null {
|
|
124
|
+
return this.active;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Cached instance for `name`, building it from its def on first use. */
|
|
128
|
+
private instantiate(name: string): TInstance {
|
|
129
|
+
let inst = this.instances.get(name);
|
|
130
|
+
if (!inst) {
|
|
131
|
+
inst = this.buildInstance(name);
|
|
132
|
+
this.instances.set(name, inst);
|
|
133
|
+
}
|
|
134
|
+
return inst;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private buildInstance(name: string, config?: Record<string, unknown>): TInstance {
|
|
138
|
+
const def = this.defs.get(name);
|
|
139
|
+
if (!def) throw new Error(`${this.opts.noun} not registered: ${name}`);
|
|
140
|
+
return this.opts.build(def, config ?? {});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ActiveDefRegistry } from './active-def-registry.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Contract test for the shared base behind compactors, cache-strategies,
|
|
6
|
+
* view-renderers, tunnel-providers and workflow-executors. The five concrete
|
|
7
|
+
* subclasses keep thin sanity tests; the invariants live here once.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
interface Thing {
|
|
11
|
+
name: string;
|
|
12
|
+
v?: number;
|
|
13
|
+
}
|
|
14
|
+
const mk = (name: string, v?: number): Thing => ({ name, v });
|
|
15
|
+
|
|
16
|
+
describe('ActiveDefRegistry (shared base)', () => {
|
|
17
|
+
it('getActive() is null when empty', () => {
|
|
18
|
+
const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
|
|
19
|
+
expect(r.getActive()).toBeNull();
|
|
20
|
+
expect(r.getActiveName()).toBeNull();
|
|
21
|
+
expect(r.list()).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('auto-activates the first registered def and keeps it on later registers', () => {
|
|
25
|
+
const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
|
|
26
|
+
r.register(mk('a'));
|
|
27
|
+
expect(r.getActive()?.name).toBe('a');
|
|
28
|
+
expect(r.getActiveName()).toBe('a');
|
|
29
|
+
r.register(mk('b'));
|
|
30
|
+
expect(r.getActive()?.name).toBe('a'); // still the first
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('throws the exact noun-prefixed message on duplicate register', () => {
|
|
34
|
+
const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
|
|
35
|
+
r.register(mk('a'));
|
|
36
|
+
expect(() => r.register(mk('a'))).toThrow('Thing already registered: a');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('replace overwrites without throwing and keeps active', () => {
|
|
40
|
+
const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
|
|
41
|
+
r.register(mk('a', 1));
|
|
42
|
+
const replacement = mk('a', 2);
|
|
43
|
+
r.replace(replacement);
|
|
44
|
+
expect(r.getActive()).toBe(replacement);
|
|
45
|
+
expect(r.getActive()?.v).toBe(2);
|
|
46
|
+
expect(r.list()).toHaveLength(1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('replace on empty registry auto-activates', () => {
|
|
50
|
+
const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
|
|
51
|
+
r.replace(mk('a'));
|
|
52
|
+
expect(r.getActive()?.name).toBe('a');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('setActive switches; throws the exact noun-prefixed message for unknown', () => {
|
|
56
|
+
const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
|
|
57
|
+
r.register(mk('a'));
|
|
58
|
+
r.register(mk('b'));
|
|
59
|
+
r.setActive('b');
|
|
60
|
+
expect(r.getActive()?.name).toBe('b');
|
|
61
|
+
expect(() => r.setActive('nope')).toThrow('Thing not registered: nope');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('unregister clears the active slot rather than picking a successor', () => {
|
|
65
|
+
const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
|
|
66
|
+
r.register(mk('a'));
|
|
67
|
+
r.register(mk('b'));
|
|
68
|
+
r.unregister('a');
|
|
69
|
+
expect(r.getActive()).toBeNull();
|
|
70
|
+
expect(r.getActiveName()).toBeNull();
|
|
71
|
+
expect(r.list().map((x) => x.name)).toEqual(['b']);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('unregistering a non-active def leaves active intact', () => {
|
|
75
|
+
const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
|
|
76
|
+
r.register(mk('a'));
|
|
77
|
+
r.register(mk('b'));
|
|
78
|
+
r.unregister('b');
|
|
79
|
+
expect(r.getActive()?.name).toBe('a');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('has() reflects membership; clearActive() deactivates without removing', () => {
|
|
83
|
+
const r = new ActiveDefRegistry<Thing>({ noun: 'Thing' });
|
|
84
|
+
r.register(mk('a'));
|
|
85
|
+
expect(r.has('a')).toBe(true);
|
|
86
|
+
expect(r.has('z')).toBe(false);
|
|
87
|
+
r.clearActive();
|
|
88
|
+
expect(r.getActive()).toBeNull();
|
|
89
|
+
expect(r.getActiveName()).toBeNull();
|
|
90
|
+
expect(r.has('a')).toBe(true); // still registered, just not active
|
|
91
|
+
r.setActive('a');
|
|
92
|
+
expect(r.getActive()?.name).toBe('a');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('autoAdoptFirst:false leaves nothing active until setActive', () => {
|
|
96
|
+
const r = new ActiveDefRegistry<Thing>({ noun: 'Thing', autoAdoptFirst: false });
|
|
97
|
+
r.register(mk('a'));
|
|
98
|
+
r.replace(mk('b'));
|
|
99
|
+
expect(r.getActive()).toBeNull();
|
|
100
|
+
r.setActive('a');
|
|
101
|
+
expect(r.getActive()?.name).toBe('a');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared base for the "single active def, no built instance" registries
|
|
3
|
+
* (compactors, cache strategies, view renderers, tunnel providers, workflow
|
|
4
|
+
* executors). Each is a `Map<name, def>` with at most one *active* def at a
|
|
5
|
+
* time:
|
|
6
|
+
* - plugins call `register(def)` at load time (throws on duplicate)
|
|
7
|
+
* - the first registration auto-activates (when `autoAdoptFirst`, the
|
|
8
|
+
* default), so a session always has a default once a plugin loads one
|
|
9
|
+
* - the host / agent calls `setActive(name)` to switch
|
|
10
|
+
* - call sites read `getActive()` (returns null when nothing is active)
|
|
11
|
+
* - `unregister` clears the active slot rather than silently picking an
|
|
12
|
+
* arbitrary successor — callers must `setActive()` again
|
|
13
|
+
*
|
|
14
|
+
* This is the def-only sibling of {@link ActiveBackendRegistry} (which also
|
|
15
|
+
* builds runtime *instances* for STT/TTS/embeddings). The two families share
|
|
16
|
+
* the same surface (register/replace/unregister/list/has/setActive/getActive/
|
|
17
|
+
* getActiveName/clearActive) so they stay consistent.
|
|
18
|
+
*
|
|
19
|
+
* `ModeRegistry` (change-listeners + legacy-name migration) and
|
|
20
|
+
* `ProviderRegistry` (disabled-set + built instances) deliberately stay
|
|
21
|
+
* bespoke rather than extend this base.
|
|
22
|
+
*/
|
|
23
|
+
export interface ActiveDefRegistryOptions {
|
|
24
|
+
/** Capitalised singular noun for error messages, e.g. `'Compactor'`. */
|
|
25
|
+
readonly noun: string;
|
|
26
|
+
/** Adopt the first registered def as active when nothing is active yet.
|
|
27
|
+
* Defaults to `true`. */
|
|
28
|
+
readonly autoAdoptFirst?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class ActiveDefRegistry<TDef extends { name: string }> {
|
|
32
|
+
private readonly defs = new Map<string, TDef>();
|
|
33
|
+
private active: string | null = null;
|
|
34
|
+
private readonly noun: string;
|
|
35
|
+
private readonly autoAdoptFirst: boolean;
|
|
36
|
+
|
|
37
|
+
constructor(opts: ActiveDefRegistryOptions) {
|
|
38
|
+
this.noun = opts.noun;
|
|
39
|
+
this.autoAdoptFirst = opts.autoAdoptFirst ?? true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Register a def. Throws on duplicate — use `replace()` to overwrite.
|
|
44
|
+
* Auto-activates the first registration (when `autoAdoptFirst`).
|
|
45
|
+
*/
|
|
46
|
+
register(def: TDef): void {
|
|
47
|
+
if (this.defs.has(def.name)) {
|
|
48
|
+
throw new Error(`${this.noun} already registered: ${def.name}`);
|
|
49
|
+
}
|
|
50
|
+
this.defs.set(def.name, def);
|
|
51
|
+
if (this.autoAdoptFirst && !this.active) this.active = def.name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Overwrite an existing def (or add a new one) without throwing.
|
|
55
|
+
* Auto-activates when nothing is active yet (mirrors `register`). */
|
|
56
|
+
replace(def: TDef): void {
|
|
57
|
+
this.defs.set(def.name, def);
|
|
58
|
+
if (this.autoAdoptFirst && !this.active) this.active = def.name;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Remove a def. If it was active, the active slot is cleared (callers must
|
|
63
|
+
* `setActive()` rather than getting an arbitrary "next").
|
|
64
|
+
*/
|
|
65
|
+
unregister(name: string): void {
|
|
66
|
+
this.defs.delete(name);
|
|
67
|
+
if (this.active === name) this.active = null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
list(): ReadonlyArray<TDef> {
|
|
71
|
+
return [...this.defs.values()];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
has(name: string): boolean {
|
|
75
|
+
return this.defs.has(name);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
setActive(name: string): void {
|
|
79
|
+
const def = this.defs.get(name);
|
|
80
|
+
if (!def) throw new Error(`${this.noun} not registered: ${name}`);
|
|
81
|
+
this.active = def.name;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Deactivate the current def (callers fall back to their default). */
|
|
85
|
+
clearActive(): void {
|
|
86
|
+
this.active = null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
getActive(): TDef | null {
|
|
90
|
+
if (!this.active) return null;
|
|
91
|
+
return this.defs.get(this.active) ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
getActiveName(): string | null {
|
|
95
|
+
return this.active;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { AgentDef } from '@moxxy/sdk';
|
|
2
|
+
import { DefMapRegistry } from './def-map-registry.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Registry of named subagent kinds contributed by plugins. The
|
|
6
|
+
* `dispatch_agent` tool looks definitions up here by `agentType` and
|
|
7
|
+
* uses them as templates (systemPrompt, allowedTools, mode, …)
|
|
8
|
+
* for the spawned child.
|
|
9
|
+
*
|
|
10
|
+
* A flat name→def map ({@link DefMapRegistry}): register throws on
|
|
11
|
+
* duplicate so two plugins can't silently shadow each other — use
|
|
12
|
+
* `replace()` when you really want to override (e.g. user-config overrides).
|
|
13
|
+
*/
|
|
14
|
+
export class AgentRegistry extends DefMapRegistry<AgentDef> {
|
|
15
|
+
constructor() {
|
|
16
|
+
super({ noun: 'Agent', keyOf: (def) => def.name });
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CacheStrategyDef } from '@moxxy/sdk';
|
|
2
|
+
import { ActiveDefRegistry } from './active-def-registry.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One active prompt-caching strategy per session. Register throws on
|
|
6
|
+
* duplicate, auto-activates the first, and `unregister` clears the active
|
|
7
|
+
* slot rather than picking an arbitrary successor. See
|
|
8
|
+
* {@link ActiveDefRegistry}.
|
|
9
|
+
*/
|
|
10
|
+
export class CacheStrategyRegistry extends ActiveDefRegistry<CacheStrategyDef> {
|
|
11
|
+
constructor() {
|
|
12
|
+
super({ noun: 'Cache strategy' });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChannelAvailability,
|
|
3
|
+
ChannelDef,
|
|
4
|
+
ChannelFactoryDeps,
|
|
5
|
+
ChannelRegistry,
|
|
6
|
+
} from '@moxxy/sdk';
|
|
7
|
+
import { DefMapRegistry } from './def-map-registry.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Flat name→def registry of channels ({@link DefMapRegistry}) plus
|
|
11
|
+
* `listWithAvailability`, which pairs each channel with its current
|
|
12
|
+
* availability (treating channels without an `isAvailable` hook as
|
|
13
|
+
* `{ ok: true }` and mapping a thrown probe to `{ ok: false }`).
|
|
14
|
+
*/
|
|
15
|
+
export class ChannelRegistryImpl extends DefMapRegistry<ChannelDef> implements ChannelRegistry {
|
|
16
|
+
constructor() {
|
|
17
|
+
super({ noun: 'Channel', keyOf: (def) => def.name });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async listWithAvailability(
|
|
21
|
+
deps: ChannelFactoryDeps,
|
|
22
|
+
): Promise<ReadonlyArray<{ def: ChannelDef; availability: ChannelAvailability }>> {
|
|
23
|
+
const out: Array<{ def: ChannelDef; availability: ChannelAvailability }> = [];
|
|
24
|
+
for (const def of this.defs.values()) {
|
|
25
|
+
let availability: ChannelAvailability;
|
|
26
|
+
if (def.isAvailable) {
|
|
27
|
+
try {
|
|
28
|
+
availability = await def.isAvailable(deps);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
availability = {
|
|
31
|
+
ok: false,
|
|
32
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
availability = { ok: true };
|
|
37
|
+
}
|
|
38
|
+
out.push({ def, availability });
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { CommandDef } from '@moxxy/sdk';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { CommandRegistry } from './commands.js';
|
|
4
|
+
|
|
5
|
+
function fakeCommand(name: string, extra: Partial<CommandDef> = {}): CommandDef {
|
|
6
|
+
return {
|
|
7
|
+
name,
|
|
8
|
+
description: `cmd ${name}`,
|
|
9
|
+
handler: () => ({ kind: 'text', text: name }),
|
|
10
|
+
...extra,
|
|
11
|
+
} as CommandDef;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('CommandRegistry', () => {
|
|
15
|
+
it('registers, looks up by name and alias, lists, and unregisters', () => {
|
|
16
|
+
const reg = new CommandRegistry();
|
|
17
|
+
reg.register(fakeCommand('help', { aliases: ['h', '?'] }));
|
|
18
|
+
expect(reg.has('help')).toBe(true);
|
|
19
|
+
expect(reg.get('help')?.name).toBe('help');
|
|
20
|
+
expect(reg.get('h')?.name).toBe('help');
|
|
21
|
+
expect(reg.get('?')?.name).toBe('help');
|
|
22
|
+
expect(reg.list().map((c) => c.name)).toEqual(['help']);
|
|
23
|
+
reg.unregister('help');
|
|
24
|
+
expect(reg.has('help')).toBe(false);
|
|
25
|
+
expect(reg.get('h')).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('throws on a duplicate primary name', () => {
|
|
29
|
+
const reg = new CommandRegistry();
|
|
30
|
+
reg.register(fakeCommand('new'));
|
|
31
|
+
expect(() => reg.register(fakeCommand('new'))).toThrow(/already registered/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('throws on an alias already claimed by another command (as alias or name)', () => {
|
|
35
|
+
const reg = new CommandRegistry();
|
|
36
|
+
reg.register(fakeCommand('compact', { aliases: ['c'] }));
|
|
37
|
+
expect(() => reg.register(fakeCommand('clear', { aliases: ['c'] }))).toThrow(
|
|
38
|
+
/alias already in use/,
|
|
39
|
+
);
|
|
40
|
+
expect(() => reg.register(fakeCommand('compact'))).toThrow(/already registered/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('throws when a new primary name collides with an existing alias', () => {
|
|
44
|
+
const reg = new CommandRegistry();
|
|
45
|
+
reg.register(fakeCommand('compact', { aliases: ['c'] }));
|
|
46
|
+
// 'c' is already an alias of /compact; registering a command literally
|
|
47
|
+
// named 'c' must NOT silently shadow it via direct-lookup precedence.
|
|
48
|
+
expect(() => reg.register(fakeCommand('c'))).toThrow(/already in use as an alias/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('replace() upserts and cleans up the prior definition aliases', () => {
|
|
52
|
+
const reg = new CommandRegistry();
|
|
53
|
+
reg.register(fakeCommand('mode', { aliases: ['m'] }));
|
|
54
|
+
reg.replace(fakeCommand('mode', { aliases: ['md'], description: 'updated' }));
|
|
55
|
+
expect(reg.get('mode')?.description).toBe('updated');
|
|
56
|
+
expect(reg.get('md')?.name).toBe('mode');
|
|
57
|
+
// Old alias is cleaned up.
|
|
58
|
+
expect(reg.get('m')).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('listForChannel filters channel-scoped commands', () => {
|
|
62
|
+
const reg = new CommandRegistry();
|
|
63
|
+
reg.register(fakeCommand('everywhere'));
|
|
64
|
+
reg.register(fakeCommand('tuionly', { channels: ['tui'] }));
|
|
65
|
+
expect(reg.listForChannel('tui').map((c) => c.name).sort()).toEqual([
|
|
66
|
+
'everywhere',
|
|
67
|
+
'tuionly',
|
|
68
|
+
]);
|
|
69
|
+
expect(reg.listForChannel('telegram').map((c) => c.name)).toEqual(['everywhere']);
|
|
70
|
+
});
|
|
71
|
+
});
|