@desplega.ai/agent-swarm 1.92.0 → 1.92.2

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 (90) hide show
  1. package/README.md +1 -1
  2. package/openapi.json +276 -3
  3. package/package.json +6 -6
  4. package/plugin/skills/pages/SKILL.md +5 -2
  5. package/src/be/db.ts +416 -20
  6. package/src/be/memory/boot-reembed.ts +85 -0
  7. package/src/be/memory/constants.ts +44 -2
  8. package/src/be/memory/providers/openai-embedding.ts +15 -5
  9. package/src/be/memory/providers/sqlite-store.ts +325 -76
  10. package/src/be/memory/reranker.ts +35 -17
  11. package/src/be/memory/types.ts +43 -0
  12. package/src/be/migrations/084_script_run_journal_duration.sql +5 -0
  13. package/src/be/migrations/085_script_runs_kind.sql +9 -0
  14. package/src/be/migrations/086_pages_default_authed.sql +64 -0
  15. package/src/be/migrations/087_skill_files.sql +19 -0
  16. package/src/be/modelsdev-cache.json +5622 -2543
  17. package/src/be/seed-scripts/catalog/boot-triage.ts +221 -0
  18. package/src/be/seed-scripts/catalog/catalog-report.ts +457 -0
  19. package/src/be/seed-scripts/catalog/compound-insights.ts +465 -0
  20. package/src/be/seed-scripts/catalog/gh-pr-snapshot.ts +1 -1
  21. package/src/be/seed-scripts/catalog/memory-eval.ts +1059 -0
  22. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +34 -439
  23. package/src/be/seed-scripts/catalog/schedule-health.ts +78 -2
  24. package/src/be/seed-scripts/catalog/task-failure-audit.ts +48 -1
  25. package/src/be/seed-scripts/index.ts +32 -4
  26. package/src/be/seed-skills/index.ts +0 -7
  27. package/src/be/skill-sync.ts +91 -7
  28. package/src/commands/runner.ts +6 -2
  29. package/src/heartbeat/templates.ts +20 -16
  30. package/src/http/index.ts +50 -7
  31. package/src/http/mcp-user.ts +23 -0
  32. package/src/http/mcp.ts +58 -0
  33. package/src/http/memory.ts +62 -0
  34. package/src/http/pages.ts +1 -1
  35. package/src/http/script-runs.ts +2 -0
  36. package/src/http/scripts.ts +39 -2
  37. package/src/http/skills.ts +225 -0
  38. package/src/providers/claude-adapter.ts +56 -24
  39. package/src/script-workflows/workflow-ctx.ts +7 -3
  40. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  41. package/src/scripts-runtime/swarm-sdk.ts +13 -0
  42. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  43. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  44. package/src/server.ts +2 -0
  45. package/src/tasks/worker-follow-up.ts +12 -0
  46. package/src/tests/claude-adapter-binary.test.ts +135 -81
  47. package/src/tests/create-page-tool.test.ts +19 -2
  48. package/src/tests/heartbeat-checklist.test.ts +36 -0
  49. package/src/tests/mcp-transport-gc.test.ts +58 -0
  50. package/src/tests/memory-e2e.test.ts +6 -6
  51. package/src/tests/memory-health-endpoint.test.ts +78 -0
  52. package/src/tests/memory-rater-e2e.test.ts +4 -5
  53. package/src/tests/memory-reranker.test.ts +135 -124
  54. package/src/tests/memory-store.test.ts +221 -1
  55. package/src/tests/memory.test.ts +13 -12
  56. package/src/tests/pages-http.test.ts +20 -2
  57. package/src/tests/pages-storage.test.ts +26 -0
  58. package/src/tests/scripts-mcp-e2e.test.ts +53 -0
  59. package/src/tests/seed-scripts.test.ts +328 -3
  60. package/src/tests/skill-files-http.test.ts +171 -0
  61. package/src/tests/skill-files.test.ts +162 -0
  62. package/src/tests/skill-get-file-tool.test.ts +110 -0
  63. package/src/tests/skill-sync.test.ts +125 -6
  64. package/src/tests/task-cascade-fail.test.ts +304 -0
  65. package/src/tools/create-page.ts +2 -2
  66. package/src/tools/skills/index.ts +1 -0
  67. package/src/tools/skills/skill-get-file.ts +80 -0
  68. package/src/tools/tool-config.ts +2 -1
  69. package/src/types.ts +20 -0
  70. package/src/utils/internal-ai/complete-structured.ts +2 -2
  71. package/templates/schedules/daily-blocker-digest/content.md +68 -54
  72. package/templates/schedules/daily-compounding-reflection/content.md +4 -4
  73. package/templates/schedules/daily-hn-briefing/content.md +5 -5
  74. package/templates/schedules/daily-workflow-health-audit/content.md +6 -6
  75. package/templates/schedules/gtm-weekly-review/content.md +9 -9
  76. package/templates/schedules/weekly-dependabot-triage/content.md +24 -20
  77. package/templates/skills/agentmail-sending/content.md +6 -7
  78. package/templates/skills/desloppify/content.md +8 -9
  79. package/templates/skills/jira-interaction/content.md +25 -33
  80. package/templates/skills/kapso-whatsapp/content.md +29 -30
  81. package/templates/skills/linear-interaction/content.md +8 -9
  82. package/templates/skills/profile-corruption-escalation/content.md +44 -85
  83. package/templates/skills/sprite-cli/content.md +4 -5
  84. package/templates/skills/turso-interaction/content.md +14 -17
  85. package/templates/skills/workflow-iterate/content.md +38 -391
  86. package/templates/skills/x-api-interactions/content.md +4 -6
  87. package/templates/workflows/llm-safe-release-context/config.json +13 -0
  88. package/templates/workflows/llm-safe-release-context/content.md +69 -0
  89. package/templates/skills/scheduled-task-resilience/config.json +0 -14
  90. package/templates/skills/scheduled-task-resilience/content.md +0 -95
