folpipe 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.4
2
+ Name: folpipe
3
+ Version: 0.1.0
4
+ Summary: Folder-based agentic AI pipeline framework
5
+ Project-URL: Homepage, https://github.com/kroczynskikrzysztof/pipelinex
6
+ Project-URL: Documentation, https://github.com/kroczynskikrzysztof/pipelinex/tree/main/docs
7
+ Project-URL: Issues, https://github.com/kroczynskikrzysztof/pipelinex/issues
8
+ Author-email: Krzysztof Roczynski <kroczynskikrzysztof.info@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,ai,automation,llm,pipeline
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.10
21
+ Requires-Dist: click>=8.0
22
+ Requires-Dist: httpx>=0.24
23
+ Requires-Dist: jinja2>=3.0
24
+ Requires-Dist: litellm>=1.40
25
+ Requires-Dist: python-dotenv>=1.0
26
+ Requires-Dist: pyyaml>=6.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ # pipelinex
30
+
31
+ Folder-based agentic AI pipeline framework. No programming required to use.
32
+
33
+ ```bash
34
+ pip install folpipe
35
+ folpipe run ./my-pipeline --watch
36
+ ```
37
+
38
+ ---
39
+
40
+ ## The idea
41
+
42
+ A pipeline is a folder. Instructions live in plain-English markdown files. Tools are drop-in folders. The model is swappable with one line.
43
+
44
+ ```
45
+ my-pipeline/
46
+ ├── pipeline.yaml ← steps, model, routing
47
+ ├── SKILL.md ← global instructions (plain English)
48
+ ├── .env ← API keys
49
+ ├── input/ ← drop files here
50
+ ├── output/ ← results land here
51
+ ├── tools/ ← drop-in custom tools
52
+ ├── step-01-ingest/
53
+ │ └── SKILL.md ← what this step does
54
+ └── step-02-process/
55
+ ├── SKILL.md
56
+ └── sub-01-chunk/ ← sub-steps, recursively
57
+ └── SKILL.md
58
+ ```
59
+
60
+ Zip the folder, share it, version it in git. No server, no database, no cloud account.
61
+
62
+ ---
63
+
64
+ ## Quick start
65
+
66
+ **1. Install**
67
+
68
+ ```bash
69
+ pip install pipelinex
70
+ ```
71
+
72
+ **2. Create a pipeline**
73
+
74
+ ```bash
75
+ pipelinex new my-pipeline
76
+ cd my-pipeline
77
+ ```
78
+
79
+ **3. Configure the model** — edit `.env`:
80
+
81
+ ```bash
82
+ DEEPSEEK_API_KEY=sk-...
83
+ ```
84
+
85
+ **4. Write your step** — edit `step-01-start/SKILL.md`:
86
+
87
+ ```markdown
88
+ # Summarise input
89
+
90
+ Read the file from input/ and write a 3-paragraph summary to output/summary.md.
91
+
92
+ ## Task
93
+
94
+ Use read_file to read the input file.
95
+ Use write_file to write output/summary.md.
96
+ Leave a handoff note when done.
97
+ ```
98
+
99
+ **5. Run it**
100
+
101
+ ```bash
102
+ cp myfile.txt input/
103
+ pipelinex run . --watch
104
+ ```
105
+
106
+ ---
107
+
108
+ ## What it can do
109
+
110
+ **Linear pipelines** — steps run in order, each hands off to the next via shared state.
111
+
112
+ **Agent branching** — the model decides which step comes next from a declared whitelist:
113
+
114
+ ```yaml
115
+ - id: step-03-validate
116
+ can_goto: [step-04-output, step-05-partial-output]
117
+ ```
118
+
119
+ **Parallel dispatch** — the model fires multiple `dispatch_task` calls in one response; the runner detects the batch and executes them concurrently:
120
+
121
+ ```markdown
122
+ ## Task
123
+ Process all documents at once — send them all for chunking
124
+ in the same breath rather than one at a time.
125
+ ```
126
+
127
+ **Sub-steps** — steps contain named sub-step folders with their own SKILL.md and tools:
128
+
129
+ ```
130
+ dispatch_task(task="chunk intro-to-ml.txt", substep="sub-01-chunk", context={...})
131
+ ```
132
+
133
+ **Human-in-the-loop** — steps can pause for console input, file review, or a custom tool (Slack, email, webhook):
134
+
135
+ ```yaml
136
+ - id: step-03-review
137
+ human_input:
138
+ mode: console
139
+ prompt: "Approve this output? (yes/no):"
140
+ ```
141
+
142
+ **Model-driven context** — each step's `## Context` section tells the model what state to pay attention to and what to ignore. Skip signals are respected; the model never sees irrelevant data.
143
+
144
+ **Any model** — DeepSeek, Anthropic, OpenAI, Ollama, Groq, or any OpenAI-compatible endpoint. Change one line in `pipeline.yaml`. Optional per-step overrides for cost optimisation.
145
+
146
+ ---
147
+
148
+ ## Custom tools
149
+
150
+ Drop a folder into `tools/`:
151
+
152
+ ```
153
+ tools/
154
+ └── send_slack/
155
+ ├── tool.json ← MCP-compatible schema + deps declaration
156
+ └── run.py ← stdin JSON in, stdout JSON out
157
+ ```
158
+
159
+ ```json
160
+ {
161
+ "name": "send_slack",
162
+ "description": "Post a message to a Slack channel. Use for approvals and failures that need human eyes.",
163
+ "inputSchema": { ... },
164
+ "deps": ["slack-sdk"]
165
+ }
166
+ ```
167
+
168
+ Dependencies declared in `deps` are installed automatically before the first run. No manual `pip install`.
169
+
170
+ Any language works — `run.py`, `run.sh`, or `run.js`.
171
+
172
+ ---
173
+
174
+ ## Example pipelines
175
+
176
+ ### doc-pipeline
177
+
178
+ Ingests documents → chunks → embeds → indexed output.
179
+
180
+ ```bash
181
+ pipelinex run ./doc-pipeline --watch
182
+ ```
183
+
184
+ Demonstrates: parallel dispatch, sub-steps, agent branching, partial-failure routing.
185
+
186
+ ### cv-pipeline
187
+
188
+ Job URL → developer profile → tailored CV + styled PDF.
189
+
190
+ ```bash
191
+ pipelinex run ./cv-pipeline --input "https://example.com/jobs/engineer" --watch
192
+ ```
193
+
194
+ Demonstrates: custom tools (`fetch_page`, `render_pdf`), linear pipeline, HTML→PDF via Edge headless.
195
+
196
+ ---
197
+
198
+ ## CLI
199
+
200
+ ```bash
201
+ pipelinex run ./my-pipeline # run
202
+ pipelinex run ./my-pipeline --watch # run with live output
203
+ pipelinex run ./my-pipeline --from step-03 # resume after failure
204
+ pipelinex run ./my-pipeline --dry-run # validate config without running
205
+ pipelinex new my-pipeline # scaffold new pipeline
206
+ pipelinex new step step-05-review --in ./my-pipeline
207
+ pipelinex new tool send_email --in ./my-pipeline/tools
208
+ pipelinex validate ./my-pipeline # check config
209
+ pipelinex tools list --pipeline ./my-pipeline
210
+ pipelinex tools install ./my-pipeline # pre-install tool deps
211
+ pipelinex log ./my-pipeline # show run log
212
+ pipelinex log ./my-pipeline --errors # show last error report
213
+ ```
214
+
215
+ ---
216
+
217
+ ## Documentation
218
+
219
+ - [Getting Started](docs/getting-started.md)
220
+ - [Pipeline Structure](docs/pipeline-structure.md) — `pipeline.yaml` and `SKILL.md` reference
221
+ - [State & Handoffs](docs/state.md)
222
+ - [Tools](docs/tools.md) — built-ins, custom tools, resolution order
223
+ - [Execution Patterns](docs/execution-patterns.md) — linear, branching, loops, dispatch, sub-steps
224
+ - [Context Management](docs/context-management.md)
225
+ - [Human Input](docs/human-input.md)
226
+ - [CLI Reference](docs/cli.md)
227
+ - [Examples](docs/examples.md)
228
+
229
+ ---
230
+
231
+ ## Requirements
232
+
233
+ - Python 3.10+
234
+ - A model with tool calling support (DeepSeek, Claude, GPT-4, Llama 3.1+)
235
+ - For PDF rendering in cv-pipeline: Edge or Chrome installed
@@ -0,0 +1,18 @@
1
+ pipelinex/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ pipelinex/cli.py,sha256=VwuCsCffJbuhX5ydlEMjp3CgJwBW3Kuma9EohMA05rE,7416
3
+ pipelinex/context_mgr.py,sha256=K9Z6lfB2g6xClSCxz_pPYxjdLlAIGo-8BaITmgvQgBo,2483
4
+ pipelinex/loader.py,sha256=44VR7_nynmmXui3nY4yk4NDBhzUx_8SHXvskannPExI,3056
5
+ pipelinex/logger.py,sha256=uhryTI-Puo4KOcaMi_TCD8vtRQLyeivkkCqL8ncRVr0,2835
6
+ pipelinex/model.py,sha256=_usWH5_fLgfa5yd-AHRONI0PZNsIpnrT0bnr85Uc17E,2828
7
+ pipelinex/runner.py,sha256=sQKdnI4vRUvmVfRzMtmcBbMdcEbB-hPKGL9XCCLeLHU,20816
8
+ pipelinex/scaffold.py,sha256=46Ho6bLF-B2cwLo3ujA5tjlWdB0V7_f2CvsemCougNM,3485
9
+ pipelinex/state.py,sha256=Dqw56Niaj7aU2JnbO-rZxZoJbXMeb3Qq7l3mHGxcxjA,2331
10
+ pipelinex/tools/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ pipelinex/tools/builtin.py,sha256=31G0UVD77WzsNnw_v1Z2v8KSErU_Gpih_y4yrCvwbJM,12344
12
+ pipelinex/tools/executor.py,sha256=AryHZ76JRhrDGF9daMO9OZHMtLT5kzUzJa34wIf0G3k,1770
13
+ pipelinex/tools/resolver.py,sha256=FlU-WvNEsmjp1ns4b1n60qCZdulXNdaRrJNuNOXC5dk,2136
14
+ folpipe-0.1.0.dist-info/METADATA,sha256=T-gvXCEI0Uahxjeg1ppLMMsbLkuNqbDsrb5gkJq_GBk,6636
15
+ folpipe-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
16
+ folpipe-0.1.0.dist-info/entry_points.txt,sha256=BtJ2UJH9QDnQt6ybIATZlAmRVD-_yJIEbepW6UKIjC0,47
17
+ folpipe-0.1.0.dist-info/licenses/LICENSE,sha256=OCwD3ACF1Br_JQbOE9Uy6W81ykoODdABXEmIGWsxZfA,1106
18
+ folpipe-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ folpipe = pipelinex.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kroczyński Krzysztof s26192
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
pipelinex/__init__.py ADDED
File without changes
pipelinex/cli.py ADDED
@@ -0,0 +1,219 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import click
5
+
6
+ # Force UTF-8 stdout/stderr on Windows (avoids charmap errors with model output)
7
+ if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
8
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
9
+ if sys.stderr.encoding and sys.stderr.encoding.lower() not in ("utf-8", "utf8"):
10
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
11
+
12
+ from .loader import load_pipeline, validate_pipeline
13
+ from .runner import PipelineRunner
14
+ from .scaffold import scaffold_pipeline, scaffold_step, scaffold_tool
15
+ from .tools.builtin import BUILTIN_SCHEMAS
16
+ from .tools.resolver import GLOBAL_TOOLS_PATH, install_tool_deps, resolve_tools
17
+
18
+
19
+ @click.group()
20
+ def main():
21
+ """pipelinex — folder-based agentic AI pipeline framework"""
22
+
23
+
24
+ @main.command()
25
+ @click.argument("pipeline_path", type=click.Path(exists=True))
26
+ @click.option("--input", "input_data", default=None, help="Input text or path to input file")
27
+ @click.option("--from", "from_step", default=None, metavar="STEP_ID", help="Resume from this step")
28
+ @click.option("--dry-run", is_flag=True, help="Validate config and env, don't execute")
29
+ @click.option("--watch", is_flag=True, help="Show live step/tool progress")
30
+ @click.option("--model", "model_override", default=None, metavar="PROVIDER/NAME", help="Override model")
31
+ def run(pipeline_path, input_data, from_step, dry_run, watch, model_override):
32
+ """Run a pipeline."""
33
+ path = Path(pipeline_path)
34
+
35
+ try:
36
+ config = load_pipeline(path)
37
+ except (FileNotFoundError, SystemExit) as e:
38
+ click.echo(f"ERROR: {e}", err=True)
39
+ sys.exit(1)
40
+
41
+ errors = validate_pipeline(config)
42
+ if errors:
43
+ click.echo("Validation errors:", err=True)
44
+ for e in errors:
45
+ click.echo(f" - {e}", err=True)
46
+ sys.exit(1)
47
+
48
+ if dry_run:
49
+ click.echo("Pipeline config valid.")
50
+ click.echo(f" Name: {config.get('name')}")
51
+ click.echo(f" Version: {config.get('version')}")
52
+ click.echo(f" Model: {config['model']['provider']}/{config['model']['name']}")
53
+ click.echo(f" Steps: {[s['id'] for s in config.get('steps', [])]}")
54
+ return
55
+
56
+ if input_data:
57
+ p = Path(input_data)
58
+ if p.exists():
59
+ input_data = p.read_text(encoding="utf-8")
60
+
61
+ runner = PipelineRunner(
62
+ pipeline_path=path,
63
+ watch=watch,
64
+ from_step=from_step,
65
+ model_override=model_override,
66
+ input_data=input_data,
67
+ )
68
+
69
+ try:
70
+ runner.run()
71
+ except SystemExit as e:
72
+ click.echo(str(e), err=True)
73
+ sys.exit(1)
74
+ except KeyboardInterrupt:
75
+ click.echo("\nInterrupted.", err=True)
76
+ sys.exit(1)
77
+ except Exception as e:
78
+ click.echo(f"ERROR: {e}", err=True)
79
+ sys.exit(1)
80
+
81
+
82
+ @main.command()
83
+ @click.argument("args", nargs=-1)
84
+ @click.option("--in", "in_dir", default=None, help="Target directory")
85
+ def new(args, in_dir):
86
+ """Scaffold a pipeline, step, or tool.
87
+
88
+ \b
89
+ pipelinex new my-pipeline
90
+ pipelinex new step step-05-review --in ./my-pipeline
91
+ pipelinex new tool send_email --in ./my-pipeline/tools
92
+ """
93
+ if not args:
94
+ click.echo("Usage:")
95
+ click.echo(" pipelinex new <pipeline-name>")
96
+ click.echo(" pipelinex new step <step-id> --in <pipeline-dir>")
97
+ click.echo(" pipelinex new tool <tool-name> --in <tools-dir>")
98
+ return
99
+
100
+ if args[0] == "step":
101
+ if len(args) < 2:
102
+ click.echo("ERROR: step name required. Usage: pipelinex new step <step-id> --in <pipeline-dir>", err=True)
103
+ sys.exit(1)
104
+ scaffold_step(args[1], Path(in_dir or "."))
105
+
106
+ elif args[0] == "tool":
107
+ if len(args) < 2:
108
+ click.echo("ERROR: tool name required. Usage: pipelinex new tool <name> --in <tools-dir>", err=True)
109
+ sys.exit(1)
110
+ scaffold_tool(args[1], Path(in_dir or "tools"))
111
+
112
+ else:
113
+ scaffold_pipeline(args[0], Path(in_dir or "."))
114
+
115
+
116
+ @main.command()
117
+ @click.argument("pipeline_path", type=click.Path(exists=True))
118
+ def validate(pipeline_path):
119
+ """Validate pipeline config without running."""
120
+ path = Path(pipeline_path)
121
+ try:
122
+ config = load_pipeline(path)
123
+ except (FileNotFoundError, SystemExit) as e:
124
+ click.echo(f"ERROR: {e}", err=True)
125
+ sys.exit(1)
126
+
127
+ errors = validate_pipeline(config)
128
+ if errors:
129
+ click.echo("Validation failed:")
130
+ for e in errors:
131
+ click.echo(f" x {e}")
132
+ sys.exit(1)
133
+
134
+ click.echo("OK Pipeline is valid")
135
+ click.echo(f" Steps: {[s['id'] for s in config.get('steps', [])]}")
136
+ click.echo(f" Model: {config['model']['provider']}/{config['model']['name']}")
137
+
138
+
139
+ @main.group()
140
+ def tools():
141
+ """Manage tools."""
142
+
143
+
144
+ @tools.command("list")
145
+ @click.option("--pipeline", "pipeline_path", default=None, help="Include pipeline-level tools")
146
+ def tools_list(pipeline_path):
147
+ """List available tools."""
148
+ click.echo("Built-in tools:")
149
+ for t in BUILTIN_SCHEMAS:
150
+ click.echo(f" {t['name']:<20} {t['description'][:60]}")
151
+
152
+ if pipeline_path:
153
+ path = Path(pipeline_path)
154
+ custom = [t for t in resolve_tools(path) if t["name"] not in {s["name"] for s in BUILTIN_SCHEMAS}]
155
+ if custom:
156
+ click.echo(f"\nPipeline tools ({path.name}):")
157
+ for t in custom:
158
+ click.echo(f" {t['name']}")
159
+
160
+ if GLOBAL_TOOLS_PATH.exists():
161
+ global_tools = list(GLOBAL_TOOLS_PATH.iterdir())
162
+ if global_tools:
163
+ click.echo(f"\nGlobal tools (~/.pipelinex/tools/):")
164
+ for d in global_tools:
165
+ if d.is_dir():
166
+ click.echo(f" {d.name}")
167
+
168
+
169
+ @tools.command("install")
170
+ @click.argument("pipeline_path", type=click.Path(exists=True))
171
+ def tools_install(pipeline_path):
172
+ """Install tool deps for a pipeline without running."""
173
+ path = Path(pipeline_path)
174
+ cache = Path.home() / ".pipelinex"
175
+ count = 0
176
+
177
+ for tools_dir in _all_tool_dirs(path):
178
+ if not tools_dir.exists():
179
+ continue
180
+ for tool_dir in tools_dir.iterdir():
181
+ if tool_dir.is_dir() and (tool_dir / "tool.json").exists():
182
+ try:
183
+ install_tool_deps(tool_dir, cache)
184
+ count += 1
185
+ except Exception as e:
186
+ click.echo(f" Failed {tool_dir.name}: {e}", err=True)
187
+
188
+ click.echo(f"Installed deps for {count} tool(s).")
189
+
190
+
191
+ def _all_tool_dirs(pipeline_path: Path):
192
+ yield pipeline_path / "tools"
193
+ try:
194
+ config = load_pipeline(pipeline_path)
195
+ for step in config.get("steps", []):
196
+ yield pipeline_path / step["id"] / "tools"
197
+ except Exception:
198
+ pass
199
+
200
+
201
+ @main.command()
202
+ @click.argument("pipeline_path", type=click.Path(exists=True))
203
+ @click.option("--errors", is_flag=True, help="Show error report from last failed run")
204
+ def log(pipeline_path, errors):
205
+ """Show run log or error report."""
206
+ path = Path(pipeline_path)
207
+
208
+ if errors:
209
+ report = path / "output" / "errors" / "report.md"
210
+ if report.exists():
211
+ click.echo(report.read_text(encoding="utf-8"))
212
+ else:
213
+ click.echo("No error report found.")
214
+ else:
215
+ log_file = path / "output" / "run.log"
216
+ if log_file.exists():
217
+ click.echo(log_file.read_text(encoding="utf-8"))
218
+ else:
219
+ click.echo("No run log found.")
@@ -0,0 +1,78 @@
1
+ import json
2
+ import re
3
+ from typing import Any
4
+
5
+
6
+ def _extract_context_section(skill_md: str) -> str:
7
+ m = re.search(r'##\s+Context\s*\n(.*?)(?=\n##|\Z)', skill_md, re.DOTALL | re.IGNORECASE)
8
+ return m.group(1).strip() if m else ""
9
+
10
+
11
+ def _classify_tiers(context_section: str, keys: list[str], model_cfg: dict) -> dict[str, str]:
12
+ from .model import call_llm, extract_text
13
+
14
+ prompt = (
15
+ f"A pipeline step has this context guidance:\n\n{context_section}\n\n"
16
+ f"Available state keys: {keys}\n\n"
17
+ "Classify each key as:\n"
18
+ "- 'essential': explicitly needed in full\n"
19
+ "- 'skip': explicitly not needed\n"
20
+ "- 'include': everything else\n\n"
21
+ "Reply with JSON only: {\"key\": \"essential|include|skip\", ...}"
22
+ )
23
+
24
+ try:
25
+ resp = call_llm(model_cfg, [{"role": "user", "content": prompt}], max_tokens=256)
26
+ text = extract_text(resp)
27
+ m = re.search(r'\{[^{}]+\}', text, re.DOTALL)
28
+ if m:
29
+ result = json.loads(m.group())
30
+ valid = {"essential", "include", "skip"}
31
+ return {k: (v if v in valid else "include") for k, v in result.items()}
32
+ except Exception:
33
+ pass
34
+
35
+ return {k: "include" for k in keys}
36
+
37
+
38
+ def _fmt(value: Any) -> str:
39
+ if isinstance(value, str):
40
+ return value
41
+ return f"```json\n{json.dumps(value, indent=2, default=str)}\n```"
42
+
43
+
44
+ def build_context_prompt(
45
+ state: dict,
46
+ skill_md: str,
47
+ handoff: str | None,
48
+ model_cfg: dict | None = None,
49
+ ) -> str:
50
+ context_section = _extract_context_section(skill_md)
51
+ parts = []
52
+
53
+ if handoff:
54
+ parts.append(f"## Handoff from previous step\n\n{handoff}")
55
+
56
+ meta_keys = {"_meta", "handoff", "_input_consumed"}
57
+ state_data = {k: v for k, v in state.items() if k not in meta_keys}
58
+
59
+ if state_data:
60
+ keys = list(state_data.keys())
61
+
62
+ if context_section and model_cfg and keys:
63
+ tiers = _classify_tiers(context_section, keys, model_cfg)
64
+ else:
65
+ tiers = {k: "include" for k in keys}
66
+
67
+ state_parts = []
68
+ for key, value in state_data.items():
69
+ tier = tiers.get(key, "include")
70
+ if tier == "skip":
71
+ continue
72
+ tag = " [ESSENTIAL]" if tier == "essential" else ""
73
+ state_parts.append(f"### {key}{tag}\n{_fmt(value)}")
74
+
75
+ if state_parts:
76
+ parts.append("## Pipeline State\n\n" + "\n\n".join(state_parts))
77
+
78
+ return "\n\n".join(parts)
pipelinex/loader.py ADDED
@@ -0,0 +1,101 @@
1
+ import os
2
+ import re
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+ from dotenv import dotenv_values
7
+
8
+
9
+ def load_env(pipeline_path: Path) -> dict:
10
+ env = dict(os.environ)
11
+ env_file = pipeline_path / ".env"
12
+ if env_file.exists():
13
+ env.update(dotenv_values(env_file))
14
+ return env
15
+
16
+
17
+ def _substitute(obj, env: dict):
18
+ if isinstance(obj, str):
19
+ def replace(m):
20
+ var = m.group(1)
21
+ if var not in env:
22
+ raise EnvironmentError(
23
+ f"Missing environment variable: {var}\n"
24
+ f" Add it to .env or export it in your shell."
25
+ )
26
+ return env[var]
27
+ return re.sub(r'\$\{([^}]+)\}', replace, obj)
28
+ elif isinstance(obj, dict):
29
+ return {k: _substitute(v, env) for k, v in obj.items()}
30
+ elif isinstance(obj, list):
31
+ return [_substitute(i, env) for i in obj]
32
+ return obj
33
+
34
+
35
+ def load_pipeline(pipeline_path: Path) -> dict:
36
+ pipeline_path = Path(pipeline_path)
37
+ yaml_file = pipeline_path / "pipeline.yaml"
38
+ if not yaml_file.exists():
39
+ raise FileNotFoundError(f"pipeline.yaml not found in {pipeline_path}")
40
+
41
+ env = load_env(pipeline_path)
42
+
43
+ with open(yaml_file) as f:
44
+ raw = yaml.safe_load(f)
45
+
46
+ try:
47
+ config = _substitute(raw, env)
48
+ except EnvironmentError as e:
49
+ raise SystemExit(f"ERROR: {e}")
50
+
51
+ config["_path"] = pipeline_path
52
+ config["_env"] = env
53
+ return config
54
+
55
+
56
+ def load_skill_md(pipeline_path: Path, step_id: str | None = None, substep_id: str | None = None) -> str:
57
+ parts = []
58
+
59
+ global_skill = pipeline_path / "SKILL.md"
60
+ if global_skill.exists():
61
+ parts.append(global_skill.read_text(encoding="utf-8"))
62
+
63
+ if step_id:
64
+ step_skill = pipeline_path / step_id / "SKILL.md"
65
+ if step_skill.exists():
66
+ parts.append(step_skill.read_text(encoding="utf-8"))
67
+
68
+ if substep_id:
69
+ substep_skill = pipeline_path / step_id / substep_id / "SKILL.md"
70
+ if substep_skill.exists():
71
+ parts.append(substep_skill.read_text(encoding="utf-8"))
72
+
73
+ return "\n\n---\n\n".join(parts)
74
+
75
+
76
+ def validate_pipeline(config: dict) -> list[str]:
77
+ errors = []
78
+
79
+ if "model" not in config:
80
+ errors.append("Missing 'model' section")
81
+ else:
82
+ if "provider" not in config["model"]:
83
+ errors.append("model.provider is required")
84
+ if "name" not in config["model"]:
85
+ errors.append("model.name is required")
86
+
87
+ if not config.get("steps"):
88
+ errors.append("No steps defined")
89
+ else:
90
+ step_ids = {s["id"] for s in config["steps"] if "id" in s}
91
+ for step in config["steps"]:
92
+ if "id" not in step:
93
+ errors.append(f"Step missing 'id': {step}")
94
+ continue
95
+ for target in step.get("can_goto", []):
96
+ if target not in step_ids:
97
+ errors.append(
98
+ f"Step '{step['id']}' can_goto '{target}' which doesn't exist"
99
+ )
100
+
101
+ return errors