@moxxy/core 0.26.0 → 0.28.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 (70) hide show
  1. package/dist/index.d.ts +4 -1
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +4 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/plugins/host-options.d.ts +2 -0
  6. package/dist/plugins/host-options.d.ts.map +1 -1
  7. package/dist/plugins/host.d.ts +8 -0
  8. package/dist/plugins/host.d.ts.map +1 -1
  9. package/dist/plugins/host.js +14 -0
  10. package/dist/plugins/host.js.map +1 -1
  11. package/dist/plugins/registry-kinds.d.ts +1 -0
  12. package/dist/plugins/registry-kinds.d.ts.map +1 -1
  13. package/dist/plugins/registry-kinds.js +11 -0
  14. package/dist/plugins/registry-kinds.js.map +1 -1
  15. package/dist/registries/modes.d.ts +8 -0
  16. package/dist/registries/modes.d.ts.map +1 -1
  17. package/dist/registries/modes.js +11 -0
  18. package/dist/registries/modes.js.map +1 -1
  19. package/dist/registries/reflectors.d.ts +15 -0
  20. package/dist/registries/reflectors.d.ts.map +1 -0
  21. package/dist/registries/reflectors.js +16 -0
  22. package/dist/registries/reflectors.js.map +1 -0
  23. package/dist/run-turn.d.ts.map +1 -1
  24. package/dist/run-turn.js +5 -0
  25. package/dist/run-turn.js.map +1 -1
  26. package/dist/session-runtime.d.ts +4 -0
  27. package/dist/session-runtime.d.ts.map +1 -1
  28. package/dist/session.d.ts +9 -1
  29. package/dist/session.d.ts.map +1 -1
  30. package/dist/session.js +17 -0
  31. package/dist/session.js.map +1 -1
  32. package/dist/sessions/event-shape.d.ts +68 -0
  33. package/dist/sessions/event-shape.d.ts.map +1 -0
  34. package/dist/sessions/event-shape.js +171 -0
  35. package/dist/sessions/event-shape.js.map +1 -0
  36. package/dist/sessions/persistence.d.ts +7 -5
  37. package/dist/sessions/persistence.d.ts.map +1 -1
  38. package/dist/sessions/persistence.js +39 -13
  39. package/dist/sessions/persistence.js.map +1 -1
  40. package/dist/skill-usage.d.ts +62 -0
  41. package/dist/skill-usage.d.ts.map +1 -0
  42. package/dist/skill-usage.js +106 -0
  43. package/dist/skill-usage.js.map +1 -0
  44. package/dist/skills/synthesize.d.ts.map +1 -1
  45. package/dist/skills/synthesize.js +42 -0
  46. package/dist/skills/synthesize.js.map +1 -1
  47. package/dist/subagents/run-child.d.ts.map +1 -1
  48. package/dist/subagents/run-child.js +4 -0
  49. package/dist/subagents/run-child.js.map +1 -1
  50. package/package.json +4 -4
  51. package/src/index.ts +11 -0
  52. package/src/plugins/host-options.ts +2 -0
  53. package/src/plugins/host.ts +14 -0
  54. package/src/plugins/registry-kinds.test.ts +4 -0
  55. package/src/plugins/registry-kinds.ts +13 -0
  56. package/src/registries/modes.test.ts +27 -0
  57. package/src/registries/modes.ts +12 -0
  58. package/src/registries/reflectors.test.ts +50 -0
  59. package/src/registries/reflectors.ts +17 -0
  60. package/src/run-turn.ts +5 -0
  61. package/src/session-runtime.ts +4 -0
  62. package/src/session.ts +18 -0
  63. package/src/sessions/event-shape.test.ts +207 -0
  64. package/src/sessions/event-shape.ts +196 -0
  65. package/src/sessions/persistence.test.ts +132 -0
  66. package/src/sessions/persistence.ts +39 -13
  67. package/src/skill-usage.test.ts +102 -0
  68. package/src/skill-usage.ts +165 -0
  69. package/src/skills/synthesize.ts +42 -0
  70. package/src/subagents/run-child.ts +4 -0
