已合并
feat(symphony): symphony 代码迁入 (retrieval) #2174
feat(symphony): symphony 代码迁入 (retrieval) #2174
已合并
xutuo创建于 8月4日
137 个文件变更+27855-3
@@ -9,6 +9,10 @@ __pycache__/
9# Distribution / packaging9# Distribution / packaging
10.Python10.Python
11build/11build/
12+!openjiuwen/symphony/retrieval/build/
13+!openjiuwen/symphony/retrieval/build/**
14+!tests/unit_tests/symphony/retrieval/build/
15+!tests/unit_tests/symphony/retrieval/build/**
12develop-eggs/16develop-eggs/
13dist/17dist/
14downloads/18downloads/
@@ -280,4 +284,4 @@ tests/system_tests/config_llm_local.yaml
280tests/unit_tests/extensions/context_evolver/memory_file/284tests/unit_tests/extensions/context_evolver/memory_file/
281 285 
282/.codex/286/.codex/
283-/.agents/287+/.agents/
@@ -1,6 +1,12 @@
1# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.1# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
2"""Public Symphony fingerprint, evaluation, graph, and orchestration APIs."""2"""Public Symphony fingerprint, evaluation, graph, and orchestration APIs."""
3 3 
4+from __future__ import annotations
5+ 
6+from importlib import import_module
7+from types import ModuleType
8+from typing import TYPE_CHECKING
9+ 
4from openjiuwen.symphony.evaluation import EvaluationContext, EvaluationSuite, EvaluationWindow, Evaluator10from openjiuwen.symphony.evaluation import EvaluationContext, EvaluationSuite, EvaluationWindow, Evaluator
5from openjiuwen.symphony.interfaces import (11from openjiuwen.symphony.interfaces import (
6 AtomicCapabilityProvider,12 AtomicCapabilityProvider,
@@ -58,8 +64,14 @@ from openjiuwen.symphony.shared.fingerprint import (
58 SkillManifestParser,64 SkillManifestParser,
59)65)
60 66 
67+if TYPE_CHECKING:
68+ from openjiuwen.symphony import agent as agent
69+ from openjiuwen.symphony import retrieval as retrieval
70+ from openjiuwen.symphony import shared as shared
71+ 
61CapabilityInput = ParameterSpec72CapabilityInput = ParameterSpec
62CapabilityOutput = ArtifactSpec73CapabilityOutput = ArtifactSpec
74+_LAZY_MODULES = frozenset({"agent", "retrieval", "shared"})
63 75 
64__all__ = [76__all__ = [
65 "FINGERPRINT_ARTIFACT_FILENAME",77 "FINGERPRINT_ARTIFACT_FILENAME",
@@ -118,4 +130,15 @@ __all__ = [
118 "SuggestionPriority",130 "SuggestionPriority",
119 "SymphonyLLM",131 "SymphonyLLM",
120 "SymphonyRuntime",132 "SymphonyRuntime",
133+ "agent",
134+ "retrieval",
135+ "shared",
121]136]
137+ 
138+ 
139+def __getattr__(name: str) -> ModuleType:
140+ if name in _LAZY_MODULES:
141+ module = import_module(f"{__name__}.{name}")
142+ globals()[name] = module
143+ return module
144+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,21 @@
1+"""Agent-facing SDK entry points for Symphony retrieval."""
2+ 
3+from .retrieval_toolkit import (
4+ AgenticRetrievalConfig,
5+ AgenticSkillRetrievalToolkit,
6+ LLMConfig,
7+ SkillIndexBuildConfig,
8+ SkillIndexRuntimeConfig,
9+ SkillRecord,
10+ scan_skill_records,
11+)
12+ 
13+__all__ = [
14+ "AgenticRetrievalConfig",
15+ "AgenticSkillRetrievalToolkit",
16+ "LLMConfig",
17+ "SkillIndexBuildConfig",
18+ "SkillIndexRuntimeConfig",
19+ "SkillRecord",
20+ "scan_skill_records",
21+]
@@ -0,0 +1,1715 @@
1+from __future__ import annotations
2+ 
3+import hashlib
4+import json
5+import shutil
6+import threading
7+import time
8+from dataclasses import asdict, dataclass, field, replace
9+from datetime import datetime, timezone
10+from pathlib import Path
11+from tempfile import TemporaryDirectory
12+from typing import Any, Callable, Iterable, Sequence
13+ 
14+from openjiuwen.symphony.retrieval.build.io import load_tree_preset
15+from openjiuwen.symphony.retrieval.build.workflows.artifacts import BuildConfig
16+from openjiuwen.symphony.retrieval.build.workflows.index_builder import IndexBuilder
17+from openjiuwen.symphony.retrieval.common.models import RetrieverItem, RetrieverNode
18+from openjiuwen.symphony.retrieval.common.prompts import AGENTIC_RETRIEVAL_YAML, get_prompt
19+from openjiuwen.symphony.retrieval.search.artifacts.loading import (
20+ CatalogRecord,
21+ LoadedRetrieverIndex,
22+ load_retriever_index,
23+)
24+from openjiuwen.symphony.retrieval.search.runtime.subtree import DefaultCurrentSubtreeProvider
25+from openjiuwen.symphony.retrieval.search.runtime.types import ProgressiveRetrieverConfig, SearchCursor
26+ 
27+TREE_INDEX_FILENAME = "tree_index.yaml"
28+CATALOG_FILENAME = "catalog.jsonl"
29+MANIFEST_FILENAME = "manifest.json"
30+STATE_FILENAME = "state.json"
31+ 
32+_TASKS: dict[str, "_BuildTask"] = {}
33+_TASKS_LOCK = threading.RLock()
34+ 
35+ 
36+@dataclass(frozen=True)
37+class SkillRecord:
38+ name: str
39+ description: str = ""
40+ worker_id: str = ""
41+ skill_md_path: str = ""
42+ enabled: bool = True
43+ metadata: dict[str, Any] = field(default_factory=dict)
44+ content: str = ""
45+ content_hash: str = ""
46+ 
47+ @property
48+ def resolved_worker_id(self) -> str:
49+ return str(self.worker_id or self.name).strip()
50+ 
51+ 
52+@dataclass(frozen=True)
53+class LLMConfig:
54+ model: str = ""
55+ api_key: str = ""
56+ base_url: str = ""
57+ client: Any | None = None
58+ seed: int | None = None
59+ 
60+ 
61+@dataclass(frozen=True)
62+class SkillIndexBuildConfig:
63+ max_depth: int = 6
64+ branching_factor: int = 128
65+ max_workers: int = 2
66+ max_retries: int = 2
67+ request_timeout_seconds: float = 420.0
68+ total_timeout_seconds: float = 0.0
69+ classification_batch_limit: int = 32
70+ root_categories: Any = None
71+ caching: bool = False
72+ context_window: int = 0
73+ max_output_tokens: int = 0
74+ discovery_seed: int = 42
75+ postprocess_enabled: bool = True
76+ postprocess_max_passes: int = 1
77+ postprocess_min_skills: int = 6
78+ equivalence_enabled: bool = True
79+ equivalence_max_groups_per_parent: int = 6
80+ equivalence_allow_singleton_groups: bool = True
81+ equivalence_min_lexical_similarity: float = 0.12
82+ deterministic_prompts: bool = True
83+ prompt_fingerprint_version: str = "v1"
84+ cache_observability: bool = True
85+ skill_profiles_enabled: bool = False
86+ skill_profile_select_rules_enabled: bool = True
87+ skill_profile_batch_size: int = 48
88+ skill_profile_description_limit: int = 140
89+ skill_profile_rule_limit: int = 120
90+ incremental_max_change_ratio: float = 0.25
91+ incremental_min_add_confidence: float = 0.18
92+ incremental_min_add_confidence_margin: float = 0.04
93+ incremental_branch_imbalance_ratio: float = 3.0
94+ generate_tree_html: bool = False
95+ preserve_previous_index_on_failure: bool = True
96+ strict_failure: bool = False
97+ 
98+ 
99+@dataclass(frozen=True)
100+class SkillIndexRuntimeConfig:
101+ index_root: str | Path
102+ state_filename: str = STATE_FILENAME
103+ 
104+ 
105+@dataclass(frozen=True)
106+class AgenticRetrievalConfig:
107+ top_k: int = 10
108+ compact_codes_enabled: bool = False
109+ flatten_tree: bool = False
110+ max_exposure_depth: int = 1
111+ exposure_threshold: int = 12
112+ max_tokens: int = 96
113+ request_timeout_seconds: float = 120.0
114+ index_build_tool_name: str = "skill_index_build"
115+ branch_explore_tool_name: str = "skill_branch_explore"
116+ branch_peek_tool_name: str = "skill_branch_peek"
117+ 
118+ 
119+@dataclass
120+class _BuildTask:
121+ thread: threading.Thread
122+ cancel_event: threading.Event
123+ build_id: str
124+ 
125+ 
126+class SkillIndexBuildCancelled(RuntimeError):
127+ """Raised when a cooperative index build cancellation is requested."""
128+ 
129+ 
130+class SkillIndexBuildTimeout(RuntimeError):
131+ """Raised when an index build exceeds the configured total timeout."""
132+ 
133+ 
134+class AgenticSkillRetrievalToolkit:
135+ """Single SDK entrypoint for indexed, agent-controlled skill retrieval."""
136+ 
137+ def __init__(
138+ self,
139+ *,
140+ index_root: str | Path,
141+ skills: Sequence[SkillRecord | dict[str, Any]] | None = None,
142+ skills_dir: str | Path | None = None,
143+ build_config: SkillIndexBuildConfig | None = None,
144+ retrieval_config: AgenticRetrievalConfig | None = None,
145+ llm_config: LLMConfig | None = None,
146+ visible_skill_names: Iterable[str] | None = None,
147+ ) -> None:
148+ self.index_root = Path(index_root).expanduser().resolve()
149+ self.index_dir = self.index_root / "index"
150+ self._build_config = build_config or SkillIndexBuildConfig()
151+ self._retrieval_config = retrieval_config or AgenticRetrievalConfig()
152+ self._llm_config = llm_config or LLMConfig()
153+ self._skills = _coerce_skill_records(skills)
154+ self._skills_dir = Path(skills_dir).expanduser().resolve() if skills_dir else None
155+ self._visible_skill_names = _normalize_visible_skill_names(visible_skill_names)
156+ self._loaded_index: LoadedRetrieverIndex | None = None
157+ self._filtered_root: RetrieverNode | None = None
158+ self._node_by_id: dict[str, RetrieverNode] = {}
159+ self._path_by_id: dict[str, tuple[str, ...]] = {}
160+ self._stats_by_id: dict[str, dict[str, int]] = {}
161+ self._catalog_by_payload: dict[str, CatalogRecord] = {}
162+ 
163+ def build_index(
164+ self,
165+ *,
166+ skills: Sequence[SkillRecord | dict[str, Any]] | None = None,
167+ skills_dir: str | Path | None = None,
168+ force: bool = False,
169+ build_config: SkillIndexBuildConfig | None = None,
170+ llm_config: LLMConfig | None = None,
171+ progress_callback: Callable[[dict[str, Any]], None] | None = None,
172+ cancel_token: Callable[[], bool] | threading.Event | None = None,
173+ _build_id: str | None = None,
174+ ) -> dict[str, Any]:
175+ records = self._resolve_records(skills=skills, skills_dir=skills_dir)
176+ if skills is not None:
177+ self._skills = list(records)
178+ if skills_dir is not None:
179+ self._skills_dir = Path(skills_dir).expanduser()
180+ self._skills = list(records)
181+ build_cfg = build_config or self._build_config
182+ llm_cfg = llm_config or self._llm_config
183+ if build_config is not None:
184+ self._build_config = build_cfg
185+ if llm_config is not None:
186+ self._llm_config = llm_cfg
187+ started = time.monotonic()
188+ build_id = str(_build_id or _new_build_id())
189+ fingerprint = _index_fingerprint(records, build_cfg)
190+ previous_index_state = self._read_state()
191+ previous_index_available = _is_complete_index(self.index_dir)
192+ self.index_root.mkdir(parents=True, exist_ok=True)
193+ 
194+ if not records:
195+ _cleanup_index(self.index_dir)
196+ state = _build_state(
197+ status="failed",
198+ stage="scan",
199+ message="No enabled skills were provided.",
200+ error="No enabled skills were provided.",
201+ progress=1.0,
202+ build_id=build_id,
203+ force=force,
204+ indexed_count=0,
205+ fingerprint=fingerprint,
206+ finished=True,
207+ )
208+ self._write_state(state)
209+ return _result(False, "Skill index build failed: no enabled skills were provided.", data=state)
210+ 
211+ if not force and self._is_fresh(fingerprint):
212+ state = _build_state(
213+ status="success",
214+ stage="reuse",
215+ message="Existing skill index is fresh; reused without rebuilding.",
216+ progress=1.0,
217+ build_id=build_id,
218+ force=force,
219+ indexed_count=len(records),
220+ fingerprint=fingerprint,
221+ record_hashes=_record_hashes(records),
222+ finished=True,
223+ elapsed_seconds=time.monotonic() - started,
224+ )
225+ self._write_state(state)
226+ return _result(True, f"Skill index is fresh. {len(records)} skills indexed.", data=state)
227+ 
228+ cancel_check = _cancel_check(cancel_token)
229+ build_check = _build_check(
230+ cancel_check=cancel_check,
231+ started=started,
232+ total_timeout_seconds=build_cfg.total_timeout_seconds,
233+ )
234+ self._set_running_state(
235+ build_id=build_id,
236+ stage="prepare",
237+ message="Preparing skill index build.",
238+ progress=0.05,
239+ force=force,
240+ indexed_count=len(records),
241+ fingerprint=fingerprint,
242+ )
243+ _emit(progress_callback, self.load_index_status())
244+ try:
245+ build_check("prepare")
246+ except SkillIndexBuildCancelled:
247+ return self._cancelled_result(build_id=build_id, started=started, fingerprint=fingerprint)
248+ except SkillIndexBuildTimeout as exc:
249+ return self._failed_build_result(
250+ error=_normalize_error(exc),
251+ build_id=build_id,
252+ started=started,
253+ fingerprint=fingerprint,
254+ force=force,
255+ preserve_previous_index=build_cfg.preserve_previous_index_on_failure,
256+ previous_state=previous_index_state,
257+ previous_index_available=previous_index_available,
258+ )
259+ 
260+ if build_cfg.strict_failure and not (llm_cfg.model and (llm_cfg.client is not None or llm_cfg.api_key)):
261+ error = "Skill index build requires a model and API key in strict failure mode."
262+ if not build_cfg.preserve_previous_index_on_failure:
263+ _cleanup_index(self.index_dir)
264+ state = _build_state(
265+ status="failed",
266+ stage="llm_config",
267+ message="Build LLM configuration is missing.",
268+ error=error,
269+ progress=1.0,
270+ build_id=build_id,
271+ force=force,
272+ indexed_count=0,
273+ fingerprint=fingerprint,
274+ finished=True,
275+ elapsed_seconds=time.monotonic() - started,
276+ )
277+ _set_failed_index_state(
278+ state,
279+ previous_state=previous_index_state,
280+ previous_index_available=previous_index_available,
281+ previous_index_preserved=build_cfg.preserve_previous_index_on_failure,
282+ attempted_fingerprint=fingerprint,
283+ )
284+ self._write_state(state)
285+ return _result(
286+ False,
287+ f"Skill index build failed: {error}",
288+ data={
289+ **state,
290+ "index_updated": False,
291+ "previous_index_available": previous_index_available,
292+ "previous_index_preserved": (
293+ previous_index_available and build_cfg.preserve_previous_index_on_failure
294+ ),
295+ },
296+ error={"code": "llm_config_missing", "message": error},
297+ )
298+ 
299+ with TemporaryDirectory(prefix="symphony-skill-index-") as tmp:
300+ tmp_root = Path(tmp)
301+ item_jsonl = tmp_root / "skills.jsonl"
302+ operation, operation_records = self._select_build_plan(records=records, force=force)
303+ _write_records_jsonl(operation_records, item_jsonl)
304+ output_dir = tmp_root / "index"
305+ try:
306+ self._set_running_state(
307+ build_id=build_id,
308+ stage="build",
309+ message="Building skill tree index.",
310+ progress=0.2,
311+ force=force,
312+ indexed_count=len(records),
313+ fingerprint=fingerprint,
314+ )
315+ _emit(progress_callback, self.load_index_status())
316+ try:
317+ build_check("build")
318+ except SkillIndexBuildCancelled:
319+ return self._cancelled_result(build_id=build_id, started=started, fingerprint=fingerprint)
320+ config = _to_retrieval_build_config(build_cfg, llm_cfg)
321+ if operation == "build":
322+ _run_index_builder(
323+ operation=operation,
324+ item_jsonl_path=item_jsonl,
325+ output_dir=output_dir,
326+ base_index_dir=self.index_dir,
327+ config=config,
328+ )
329+ else:
330+ try:
331+ _run_index_builder(
332+ operation=operation,
333+ item_jsonl_path=item_jsonl,
334+ output_dir=output_dir,
335+ base_index_dir=self.index_dir,
336+ config=config,
337+ )
338+ except Exception:
339+ self._set_running_state(
340+ build_id=build_id,
341+ stage="build",
342+ message="Incremental skill index build failed; rebuilding the full index.",
343+ progress=0.35,
344+ force=force,
345+ indexed_count=len(records),
346+ fingerprint=fingerprint,
347+ )
348+ shutil.rmtree(output_dir, ignore_errors=True)
349+ _write_records_jsonl(records, item_jsonl)
350+ _run_index_builder(
351+ operation="build",
352+ item_jsonl_path=item_jsonl,
353+ output_dir=output_dir,
354+ base_index_dir=self.index_dir,
355+ config=config,
356+ )
357+ try:
358+ build_check("publish")
359+ except SkillIndexBuildCancelled:
360+ return self._cancelled_result(build_id=build_id, started=started, fingerprint=fingerprint)
361+ if not _is_complete_index(output_dir):
362+ raise RuntimeError("index artifacts are incomplete")
363+ self._set_running_state(
364+ build_id=build_id,
365+ stage="publish",
366+ message="Publishing skill index.",
367+ progress=0.9,
368+ force=force,
369+ indexed_count=len(records),
370+ fingerprint=fingerprint,
371+ )
372+ _publish_index(candidate_dir=output_dir, index_dir=self.index_dir)
373+ elapsed = time.monotonic() - started
374+ state = _build_state(
375+ status="success",
376+ stage="success",
377+ message="Skill index build completed.",
378+ progress=1.0,
379+ build_id=build_id,
380+ force=force,
381+ indexed_count=len(records),
382+ fingerprint=fingerprint,
383+ record_hashes=_record_hashes(records),
384+ finished=True,
385+ elapsed_seconds=elapsed,
386+ )
387+ self._write_state(state)
388+ self._clear_loaded_index()
389+ return _result(
390+ True,
391+ f"Skill index build completed. {len(records)} skills indexed.",
392+ data={**state, "index_dir": str(self.index_dir), "elapsed_seconds": elapsed},
393+ )
394+ except Exception as exc:
395+ error = _normalize_error(exc)
396+ if not build_cfg.preserve_previous_index_on_failure:
397+ _cleanup_index(self.index_dir)
398+ state = _build_state(
399+ status="failed",
400+ stage="failed",
401+ message="Skill index build failed.",
402+ error=error,
403+ progress=1.0,
404+ build_id=build_id,
405+ force=force,
406+ indexed_count=0,
407+ fingerprint=fingerprint,
408+ finished=True,
409+ elapsed_seconds=time.monotonic() - started,
410+ )
411+ _set_failed_index_state(
412+ state,
413+ previous_state=previous_index_state,
414+ previous_index_available=previous_index_available,
415+ previous_index_preserved=build_cfg.preserve_previous_index_on_failure,
416+ attempted_fingerprint=fingerprint,
417+ )
418+ self._write_state(state)
419+ return _result(
420+ False,
421+ f"Skill index build failed: {error}",
422+ data={
423+ **state,
424+ "index_updated": False,
425+ "previous_index_available": previous_index_available,
426+ "previous_index_preserved": (
427+ previous_index_available and build_cfg.preserve_previous_index_on_failure
428+ ),
429+ },
430+ error={"code": "build_failed", "message": error},
431+ )
432+ 
433+ def build_index_async(
434+ self,
435+ *,
436+ skills: Sequence[SkillRecord | dict[str, Any]] | None = None,
437+ skills_dir: str | Path | None = None,
438+ force: bool = False,
439+ build_config: SkillIndexBuildConfig | None = None,
440+ llm_config: LLMConfig | None = None,
441+ replace_running: bool = False,
442+ ) -> dict[str, Any]:
443+ key = str(self.index_root)
444+ with _TASKS_LOCK:
445+ existing = _TASKS.get(key)
446+ if existing and existing.thread.is_alive():
447+ if not replace_running:
448+ return _result(
449+ True,
450+ "Skill index build is already running.",
451+ data={"build_id": existing.build_id, "state": "running"},
452+ )
453+ existing.cancel_event.set()
454+ cancel_event = threading.Event()
455+ build_id = _new_build_id()
456+ thread = threading.Thread(
457+ target=self._run_async_build,
458+ kwargs={
459+ "build_id": build_id,
460+ "cancel_event": cancel_event,
461+ "skills": skills,
462+ "skills_dir": skills_dir,
463+ "force": force,
464+ "build_config": build_config,
465+ "llm_config": llm_config,
466+ "_build_id": build_id,
467+ },
468+ daemon=True,
469+ name=f"symphony-skill-index-{build_id}",
470+ )
471+ _TASKS[key] = _BuildTask(thread=thread, cancel_event=cancel_event, build_id=build_id)
472+ thread.start()
473+ return _result(True, "Skill index build started.", data={"build_id": build_id, "state": "running"})
474+ 
475+ def check_build_status(
476+ self,
477+ *,
478+ build_id: str | None = None,
479+ include_logs: bool = True,
480+ include_inventory: bool = False,
481+ refresh_inventory: bool = False,
482+ ) -> dict[str, Any]:
483+ status = self.load_index_status(
484+ build_id=build_id,
485+ include_logs=include_logs,
486+ include_inventory=include_inventory,
487+ refresh_inventory=refresh_inventory,
488+ )
489+ message = _status_message(str(status.get("status") or "idle"))
490+ return _result(True, message, data=status)
491+ 
492+ def cancel_build(
493+ self,
494+ *,
495+ build_id: str | None = None,
496+ wait: bool = False,
497+ timeout_seconds: float = 5.0,
498+ ) -> dict[str, Any]:
499+ key = str(self.index_root)
500+ cancelled = False
501+ with _TASKS_LOCK:
502+ task = _TASKS.get(key)
503+ if _is_running_build_task(task, build_id):
504+ task.cancel_event.set()
505+ cancelled = True
506+ state = self._read_state()
507+ build = dict(state.get("build") or {})
508+ if cancelled or build.get("status") == "running":
509+ build["status"] = "cancelled"
510+ build["stage"] = "cancelled"
511+ build["message"] = "Skill index build cancellation requested."
512+ build["progress"] = 1.0
513+ build["finished_at"] = _now_iso()
514+ state["build"] = build
515+ self._write_state(state)
516+ if wait and cancelled:
517+ deadline = time.monotonic() + max(0.0, float(timeout_seconds))
518+ while time.monotonic() < deadline:
519+ with _TASKS_LOCK:
520+ task = _TASKS.get(key)
521+ if task is None or not task.thread.is_alive():
522+ break
523+ time.sleep(0.05)
524+ return _result(
525+ True,
526+ "Skill index build cancellation requested." if cancelled else "No running skill index build.",
527+ data={"state": "cancelled" if cancelled else "idle", "build_id": build_id or build.get("build_id", "")},
528+ )
529+ 
530+ def load_index_status(
531+ self,
532+ *,
533+ build_id: str | None = None,
534+ include_logs: bool = True,
535+ include_inventory: bool = False,
536+ refresh_inventory: bool = False,
537+ ) -> dict[str, Any]:
538+ state = self._read_state()
539+ build = dict(state.get("build") or {})
540+ index_exists = _is_complete_index(self.index_dir)
541+ if build.get("status") == "running" and not self._has_running_task(build.get("build_id")):
542+ build.update(
543+ {
544+ "status": "failed",
545+ "stage": "interrupted",
546+ "message": "Skill index build was interrupted.",
547+ "error": "No running build task was found for the persisted running state.",
548+ "finished_at": _now_iso(),
549+ "progress": 1.0,
550+ }
551+ )
552+ state["build"] = build
553+ self._write_state(state)
554+ if not index_exists:
555+ state["indexed_count"] = 0
556+ if build.get("status") == "success":
557+ build.update(
558+ {
559+ "status": "idle",
560+ "stage": "missing",
561+ "message": "No usable skill index is available.",
562+ "error": "",
563+ "progress": 0.0,
564+ "updated_at": _now_iso(),
565+ }
566+ )
567+ state["build"] = build
568+ state["fingerprint"] = ""
569+ self._write_state(state)
570+ status = {
571+ "status": str(build.get("status") or "idle"),
572+ "stage": str(build.get("stage") or ""),
573+ "progress": _coerce_progress(build.get("progress")),
574+ "message": str(build.get("message") or ""),
575+ "error": str(build.get("error") or ""),
576+ "build_id": str(build.get("build_id") or ""),
577+ "force": bool(build.get("force", False)),
578+ "started_at": str(build.get("started_at") or ""),
579+ "finished_at": str(build.get("finished_at") or ""),
580+ "updated_at": str(build.get("updated_at") or state.get("updated_at") or ""),
581+ "elapsed_seconds": float(build.get("elapsed_seconds") or 0.0),
582+ "index_dir": str(self.index_dir),
583+ "index_exists": index_exists,
584+ "fresh": index_exists
585+ and str(state.get("fingerprint") or "")
586+ == _index_fingerprint(self._current_records(refresh=refresh_inventory), self._build_config),
587+ "indexed_count": int(state.get("indexed_count") or 0) if index_exists else 0,
588+ "fingerprint": str(state.get("fingerprint") or ""),
589+ }
590+ if include_logs:
591+ status["logs"] = list(build.get("logs") or [])
592+ if include_inventory:
593+ records = self._current_records(refresh=refresh_inventory)
594+ status["inventory"] = {"count": len(records), "fingerprint": _records_fingerprint(records)}
595+ if build_id and status["build_id"] and build_id != status["build_id"]:
596+ status["message"] = f"Latest build id is {status['build_id']}; requested {build_id}."
597+ return status
598+ 
599+ def load_index_tree(
600+ self,
601+ *,
602+ language: str = "zh",
603+ max_nodes: int = 400,
604+ validate_fresh: bool = True,
605+ refresh_inventory: bool = False,
606+ ) -> dict[str, Any]:
607+ error = self._index_readiness_error(refresh_inventory=refresh_inventory) if validate_fresh else None
608+ if error:
609+ text = _index_unavailable_text(error, language=language)
610+ return _result(False, text, data={"index_exists": _is_complete_index(self.index_dir), "tree": []})
611+ payload = load_tree_preset(self.index_dir / TREE_INDEX_FILENAME)
612+ raw_nodes = payload.get("nodes")
613+ nodes = [node for node in raw_nodes if isinstance(node, dict)] if isinstance(raw_nodes, list) else []
614+ outline = _render_tree_outline(nodes, max_nodes=max_nodes)
615+ return _result(
616+ True,
617+ outline or "Skill index tree is empty.",
618+ data={"index_exists": True, "tree": _tree_payload(nodes)},
619+ )
620+ 
621+ def branch_explore(
622+ self,
623+ node_ids: Sequence[str],
624+ *,
625+ visible_skill_names: Iterable[str] | None = None,
626+ retrieval_config: AgenticRetrievalConfig | None = None,
627+ max_exposure_depth: int | None = None,
628+ ) -> dict[str, Any]:
629+ try:
630+ self._ensure_runtime(visible_skill_names=visible_skill_names)
631+ except Exception as exc:
632+ return _result(False, _index_unavailable_text(_normalize_error(exc), language="en"))
633+ nodes, error = self._resolve_nodes(node_ids, default_root=False)
634+ if error:
635+ return _result(False, error)
636+ if any(node.node_id == "ROOT" for node in nodes):
637+ return _result(
638+ False,
639+ "`ROOT` is already summarized in the retrieval prompt. "
640+ "Call `branch_explore` with a first-level category id.",
641+ )
642+ base_config = retrieval_config or self._retrieval_config
643+ config = replace(
644+ base_config,
645+ max_exposure_depth=(
646+ max_exposure_depth if max_exposure_depth is not None else base_config.max_exposure_depth
647+ ),
648+ )
649+ provider = DefaultCurrentSubtreeProvider(
650+ config=_progressive_config(config),
651+ subtree_item_count=lambda current: self._node_stats(current)["skill_count"],
652+ cache={},
653+ cache_lock=None,
654+ )
655+ lines = ["# Skill Branch Explore", ""]
656+ steps: list[dict[str, Any]] = []
657+ candidates: list[dict[str, Any]] = []
658+ for index, node in enumerate(nodes):
659+ if index:
660+ lines.append("")
661+ path = self._path_by_id.get(node.node_id, ("ROOT", node.node_id))
662+ subtree = provider.get_current_subtree(
663+ cursor=SearchCursor(node=node, depth=max(0, len(path) - 1), branch_path=path, top_k=config.top_k)
664+ )
665+ self._render_explore_fragment(lines, node=node, fragment=subtree.fragment, candidates=candidates)
666+ steps.append(_step_payload("explore", node))
667+ return _result(
668+ True,
669+ "\n".join(lines).rstrip(),
670+ detailed_output={
671+ "skill_tree": {
672+ "query": {"tool": "branch_explore", "node_ids": list(node_ids)},
673+ "steps": steps,
674+ "candidates": candidates,
675+ }
676+ },
677+ )
678+ 
679+ def branch_peek(
680+ self,
681+ node_ids: Sequence[str],
682+ *,
683+ visible_skill_names: Iterable[str] | None = None,
684+ ) -> dict[str, Any]:
685+ try:
686+ self._ensure_runtime(visible_skill_names=visible_skill_names)
687+ except Exception as exc:
688+ return _result(False, _index_unavailable_text(_normalize_error(exc), language="en"))
689+ nodes, error = self._resolve_nodes(node_ids, default_root=True)
690+ if error:
691+ return _result(False, error)
692+ lines = ["# Skill Branch Peek", ""]
693+ steps: list[dict[str, Any]] = []
694+ for index, node in enumerate(nodes):
695+ if index:
696+ lines.append("")
697+ self._render_peek_node(lines, node)
698+ steps.append(_step_payload("peek", node))
699+ return _result(
700+ True,
701+ "\n".join(lines).rstrip(),
702+ detailed_output={
703+ "skill_tree": {
704+ "query": {"tool": "branch_peek", "node_ids": list(node_ids)},
705+ "steps": steps,
706+ "candidates": [],
707+ }
708+ },
709+ )
710+ 
711+ def render_retrieval_prompt(
712+ self,
713+ *,
714+ language: str = "zh",
715+ visible_skill_names: Iterable[str] | None = None,
716+ max_children: int = 30,
717+ ) -> str:
718+ error = self._index_readiness_error()
719+ if error:
720+ return self._render_index_unavailable_prompt(error, language=language)
721+ try:
722+ self._ensure_runtime(visible_skill_names=visible_skill_names)
723+ except Exception as exc:
724+ return self._render_index_unavailable_prompt(_normalize_error(exc), language=language)
725+ children = list((self._filtered_root or RetrieverNode("ROOT", "ROOT")).children)
726+ category_lines: list[str] = []
727+ for child in children[: max(1, int(max_children))]:
728+ stats = self._node_stats(child)
729+ description = _compact_text(child.description, limit=120)
730+ suffix = f" - {description}" if description else ""
731+ category_lines.append(f"- `{child.node_id}`{suffix} ({stats['skill_count']} skills)")
732+ if len(children) > max_children:
733+ category_lines.append(f"- ... {len(children) - max_children} more categories")
734+ if not category_lines:
735+ category_lines.append(
736+ "当前索引树没有第一层分支。" if language.startswith("zh") else "No first-level branches are available."
737+ )
738+ key = "zh" if language.startswith("zh") else "en"
739+ return get_prompt(AGENTIC_RETRIEVAL_YAML, "root_prompt", key).format(
740+ build_tool=self._retrieval_config.index_build_tool_name,
741+ explore_tool=self._retrieval_config.branch_explore_tool_name,
742+ peek_tool=self._retrieval_config.branch_peek_tool_name,
743+ categories="\n".join(category_lines),
744+ )
745+ 
746+ def update_skills(
747+ self,
748+ skills: Sequence[SkillRecord | dict[str, Any]] | None = None,
749+ *,
750+ skills_dir: str | Path | None = None,
751+ ) -> None:
752+ if skills is not None:
753+ self._skills = _coerce_skill_records(skills)
754+ if skills_dir is not None:
755+ self._skills_dir = Path(skills_dir).expanduser().resolve()
756+ 
757+ def update_visible_skills(self, visible_skill_names: Iterable[str] | None) -> None:
758+ self._visible_skill_names = _normalize_visible_skill_names(visible_skill_names)
759+ self._clear_loaded_index()
760+ 
761+ def close(self) -> None:
762+ self._clear_loaded_index()
763+ 
764+ def _run_async_build(self, **kwargs: Any) -> None:
765+ build_id = str(kwargs.pop("build_id"))
766+ cancel_event = kwargs.pop("cancel_event")
767+ try:
768+ self.build_index(cancel_token=cancel_event, **kwargs)
769+ finally:
770+ with _TASKS_LOCK:
771+ task = _TASKS.get(str(self.index_root))
772+ if task and task.build_id == build_id:
773+ _TASKS.pop(str(self.index_root), None)
774+ 
775+ def _resolve_records(
776+ self,
777+ *,
778+ skills: Sequence[SkillRecord | dict[str, Any]] | None,
779+ skills_dir: str | Path | None,
780+ ) -> list[SkillRecord]:
781+ if skills is not None:
782+ records = _coerce_skill_records(skills)
783+ elif skills_dir is not None:
784+ records = scan_skill_records(skills_dir)
785+ elif self._skills:
786+ records = list(self._skills)
787+ elif self._skills_dir is not None:
788+ records = scan_skill_records(self._skills_dir)
789+ else:
790+ records = []
791+ return [record for record in records if record.enabled and record.resolved_worker_id]
792+ 
793+ def _current_records(self, *, refresh: bool = False) -> list[SkillRecord]:
794+ if refresh and self._skills_dir is not None:
795+ self._skills = scan_skill_records(self._skills_dir)
796+ return [record for record in self._skills if record.enabled and record.resolved_worker_id]
797+ 
798+ def _select_build_plan(self, *, records: Sequence[SkillRecord], force: bool) -> tuple[str, list[SkillRecord]]:
799+ if force or not _is_complete_index(self.index_dir):
800+ return "build", list(records)
801+ state = self._read_state()
802+ previous_hashes = dict(state.get("record_hashes") or {})
803+ current_hashes = _record_hashes(records)
804+ if not previous_hashes:
805+ return "build", list(records)
806+ previous = set(previous_hashes)
807+ current = set(current_hashes)
808+ added = current - previous
809+ removed = previous - current
810+ changed = {key for key in current & previous if current_hashes.get(key) != previous_hashes.get(key)}
811+ if changed or (added and removed):
812+ return "build", list(records)
813+ if added and not removed:
814+ return "add", [record for record in records if record.resolved_worker_id in added]
815+ if removed and not added:
816+ return "delete", [SkillRecord(name=worker_id, worker_id=worker_id) for worker_id in sorted(removed)]
817+ return "build", list(records)
818+ 
819+ def _is_fresh(self, fingerprint: str) -> bool:
820+ state = self._read_state()
821+ return _is_complete_index(self.index_dir) and str(state.get("fingerprint") or "") == fingerprint
822+ 
823+ def _ensure_runtime(self, *, visible_skill_names: Iterable[str] | None = None) -> None:
824+ error = self._index_readiness_error()
825+ if error:
826+ raise RuntimeError(error)
827+ visible = _normalize_visible_skill_names(visible_skill_names)
828+ if visible is None:
829+ visible = self._visible_skill_names
830+ if self._loaded_index is not None and visible == self._visible_skill_names:
831+ return
832+ if not _is_complete_index(self.index_dir):
833+ raise RuntimeError(f"skill index is not complete: {self.index_dir}")
834+ loaded = load_retriever_index(self.index_dir)
835+ self._loaded_index = loaded
836+ self._visible_skill_names = visible
837+ self._catalog_by_payload = {str(record.payload): record for record in loaded.catalog_records}
838+ self._filtered_root = _filter_tree(loaded.tree_root, self._catalog_by_payload, visible)
839+ self._node_by_id = {}
840+ self._path_by_id = {}
841+ self._stats_by_id = {}
842+ self._index_nodes(self._filtered_root, ("ROOT",))
843+ 
844+ def _index_readiness_error(self, *, refresh_inventory: bool = False) -> str | None:
845+ if not _is_complete_index(self.index_dir):
846+ return f"Skill index is missing or incomplete: {self.index_dir}"
847+ records = self._current_records(refresh=refresh_inventory)
848+ if not records:
849+ return None
850+ expected = _index_fingerprint(records, self._build_config)
851+ state = self._read_state()
852+ actual = str(state.get("fingerprint") or "")
853+ if actual != expected:
854+ return "Skill index is stale because skills or build settings changed."
855+ return None
856+ 
857+ def _render_index_unavailable_prompt(self, reason: str, *, language: str) -> str:
858+ key = "zh" if language.startswith("zh") else "en"
859+ return get_prompt(AGENTIC_RETRIEVAL_YAML, "index_unavailable", key).format(
860+ build_tool=self._retrieval_config.index_build_tool_name,
861+ reason=_index_unavailable_text(reason, language=language),
862+ )
863+ 
864+ def _clear_loaded_index(self) -> None:
865+ self._loaded_index = None
866+ self._filtered_root = None
867+ self._node_by_id = {}
868+ self._path_by_id = {}
869+ self._stats_by_id = {}
870+ self._catalog_by_payload = {}
871+ 
872+ def _index_nodes(self, node: RetrieverNode, path: tuple[str, ...]) -> None:
873+ self._node_by_id[node.node_id] = node
874+ self._path_by_id[node.node_id] = path
875+ for child in node.children:
876+ self._index_nodes(child, (*path, child.node_id))
877+ 
878+ def _resolve_nodes(self, node_ids: Sequence[str], *, default_root: bool) -> tuple[list[RetrieverNode], str]:
879+ normalized = [str(item or "").strip() for item in (node_ids or []) if str(item or "").strip()]
880+ if not normalized and default_root:
881+ normalized = ["ROOT"]
882+ if not normalized:
883+ return [], "No branch node ids were provided."
884+ nodes: list[RetrieverNode] = []
885+ missing: list[str] = []
886+ for node_id in normalized:
887+ node = self._node_by_id.get(node_id)
888+ if node is None:
889+ missing.append(node_id)
890+ else:
891+ nodes.append(node)
892+ if missing:
893+ return [], f"Unknown skill tree branch id(s): {', '.join(missing)}."
894+ return nodes, ""
895+ 
896+ def _node_stats(self, node: RetrieverNode) -> dict[str, int]:
897+ cached = self._stats_by_id.get(node.node_id)
898+ if cached is not None:
899+ return cached
900+ branch_count = 0
901+ skill_count = len(node.items)
902+ for child in node.children:
903+ branch_count += 1
904+ child_stats = self._node_stats(child)
905+ branch_count += child_stats["branch_count"]
906+ skill_count += child_stats["skill_count"]
907+ stats = {"branch_count": branch_count, "skill_count": skill_count}
908+ self._stats_by_id[node.node_id] = stats
909+ return stats
910+ 
911+ def _render_peek_node(self, lines: list[str], node: RetrieverNode) -> None:
912+ lines.append(f"## Input Node: `{node.node_id}`")
913+ children = list(node.children)
914+ if not children:
915+ lines.append("")
916+ lines.append("No child branches.")
917+ return
918+ lines.append("")
919+ for child in children:
920+ stats = self._node_stats(child)
921+ description = _compact_text(child.description, limit=140)
922+ suffix = f" - {description}" if description else ""
923+ lines.append(f"- `{child.node_id}`{suffix} ({stats['skill_count']} skills)")
924+ 
925+ def _render_explore_fragment(
926+ self,
927+ lines: list[str],
928+ *,
929+ node: RetrieverNode,
930+ fragment: Any,
931+ candidates: list[dict[str, Any]],
932+ ) -> None:
933+ lines.append(f"## Input Node: `{node.node_id}`")
934+ lines.append("")
935+ resolutions = {
936+ str(resolution.canonical_id): resolution
937+ for resolution in fragment.code_to_resolution.values()
938+ if getattr(resolution, "item", None) is not None
939+ }
940+ children = list(getattr(fragment.root, "children", ()) or ())
941+ if not children:
942+ lines.append("No exposed branches or visible skills.")
943+ return
944+ self._render_exposed_children(lines, children, resolutions=resolutions, level=3, candidates=candidates)
945+ 
946+ def _render_exposed_children(
947+ self,
948+ lines: list[str],
949+ children: Sequence[Any],
950+ *,
951+ resolutions: dict[str, Any],
952+ level: int,
953+ candidates: list[dict[str, Any]],
954+ ) -> None:
955+ terminal_children = [child for child in children if _terminal_resolution(child, resolutions) is not None]
956+ branch_children = [child for child in children if _terminal_resolution(child, resolutions) is None]
957+ if terminal_children:
958+ for index, child in enumerate(terminal_children, start=1):
959+ resolution = _terminal_resolution(child, resolutions)
960+ entry = self._skill_entry_from_exposed(child, resolution)
961+ candidates.append(asdict(entry))
962+ lines.append(f"{index}. `{entry.label}`")
963+ if entry.description:
964+ lines.append(f" - Description: {entry.description}")
965+ if entry.skill_md_path:
966+ lines.append(f" - SKILL.md: `{entry.skill_md_path}`")
967+ if branch_children:
968+ lines.append("")
969+ for child in branch_children:
970+ node_id = str(getattr(child, "canonical_id", "") or getattr(child, "label", "") or "").strip()
971+ title = _compact_text(str(getattr(child, "label", "") or node_id), limit=80)
972+ lines.append(f"{'#' * max(3, level)} `{node_id}` {title}".rstrip())
973+ description = _compact_text(str(getattr(child, "description", "") or ""), limit=180)
974+ if description:
975+ lines.append("")
976+ lines.append(description)
977+ grandchildren = list(getattr(child, "children", ()) or ())
978+ if grandchildren:
979+ lines.append("")
980+ self._render_exposed_children(
981+ lines,
982+ grandchildren,
983+ resolutions=resolutions,
984+ level=level + 1,
985+ candidates=candidates,
986+ )
987+ lines.append("")
988+ 
989+ def _skill_entry_from_exposed(self, child: Any, resolution: Any | None) -> "_SkillEntry":
990+ item = getattr(resolution, "item", None)
991+ payload = str(getattr(item, "payload", "") or getattr(child, "canonical_id", "") or "").strip()
992+ record = self._catalog_by_payload.get(payload)
993+ metadata = dict(getattr(record, "metadata", {}) or {}) if record else {}
994+ label = str(
995+ (getattr(record, "name", "") if record else "")
996+ or getattr(item, "label", "")
997+ or getattr(child, "label", "")
998+ or payload
999+ ).strip()
1000+ description = str(
1001+ (getattr(record, "description", "") if record else "")
1002+ or getattr(item, "description", "")
1003+ or getattr(child, "description", "")
1004+ or ""
1005+ ).strip()
1006+ skill_md_path = str(metadata.get("skill_path") or "").strip()
1007+ return _SkillEntry(label=label, description=_first_description_line(description), skill_md_path=skill_md_path)
1008+ 
1009+ def _set_running_state(
1010+ self,
1011+ *,
1012+ build_id: str,
1013+ stage: str,
1014+ message: str,
1015+ progress: float,
1016+ force: bool,
1017+ indexed_count: int,
1018+ fingerprint: str,
1019+ ) -> None:
1020+ state = self._read_state()
1021+ previous_build = dict(state.get("build") or {})
1022+ logs = list(previous_build.get("logs") or [])
1023+ logs.append({"stage": stage, "status": "running", "message": message, "time": _now_iso()})
1024+ build = {
1025+ **previous_build,
1026+ "status": "running",
1027+ "stage": stage,
1028+ "message": message,
1029+ "error": "",
1030+ "progress": _coerce_progress(progress),
1031+ "build_id": build_id,
1032+ "force": bool(force),
1033+ "started_at": str(previous_build.get("started_at") or _now_iso()),
1034+ "updated_at": _now_iso(),
1035+ "logs": logs[-40:],
1036+ }
1037+ state.update(
1038+ {
1039+ "build": build,
1040+ "fingerprint": fingerprint,
1041+ "indexed_count": int(indexed_count),
1042+ "updated_at": _now_iso(),
1043+ }
1044+ )
1045+ self._write_state(state)
1046+ 
1047+ def _cancelled_result(self, *, build_id: str, started: float, fingerprint: str) -> dict[str, Any]:
1048+ state = _build_state(
1049+ status="cancelled",
1050+ stage="cancelled",
1051+ message="Skill index build was cancelled.",
1052+ progress=1.0,
1053+ build_id=build_id,
1054+ force=False,
1055+ indexed_count=0,
1056+ fingerprint=fingerprint,
1057+ finished=True,
1058+ elapsed_seconds=time.monotonic() - started,
1059+ )
1060+ self._write_state(state)
1061+ return _result(
1062+ False,
1063+ "Skill index build was cancelled.",
1064+ data=state,
1065+ error={"code": "cancelled", "message": "cancelled"},
1066+ )
1067+ 
1068+ def _failed_build_result(
1069+ self,
1070+ *,
1071+ error: str,
1072+ build_id: str,
1073+ started: float,
1074+ fingerprint: str,
1075+ force: bool,
1076+ indexed_count: int = 0,
1077+ preserve_previous_index: bool = True,
1078+ previous_state: dict[str, Any] | None = None,
1079+ previous_index_available: bool | None = None,
1080+ ) -> dict[str, Any]:
1081+ previous_state = previous_state if previous_state is not None else self._read_state()
1082+ previous_index_available = (
1083+ _is_complete_index(self.index_dir) if previous_index_available is None else previous_index_available
1084+ )
1085+ if not preserve_previous_index:
1086+ _cleanup_index(self.index_dir)
1087+ state = _build_state(
1088+ status="failed",
1089+ stage="failed",
1090+ message="Skill index build failed.",
1091+ error=error,
1092+ progress=1.0,
1093+ build_id=build_id,
1094+ force=force,
1095+ indexed_count=indexed_count,
1096+ fingerprint=fingerprint,
1097+ finished=True,
1098+ elapsed_seconds=time.monotonic() - started,
1099+ )
1100+ _set_failed_index_state(
1101+ state,
1102+ previous_state=previous_state,
1103+ previous_index_available=previous_index_available,
1104+ previous_index_preserved=preserve_previous_index,
1105+ attempted_fingerprint=fingerprint,
1106+ )
1107+ self._write_state(state)
1108+ return _result(
1109+ False,
1110+ f"Skill index build failed: {error}",
1111+ data={
1112+ **state,
1113+ "index_updated": False,
1114+ "previous_index_available": previous_index_available,
1115+ "previous_index_preserved": previous_index_available and preserve_previous_index,
1116+ },
1117+ error={"code": "build_failed", "message": error},
1118+ )
1119+ 
1120+ def _read_state(self) -> dict[str, Any]:
1121+ path = self.index_root / STATE_FILENAME
1122+ if not path.exists():
1123+ return {}
1124+ try:
1125+ payload = json.loads(path.read_text(encoding="utf-8"))
1126+ return payload if isinstance(payload, dict) else {}
1127+ except Exception:
1128+ return {}
1129+ 
1130+ def _write_state(self, state: dict[str, Any]) -> None:
1131+ self.index_root.mkdir(parents=True, exist_ok=True)
1132+ payload = dict(state)
1133+ payload["updated_at"] = _now_iso()
1134+ tmp = self.index_root / f".{STATE_FILENAME}.tmp"
1135+ tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
1136+ tmp.replace(self.index_root / STATE_FILENAME)
1137+ 
1138+ def _has_running_task(self, build_id: Any) -> bool:
1139+ with _TASKS_LOCK:
1140+ task = _TASKS.get(str(self.index_root))
1141+ return _is_running_build_task(task, build_id)
1142+ 
1143+ 
1144+def _is_running_build_task(task: _BuildTask | None, build_id: Any) -> bool:
1145+ if task is None:
1146+ return False
1147+ if not task.thread.is_alive():
1148+ return False
1149+ if not build_id:
1150+ return True
1151+ return task.build_id == build_id
1152+ 
1153+ 
1154+@dataclass(frozen=True)
1155+class _SkillEntry:
1156+ label: str
1157+ description: str
1158+ skill_md_path: str
1159+ 
1160+ 
1161+def scan_skill_records(skills_dir: str | Path) -> list[SkillRecord]:
1162+ root = Path(skills_dir).expanduser().resolve()
1163+ records: list[SkillRecord] = []
1164+ if not root.exists():
1165+ return records
1166+ candidates = [path for path in root.iterdir() if path.is_dir()]
1167+ for skill_dir in sorted(candidates, key=lambda item: item.name):
1168+ skill_file = _find_skill_file(skill_dir)
1169+ if skill_file is None:
1170+ continue
1171+ try:
1172+ content = skill_file.read_text(encoding="utf-8")
1173+ except (OSError, UnicodeError):
1174+ continue
1175+ frontmatter, body = _parse_frontmatter(content)
1176+ name = str(frontmatter.get("name") or skill_dir.name).strip() or skill_dir.name
1177+ description = str(frontmatter.get("description") or "").strip() or _first_paragraph(body)
1178+ records.append(
1179+ SkillRecord(
1180+ name=name,
1181+ worker_id=skill_dir.name,
1182+ description=description,
1183+ skill_md_path=str(skill_file),
1184+ enabled=True,
1185+ metadata=dict(frontmatter),
1186+ content=body.strip(),
1187+ content_hash=_sha256_text(content),
1188+ )
1189+ )
1190+ return records
1191+ 
1192+ 
1193+def _run_index_builder(
1194+ *,
1195+ operation: str,
1196+ item_jsonl_path: Path,
1197+ output_dir: Path,
1198+ base_index_dir: Path,
1199+ config: BuildConfig,
1200+) -> None:
1201+ if operation == "add":
1202+ IndexBuilder.add(
1203+ item_jsonl_path=str(item_jsonl_path),
1204+ base_index_dir=base_index_dir,
1205+ output_dir=output_dir,
1206+ item_type="skill",
1207+ config=config,
1208+ )
1209+ return
1210+ if operation == "delete":
1211+ IndexBuilder.delete(
1212+ item_jsonl_path=str(item_jsonl_path),
1213+ base_index_dir=base_index_dir,
1214+ output_dir=output_dir,
1215+ item_type="skill",
1216+ config=config,
1217+ )
1218+ return
1219+ IndexBuilder.build(
1220+ item_paths=[],
1221+ item_jsonl_path=str(item_jsonl_path),
1222+ output_dir=output_dir,
1223+ item_type="skill",
1224+ config=config,
1225+ )
1226+ 
1227+ 
1228+def _to_retrieval_build_config(config: SkillIndexBuildConfig, llm: LLMConfig) -> BuildConfig:
1229+ return BuildConfig(
1230+ llm_openai_client=llm.client,
1231+ llm_model=llm.model,
1232+ llm_api_key=llm.api_key,
1233+ llm_base_url=llm.base_url,
1234+ llm_seed=llm.seed,
1235+ tree_branching_factor=config.branching_factor,
1236+ tree_max_depth=config.max_depth,
1237+ tree_root_categories=config.root_categories,
1238+ tree_max_workers=config.max_workers,
1239+ tree_caching=config.caching,
1240+ tree_num_retries=config.max_retries,
1241+ tree_timeout_seconds=config.request_timeout_seconds,
1242+ tree_classify_batch_cap=config.classification_batch_limit,
1243+ tree_context_window=config.context_window,
1244+ tree_max_output_tokens=config.max_output_tokens,
1245+ tree_postprocess_enabled=config.postprocess_enabled,
1246+ tree_postprocess_max_passes=config.postprocess_max_passes,
1247+ tree_postprocess_min_skills=config.postprocess_min_skills,
1248+ tree_equiv_grouping_enabled=config.equivalence_enabled,
1249+ tree_equiv_max_groups_per_parent=config.equivalence_max_groups_per_parent,
1250+ tree_equiv_allow_singleton_groups=config.equivalence_allow_singleton_groups,
1251+ tree_equiv_min_lexical_similarity=config.equivalence_min_lexical_similarity,
1252+ tree_deterministic_prompts=config.deterministic_prompts,
1253+ tree_discovery_seed=config.discovery_seed,
1254+ tree_prompt_fingerprint_version=config.prompt_fingerprint_version,
1255+ tree_cache_observability=config.cache_observability,
1256+ tree_skill_profiles_enabled=config.skill_profiles_enabled,
1257+ tree_skill_profile_select_rules_enabled=config.skill_profile_select_rules_enabled,
1258+ tree_skill_profile_batch_size=config.skill_profile_batch_size,
1259+ tree_skill_profile_description_limit=config.skill_profile_description_limit,
1260+ tree_skill_profile_rule_limit=config.skill_profile_rule_limit,
1261+ incremental_max_change_ratio=config.incremental_max_change_ratio,
1262+ incremental_min_add_confidence=config.incremental_min_add_confidence,
1263+ incremental_min_add_confidence_margin=config.incremental_min_add_confidence_margin,
1264+ incremental_branch_imbalance_ratio=config.incremental_branch_imbalance_ratio,
1265+ generate_tree_html=config.generate_tree_html,
1266+ allow_fallback_tree=not config.strict_failure,
1267+ )
1268+ 
1269+ 
1270+def _progressive_config(config: AgenticRetrievalConfig) -> ProgressiveRetrieverConfig:
1271+ return ProgressiveRetrieverConfig(
1272+ top_k=max(1, int(config.top_k)),
1273+ max_tokens=max(1, int(config.max_tokens)),
1274+ request_timeout=config.request_timeout_seconds,
1275+ compact_boundary_codes_enabled=bool(config.compact_codes_enabled),
1276+ flatten_full_tree_in_prompt=bool(config.flatten_tree),
1277+ max_exposure_depth_per_call=max(0, int(config.max_exposure_depth)),
1278+ exposure_threshold=max(0, int(config.exposure_threshold)),
1279+ )
1280+ 
1281+ 
1282+def _coerce_skill_records(records: Sequence[SkillRecord | dict[str, Any]] | None) -> list[SkillRecord]:
1283+ out: list[SkillRecord] = []
1284+ for record in records or []:
1285+ if isinstance(record, SkillRecord):
1286+ out.append(record)
1287+ continue
1288+ payload = dict(record or {})
1289+ out.append(
1290+ SkillRecord(
1291+ name=str(payload.get("name") or payload.get("worker_id") or "").strip(),
1292+ description=str(payload.get("description") or "").strip(),
1293+ worker_id=str(payload.get("worker_id") or payload.get("id") or payload.get("name") or "").strip(),
1294+ skill_md_path=str(payload.get("skill_md_path") or payload.get("path") or "").strip(),
1295+ enabled=bool(payload.get("enabled", True)),
1296+ metadata=dict(payload.get("metadata") or {}),
1297+ content=str(payload.get("content") or "").strip(),
1298+ content_hash=str(payload.get("content_hash") or "").strip(),
1299+ )
1300+ )
1301+ return out
1302+ 
1303+ 
1304+def _write_records_jsonl(records: Sequence[SkillRecord], path: Path) -> None:
1305+ lines = []
1306+ for record in records:
1307+ worker_id = record.resolved_worker_id
1308+ content_hash = record.content_hash or _skill_record_hash(record)
1309+ content_extend = {
1310+ **dict(record.metadata or {}),
1311+ "skillId": worker_id,
1312+ "skillName": record.name or worker_id,
1313+ "skillDesc": record.description,
1314+ "skillPath": record.skill_md_path,
1315+ "skillContent": record.content,
1316+ "contentHash": content_hash,
1317+ }
1318+ lines.append(json.dumps({"contentExtendParam": content_extend}, ensure_ascii=False, default=str))
1319+ path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
1320+ 
1321+ 
1322+def _filter_tree(
1323+ root: RetrieverNode,
1324+ catalog_by_payload: dict[str, CatalogRecord],
1325+ visible_skill_names: frozenset[str] | None,
1326+) -> RetrieverNode:
1327+ if visible_skill_names is None:
1328+ return root
1329+ 
1330+ def item_visible(item: RetrieverItem) -> bool:
1331+ record = catalog_by_payload.get(str(item.payload))
1332+ values = {
1333+ str(item.item_id or "").strip(),
1334+ str(item.label or "").strip(),
1335+ str(item.payload or "").strip(),
1336+ }
1337+ if record:
1338+ values.update(
1339+ {
1340+ str(record.worker_id or "").strip(),
1341+ str(record.name or "").strip(),
1342+ str(record.choice_id or "").strip(),
1343+ str(record.payload or "").strip(),
1344+ }
1345+ )
1346+ return any(value in visible_skill_names for value in values if value)
1347+ 
1348+ def visit(node: RetrieverNode) -> RetrieverNode | None:
1349+ items = tuple(item for item in node.items if item_visible(item))
1350+ children = tuple(child for child in (visit(child) for child in node.children) if child is not None)
1351+ if node.node_id == "ROOT" or items or children:
1352+ return RetrieverNode(
1353+ node_id=node.node_id,
1354+ label=node.label,
1355+ description=node.description,
1356+ children=children,
1357+ items=items,
1358+ )
1359+ return None
1360+ 
1361+ return visit(root) or RetrieverNode(node_id="ROOT", label="ROOT")
1362+ 
1363+ 
1364+def _terminal_resolution(child: Any, resolutions: dict[str, Any]) -> Any | None:
1365+ selectable = str(getattr(child, "selectable_canonical_id", "") or "").strip()
1366+ if not selectable:
1367+ selectable = str(getattr(child, "canonical_id", "") or "").strip()
1368+ resolution = resolutions.get(selectable)
1369+ if resolution is not None:
1370+ return resolution
1371+ for candidate in resolutions.values():
1372+ item = getattr(candidate, "item", None)
1373+ if item is not None and str(getattr(item, "payload", "") or "") == selectable:
1374+ return candidate
1375+ return None
1376+ 
1377+ 
1378+def _step_payload(kind: str, node: RetrieverNode) -> dict[str, Any]:
1379+ return {
1380+ "source": kind,
1381+ "node_id": node.node_id,
1382+ "label": node.label,
1383+ "description": _compact_text(node.description, limit=180),
1384+ }
1385+ 
1386+ 
1387+def _tree_payload(nodes: Sequence[dict[str, Any]]) -> list[dict[str, Any]]:
1388+ by_parent: dict[str, list[dict[str, Any]]] = {}
1389+ by_cid: dict[str, dict[str, Any]] = {}
1390+ for node in nodes:
1391+ cid = str(node.get("cid") or "").strip()
1392+ if not cid:
1393+ continue
1394+ item: dict[str, Any] = {
1395+ "id": cid,
1396+ "label": cid.rsplit(".", 1)[-1],
1397+ "type": str(node.get("type") or ""),
1398+ "description": str(node.get("description") or ""),
1399+ "worker_id": str(node.get("worker_id") or ""),
1400+ "children": [],
1401+ }
1402+ by_cid[cid] = item
1403+ parent = cid.rsplit(".", 1)[0] if "." in cid else ""
1404+ by_parent.setdefault(parent, []).append(item)
1405+ for cid, item in by_cid.items():
1406+ item["children"] = sorted(by_parent.get(cid, []), key=lambda child: str(child.get("id")))
1407+ return sorted(by_parent.get("", []), key=lambda child: str(child.get("id")))
1408+ 
1409+ 
1410+def _render_tree_outline(nodes: Sequence[dict[str, Any]], *, max_nodes: int) -> str:
1411+ by_parent: dict[str, list[dict[str, Any]]] = {}
1412+ for node in nodes:
1413+ cid = str(node.get("cid") or "").strip()
1414+ if not cid:
1415+ continue
1416+ parent = cid.rsplit(".", 1)[0] if "." in cid else ""
1417+ by_parent.setdefault(parent, []).append(node)
1418+ lines: list[str] = []
1419+ count = 0
1420+ 
1421+ def visit(parent: str, depth: int) -> None:
1422+ nonlocal count
1423+ for node in sorted(by_parent.get(parent, []), key=lambda item: str(item.get("cid") or "")):
1424+ if count >= max_nodes:
1425+ return
1426+ count += 1
1427+ cid = str(node.get("cid") or "")
1428+ label = cid.rsplit(".", 1)[-1]
1429+ node_type = str(node.get("type") or "")
1430+ marker = "- " if depth == 0 else " " * depth + "- "
1431+ worker = str(node.get("worker_id") or "")
1432+ suffix = f" -> `{worker}`" if worker else ""
1433+ lines.append(f"{marker}`{cid}` {label} [{node_type}]{suffix}".rstrip())
1434+ visit(cid, depth + 1)
1435+ 
1436+ visit("", 0)
1437+ if count >= max_nodes:
1438+ lines.append(f"- ... truncated at {max_nodes} nodes")
1439+ return "\n".join(lines)
1440+ 
1441+ 
1442+def _build_state(
1443+ *,
1444+ status: str,
1445+ stage: str,
1446+ message: str,
1447+ progress: float,
1448+ build_id: str,
1449+ force: bool,
1450+ indexed_count: int,
1451+ fingerprint: str,
1452+ error: str = "",
1453+ record_hashes: dict[str, str] | None = None,
1454+ finished: bool = False,
1455+ elapsed_seconds: float = 0.0,
1456+) -> dict[str, Any]:
1457+ now = _now_iso()
1458+ return {
1459+ "fingerprint": fingerprint,
1460+ "indexed_count": int(indexed_count),
1461+ "record_hashes": dict(record_hashes or {}),
1462+ "updated_at": now,
1463+ "build": {
1464+ "status": status,
1465+ "stage": stage,
1466+ "message": message,
1467+ "error": error,
1468+ "progress": _coerce_progress(progress),
1469+ "build_id": build_id,
1470+ "force": bool(force),
1471+ "started_at": now,
1472+ "updated_at": now,
1473+ "finished_at": now if finished else "",
1474+ "elapsed_seconds": float(elapsed_seconds),
1475+ "logs": [{"stage": stage, "status": status, "message": message, "time": now}],
1476+ },
1477+ }
1478+ 
1479+ 
1480+def _set_failed_index_state(
1481+ state: dict[str, Any],
1482+ *,
1483+ previous_state: dict[str, Any],
1484+ previous_index_available: bool,
1485+ previous_index_preserved: bool,
1486+ attempted_fingerprint: str,
1487+) -> None:
1488+ if previous_index_available and previous_index_preserved:
1489+ state["fingerprint"] = str(previous_state.get("fingerprint") or "")
1490+ state["indexed_count"] = int(previous_state.get("indexed_count") or 0)
1491+ else:
1492+ state["fingerprint"] = ""
1493+ state["indexed_count"] = 0
1494+ state["attempted_fingerprint"] = attempted_fingerprint
1495+ build = dict(state.get("build") or {})
1496+ build["attempted_fingerprint"] = attempted_fingerprint
1497+ state["build"] = build
1498+ 
1499+ 
1500+def _publish_index(*, candidate_dir: Path, index_dir: Path) -> None:
1501+ parent = index_dir.parent
1502+ parent.mkdir(parents=True, exist_ok=True)
1503+ backup = parent / f".{index_dir.name}.backup-{time.time_ns()}"
1504+ if index_dir.exists():
1505+ index_dir.replace(backup)
1506+ shutil.copytree(candidate_dir, index_dir)
1507+ if backup.exists():
1508+ shutil.rmtree(backup, ignore_errors=True)
1509+ 
1510+ 
1511+def _cleanup_index(index_dir: Path) -> None:
1512+ shutil.rmtree(index_dir, ignore_errors=True)
1513+ 
1514+ 
1515+def _is_complete_index(index_dir: Path) -> bool:
1516+ return all(
1517+ (index_dir / filename).exists() for filename in (TREE_INDEX_FILENAME, CATALOG_FILENAME, MANIFEST_FILENAME)
1518+ )
1519+ 
1520+ 
1521+def _index_fingerprint(records: Sequence[SkillRecord], config: SkillIndexBuildConfig) -> str:
1522+ payload = {
1523+ "records": _record_hashes(records),
1524+ "config": asdict(config),
1525+ }
1526+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
1527+ return hashlib.sha256(encoded).hexdigest()
1528+ 
1529+ 
1530+def _records_fingerprint(records: Sequence[SkillRecord]) -> str:
1531+ encoded = json.dumps(_record_hashes(records), ensure_ascii=False, sort_keys=True).encode("utf-8")
1532+ return hashlib.sha256(encoded).hexdigest()
1533+ 
1534+ 
1535+def _record_hashes(records: Sequence[SkillRecord]) -> dict[str, str]:
1536+ return {
1537+ record.resolved_worker_id: _skill_record_hash(record)
1538+ for record in sorted(records, key=lambda item: item.resolved_worker_id)
1539+ }
1540+ 
1541+ 
1542+def _skill_record_hash(record: SkillRecord) -> str:
1543+ if record.content_hash:
1544+ return str(record.content_hash)
1545+ payload = {
1546+ "name": record.name,
1547+ "description": record.description,
1548+ "worker_id": record.resolved_worker_id,
1549+ "skill_md_path": record.skill_md_path,
1550+ "content": record.content,
1551+ "metadata": record.metadata,
1552+ }
1553+ encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
1554+ return hashlib.sha256(encoded).hexdigest()
1555+ 
1556+ 
1557+def _find_skill_file(skill_dir: Path) -> Path | None:
1558+ for name in ("SKILL.md", "skill.md", "Skill.md"):
1559+ path = skill_dir / name
1560+ if path.exists():
1561+ return path
1562+ return None
1563+ 
1564+ 
1565+def _parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
1566+ text = str(content or "")
1567+ if not text.startswith("---"):
1568+ return {}, text
1569+ end = text.find("\n---", 3)
1570+ if end < 0:
1571+ return {}, text
1572+ raw = text[3:end].strip()
1573+ body_start = end + 4
1574+ body = text[body_start:].lstrip()
1575+ try:
1576+ import yaml
1577+ 
1578+ parsed = yaml.safe_load(raw) or {}
1579+ return (parsed if isinstance(parsed, dict) else {}), body
1580+ except Exception:
1581+ return {}, body
1582+ 
1583+ 
1584+def _first_paragraph(text: str) -> str:
1585+ paragraph: list[str] = []
1586+ for line in str(text or "").splitlines():
1587+ stripped = line.strip()
1588+ if not stripped and paragraph:
1589+ break
1590+ if stripped:
1591+ paragraph.append(stripped)
1592+ return " ".join(paragraph)
1593+ 
1594+ 
1595+def _first_description_line(text: str) -> str:
1596+ for line in str(text or "").splitlines():
1597+ stripped = line.strip()
1598+ if stripped and not stripped.startswith("Select when:") and not stripped.startswith("Don't select when:"):
1599+ return stripped
1600+ return ""
1601+ 
1602+ 
1603+def _sha256_text(text: str) -> str:
1604+ return hashlib.sha256(str(text or "").encode("utf-8")).hexdigest()
1605+ 
1606+ 
1607+def _normalize_visible_skill_names(values: Iterable[str] | None) -> frozenset[str] | None:
1608+ if values is None:
1609+ return None
1610+ return frozenset(str(value or "").strip() for value in values if str(value or "").strip())
1611+ 
1612+ 
1613+def _cancel_check(cancel_token: Callable[[], bool] | threading.Event | None) -> Callable[[], bool]:
1614+ if cancel_token is None:
1615+ return lambda: False
1616+ if isinstance(cancel_token, threading.Event):
1617+ return cancel_token.is_set
1618+ if callable(cancel_token):
1619+ return lambda: bool(cancel_token())
1620+ return lambda: False
1621+ 
1622+ 
1623+def _build_check(
1624+ *,
1625+ cancel_check: Callable[[], bool],
1626+ started: float,
1627+ total_timeout_seconds: float,
1628+) -> Callable[[str], None]:
1629+ total_timeout = float(total_timeout_seconds or 0.0)
1630+ 
1631+ def check(stage: str) -> None:
1632+ if cancel_check():
1633+ raise SkillIndexBuildCancelled(f"Skill index build cancelled at stage `{stage}`.")
1634+ if total_timeout > 0 and time.monotonic() - started > total_timeout:
1635+ raise SkillIndexBuildTimeout(
1636+ f"Skill index build exceeded total timeout {total_timeout:.1f}s at stage `{stage}`."
1637+ )
1638+ 
1639+ return check
1640+ 
1641+ 
1642+def _result(
1643+ success: bool,
1644+ result: str,
1645+ *,
1646+ data: dict[str, Any] | None = None,
1647+ detailed_output: dict[str, Any] | None = None,
1648+ error: dict[str, Any] | None = None,
1649+) -> dict[str, Any]:
1650+ payload: dict[str, Any] = {"success": bool(success), "result": str(result or "")}
1651+ if data is not None:
1652+ payload["data"] = data
1653+ if detailed_output is not None:
1654+ payload["detailed_output"] = detailed_output
1655+ if error is not None:
1656+ payload["error"] = error
1657+ return payload
1658+ 
1659+ 
1660+def _emit(callback: Callable[[dict[str, Any]], None] | None, payload: dict[str, Any]) -> None:
1661+ if callback is None:
1662+ return
1663+ callback(payload)
1664+ 
1665+ 
1666+def _status_message(status: str) -> str:
1667+ return {
1668+ "idle": "Skill index build is idle.",
1669+ "running": "Skill index build is running.",
1670+ "success": "Skill index build completed.",
1671+ "failed": "Skill index build failed.",
1672+ "cancelled": "Skill index build was cancelled.",
1673+ }.get(status, f"Skill index build status: {status}.")
1674+ 
1675+ 
1676+def _index_unavailable_text(reason: str, *, language: str) -> str:
1677+ if language.startswith("zh"):
1678+ return (
1679+ "技能索引当前不可用。\n\n"
1680+ f"原因:{str(reason or '').strip()}\n\n"
1681+ "处理方法:构建或刷新技能索引后重试;如果当前任务不需要索引化技能检索,"
1682+ "也可以忽略该结果并继续使用宿主系统原有流程。"
1683+ )
1684+ return (
1685+ "Skill index is not available.\n\n"
1686+ f"Reason: {str(reason or '').strip()}\n\n"
1687+ "Next step: build or refresh the skill index and retry. If indexed skill retrieval is not useful "
1688+ "for the task, ignore this result and continue with the host system's original flow."
1689+ )
1690+ 
1691+ 
1692+def _coerce_progress(value: Any) -> float:
1693+ try:
1694+ return max(0.0, min(1.0, float(value)))
1695+ except (TypeError, ValueError):
1696+ return 0.0
1697+ 
1698+ 
1699+def _compact_text(text: str, *, limit: int) -> str:
1700+ normalized = " ".join(str(text or "").split())
1701+ if len(normalized) <= limit:
1702+ return normalized
1703+ return normalized[: max(0, limit - 1)].rstrip() + "..."
1704+ 
1705+ 
1706+def _normalize_error(exc: Exception) -> str:
1707+ return " ".join(str(exc or exc.__class__.__name__).split()) or exc.__class__.__name__
1708+ 
1709+ 
1710+def _now_iso() -> str:
1711+ return datetime.now(timezone.utc).isoformat()
1712+ 
1713+ 
1714+def _new_build_id() -> str:
1715+ return f"build-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}-{time.time_ns()}"
@@ -0,0 +1,28 @@
1+"""Skill retrieval package grouped by build, search, and shared helpers."""
2+ 
3+from __future__ import annotations
4+ 
5+from importlib import import_module
6+from types import ModuleType
7+from typing import TYPE_CHECKING
8+ 
9+if TYPE_CHECKING:
10+ from . import build as build
11+ from . import common as common
12+ from . import llm as llm
13+ from . import search as search
14+ 
15+__all__ = [
16+ "build",
17+ "common",
18+ "llm",
19+ "search",
20+]
21+ 
22+ 
23+def __getattr__(name: str) -> ModuleType:
24+ if name in __all__:
25+ module = import_module(f"{__name__}.{name}")
26+ globals()[name] = module
27+ return module
28+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,17 @@
1+"""Offline retrieval-index build package."""
2+ 
3+from .models import (
4+ CATALOG_FILENAME,
5+ INDEX_MANIFEST_FILENAME,
6+ TREE_HTML_FILENAME,
7+ TREE_INDEX_FILENAME,
8+ CatalogRecord,
9+)
10+ 
11+__all__ = [
12+ "CATALOG_FILENAME",
13+ "CatalogRecord",
14+ "INDEX_MANIFEST_FILENAME",
15+ "TREE_HTML_FILENAME",
16+ "TREE_INDEX_FILENAME",
17+]
@@ -0,0 +1,71 @@
1+import logging
2+import os
3+import shutil
4+import time
5+ 
6+from openjiuwen.symphony.retrieval.build.workflows.artifacts import BuildConfig, BuildMethod
7+from openjiuwen.symphony.retrieval.build.workflows.index_builder import IndexBuilder
8+ 
9+logger = logging.getLogger(__name__)
10+ 
11+ 
12+def main():
13+ # 记录开始时间
14+ start_time = time.time()
15+ 
16+ logging.basicConfig(level=logging.INFO, format="%(message)s")
17+ logger.info("=" * 60)
18+ logger.info("开始构建索引...")
19+ logger.info("开始时间: %s", time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(start_time)))
20+ logger.info("=" * 60)
21+ 
22+ # 配置初始化
23+ config_start = time.time()
24+ config = BuildConfig(
25+ method=BuildMethod.TREE,
26+ llm_model=os.getenv("LLM_MODEL_NAME"),
27+ llm_api_key=os.getenv("OPENAI_API_KEY"),
28+ llm_base_url=os.getenv("OPENAI_BASE_URL"),
29+ )
30+ config_cost = time.time() - config_start
31+ logger.info("配置初始化完成,耗时: %.2f 秒", config_cost)
32+ 
33+ # 索引构建
34+ build_start = time.time()
35+ logger.info("\n开始执行索引构建...")
36+ IndexBuilder.build(
37+ # 自动获取 skills_dir 下所有文件夹
38+ item_paths=[f.path for f in os.scandir(os.getenv("skills_dir")) if f.is_dir()][:15],
39+ output_dir=os.getenv("OUTPUT_DIR"),
40+ config=config,
41+ )
42+ build_cost = time.time() - build_start
43+ logger.info("索引构建完成,耗时: %.2f 秒", build_cost)
44+ 
45+ # ===================== 新增:目录拷贝逻辑 =====================
46+ src_dir = os.getenv("OUTPUT_DIR")
47+ dst_dir = os.getenv("output_tree")
48+ 
49+ if dst_dir and os.path.exists(dst_dir):
50+ logger.info("\n目标目录存在,开始拷贝: %s -> %s", src_dir, dst_dir)
51+ shutil.copytree(src_dir, dst_dir, dirs_exist_ok=True)
52+ logger.info("目录拷贝完成")
53+ else:
54+ logger.info("\n目标目录不存在,跳过拷贝")
55+ # ==============================================================
56+ 
57+ # 总耗时统计
58+ total_cost = time.time() - start_time
59+ logger.info("\n%s", "=" * 60)
60+ logger.info("索引构建任务全部完成!")
61+ logger.info("配置初始化耗时: %.2f 秒", config_cost)
62+ logger.info("索引构建耗时: %.2f 秒", build_cost)
63+ logger.info("总耗时: %.2f 秒 (%.2f 分钟)", total_cost, total_cost / 60)
64+ logger.info("结束时间: %s", time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()))
65+ logger.info("=" * 60)
66+ 
67+ return
68+ 
69+ 
70+if __name__ == "__main__":
71+ main()
@@ -0,0 +1,14 @@
1+from .catalog import load_catalog_records
2+from .manifest import load_manifest, write_manifest
3+from .tree import load_tree_preset, normalize_item_paths, parse_simple_nodes_yaml, sort_tree_nodes, write_tree_preset
4+ 
5+__all__ = [
6+ "load_catalog_records",
7+ "load_manifest",
8+ "load_tree_preset",
9+ "normalize_item_paths",
10+ "parse_simple_nodes_yaml",
11+ "sort_tree_nodes",
12+ "write_manifest",
13+ "write_tree_preset",
14+]
@@ -0,0 +1,35 @@
1+from __future__ import annotations
2+ 
3+import json
4+from pathlib import Path
5+from typing import List
6+ 
7+from openjiuwen.symphony.retrieval.build.models import CatalogRecord
8+ 
9+ 
10+def load_catalog_records(path: Path) -> List[CatalogRecord]:
11+ if not path.exists():
12+ return []
13+ records: List[CatalogRecord] = []
14+ for line in path.read_text(encoding="utf-8").splitlines():
15+ if not line.strip():
16+ continue
17+ payload = json.loads(line)
18+ worker_id = str(payload.get("worker_id") or payload.get("skill_id") or "")
19+ records.append(
20+ CatalogRecord(
21+ worker_id=worker_id,
22+ cid=str(payload.get("cid") or ""),
23+ name=str(payload.get("name") or ""),
24+ description=str(payload.get("description") or ""),
25+ skill_path=str(payload.get("skill_path") or ""),
26+ branch_path=tuple(str(item) for item in payload.get("branch_path") or ()),
27+ category=str(payload.get("category") or ""),
28+ retrieval_text=str(payload.get("retrieval_text") or ""),
29+ metadata=dict(payload.get("metadata") or {}),
30+ )
31+ )
32+ return records
33+ 
34+ 
35+__all__ = ["load_catalog_records"]
@@ -0,0 +1,33 @@
1+from __future__ import annotations
2+ 
3+import json
4+from pathlib import Path
5+ 
6+import yaml
7+ 
8+from openjiuwen.symphony.shared.storage import is_s3_uri, read_s3_text
9+ 
10+ 
11+def read_config_text(source: str | Path, *, description: str = "config") -> str:
12+ raw_source = str(source or "").strip()
13+ if not raw_source:
14+ raise ValueError(f"{description} path is empty")
15+ if is_s3_uri(raw_source):
16+ return read_s3_text(raw_source)
17+ path = Path(raw_source).expanduser().resolve()
18+ if not path.exists():
19+ raise FileNotFoundError(f"{description} file not found: {path}")
20+ return path.read_text(encoding="utf-8")
21+ 
22+ 
23+def parse_json_or_yaml(text: str, *, source: str) -> object:
24+ try:
25+ return json.loads(text)
26+ except json.JSONDecodeError:
27+ try:
28+ return yaml.safe_load(text)
29+ except yaml.YAMLError as exc:
30+ raise ValueError(f"Failed to parse config file: {source}") from exc
31+ 
32+ 
33+__all__ = ["parse_json_or_yaml", "read_config_text"]
@@ -0,0 +1,138 @@
1+from __future__ import annotations
2+ 
3+import json
4+import logging
5+from pathlib import Path
6+from urllib.parse import urlparse
7+from urllib.request import urlopen
8+ 
9+from openjiuwen.symphony.shared.storage import is_s3_uri, read_s3_text
10+ 
11+LOGGER = logging.getLogger("index_builder")
12+ 
13+ 
14+def is_http_uri(value: str | Path) -> bool:
15+ parsed = urlparse(str(value).strip())
16+ scheme = str(parsed.scheme or "").strip().lower()
17+ return scheme in {"http", "https"}
18+ 
19+ 
20+def is_passthrough_item_uri(value: str | Path) -> bool:
21+ raw = str(value or "").strip()
22+ lowered = raw.lower()
23+ return is_s3_uri(raw) or is_http_uri(raw) or lowered.startswith("jsonl://")
24+ 
25+ 
26+def _read_http_text(uri: str, *, encoding: str = "utf-8") -> str:
27+ with urlopen(uri, timeout=60) as response:
28+ payload = response.read()
29+ return payload.decode(encoding)
30+ 
31+ 
32+def download_http_object_to_path(uri: str, destination_path: str | Path) -> Path:
33+ path = Path(destination_path)
34+ path.parent.mkdir(parents=True, exist_ok=True)
35+ with urlopen(uri, timeout=60) as response:
36+ path.write_bytes(response.read())
37+ return path
38+ 
39+ 
40+def load_items_jsonl_text(*, item_jsonl_path: str | None = None) -> str:
41+ raw_path = str(item_jsonl_path or "").strip()
42+ if not raw_path:
43+ return ""
44+ if is_s3_uri(raw_path):
45+ return read_s3_text(raw_path)
46+ if is_http_uri(raw_path):
47+ return _read_http_text(raw_path)
48+ local_path = Path(raw_path).expanduser().resolve()
49+ if not local_path.exists():
50+ raise FileNotFoundError(f"JSONL path not found: {local_path}")
51+ return local_path.read_text(encoding="utf-8")
52+ 
53+ 
54+def parse_jsonl_scanned_items(jsonl_content: str) -> tuple[dict[str, dict], list[str]]:
55+ scanned: dict[str, dict] = {}
56+ ordered_paths: list[str] = []
57+ seen_paths: set[str] = set()
58+ decoder = json.JSONDecoder()
59+ text = str(jsonl_content or "").lstrip("\ufeff")
60+ index = 0
61+ item_no = 0
62+ text_len = len(text)
63+ 
64+ while index < text_len:
65+ # Accept whitespace or comma as separators between adjacent JSON objects.
66+ while index < text_len and (text[index].isspace() or text[index] == ","):
67+ index += 1
68+ if index >= text_len:
69+ break
70+ start_index = index
71+ try:
72+ payload, next_index = decoder.raw_decode(text, index)
73+ except Exception as exc:
74+ LOGGER.warning("skip invalid json item at char %s: %s", start_index, exc)
75+ next_brace = text.find("{", start_index + 1)
76+ if next_brace == -1:
77+ break
78+ index = next_brace
79+ continue
80+ item_no += 1
81+ index = next_index
82+ try:
83+ if not isinstance(payload, dict):
84+ raise ValueError(f"Invalid JSON item #{item_no}: expected object")
85+ content_extend = payload.get("contentExtendParam")
86+ if not isinstance(content_extend, dict):
87+ raise ValueError(f"Invalid JSON item #{item_no}: missing object field 'contentExtendParam'")
88+ 
89+ skill_id = str(content_extend.get("skillId") or "").strip()
90+ if not skill_id:
91+ raise ValueError(f"Invalid JSON item #{item_no}: missing required field contentExtendParam.skillId")
92+ if skill_id in scanned:
93+ continue
94+ 
95+ skill_name = str(content_extend.get("skillName") or "").strip() or skill_id
96+ skill_desc = str(content_extend.get("skillDesc") or "").strip()
97+ source_path = f"jsonl://skill/{skill_id}"
98+ skill_path = str(
99+ content_extend.get("skillPath")
100+ or content_extend.get("skill_path")
101+ or content_extend.get("path")
102+ or source_path
103+ ).strip()
104+ skill_content = str(
105+ content_extend.get("skillContent")
106+ or content_extend.get("skill_content")
107+ or content_extend.get("content")
108+ or ""
109+ ).strip()
110+ description = skill_desc or skill_name
111+ 
112+ normalized_star = content_extend.get("stars", 0)
113+ try:
114+ stars = int(normalized_star or 0)
115+ except Exception:
116+ stars = 0
117+ 
118+ scanned[skill_id] = {
119+ "id": skill_id,
120+ "name": skill_name,
121+ "description": description,
122+ "skill_path": skill_path,
123+ "path": skill_path,
124+ "content": skill_content,
125+ "github_url": str(content_extend.get("githubUrl") or content_extend.get("github_url") or ""),
126+ "stars": stars,
127+ "is_official": bool(content_extend.get("isOfficial", content_extend.get("is_official", False))),
128+ "author": str(content_extend.get("author") or ""),
129+ "content_hash": str(content_extend.get("contentHash") or content_extend.get("content_hash") or ""),
130+ "content_extend_param": dict(content_extend),
131+ }
132+ if source_path not in seen_paths:
133+ seen_paths.add(source_path)
134+ ordered_paths.append(source_path)
135+ except Exception as exc:
136+ LOGGER.warning("skip invalid json item #%s: %s", item_no, exc)
137+ continue
138+ return scanned, ordered_paths
@@ -0,0 +1,44 @@
1+from __future__ import annotations
2+ 
3+import json
4+from pathlib import Path
5+from typing import Dict, Sequence
6+ 
7+from openjiuwen.symphony.retrieval.build.io.items_jsonl import is_passthrough_item_uri
8+from openjiuwen.symphony.retrieval.build.models import CatalogRecord
9+ 
10+ 
11+def write_manifest(
12+ output_dir: Path,
13+ item_paths: Sequence[str | Path],
14+ records: Sequence[CatalogRecord],
15+ *,
16+ mode: str,
17+ item_type: str | None = None,
18+) -> None:
19+ manifest = {
20+ "mode": mode,
21+ "count": len(records),
22+ "item_paths": [_serialize_item_path(path) for path in item_paths],
23+ "worker_ids": [record.worker_id for record in records],
24+ }
25+ if item_type:
26+ manifest["item_type"] = str(item_type)
27+ (output_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
28+ 
29+ 
30+def load_manifest(index_dir: Path) -> Dict[str, object]:
31+ manifest_path = index_dir / "manifest.json"
32+ if not manifest_path.exists():
33+ return {}
34+ return json.loads(manifest_path.read_text(encoding="utf-8"))
35+ 
36+ 
37+def _serialize_item_path(path: str | Path) -> str:
38+ raw = str(path).strip()
39+ if is_passthrough_item_uri(raw):
40+ return raw
41+ return str(Path(raw).expanduser().resolve())
42+ 
43+ 
44+__all__ = ["load_manifest", "write_manifest"]
@@ -0,0 +1,124 @@
1+from __future__ import annotations
2+ 
3+import json
4+from pathlib import Path
5+from typing import Dict, Iterable, List, Sequence
6+ 
7+from openjiuwen.symphony.retrieval.build.io.items_jsonl import is_passthrough_item_uri
8+ 
9+ 
10+def write_tree_preset(payload: Dict[str, object], path: Path) -> None:
11+ raw_nodes = payload.get("nodes")
12+ nodes = sort_tree_nodes(raw_nodes if isinstance(raw_nodes, list) else [])
13+ lines = ["nodes:"]
14+ for node in nodes:
15+ if not isinstance(node, dict):
16+ continue
17+ lines.append(f" - cid: {json.dumps(str(node.get('cid', '')), ensure_ascii=False)}")
18+ lines.append(f" type: {json.dumps(str(node.get('type', '')), ensure_ascii=False)}")
19+ description = str(node.get("description", ""))
20+ if description:
21+ lines.append(f" description: {json.dumps(description, ensure_ascii=False)}")
22+ select_when = str(node.get("select_when", ""))
23+ if select_when:
24+ lines.append(f" select_when: {json.dumps(select_when, ensure_ascii=False)}")
25+ dont_select_when = str(node.get("dont_select_when", ""))
26+ if dont_select_when:
27+ lines.append(f" dont_select_when: {json.dumps(dont_select_when, ensure_ascii=False)}")
28+ source_description = str(node.get("source_description", ""))
29+ if source_description:
30+ lines.append(f" source_description: {json.dumps(source_description, ensure_ascii=False)}")
31+ worker_id = str(node.get("worker_id", "")).strip()
32+ if worker_id:
33+ lines.append(f" worker_id: {json.dumps(worker_id, ensure_ascii=False)}")
34+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
35+ 
36+ 
37+def sort_tree_nodes(nodes: Sequence[Dict[str, object]]) -> List[Dict[str, object]]:
38+ return sorted(
39+ [dict(node) for node in nodes if isinstance(node, dict)],
40+ key=lambda node: (
41+ len(str(node.get("cid") or "").split(".")),
42+ str(node.get("cid") or ""),
43+ ),
44+ )
45+ 
46+ 
47+def normalize_item_paths(item_paths: Iterable[str | Path]) -> List[str]:
48+ normalized: List[str] = []
49+ seen: set[str] = set()
50+ for item in item_paths:
51+ raw = str(item).strip()
52+ if not raw:
53+ continue
54+ if is_passthrough_item_uri(raw):
55+ key = raw
56+ else:
57+ key = str(Path(raw).expanduser().resolve())
58+ if key in seen:
59+ continue
60+ seen.add(key)
61+ normalized.append(key)
62+ return normalized
63+ 
64+ 
65+def load_tree_preset(path: Path) -> Dict[str, object]:
66+ try:
67+ import yaml
68+ except Exception:
69+ yaml = None
70+ text = path.read_text(encoding="utf-8")
71+ if yaml is not None:
72+ payload = yaml.safe_load(text) or {}
73+ if isinstance(payload, dict):
74+ payload["nodes"] = sort_tree_nodes(payload.get("nodes") or [])
75+ return payload
76+ return parse_simple_nodes_yaml(text)
77+ 
78+ 
79+def parse_simple_nodes_yaml(text: str) -> Dict[str, object]:
80+ nodes: List[Dict[str, object]] = []
81+ current: Dict[str, object] | None = None
82+ for raw_line in text.splitlines():
83+ line = raw_line.rstrip()
84+ if line.strip() == "nodes:":
85+ continue
86+ if line.startswith(" - "):
87+ if current:
88+ nodes.append(current)
89+ current = {}
90+ line = line[4:]
91+ elif current is None:
92+ continue
93+ else:
94+ line = line.strip()
95+ if ":" not in line:
96+ continue
97+ key, value = line.split(":", 1)
98+ raw_value = value.strip()
99+ if not raw_value:
100+ parsed_value = ""
101+ elif (
102+ raw_value[:1] in {'"', "[", "{", "-"}
103+ or raw_value in {"true", "false", "null"}
104+ or raw_value.replace(".", "", 1).isdigit()
105+ ):
106+ try:
107+ parsed_value = json.loads(raw_value)
108+ except Exception:
109+ parsed_value = raw_value.strip('"')
110+ else:
111+ parsed_value = raw_value
112+ current[str(key).strip()] = parsed_value
113+ if current:
114+ nodes.append(current)
115+ return {"nodes": sort_tree_nodes(nodes)}
116+ 
117+ 
118+__all__ = [
119+ "load_tree_preset",
120+ "normalize_item_paths",
121+ "parse_simple_nodes_yaml",
122+ "sort_tree_nodes",
123+ "write_tree_preset",
124+]
@@ -0,0 +1,31 @@
1+from __future__ import annotations
2+ 
3+from dataclasses import dataclass
4+from typing import Dict
5+ 
6+INDEX_MANIFEST_FILENAME = "manifest.json"
7+TREE_INDEX_FILENAME = "tree_index.yaml"
8+TREE_HTML_FILENAME = "tree_index.html"
9+CATALOG_FILENAME = "catalog.jsonl"
10+ 
11+ 
12+@dataclass(frozen=True)
13+class CatalogRecord:
14+ worker_id: str
15+ cid: str
16+ name: str
17+ description: str
18+ skill_path: str
19+ branch_path: tuple[str, ...]
20+ category: str
21+ retrieval_text: str
22+ metadata: Dict[str, object]
23+ 
24+ 
25+__all__ = [
26+ "CATALOG_FILENAME",
27+ "CatalogRecord",
28+ "INDEX_MANIFEST_FILENAME",
29+ "TREE_HTML_FILENAME",
30+ "TREE_INDEX_FILENAME",
31+]
@@ -0,0 +1,42 @@
1+from __future__ import annotations
2+ 
3+from pathlib import Path
4+ 
5+from .base import BaseScanner, ScannedItem
6+from .plugin import PluginScanner
7+from .skill import SkillScanner
8+ 
9+ScannerType = type[BaseScanner]
10+ 
11+ 
12+def normalize_item_type(item_type: str | None) -> str:
13+ normalized = str(item_type or "skill").strip().lower()
14+ if normalized not in {"skill", "plugin"}:
15+ raise ValueError("item_type must be one of: skill, plugin")
16+ return normalized
17+ 
18+ 
19+def get_scanner_class(item_type: str | None) -> ScannerType:
20+ normalized = normalize_item_type(item_type)
21+ return PluginScanner if normalized == "plugin" else SkillScanner
22+ 
23+ 
24+def create_scanner(
25+ item_type: str | None,
26+ items_dir: Path | str,
27+ *,
28+ display_items_dir: Path | str | None = None,
29+) -> BaseScanner:
30+ scanner_cls = get_scanner_class(item_type)
31+ return scanner_cls(items_dir, display_items_dir=display_items_dir)
32+ 
33+ 
34+__all__ = [
35+ "BaseScanner",
36+ "PluginScanner",
37+ "ScannedItem",
38+ "SkillScanner",
39+ "create_scanner",
40+ "get_scanner_class",
41+ "normalize_item_type",
42+]
@@ -0,0 +1,110 @@
1+from __future__ import annotations
2+ 
3+from abc import ABC, abstractmethod
4+from dataclasses import dataclass
5+from pathlib import Path
6+ 
7+from openjiuwen.symphony.shared.rich_compat import (
8+ BarColumn,
9+ Console,
10+ Progress,
11+ SpinnerColumn,
12+ TaskProgressColumn,
13+ TextColumn,
14+)
15+ 
16+console = Console()
17+ 
18+ 
19+@dataclass
20+class ScannedItem:
21+ """Normalized scanned item used by tree/catalog builders."""
22+ 
23+ id: str
24+ name: str
25+ description: str
26+ item_path: str
27+ content: str = ""
28+ github_url: str = ""
29+ stars: int = 0
30+ is_official: bool = False
31+ author: str = ""
32+ 
33+ def to_dict(self) -> dict[str, object]:
34+ return {
35+ "id": self.id,
36+ "name": self.name,
37+ "description": self.description,
38+ "skill_path": self.item_path,
39+ "path": self.item_path,
40+ "content": self.content,
41+ "github_url": self.github_url,
42+ "stars": self.stars,
43+ "is_official": self.is_official,
44+ "author": self.author,
45+ }
46+ 
47+ 
48+class BaseScanner(ABC):
49+ """Common scanner contract for item-type-specific scanners."""
50+ 
51+ item_type = "item"
52+ 
53+ def __init__(self, items_dir: Path | str, *, display_items_dir: Path | str | None = None) -> None:
54+ self.items_dir = Path(items_dir)
55+ self.display_items_dir = Path(display_items_dir) if display_items_dir is not None else self.items_dir
56+ 
57+ def scan(self, show_progress: bool = True) -> list[ScannedItem]:
58+ items: list[ScannedItem] = []
59+ if not self.items_dir.exists():
60+ console.print(f"[red]{self.item_type.title()}s directory not found: {self.items_dir}[/red]")
61+ return items
62+ 
63+ subdirs = [path for path in sorted(self.items_dir.iterdir()) if path.is_dir() and not path.name.startswith(".")]
64+ if show_progress:
65+ items = self._scan_with_progress(subdirs)
66+ else:
67+ items = self._scan_simple(subdirs)
68+ items.sort(key=lambda item: item.name.lower())
69+ if show_progress:
70+ console.print(f"[green]Found {len(items)} {self.item_type}s in {self.display_items_dir}[/green]")
71+ return items
72+ 
73+ def to_dict_list(self, items: list[ScannedItem] | None = None) -> list[dict[str, object]]:
74+ resolved_items = items if items is not None else self.scan()
75+ return [item.to_dict() for item in resolved_items]
76+ 
77+ def _scan_with_progress(self, subdirs: list[Path]) -> list[ScannedItem]:
78+ items: list[ScannedItem] = []
79+ with Progress(
80+ SpinnerColumn(),
81+ TextColumn(f"[bold blue]Scanning {self.item_type} files..."),
82+ BarColumn(bar_width=40),
83+ TaskProgressColumn(),
84+ TextColumn("({task.completed}/{task.total})"),
85+ console=console,
86+ ) as progress:
87+ task = progress.add_task("Scanning", total=len(subdirs))
88+ for item_dir in subdirs:
89+ item = self.scan_item_dir(item_dir)
90+ if item is not None:
91+ items.append(item)
92+ progress.update(task, advance=1)
93+ return items
94+ 
95+ def _scan_simple(self, subdirs: list[Path]) -> list[ScannedItem]:
96+ items: list[ScannedItem] = []
97+ for item_dir in subdirs:
98+ item = self.scan_item_dir(item_dir)
99+ if item is not None:
100+ items.append(item)
101+ return items
102+ 
103+ @classmethod
104+ @abstractmethod
105+ def detect_item_root(cls, path: Path) -> Path | None:
106+ """Return the canonical item root when this scanner recognizes the path."""
107+ 
108+ @abstractmethod
109+ def scan_item_dir(self, item_dir: Path) -> ScannedItem | None:
110+ """Scan a single item directory into a normalized record."""
@@ -0,0 +1,108 @@
1+from __future__ import annotations
2+ 
3+import re
4+from pathlib import Path
5+from typing import Any
6+ 
7+ 
8+def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
9+ if not content.startswith("---"):
10+ return {}, content
11+ 
12+ end_match = re.search(r"\n---\s*\n", content[3:])
13+ if not end_match:
14+ return {}, content
15+ 
16+ frontmatter_end = end_match.start() + 3
17+ body_start = end_match.end() + 3
18+ frontmatter_str = content[3:frontmatter_end]
19+ body = content[body_start:]
20+ parsed = _safe_load_frontmatter(frontmatter_str.strip())
21+ return parsed, body
22+ 
23+ 
24+def clean_first_paragraph(body: str, *, limit: int = 500) -> str:
25+ text = str(body or "").strip()
26+ if not text:
27+ return ""
28+ first_para = text.split("\n\n")[0]
29+ first_para = re.sub(r"^#+\s*", "", first_para)
30+ first_para = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", first_para)
31+ return first_para[:limit].strip()
32+ 
33+ 
34+def read_text_if_exists(path: Path) -> str:
35+ if not path.exists() or not path.is_file():
36+ return ""
37+ return path.read_text(encoding="utf-8")
38+ 
39+ 
40+def _safe_load_frontmatter(text: str) -> dict[str, Any]:
41+ try:
42+ import yaml
43+ except ModuleNotFoundError:
44+ return _parse_simple_frontmatter(text)
45+ 
46+ try:
47+ payload = yaml.safe_load(text) or {}
48+ except Exception:
49+ return _parse_simple_frontmatter(text)
50+ if not isinstance(payload, dict):
51+ return _parse_simple_frontmatter(text)
52+ return payload
53+ 
54+ 
55+def _parse_simple_frontmatter(text: str) -> dict[str, Any]:
56+ payload: dict[str, Any] = {}
57+ lines = text.splitlines()
58+ index = 0
59+ last_key: str | None = None
60+ 
61+ while index < len(lines):
62+ raw_line = lines[index]
63+ stripped = raw_line.strip()
64+ if not stripped or stripped.startswith("#"):
65+ index += 1
66+ continue
67+ if raw_line.startswith((" ", "\t")):
68+ index += 1
69+ continue
70+ if ":" not in raw_line:
71+ if last_key is not None and isinstance(payload.get(last_key), str):
72+ payload[last_key] = f"{payload[last_key]}\n{stripped}".strip()
73+ index += 1
74+ continue
75+ 
76+ key, value = raw_line.split(":", 1)
77+ clean_key = key.strip()
78+ clean_value = value.strip()
79+ 
80+ if clean_value in {"|", "|-", "|+", ">", ">-", ">+"}:
81+ block_lines: list[str] = []
82+ index += 1
83+ while index < len(lines):
84+ block_line = lines[index]
85+ if not block_line.strip():
86+ block_lines.append("")
87+ index += 1
88+ continue
89+ if not block_line.startswith((" ", "\t")):
90+ break
91+ block_lines.append(block_line.lstrip(" \t"))
92+ index += 1
93+ payload[clean_key] = "\n".join(block_lines).strip()
94+ last_key = clean_key
95+ continue
96+ 
97+ payload[clean_key] = _parse_simple_scalar(clean_value)
98+ last_key = clean_key
99+ index += 1
100+ 
101+ return payload
102+ 
103+ 
104+def _parse_simple_scalar(value: str) -> str:
105+ text = str(value or "").strip()
106+ if len(text) >= 2 and text[0] == text[-1] and text[0] in {'"', "'"}:
107+ return text[1:-1]
108+ return text
@@ -0,0 +1,146 @@
1+from __future__ import annotations
2+ 
3+import json
4+from pathlib import Path
5+from typing import Any
6+ 
7+from .base import BaseScanner, ScannedItem, console
8+from .common import clean_first_paragraph, parse_frontmatter, read_text_if_exists
9+ 
10+ 
11+class PluginScanner(BaseScanner):
12+ item_type = "plugin"
13+ 
14+ @classmethod
15+ def _plugin_metadata_candidates(cls, path: Path) -> list[Path]:
16+ metadata_dir = path / ".codex-plugin"
17+ yaml_names = ("plugin.yaml", "plugin.yml")
18+ candidates = [metadata_dir / name for name in yaml_names]
19+ candidates.extend(path / name for name in yaml_names)
20+ candidates.extend((metadata_dir / "plugin.json", path / "plugin.json"))
21+ return candidates
22+ 
23+ @classmethod
24+ def detect_item_root(cls, path: Path) -> Path | None:
25+ for candidate in cls._plugin_metadata_candidates(path):
26+ if candidate.exists():
27+ return path
28+ for filename in ("SKILL.md", "skill.md", "Skill.md"):
29+ if (path / filename).exists():
30+ return path
31+ return None
32+ 
33+ def scan_item_dir(self, item_dir: Path) -> ScannedItem | None:
34+ item_root = self.detect_item_root(item_dir)
35+ if item_root is None:
36+ return None
37+ 
38+ plugin_file = next(
39+ (candidate for candidate in self._plugin_metadata_candidates(item_root) if candidate.exists()), None
40+ )
41+ if plugin_file is not None:
42+ payload = self._load_plugin_payload(plugin_file)
43+ if payload is None:
44+ return None
45+ readme_text = read_text_if_exists(item_root / "README.md")
46+ description = str(payload.get("description") or "").strip() or clean_first_paragraph(readme_text)
47+ content = readme_text.strip()
48+ metadata = payload.get("metadata")
49+ author = ""
50+ if isinstance(metadata, dict):
51+ author = str(metadata.get("author") or "").strip()
52+ if not author:
53+ author = str(payload.get("author") or "").strip()
54+ display_name = str(payload.get("display_name") or "").strip()
55+ plugin_name = str(payload.get("name") or "").strip()
56+ return ScannedItem(
57+ id=item_root.name,
58+ name=display_name or plugin_name or item_root.name,
59+ description=description,
60+ item_path=str(plugin_file.resolve()),
61+ content=content,
62+ author=author,
63+ )
64+ 
65+ skill_file = next(
66+ (item_root / name for name in ("SKILL.md", "skill.md", "Skill.md") if (item_root / name).exists()), None
67+ )
68+ if skill_file is None:
69+ return None
70+ try:
71+ content = skill_file.read_text(encoding="utf-8")
72+ except Exception as exc:
73+ console.print(f"[yellow]Failed to read {skill_file}: {exc}[/yellow]")
74+ return None
75+ frontmatter, body = parse_frontmatter(content)
76+ description = str(frontmatter.get("description") or "").strip() or clean_first_paragraph(body)
77+ return ScannedItem(
78+ id=item_root.name,
79+ name=str(frontmatter.get("name") or item_root.name).strip() or item_root.name,
80+ description=description,
81+ item_path=str(skill_file.resolve()),
82+ content=body.strip(),
83+ )
84+ 
85+ @staticmethod
86+ def _load_plugin_payload(plugin_file: Path) -> dict[str, Any] | None:
87+ try:
88+ if plugin_file.suffix.lower() == ".json":
89+ payload = json.loads(plugin_file.read_text(encoding="utf-8"))
90+ else:
91+ text = plugin_file.read_text(encoding="utf-8")
92+ try:
93+ import yaml
94+ except ModuleNotFoundError:
95+ payload = PluginScanner._parse_simple_yaml_payload(text)
96+ else:
97+ payload = yaml.safe_load(text) or {}
98+ except Exception as exc:
99+ console.print(f"[yellow]Failed to read {plugin_file}: {exc}[/yellow]")
100+ return None
101+ if not isinstance(payload, dict):
102+ console.print(f"[yellow]Failed to read {plugin_file}: expected a mapping payload[/yellow]")
103+ return None
104+ return payload
105+ 
106+ @staticmethod
107+ def _parse_simple_yaml_payload(text: str) -> dict[str, Any]:
108+ payload: dict[str, Any] = {}
109+ active_section: str | None = None
110+ for raw_line in text.splitlines():
111+ if not raw_line.strip() or raw_line.lstrip().startswith("#"):
112+ continue
113+ indent = len(raw_line) - len(raw_line.lstrip(" "))
114+ line = raw_line.strip()
115+ if indent == 0:
116+ active_section = None
117+ if ":" not in line:
118+ continue
119+ key, value = line.split(":", 1)
120+ clean_key = key.strip()
121+ clean_value = value.strip()
122+ if clean_value:
123+ payload[clean_key] = PluginScanner._parse_yaml_scalar(clean_value)
124+ else:
125+ payload[clean_key] = {}
126+ active_section = clean_key
127+ continue
128+ if not active_section:
129+ continue
130+ if indent < 2 or ":" not in line or line.startswith("- "):
131+ continue
132+ section = payload.get(active_section)
133+ if not isinstance(section, dict):
134+ section = {}
135+ payload[active_section] = section
136+ key, value = line.split(":", 1)
137+ clean_value = value.strip()
138+ section[key.strip()] = PluginScanner._parse_yaml_scalar(clean_value) if clean_value else {}
139+ return payload
140+ 
141+ @staticmethod
142+ def _parse_yaml_scalar(value: str) -> str:
143+ text = str(value or "").strip()
144+ if len(text) >= 2 and text[0] == text[-1] and text[0] in {'"', "'"}:
145+ return text[1:-1]
146+ return text
@@ -0,0 +1,80 @@
1+from __future__ import annotations
2+ 
3+import json
4+from pathlib import Path
5+ 
6+from .base import BaseScanner, ScannedItem, console
7+from .common import clean_first_paragraph, parse_frontmatter
8+ 
9+ 
10+class SkillScanner(BaseScanner):
11+ item_type = "skill"
12+ 
13+ def __init__(self, items_dir: Path | str, *, display_items_dir: Path | str | None = None) -> None:
14+ super().__init__(items_dir, display_items_dir=display_items_dir)
15+ self._metadata: dict[str, dict[str, object]] = {}
16+ self._load_metadata()
17+ 
18+ def _load_metadata(self) -> None:
19+ metadata_path = self.items_dir / "skills.json"
20+ if not metadata_path.exists():
21+ return
22+ try:
23+ payload = json.loads(metadata_path.read_text(encoding="utf-8"))
24+ except Exception as exc:
25+ console.print(f"[yellow]Warning: Failed to load skills.json: {exc}[/yellow]")
26+ return
27+ for item in payload.get("skills", []):
28+ item_id = str(item.get("id") or "").strip()
29+ if not item_id:
30+ continue
31+ self._metadata[item_id] = {
32+ "github_url": item.get("github_url", ""),
33+ "stars": item.get("stars", 0),
34+ "is_official": item.get("is_official", False),
35+ "author": item.get("author", ""),
36+ }
37+ 
38+ @classmethod
39+ def detect_item_root(cls, path: Path) -> Path | None:
40+ for filename in ("SKILL.md", "skill.md", "Skill.md"):
41+ candidate = path / filename
42+ if candidate.exists():
43+ return path
44+ return None
45+ 
46+ def scan_item_dir(self, item_dir: Path) -> ScannedItem | None:
47+ item_root = self.detect_item_root(item_dir)
48+ if item_root is None:
49+ return None
50+ 
51+ skill_file = next(
52+ (item_root / name for name in ("SKILL.md", "skill.md", "Skill.md") if (item_root / name).exists()), None
53+ )
54+ if skill_file is None:
55+ return None
56+ try:
57+ content = skill_file.read_text(encoding="utf-8")
58+ except Exception as exc:
59+ console.print(f"[yellow]Failed to read {skill_file}: {exc}[/yellow]")
60+ return None
61+ 
62+ frontmatter, body = parse_frontmatter(content)
63+ item_id = item_root.name
64+ meta = self._metadata.get(item_id, {})
65+ name = str(frontmatter.get("name") or item_root.name).strip() or item_root.name
66+ description = str(frontmatter.get("description") or "").strip()
67+ if not description:
68+ description = clean_first_paragraph(body)
69+ 
70+ return ScannedItem(
71+ id=item_id,
72+ name=name,
73+ description=description,
74+ item_path=str(skill_file.resolve()),
75+ content=body.strip(),
76+ github_url=str(meta.get("github_url") or ""),
77+ stars=int(str(meta.get("stars") or 0)),
78+ is_official=bool(meta.get("is_official")),
79+ author=str(meta.get("author") or ""),
80+ )
@@ -0,0 +1,45 @@
1+from __future__ import annotations
2+ 
3+from typing import TYPE_CHECKING, Any
4+ 
5+if TYPE_CHECKING:
6+ from .builder import TreeBuilder as TreeBuilder
7+ from .builder import build_tree as build_tree
8+ from .schema import DynamicTreeConfig as DynamicTreeConfig
9+ from .schema import Skill as Skill
10+ from .schema import SkillStatus as SkillStatus
11+ from .schema import TreeBuildConfig as TreeBuildConfig
12+ from .schema import TreeManagerConfig as TreeManagerConfig
13+ from .schema import TreeNode as TreeNode
14+ 
15+__all__ = [
16+ "DynamicTreeConfig",
17+ "Skill",
18+ "SkillStatus",
19+ "TreeBuildConfig",
20+ "TreeBuilder",
21+ "TreeManagerConfig",
22+ "TreeNode",
23+ "build_tree",
24+]
25+ 
26+ 
27+def __getattr__(name: str):
28+ if name in {"DynamicTreeConfig", "Skill", "SkillStatus", "TreeNode", "TreeBuildConfig", "TreeManagerConfig"}:
29+ from .schema import DynamicTreeConfig, Skill, SkillStatus, TreeBuildConfig, TreeManagerConfig, TreeNode
30+ 
31+ exports: dict[str, Any] = {
32+ "DynamicTreeConfig": DynamicTreeConfig,
33+ "Skill": Skill,
34+ "SkillStatus": SkillStatus,
35+ "TreeBuildConfig": TreeBuildConfig,
36+ "TreeManagerConfig": TreeManagerConfig,
37+ "TreeNode": TreeNode,
38+ }
39+ return exports[name]
40+ if name in {"TreeBuilder", "build_tree"}:
41+ from .builder import TreeBuilder, build_tree
42+ 
43+ builder_exports: dict[str, Any] = {"TreeBuilder": TreeBuilder, "build_tree": build_tree}
44+ return builder_exports[name]
45+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
Muv.lock+13-0