@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,170 @@
1
+ import { describe, expect, it, beforeEach, afterEach } 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 type { LLMProvider, ProviderEvent } from '@moxxy/sdk';
6
+ import { Session } from '../session.js';
7
+ import { asSkillId, asTurnId, defineProvider, definePlugin } from '@moxxy/sdk';
8
+ import { synthesizeSkill, buildSynthesizeSkillPlugin } from './synthesize.js';
9
+
10
+ const DRAFT_DELTA =
11
+ '---\nname: refactor-component\ndescription: Split a large React component.\ntriggers: ["refactor", "split"]\nallowed-tools: [Read, Edit]\n---\n# Steps\n\n1. Read the file.\n2. Identify boundaries.\n3. Edit into pieces.\n';
12
+
13
+ class InlineProvider implements LLMProvider {
14
+ readonly name = 'inline';
15
+ readonly models = [{ id: 'inline-1', contextWindow: 100_000, maxOutputTokens: 2000, supportsTools: true, supportsStreaming: true }];
16
+ private cursor = 0;
17
+ constructor(private readonly scripts: ReadonlyArray<ReadonlyArray<ProviderEvent>>) {}
18
+ async *stream(): AsyncIterable<ProviderEvent> {
19
+ const reply = this.scripts[this.cursor++];
20
+ if (!reply) throw new Error('InlineProvider: no script left');
21
+ for (const e of reply) yield e;
22
+ }
23
+ async countTokens(): Promise<number> { return 0; }
24
+ }
25
+
26
+ const draftReply = (): ReadonlyArray<ProviderEvent> => [
27
+ { type: 'message_start', model: 'inline-1' },
28
+ { type: 'text_delta', delta: DRAFT_DELTA },
29
+ { type: 'message_end', stopReason: 'end_turn' },
30
+ ];
31
+
32
+ let tmp: string;
33
+
34
+ const newSessionWithProvider = (provider: InlineProvider) => {
35
+ const session = new Session({ cwd: '/tmp', silent: true });
36
+ const providerPlugin = definePlugin({
37
+ name: 'shim',
38
+ providers: [
39
+ defineProvider({ name: provider.name, models: [...provider.models], createClient: () => provider }),
40
+ ],
41
+ });
42
+ session.pluginHost.registerStatic(providerPlugin);
43
+ session.providers.setActive(provider.name);
44
+ return session;
45
+ };
46
+
47
+ beforeEach(async () => {
48
+ tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'mox-synth-'));
49
+ });
50
+ afterEach(async () => {
51
+ await fs.rm(tmp, { recursive: true, force: true });
52
+ });
53
+
54
+ describe('synthesizeSkill', () => {
55
+ it('drafts a skill, writes it, registers, and emits SkillCreatedEvent', async () => {
56
+ const provider = new InlineProvider([draftReply()]);
57
+ const session = newSessionWithProvider(provider);
58
+
59
+ const result = await synthesizeSkill(session, 'split component up', 'user', {
60
+ userDir: path.join(tmp, 'skills'),
61
+ auditPath: path.join(tmp, 'audit.jsonl'),
62
+ });
63
+
64
+ expect(result.scope).toBe('user');
65
+ expect(result.skill.frontmatter.name).toBe('refactor-component');
66
+ expect(result.path).toContain(path.join(tmp, 'skills'));
67
+
68
+ const written = await fs.readFile(result.path, 'utf8');
69
+ expect(written).toContain('refactor-component');
70
+
71
+ expect(session.skills.byName('refactor-component')).toBeDefined();
72
+
73
+ const createdEvents = session.log.ofType('skill_created');
74
+ expect(createdEvents).toHaveLength(1);
75
+ expect(createdEvents[0].name).toBe('refactor-component');
76
+
77
+ const audit = await fs.readFile(path.join(tmp, 'audit.jsonl'), 'utf8');
78
+ expect(audit).toContain('refactor-component');
79
+ });
80
+
81
+ it('stamps skill_created with the active turnId, not a fresh orphan turn', async () => {
82
+ const provider = new InlineProvider([draftReply()]);
83
+ const session = newSessionWithProvider(provider);
84
+ const turnId = asTurnId('active-turn-1');
85
+
86
+ await synthesizeSkill(
87
+ session,
88
+ 'split component up',
89
+ 'user',
90
+ { userDir: path.join(tmp, 'skills'), auditPath: path.join(tmp, 'audit.jsonl') },
91
+ turnId,
92
+ );
93
+
94
+ const createdEvents = session.log.ofType('skill_created');
95
+ expect(createdEvents).toHaveLength(1);
96
+ // Must carry the active turn's id so run-turn's per-turn subscriber filter
97
+ // (event.turnId !== turnId → drop) doesn't discard it. A fresh startTurn()
98
+ // id would never match the running turn.
99
+ expect(createdEvents[0].turnId).toBe(turnId);
100
+ });
101
+
102
+ it('uses project dir when scope=project', async () => {
103
+ const provider = new InlineProvider([draftReply()]);
104
+ const session = newSessionWithProvider(provider);
105
+
106
+ const result = await synthesizeSkill(session, 'do x', 'project', {
107
+ projectDir: path.join(tmp, 'project/.moxxy/skills'),
108
+ auditPath: path.join(tmp, 'audit.jsonl'),
109
+ });
110
+ expect(result.scope).toBe('project');
111
+ expect(result.path).toContain(path.join(tmp, 'project/.moxxy/skills'));
112
+ });
113
+
114
+ it('avoids overwriting existing files with -2 suffix', async () => {
115
+ const provider = new InlineProvider([draftReply(), draftReply()]);
116
+ const session = newSessionWithProvider(provider);
117
+
118
+ const a = await synthesizeSkill(session, 'first', 'user', {
119
+ userDir: path.join(tmp, 'skills'),
120
+ auditPath: path.join(tmp, 'audit.jsonl'),
121
+ });
122
+ const b = await synthesizeSkill(session, 'second', 'user', {
123
+ userDir: path.join(tmp, 'skills'),
124
+ auditPath: path.join(tmp, 'audit.jsonl'),
125
+ });
126
+ expect(b.path).not.toBe(a.path);
127
+ expect(b.path).toMatch(/-2\.md$/);
128
+ });
129
+ });
130
+
131
+ describe('buildSynthesizeSkillPlugin', () => {
132
+ it('registers synthesize_skill and reload_skills tools', () => {
133
+ const provider = new InlineProvider([]);
134
+ const session = newSessionWithProvider(provider);
135
+ const plugin = buildSynthesizeSkillPlugin(session);
136
+ session.pluginHost.registerStatic(plugin);
137
+ expect(session.tools.has('synthesize_skill')).toBe(true);
138
+ expect(session.tools.has('reload_skills')).toBe(true);
139
+ });
140
+
141
+ it('load_skill emits skill_invoked with the active turnId from ctx', async () => {
142
+ const provider = new InlineProvider([]);
143
+ const session = newSessionWithProvider(provider);
144
+ session.pluginHost.registerStatic(buildSynthesizeSkillPlugin(session));
145
+ // A pre-authored skill load_skill can resolve.
146
+ session.skills.register({
147
+ id: asSkillId('user/demo'),
148
+ path: '/demo.md',
149
+ scope: 'user',
150
+ frontmatter: { name: 'demo', description: 'd' },
151
+ body: 'steps',
152
+ });
153
+
154
+ const turnId = asTurnId('active-turn-2');
155
+ // Drive the tool through the registry exactly as the loop does: the
156
+ // ToolContext is built with the running turn's id, so the handler must
157
+ // stamp skill_invoked with THAT id (not a fresh startTurn() orphan that the
158
+ // run-turn subscriber filter would drop).
159
+ await session.tools.execute(
160
+ 'load_skill',
161
+ { name: 'demo' },
162
+ session.signal,
163
+ { turnId: String(turnId), sessionId: String(session.id), callId: 'c1' },
164
+ );
165
+
166
+ const invoked = session.log.ofType('skill_invoked');
167
+ expect(invoked).toHaveLength(1);
168
+ expect(invoked[0].turnId).toBe(turnId);
169
+ });
170
+ });
@@ -0,0 +1,304 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import {
4
+ asSkillId,
5
+ definePlugin,
6
+ defineTool,
7
+ skillFrontmatterSchema,
8
+ type Plugin,
9
+ type Skill,
10
+ type SkillScope,
11
+ type TurnId,
12
+ } from '@moxxy/sdk';
13
+ import { z } from 'zod';
14
+ import { defaultProjectSkillsDir, defaultUserSkillsDir } from './loader.js';
15
+ import { draftSkill } from './synthesize-draft.js';
16
+ import type { Session } from '../session.js';
17
+
18
+ export interface SynthesizeOptions {
19
+ readonly userDir?: string;
20
+ readonly projectDir?: string;
21
+ readonly model?: string;
22
+ readonly auditPath?: string;
23
+ /**
24
+ * Directory holding builtin skills (`@moxxy/skills-builtin`). Threaded
25
+ * through so `reload_skills` can rescan the same source set as the boot
26
+ * loader — otherwise reload silently drops the builtins, observed when
27
+ * a session called `reload_skills` and lost every shipped skill.
28
+ */
29
+ readonly builtinDir?: string;
30
+ /** Extra plugin-supplied skill directories. Same boot-vs-reload story. */
31
+ readonly pluginDirs?: ReadonlyArray<string>;
32
+ }
33
+
34
+ export interface SynthesizedSkill {
35
+ readonly skill: Skill;
36
+ readonly path: string;
37
+ readonly scope: SkillScope;
38
+ }
39
+
40
+ export async function synthesizeSkill(
41
+ session: Session,
42
+ intent: string,
43
+ scope: 'user' | 'project',
44
+ opts: SynthesizeOptions = {},
45
+ /**
46
+ * The active turn's id, threaded from the tool's `ctx.turnId`. The
47
+ * `skill_created` event MUST carry it so run-turn's per-turn subscriber
48
+ * (`event.turnId !== turnId → drop`) doesn't filter the event out of the
49
+ * running turn's stream. Falls back to a fresh turn only for legacy callers
50
+ * that invoke this outside a turn.
51
+ */
52
+ turnId?: TurnId,
53
+ ): Promise<SynthesizedSkill> {
54
+ const provider = session.providers.getActive();
55
+ // Prefer the model the conversation last ran on over the provider's first
56
+ // descriptor; 'default' matches run-turn's terminal fallback (never a
57
+ // hardcoded vendor id that goes stale).
58
+ const model =
59
+ opts.model ?? session.lastResolvedModel ?? provider.models[0]?.id ?? 'default';
60
+ const draft = await draftSkill(provider, model, intent, session.signal);
61
+
62
+ const baseDir =
63
+ scope === 'project'
64
+ ? opts.projectDir ?? defaultProjectSkillsDir(session.cwd)
65
+ : opts.userDir ?? defaultUserSkillsDir();
66
+ await fs.mkdir(baseDir, { recursive: true });
67
+
68
+ // Validate the LLM-drafted frontmatter against the published schema BEFORE
69
+ // we write it to disk or register it. A model that returns sloppy YAML
70
+ // (missing description, illegal slug, etc.) should fail with a single
71
+ // readable line — the raw zod issue array was dumping ~30 lines of
72
+ // angry red JSON into the chat, which is a worse signal than just
73
+ // saying "the model didn't produce valid frontmatter."
74
+ const parsed = skillFrontmatterSchema.safeParse(draft.frontmatter);
75
+ if (!parsed.success) {
76
+ const missing = parsed.error.issues
77
+ .map((iss) => iss.path.join('.') || '(root)')
78
+ .join(', ');
79
+ throw new Error(
80
+ `synthesize_skill: the model didn't produce valid skill frontmatter ` +
81
+ `(missing or invalid: ${missing}). This usually means the model returned ` +
82
+ `prose or a code block without proper YAML frontmatter. Try a more specific intent.`,
83
+ );
84
+ }
85
+ const frontmatter = parsed.data as Skill['frontmatter'];
86
+
87
+ const finalPath = await uniqueFilename(baseDir, slugify(frontmatter.name));
88
+ await fs.writeFile(finalPath, draft.raw, 'utf8');
89
+
90
+ // Derive the skill id from the on-disk filename, not from the LLM-supplied
91
+ // frontmatter name — otherwise synthesizing the same name twice collides
92
+ // even when uniqueFilename has just bumped the filename to `<slug>-2.md`.
93
+ const basename = path.basename(finalPath, '.md');
94
+ const skill: Skill = {
95
+ id: asSkillId(`${scope}/${basename}`),
96
+ path: finalPath,
97
+ scope,
98
+ frontmatter,
99
+ body: draft.body.trimEnd(),
100
+ };
101
+ session.skills.register(skill);
102
+
103
+ await session.log.append({
104
+ type: 'skill_created',
105
+ sessionId: session.id,
106
+ turnId: turnId ?? session.startTurn().turnId,
107
+ source: 'system',
108
+ skillId: skill.id,
109
+ name: skill.frontmatter.name,
110
+ path: finalPath,
111
+ scope,
112
+ originatingPrompt: intent,
113
+ });
114
+
115
+ const auditPath = opts.auditPath ?? path.join(defaultUserSkillsDir(), '.meta', 'created.jsonl');
116
+ await appendAudit(auditPath, {
117
+ slug: path.basename(finalPath, '.md'),
118
+ ts: new Date().toISOString(),
119
+ sessionId: String(session.id),
120
+ originatingPrompt: intent,
121
+ scope,
122
+ });
123
+
124
+ return { skill, path: finalPath, scope };
125
+ }
126
+
127
+ function slugify(name: string): string {
128
+ return name
129
+ .toLowerCase()
130
+ .replace(/[^a-z0-9-]+/g, '-')
131
+ .replace(/^-+|-+$/g, '')
132
+ .slice(0, 60);
133
+ }
134
+
135
+ async function uniqueFilename(dir: string, base: string): Promise<string> {
136
+ let candidate = path.join(dir, `${base}.md`);
137
+ let n = 2;
138
+ while (await exists(candidate)) {
139
+ candidate = path.join(dir, `${base}-${n}.md`);
140
+ n += 1;
141
+ }
142
+ return candidate;
143
+ }
144
+
145
+ async function exists(p: string): Promise<boolean> {
146
+ try {
147
+ await fs.access(p);
148
+ return true;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ async function appendAudit(filePath: string, entry: Record<string, unknown>): Promise<void> {
155
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
156
+ await fs.appendFile(filePath, JSON.stringify(entry) + '\n', 'utf8');
157
+ }
158
+
159
+ export function buildSynthesizeSkillPlugin(
160
+ session: Session,
161
+ opts: SynthesizeOptions = {},
162
+ ): Plugin {
163
+ return definePlugin({
164
+ name: '@moxxy/synthesize-skill',
165
+ version: '0.0.0',
166
+ tools: [
167
+ defineTool({
168
+ name: 'synthesize_skill',
169
+ description:
170
+ 'Draft and persist a new Markdown skill for the given user intent. ' +
171
+ 'Uses the active provider to generate the skill body. Returns the path of the created skill. ' +
172
+ 'ALWAYS pass scope="user" (the default) unless the user has EXPLICITLY asked to scope ' +
173
+ 'the skill to this project — "user" writes to ~/.moxxy/skills/ and the skill is ' +
174
+ 'available across every project; "project" writes to <cwd>/.moxxy/skills/ and only ' +
175
+ 'applies in this directory. Most skills are general-purpose; pick "project" only when ' +
176
+ 'the user said something like "for this repo only" or "project-specific".',
177
+ inputSchema: z.object({
178
+ intent: z.string().min(1).describe('What the user is trying to do. One sentence is enough.'),
179
+ scope: z
180
+ .enum(['user', 'project'])
181
+ .optional()
182
+ .default('user')
183
+ .describe(
184
+ 'Where to write the skill. "user" → ~/.moxxy/skills/ (default, recommended). ' +
185
+ '"project" → <cwd>/.moxxy/skills/ — ONLY when the user explicitly asks for a project-scoped skill.',
186
+ ),
187
+ }),
188
+ permission: { action: 'prompt' },
189
+ handler: async ({ intent, scope }, ctx) => {
190
+ const result = await synthesizeSkill(session, intent, scope, opts, ctx.turnId);
191
+ return {
192
+ path: result.path,
193
+ scope: result.scope,
194
+ name: result.skill.frontmatter.name,
195
+ };
196
+ },
197
+ }),
198
+ defineTool({
199
+ name: 'load_skill',
200
+ description:
201
+ 'Fetch the full body (instructions) of a pre-authored skill by name. ' +
202
+ 'The system prompt lists each skill\'s name, description, and triggers; ' +
203
+ 'call this tool to retrieve the actual workflow when the user\'s intent ' +
204
+ 'matches one of those skills. Returns the markdown body verbatim plus the ' +
205
+ 'frontmatter metadata (allowed-tools, scope, etc.).',
206
+ inputSchema: z.object({
207
+ name: z
208
+ .string()
209
+ .min(1)
210
+ .describe('The exact skill name from the "Available skills" list in the system prompt.'),
211
+ }),
212
+ handler: async ({ name }, ctx) => {
213
+ const skill = session.skills.byName(name);
214
+ if (!skill) {
215
+ const known = session.skills
216
+ .list()
217
+ .map((s) => s.frontmatter.name)
218
+ .join(', ');
219
+ throw new Error(
220
+ `load_skill: no skill named "${name}". ` +
221
+ `Known skills: ${known || '(none registered)'}.`,
222
+ );
223
+ }
224
+ // Emit a skill_invoked event so the audit log captures which
225
+ // skills were actually exercised in this turn — useful for
226
+ // routing analytics and for the self-improver agent later. Use the
227
+ // active turn's id (ctx.turnId) so run-turn's per-turn subscriber
228
+ // filter doesn't drop it (a fresh startTurn() id wouldn't match).
229
+ await session.log.append({
230
+ type: 'skill_invoked',
231
+ sessionId: session.id,
232
+ turnId: ctx.turnId,
233
+ source: 'model',
234
+ skillId: skill.id,
235
+ name: skill.frontmatter.name,
236
+ reason: 'load_skill_tool',
237
+ });
238
+ return {
239
+ name: skill.frontmatter.name,
240
+ description: skill.frontmatter.description,
241
+ scope: skill.scope,
242
+ allowedTools: skill.frontmatter['allowed-tools'] ?? null,
243
+ body: skill.body,
244
+ };
245
+ },
246
+ }),
247
+ defineTool({
248
+ name: 'reload_skills',
249
+ description:
250
+ 'Rescan all skill sources (builtin + plugin + ~/.moxxy/skills + ./.moxxy/skills), ' +
251
+ 'registering any new or changed skills.',
252
+ inputSchema: z.object({}),
253
+ // Safe, idempotent, local-only rescan — never prompt. Without this the
254
+ // tool inherits the channel resolver's default, which denies in
255
+ // headless runs (the skill-author flow couldn't activate a new skill).
256
+ permission: { action: 'allow' },
257
+ handler: async () => {
258
+ const { discoverSkills } = await import('./loader.js');
259
+ // Discover first, swap second: never empty the registry while
260
+ // the fs scan is in flight, because concurrent skill lookups
261
+ // would observe an empty registry mid-reload. Pass the SAME
262
+ // source set the boot loader used (builtin + pluginDirs +
263
+ // user + project); a previous version of this handler omitted
264
+ // builtinDir/pluginDirs and reload silently nuked the builtin
265
+ // skill set.
266
+ const discovered = await discoverSkills({
267
+ projectDir: opts.projectDir ?? defaultProjectSkillsDir(session.cwd),
268
+ userDir: opts.userDir ?? defaultUserSkillsDir(),
269
+ ...(opts.builtinDir ? { builtinDir: opts.builtinDir } : {}),
270
+ ...(opts.pluginDirs ? { pluginDirs: opts.pluginDirs } : {}),
271
+ });
272
+ session.skills.replaceAll(discovered);
273
+ return `loaded ${discovered.length} skill${discovered.length === 1 ? '' : 's'}`;
274
+ },
275
+ }),
276
+ defineTool({
277
+ name: 'load_tool',
278
+ description:
279
+ 'Load a tool whose full schema was indexed but not sent (see "Loadable tools" ' +
280
+ 'in the system prompt). Call this with the tool name; the tool becomes callable ' +
281
+ 'on the next turn. Only needed when lazy tool loading is enabled — core tools ' +
282
+ '(Read/Write/Edit/Bash/Grep/Glob) are always available.',
283
+ inputSchema: z.object({
284
+ name: z.string().min(1).describe('Exact tool name from the "Loadable tools" index.'),
285
+ }),
286
+ permission: { action: 'allow' },
287
+ handler: ({ name }) => {
288
+ // The call itself is recorded in the log; `applyLazyTools` reads that
289
+ // to include the tool's schema on subsequent requests. Here we just
290
+ // validate the name and echo the description so the model can proceed.
291
+ const tool = session.tools.get(name);
292
+ if (!tool) {
293
+ const known = session.tools
294
+ .list()
295
+ .map((t) => t.name)
296
+ .join(', ');
297
+ throw new Error(`load_tool: no tool named "${name}". Known tools: ${known}.`);
298
+ }
299
+ return { name: tool.name, description: tool.description, loaded: true };
300
+ },
301
+ }),
302
+ ],
303
+ });
304
+ }
@@ -0,0 +1,56 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { asSessionId, asTurnId, type MoxxyEvent } from '@moxxy/sdk';
3
+ import type { SessionRuntime } from '../session-runtime.js';
4
+ import type { EventLog } from '../events/log.js';
5
+ import { streamChildEventToParent } from './events.js';
6
+
7
+ const childTool: MoxxyEvent = {
8
+ type: 'tool_call_requested',
9
+ seq: 0,
10
+ id: 'e0',
11
+ ts: 0,
12
+ sessionId: asSessionId('child'),
13
+ turnId: asTurnId('ct'),
14
+ source: 'model',
15
+ callId: 'call-1',
16
+ name: 'list_dir',
17
+ input: { path: '.' },
18
+ } as unknown as MoxxyEvent;
19
+
20
+ describe('streamChildEventToParent', () => {
21
+ afterEach(() => vi.restoreAllMocks());
22
+
23
+ // u47-5: a parent-log append failure while forwarding child progress must be
24
+ // non-fatal (the subagent run continues) AND observable (not silently lost).
25
+ it('does not throw and surfaces a diagnostic when the parent log append rejects', async () => {
26
+ const stderr = vi.spyOn(process.stderr, 'write').mockReturnValue(true);
27
+ const append = vi.fn().mockRejectedValue(new Error('disk full'));
28
+ const parent = {
29
+ id: asSessionId('parent'),
30
+ log: { append } as unknown as EventLog,
31
+ } as unknown as SessionRuntime;
32
+
33
+ await expect(
34
+ streamChildEventToParent(parent, asTurnId('pt'), 'researcher', asSessionId('child'), childTool),
35
+ ).resolves.toBeUndefined();
36
+
37
+ expect(append).toHaveBeenCalledTimes(1);
38
+ expect(stderr).toHaveBeenCalledTimes(1);
39
+ expect(String(stderr.mock.calls[0]![0])).toContain('disk full');
40
+ expect(String(stderr.mock.calls[0]![0])).toContain('subagent_tool_call');
41
+ });
42
+
43
+ it('forwards a mapped child event to the parent log on the happy path', async () => {
44
+ const append = vi.fn().mockResolvedValue(undefined);
45
+ const parent = {
46
+ id: asSessionId('parent'),
47
+ log: { append } as unknown as EventLog,
48
+ } as unknown as SessionRuntime;
49
+
50
+ await streamChildEventToParent(parent, asTurnId('pt'), 'researcher', asSessionId('child'), childTool);
51
+ expect(append).toHaveBeenCalledTimes(1);
52
+ const arg = append.mock.calls[0]![0] as { type: string; subtype: string };
53
+ expect(arg.type).toBe('plugin_event');
54
+ expect(arg.subtype).toBe('subagent_tool_call');
55
+ });
56
+ });