stagent 0.5.0 → 0.6.1

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 (256) hide show
  1. package/README.md +8 -8
  2. package/dist/cli.js +146 -2
  3. package/docs/.coverage-gaps.json +21 -0
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +36 -14
  6. package/docs/features/chat.md +33 -56
  7. package/docs/features/cost-usage.md +14 -10
  8. package/docs/features/dashboard-kanban.md +30 -13
  9. package/docs/features/delivery-channels.md +198 -0
  10. package/docs/features/design-system.md +10 -10
  11. package/docs/features/documents.md +8 -8
  12. package/docs/features/home-workspace.md +20 -15
  13. package/docs/features/inbox-notifications.md +22 -10
  14. package/docs/features/keyboard-navigation.md +11 -11
  15. package/docs/features/monitoring.md +1 -1
  16. package/docs/features/playbook.md +30 -32
  17. package/docs/features/profiles.md +33 -11
  18. package/docs/features/projects.md +2 -2
  19. package/docs/features/provider-runtimes.md +58 -14
  20. package/docs/features/schedules.md +70 -40
  21. package/docs/features/settings.md +74 -46
  22. package/docs/features/shared-components.md +7 -15
  23. package/docs/features/tool-permissions.md +9 -9
  24. package/docs/features/workflows.md +32 -21
  25. package/docs/getting-started.md +33 -9
  26. package/docs/index.md +25 -16
  27. package/docs/journeys/developer.md +124 -207
  28. package/docs/journeys/personal-use.md +70 -79
  29. package/docs/journeys/power-user.md +107 -151
  30. package/docs/journeys/work-use.md +81 -113
  31. package/docs/manifest.json +77 -45
  32. package/docs/superpowers/plans/2026-03-30-finish-in-progress-features.md +547 -0
  33. package/docs/use-cases/agency-operator.md +84 -0
  34. package/docs/use-cases/solo-founder.md +75 -0
  35. package/docs/why-stagent.md +59 -0
  36. package/package.json +10 -3
  37. package/src/app/api/channels/[id]/route.ts +104 -0
  38. package/src/app/api/channels/[id]/test/route.ts +52 -0
  39. package/src/app/api/channels/inbound/slack/route.ts +116 -0
  40. package/src/app/api/channels/inbound/telegram/poll/route.ts +140 -0
  41. package/src/app/api/channels/inbound/telegram/route.ts +87 -0
  42. package/src/app/api/channels/route.ts +72 -0
  43. package/src/app/api/chat/conversations/route.ts +15 -0
  44. package/src/app/api/chat/entities/search/route.ts +46 -31
  45. package/src/app/api/data/clear/route.ts +4 -0
  46. package/src/app/api/data/seed/route.ts +4 -0
  47. package/src/app/api/documents/route.ts +36 -6
  48. package/src/app/api/environment/profiles/suggest/route.ts +19 -3
  49. package/src/app/api/environment/scan/route.ts +8 -1
  50. package/src/app/api/handoffs/[id]/route.ts +76 -0
  51. package/src/app/api/handoffs/route.ts +89 -0
  52. package/src/app/api/memory/route.ts +181 -0
  53. package/src/app/api/profiles/[id]/route.ts +16 -1
  54. package/src/app/api/profiles/[id]/test/route.ts +4 -0
  55. package/src/app/api/profiles/[id]/test-results/route.ts +22 -0
  56. package/src/app/api/profiles/[id]/test-single/route.ts +64 -0
  57. package/src/app/api/profiles/assist/route.ts +35 -0
  58. package/src/app/api/profiles/import-repo/apply-updates/route.ts +123 -0
  59. package/src/app/api/profiles/import-repo/check-updates/route.ts +163 -0
  60. package/src/app/api/profiles/import-repo/confirm/route.ts +118 -0
  61. package/src/app/api/profiles/import-repo/preview/route.ts +107 -0
  62. package/src/app/api/profiles/import-repo/route.ts +29 -0
  63. package/src/app/api/profiles/import-repo/scan/route.ts +25 -0
  64. package/src/app/api/profiles/route.ts +73 -22
  65. package/src/app/api/runtimes/ollama/route.ts +86 -0
  66. package/src/app/api/runtimes/suggest/route.ts +29 -0
  67. package/src/app/api/schedules/[id]/heartbeat-history/route.ts +77 -0
  68. package/src/app/api/schedules/[id]/route.ts +41 -3
  69. package/src/app/api/schedules/parse/route.ts +66 -0
  70. package/src/app/api/schedules/route.ts +71 -12
  71. package/src/app/api/settings/author-default/route.ts +7 -0
  72. package/src/app/api/settings/learning/route.ts +41 -0
  73. package/src/app/api/settings/ollama/route.ts +34 -0
  74. package/src/app/api/settings/providers/route.ts +57 -0
  75. package/src/app/api/settings/routing/route.ts +24 -0
  76. package/src/app/api/settings/web-search/route.ts +28 -0
  77. package/src/app/api/tasks/[id]/execute/route.ts +13 -1
  78. package/src/app/api/tasks/[id]/respond/route.ts +23 -1
  79. package/src/app/documents/page.tsx +3 -0
  80. package/src/app/environment/page.tsx +8 -1
  81. package/src/app/settings/page.tsx +10 -4
  82. package/src/app/workflows/[id]/edit/page.tsx +2 -0
  83. package/src/app/workflows/new/page.tsx +2 -0
  84. package/src/components/chat/chat-command-popover.tsx +22 -19
  85. package/src/components/chat/chat-input.tsx +5 -0
  86. package/src/components/chat/chat-model-selector.tsx +42 -1
  87. package/src/components/chat/chat-shell.tsx +2 -0
  88. package/src/components/dashboard/welcome-landing.tsx +9 -9
  89. package/src/components/environment/artifact-card.tsx +27 -1
  90. package/src/components/environment/environment-dashboard.tsx +50 -2
  91. package/src/components/environment/environment-summary-card.tsx +5 -2
  92. package/src/components/environment/suggested-profiles.tsx +117 -52
  93. package/src/components/handoffs/handoff-approval-card.tsx +159 -0
  94. package/src/components/memory/memory-browser.tsx +315 -0
  95. package/src/components/profiles/learned-context-panel.tsx +4 -4
  96. package/src/components/profiles/profile-assist-panel.tsx +512 -0
  97. package/src/components/profiles/profile-browser.tsx +109 -8
  98. package/src/components/profiles/profile-card.tsx +29 -1
  99. package/src/components/profiles/profile-detail-view.tsx +200 -28
  100. package/src/components/profiles/profile-form-view.tsx +220 -82
  101. package/src/components/profiles/repo-import-wizard.tsx +648 -0
  102. package/src/components/profiles/smoke-test-editor.tsx +106 -0
  103. package/src/components/schedules/schedule-create-sheet.tsx +9 -1
  104. package/src/components/schedules/schedule-form.tsx +348 -9
  105. package/src/components/schedules/schedule-list.tsx +15 -2
  106. package/src/components/settings/auth-method-selector.tsx +7 -1
  107. package/src/components/settings/budget-guardrails-section.tsx +111 -48
  108. package/src/components/settings/channels-section.tsx +526 -0
  109. package/src/components/settings/chat-settings-section.tsx +27 -1
  110. package/src/components/settings/data-management-section.tsx +8 -6
  111. package/src/components/settings/learning-context-section.tsx +124 -0
  112. package/src/components/settings/ollama-section.tsx +270 -0
  113. package/src/components/settings/providers-runtimes-section.tsx +499 -0
  114. package/src/components/settings/web-search-section.tsx +101 -0
  115. package/src/components/shared/tag-input.tsx +156 -0
  116. package/src/components/tasks/kanban-board.tsx +32 -0
  117. package/src/components/tasks/kanban-column.tsx +4 -2
  118. package/src/components/tasks/task-card.tsx +1 -0
  119. package/src/components/tasks/task-chip-bar.tsx +6 -1
  120. package/src/components/tasks/task-create-panel.tsx +55 -5
  121. package/src/components/workflows/workflow-form-view.tsx +38 -3
  122. package/src/hooks/use-chat-autocomplete.ts +24 -26
  123. package/src/hooks/use-project-skills.ts +66 -0
  124. package/src/hooks/use-tag-suggestions.ts +31 -0
  125. package/src/instrumentation.ts +4 -1
  126. package/src/lib/agents/__tests__/claude-agent.test.ts +3 -0
  127. package/src/lib/agents/__tests__/learned-context.test.ts +10 -0
  128. package/src/lib/agents/agentic-loop.ts +235 -0
  129. package/src/lib/agents/browser-mcp.ts +59 -4
  130. package/src/lib/agents/claude-agent.ts +27 -200
  131. package/src/lib/agents/handoff/bus.ts +164 -0
  132. package/src/lib/agents/handoff/governance.ts +47 -0
  133. package/src/lib/agents/handoff/types.ts +16 -0
  134. package/src/lib/agents/learned-context.ts +27 -7
  135. package/src/lib/agents/memory/decay.ts +61 -0
  136. package/src/lib/agents/memory/extractor.ts +181 -0
  137. package/src/lib/agents/memory/retrieval.ts +96 -0
  138. package/src/lib/agents/memory/types.ts +6 -0
  139. package/src/lib/agents/profiles/__tests__/project-profiles.test.ts +119 -0
  140. package/src/lib/agents/profiles/__tests__/registry.test.ts +11 -3
  141. package/src/lib/agents/profiles/builtins/code-reviewer/profile.yaml +2 -2
  142. package/src/lib/agents/profiles/builtins/content-creator/SKILL.md +19 -0
  143. package/src/lib/agents/profiles/builtins/content-creator/profile.yaml +27 -0
  144. package/src/lib/agents/profiles/builtins/customer-support-agent/SKILL.md +19 -0
  145. package/src/lib/agents/profiles/builtins/customer-support-agent/profile.yaml +26 -0
  146. package/src/lib/agents/profiles/builtins/data-analyst/profile.yaml +2 -2
  147. package/src/lib/agents/profiles/builtins/devops-engineer/profile.yaml +2 -2
  148. package/src/lib/agents/profiles/builtins/document-writer/profile.yaml +2 -2
  149. package/src/lib/agents/profiles/builtins/financial-analyst/SKILL.md +19 -0
  150. package/src/lib/agents/profiles/builtins/financial-analyst/profile.yaml +24 -0
  151. package/src/lib/agents/profiles/builtins/general/profile.yaml +2 -2
  152. package/src/lib/agents/profiles/builtins/health-fitness-coach/profile.yaml +2 -2
  153. package/src/lib/agents/profiles/builtins/learning-coach/profile.yaml +2 -2
  154. package/src/lib/agents/profiles/builtins/marketing-strategist/SKILL.md +19 -0
  155. package/src/lib/agents/profiles/builtins/marketing-strategist/profile.yaml +27 -0
  156. package/src/lib/agents/profiles/builtins/operations-coordinator/SKILL.md +19 -0
  157. package/src/lib/agents/profiles/builtins/operations-coordinator/profile.yaml +26 -0
  158. package/src/lib/agents/profiles/builtins/project-manager/profile.yaml +2 -2
  159. package/src/lib/agents/profiles/builtins/researcher/SKILL.md +1 -0
  160. package/src/lib/agents/profiles/builtins/researcher/profile.yaml +2 -2
  161. package/src/lib/agents/profiles/builtins/sales-researcher/SKILL.md +19 -0
  162. package/src/lib/agents/profiles/builtins/sales-researcher/profile.yaml +26 -0
  163. package/src/lib/agents/profiles/builtins/shopping-assistant/SKILL.md +1 -0
  164. package/src/lib/agents/profiles/builtins/shopping-assistant/profile.yaml +2 -2
  165. package/src/lib/agents/profiles/builtins/sweep/profile.yaml +1 -1
  166. package/src/lib/agents/profiles/builtins/technical-writer/profile.yaml +2 -2
  167. package/src/lib/agents/profiles/builtins/travel-planner/SKILL.md +2 -0
  168. package/src/lib/agents/profiles/builtins/travel-planner/profile.yaml +2 -2
  169. package/src/lib/agents/profiles/builtins/wealth-manager/SKILL.md +2 -0
  170. package/src/lib/agents/profiles/builtins/wealth-manager/profile.yaml +2 -2
  171. package/src/lib/agents/profiles/project-profiles.ts +193 -0
  172. package/src/lib/agents/profiles/registry.ts +130 -6
  173. package/src/lib/agents/profiles/types.ts +28 -0
  174. package/src/lib/agents/router.ts +174 -2
  175. package/src/lib/agents/runtime/__tests__/catalog.test.ts +15 -4
  176. package/src/lib/agents/runtime/anthropic-direct.ts +644 -0
  177. package/src/lib/agents/runtime/catalog.ts +57 -2
  178. package/src/lib/agents/runtime/claude.ts +205 -1
  179. package/src/lib/agents/runtime/index.ts +22 -0
  180. package/src/lib/agents/runtime/ollama-adapter.ts +409 -0
  181. package/src/lib/agents/runtime/openai-direct.ts +514 -0
  182. package/src/lib/agents/runtime/profile-assist-types.ts +30 -0
  183. package/src/lib/agents/runtime/types.ts +2 -0
  184. package/src/lib/agents/tool-permissions.ts +203 -0
  185. package/src/lib/channels/gateway.ts +321 -0
  186. package/src/lib/channels/poller.ts +268 -0
  187. package/src/lib/channels/registry.ts +90 -0
  188. package/src/lib/channels/slack-adapter.ts +188 -0
  189. package/src/lib/channels/telegram-adapter.ts +218 -0
  190. package/src/lib/channels/types.ts +75 -0
  191. package/src/lib/channels/webhook-adapter.ts +74 -0
  192. package/src/lib/chat/context-builder.ts +22 -2
  193. package/src/lib/chat/engine.ts +95 -13
  194. package/src/lib/chat/ollama-engine.ts +198 -0
  195. package/src/lib/chat/stagent-tools.ts +106 -20
  196. package/src/lib/chat/tool-catalog.ts +24 -0
  197. package/src/lib/chat/tool-registry.ts +90 -0
  198. package/src/lib/chat/tools/chat-history-tools.ts +4 -4
  199. package/src/lib/chat/tools/document-tools.ts +7 -7
  200. package/src/lib/chat/tools/handoff-tools.ts +70 -0
  201. package/src/lib/chat/tools/notification-tools.ts +4 -4
  202. package/src/lib/chat/tools/profile-tools.ts +3 -3
  203. package/src/lib/chat/tools/project-tools.ts +3 -3
  204. package/src/lib/chat/tools/schedule-tools.ts +29 -13
  205. package/src/lib/chat/tools/settings-tools.ts +2 -2
  206. package/src/lib/chat/tools/task-tools.ts +66 -11
  207. package/src/lib/chat/tools/usage-tools.ts +2 -2
  208. package/src/lib/chat/tools/workflow-tools.ts +8 -8
  209. package/src/lib/chat/types.ts +11 -5
  210. package/src/lib/constants/known-tools.ts +19 -0
  211. package/src/lib/constants/prose-styles.ts +1 -1
  212. package/src/lib/constants/settings.ts +7 -0
  213. package/src/lib/data/channel-bindings.ts +85 -0
  214. package/src/lib/data/clear.ts +22 -0
  215. package/src/lib/data/profile-test-results.ts +48 -0
  216. package/src/lib/data/seed-data/conversations.ts +196 -0
  217. package/src/lib/data/seed-data/learned-context.ts +99 -0
  218. package/src/lib/data/seed-data/notifications.ts +54 -1
  219. package/src/lib/data/seed-data/profile-test-results.ts +96 -0
  220. package/src/lib/data/seed-data/repo-imports.ts +51 -0
  221. package/src/lib/data/seed-data/views.ts +60 -0
  222. package/src/lib/data/seed.ts +51 -0
  223. package/src/lib/db/bootstrap.ts +162 -0
  224. package/src/lib/db/migrations/0013_add_repo_imports.sql +15 -0
  225. package/src/lib/db/migrations/0014_add_linked_profile_id.sql +3 -0
  226. package/src/lib/db/migrations/0015_add_channel_bindings.sql +23 -0
  227. package/src/lib/db/schema.ts +190 -1
  228. package/src/lib/environment/__tests__/auto-scan.test.ts +86 -0
  229. package/src/lib/environment/__tests__/profile-linker.test.ts +187 -0
  230. package/src/lib/environment/auto-scan.ts +48 -0
  231. package/src/lib/environment/data.ts +25 -0
  232. package/src/lib/environment/profile-generator.ts +40 -10
  233. package/src/lib/environment/profile-linker.ts +143 -0
  234. package/src/lib/environment/profile-rules.ts +96 -0
  235. package/src/lib/import/dedup.ts +149 -0
  236. package/src/lib/import/format-adapter.ts +631 -0
  237. package/src/lib/import/github-api.ts +219 -0
  238. package/src/lib/import/repo-scanner.ts +251 -0
  239. package/src/lib/schedules/__tests__/nlp-parser.test.ts +330 -0
  240. package/src/lib/schedules/active-hours.ts +120 -0
  241. package/src/lib/schedules/heartbeat-parser.ts +224 -0
  242. package/src/lib/schedules/heartbeat-prompt.ts +153 -0
  243. package/src/lib/schedules/nlp-parser.ts +357 -0
  244. package/src/lib/schedules/scheduler.ts +218 -3
  245. package/src/lib/settings/__tests__/budget-guardrails.test.ts +39 -1
  246. package/src/lib/settings/helpers.ts +6 -0
  247. package/src/lib/settings/routing.ts +24 -0
  248. package/src/lib/settings/runtime-setup.ts +28 -1
  249. package/src/lib/usage/ledger.ts +2 -1
  250. package/src/lib/validators/__tests__/settings.test.ts +9 -0
  251. package/src/lib/validators/profile.ts +39 -0
  252. package/src/lib/workflows/blueprints/builtins/business-daily-briefing.yaml +102 -0
  253. package/src/lib/workflows/blueprints/builtins/content-marketing-pipeline.yaml +90 -0
  254. package/src/lib/workflows/blueprints/builtins/customer-support-triage.yaml +107 -0
  255. package/src/lib/workflows/blueprints/builtins/financial-reporting.yaml +104 -0
  256. package/src/lib/workflows/blueprints/builtins/lead-research-pipeline.yaml +82 -0