@@ -0,0 +1,165 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { createMutex } from '@moxxy/sdk';
3
+ import { moxxyPath, writeFileAtomic } from '@moxxy/sdk/server';
4
+ import { z } from 'zod';
5
+
6
+ /**
7
+ * Cross-session skill usage, persisted at `~/.moxxy/skills/.meta/usage.json`
8
+ * (next to the agent-created audit log). A forward-going aggregate keyed by
9
+ * skill NAME: each session folds its own `skill_invoked` / `skill_created`
10
+ * events and merges the delta in on shutdown (see `@moxxy/plugin-usage-stats`).
11
+ * Purely additive `invocations` counter plus first-`createdAt` / latest-
12
+ * `lastInvokedAt` timestamps — a session's contribution is added once and the
13
+ * file stays a monotone sum.
14
+ *
15
+ * KNOWN LIMITATION (as of this file's introduction): the only site that emits
16
+ * `skill_invoked` today is the `load_skill` tool (reason `'load_skill_tool'` —
17
+ * see `packages/core/src/skills/synthesize.ts`). The event type also carries
18
+ * `'trigger_match' | 'classifier' | 'manual'` reasons, but nothing emits those
19
+ * yet. So `invocations` counts ONLY explicit `load_skill` calls for now. When
20
+ * trigger-match / classifier emission lands later, this same file simply starts
21
+ * counting more — no format change required.
22
+ *
23
+ * Like `usage-stats.ts`, this is best-effort: a missing or malformed file reads
24
+ * as empty, and a write failure never blocks shutdown.
25
+ */
26
+ export interface SkillUsage {
27
+ /** Total `load_skill`-driven invocations recorded across all sessions. */
28
+ readonly invocations: number;
29
+ /** ISO timestamp of the most recent recorded invocation, if any. */
30
+ readonly lastInvokedAt?: string;
31
+ /** ISO timestamp of when this skill was first agent-synthesized, if seen. */
32
+ readonly createdAt?: string;
33
+ }
34
+
35
+ export interface SkillUsageFile {
36
+ readonly version: 1;
37
+ /** ISO timestamp of the last merge. */
38
+ readonly updatedAt: string;
39
+ /** Per-skill-name lifetime usage. */
40
+ readonly skills: Record<string, SkillUsage>;
41
+ }
42
+
43
+ /**
44
+ * One session's contribution for a single skill: how many invocations it saw,
45
+ * the latest invocation timestamp within the run, and — if the skill was
46
+ * created during the run — its creation timestamp.
47
+ */
48
+ export interface SkillUsageDelta {
49
+ readonly invocations: number;
50
+ readonly lastInvokedAt?: string;
51
+ readonly createdAt?: string;
52
+ }
53
+
54
+ export function skillsUsagePath(): string {
55
+ // Route through `moxxyPath` so a `$MOXXY_HOME` override relocates the skill
56
+ // usage aggregate alongside the rest of the data dir. Identical to
57
+ // `~/.moxxy/skills/.meta/usage.json` when MOXXY_HOME is unset.
58
+ return moxxyPath('skills/.meta/usage.json');
59
+ }
60
+
61
+ function emptyUsage(): SkillUsageFile {
62
+ return { version: 1, updatedAt: new Date().toISOString(), skills: {} };
63
+ }
64
+
65
+ // Validates the on-disk shape so a hand-edited or partially-written file with a
66
+ // non-numeric counter (e.g. `invocations: "3"`) can't flow into the additive
67
+ // merge and corrupt the persisted aggregate via string concatenation. A failed
68
+ // parse falls through to `emptyUsage()`, exactly like malformed JSON.
69
+ const skillUsageSchema = z.object({
70
+ invocations: z.number(),
71
+ lastInvokedAt: z.string().optional(),
72
+ createdAt: z.string().optional(),
73
+ });
74
+
75
+ const skillUsageFileSchema = z.object({
76
+ version: z.literal(1),
77
+ updatedAt: z.string(),
78
+ skills: z.record(z.string(), skillUsageSchema),
79
+ });
80
+
81
+ /**
82
+ * Read the skill usage aggregate. Returns an empty file when missing or
83
+ * unparseable — skill usage is an optional, non-load-blocking layer.
84
+ */
85
+ export async function loadSkillUsage(
86
+ filePath: string = skillsUsagePath(),
87
+ ): Promise<SkillUsageFile> {
88
+ try {
89
+ const raw = await fs.readFile(filePath, 'utf8');
90
+ const result = skillUsageFileSchema.safeParse(JSON.parse(raw));
91
+ if (result.success) return result.data;
92
+ // shape-invalid (e.g. a non-numeric counter) — start fresh rather than let
93
+ // a corrupt entry poison the aggregate via string-concat addition.
94
+ } catch {
95
+ // missing or malformed JSON — start fresh
96
+ }
97
+ return emptyUsage();
98
+ }
99
+
100
+ async function writeAtomic(file: SkillUsageFile, filePath: string): Promise<void> {
101
+ await writeFileAtomic(filePath, JSON.stringify(file, null, 2) + '\n');
102
+ }
103
+
104
+ // Serializes the read-modify-write in `mergeSkillUsage` so two concurrent merges
105
+ // (e.g. overlapping shutdowns) can't both read the same snapshot and have the
106
+ // second write clobber the first's delta.
107
+ const mergeMutex = createMutex();
108
+
109
+ /** Later of two ISO timestamps (lexicographic sort is correct for same-format
110
+ * UTC `toISOString()` output). Either may be absent. */
111
+ function laterIso(a: string | undefined, b: string | undefined): string | undefined {
112
+ if (!a) return b;
113
+ if (!b) return a;
114
+ return a >= b ? a : b;
115
+ }
116
+
117
+ /**
118
+ * Merge a session's per-skill delta into the persisted aggregate and write it
119
+ * back. Reads the current file, adds each `invocations` field-wise, keeps the
120
+ * earliest `createdAt` and the latest `lastInvokedAt`, and writes atomically.
121
+ * Returns the updated file.
122
+ *
123
+ * Best-effort: a write failure logs to stderr but does not throw — losing one
124
+ * session's stats must never block shutdown.
125
+ */
126
+ export async function mergeSkillUsage(
127
+ delta: Record<string, SkillUsageDelta>,
128
+ filePath: string = skillsUsagePath(),
129
+ ): Promise<SkillUsageFile> {
130
+ // An empty delta writes nothing, so there's no read-modify-write to serialize:
131
+ // skip the mutex and read the current file directly. A session that exercised
132
+ // no skills produces an empty delta on every shutdown, so this avoids needless
133
+ // I/O + contention on the common no-op path.
134
+ const keys = Object.keys(delta);
135
+ if (keys.length === 0) return loadSkillUsage(filePath);
136
+
137
+ return mergeMutex.run(async () => {
138
+ const current = await loadSkillUsage(filePath);
139
+
140
+ const now = new Date().toISOString();
141
+ const skills: Record<string, SkillUsage> = { ...current.skills };
142
+ for (const key of keys) {
143
+ const d = delta[key]!;
144
+ const existing = skills[key];
145
+ const invocations = (existing?.invocations ?? 0) + d.invocations;
146
+ const lastInvokedAt = laterIso(existing?.lastInvokedAt, d.lastInvokedAt);
147
+ const createdAt = existing?.createdAt ?? d.createdAt;
148
+ skills[key] = {
149
+ invocations,
150
+ ...(lastInvokedAt ? { lastInvokedAt } : {}),
151
+ ...(createdAt ? { createdAt } : {}),
152
+ };
153
+ }
154
+ const next: SkillUsageFile = { version: 1, updatedAt: now, skills };
155
+ try {
156
+ await writeAtomic(next, filePath);
157
+ } catch (err) {
158
+ process.stderr.write(
159
+ `moxxy: failed to persist skill usage to ${filePath}: ` +
160
+ `${err instanceof Error ? err.message : String(err)}\n`,
161
+ );
162
+ }
163
+ return next;
164
+ });
165
+ }
@@ -213,6 +213,14 @@ export function buildSynthesizeSkillPlugin(
213
213
  session: Session,
214
214
  opts: SynthesizeOptions = {},
215
215
  ): Plugin {
216
+ // Capability fs globs for the tools below, resolved from the same option
217
+ // fallbacks the handlers use. The audit JSONL defaults under the DEFAULT
218
+ // user skills dir even when `userDir` is overridden (see synthesizeSkill).
219
+ const userSkillsGlob = `${opts.userDir ?? defaultUserSkillsDir()}/**`;
220
+ const projectSkillsGlob = `${opts.projectDir ?? defaultProjectSkillsDir(session.cwd)}/**`;
221
+ const auditGlob = opts.auditPath
222
+ ? `${path.dirname(opts.auditPath)}/**`
223
+ : `${defaultUserSkillsDir()}/**`;
216
224
  return definePlugin({
217
225
  name: '@moxxy/synthesize-skill',
218
226
  version: '0.0.0',
@@ -239,6 +247,19 @@ export function buildSynthesizeSkillPlugin(
239
247
  ),
240
248
  }),
241
249
  permission: { action: 'prompt' },
250
+ // Drafts the skill body via the active LLMProvider (host is
251
+ // provider-config dependent, hence net: any), then writes the skill
252
+ // file + the audit JSONL.
253
+ isolation: {
254
+ capabilities: {
255
+ fs: {
256
+ read: [auditGlob],
257
+ write: [userSkillsGlob, projectSkillsGlob, auditGlob],
258
+ },
259
+ net: { mode: 'any' },
260
+ timeMs: 120_000,
261
+ },
262
+ },
242
263
  handler: async ({ intent, scope }, ctx) => {
243
264
  const result = await synthesizeSkill(session, intent, scope, opts, ctx.turnId);
244
265
  return {
@@ -262,6 +283,8 @@ export function buildSynthesizeSkillPlugin(
262
283
  .min(1)
263
284
  .describe('The exact skill name from the "Available skills" list in the system prompt.'),
264
285
  }),
286
+ // Registry lookup — the skill body is already in memory.
287
+ isolation: { capabilities: { net: { mode: 'none' }, timeMs: 10_000 } },
265
288
  handler: async ({ name }, ctx) => {
266
289
  const skill = session.skills.byName(name);
267
290
  if (!skill) {
@@ -307,6 +330,23 @@ export function buildSynthesizeSkillPlugin(
307
330
  // tool inherits the channel resolver's default, which denies in
308
331
  // headless runs (the skill-author flow couldn't activate a new skill).
309
332
  permission: { action: 'allow' },
333
+ // Rescans every skill source the boot loader used — the builtin and
334
+ // plugin dirs live wherever those packages are installed, so they're
335
+ // covered explicitly from the configured options.
336
+ isolation: {
337
+ capabilities: {
338
+ fs: {
339
+ read: [
340
+ userSkillsGlob,
341
+ projectSkillsGlob,
342
+ ...(opts.builtinDir ? [`${opts.builtinDir}/**`] : []),
343
+ ...(opts.pluginDirs ?? []).map((d) => `${d}/**`),
344
+ ],
345
+ },
346
+ net: { mode: 'none' },
347
+ timeMs: 30_000,
348
+ },
349
+ },
310
350
  handler: async () => {
311
351
  const { discoverSkills } = await import('./loader.js');
312
352
  // Discover first, swap second: never empty the registry while
@@ -337,6 +377,8 @@ export function buildSynthesizeSkillPlugin(
337
377
  name: z.string().min(1).describe('Exact tool name from the "Loadable tools" index.'),
338
378
  }),
339
379
  permission: { action: 'allow' },
380
+ // Registry lookup — the schema inclusion happens on later requests.
381
+ isolation: { capabilities: { net: { mode: 'none' }, timeMs: 10_000 } },
340
382
  handler: ({ name }) => {
341
383
  // The call itself is recorded in the log; `applyLazyTools` reads that
342
384
  // to include the tool's schema on subsequent requests. Here we just
@@ -468,6 +468,10 @@ function buildChildContext(
468
468
  signal: parentSignal, // child cancels when parent cancels
469
469
  maxIterations: spec.maxIterations ?? 50,
470
470
  subagents: spawner,
471
+ // Disarms turn-end checkpoints inside children (runReactLoop's recursion
472
+ // backstop) — a checkpoint spawning a child in a checkpoint-bearing mode
473
+ // must not gate the child's turn-end and recurse forever.
474
+ isSubagent: true,
471
475
  emit: (event: EmittedEvent): Promise<MoxxyEvent> => childLog.append(event),
472
476
  };
473
477
  }