@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.
Files changed (332) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +125 -0
  3. package/dist/events/factory.d.ts +6 -0
  4. package/dist/events/factory.d.ts.map +1 -0
  5. package/dist/events/factory.js +14 -0
  6. package/dist/events/factory.js.map +1 -0
  7. package/dist/events/log.d.ts +92 -0
  8. package/dist/events/log.d.ts.map +1 -0
  9. package/dist/events/log.js +244 -0
  10. package/dist/events/log.js.map +1 -0
  11. package/dist/index.d.ts +40 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +41 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/logger.d.ts +16 -0
  16. package/dist/logger.d.ts.map +1 -0
  17. package/dist/logger.js +27 -0
  18. package/dist/logger.js.map +1 -0
  19. package/dist/permissions/engine.d.ts +94 -0
  20. package/dist/permissions/engine.d.ts.map +1 -0
  21. package/dist/permissions/engine.js +203 -0
  22. package/dist/permissions/engine.js.map +1 -0
  23. package/dist/permissions/resolvers.d.ts +8 -0
  24. package/dist/permissions/resolvers.d.ts.map +1 -0
  25. package/dist/permissions/resolvers.js +8 -0
  26. package/dist/permissions/resolvers.js.map +1 -0
  27. package/dist/plugins/discovery.d.ts +9 -0
  28. package/dist/plugins/discovery.d.ts.map +1 -0
  29. package/dist/plugins/discovery.js +112 -0
  30. package/dist/plugins/discovery.js.map +1 -0
  31. package/dist/plugins/host-options.d.ts +72 -0
  32. package/dist/plugins/host-options.d.ts.map +1 -0
  33. package/dist/plugins/host-options.js +2 -0
  34. package/dist/plugins/host-options.js.map +1 -0
  35. package/dist/plugins/host.d.ts +53 -0
  36. package/dist/plugins/host.d.ts.map +1 -0
  37. package/dist/plugins/host.js +269 -0
  38. package/dist/plugins/host.js.map +1 -0
  39. package/dist/plugins/lifecycle.d.ts +31 -0
  40. package/dist/plugins/lifecycle.d.ts.map +1 -0
  41. package/dist/plugins/lifecycle.js +100 -0
  42. package/dist/plugins/lifecycle.js.map +1 -0
  43. package/dist/plugins/loader.d.ts +7 -0
  44. package/dist/plugins/loader.d.ts.map +1 -0
  45. package/dist/plugins/loader.js +68 -0
  46. package/dist/plugins/loader.js.map +1 -0
  47. package/dist/plugins/package-requirements.d.ts +16 -0
  48. package/dist/plugins/package-requirements.d.ts.map +1 -0
  49. package/dist/plugins/package-requirements.js +52 -0
  50. package/dist/plugins/package-requirements.js.map +1 -0
  51. package/dist/plugins/registry-kinds.d.ts +58 -0
  52. package/dist/plugins/registry-kinds.d.ts.map +1 -0
  53. package/dist/plugins/registry-kinds.js +139 -0
  54. package/dist/plugins/registry-kinds.js.map +1 -0
  55. package/dist/plugins/toposort.d.ts +24 -0
  56. package/dist/plugins/toposort.d.ts.map +1 -0
  57. package/dist/plugins/toposort.js +67 -0
  58. package/dist/plugins/toposort.js.map +1 -0
  59. package/dist/preferences.d.ts +40 -0
  60. package/dist/preferences.d.ts.map +1 -0
  61. package/dist/preferences.js +58 -0
  62. package/dist/preferences.js.map +1 -0
  63. package/dist/registries/active-backend-registry.d.ts +65 -0
  64. package/dist/registries/active-backend-registry.d.ts.map +1 -0
  65. package/dist/registries/active-backend-registry.js +108 -0
  66. package/dist/registries/active-backend-registry.js.map +1 -0
  67. package/dist/registries/active-def-registry.d.ts +59 -0
  68. package/dist/registries/active-def-registry.d.ts.map +1 -0
  69. package/dist/registries/active-def-registry.js +63 -0
  70. package/dist/registries/active-def-registry.js.map +1 -0
  71. package/dist/registries/agents.d.ts +16 -0
  72. package/dist/registries/agents.d.ts.map +1 -0
  73. package/dist/registries/agents.js +17 -0
  74. package/dist/registries/agents.js.map +1 -0
  75. package/dist/registries/cache-strategies.d.ts +12 -0
  76. package/dist/registries/cache-strategies.d.ts.map +1 -0
  77. package/dist/registries/cache-strategies.js +13 -0
  78. package/dist/registries/cache-strategies.js.map +1 -0
  79. package/dist/registries/channels.d.ts +16 -0
  80. package/dist/registries/channels.d.ts.map +1 -0
  81. package/dist/registries/channels.js +35 -0
  82. package/dist/registries/channels.js.map +1 -0
  83. package/dist/registries/commands.d.ts +29 -0
  84. package/dist/registries/commands.d.ts.map +1 -0
  85. package/dist/registries/commands.js +72 -0
  86. package/dist/registries/commands.js.map +1 -0
  87. package/dist/registries/compactors.d.ts +12 -0
  88. package/dist/registries/compactors.d.ts.map +1 -0
  89. package/dist/registries/compactors.js +13 -0
  90. package/dist/registries/compactors.js.map +1 -0
  91. package/dist/registries/def-map-registry.d.ts +33 -0
  92. package/dist/registries/def-map-registry.d.ts.map +1 -0
  93. package/dist/registries/def-map-registry.js +36 -0
  94. package/dist/registries/def-map-registry.js.map +1 -0
  95. package/dist/registries/embedders.d.ts +16 -0
  96. package/dist/registries/embedders.d.ts.map +1 -0
  97. package/dist/registries/embedders.js +17 -0
  98. package/dist/registries/embedders.js.map +1 -0
  99. package/dist/registries/isolators.d.ts +21 -0
  100. package/dist/registries/isolators.d.ts.map +1 -0
  101. package/dist/registries/isolators.js +32 -0
  102. package/dist/registries/isolators.js.map +1 -0
  103. package/dist/registries/modes.d.ts +28 -0
  104. package/dist/registries/modes.d.ts.map +1 -0
  105. package/dist/registries/modes.js +81 -0
  106. package/dist/registries/modes.js.map +1 -0
  107. package/dist/registries/providers.d.ts +44 -0
  108. package/dist/registries/providers.d.ts.map +1 -0
  109. package/dist/registries/providers.js +95 -0
  110. package/dist/registries/providers.js.map +1 -0
  111. package/dist/registries/skills.d.ts +24 -0
  112. package/dist/registries/skills.d.ts.map +1 -0
  113. package/dist/registries/skills.js +81 -0
  114. package/dist/registries/skills.js.map +1 -0
  115. package/dist/registries/surfaces.d.ts +13 -0
  116. package/dist/registries/surfaces.d.ts.map +1 -0
  117. package/dist/registries/surfaces.js +14 -0
  118. package/dist/registries/surfaces.js.map +1 -0
  119. package/dist/registries/synthesizers.d.ts +26 -0
  120. package/dist/registries/synthesizers.d.ts.map +1 -0
  121. package/dist/registries/synthesizers.js +34 -0
  122. package/dist/registries/synthesizers.js.map +1 -0
  123. package/dist/registries/tools.d.ts +49 -0
  124. package/dist/registries/tools.d.ts.map +1 -0
  125. package/dist/registries/tools.js +98 -0
  126. package/dist/registries/tools.js.map +1 -0
  127. package/dist/registries/transcribers.d.ts +17 -0
  128. package/dist/registries/transcribers.d.ts.map +1 -0
  129. package/dist/registries/transcribers.js +18 -0
  130. package/dist/registries/transcribers.js.map +1 -0
  131. package/dist/registries/tunnel-providers.d.ts +12 -0
  132. package/dist/registries/tunnel-providers.d.ts.map +1 -0
  133. package/dist/registries/tunnel-providers.js +13 -0
  134. package/dist/registries/tunnel-providers.js.map +1 -0
  135. package/dist/registries/view-renderers.d.ts +13 -0
  136. package/dist/registries/view-renderers.d.ts.map +1 -0
  137. package/dist/registries/view-renderers.js +14 -0
  138. package/dist/registries/view-renderers.js.map +1 -0
  139. package/dist/registries/workflow-executors.d.ts +13 -0
  140. package/dist/registries/workflow-executors.d.ts.map +1 -0
  141. package/dist/registries/workflow-executors.js +14 -0
  142. package/dist/registries/workflow-executors.js.map +1 -0
  143. package/dist/requirements.d.ts +59 -0
  144. package/dist/requirements.d.ts.map +1 -0
  145. package/dist/requirements.js +149 -0
  146. package/dist/requirements.js.map +1 -0
  147. package/dist/run-turn.d.ts +6 -0
  148. package/dist/run-turn.d.ts.map +1 -0
  149. package/dist/run-turn.js +141 -0
  150. package/dist/run-turn.js.map +1 -0
  151. package/dist/session-runtime.d.ts +67 -0
  152. package/dist/session-runtime.d.ts.map +1 -0
  153. package/dist/session-runtime.js +2 -0
  154. package/dist/session-runtime.js.map +1 -0
  155. package/dist/session.d.ts +203 -0
  156. package/dist/session.d.ts.map +1 -0
  157. package/dist/session.js +399 -0
  158. package/dist/session.js.map +1 -0
  159. package/dist/sessions/persistence.d.ts +184 -0
  160. package/dist/sessions/persistence.d.ts.map +1 -0
  161. package/dist/sessions/persistence.js +442 -0
  162. package/dist/sessions/persistence.js.map +1 -0
  163. package/dist/setup-agent.d.ts +91 -0
  164. package/dist/setup-agent.d.ts.map +1 -0
  165. package/dist/setup-agent.js +138 -0
  166. package/dist/setup-agent.js.map +1 -0
  167. package/dist/skills/index.d.ts +4 -0
  168. package/dist/skills/index.d.ts.map +1 -0
  169. package/dist/skills/index.js +4 -0
  170. package/dist/skills/index.js.map +1 -0
  171. package/dist/skills/loader.d.ts +16 -0
  172. package/dist/skills/loader.d.ts.map +1 -0
  173. package/dist/skills/loader.js +99 -0
  174. package/dist/skills/loader.js.map +1 -0
  175. package/dist/skills/parse.d.ts +5 -0
  176. package/dist/skills/parse.d.ts.map +1 -0
  177. package/dist/skills/parse.js +8 -0
  178. package/dist/skills/parse.js.map +1 -0
  179. package/dist/skills/synthesize-draft.d.ts +8 -0
  180. package/dist/skills/synthesize-draft.d.ts.map +1 -0
  181. package/dist/skills/synthesize-draft.js +32 -0
  182. package/dist/skills/synthesize-draft.js.map +1 -0
  183. package/dist/skills/synthesize.d.ts +33 -0
  184. package/dist/skills/synthesize.d.ts.map +1 -0
  185. package/dist/skills/synthesize.js +241 -0
  186. package/dist/skills/synthesize.js.map +1 -0
  187. package/dist/subagents/events.d.ts +20 -0
  188. package/dist/subagents/events.d.ts.map +1 -0
  189. package/dist/subagents/events.js +146 -0
  190. package/dist/subagents/events.js.map +1 -0
  191. package/dist/subagents/registry.d.ts +40 -0
  192. package/dist/subagents/registry.d.ts.map +1 -0
  193. package/dist/subagents/registry.js +42 -0
  194. package/dist/subagents/registry.js.map +1 -0
  195. package/dist/subagents/run-child.d.ts +43 -0
  196. package/dist/subagents/run-child.d.ts.map +1 -0
  197. package/dist/subagents/run-child.js +276 -0
  198. package/dist/subagents/run-child.js.map +1 -0
  199. package/dist/subagents/spawn.d.ts +8 -0
  200. package/dist/subagents/spawn.d.ts.map +1 -0
  201. package/dist/subagents/spawn.js +8 -0
  202. package/dist/subagents/spawn.js.map +1 -0
  203. package/dist/subagents/tools.d.ts +4 -0
  204. package/dist/subagents/tools.d.ts.map +1 -0
  205. package/dist/subagents/tools.js +13 -0
  206. package/dist/subagents/tools.js.map +1 -0
  207. package/dist/subagents.d.ts +3 -0
  208. package/dist/subagents.d.ts.map +1 -0
  209. package/dist/subagents.js +3 -0
  210. package/dist/subagents.js.map +1 -0
  211. package/dist/surfaces/host.d.ts +53 -0
  212. package/dist/surfaces/host.d.ts.map +1 -0
  213. package/dist/surfaces/host.js +177 -0
  214. package/dist/surfaces/host.js.map +1 -0
  215. package/dist/tunnel/localhost.d.ts +7 -0
  216. package/dist/tunnel/localhost.d.ts.map +1 -0
  217. package/dist/tunnel/localhost.js +21 -0
  218. package/dist/tunnel/localhost.js.map +1 -0
  219. package/dist/usage-stats.d.ts +42 -0
  220. package/dist/usage-stats.d.ts.map +1 -0
  221. package/dist/usage-stats.js +83 -0
  222. package/dist/usage-stats.js.map +1 -0
  223. package/dist/view/default-renderer.d.ts +7 -0
  224. package/dist/view/default-renderer.d.ts.map +1 -0
  225. package/dist/view/default-renderer.js +14 -0
  226. package/dist/view/default-renderer.js.map +1 -0
  227. package/dist/view/parse.d.ts +7 -0
  228. package/dist/view/parse.d.ts.map +1 -0
  229. package/dist/view/parse.js +479 -0
  230. package/dist/view/parse.js.map +1 -0
  231. package/package.json +61 -0
  232. package/src/events/factory.ts +27 -0
  233. package/src/events/log.test.ts +470 -0
  234. package/src/events/log.ts +256 -0
  235. package/src/index.ts +102 -0
  236. package/src/logger.ts +46 -0
  237. package/src/permissions/engine.test.ts +206 -0
  238. package/src/permissions/engine.ts +247 -0
  239. package/src/permissions/resolvers.test.ts +40 -0
  240. package/src/permissions/resolvers.ts +17 -0
  241. package/src/plugins/discovery.test.ts +115 -0
  242. package/src/plugins/discovery.ts +124 -0
  243. package/src/plugins/host-options.ts +73 -0
  244. package/src/plugins/host-view-tunnel.test.ts +86 -0
  245. package/src/plugins/host.test.ts +434 -0
  246. package/src/plugins/host.ts +327 -0
  247. package/src/plugins/lifecycle.test.ts +187 -0
  248. package/src/plugins/lifecycle.ts +136 -0
  249. package/src/plugins/loader.ts +79 -0
  250. package/src/plugins/package-requirements.ts +50 -0
  251. package/src/plugins/registry-kinds.test.ts +162 -0
  252. package/src/plugins/registry-kinds.ts +210 -0
  253. package/src/plugins/toposort.test.ts +66 -0
  254. package/src/plugins/toposort.ts +69 -0
  255. package/src/preferences.test.ts +105 -0
  256. package/src/preferences.ts +84 -0
  257. package/src/registries/active-backend-registry.ts +142 -0
  258. package/src/registries/active-def-registry.test.ts +103 -0
  259. package/src/registries/active-def-registry.ts +97 -0
  260. package/src/registries/agents.ts +18 -0
  261. package/src/registries/cache-strategies.ts +14 -0
  262. package/src/registries/channels.ts +42 -0
  263. package/src/registries/commands.test.ts +71 -0
  264. package/src/registries/commands.ts +78 -0
  265. package/src/registries/compactors.ts +14 -0
  266. package/src/registries/def-map-registry.test.ts +67 -0
  267. package/src/registries/def-map-registry.ts +59 -0
  268. package/src/registries/embedders.test.ts +54 -0
  269. package/src/registries/embedders.ts +18 -0
  270. package/src/registries/isolators.test.ts +43 -0
  271. package/src/registries/isolators.ts +38 -0
  272. package/src/registries/modes.test.ts +66 -0
  273. package/src/registries/modes.ts +80 -0
  274. package/src/registries/providers.test.ts +50 -0
  275. package/src/registries/providers.ts +97 -0
  276. package/src/registries/semantics.test.ts +117 -0
  277. package/src/registries/skills.ts +87 -0
  278. package/src/registries/surfaces.ts +18 -0
  279. package/src/registries/synthesizers.test.ts +63 -0
  280. package/src/registries/synthesizers.ts +36 -0
  281. package/src/registries/tools.test.ts +142 -0
  282. package/src/registries/tools.ts +145 -0
  283. package/src/registries/transcribers.test.ts +70 -0
  284. package/src/registries/transcribers.ts +19 -0
  285. package/src/registries/tunnel-providers.test.ts +30 -0
  286. package/src/registries/tunnel-providers.ts +14 -0
  287. package/src/registries/view-renderers.test.ts +68 -0
  288. package/src/registries/view-renderers.ts +15 -0
  289. package/src/registries/workflow-executors.test.ts +53 -0
  290. package/src/registries/workflow-executors.ts +15 -0
  291. package/src/requirements.targetinfo.test.ts +70 -0
  292. package/src/requirements.test.ts +111 -0
  293. package/src/requirements.ts +262 -0
  294. package/src/run-turn.test.ts +178 -0
  295. package/src/run-turn.ts +156 -0
  296. package/src/session-runtime.ts +75 -0
  297. package/src/session.test.ts +216 -0
  298. package/src/session.ts +491 -0
  299. package/src/sessions/persistence.flush.test.ts +100 -0
  300. package/src/sessions/persistence.ready.test.ts +91 -0
  301. package/src/sessions/persistence.test.ts +274 -0
  302. package/src/sessions/persistence.ts +498 -0
  303. package/src/setup-agent.test.ts +72 -0
  304. package/src/setup-agent.ts +189 -0
  305. package/src/skills/index.ts +14 -0
  306. package/src/skills/loader.test.ts +102 -0
  307. package/src/skills/loader.ts +114 -0
  308. package/src/skills/parse.test.ts +59 -0
  309. package/src/skills/parse.ts +11 -0
  310. package/src/skills/synthesize-draft.test.ts +79 -0
  311. package/src/skills/synthesize-draft.ts +46 -0
  312. package/src/skills/synthesize.test.ts +170 -0
  313. package/src/skills/synthesize.ts +304 -0
  314. package/src/subagents/events.test.ts +56 -0
  315. package/src/subagents/events.ts +194 -0
  316. package/src/subagents/registry.ts +62 -0
  317. package/src/subagents/run-child.test.ts +185 -0
  318. package/src/subagents/run-child.ts +425 -0
  319. package/src/subagents/spawn.ts +12 -0
  320. package/src/subagents/tools.ts +19 -0
  321. package/src/subagents.ts +2 -0
  322. package/src/surfaces/host.test.ts +330 -0
  323. package/src/surfaces/host.ts +192 -0
  324. package/src/tunnel/localhost.test.ts +22 -0
  325. package/src/tunnel/localhost.ts +21 -0
  326. package/src/usage-stats.test.ts +77 -0
  327. package/src/usage-stats.ts +119 -0
  328. package/src/view/default-renderer.test.ts +29 -0
  329. package/src/view/default-renderer.ts +14 -0
  330. package/src/view/parse-extended.test.ts +255 -0
  331. package/src/view/parse.test.ts +232 -0
  332. 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
+ });