@@ -0,0 +1,631 @@
1
+ /**
2
+ * Converts non-Stagent skill formats into valid ProfileConfig + SKILL.md pairs.
3
+ * Handles gstack-style SKILL.md-with-frontmatter → profile.yaml generation.
4
+ */
5
+
6
+ import { createHash } from "node:crypto";
7
+ import yaml from "js-yaml";
8
+ import { ProfileConfigSchema, type ProfileConfig } from "@/lib/validators/profile";
9
+ import type { ImportMeta } from "@/lib/validators/profile";
10
+ import type { DiscoveredSkill } from "./repo-scanner";
11
+
12
+ export interface AdaptedProfile {
13
+ config: ProfileConfig;
14
+ skillMd: string;
15
+ importMeta: ImportMeta;
16
+ }
17
+
18
+ interface RepoMeta {
19
+ repoUrl: string;
20
+ owner: string;
21
+ repo: string;
22
+ branch: string;
23
+ commitSha: string;
24
+ }
25
+
26
+ /** Extra context from README files used to enrich profile metadata. */
27
+ export interface ReadmeContext {
28
+ /** Per-skill README.md (if the skill directory has one) */
29
+ skillReadme: string | null;
30
+ /** Repo-level README.md */
31
+ repoReadme: string;
32
+ }
33
+
34
+ /** Slugify a name to a valid profile ID. */
35
+ function slugify(name: string): string {
36
+ return name
37
+ .toLowerCase()
38
+ .replace(/[^a-z0-9-]/g, "-")
39
+ .replace(/-+/g, "-")
40
+ .replace(/^-|-$/g, "")
41
+ .slice(0, 64);
42
+ }
43
+
44
+ /** Infer domain from description/name keywords. */
45
+ function inferDomain(description: string, name: string): "work" | "personal" {
46
+ const text = `${description} ${name}`.toLowerCase();
47
+ const personalKeywords = [
48
+ "personal", "health", "fitness", "travel", "shopping",
49
+ "nutrition", "workout", "hobby", "recipe", "meditation",
50
+ ];
51
+ return personalKeywords.some((kw) => text.includes(kw)) ? "personal" : "work";
52
+ }
53
+
54
+ /** Common stop words to exclude from tag extraction. */
55
+ const TAG_STOP_WORDS = new Set([
56
+ "the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
57
+ "was", "one", "our", "out", "has", "have", "that", "this", "with",
58
+ "from", "they", "been", "will", "each", "make", "like", "into", "them",
59
+ "some", "when", "what", "your", "should", "would", "could", "about",
60
+ "which", "their", "other", "than", "then", "more", "also", "only",
61
+ "must", "does", "here", "just", "over", "such", "after", "before",
62
+ "using", "ensure", "every", "following", "include", "note", "step",
63
+ "always", "never", "check", "first", "output", "input", "file",
64
+ "true", "false", "null", "undefined", "return", "function", "class",
65
+ ]);
66
+
67
+ /**
68
+ * Extract a rich description from SKILL.md body content + optional README.
69
+ *
70
+ * Priority:
71
+ * 1. Frontmatter `description` (if multi-word and meaningful)
72
+ * 2. Per-skill README.md first paragraph
73
+ * 3. First meaningful paragraph from SKILL.md body (after frontmatter)
74
+ * 4. Fallback to frontmatter description or name
75
+ */
76
+ function extractDescription(
77
+ frontmatter: Record<string, string>,
78
+ skillMdBody: string,
79
+ readmeCtx: ReadmeContext | null,
80
+ skillName: string,
81
+ repoReadmeSkillDesc: string | null,
82
+ ): string {
83
+ // 1. If frontmatter description is already rich (> 20 chars), use it
84
+ const fmDesc = frontmatter.description ?? "";
85
+ if (fmDesc.length > 20) return fmDesc;
86
+
87
+ // 2. Try per-skill README first paragraph
88
+ if (readmeCtx?.skillReadme) {
89
+ const para = extractFirstParagraph(readmeCtx.skillReadme);
90
+ if (para) return para;
91
+ }
92
+
93
+ // 3. Try description extracted from repo README (skill-specific section)
94
+ if (repoReadmeSkillDesc) return repoReadmeSkillDesc;
95
+
96
+ // 4. Extract from SKILL.md body — first non-heading, non-empty paragraph
97
+ const bodyPara = extractFirstParagraph(skillMdBody);
98
+ if (bodyPara) return bodyPara;
99
+
100
+ // 5. Fallback
101
+ return fmDesc || skillName;
102
+ }
103
+
104
+ /**
105
+ * Patterns that indicate a paragraph is AI instruction text, not a human-readable description.
106
+ * These are common across skill repos (not specific to any one repo).
107
+ */
108
+ const INSTRUCTION_PATTERNS = [
109
+ /^you are\b/i,
110
+ /^you must\b/i,
111
+ /^you should\b/i,
112
+ /^you will\b/i,
113
+ /^your (?:role|job|task|goal)\b/i,
114
+ /^run the\b/i,
115
+ /^execute\b/i,
116
+ /^always\b/i,
117
+ /^never\b/i,
118
+ /^when the user\b/i,
119
+ /^when asked\b/i,
120
+ /^this skill\b/i,
121
+ /^this tool\b/i,
122
+ /^this agent\b/i,
123
+ /^if the user\b/i,
124
+ /^before (?:you|running|starting)\b/i,
125
+ /^after (?:you|running|completing)\b/i,
126
+ /^do not\b/i,
127
+ /^don't\b/i,
128
+ /^make sure\b/i,
129
+ /^important:/i,
130
+ /^note:/i,
131
+ /^⚠/,
132
+ /^warning/i,
133
+ /^todo/i,
134
+ ];
135
+
136
+ /** Check if a paragraph looks like AI skill instructions rather than a description. */
137
+ function isInstructionText(text: string): boolean {
138
+ // Check against known instruction patterns
139
+ if (INSTRUCTION_PATTERNS.some((p) => p.test(text))) return true;
140
+
141
+ // Heavy use of second-person "you" suggests instruction text
142
+ const youCount = (text.match(/\byou\b/gi) ?? []).length;
143
+ const wordCount = text.split(/\s+/).length;
144
+ if (youCount >= 3 || (wordCount > 0 && youCount / wordCount > 0.08)) return true;
145
+
146
+ // References to tool names suggest internal skill instructions
147
+ const toolRefs = /\b(Bash|Read|Write|Edit|Glob|Grep|WebFetch|WebSearch|Agent|AskUserQuestion)\b/;
148
+ if (toolRefs.test(text)) return true;
149
+
150
+ return false;
151
+ }
152
+
153
+ /**
154
+ * Extract the first meaningful, non-instruction paragraph from markdown.
155
+ * Skips headings, lists, code blocks, HTML comments, and AI instruction text.
156
+ */
157
+ function extractFirstParagraph(md: string): string | null {
158
+ const lines = md.split("\n");
159
+ let inCodeBlock = false;
160
+ let inComment = false;
161
+
162
+ // Collect candidate paragraphs, return the first non-instruction one
163
+ let paraLines: string[] = [];
164
+
165
+ for (const line of lines) {
166
+ if (line.startsWith("```")) {
167
+ inCodeBlock = !inCodeBlock;
168
+ if (paraLines.length > 0) { paraLines = []; } // discard partial
169
+ continue;
170
+ }
171
+ if (inCodeBlock) continue;
172
+
173
+ const trimmed = line.trim();
174
+
175
+ // Skip HTML comments (single-line and multi-line)
176
+ if (trimmed.startsWith("<!--")) {
177
+ if (!trimmed.includes("-->")) inComment = true;
178
+ if (paraLines.length > 0) { paraLines = []; }
179
+ continue;
180
+ }
181
+ if (inComment) {
182
+ if (trimmed.includes("-->")) inComment = false;
183
+ continue;
184
+ }
185
+
186
+ // Skip headings, HR, HTML tags
187
+ if (trimmed.startsWith("#") || trimmed.startsWith("---") || trimmed.startsWith("<")) {
188
+ if (paraLines.length > 0) {
189
+ // Try this paragraph
190
+ const candidate = paraLines.join(" ").trim();
191
+ if (isGoodDescription(candidate)) return formatDescription(candidate);
192
+ paraLines = [];
193
+ }
194
+ continue;
195
+ }
196
+ // Skip list items and blockquotes
197
+ if (trimmed.startsWith("- ") || trimmed.startsWith("* ") || trimmed.startsWith("> ") || /^\d+\.\s/.test(trimmed)) {
198
+ if (paraLines.length > 0) {
199
+ const candidate = paraLines.join(" ").trim();
200
+ if (isGoodDescription(candidate)) return formatDescription(candidate);
201
+ paraLines = [];
202
+ }
203
+ continue;
204
+ }
205
+
206
+ if (trimmed === "") {
207
+ if (paraLines.length > 0) {
208
+ const candidate = paraLines.join(" ").trim();
209
+ if (isGoodDescription(candidate)) return formatDescription(candidate);
210
+ paraLines = [];
211
+ }
212
+ continue;
213
+ }
214
+
215
+ paraLines.push(trimmed);
216
+ }
217
+
218
+ // Check final paragraph
219
+ if (paraLines.length > 0) {
220
+ const candidate = paraLines.join(" ").trim();
221
+ if (isGoodDescription(candidate)) return formatDescription(candidate);
222
+ }
223
+
224
+ return null;
225
+ }
226
+
227
+ /** Check if text is a good human-readable description (not instruction text). */
228
+ function isGoodDescription(text: string): boolean {
229
+ if (text.length < 15) return false;
230
+ if (text.startsWith("```") || text.startsWith("$")) return false;
231
+ if (isInstructionText(text)) return false;
232
+ return true;
233
+ }
234
+
235
+ /** Trim description to max length. */
236
+ function formatDescription(text: string): string {
237
+ return text.length > 200 ? text.slice(0, 197) + "..." : text;
238
+ }
239
+
240
+ /** Strip markdown inline formatting: **bold**, *italic*, `code`, [links](url) */
241
+ function stripMarkdownFormatting(text: string): string {
242
+ return text
243
+ .replace(/\*\*(.+?)\*\*/g, "$1") // **bold**
244
+ .replace(/\*(.+?)\*/g, "$1") // *italic*
245
+ .replace(/`(.+?)`/g, "$1") // `code`
246
+ .replace(/\[(.+?)\]\(.+?\)/g, "$1") // [text](url)
247
+ .trim();
248
+ }
249
+
250
+ /**
251
+ * Search the repo README for a section or table row that describes a specific skill.
252
+ * Generalized for any repo format:
253
+ * - N-column tables: `| /name | role | description |` or `| name | description |`
254
+ * - List items: `- **name**: description` or `- \`name\` — description`
255
+ * - Headings: `### name\n description paragraph`
256
+ */
257
+ function findSkillInRepoReadme(repoReadme: string, skillName: string): string | null {
258
+ if (!repoReadme) return null;
259
+
260
+ const nameL = skillName.toLowerCase();
261
+ const namePat = skillName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
262
+
263
+ // Strategy 1: Table rows — find any row where a cell matches the skill name,
264
+ // then take the LAST non-name cell as the description (works for 2, 3, or N columns).
265
+ const tableLines = repoReadme.split("\n").filter((l) => l.trim().startsWith("|"));
266
+ for (const line of tableLines) {
267
+ // Skip separator rows (| --- | --- |)
268
+ if (/^\|[\s-:|]+\|$/.test(line.trim())) continue;
269
+
270
+ const cells = line
271
+ .split("|")
272
+ .map((c) => c.trim())
273
+ .filter((c) => c.length > 0);
274
+
275
+ // Check if any cell contains the skill name (with or without / prefix)
276
+ const nameCell = cells.findIndex((c) => {
277
+ const stripped = stripMarkdownFormatting(c).replace(/^\//, "").toLowerCase();
278
+ return stripped === nameL || stripped === namePat.toLowerCase();
279
+ });
280
+
281
+ if (nameCell >= 0 && cells.length > nameCell + 1) {
282
+ // Take the last cell as description (skip the name cell and any role/middle cells)
283
+ const descCell = stripMarkdownFormatting(cells[cells.length - 1]);
284
+ // But if the last cell IS the name cell, try the one before it
285
+ const desc = nameCell === cells.length - 1
286
+ ? stripMarkdownFormatting(cells[Math.max(0, cells.length - 2)])
287
+ : descCell;
288
+
289
+ if (desc.length > 10 && desc.toLowerCase() !== nameL) {
290
+ return formatDescription(desc);
291
+ }
292
+ }
293
+ }
294
+
295
+ // Strategy 2: List items — `- **name** — desc` or `- \`name\`: desc` etc.
296
+ const backtick = "`";
297
+ const listPatterns = [
298
+ // - **name** — description or - **name**: description
299
+ new RegExp("[-*]\\s+\\*\\*/?\\s*" + namePat + "\\s*\\*\\*\\s*[—:\\-–|]\\s*(.+)", "i"),
300
+ // - `name` — description or - `/name`: description
301
+ new RegExp("[-*]\\s+" + backtick + "/?" + namePat + backtick + "\\s*[—:\\-–|]\\s*(.+)", "i"),
302
+ // - name: description (plain)
303
+ new RegExp("[-*]\\s+/?" + namePat + "\\s*[—:\\-–]\\s*(.+)", "i"),
304
+ ];
305
+
306
+ for (const pattern of listPatterns) {
307
+ const match = repoReadme.match(pattern);
308
+ if (match) {
309
+ const desc = stripMarkdownFormatting(match[1].trim());
310
+ if (desc.length > 10) return formatDescription(desc);
311
+ }
312
+ }
313
+
314
+ // Strategy 3: Section heading containing the skill name, then next paragraph
315
+ const lines = repoReadme.split("\n");
316
+ for (let i = 0; i < lines.length; i++) {
317
+ const line = lines[i].trim();
318
+ if (/^#{1,4}\s/.test(line)) {
319
+ const headingText = line.replace(/^#+\s+/, "").toLowerCase();
320
+ // Check if heading matches skill name (exact word, not substring of a longer word)
321
+ const headingWords = headingText.split(/[\s/]+/);
322
+ if (!headingWords.includes(nameL) && !headingText.includes(`/${nameL}`)) continue;
323
+
324
+ // Grab the next non-empty, non-heading line as description
325
+ for (let j = i + 1; j < Math.min(i + 5, lines.length); j++) {
326
+ const next = lines[j].trim();
327
+ if (!next || next.startsWith("#") || next.startsWith("---") || next.startsWith("|")) continue;
328
+ if (next.length > 15) {
329
+ return formatDescription(stripMarkdownFormatting(next));
330
+ }
331
+ }
332
+ }
333
+ }
334
+
335
+ return null;
336
+ }
337
+
338
+ /**
339
+ * Extract semantic tags from SKILL.md body content + README context.
340
+ * Looks at headings, role keywords, domain terms, and tool names.
341
+ */
342
+ function extractTags(
343
+ frontmatter: Record<string, string>,
344
+ skillMdBody: string,
345
+ dirName: string,
346
+ readmeCtx: ReadmeContext | null,
347
+ ): string[] {
348
+ const tags = new Set<string>();
349
+ const text = skillMdBody.toLowerCase();
350
+
351
+ // Directory name is always a tag
352
+ if (dirName && dirName.length > 1) tags.add(dirName.toLowerCase());
353
+
354
+ // Role/persona keywords found in content
355
+ const rolePatterns: Array<[RegExp, string]> = [
356
+ [/\bcode review/i, "code-review"],
357
+ [/\bsecurity\b/i, "security"],
358
+ [/\bqa\b|\bquality assurance/i, "qa"],
359
+ [/\btesting\b|\btest suite/i, "testing"],
360
+ [/\bdesign\b|\bui\/ux\b|\bfrontend design/i, "design"],
361
+ [/\bdeployment\b|\bci\/cd\b|\binfrastructure/i, "devops"],
362
+ [/\bship\b|\brelease\b|\bchangelog/i, "shipping"],
363
+ [/\bresearch\b|\binvestigat/i, "research"],
364
+ [/\bplanning\b|\barchitect/i, "planning"],
365
+ [/\brefactor/i, "refactoring"],
366
+ [/\bperformance\b|\bbenchmark/i, "performance"],
367
+ [/\baccessibility\b|\ba11y/i, "accessibility"],
368
+ [/\bdocument/i, "documentation"],
369
+ [/\bapi\b/i, "api"],
370
+ [/\bdatabase\b|\bsql\b/i, "database"],
371
+ [/\bbrowser\b|\bplaywright\b|\bpuppeteer/i, "browser"],
372
+ [/\bautomation\b/i, "automation"],
373
+ [/\bworkflow/i, "workflow"],
374
+ [/\blint/i, "linting"],
375
+ [/\bmigrat/i, "migration"],
376
+ [/\bmonitor/i, "monitoring"],
377
+ [/\bdebug/i, "debugging"],
378
+ [/\bowasp\b|\bvulnerabilit/i, "security"],
379
+ [/\bprompt\b|\bllm\b|\bai\b/i, "ai"],
380
+ ];
381
+
382
+ for (const [pattern, tag] of rolePatterns) {
383
+ if (pattern.test(text)) tags.add(tag);
384
+ }
385
+
386
+ // Extract heading-level topics (## headings become tags)
387
+ const headings = skillMdBody.match(/^#{2,3}\s+(.+)$/gm) ?? [];
388
+ for (const h of headings.slice(0, 5)) {
389
+ const topic = h.replace(/^#+\s+/, "").trim().toLowerCase();
390
+ // Only use short, meaningful heading words as tags
391
+ const words = topic.split(/\s+/).filter(
392
+ (w) => w.length > 3 && w.length < 20 && !TAG_STOP_WORDS.has(w)
393
+ );
394
+ for (const w of words.slice(0, 2)) tags.add(w);
395
+ }
396
+
397
+ // Parse allowed-tools from frontmatter (but as capability tags, not raw tool names)
398
+ const tools = frontmatter["allowed-tools"];
399
+ if (tools) {
400
+ const toolList = tools
401
+ .split(/[,\n]/)
402
+ .map((t) => t.replace(/^-\s*/, "").trim().toLowerCase())
403
+ .filter(Boolean);
404
+ if (toolList.includes("bash")) tags.add("cli");
405
+ if (toolList.includes("webfetch") || toolList.includes("websearch")) tags.add("web");
406
+ if (toolList.includes("agent")) tags.add("orchestration");
407
+ }
408
+
409
+ // Look for keywords in README context
410
+ if (readmeCtx?.skillReadme) {
411
+ const readmeText = readmeCtx.skillReadme.toLowerCase();
412
+ for (const [pattern, tag] of rolePatterns) {
413
+ if (pattern.test(readmeText)) tags.add(tag);
414
+ }
415
+ }
416
+
417
+ return Array.from(tags).slice(0, 12);
418
+ }
419
+
420
+ /** Strip YAML frontmatter from markdown, returning just the body. */
421
+ function stripFrontmatter(md: string): string {
422
+ return md.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, "").trim();
423
+ }
424
+
425
+ /** Parse allowed-tools from frontmatter (comma-separated, YAML array, or newline-separated). */
426
+ function parseAllowedTools(frontmatter: Record<string, string>): string[] | undefined {
427
+ const raw = frontmatter["allowed-tools"];
428
+ if (!raw) return undefined;
429
+
430
+ const tools = raw
431
+ .split(/[,\n]/)
432
+ .map((t) => t.replace(/^-\s*/, "").trim())
433
+ .filter(Boolean);
434
+
435
+ return tools.length > 0 ? tools : undefined;
436
+ }
437
+
438
+ /** Compute SHA-256 content hash. */
439
+ export function contentHash(content: string): string {
440
+ return createHash("sha256").update(content).digest("hex");
441
+ }
442
+
443
+ /**
444
+ * Adapt a SKILL.md-only format (e.g., gstack) into a Stagent ProfileConfig.
445
+ * Reads SKILL.md body + README context for rich descriptions and tags.
446
+ */
447
+ export function adaptSkillMdOnly(
448
+ skill: DiscoveredSkill,
449
+ skillMd: string,
450
+ repoMeta: RepoMeta,
451
+ readmeCtx: ReadmeContext | null = null,
452
+ ): AdaptedProfile {
453
+ const fm = skill.frontmatter;
454
+ const dirName = skill.path.split("/").pop() ?? skill.name;
455
+ const id = slugify(fm.name ?? dirName);
456
+ const name = fm.name ?? dirName;
457
+ const body = stripFrontmatter(skillMd);
458
+
459
+ // Extract rich description from content + README
460
+ const repoReadmeSkillDesc = readmeCtx
461
+ ? findSkillInRepoReadme(readmeCtx.repoReadme, name)
462
+ : null;
463
+ const description = extractDescription(fm, body, readmeCtx, name, repoReadmeSkillDesc);
464
+
465
+ // Extract semantic tags from content
466
+ const tags = extractTags(fm, body, dirName, readmeCtx);
467
+
468
+ // Inject description into SKILL.md frontmatter so registry picks it up
469
+ const enrichedSkillMd = ensureSkillMdDescription(skillMd, description);
470
+
471
+ const config: ProfileConfig = {
472
+ id,
473
+ name: name.charAt(0).toUpperCase() + name.slice(1),
474
+ version: fm.version ?? "1.0.0",
475
+ domain: inferDomain(description, name),
476
+ tags,
477
+ allowedTools: parseAllowedTools(fm),
478
+ author: repoMeta.owner,
479
+ source: `https://github.com/${repoMeta.owner}/${repoMeta.repo}/tree/${repoMeta.branch}/${skill.path}`,
480
+ importMeta: {
481
+ repoUrl: repoMeta.repoUrl,
482
+ repoOwner: repoMeta.owner,
483
+ repoName: repoMeta.repo,
484
+ branch: repoMeta.branch,
485
+ filePath: skill.path,
486
+ commitSha: repoMeta.commitSha,
487
+ contentHash: contentHash(skillMd),
488
+ importedAt: new Date().toISOString(),
489
+ sourceFormat: "skillmd-only",
490
+ },
491
+ };
492
+
493
+ return { config, skillMd: enrichedSkillMd, importMeta: config.importMeta! };
494
+ }
495
+
496
+ /**
497
+ * Adapt a Stagent-native format (profile.yaml + SKILL.md) from a remote repo.
498
+ * Enriches tags/description from SKILL.md body + README if the profile.yaml values are weak.
499
+ */
500
+ export function adaptStagentNative(
501
+ skill: DiscoveredSkill,
502
+ skillMd: string,
503
+ profileYamlContent: string,
504
+ repoMeta: RepoMeta,
505
+ readmeCtx: ReadmeContext | null = null,
506
+ ): AdaptedProfile {
507
+ const parsed = yaml.load(profileYamlContent) as Record<string, unknown>;
508
+ const result = ProfileConfigSchema.safeParse(parsed);
509
+
510
+ if (!result.success) {
511
+ throw new Error(
512
+ `Invalid profile.yaml in ${skill.path}: ${result.error.issues.map((i) => i.message).join(", ")}`
513
+ );
514
+ }
515
+
516
+ const config = result.data;
517
+ const body = stripFrontmatter(skillMd);
518
+ const dirName = skill.path.split("/").pop() ?? skill.name;
519
+
520
+ // Enrich tags if the profile has fewer than 3
521
+ if (config.tags.length < 3) {
522
+ const enrichedTags = extractTags(skill.frontmatter, body, dirName, readmeCtx);
523
+ // Merge — keep originals, add new ones
524
+ const merged = new Set([...config.tags, ...enrichedTags]);
525
+ config.tags = Array.from(merged).slice(0, 12);
526
+ }
527
+
528
+ // Ensure SKILL.md frontmatter has a rich description
529
+ const repoReadmeSkillDesc = readmeCtx
530
+ ? findSkillInRepoReadme(readmeCtx.repoReadme, config.name)
531
+ : null;
532
+ const richDescription = extractDescription(
533
+ skill.frontmatter, body, readmeCtx, config.name, repoReadmeSkillDesc
534
+ );
535
+ const enrichedSkillMd = ensureSkillMdDescription(skillMd, richDescription);
536
+
537
+ // Inject importMeta
538
+ config.importMeta = {
539
+ repoUrl: repoMeta.repoUrl,
540
+ repoOwner: repoMeta.owner,
541
+ repoName: repoMeta.repo,
542
+ branch: repoMeta.branch,
543
+ filePath: skill.path,
544
+ commitSha: repoMeta.commitSha,
545
+ contentHash: contentHash(skillMd),
546
+ importedAt: new Date().toISOString(),
547
+ sourceFormat: "stagent",
548
+ };
549
+
550
+ // Set source URL if not already set
551
+ if (!config.source) {
552
+ config.source = `https://github.com/${repoMeta.owner}/${repoMeta.repo}/tree/${repoMeta.branch}/${skill.path}`;
553
+ }
554
+
555
+ // Set author if not already set
556
+ if (!config.author) {
557
+ config.author = repoMeta.owner;
558
+ }
559
+
560
+ return { config, skillMd: enrichedSkillMd, importMeta: config.importMeta };
561
+ }
562
+
563
+ /**
564
+ * Ensure SKILL.md has a `description:` in its frontmatter.
565
+ * If frontmatter exists but has no description (or a weak one), inject one.
566
+ * If no frontmatter exists, prepend one with name + description.
567
+ */
568
+ function ensureSkillMdDescription(skillMd: string, description: string): string {
569
+ const fmMatch = skillMd.match(/^(---\s*\n)([\s\S]*?)\n(---)/);
570
+ if (!fmMatch) {
571
+ // No frontmatter — prepend one
572
+ const name = description.split(/[.—:,]/, 1)[0].trim().slice(0, 40);
573
+ return `---\nname: ${name}\ndescription: ${description}\n---\n\n${skillMd}`;
574
+ }
575
+
576
+ const [fullMatch, open, body, close] = fmMatch;
577
+ const descLine = body.match(/^description:\s*(.*)$/m);
578
+
579
+ if (descLine && descLine[1].trim().length > 20) {
580
+ // Already has a rich description — don't touch
581
+ return skillMd;
582
+ }
583
+
584
+ if (descLine) {
585
+ // Replace weak description
586
+ const newBody = body.replace(
587
+ /^description:\s*.*$/m,
588
+ `description: ${description}`
589
+ );
590
+ return skillMd.replace(fullMatch, `${open}${newBody}\n${close}`);
591
+ }
592
+
593
+ // No description line — add one
594
+ return skillMd.replace(fullMatch, `${open}${body}\ndescription: ${description}\n${close}`);
595
+ }
596
+
597
+ /**
598
+ * Re-extract description and tags for an already-imported profile during update.
599
+ * Used by apply-updates to refresh metadata when upstream SKILL.md changes.
600
+ */
601
+ export function enrichProfileFromContent(
602
+ skillMd: string,
603
+ currentTags: string[],
604
+ name: string,
605
+ dirName: string,
606
+ readmeCtx: ReadmeContext | null = null,
607
+ ): { enrichedSkillMd: string; tags: string[]; description: string } {
608
+ const fmMatch = skillMd.match(/^---\s*\n([\s\S]*?)\n---/);
609
+ const fm: Record<string, string> = {};
610
+ if (fmMatch) {
611
+ for (const line of fmMatch[1].split("\n")) {
612
+ const colonIdx = line.indexOf(":");
613
+ if (colonIdx > 0) {
614
+ const key = line.slice(0, colonIdx).trim();
615
+ const value = line.slice(colonIdx + 1).trim();
616
+ if (key && value) fm[key] = value;
617
+ }
618
+ }
619
+ }
620
+
621
+ const body = stripFrontmatter(skillMd);
622
+ const repoReadmeSkillDesc = readmeCtx
623
+ ? findSkillInRepoReadme(readmeCtx.repoReadme, name)
624
+ : null;
625
+ const description = extractDescription(fm, body, readmeCtx, name, repoReadmeSkillDesc);
626
+ const newTags = extractTags(fm, body, dirName, readmeCtx);
627
+ const mergedTags = Array.from(new Set([...currentTags, ...newTags])).slice(0, 12);
628
+ const enrichedSkillMd = ensureSkillMdDescription(skillMd, description);
629
+
630
+ return { enrichedSkillMd, tags: mergedTags, description };
631
+ }