package/src/http/mcp.ts CHANGED
@@ -4,10 +4,62 @@ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/
4
4
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { createServer } from "@/server";
6
6
 
7
+ export type McpTransportActivity = Record<string, number>;
8
+
9
+ export const DEFAULT_MCP_TRANSPORT_IDLE_TIMEOUT_MS = 2 * 60 * 60 * 1000;
10
+
11
+ export function markMcpTransportActivity(
12
+ sessionActivity: McpTransportActivity,
13
+ sessionId: string | undefined,
14
+ now = Date.now(),
15
+ ): void {
16
+ if (sessionId) {
17
+ sessionActivity[sessionId] = now;
18
+ }
19
+ }
20
+
21
+ export function closeIdleMcpTransports(
22
+ transports: Record<string, StreamableHTTPServerTransport>,
23
+ sessionActivity: McpTransportActivity,
24
+ options: {
25
+ now?: number;
26
+ idleTimeoutMs?: number;
27
+ label?: string;
28
+ onClose?: (id: string) => void;
29
+ } = {},
30
+ ): number {
31
+ const now = options.now ?? Date.now();
32
+ const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_MCP_TRANSPORT_IDLE_TIMEOUT_MS;
33
+ let closed = 0;
34
+
35
+ for (const [id, transport] of Object.entries(transports)) {
36
+ const lastActivity = sessionActivity[id];
37
+ if (lastActivity === undefined) {
38
+ sessionActivity[id] = now;
39
+ continue;
40
+ }
41
+ if (now - lastActivity < idleTimeoutMs) continue;
42
+
43
+ try {
44
+ transport.close();
45
+ } catch (err) {
46
+ console.warn(`[HTTP] Failed to close idle ${options.label ?? "MCP"} transport ${id}: ${err}`);
47
+ } finally {
48
+ delete transports[id];
49
+ delete sessionActivity[id];
50
+ options.onClose?.(id);
51
+ closed++;
52
+ }
53
+ }
54
+
55
+ return closed;
56
+ }
57
+
7
58
  export async function handleMcp(
8
59
  req: IncomingMessage,
9
60
  res: ServerResponse,
10
61
  transports: Record<string, StreamableHTTPServerTransport>,
62
+ sessionActivity: McpTransportActivity = {},
11
63
  ): Promise<boolean> {
12
64
  const sessionId = req.headers["mcp-session-id"] as string | undefined;
13
65
 
@@ -26,20 +78,24 @@ export async function handleMcp(
26
78
 
27
79
  if (sessionId && transports[sessionId]) {
28
80
  transport = transports[sessionId];
81
+ markMcpTransportActivity(sessionActivity, sessionId);
29
82
  } else if (!sessionId && isInitializeRequest(body)) {
30
83
  transport = new StreamableHTTPServerTransport({
31
84
  sessionIdGenerator: () => randomUUID(),
32
85
  onsessioninitialized: (id) => {
33
86
  transports[id] = transport;
87
+ markMcpTransportActivity(sessionActivity, id);
34
88
  },
35
89
  onsessionclosed: (id) => {
36
90
  delete transports[id];
91
+ delete sessionActivity[id];
37
92
  },
38
93
  });
39
94
 
40
95
  transport.onclose = () => {
41
96
  if (transport.sessionId) {
42
97
  delete transports[transport.sessionId];
98
+ delete sessionActivity[transport.sessionId];
43
99
  }
44
100
  };
45
101
 
@@ -58,11 +114,13 @@ export async function handleMcp(
58
114
  }
59
115
 
60
116
  await transport.handleRequest(req, res, body);
117
+ markMcpTransportActivity(sessionActivity, transport.sessionId);
61
118
  return true;
62
119
  }
63
120
 
64
121
  if (req.method === "GET" || req.method === "DELETE") {
65
122
  if (sessionId && transports[sessionId]) {
123
+ markMcpTransportActivity(sessionActivity, sessionId);
66
124
  await transports[sessionId].handleRequest(req, res);
67
125
  return true;
68
126
  }
@@ -115,6 +115,18 @@ const listMemory = route({
115
115
  },
116
116
  });
117
117
 
118
+ const memoryHealth = route({
119
+ method: "get",
120
+ path: "/api/memory/health",
121
+ pattern: ["api", "memory", "health"],
122
+ summary: "Report memory vector index health and retrieval mode",
123
+ tags: ["Memory"],
124
+ auth: { apiKey: true },
125
+ responses: {
126
+ 200: { description: "Memory vector index health" },
127
+ },
128
+ });
129
+
118
130
  const deleteMemoryById = route({
119
131
  method: "delete",
120
132
  path: "/api/memory/{id}",
@@ -375,6 +387,8 @@ export async function handleMemory(
375
387
  name: r.name,
376
388
  content: r.content,
377
389
  similarity: r.similarity,
390
+ rawSimilarity: r.rawSimilarity,
391
+ compositeScore: r.compositeScore,
378
392
  source: r.source,
379
393
  scope: r.scope,
380
394
  })),
@@ -430,6 +444,8 @@ export async function handleMemory(
430
444
  scope: r.scope,
431
445
  source: r.source,
432
446
  similarity: r.similarity,
447
+ rawSimilarity: r.rawSimilarity,
448
+ compositeScore: r.compositeScore,
433
449
  createdAt: r.createdAt,
434
450
  accessedAt: r.accessedAt,
435
451
  accessCount: r.accessCount ?? 0,
@@ -498,6 +514,14 @@ export async function handleMemory(
498
514
  return true;
499
515
  }
500
516
 
517
+ if (memoryHealth.match(req.method, pathSegments)) {
518
+ const parsed = await memoryHealth.parse(req, res, pathSegments, new URLSearchParams());
519
+ if (!parsed) return true;
520
+
521
+ json(res, getMemoryStore().getHealth());
522
+ return true;
523
+ }
524
+
501
525
  if (deleteMemoryById.match(req.method, pathSegments)) {
502
526
  const parsed = await deleteMemoryById.parse(req, res, pathSegments, new URLSearchParams());
503
527
  if (!parsed) return true;
@@ -663,3 +687,41 @@ export async function handleMemory(
663
687
 
664
688
  return false;
665
689
  }
690
+
691
+ // ─── Expired Memory GC ──────────────────────────────────────────────────────
692
+
693
+ const MEMORY_GC_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
694
+ let memoryGcTimer: ReturnType<typeof setInterval> | null = null;
695
+
696
+ export function startMemoryGc(intervalMs = MEMORY_GC_INTERVAL_MS): void {
697
+ if (memoryGcTimer) return;
698
+
699
+ // Run immediately on startup to clear any backlog
700
+ try {
701
+ const purged = getMemoryStore().purgeExpired();
702
+ if (purged > 0) {
703
+ console.log(`[memory-gc] Initial purge removed ${purged} expired memory row(s)`);
704
+ }
705
+ } catch (err) {
706
+ console.error("[memory-gc] Initial purge failed:", err);
707
+ }
708
+
709
+ memoryGcTimer = setInterval(() => {
710
+ try {
711
+ const purged = getMemoryStore().purgeExpired();
712
+ if (purged > 0) {
713
+ console.log(`[memory-gc] Periodic purge removed ${purged} expired memory row(s)`);
714
+ }
715
+ } catch (err) {
716
+ console.error("[memory-gc] Periodic purge failed:", err);
717
+ }
718
+ }, intervalMs);
719
+ if (typeof memoryGcTimer?.unref === "function") memoryGcTimer.unref();
720
+ }
721
+
722
+ export function stopMemoryGc(): void {
723
+ if (memoryGcTimer) {
724
+ clearInterval(memoryGcTimer);
725
+ memoryGcTimer = null;
726
+ }
727
+ }
package/src/http/pages.ts CHANGED
@@ -58,7 +58,7 @@ const createPageRoute = route({
58
58
  title: z.string().min(1),
59
59
  description: z.string().optional(),
60
60
  contentType: PageContentTypeSchema,
61
- authMode: PageAuthModeSchema,
61
+ authMode: PageAuthModeSchema.default("authed"),
62
62
  password: z.string().min(1).optional(),
63
63
  body: z.string(),
64
64
  needsCredentials: z.array(z.string()).optional(),
@@ -62,6 +62,7 @@ const journalStepBodySchema = z.object({
62
62
  status: z.enum(["completed", "failed"]),
63
63
  result: z.unknown().optional(),
64
64
  error: z.string().optional(),
65
+ durationMs: z.number().int().nonnegative().optional(),
65
66
  });
66
67
 
67
68
  const statusBodySchema = z.discriminatedUnion("status", [
@@ -440,6 +441,7 @@ export async function handleScriptRuns(
440
441
  status: parsed.body.status,
441
442
  result: parsed.body.result,
442
443
  error: parsed.body.error,
444
+ durationMs: parsed.body.durationMs,
443
445
  });
444
446
  const limit = assertRunWithinLimits(run.id);
445
447
  if (!limit.ok) {
@@ -1,6 +1,6 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
- import { getAgentById, upsertKv } from "../be/db";
3
+ import { getAgentById, recordInlineScriptRun, upsertKv } from "../be/db";
4
4
  import { createEvent } from "../be/events";
5
5
  import { deleteScript, getScript, upsertScriptByName } from "../be/scripts/db";
6
6
  import { searchScripts } from "../be/scripts/embeddings";
@@ -14,7 +14,7 @@ import {
14
14
  type ScriptScope,
15
15
  ScriptScopeSchema,
16
16
  } from "../types";
17
- import { scrubObject } from "../utils/secret-scrubber";
17
+ import { scrubObject, scrubSecrets } from "../utils/secret-scrubber";
18
18
  import { route } from "./route-def";
19
19
  import { json, jsonError } from "./utils";
20
20
 
@@ -283,6 +283,7 @@ export async function handleScripts(
283
283
  return true;
284
284
  }
285
285
 
286
+ const startedAt = new Date().toISOString();
286
287
  const output = await runScript({
287
288
  source: source as string,
288
289
  args: parsed.body.args,
@@ -331,6 +332,42 @@ export async function handleScripts(
331
332
  autoSaved = { slug, reason: "successful_inline_run" };
332
333
  }
333
334
 
335
+ // Persist the inline run (no journal) so one-off executions show up alongside
336
+ // durable workflow runs in the Script Runs dashboard. Best-effort: recording
337
+ // must never fail the actual execution.
338
+ const ok = output.exitCode === 0 && !output.error && !output.runtimeError;
339
+ const runError = ok
340
+ ? undefined
341
+ : scrubSecrets(
342
+ [
343
+ output.error,
344
+ output.runtimeError
345
+ ? `${output.runtimeError.name}: ${output.runtimeError.message}`
346
+ : undefined,
347
+ ]
348
+ .filter(Boolean)
349
+ .join(" — ") || `Script exited with code ${output.exitCode}`,
350
+ );
351
+ try {
352
+ recordInlineScriptRun({
353
+ id: crypto.randomUUID(),
354
+ agentId: agent.id,
355
+ source: source as string,
356
+ // Scrub args + result before persisting: the stored row is later served
357
+ // raw by GET /api/script-runs/{id} to the dashboard, so it needs the same
358
+ // redaction guarantees as the scrubbed run response below.
359
+ args: scrubObject(parsed.body.args ?? null),
360
+ scriptName: parsed.body.name,
361
+ status: ok ? "completed" : "failed",
362
+ output: scrubObject(output.result),
363
+ error: runError,
364
+ startedAt,
365
+ finishedAt: new Date().toISOString(),
366
+ });
367
+ } catch {
368
+ // swallow — the run already executed; persistence is observability only.
369
+ }
370
+
334
371
  json(
335
372
  res,
336
373
  scrubObject({
@@ -3,13 +3,18 @@ import { z } from "zod";
3
3
  import {
4
4
  createSkill,
5
5
  deleteSkill,
6
+ deleteSkillFile,
6
7
  getAgentSkills,
7
8
  getSkillById,
9
+ getSkillFile,
8
10
  installSkill,
11
+ listSkillFileManifest,
9
12
  listSkills,
10
13
  searchSkills,
11
14
  uninstallSkill,
12
15
  updateSkill,
16
+ upsertSkillFile,
17
+ upsertSkillFiles,
13
18
  } from "../be/db";
14
19
  import { parseSkillContent } from "../be/skill-parser";
15
20
  import { computeAgentSkillsSignature, syncSkillsToFilesystem } from "../be/skill-sync";
@@ -19,6 +24,24 @@ import { json, jsonError } from "./utils";
19
24
  const SYSTEM_DEFAULT_SKILL_LOCKED_MESSAGE =
20
25
  "This skill is system-managed and cannot be edited from the UI; it is re-seeded on each start. Fork it under a new name to customize.";
21
26
 
27
+ const skillFileBodySchema = z.object({
28
+ content: z.string(),
29
+ mimeType: z.string().optional(),
30
+ isBinary: z.boolean().optional(),
31
+ size: z.number().int().nonnegative().optional(),
32
+ });
33
+
34
+ const skillFileWithPathSchema = skillFileBodySchema.extend({
35
+ path: z.string().min(1),
36
+ });
37
+
38
+ function decodeSkillFilePath(pathSegments: string[]): string {
39
+ return pathSegments
40
+ .slice(4)
41
+ .map((segment) => decodeURIComponent(segment))
42
+ .join("/");
43
+ }
44
+
22
45
  // ─── Route Definitions ───────────────────────────────────────────────────────
23
46
 
24
47
  const listSkillsRoute = route({
@@ -58,6 +81,86 @@ const getSkillRoute = route({
58
81
  },
59
82
  });
60
83
 
84
+ const listSkillFilesRoute = route({
85
+ method: "get",
86
+ path: "/api/skills/{id}/files",
87
+ pattern: ["api", "skills", null, "files"],
88
+ summary: "List bundled files for a skill",
89
+ description: "Returns a manifest of bundled skill files without file content.",
90
+ tags: ["Skills"],
91
+ auth: { apiKey: true },
92
+ params: z.object({ id: z.string() }),
93
+ responses: {
94
+ 200: { description: "Skill file manifest" },
95
+ 404: { description: "Skill not found" },
96
+ },
97
+ });
98
+
99
+ const bulkUpsertSkillFilesRoute = route({
100
+ method: "post",
101
+ path: "/api/skills/{id}/files",
102
+ pattern: ["api", "skills", null, "files"],
103
+ summary: "Bulk upsert bundled files for a skill",
104
+ tags: ["Skills"],
105
+ auth: { apiKey: true },
106
+ params: z.object({ id: z.string() }),
107
+ body: z.object({
108
+ files: z.array(skillFileWithPathSchema).max(100),
109
+ }),
110
+ responses: {
111
+ 200: { description: "Skill files upserted" },
112
+ 400: { description: "Validation error" },
113
+ 404: { description: "Skill not found" },
114
+ },
115
+ });
116
+
117
+ const getSkillFileRoute = route({
118
+ method: "get",
119
+ path: "/api/skills/{id}/files/{path}",
120
+ pattern: ["api", "skills", null, "files", null],
121
+ exact: false,
122
+ summary: "Get a bundled skill file",
123
+ tags: ["Skills"],
124
+ auth: { apiKey: true },
125
+ params: z.object({ id: z.string(), path: z.string() }),
126
+ responses: {
127
+ 200: { description: "Skill file" },
128
+ 404: { description: "Skill or file not found" },
129
+ },
130
+ });
131
+
132
+ const upsertSkillFileRoute = route({
133
+ method: "put",
134
+ path: "/api/skills/{id}/files/{path}",
135
+ pattern: ["api", "skills", null, "files", null],
136
+ exact: false,
137
+ summary: "Upsert a bundled skill file",
138
+ tags: ["Skills"],
139
+ auth: { apiKey: true },
140
+ params: z.object({ id: z.string(), path: z.string() }),
141
+ body: skillFileBodySchema,
142
+ responses: {
143
+ 200: { description: "Skill file upserted" },
144
+ 400: { description: "Validation error" },
145
+ 404: { description: "Skill not found" },
146
+ },
147
+ });
148
+
149
+ const deleteSkillFileRoute = route({
150
+ method: "delete",
151
+ path: "/api/skills/{id}/files/{path}",
152
+ pattern: ["api", "skills", null, "files", null],
153
+ exact: false,
154
+ summary: "Delete a bundled skill file",
155
+ tags: ["Skills"],
156
+ auth: { apiKey: true },
157
+ params: z.object({ id: z.string(), path: z.string() }),
158
+ responses: {
159
+ 200: { description: "Skill file deleted" },
160
+ 404: { description: "Skill or file not found" },
161
+ },
162
+ });
163
+
61
164
  const createSkillRoute = route({
62
165
  method: "post",
63
166
  path: "/api/skills",
@@ -423,6 +526,128 @@ export async function handleSkills(
423
526
  return true;
424
527
  }
425
528
 
529
+ // GET /api/skills/:id/files
530
+ if (listSkillFilesRoute.match(req.method, pathSegments)) {
531
+ const parsed = await listSkillFilesRoute.parse(req, res, pathSegments, queryParams);
532
+ if (!parsed) return true;
533
+
534
+ const skill = getSkillById(parsed.params.id);
535
+ if (!skill) {
536
+ jsonError(res, "Skill not found", 404);
537
+ return true;
538
+ }
539
+
540
+ const files = listSkillFileManifest(parsed.params.id);
541
+ json(res, { files, total: files.length });
542
+ return true;
543
+ }
544
+
545
+ // POST /api/skills/:id/files
546
+ if (bulkUpsertSkillFilesRoute.match(req.method, pathSegments)) {
547
+ const parsed = await bulkUpsertSkillFilesRoute.parse(req, res, pathSegments, queryParams);
548
+ if (!parsed) return true;
549
+
550
+ const skill = getSkillById(parsed.params.id);
551
+ if (!skill) {
552
+ jsonError(res, "Skill not found", 404);
553
+ return true;
554
+ }
555
+ if (skill.systemDefault) {
556
+ jsonError(res, SYSTEM_DEFAULT_SKILL_LOCKED_MESSAGE, 403);
557
+ return true;
558
+ }
559
+
560
+ try {
561
+ const files = upsertSkillFiles(parsed.params.id, parsed.body.files);
562
+ const updatedSkill = getSkillById(parsed.params.id);
563
+ json(res, { files, total: files.length, skill: updatedSkill });
564
+ } catch (err) {
565
+ jsonError(res, err instanceof Error ? err.message : "Failed to upsert files", 400);
566
+ }
567
+ return true;
568
+ }
569
+
570
+ // GET /api/skills/:id/files/:path
571
+ if (getSkillFileRoute.match(req.method, pathSegments)) {
572
+ const parsed = await getSkillFileRoute.parse(req, res, pathSegments, queryParams);
573
+ if (!parsed) return true;
574
+
575
+ const skill = getSkillById(parsed.params.id);
576
+ if (!skill) {
577
+ jsonError(res, "Skill not found", 404);
578
+ return true;
579
+ }
580
+
581
+ try {
582
+ const file = getSkillFile(parsed.params.id, decodeSkillFilePath(pathSegments));
583
+ if (!file) {
584
+ jsonError(res, "Skill file not found", 404);
585
+ return true;
586
+ }
587
+ json(res, { file });
588
+ } catch (err) {
589
+ jsonError(res, err instanceof Error ? err.message : "Invalid file path", 400);
590
+ }
591
+ return true;
592
+ }
593
+
594
+ // PUT /api/skills/:id/files/:path
595
+ if (upsertSkillFileRoute.match(req.method, pathSegments)) {
596
+ const parsed = await upsertSkillFileRoute.parse(req, res, pathSegments, queryParams);
597
+ if (!parsed) return true;
598
+
599
+ const skill = getSkillById(parsed.params.id);
600
+ if (!skill) {
601
+ jsonError(res, "Skill not found", 404);
602
+ return true;
603
+ }
604
+ if (skill.systemDefault) {
605
+ jsonError(res, SYSTEM_DEFAULT_SKILL_LOCKED_MESSAGE, 403);
606
+ return true;
607
+ }
608
+
609
+ try {
610
+ const file = upsertSkillFile(parsed.params.id, {
611
+ path: decodeSkillFilePath(pathSegments),
612
+ ...parsed.body,
613
+ });
614
+ const updatedSkill = getSkillById(parsed.params.id);
615
+ json(res, { file, skill: updatedSkill });
616
+ } catch (err) {
617
+ jsonError(res, err instanceof Error ? err.message : "Failed to upsert file", 400);
618
+ }
619
+ return true;
620
+ }
621
+
622
+ // DELETE /api/skills/:id/files/:path
623
+ if (deleteSkillFileRoute.match(req.method, pathSegments)) {
624
+ const parsed = await deleteSkillFileRoute.parse(req, res, pathSegments, queryParams);
625
+ if (!parsed) return true;
626
+
627
+ const skill = getSkillById(parsed.params.id);
628
+ if (!skill) {
629
+ jsonError(res, "Skill not found", 404);
630
+ return true;
631
+ }
632
+ if (skill.systemDefault) {
633
+ jsonError(res, SYSTEM_DEFAULT_SKILL_LOCKED_MESSAGE, 403);
634
+ return true;
635
+ }
636
+
637
+ try {
638
+ const deleted = deleteSkillFile(parsed.params.id, decodeSkillFilePath(pathSegments));
639
+ if (!deleted) {
640
+ jsonError(res, "Skill file not found", 404);
641
+ return true;
642
+ }
643
+ const updatedSkill = getSkillById(parsed.params.id);
644
+ json(res, { success: true, skill: updatedSkill });
645
+ } catch (err) {
646
+ jsonError(res, err instanceof Error ? err.message : "Invalid file path", 400);
647
+ }
648
+ return true;
649
+ }
650
+
426
651
  // GET /api/skills/:id
427
652
  if (getSkillRoute.match(req.method, pathSegments)) {
428
653
  const parsed = await getSkillRoute.parse(req, res, pathSegments, queryParams);