@geminilight/mindos 0.6.29 → 0.6.30

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 (71) hide show
  1. package/README.md +10 -4
  2. package/app/app/api/acp/config/route.ts +82 -0
  3. package/app/app/api/acp/detect/route.ts +71 -48
  4. package/app/app/api/acp/install/route.ts +51 -0
  5. package/app/app/api/acp/session/route.ts +141 -11
  6. package/app/app/api/ask/route.ts +116 -13
  7. package/app/app/api/workflows/route.ts +156 -0
  8. package/app/app/page.tsx +7 -2
  9. package/app/components/ActivityBar.tsx +12 -4
  10. package/app/components/AskModal.tsx +4 -1
  11. package/app/components/FileTree.tsx +21 -10
  12. package/app/components/HomeContent.tsx +1 -0
  13. package/app/components/Panel.tsx +1 -0
  14. package/app/components/RightAskPanel.tsx +5 -1
  15. package/app/components/SidebarLayout.tsx +6 -0
  16. package/app/components/agents/AgentDetailContent.tsx +263 -47
  17. package/app/components/agents/AgentsContentPage.tsx +11 -0
  18. package/app/components/agents/AgentsPanelA2aTab.tsx +285 -46
  19. package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
  20. package/app/components/agents/agents-content-model.ts +2 -2
  21. package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
  22. package/app/components/ask/AskContent.tsx +197 -239
  23. package/app/components/ask/FileChip.tsx +82 -17
  24. package/app/components/ask/MentionPopover.tsx +21 -3
  25. package/app/components/ask/MessageList.tsx +30 -9
  26. package/app/components/ask/SlashCommandPopover.tsx +21 -3
  27. package/app/components/panels/AgentsPanel.tsx +1 -0
  28. package/app/components/panels/AgentsPanelHubNav.tsx +9 -2
  29. package/app/components/panels/WorkflowsPanel.tsx +206 -0
  30. package/app/components/renderers/workflow-yaml/StepEditor.tsx +157 -0
  31. package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +201 -0
  32. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +226 -0
  33. package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
  34. package/app/components/renderers/workflow-yaml/execution.ts +177 -0
  35. package/app/components/renderers/workflow-yaml/index.ts +6 -0
  36. package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
  37. package/app/components/renderers/workflow-yaml/parser.ts +172 -0
  38. package/app/components/renderers/workflow-yaml/selectors.tsx +522 -0
  39. package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
  40. package/app/components/renderers/workflow-yaml/types.ts +46 -0
  41. package/app/hooks/useAcpConfig.ts +96 -0
  42. package/app/hooks/useAcpDetection.ts +69 -14
  43. package/app/hooks/useAcpRegistry.ts +46 -11
  44. package/app/hooks/useAskModal.ts +12 -5
  45. package/app/hooks/useAskPanel.ts +8 -5
  46. package/app/hooks/useAskSession.ts +19 -2
  47. package/app/hooks/useImageUpload.ts +152 -0
  48. package/app/lib/acp/acp-tools.ts +3 -1
  49. package/app/lib/acp/agent-descriptors.ts +274 -0
  50. package/app/lib/acp/bridge.ts +6 -0
  51. package/app/lib/acp/index.ts +20 -4
  52. package/app/lib/acp/registry.ts +74 -7
  53. package/app/lib/acp/session.ts +481 -28
  54. package/app/lib/acp/subprocess.ts +307 -21
  55. package/app/lib/acp/types.ts +158 -20
  56. package/app/lib/agent/model.ts +18 -3
  57. package/app/lib/agent/to-agent-messages.ts +25 -2
  58. package/app/lib/i18n/modules/knowledge.ts +4 -0
  59. package/app/lib/i18n/modules/navigation.ts +2 -0
  60. package/app/lib/i18n/modules/panels.ts +146 -2
  61. package/app/lib/pi-integration/skills.ts +21 -6
  62. package/app/lib/renderers/index.ts +2 -2
  63. package/app/lib/settings.ts +10 -0
  64. package/app/lib/types.ts +12 -1
  65. package/app/next-env.d.ts +1 -1
  66. package/app/package.json +3 -1
  67. package/package.json +1 -1
  68. package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
  69. package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
  70. package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
  71. package/app/components/renderers/workflow/manifest.ts +0 -14
