skillflow-py 1.0.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.
- skillflow/__init__.py +97 -0
- skillflow/agent_registry.py +126 -0
- skillflow/context.py +205 -0
- skillflow/core.py +2037 -0
- skillflow/exceptions.py +55 -0
- skillflow/graph.py +754 -0
- skillflow/notifications.py +162 -0
- skillflow/outbox.py +33 -0
- skillflow/plugins/linter/__init__.py +189 -0
- skillflow/plugins/linter/cli.py +56 -0
- skillflow/plugins/linter/tools/skillflow_lint/impl.py +19 -0
- skillflow/plugins/linter/tools/skillflow_lint/tool.yaml +15 -0
- skillflow/plugins/skill_converter/AGENT.md +172 -0
- skillflow/plugins/skill_converter/__init__.py +18 -0
- skillflow/plugins/skill_converter/converter.py +181 -0
- skillflow/plugins/skill_converter/prompts/analyze_skill.md +32 -0
- skillflow/plugins/skill_converter/prompts/design_graph.md +47 -0
- skillflow/plugins/skill_converter/prompts/fix_issues.md +23 -0
- skillflow/plugins/skill_converter/skill_converter.yaml +80 -0
- skillflow/plugins/skill_runner/AGENT.md +161 -0
- skillflow/plugins/skill_runner/__init__.py +19 -0
- skillflow/plugins/skill_runner/runner.py +324 -0
- skillflow/recovery.py +78 -0
- skillflow/schema.py +137 -0
- skillflow/step_validation.py +114 -0
- skillflow/tool_loader.py +123 -0
- skillflow/tools/__init__.py +1 -0
- skillflow/tools/dir_tree/impl.py +32 -0
- skillflow/tools/dir_tree/tool.yaml +10 -0
- skillflow/tools/draft_commit/impl.py +63 -0
- skillflow/tools/draft_commit/tool.yaml +7 -0
- skillflow/tools/file_exists/impl.py +23 -0
- skillflow/tools/file_exists/tool.yaml +11 -0
- skillflow/tools/json_schema/impl.py +47 -0
- skillflow/tools/json_schema/tool.yaml +15 -0
- skillflow/tools/list_tree/impl.py +41 -0
- skillflow/tools/list_tree/tool.yaml +15 -0
- skillflow/tools/notify/impl.py +52 -0
- skillflow/tools/notify/tool.yaml +15 -0
- skillflow/tools/py_compile/impl.py +28 -0
- skillflow/tools/py_compile/tool.yaml +9 -0
- skillflow/tools/pytest/impl.py +25 -0
- skillflow/tools/pytest/tool.yaml +9 -0
- skillflow/tools/read_file/impl.py +26 -0
- skillflow/tools/read_file/tool.yaml +19 -0
- skillflow/tools/repo_apply/impl.py +53 -0
- skillflow/tools/repo_apply/tool.yaml +10 -0
- skillflow/tools/repo_validate/impl.py +68 -0
- skillflow/tools/repo_validate/tool.yaml +13 -0
- skillflow/tools/syntax_lint/impl.py +51 -0
- skillflow/tools/syntax_lint/tool.yaml +10 -0
- skillflow/tools/write/impl.py +24 -0
- skillflow/tools/write/tool.yaml +12 -0
- skillflow/validation.py +72 -0
- skillflow/workspace.py +192 -0
- skillflow/write_tools.py +240 -0
- skillflow_py-1.0.0.dist-info/METADATA +364 -0
- skillflow_py-1.0.0.dist-info/RECORD +61 -0
- skillflow_py-1.0.0.dist-info/WHEEL +5 -0
- skillflow_py-1.0.0.dist-info/licenses/LICENSE +21 -0
- skillflow_py-1.0.0.dist-info/top_level.txt +1 -0
skillflow/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""skillflow — Transactional graph orchestrator for Python.
|
|
2
|
+
|
|
3
|
+
A standalone framework for defining and executing pipeline graphs
|
|
4
|
+
with transactional state management on SQLite. Only depends on
|
|
5
|
+
PyYAML.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from skillflow import SkillFlow, PipelineGraph, StepRunner, StepResult
|
|
10
|
+
|
|
11
|
+
sf = SkillFlow(":memory:")
|
|
12
|
+
graph = PipelineGraph.from_yaml("pipeline.yaml")
|
|
13
|
+
sf.register_graph(graph)
|
|
14
|
+
|
|
15
|
+
run_id = sf.create_run("my_pipeline", {"project_id": "X"})
|
|
16
|
+
sf.start_run(run_id)
|
|
17
|
+
|
|
18
|
+
while True:
|
|
19
|
+
next_node = sf.advance_run(run_id)
|
|
20
|
+
if next_node is None:
|
|
21
|
+
break
|
|
22
|
+
claimed = sf.claim_next_step(run_id)
|
|
23
|
+
if claimed is None:
|
|
24
|
+
continue
|
|
25
|
+
result = await runner.execute(claimed)
|
|
26
|
+
sf.confirm_step(claimed.token, result)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
# ── Public API re-exports ──────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
from skillflow.exceptions import (
|
|
32
|
+
SkillFlowError,
|
|
33
|
+
StepVersionConflict,
|
|
34
|
+
CycleLimitExceeded,
|
|
35
|
+
GraphValidationError,
|
|
36
|
+
OutputValidationError,
|
|
37
|
+
NoMatchingTransition,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from skillflow.graph import (
|
|
41
|
+
Transition,
|
|
42
|
+
StepNode,
|
|
43
|
+
EndCondition,
|
|
44
|
+
EndConditions,
|
|
45
|
+
EndResult,
|
|
46
|
+
PipelineGraph,
|
|
47
|
+
GraphResolver,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
from skillflow.core import (
|
|
51
|
+
SkillFlow,
|
|
52
|
+
StepRunner,
|
|
53
|
+
ClaimToken,
|
|
54
|
+
ClaimedStep,
|
|
55
|
+
StepResult,
|
|
56
|
+
OutboxEvent,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
from skillflow.validation import OutputValidator
|
|
60
|
+
from skillflow.outbox import OutboxConsumer
|
|
61
|
+
from skillflow.recovery import recover_stale_claims
|
|
62
|
+
from skillflow.notifications import NotificationBus, Notification
|
|
63
|
+
from skillflow.agent_registry import AgentRegistry, AgentConfig
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
# Main class
|
|
67
|
+
"SkillFlow",
|
|
68
|
+
# Graph model
|
|
69
|
+
"PipelineGraph",
|
|
70
|
+
"GraphResolver",
|
|
71
|
+
"StepNode",
|
|
72
|
+
"Transition",
|
|
73
|
+
"EndCondition",
|
|
74
|
+
"EndConditions",
|
|
75
|
+
"EndResult",
|
|
76
|
+
# Protocol & values
|
|
77
|
+
"StepRunner",
|
|
78
|
+
"ClaimToken",
|
|
79
|
+
"ClaimedStep",
|
|
80
|
+
"StepResult",
|
|
81
|
+
"OutboxEvent",
|
|
82
|
+
# Utilities
|
|
83
|
+
"OutputValidator",
|
|
84
|
+
"OutboxConsumer",
|
|
85
|
+
"recover_stale_claims",
|
|
86
|
+
"NotificationBus",
|
|
87
|
+
"Notification",
|
|
88
|
+
"AgentRegistry",
|
|
89
|
+
"AgentConfig",
|
|
90
|
+
# Exceptions
|
|
91
|
+
"SkillFlowError",
|
|
92
|
+
"StepVersionConflict",
|
|
93
|
+
"CycleLimitExceeded",
|
|
94
|
+
"GraphValidationError",
|
|
95
|
+
"OutputValidationError",
|
|
96
|
+
"NoMatchingTransition",
|
|
97
|
+
]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Agent registry — validates graph agent_config references exist.
|
|
2
|
+
|
|
3
|
+
Host apps register agent configs at startup. The registry does NOT
|
|
4
|
+
know how to call LLMs — it only stores configs and resolves tool
|
|
5
|
+
schemas so the graph can be fully validated before any run starts.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
sf = SkillFlow(":memory:")
|
|
10
|
+
sf.register_agent_config("researcher", {
|
|
11
|
+
"model": "deepseek/deepseek-v4-flash",
|
|
12
|
+
"tools": ["read_file", "write", "list_tree"],
|
|
13
|
+
"system_prompt": "You are a researcher...",
|
|
14
|
+
})
|
|
15
|
+
# Graph validation will now catch missing agent_config refs.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class AgentConfig:
|
|
26
|
+
"""Opaque config for an agent referenced by name in a graph step.
|
|
27
|
+
|
|
28
|
+
skillflow never interprets these fields — they are passed through
|
|
29
|
+
to the host's StepRunner implementation. The only thing skillflow
|
|
30
|
+
does is validate that the name exists when registering a graph.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
model: str = ""
|
|
35
|
+
tools: list[str] = field(default_factory=list)
|
|
36
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
37
|
+
# Resolved tool schemas (populated when tool_loader is available)
|
|
38
|
+
tool_schemas: dict[str, dict] = field(default_factory=dict)
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict:
|
|
41
|
+
return {
|
|
42
|
+
"name": self.name,
|
|
43
|
+
"model": self.model,
|
|
44
|
+
"tools": self.tools,
|
|
45
|
+
"config": self.config,
|
|
46
|
+
"tool_schemas": self.tool_schemas,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class AgentRegistry:
|
|
51
|
+
"""Registry of agent configs indexed by name.
|
|
52
|
+
|
|
53
|
+
Validates that every graph step's ``agent_config`` references a
|
|
54
|
+
registered agent name. Optionally resolves tool schemas from a
|
|
55
|
+
ToolLoader so the host StepRunner receives everything it needs.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self):
|
|
59
|
+
self._configs: dict[str, AgentConfig] = {}
|
|
60
|
+
|
|
61
|
+
# ── Registration ──────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def register(self, name: str, *,
|
|
64
|
+
model: str = "",
|
|
65
|
+
tools: list[str] | None = None,
|
|
66
|
+
**kwargs) -> AgentConfig:
|
|
67
|
+
"""Register an agent config.
|
|
68
|
+
|
|
69
|
+
Extra kwargs become ``config`` entries (e.g. template, temperature,
|
|
70
|
+
thinking settings — anything the host StepRunner needs).
|
|
71
|
+
"""
|
|
72
|
+
cfg = AgentConfig(
|
|
73
|
+
name=name,
|
|
74
|
+
model=model,
|
|
75
|
+
tools=tools or [],
|
|
76
|
+
config=kwargs,
|
|
77
|
+
)
|
|
78
|
+
self._configs[name] = cfg
|
|
79
|
+
return cfg
|
|
80
|
+
|
|
81
|
+
def register_dict(self, name: str, d: dict) -> AgentConfig:
|
|
82
|
+
"""Register from a flat dict (convenience for YAML-loaded configs).
|
|
83
|
+
|
|
84
|
+
``model`` and ``tools`` are extracted; everything else goes into
|
|
85
|
+
``config``.
|
|
86
|
+
"""
|
|
87
|
+
d = dict(d)
|
|
88
|
+
model = d.pop("model", "")
|
|
89
|
+
tools = d.pop("tools", [])
|
|
90
|
+
return self.register(name, model=model, tools=tools, **d)
|
|
91
|
+
|
|
92
|
+
# ── Query ─────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
def get(self, name: str) -> AgentConfig | None:
|
|
95
|
+
return self._configs.get(name)
|
|
96
|
+
|
|
97
|
+
def list_names(self) -> list[str]:
|
|
98
|
+
return list(self._configs.keys())
|
|
99
|
+
|
|
100
|
+
def __contains__(self, name: str) -> bool:
|
|
101
|
+
return name in self._configs
|
|
102
|
+
|
|
103
|
+
def __len__(self) -> int:
|
|
104
|
+
return len(self._configs)
|
|
105
|
+
|
|
106
|
+
# ── Tool schema resolution ────────────────────────────────
|
|
107
|
+
|
|
108
|
+
def resolve_tool_schemas(self, tool_loader) -> None:
|
|
109
|
+
"""Resolve tool schemas for all registered agent configs.
|
|
110
|
+
|
|
111
|
+
Called once after all configs and tools are registered.
|
|
112
|
+
For each tool name in each agent config, loads the tool
|
|
113
|
+
schema from the ToolLoader and caches it in tool_schemas.
|
|
114
|
+
"""
|
|
115
|
+
for cfg in self._configs.values():
|
|
116
|
+
cfg.tool_schemas = {}
|
|
117
|
+
for tool_name in cfg.tools:
|
|
118
|
+
try:
|
|
119
|
+
cfg.tool_schemas[tool_name] = tool_loader.load_schema(tool_name)
|
|
120
|
+
except ImportError:
|
|
121
|
+
pass # tool not found — graph validation will catch
|
|
122
|
+
|
|
123
|
+
# ── Serialization ─────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def to_dict(self) -> dict[str, dict]:
|
|
126
|
+
return {name: cfg.to_dict() for name, cfg in self._configs.items()}
|
skillflow/context.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Context resolution from step config context specs.
|
|
2
|
+
|
|
3
|
+
Resolves ``context`` entries from a step node's config into assembled
|
|
4
|
+
content for prompt injection. Supports five source types:
|
|
5
|
+
|
|
6
|
+
- ``{config: "name", output: "file"}`` — cross-config read
|
|
7
|
+
- ``{config: "name", step: "id", output: "file"}`` — cross-config from specific step
|
|
8
|
+
- ``{step: "id", file: "name", mode: "full"|"summary"|"interfaces"}`` — same-config read
|
|
9
|
+
- ``{step: "id"}`` — all files from that step's directory
|
|
10
|
+
- ``{tool: "name"}`` — dynamic tool call (e.g. dir_tree)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ContextResolver:
|
|
19
|
+
"""Resolves context sources into assembled content."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, workspace_root: Path, tool_loader=None):
|
|
22
|
+
self._workspace_root = Path(workspace_root)
|
|
23
|
+
self._tool_loader = tool_loader
|
|
24
|
+
|
|
25
|
+
def resolve(self, specs: list[dict],
|
|
26
|
+
current_config: str = "") -> dict[str, str]:
|
|
27
|
+
"""Resolve a list of context specs into a dict of label→content.
|
|
28
|
+
|
|
29
|
+
Returns a dict keyed by human-readable labels (e.g. "Project Brief",
|
|
30
|
+
"Architecture Design") suitable for prompt assembly.
|
|
31
|
+
"""
|
|
32
|
+
result: dict[str, str] = {}
|
|
33
|
+
for spec in specs:
|
|
34
|
+
source = spec.get("source", spec)
|
|
35
|
+
label, content = self._resolve_one(source, current_config)
|
|
36
|
+
if content:
|
|
37
|
+
result[label] = content
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
def _resolve_one(self, source: dict, current_config: str) -> tuple[str, str]:
|
|
41
|
+
if "config" in source:
|
|
42
|
+
return self._resolve_cross_config(source, current_config)
|
|
43
|
+
if "step" in source:
|
|
44
|
+
return self._resolve_step_output(source, current_config)
|
|
45
|
+
if "tool" in source:
|
|
46
|
+
return self._resolve_tool(source)
|
|
47
|
+
return "", ""
|
|
48
|
+
|
|
49
|
+
def _resolve_cross_config(self, source: dict, current_config: str) -> tuple[str, str]:
|
|
50
|
+
config_name = source["config"]
|
|
51
|
+
output_file = source["output"]
|
|
52
|
+
cfg_dir = self._workspace_root / config_name
|
|
53
|
+
if not cfg_dir.exists():
|
|
54
|
+
return "", ""
|
|
55
|
+
|
|
56
|
+
# If step is specified, read from that step's directory
|
|
57
|
+
if "step" in source:
|
|
58
|
+
step_dir = cfg_dir / source["step"]
|
|
59
|
+
if step_dir.exists() and step_dir.is_dir():
|
|
60
|
+
file_path = step_dir / output_file
|
|
61
|
+
if file_path.exists():
|
|
62
|
+
try:
|
|
63
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
64
|
+
label = f"{config_name}/{source['step']}/{output_file}"
|
|
65
|
+
return label, content
|
|
66
|
+
except Exception:
|
|
67
|
+
return "", ""
|
|
68
|
+
|
|
69
|
+
# Otherwise scan all step dirs (new-style) and legacy Outbox_Final_* dirs
|
|
70
|
+
for d in sorted(cfg_dir.glob("*")):
|
|
71
|
+
if d.name.endswith(".tmp") or d.name.startswith("Outbox_Draft"):
|
|
72
|
+
continue
|
|
73
|
+
if not d.is_dir():
|
|
74
|
+
continue
|
|
75
|
+
file_path = d / output_file
|
|
76
|
+
if file_path.exists():
|
|
77
|
+
try:
|
|
78
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
79
|
+
label = f"{config_name}/{output_file}"
|
|
80
|
+
return label, content
|
|
81
|
+
except Exception:
|
|
82
|
+
continue
|
|
83
|
+
|
|
84
|
+
return "", ""
|
|
85
|
+
|
|
86
|
+
def _resolve_step_output(self, source: dict, current_config: str) -> tuple[str, str]:
|
|
87
|
+
step_id = source["step"]
|
|
88
|
+
output_file = source.get("output") or source.get("file")
|
|
89
|
+
mode = source.get("mode", "full")
|
|
90
|
+
cfg = current_config or "dpe_default"
|
|
91
|
+
|
|
92
|
+
# New path: workspace/{project}/{config}/{step_id}/
|
|
93
|
+
step_dir = self._workspace_root / cfg / step_id
|
|
94
|
+
|
|
95
|
+
if not step_dir.exists() or not step_dir.is_dir():
|
|
96
|
+
return "", ""
|
|
97
|
+
|
|
98
|
+
# No specific file requested — return all files concatenated
|
|
99
|
+
if not output_file:
|
|
100
|
+
parts: list[str] = []
|
|
101
|
+
for f in sorted(step_dir.rglob("*")):
|
|
102
|
+
if f.is_file() and f.name != ".gitkeep":
|
|
103
|
+
try:
|
|
104
|
+
content = f.read_text(encoding="utf-8", errors="replace")
|
|
105
|
+
except Exception:
|
|
106
|
+
continue
|
|
107
|
+
rel = f.relative_to(step_dir)
|
|
108
|
+
parts.append(f"### {rel}\n{content}")
|
|
109
|
+
if not parts:
|
|
110
|
+
return "", ""
|
|
111
|
+
label = f"Step {step_id}"
|
|
112
|
+
return label, "\n\n".join(parts)
|
|
113
|
+
|
|
114
|
+
# Specific file: glob for patterns like "tasks/*.json"
|
|
115
|
+
if "*" in output_file:
|
|
116
|
+
parts = []
|
|
117
|
+
for f in sorted(step_dir.glob(output_file)):
|
|
118
|
+
if f.is_file():
|
|
119
|
+
try:
|
|
120
|
+
content = f.read_text(encoding="utf-8", errors="replace")
|
|
121
|
+
except Exception:
|
|
122
|
+
continue
|
|
123
|
+
parts.append(f"### {f.name}\n{content}")
|
|
124
|
+
if not parts:
|
|
125
|
+
return "", ""
|
|
126
|
+
label = f"Step {step_id} — {output_file}"
|
|
127
|
+
return label, "\n\n".join(parts)
|
|
128
|
+
|
|
129
|
+
file_path = step_dir / output_file
|
|
130
|
+
if not file_path.exists():
|
|
131
|
+
return "", ""
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
content = file_path.read_text(encoding="utf-8", errors="replace")
|
|
135
|
+
except Exception:
|
|
136
|
+
return "", ""
|
|
137
|
+
|
|
138
|
+
if mode == "summary":
|
|
139
|
+
lines = content.splitlines()
|
|
140
|
+
if len(lines) > 100:
|
|
141
|
+
content = "\n".join(lines[:100]) + "\n... [summary truncated]"
|
|
142
|
+
elif mode == "interfaces":
|
|
143
|
+
content = self._extract_interfaces(content)
|
|
144
|
+
|
|
145
|
+
label = f"Step {step_id} — {output_file}"
|
|
146
|
+
return label, content
|
|
147
|
+
|
|
148
|
+
def _resolve_tool(self, source: dict) -> tuple[str, str]:
|
|
149
|
+
tool_name = source["tool"]
|
|
150
|
+
if not self._tool_loader:
|
|
151
|
+
return f"[{tool_name}]", ""
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
fn = self._tool_loader.load_fn(tool_name)
|
|
155
|
+
result = fn(
|
|
156
|
+
workspace_root=str(self._workspace_root),
|
|
157
|
+
project_root=str(self._workspace_root / "project"),
|
|
158
|
+
)
|
|
159
|
+
if isinstance(result, dict):
|
|
160
|
+
content = result.get("tree", result.get("content", str(result)))
|
|
161
|
+
else:
|
|
162
|
+
content = str(result)
|
|
163
|
+
label = f"[{tool_name}]"
|
|
164
|
+
return label, content
|
|
165
|
+
except Exception:
|
|
166
|
+
return f"[{tool_name}]", ""
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _extract_interfaces(content: str) -> str:
|
|
170
|
+
"""Extract API/interface sections from architecture docs."""
|
|
171
|
+
import re
|
|
172
|
+
lines = content.splitlines()
|
|
173
|
+
result: list[str] = []
|
|
174
|
+
interface_keywords = {
|
|
175
|
+
"interface", "api", "contract", "endpoint", "module boundary",
|
|
176
|
+
"component", "data flow", "interaction"
|
|
177
|
+
}
|
|
178
|
+
in_section = False
|
|
179
|
+
section_depth = 0
|
|
180
|
+
|
|
181
|
+
for line in lines:
|
|
182
|
+
m = re.match(r'^(#{1,4})\s+(.*)', line)
|
|
183
|
+
if m:
|
|
184
|
+
header_text = m.group(2).lower()
|
|
185
|
+
depth = len(m.group(1))
|
|
186
|
+
if any(kw in header_text for kw in interface_keywords):
|
|
187
|
+
in_section = True
|
|
188
|
+
section_depth = depth
|
|
189
|
+
result.append(line)
|
|
190
|
+
elif in_section and depth <= section_depth:
|
|
191
|
+
in_section = False
|
|
192
|
+
if any(kw in header_text for kw in interface_keywords):
|
|
193
|
+
in_section = True
|
|
194
|
+
section_depth = depth
|
|
195
|
+
result.append(line)
|
|
196
|
+
elif in_section:
|
|
197
|
+
result.append(line)
|
|
198
|
+
elif in_section:
|
|
199
|
+
result.append(line)
|
|
200
|
+
|
|
201
|
+
if not result:
|
|
202
|
+
return "\n".join(lines[:150]) + "\n... [no interface sections found]"
|
|
203
|
+
|
|
204
|
+
extracted = "\n".join(result)
|
|
205
|
+
return extracted[:8000]
|