@@ -0,0 +1,126 @@
1
+ 'use client';
2
+
3
+ import { useMemo, useState, useCallback, useRef } from 'react';
4
+ import { Pencil, Play, AlertCircle } from 'lucide-react';
5
+ import type { RendererContext } from '@/lib/renderers/registry';
6
+ import { parseWorkflowYaml } from './parser';
7
+ import WorkflowEditor from './WorkflowEditor';
8
+ import WorkflowRunner from './WorkflowRunner';
9
+ import type { WorkflowYaml } from './types';
10
+
11
+ type Mode = 'edit' | 'run';
12
+
13
+ export function WorkflowYamlRenderer({ filePath, content }: RendererContext) {
14
+ const parsed = useMemo(() => parseWorkflowYaml(content), [content]);
15
+ const [mode, setMode] = useState<Mode>('edit');
16
+ const [dirty, setDirty] = useState(false);
17
+
18
+ // Editor state: start from parsed workflow or a blank template
19
+ const [editWorkflow, setEditWorkflow] = useState<WorkflowYaml>(() => {
20
+ if (parsed.workflow) return structuredClone(parsed.workflow);
21
+ return { title: '', description: '', steps: [] };
22
+ });
23
+
24
+ // Track original content for dirty detection
25
+ const savedContentRef = useRef(content);
26
+
27
+ const latestParsed = useMemo(() => parsed.workflow ?? null, [parsed.workflow]);
28
+
29
+ const handleEditorChange = useCallback((wf: WorkflowYaml) => {
30
+ setEditWorkflow(wf);
31
+ setDirty(true);
32
+ }, []);
33
+
34
+ const handleSaved = useCallback(() => {
35
+ setDirty(false);
36
+ }, []);
37
+
38
+ // Runner uses latest parsed workflow (from file), not editor state
39
+ const runWorkflow = latestParsed ?? editWorkflow;
40
+ const canRun = runWorkflow.steps.length > 0 && !!runWorkflow.title;
41
+ const hasParseErrors = parsed.errors.length > 0;
42
+
43
+ // Explain why Run is disabled
44
+ const runDisabledReason = !runWorkflow.title
45
+ ? 'Add a title first'
46
+ : runWorkflow.steps.length === 0
47
+ ? 'Add at least one step'
48
+ : dirty
49
+ ? 'Save changes first'
50
+ : undefined;
51
+
52
+ const handleModeSwitch = (target: Mode) => {
53
+ if (target === 'run' && dirty) {
54
+ // Don't block, but show the tooltip
55
+ }
56
+ if (target === 'run' && !canRun) return;
57
+ setMode(target);
58
+ };
59
+
60
+ return (
61
+ <div className="max-w-[720px] mx-auto py-6 px-1">
62
+ {/* Mode switcher + title */}
63
+ <div className="flex items-center gap-3 mb-5">
64
+ <div className="flex rounded-lg border border-border overflow-hidden text-xs">
65
+ <button
66
+ onClick={() => handleModeSwitch('edit')}
67
+ className={`flex items-center gap-1.5 px-3.5 py-1.5 transition-colors ${
68
+ mode === 'edit'
69
+ ? 'bg-[var(--amber)] text-[var(--amber-foreground)] font-medium'
70
+ : 'bg-card text-muted-foreground hover:bg-muted'
71
+ }`}
72
+ >
73
+ <Pencil size={12} />
74
+ Edit
75
+ </button>
76
+ <button
77
+ onClick={() => handleModeSwitch('run')}
78
+ disabled={!canRun}
79
+ title={runDisabledReason}
80
+ className={`flex items-center gap-1.5 px-3.5 py-1.5 transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
81
+ mode === 'run'
82
+ ? 'bg-[var(--amber)] text-[var(--amber-foreground)] font-medium'
83
+ : 'bg-card text-muted-foreground hover:bg-muted'
84
+ }`}
85
+ >
86
+ <Play size={12} />
87
+ Run
88
+ </button>
89
+ </div>
90
+
91
+ <h1 className="text-base font-semibold text-foreground truncate flex-1">
92
+ {editWorkflow.title || 'New Workflow'}
93
+ {dirty && <span className="text-[var(--amber)] ml-1" title="Unsaved changes">*</span>}
94
+ </h1>
95
+ </div>
96
+
97
+ {/* Parse error banner */}
98
+ {hasParseErrors && mode === 'edit' && (
99
+ <div className="mb-4 px-3 py-2.5 rounded-lg bg-[var(--error)]/10 border border-[var(--error)]/30">
100
+ <div className="flex items-center gap-2 mb-1">
101
+ <AlertCircle size={13} className="text-[var(--error)] shrink-0" />
102
+ <span className="text-xs font-medium text-[var(--error)]">File has parse issues</span>
103
+ </div>
104
+ <ul className="text-2xs text-[var(--error)]/80 pl-5 list-disc">
105
+ {parsed.errors.slice(0, 3).map((e, i) => <li key={i}>{e}</li>)}
106
+ </ul>
107
+ </div>
108
+ )}
109
+
110
+ {/* Content */}
111
+ {mode === 'edit' ? (
112
+ <WorkflowEditor
113
+ workflow={editWorkflow}
114
+ filePath={filePath}
115
+ onChange={handleEditorChange}
116
+ onSaved={handleSaved}
117
+ />
118
+ ) : (
119
+ <WorkflowRunner
120
+ workflow={runWorkflow}
121
+ filePath={filePath}
122
+ />
123
+ )}
124
+ </div>
125
+ );
126
+ }
@@ -0,0 +1,177 @@
1
+ // Workflow step execution logic — fetches skills, constructs prompts, streams AI responses
2
+
3
+ import type { WorkflowYaml, WorkflowStepRuntime } from './types';
4
+
5
+ // ─── Skill Fetching ───────────────────────────────────────────────────────
6
+
7
+ /** Cache skill content for the duration of a workflow run to avoid redundant fetches */
8
+ const skillCache = new Map<string, string | null>();
9
+
10
+ export function clearSkillCache() {
11
+ skillCache.clear();
12
+ }
13
+
14
+ /**
15
+ * Fetch skill content from /api/skills. Returns the full SKILL.md content,
16
+ * or null if the skill doesn't exist. Results are cached per skill name.
17
+ */
18
+ async function fetchSkillContent(name: string, signal: AbortSignal): Promise<string | null> {
19
+ if (skillCache.has(name)) return skillCache.get(name)!;
20
+
21
+ try {
22
+ const res = await fetch('/api/skills', {
23
+ method: 'POST',
24
+ headers: { 'Content-Type': 'application/json' },
25
+ body: JSON.stringify({ action: 'read', name }),
26
+ signal,
27
+ });
28
+ if (!res.ok) {
29
+ skillCache.set(name, null);
30
+ return null;
31
+ }
32
+ const data = await res.json();
33
+ const content = data.content as string | undefined;
34
+ skillCache.set(name, content ?? null);
35
+ return content ?? null;
36
+ } catch {
37
+ // Network error or abort — don't cache failures
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Collect all unique skill names referenced by a step (step-level + workflow-level).
44
+ * Step-level skill takes priority; workflow-level skills provide additional context.
45
+ */
46
+ function collectSkillNames(step: WorkflowStepRuntime, workflow: WorkflowYaml): string[] {
47
+ const names: string[] = [];
48
+ if (step.skill) names.push(step.skill);
49
+ for (const s of workflow.skills ?? []) {
50
+ if (!names.includes(s)) names.push(s);
51
+ }
52
+ return names;
53
+ }
54
+
55
+ // ─── Prompt Construction ──────────────────────────────────────────────────
56
+
57
+ function buildPrompt(
58
+ step: WorkflowStepRuntime,
59
+ workflow: WorkflowYaml,
60
+ skillContents: Map<string, string>,
61
+ ): string {
62
+ const allStepsSummary = workflow.steps.map((s, i) => `${i + 1}. ${s.name}`).join('\n');
63
+
64
+ // Build skill context block
65
+ let skillBlock = '';
66
+ if (skillContents.size > 0) {
67
+ const sections: string[] = [];
68
+ for (const [name, content] of skillContents) {
69
+ const isPrimary = name === step.skill;
70
+ sections.push(
71
+ `### ${isPrimary ? '[Primary] ' : ''}Skill: ${name}\n\n${content}`
72
+ );
73
+ }
74
+ skillBlock = `\n\n---\n## Skill Reference\n\nFollow the guidelines from these skills when executing this step:\n\n${sections.join('\n\n---\n\n')}`;
75
+ }
76
+
77
+ return `You are executing step ${step.index + 1} of a workflow: "${step.name}".
78
+
79
+ Context of the full workflow "${workflow.title}":
80
+ ${allStepsSummary}
81
+
82
+ Current step instructions:
83
+ ${step.prompt || '(No specific instructions — use common sense.)'}${skillBlock}
84
+
85
+ Execute concisely. Provide:
86
+ 1. What you did / what the output is
87
+ 2. Any decisions made
88
+ 3. What the next step should watch out for
89
+
90
+ Be specific and actionable. Format in Markdown.`;
91
+ }
92
+
93
+ // ─── Streaming Execution ──────────────────────────────────────────────────
94
+
95
+ /**
96
+ * Execute a workflow step with AI:
97
+ * 1. Fetch referenced skill(s) content
98
+ * 2. Build prompt with injected skill context
99
+ * 3. Stream response from /api/ask
100
+ */
101
+ export async function runStepWithAI(
102
+ step: WorkflowStepRuntime,
103
+ workflow: WorkflowYaml,
104
+ filePath: string,
105
+ onChunk: (accumulated: string) => void,
106
+ signal: AbortSignal,
107
+ ): Promise<void> {
108
+ // 1. Fetch skill content
109
+ const skillNames = collectSkillNames(step, workflow);
110
+ const skillContents = new Map<string, string>();
111
+
112
+ if (skillNames.length > 0) {
113
+ const results = await Promise.all(
114
+ skillNames.map(async (name) => {
115
+ const content = await fetchSkillContent(name, signal);
116
+ return [name, content] as const;
117
+ })
118
+ );
119
+ for (const [name, content] of results) {
120
+ if (content) skillContents.set(name, content);
121
+ }
122
+ }
123
+
124
+ // 2. Build prompt
125
+ const prompt = buildPrompt(step, workflow, skillContents);
126
+
127
+ // 3. Stream from /api/ask
128
+ const res = await fetch('/api/ask', {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({
132
+ messages: [{ role: 'user', content: prompt }],
133
+ currentFile: filePath,
134
+ }),
135
+ signal,
136
+ });
137
+
138
+ if (!res.ok) throw new Error(`Request failed (HTTP ${res.status})`);
139
+ if (!res.body) throw new Error('No response body');
140
+
141
+ const reader = res.body.getReader();
142
+ const decoder = new TextDecoder();
143
+ let acc = '';
144
+
145
+ while (true) {
146
+ const { done, value } = await reader.read();
147
+ if (done) break;
148
+ const raw = decoder.decode(value, { stream: true });
149
+ for (const line of raw.split('\n')) {
150
+ // SSE format: data:{"type":"text_delta","delta":"..."}
151
+ const sseMatch = line.match(/^data:(.+)$/);
152
+ if (sseMatch) {
153
+ try {
154
+ const event = JSON.parse(sseMatch[1]);
155
+ if (event.type === 'text_delta' && typeof event.delta === 'string') {
156
+ acc += event.delta;
157
+ onChunk(acc);
158
+ }
159
+ } catch {
160
+ // Not valid JSON — try legacy Vercel AI SDK format: 0:"..."
161
+ const legacyMatch = line.match(/^0:"((?:[^"\\]|\\.)*)"$/);
162
+ if (legacyMatch) {
163
+ acc += legacyMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
164
+ onChunk(acc);
165
+ }
166
+ }
167
+ continue;
168
+ }
169
+ // Legacy Vercel AI SDK format (without SSE prefix)
170
+ const legacyMatch = line.match(/^0:"((?:[^"\\]|\\.)*)"$/);
171
+ if (legacyMatch) {
172
+ acc += legacyMatch[1].replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
173
+ onChunk(acc);
174
+ }
175
+ }
176
+ }
177
+ }
@@ -0,0 +1,6 @@
1
+ export { manifest } from './manifest';
2
+ export { WorkflowYamlRenderer } from './WorkflowYamlRenderer';
3
+ export { parseWorkflowYaml, validateWorkflowSchema, getStepDescription } from './parser';
4
+ export { runStepWithAI, clearSkillCache } from './execution';
5
+ export { serializeWorkflowYaml, generateStepId } from './serializer';
6
+ export type { WorkflowStep, WorkflowYaml, WorkflowStepRuntime, StepStatus, ParseResult, ValidationResult } from './types';
@@ -0,0 +1,21 @@
1
+ import type { RendererDefinition } from '@/lib/renderers/registry';
2
+
3
+ export const manifest: RendererDefinition = {
4
+ id: 'workflow-yaml',
5
+ name: 'Workflow Runner',
6
+ description: 'Execute step-by-step YAML workflows with Skills & Agent support.',
7
+ author: 'MindOS',
8
+ icon: '⚡',
9
+ tags: ['workflow', 'automation', 'steps', 'ai', 'yaml'],
10
+ builtin: true,
11
+ core: true,
12
+ appBuiltinFeature: true,
13
+ entryPath: '.mindos/workflows/',
14
+ match: ({ extension, filePath }) => {
15
+ if (extension !== 'yaml' && extension !== 'yml') return false;
16
+ return /\.flow\.(yaml|yml)$/i.test(filePath);
17
+ },
18
+ load: () => import('./WorkflowYamlRenderer').then(m => ({
19
+ default: m.WorkflowYamlRenderer
20
+ })),
21
+ };
@@ -0,0 +1,172 @@
1
+ import YAML from 'js-yaml';
2
+ import type { WorkflowYaml, ParseResult, ValidationResult, WorkflowStep } from './types';
3
+
4
+ export function parseWorkflowYaml(content: string): ParseResult {
5
+ try {
6
+ const parsed = YAML.load(content, { schema: YAML.JSON_SCHEMA }) as unknown;
7
+
8
+ if (!parsed) {
9
+ return {
10
+ workflow: null,
11
+ errors: ['File is empty'],
12
+ };
13
+ }
14
+
15
+ if (typeof parsed !== 'object') {
16
+ return {
17
+ workflow: null,
18
+ errors: ['Content is not valid YAML'],
19
+ };
20
+ }
21
+
22
+ const validation = validateWorkflowSchema(parsed);
23
+ if (!validation.valid) {
24
+ return {
25
+ workflow: null,
26
+ errors: validation.errors,
27
+ };
28
+ }
29
+
30
+ return {
31
+ workflow: parsed as WorkflowYaml,
32
+ errors: [],
33
+ };
34
+ } catch (err) {
35
+ return {
36
+ workflow: null,
37
+ errors: [err instanceof Error ? err.message : String(err)],
38
+ };
39
+ }
40
+ }
41
+
42
+ export function validateWorkflowSchema(obj: unknown): ValidationResult {
43
+ const errors: string[] = [];
44
+
45
+ if (!obj || typeof obj !== 'object') {
46
+ return { valid: false, errors: ['Must be a valid YAML object'] };
47
+ }
48
+
49
+ const w = obj as Record<string, any>;
50
+
51
+ // Validate title
52
+ if (!w.title || typeof w.title !== 'string' || !w.title.trim()) {
53
+ errors.push("Missing required field 'title' (string)");
54
+ }
55
+
56
+ // Validate description (optional)
57
+ if (w.description !== undefined && typeof w.description !== 'string') {
58
+ errors.push("'description' must be a string");
59
+ }
60
+
61
+ // Validate skills (optional)
62
+ if (w.skills !== undefined) {
63
+ if (!Array.isArray(w.skills)) {
64
+ errors.push("'skills' must be an array of strings");
65
+ } else if (!w.skills.every((s: any) => typeof s === 'string')) {
66
+ errors.push("'skills' must contain only strings");
67
+ }
68
+ }
69
+
70
+ // Validate tools (optional)
71
+ if (w.tools !== undefined) {
72
+ if (!Array.isArray(w.tools)) {
73
+ errors.push("'tools' must be an array of strings");
74
+ } else if (!w.tools.every((t: any) => typeof t === 'string')) {
75
+ errors.push("'tools' must contain only strings");
76
+ }
77
+ }
78
+
79
+ // Validate steps
80
+ if (!w.steps) {
81
+ errors.push("Missing required field 'steps' (array)");
82
+ } else if (!Array.isArray(w.steps)) {
83
+ errors.push("'steps' must be an array");
84
+ } else if (w.steps.length === 0) {
85
+ errors.push("'steps' must have at least 1 step");
86
+ } else {
87
+ const seenIds = new Set<string>();
88
+ w.steps.forEach((step: any, idx: number) => {
89
+ const stepErrors = validateStep(step, idx);
90
+ errors.push(...stepErrors);
91
+ // Check for duplicate IDs
92
+ const id = step?.id;
93
+ if (typeof id === 'string' && id.trim()) {
94
+ if (seenIds.has(id)) {
95
+ errors.push(`steps[${idx}].id: duplicate id '${id}' (each step must have a unique id)`);
96
+ }
97
+ seenIds.add(id);
98
+ }
99
+ });
100
+ }
101
+
102
+ return {
103
+ valid: errors.length === 0,
104
+ errors,
105
+ };
106
+ }
107
+
108
+ function validateStep(step: unknown, index: number): string[] {
109
+ const errors: string[] = [];
110
+ const prefix = `steps[${index}]`;
111
+
112
+ if (!step || typeof step !== 'object') {
113
+ errors.push(`${prefix}: must be a valid object`);
114
+ return errors;
115
+ }
116
+
117
+ const s = step as Record<string, any>;
118
+
119
+ // Validate id
120
+ if (!s.id || typeof s.id !== 'string' || !s.id.trim()) {
121
+ errors.push(`${prefix}: missing required field 'id' (non-empty string)`);
122
+ } else if (!/^[a-z0-9_-]+$/.test(s.id)) {
123
+ errors.push(`${prefix}.id: '${s.id}' is invalid — use only lowercase letters, numbers, hyphens, underscores (e.g., 'run-tests')`);
124
+ }
125
+
126
+ // Validate name
127
+ if (!s.name || typeof s.name !== 'string' || !s.name.trim()) {
128
+ errors.push(`${prefix}: missing required field 'name' (non-empty string)`);
129
+ }
130
+
131
+ // Validate description (optional)
132
+ if (s.description !== undefined && typeof s.description !== 'string') {
133
+ errors.push(`${prefix}.description: must be a string`);
134
+ }
135
+
136
+ // Validate agent (optional)
137
+ if (s.agent !== undefined && (typeof s.agent !== 'string' || !s.agent.trim())) {
138
+ errors.push(`${prefix}.agent: must be a non-empty string`);
139
+ }
140
+
141
+ // Validate skill (optional)
142
+ if (s.skill !== undefined && (typeof s.skill !== 'string' || !s.skill.trim())) {
143
+ errors.push(`${prefix}.skill: must be a non-empty string`);
144
+ }
145
+
146
+ // Validate tools (optional)
147
+ if (s.tools !== undefined) {
148
+ if (!Array.isArray(s.tools)) {
149
+ errors.push(`${prefix}.tools: must be an array of strings`);
150
+ } else if (!s.tools.every((t: any) => typeof t === 'string')) {
151
+ errors.push(`${prefix}.tools: must contain only strings`);
152
+ }
153
+ }
154
+
155
+ // Validate prompt (required)
156
+ if (!s.prompt || typeof s.prompt !== 'string' || !s.prompt.trim()) {
157
+ errors.push(`${prefix}: missing required field 'prompt' (non-empty string)`);
158
+ }
159
+
160
+ // Validate timeout (optional)
161
+ if (s.timeout !== undefined) {
162
+ if (typeof s.timeout !== 'number' || s.timeout <= 0) {
163
+ errors.push(`${prefix}.timeout: must be a positive number (seconds)`);
164
+ }
165
+ }
166
+
167
+ return errors;
168
+ }
169
+
170
+ export function getStepDescription(step: WorkflowStep): string {
171
+ return step.description || step.prompt.split('\n')[0].slice(0, 100);
172
+ }