clouds-coder 2026.3.28__tar.gz → 2026.3.29__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -448,6 +448,7 @@ SKILL_RESOURCE_MANIFEST_MAX_ITEMS = 120
448
448
  SKILL_BODY_COMPACT_THRESHOLD_CHARS = 12_000
449
449
  SKILL_BODY_PREVIEW_CHARS = 4_000
450
450
  SKILLS_VIRTUAL_PREFIX = "/skills"
451
+ SKILLS_EXTERNAL_MOUNT = "__external__"
451
452
  PLAN_MODE_ENABLED_LEVELS = {3, 4, 5}
452
453
  PLAN_MODE_FORCED_LEVELS = {4, 5}
453
454
  PLAN_MODE_USER_CHOICES = ("auto", "on", "off")
@@ -478,7 +479,7 @@ REVIEWER_DEBUG_TOOL_ALLOWLIST = {
478
479
  }
479
480
  EXPLORER_STALL_THRESHOLD = 3 # consecutive same-target delegations before forced switch
480
481
  DEVELOPER_EDIT_STALL_THRESHOLD = 3 # consecutive edit_file failures on same file before forced strategy change
481
- PLAN_MODE_MANAGER_SYNTHESIS_MAX_TOKENS = 4096
482
+ PLAN_MODE_MANAGER_SYNTHESIS_MAX_TOKENS = 6144
482
483
  PLAN_MODE_MAX_OPTIONS = 3
483
484
  PLAN_FILE_RELATIVE_PATH = ".clouds_coder/plan.md"
484
485
  PLAN_BUBBLE_MAX_CHARS = 3800 # margin under ASSISTANT_MESSAGE_EVENT_MAX_CHARS (4000)
@@ -1529,6 +1530,9 @@ def infer_model_multimodal_capabilities(provider: str, model: str) -> dict[str,
1529
1530
  "qwen-vl",
1530
1531
  "gemini",
1531
1532
  "claude-3",
1533
+ "claude-sonnet-4",
1534
+ "claude-opus",
1535
+ "glm-4v",
1532
1536
  "omni",
1533
1537
  )
1534
1538
  ):
@@ -1543,6 +1547,10 @@ def infer_model_multimodal_capabilities(provider: str, model: str) -> dict[str,
1543
1547
  caps["output_audio"] = True
1544
1548
  if any(x in m for x in ("video", "sora", "kling", "wan")):
1545
1549
  caps["output_video"] = True
1550
+ if p == "anthropic":
1551
+ # All current Claude models support image input
1552
+ if any(x in m for x in ("claude-3", "claude-sonnet-4", "claude-opus", "claude-haiku")):
1553
+ caps["input_image"] = True
1546
1554
  return caps
1547
1555
 
1548
1556
 
@@ -3104,6 +3112,136 @@ def parse_llm_config_profiles(config: dict, default_ollama_url: str, default_oll
3104
3112
  media_endpoints=build_profile_media_endpoints("siliconflow"),
3105
3113
  )
3106
3114
 
3115
+ # ── vLLM (local) ────────────────────────────────────────���─────
3116
+ vllm_url = str(config.get("vllm_url", "")).strip()
3117
+ vllm_model = str(config.get("vllm_model", "")).strip()
3118
+ vllm_key = str(config.get("vllm_key", "")).strip()
3119
+ if vllm_url or vllm_model:
3120
+ _vllm_default = "http://localhost:8000/v1"
3121
+ add_profile(
3122
+ profiles,
3123
+ profile_id="vllm",
3124
+ provider="openai_compat",
3125
+ label="vLLM",
3126
+ model=vllm_model or "auto",
3127
+ base_url=extract_base_url(vllm_url or _vllm_default),
3128
+ endpoint=complete_chat_endpoint(vllm_url or _vllm_default),
3129
+ api_key=vllm_key,
3130
+ thinking_stream=bool(config.get("vllm_thinking_stream", thinking_stream_default)),
3131
+ temperature=temp,
3132
+ request_timeout=timeout,
3133
+ capabilities=build_profile_capabilities("vllm", "openai_compat", vllm_model or "auto"),
3134
+ media_endpoints=build_profile_media_endpoints("vllm"),
3135
+ )
3136
+
3137
+ # ── LM Studio (local) ─────────────────────────────────────────
3138
+ lms_url = str(config.get("lmstudio_url", "")).strip()
3139
+ lms_model = str(config.get("lmstudio_model", "")).strip()
3140
+ if lms_url or lms_model:
3141
+ _lms_default = "http://localhost:1234/v1"
3142
+ add_profile(
3143
+ profiles,
3144
+ profile_id="lmstudio",
3145
+ provider="openai_compat",
3146
+ label="LM Studio",
3147
+ model=lms_model or "auto",
3148
+ base_url=extract_base_url(lms_url or _lms_default),
3149
+ endpoint=complete_chat_endpoint(lms_url or _lms_default),
3150
+ thinking_stream=bool(config.get("lmstudio_thinking_stream", thinking_stream_default)),
3151
+ temperature=temp,
3152
+ request_timeout=timeout,
3153
+ capabilities=build_profile_capabilities("lmstudio", "openai_compat", lms_model or "auto"),
3154
+ media_endpoints=build_profile_media_endpoints("lmstudio"),
3155
+ )
3156
+
3157
+ # ── Anthropic ──────────────────────────────────────────────────
3158
+ anth_url = str(config.get("anthropic_url", "")).strip()
3159
+ anth_model = str(config.get("anthropic_model", "")).strip()
3160
+ anth_key = str(config.get("anthropic_key", "")).strip()
3161
+ if anth_url or anth_model or anth_key:
3162
+ _anth_base = anth_url or "https://api.anthropic.com"
3163
+ add_profile(
3164
+ profiles,
3165
+ profile_id="anthropic",
3166
+ provider="anthropic",
3167
+ label="Anthropic",
3168
+ model=anth_model or "claude-sonnet-4-20250514",
3169
+ base_url=extract_base_url(_anth_base),
3170
+ endpoint=_anth_base.rstrip("/") + "/v1/messages",
3171
+ api_key=anth_key,
3172
+ thinking_stream=bool(config.get("anthropic_thinking_stream", thinking_stream_default)),
3173
+ temperature=temp,
3174
+ request_timeout=timeout,
3175
+ capabilities=build_profile_capabilities("anthropic", "anthropic", anth_model or "claude-sonnet-4-20250514"),
3176
+ media_endpoints=build_profile_media_endpoints("anthropic"),
3177
+ )
3178
+
3179
+ # ── GLM (智谱) ─────────────────────────────────────────────────
3180
+ glm_url = str(config.get("glm_url", "")).strip()
3181
+ glm_model = str(config.get("glm_model", "")).strip()
3182
+ glm_key = str(config.get("glm_key", "")).strip()
3183
+ if glm_url or glm_model or glm_key:
3184
+ _glm_default = "https://open.bigmodel.cn/api/paas/v4"
3185
+ add_profile(
3186
+ profiles,
3187
+ profile_id="glm",
3188
+ provider="openai_compat",
3189
+ label="GLM",
3190
+ model=glm_model or "glm-4-flash",
3191
+ base_url=extract_base_url(glm_url or _glm_default),
3192
+ endpoint=complete_chat_endpoint(glm_url or _glm_default),
3193
+ api_key=glm_key,
3194
+ thinking_stream=bool(config.get("glm_thinking_stream", thinking_stream_default)),
3195
+ temperature=temp,
3196
+ request_timeout=timeout,
3197
+ capabilities=build_profile_capabilities("glm", "openai_compat", glm_model or "glm-4-flash"),
3198
+ media_endpoints=build_profile_media_endpoints("glm"),
3199
+ )
3200
+
3201
+ # ── KIMI (Moonshot / 月之暗面) ─────────────────────────────────
3202
+ kimi_url = str(config.get("kimi_url", "")).strip()
3203
+ kimi_model = str(config.get("kimi_model", "")).strip()
3204
+ kimi_key = str(config.get("kimi_key", "")).strip()
3205
+ if kimi_url or kimi_model or kimi_key:
3206
+ _kimi_default = "https://api.moonshot.cn/v1"
3207
+ add_profile(
3208
+ profiles,
3209
+ profile_id="kimi",
3210
+ provider="openai_compat",
3211
+ label="KIMI (Moonshot)",
3212
+ model=kimi_model or "moonshot-v1-8k",
3213
+ base_url=extract_base_url(kimi_url or _kimi_default),
3214
+ endpoint=complete_chat_endpoint(kimi_url or _kimi_default),
3215
+ api_key=kimi_key,
3216
+ thinking_stream=bool(config.get("kimi_thinking_stream", thinking_stream_default)),
3217
+ temperature=temp,
3218
+ request_timeout=timeout,
3219
+ capabilities=build_profile_capabilities("kimi", "openai_compat", kimi_model or "moonshot-v1-8k"),
3220
+ media_endpoints=build_profile_media_endpoints("kimi"),
3221
+ )
3222
+
3223
+ # ── OpenRouter ─────────────────────────────────────────────────
3224
+ or_url = str(config.get("openrouter_url", "")).strip()
3225
+ or_model = str(config.get("openrouter_model", "")).strip()
3226
+ or_key = str(config.get("openrouter_key", "")).strip()
3227
+ if or_url or or_model or or_key:
3228
+ _or_default = "https://openrouter.ai/api/v1"
3229
+ add_profile(
3230
+ profiles,
3231
+ profile_id="openrouter",
3232
+ provider="openai_compat",
3233
+ label="OpenRouter",
3234
+ model=or_model or "meta-llama/llama-3.1-8b-instruct",
3235
+ base_url=extract_base_url(or_url or _or_default),
3236
+ endpoint=complete_chat_endpoint(or_url or _or_default),
3237
+ api_key=or_key,
3238
+ thinking_stream=bool(config.get("openrouter_thinking_stream", thinking_stream_default)),
3239
+ temperature=temp,
3240
+ request_timeout=timeout,
3241
+ capabilities=build_profile_capabilities("openrouter", "openai_compat", or_model or "meta-llama/llama-3.1-8b-instruct"),
3242
+ media_endpoints=build_profile_media_endpoints("openrouter"),
3243
+ )
3244
+
3107
3245
  custom_url = str(config.get("custom_url", "")).strip()
3108
3246
  custom_key = str(config.get("custom_key", "")).strip()
3109
3247
  custom_headers = parse_json_object(str(config.get("custom_headers", "{}") or "{}"), {})
@@ -3147,9 +3285,24 @@ def parse_llm_config_profiles(config: dict, default_ollama_url: str, default_oll
3147
3285
  "ollama": "ollama",
3148
3286
  "openai": "openai",
3149
3287
  "siliconflow": "siliconflow",
3288
+ "vllm": "vllm",
3289
+ "lmstudio": "lmstudio",
3290
+ "anthropic": "anthropic",
3291
+ "glm": "glm",
3292
+ "kimi": "kimi",
3293
+ "openrouter": "openrouter",
3150
3294
  "custom": "custom",
3151
3295
  }
3152
- default_profile_id = active_map.get(provider, profiles[0]["id"])
3296
+ profile_ids = {p["id"] for p in profiles}
3297
+ default_profile_id = active_map.get(provider, "")
3298
+ if not default_profile_id or default_profile_id not in profile_ids:
3299
+ # Fallback: first non-ollama profile that was explicitly configured
3300
+ for p in profiles:
3301
+ if p["id"] != "ollama" and p.get("source") != "default":
3302
+ default_profile_id = p["id"]
3303
+ break
3304
+ if not default_profile_id or default_profile_id not in profile_ids:
3305
+ default_profile_id = profiles[0]["id"]
3153
3306
  return {"profiles": profiles, "default_profile_id": default_profile_id}
3154
3307
 
3155
3308
  def looks_like_llm_config(config: dict) -> bool:
@@ -3166,6 +3319,22 @@ def looks_like_llm_config(config: dict) -> bool:
3166
3319
  "siliconflow_url",
3167
3320
  "siliconflow_model",
3168
3321
  "siliconflow_key",
3322
+ "vllm_url",
3323
+ "vllm_model",
3324
+ "lmstudio_url",
3325
+ "lmstudio_model",
3326
+ "anthropic_url",
3327
+ "anthropic_model",
3328
+ "anthropic_key",
3329
+ "glm_url",
3330
+ "glm_model",
3331
+ "glm_key",
3332
+ "kimi_url",
3333
+ "kimi_model",
3334
+ "kimi_key",
3335
+ "openrouter_url",
3336
+ "openrouter_model",
3337
+ "openrouter_key",
3169
3338
  "custom_url",
3170
3339
  "custom_model",
3171
3340
  "custom_key",
@@ -3178,6 +3347,12 @@ def looks_like_llm_config(config: dict) -> bool:
3178
3347
  "ollama_capabilities",
3179
3348
  "openai_capabilities",
3180
3349
  "siliconflow_capabilities",
3350
+ "anthropic_capabilities",
3351
+ "glm_capabilities",
3352
+ "kimi_capabilities",
3353
+ "openrouter_capabilities",
3354
+ "vllm_capabilities",
3355
+ "lmstudio_capabilities",
3181
3356
  "custom_capabilities",
3182
3357
  "ollama_media_endpoints",
3183
3358
  "openai_media_endpoints",
@@ -6416,6 +6591,1004 @@ if __name__ == "__main__":
6416
6591
  ),
6417
6592
  )
6418
6593
 
6594
+ # ---------------------------------------------------------------------------
6595
+ # Generated Skills: RAG Retrieval Mastery
6596
+ # ---------------------------------------------------------------------------
6597
+
6598
+ def ensure_generated_rag_mastery_skills(skills_root: Path):
6599
+ generated_root = skills_root / "generated"
6600
+
6601
+ # ── Skill A: rag-retrieval-mastery ────────────────────────────────
6602
+
6603
+ rag_mastery_skill = """---
6604
+ name: rag-retrieval-mastery
6605
+ aliases:
6606
+ - rag-mastery
6607
+ - rag-guide
6608
+ - retrieval-mastery
6609
+ triggers:
6610
+ - RAG query
6611
+ - knowledge library
6612
+ - retrieval strategy
6613
+ - query formulation
6614
+ - cross-library search
6615
+ - empty retrieval results
6616
+ - RAG检索
6617
+ - 知识库查询
6618
+ - 检索策略
6619
+ - 查询优化
6620
+ clouds_coder:
6621
+ preferred_tools:
6622
+ - query_knowledge_library
6623
+ - query_code_library
6624
+ - read_file
6625
+ description: >
6626
+ Comprehensive guide for effective RAG retrieval across knowledge and code libraries.
6627
+ Covers query formulation, route selection, iterative refinement, cross-library strategy,
6628
+ result interpretation, and citation best practices.
6629
+ TRIGGER when: user asks about RAG usage, retrieval quality is poor, empty results from library queries,
6630
+ or task requires grounded knowledge retrieval before answering.
6631
+ DO NOT TRIGGER for: general research workflows (use research-orchestrator-pro), scientific reasoning.
6632
+ ---
6633
+
6634
+ # RAG Retrieval Mastery
6635
+
6636
+ Master guide for effective use of `query_knowledge_library` and `query_code_library`.
6637
+
6638
+ ## When to Use
6639
+ - Before answering questions that may have grounded references in the library
6640
+ - When retrieval results are empty or irrelevant
6641
+ - When combining knowledge from code and document libraries
6642
+ - When you need to formulate complex multi-step queries
6643
+
6644
+ ## 1. Query Formulation Strategies
6645
+
6646
+ ### Entity-Focused Query
6647
+ Extract the **core entity/concept** from the user's question:
6648
+ - Bad: "What does the system do when a user logs in?"
6649
+ - Good: `query_knowledge_library(query="user login authentication flow", top_k=8)`
6650
+
6651
+ ### Concept Decomposition
6652
+ Break complex questions into 2-3 sub-queries:
6653
+ 1. `query_knowledge_library(query="transformer attention mechanism", top_k=5)`
6654
+ 2. `query_knowledge_library(query="self-attention vs cross-attention", top_k=5)`
6655
+
6656
+ ### Specificity Ladder
6657
+ Start specific, broaden if empty:
6658
+ 1. `query="BERT fine-tuning learning rate schedule"` (narrow)
6659
+ 2. `query="BERT fine-tuning hyperparameters"` (medium)
6660
+ 3. `query="transformer model fine-tuning"` (broad)
6661
+
6662
+ ### Synonym Expansion
6663
+ If first query returns few results, try synonyms:
6664
+ - "machine learning" → "ML", "deep learning", "neural network"
6665
+ - "API endpoint" → "REST route", "HTTP handler", "web service"
6666
+
6667
+ ### Multilingual Bridging (CN/EN)
6668
+ Query in **both languages** if the library may contain mixed content:
6669
+ 1. `query="注意力机制 attention mechanism"` (combined)
6670
+ 2. Or run two queries: one Chinese, one English, merge results
6671
+
6672
+ ### Negation-Aware Query
6673
+ To find counter-evidence or alternatives:
6674
+ - `query="limitations of batch normalization alternatives"` (includes both positive and negative)
6675
+
6676
+ ## 2. Route Selection Decision Table
6677
+
6678
+ | Route | When to Use | Example |
6679
+ |-------|-------------|---------|
6680
+ | `fast` | Exact terms, IDs, specific names, short lookup | `query="ResNet-50 accuracy ImageNet", route="fast"` |
6681
+ | `global` | Broad themes, overview, synthesis across topics | `query="deep learning trends 2024", route="global"` |
6682
+ | `hybrid` | Best default — combines keyword + semantic | `query="transformer optimization techniques", route="hybrid"` |
6683
+ | `auto` | Unsure — let system decide | `query="attention", route="auto"` |
6684
+
6685
+ **Default recommendation: always start with `route="hybrid"`** unless you have a specific reason for another route.
6686
+
6687
+ ## 3. Iterative Refinement Protocol
6688
+
6689
+ When results are empty or irrelevant, follow this escalation:
6690
+
6691
+ ```
6692
+ Step 1: Try synonyms/alternative terms (same route)
6693
+ → Still empty?
6694
+ Step 2: Switch route (fast→hybrid, or hybrid→global)
6695
+ → Still empty?
6696
+ Step 3: Broaden query (remove modifiers, use parent concept)
6697
+ → Still empty?
6698
+ Step 4: Check library status (is it initialized? has documents?)
6699
+ → query_knowledge_library(query="*", top_k=1) to test readiness
6700
+ ```
6701
+
6702
+ **Never give up after one empty query.** Always try at least 2-3 variations before concluding the library has no relevant content.
6703
+
6704
+ ## 4. Cross-Library Strategy
6705
+
6706
+ ### When to use Knowledge Library
6707
+ - Research papers, reports, uploaded documents
6708
+ - Domain knowledge, definitions, benchmarks
6709
+ - Historical data, reference material
6710
+
6711
+ ### When to use Code Library
6712
+ - Function implementations, API signatures
6713
+ - Code patterns, architectural decisions
6714
+ - Error handling patterns, test examples
6715
+
6716
+ ### Combined workflow
6717
+ 1. Knowledge first: `query_knowledge_library(query="OAuth 2.0 PKCE flow", route="hybrid")`
6718
+ 2. Code second: `query_code_library(query="oauth pkce implementation", route="fast")`
6719
+ 3. Merge: Use knowledge context to understand the code, use code to ground the theory
6720
+
6721
+ ## 5. Result Interpretation
6722
+
6723
+ ### Score Thresholds
6724
+ - score > 0.7: Strong match — high confidence
6725
+ - score 0.4-0.7: Moderate match — review carefully
6726
+ - score < 0.4: Weak match — may be tangential
6727
+
6728
+ ### Community Cards
6729
+ When results include `community_cards`:
6730
+ - Multiple communities = topic spans multiple areas → consider refining query
6731
+ - Single community = focused topic → results are likely relevant
6732
+
6733
+ ### Empty Results Debugging
6734
+ 1. Check `ready` field in library status
6735
+ 2. Try `query="*", top_k=1` to verify library has content
6736
+ 3. Check if documents were imported (documents > 0)
6737
+ 4. Try simpler single-word queries to find what terms the library contains
6738
+
6739
+ ## 6. Citation Best Practices
6740
+ - Always include the `citation` field from results in your response
6741
+ - Format: [Source: citation_string] after the referenced claim
6742
+ - For multiple sources supporting one claim, list all citations
6743
+ - If `title` differs from `citation`, prefer `citation` for attribution
6744
+
6745
+ ## 7. Common Pitfalls
6746
+ - **Overly broad queries**: "tell me everything" → returns noise. Be specific.
6747
+ - **Wrong route**: Using `fast` for conceptual questions → misses semantic matches.
6748
+ - **Single attempt**: One query with no refinement → likely misses relevant content.
6749
+ - **Ignoring low-score results**: Sometimes the 8th result has the answer. Scan all returned results.
6750
+ - **Not using top_k**: Default may be too low. Use `top_k=10` for exploration, `top_k=5` for focused.
6751
+ """
6752
+
6753
+ # ── Skill B: code-library-navigator ───────────────────────────────
6754
+
6755
+ code_nav_skill = """---
6756
+ name: code-library-navigator
6757
+ aliases:
6758
+ - code-navigator
6759
+ - code-rag
6760
+ - code-search
6761
+ triggers:
6762
+ - code library
6763
+ - code search
6764
+ - function lookup
6765
+ - symbol search
6766
+ - API reference
6767
+ - code review with library
6768
+ - 代码库查询
6769
+ - 代码搜索
6770
+ - 函数查找
6771
+ - 符号搜索
6772
+ clouds_coder:
6773
+ preferred_tools:
6774
+ - query_code_library
6775
+ - read_file
6776
+ - bash
6777
+ description: >
6778
+ Specialized guide for code library retrieval. Code-specific query patterns,
6779
+ language filtering, symbol-aware search, and integration with read_file for full context.
6780
+ TRIGGER when: looking up code implementations, function signatures, API patterns, code review.
6781
+ DO NOT TRIGGER for: knowledge/document retrieval (use rag-retrieval-mastery),
6782
+ general research (use research-orchestrator-pro).
6783
+ ---
6784
+
6785
+ # Code Library Navigator
6786
+
6787
+ Specialized guide for effective code library retrieval via `query_code_library`.
6788
+
6789
+ ## When to Use
6790
+ - Looking up function implementations or API signatures
6791
+ - Finding code patterns or architectural references
6792
+ - Code review with library-backed evidence
6793
+ - Understanding unfamiliar codebases via indexed symbols
6794
+
6795
+ ## 1. Code-Specific Query Patterns
6796
+
6797
+ ### Function/Method Lookup
6798
+ ```
6799
+ query_code_library(query="def authenticate_user", route="fast")
6800
+ query_code_library(query="handleSubmit onClick", route="fast", language="javascript")
6801
+ ```
6802
+
6803
+ ### Class/Module Discovery
6804
+ ```
6805
+ query_code_library(query="class UserRepository database", route="hybrid")
6806
+ query_code_library(query="module authentication middleware", route="hybrid")
6807
+ ```
6808
+
6809
+ ### API Pattern Search
6810
+ ```
6811
+ query_code_library(query="REST endpoint /api/users POST", route="fast")
6812
+ query_code_library(query="GraphQL resolver mutation", route="hybrid")
6813
+ ```
6814
+
6815
+ ### Error Handling Patterns
6816
+ ```
6817
+ query_code_library(query="try catch database connection retry", route="hybrid")
6818
+ query_code_library(query="error boundary fallback component", route="fast", language="typescript")
6819
+ ```
6820
+
6821
+ ### Algorithm/Logic Search
6822
+ ```
6823
+ query_code_library(query="binary search sorted array", route="hybrid")
6824
+ query_code_library(query="LRU cache eviction policy", route="hybrid")
6825
+ ```
6826
+
6827
+ ## 2. Language Filtering
6828
+
6829
+ Use the `language` parameter to narrow results:
6830
+ ```
6831
+ query_code_library(query="sort implementation", language="python")
6832
+ query_code_library(query="async await pattern", language="javascript")
6833
+ query_code_library(query="error handling", language="go")
6834
+ ```
6835
+
6836
+ For multi-language projects, run **two queries** — one filtered, one unfiltered — and compare.
6837
+
6838
+ ## 3. From Snippet to Full Context
6839
+
6840
+ Code library returns **snippets** (320 chars max). To get full context:
6841
+
6842
+ 1. **Query**: `result = query_code_library(query="parse config file", top_k=5)`
6843
+ 2. **Extract path**: Read `citation` field → contains file path
6844
+ 3. **Read full file**: `read_file` on the extracted path
6845
+ 4. **Analyze**: Now you have the full function/class context
6846
+
6847
+ This is the core workflow: **search → locate → read → understand**.
6848
+
6849
+ ## 4. Symbol-Aware Search
6850
+
6851
+ Results may include a `symbol` field (function/class name):
6852
+ - Use symbol for precise follow-up: `query_code_library(query="symbol:UserService.create")`
6853
+ - Symbol results have higher precision than text-only matches
6854
+
6855
+ ## 5. Code Review Workflow
6856
+
6857
+ 1. **Find reference implementations**:
6858
+ `query_code_library(query="similar function pattern", top_k=8)`
6859
+ 2. **Compare patterns**: Check if the code under review follows library conventions
6860
+ 3. **Report deviations**: Note differences in error handling, naming, structure
6861
+ 4. **Verify edge cases**: Query for test files: `query_code_library(query="test_authenticate", route="fast")`
6862
+
6863
+ ## 6. Integration with Knowledge Library
6864
+
6865
+ When a code question needs **domain context**:
6866
+ 1. First: `query_knowledge_library(query="OAuth 2.0 PKCE specification")` — understand the concept
6867
+ 2. Then: `query_code_library(query="pkce code_verifier code_challenge")` — find the implementation
6868
+ 3. Combine: Verify implementation matches specification
6869
+
6870
+ ## Quick Reference
6871
+
6872
+ ```
6873
+ query_code_library(
6874
+ query="search text", # Required: the search query
6875
+ top_k=8, # Optional: results count (1-12, default 8)
6876
+ route="hybrid", # Optional: fast|global|hybrid|auto
6877
+ language="python", # Optional: filter by programming language
6878
+ )
6879
+ ```
6880
+ """
6881
+
6882
+ _write_text_if_changed(generated_root / "rag-retrieval-mastery" / "SKILL.md", rag_mastery_skill)
6883
+ _write_text_if_changed(generated_root / "code-library-navigator" / "SKILL.md", code_nav_skill)
6884
+ _write_text_if_changed(
6885
+ generated_root / "rag-mastery-capabilities.json",
6886
+ json_dumps(
6887
+ {
6888
+ "generated_at": int(now_ts()),
6889
+ "skills": ["rag-retrieval-mastery", "code-library-navigator"],
6890
+ "focus": ["rag_query_mastery", "code_library_navigation"],
6891
+ },
6892
+ indent=2,
6893
+ ),
6894
+ )
6895
+
6896
+ # ---------------------------------------------------------------------------
6897
+ # Generated Skills: Multimodal Reading Comprehension
6898
+ # ---------------------------------------------------------------------------
6899
+
6900
+ def ensure_generated_multimodal_comprehension_skills(skills_root: Path):
6901
+ generated_root = skills_root / "generated"
6902
+
6903
+ # ── Skill C: pdf-reading-comprehension ────────────────────────────
6904
+
6905
+ pdf_skill = """---
6906
+ name: pdf-reading-comprehension
6907
+ aliases:
6908
+ - pdf-reader
6909
+ - pdf-comprehension
6910
+ - read-pdf
6911
+ triggers:
6912
+ - read PDF
6913
+ - PDF analysis
6914
+ - PDF summary
6915
+ - PDF comparison
6916
+ - extract from PDF
6917
+ - PDF table
6918
+ - PDF figure
6919
+ - 阅读PDF
6920
+ - PDF分析
6921
+ - PDF摘要
6922
+ - PDF对比
6923
+ - PDF提取
6924
+ clouds_coder:
6925
+ preferred_tools:
6926
+ - read_file
6927
+ - bash
6928
+ - query_knowledge_library
6929
+ description: >
6930
+ General-purpose PDF reading comprehension skill. Text extraction via .parsed.md,
6931
+ figure analysis via read_file on extracted images, structure extraction, multi-PDF comparison,
6932
+ and summary generation at three depth levels.
6933
+ TRIGGER when: user uploads PDF and asks for reading/analysis/summary/comparison/extraction.
6934
+ DO NOT TRIGGER for: research literature review (use pdf-vision-literature-integrator),
6935
+ PDF creation/generation (use kimi-pdf or pdf skill).
6936
+ ---
6937
+
6938
+ # PDF Reading Comprehension
6939
+
6940
+ General-purpose skill for reading and understanding PDF documents.
6941
+
6942
+ ## When to Use
6943
+ - User uploads a PDF and asks to read, summarize, analyze, or compare it
6944
+ - Need to extract tables, figures, or specific sections from PDFs
6945
+ - Multi-PDF comparison or cross-reference tasks
6946
+
6947
+ ## When NOT to Use
6948
+ - Academic literature review with evidence synthesis → use `pdf-vision-literature-integrator`
6949
+ - Creating/generating PDF files → use `kimi-pdf` or `pdf` output skill
6950
+
6951
+ ## Step 1: Text Extraction
6952
+
6953
+ **Always try `.parsed.md` first** — the backend auto-generates it on upload:
6954
+ ```
6955
+ read_file uploaded/<filename>.parsed.md
6956
+ ```
6957
+
6958
+ If `.parsed.md` is unavailable or incomplete:
6959
+ ```bash
6960
+ # pdftotext (preserves layout)
6961
+ bash: pdftotext -layout "uploaded/<filename>.pdf" -
6962
+
6963
+ # pdfminer (Python, more robust)
6964
+ bash: python3 -c "from pdfminer.high_level import extract_text; print(extract_text('uploaded/<filename>.pdf'))"
6965
+ ```
6966
+
6967
+ **Scanned PDF detection**: If extracted text is empty, garbled, or very short relative to page count,
6968
+ the PDF is likely scanned → use OCR fallback (Step 5).
6969
+
6970
+ ## Step 2: Structure Recognition
6971
+
6972
+ After reading text, identify the document structure:
6973
+ 1. **Headings/Sections**: Look for numbered sections, bold text, ALL CAPS patterns
6974
+ 2. **Table of Contents**: Often in first 2 pages — use as navigation map
6975
+ 3. **Tables**: Look for tab-separated or pipe-separated data in parsed text
6976
+ 4. **Figure references**: "Figure 1", "Fig. 2", "Table 3" — note their locations
6977
+ 5. **Page numbers**: Track content locations for citations
6978
+
6979
+ ## Step 3: Visual Elements (Figures/Charts/Diagrams)
6980
+
6981
+ Check if images were extracted alongside the upload:
6982
+ ```bash
6983
+ bash: ls uploaded/<filename_stem>*images* 2>/dev/null || ls uploaded/*images*/ 2>/dev/null
6984
+ ```
6985
+
6986
+ If image directory exists:
6987
+ 1. `read_file` on each image file — runtime injects as native vision input
6988
+ 2. Correlate each image with nearby text/captions in the parsed content
6989
+ 3. Describe what the figure shows and how it relates to the text
6990
+
6991
+ If no images extracted but the PDF contains figures:
6992
+ ```bash
6993
+ # Probe PyMuPDF availability
6994
+ bash: python3 -c "import fitz; print('PyMuPDF available')" 2>&1
6995
+ # If available, extract figures
6996
+ bash: python3 -c "
6997
+ import fitz
6998
+ doc = fitz.open('uploaded/<filename>.pdf')
6999
+ for i, page in enumerate(doc):
7000
+ for j, img in enumerate(page.get_images(full=True)):
7001
+ xref = img[0]
7002
+ pix = fitz.Pixmap(doc, xref)
7003
+ if pix.n >= 5: pix = fitz.Pixmap(fitz.csRGB, pix)
7004
+ pix.save(f'uploaded/fig_p{i+1}_{j+1}.png')
7005
+ print(f'Saved: uploaded/fig_p{i+1}_{j+1}.png')
7006
+ "
7007
+ ```
7008
+
7009
+ ## Step 4: Multi-PDF Comparison
7010
+
7011
+ When comparing multiple PDFs:
7012
+ 1. Read each PDF's `.parsed.md`
7013
+ 2. Build a comparison matrix:
7014
+
7015
+ | Aspect | Document A | Document B | Notes |
7016
+ |--------|-----------|-----------|-------|
7017
+ | Topic | ... | ... | Agreement/Difference |
7018
+ | Method | ... | ... | ... |
7019
+ | Findings | ... | ... | ... |
7020
+
7021
+ 3. Synthesize: What do the documents agree on? Where do they differ? Why?
7022
+
7023
+ ## Step 5: Summary Generation
7024
+
7025
+ Three depth levels:
7026
+
7027
+ ### Executive Summary (3-5 bullets)
7028
+ - Core conclusion
7029
+ - Key data point
7030
+ - Main recommendation
7031
+ - Critical limitation
7032
+
7033
+ ### Standard Summary (section-by-section)
7034
+ For each major section: 2-3 sentences covering the key points.
7035
+ Always cite section/page numbers.
7036
+
7037
+ ### Deep Summary (claim-evidence-source)
7038
+ | Claim | Evidence | Source (page/section) | Confidence |
7039
+ |-------|----------|----------------------|------------|
7040
+
7041
+ ## Step 6: Fallback for Scanned PDFs
7042
+
7043
+ If text extraction returns empty/garbled content:
7044
+ ```bash
7045
+ # OCR with tesseract
7046
+ bash: tesseract "uploaded/<filename>.pdf" stdout pdf 2>/dev/null || echo "tesseract not available"
7047
+
7048
+ # ocrmypdf (adds OCR layer to PDF, then re-extract)
7049
+ bash: ocrmypdf "uploaded/<filename>.pdf" "/tmp/ocr_output.pdf" && pdftotext "/tmp/ocr_output.pdf" -
7050
+ ```
7051
+
7052
+ ## Notes
7053
+ - Always cite page numbers or section references in your analysis
7054
+ - For very large PDFs (100+ pages), focus on the sections relevant to the user's question
7055
+ - When RAG library has the PDF imported, also try: `query_knowledge_library(query="<topic>", route="hybrid")`
7056
+ """
7057
+
7058
+ # ── Skill D: audio-comprehension ──────────────────────────────────
7059
+
7060
+ audio_skill = """---
7061
+ name: audio-comprehension
7062
+ aliases:
7063
+ - audio-reader
7064
+ - audio-analysis
7065
+ - listen-audio
7066
+ - transcription
7067
+ triggers:
7068
+ - audio analysis
7069
+ - listen to audio
7070
+ - transcribe
7071
+ - meeting notes
7072
+ - lecture notes
7073
+ - podcast summary
7074
+ - speech analysis
7075
+ - 音频分析
7076
+ - 听音频
7077
+ - 转录
7078
+ - 会议纪要
7079
+ - 讲座笔记
7080
+ - 语音识别
7081
+ clouds_coder:
7082
+ preferred_tools:
7083
+ - read_file
7084
+ - bash
7085
+ description: >
7086
+ Audio comprehension workflow: native analysis via read_file when model supports audio input,
7087
+ with whisper/ffmpeg fallback. Covers speech, music, sound analysis, meeting/lecture notes.
7088
+ TRIGGER when: user uploads audio and asks for analysis/transcription/notes/summary.
7089
+ DO NOT TRIGGER for: audio generation or text-to-speech tasks.
7090
+ ---
7091
+
7092
+ # Audio Comprehension
7093
+
7094
+ Workflow for analyzing and understanding audio files.
7095
+
7096
+ ## Supported Formats
7097
+ mp3, wav, m4a, aac, flac, ogg, oga, opus, webm
7098
+
7099
+ ## Primary: Native Audio Analysis
7100
+
7101
+ Use `read_file` on the audio file — the runtime automatically injects it as native audio input
7102
+ if the model supports it:
7103
+ ```
7104
+ read_file uploaded/<filename>.mp3
7105
+ ```
7106
+
7107
+ The model can then directly analyze:
7108
+ - Speech content (what is being said)
7109
+ - Speaker tone, emotion, emphasis
7110
+ - Music elements (if applicable)
7111
+ - Background sounds and environment
7112
+
7113
+ **After loading**, describe what you hear and answer the user's question based on the audio content.
7114
+
7115
+ ## Fallback: Transcription Tools
7116
+
7117
+ If the model reports native audio input is unavailable, use external transcription:
7118
+
7119
+ ### Whisper (recommended)
7120
+ ```bash
7121
+ # Quick transcription
7122
+ bash: whisper "uploaded/<filename>.mp3" --model base --output_format txt --output_dir /tmp/
7123
+
7124
+ # Higher quality (slower)
7125
+ bash: whisper "uploaded/<filename>.mp3" --model small --output_format txt --output_dir /tmp/
7126
+
7127
+ # Read transcript
7128
+ read_file /tmp/<filename>.txt
7129
+ ```
7130
+
7131
+ ### Python whisper library
7132
+ ```bash
7133
+ bash: python3 -c "
7134
+ import whisper
7135
+ model = whisper.load_model('base')
7136
+ result = model.transcribe('uploaded/<filename>.mp3')
7137
+ print(result['text'])
7138
+ "
7139
+ ```
7140
+
7141
+ ### Format conversion (if needed)
7142
+ ```bash
7143
+ # Convert to wav for better compatibility
7144
+ bash: ffmpeg -i "uploaded/<filename>.m4a" -ar 16000 -ac 1 /tmp/converted.wav
7145
+ ```
7146
+
7147
+ ## Meeting Notes Workflow
7148
+
7149
+ 1. Load audio via `read_file` (or transcribe via fallback)
7150
+ 2. Identify key elements:
7151
+ - Participants (if identifiable)
7152
+ - Topics discussed (segment by theme)
7153
+ - Decisions made
7154
+ - Action items assigned
7155
+ 3. Output structured meeting notes:
7156
+
7157
+ ```markdown
7158
+ # Meeting Notes — [Date/Topic]
7159
+
7160
+ ## Participants
7161
+ - Speaker A, Speaker B, ...
7162
+
7163
+ ## Discussion Topics
7164
+ ### Topic 1: [name]
7165
+ - Key points discussed
7166
+ - Decisions: ...
7167
+
7168
+ ### Topic 2: [name]
7169
+ - ...
7170
+
7171
+ ## Action Items
7172
+ - [ ] [Person]: [Action] — by [deadline]
7173
+
7174
+ ## Follow-up
7175
+ - Next meeting: ...
7176
+ ```
7177
+
7178
+ ## Lecture / Podcast Summary
7179
+
7180
+ 1. Load or transcribe the audio
7181
+ 2. Identify the structure:
7182
+ - Introduction / topic statement
7183
+ - Main arguments / sections
7184
+ - Examples and evidence
7185
+ - Conclusion / takeaways
7186
+ 3. Output:
7187
+ - **Key Topics** (bulleted list)
7188
+ - **Section-by-section summary** (2-3 sentences each)
7189
+ - **Notable quotes** (verbatim if possible)
7190
+ - **Study questions** (for lectures)
7191
+
7192
+ ## Quality Notes
7193
+ - File size limit: <20 MB for native audio input
7194
+ - Long audio (>15 min): Consider chunking via ffmpeg:
7195
+ `bash: ffmpeg -i input.mp3 -ss 0 -t 300 chunk1.mp3` (first 5 min)
7196
+ - Noisy audio: Transcription quality degrades — note uncertainty in output
7197
+ - Multiple speakers: Native models may distinguish speakers; whisper may not
7198
+ """
7199
+
7200
+ # ── Skill E: video-comprehension ──────────────────────────────────
7201
+
7202
+ video_skill = """---
7203
+ name: video-comprehension
7204
+ aliases:
7205
+ - video-reader
7206
+ - video-analysis
7207
+ - watch-video
7208
+ triggers:
7209
+ - video analysis
7210
+ - watch video
7211
+ - video summary
7212
+ - video transcript
7213
+ - screen recording
7214
+ - 视频分析
7215
+ - 看视频
7216
+ - 视频摘要
7217
+ - 视频转录
7218
+ - 录屏分析
7219
+ clouds_coder:
7220
+ preferred_tools:
7221
+ - read_file
7222
+ - bash
7223
+ description: >
7224
+ Video comprehension workflow: native analysis via read_file when model supports video input,
7225
+ with frame extraction fallback via ffmpeg. Covers temporal analysis, video summarization,
7226
+ screen recording interpretation, and tutorial extraction.
7227
+ TRIGGER when: user uploads video and asks for analysis/summary/transcription.
7228
+ DO NOT TRIGGER for: video generation tasks.
7229
+ ---
7230
+
7231
+ # Video Comprehension
7232
+
7233
+ Workflow for analyzing and understanding video files.
7234
+
7235
+ ## Supported Formats
7236
+ mp4, mov, avi, mkv, webm, m4v, mpeg, mpg, 3gp
7237
+
7238
+ ## Primary: Native Video Analysis
7239
+
7240
+ Use `read_file` on the video file — the runtime injects it as native video input if the model supports it:
7241
+ ```
7242
+ read_file uploaded/<filename>.mp4
7243
+ ```
7244
+
7245
+ The model can then directly analyze:
7246
+ - Visual content (scenes, objects, people, text overlays)
7247
+ - Audio track (speech, music, sound effects)
7248
+ - Temporal flow (what happens when)
7249
+ - UI elements (for screen recordings)
7250
+
7251
+ ## Fallback: Frame Extraction + Audio Separation
7252
+
7253
+ If native video input is unavailable:
7254
+
7255
+ ### Extract Key Frames
7256
+ ```bash
7257
+ # One frame every 5 seconds
7258
+ bash: mkdir -p /tmp/frames && ffmpeg -i "uploaded/<filename>.mp4" -vf "fps=1/5" -q:v 2 /tmp/frames/frame_%04d.jpg 2>&1 | tail -3
7259
+
7260
+ # List extracted frames
7261
+ bash: ls /tmp/frames/
7262
+ ```
7263
+
7264
+ Then analyze each frame via `read_file`:
7265
+ ```
7266
+ read_file /tmp/frames/frame_0001.jpg
7267
+ read_file /tmp/frames/frame_0002.jpg
7268
+ ...
7269
+ ```
7270
+
7271
+ ### Extract Audio Track
7272
+ ```bash
7273
+ bash: ffmpeg -i "uploaded/<filename>.mp4" -vn -acodec pcm_s16le /tmp/audio_track.wav 2>&1 | tail -3
7274
+ ```
7275
+ Then apply the `audio-comprehension` workflow on the extracted audio.
7276
+
7277
+ ### Combine Visual + Audio Analysis
7278
+ Merge the visual scene descriptions with audio transcription to build a complete understanding.
7279
+
7280
+ ## Screen Recording Analysis
7281
+
7282
+ For screen recordings / UI walkthroughs:
7283
+ 1. Extract frames at higher frequency (every 2 seconds):
7284
+ `bash: ffmpeg -i input.mp4 -vf "fps=1/2" frames/frame_%04d.jpg`
7285
+ 2. Analyze each frame for:
7286
+ - Application/webpage being shown
7287
+ - UI state changes between frames
7288
+ - Text content on screen
7289
+ - Error messages or notifications
7290
+ 3. Reconstruct the workflow step by step
7291
+ 4. Output as a tutorial or issue report
7292
+
7293
+ ## Video Summarization
7294
+
7295
+ 1. Load video natively or extract frames
7296
+ 2. Build scene list:
7297
+ | Timestamp | Scene Description | Key Content |
7298
+ |-----------|------------------|-------------|
7299
+ | 0:00-0:30 | Intro | Title, speaker |
7300
+ | 0:30-2:00 | Topic 1 | Main argument |
7301
+ | ... | ... | ... |
7302
+ 3. Generate structured summary:
7303
+ - **Overview** (1-2 sentences)
7304
+ - **Timeline** (scene-by-scene)
7305
+ - **Key moments** (with timestamps)
7306
+ - **Conclusions**
7307
+
7308
+ ## Quality Notes
7309
+ - File size limit: <20 MB for native video input
7310
+ - Long videos: Sample frames strategically rather than extracting all
7311
+ - First frame, last frame, and N evenly-spaced frames in between
7312
+ - Resolution affects analysis quality — high-res frames give better results
7313
+ - For tutorials: higher frame rate (1 fps) captures more UI state changes
7314
+ """
7315
+
7316
+ # ── Skill F: data-analysis-deep-dive ──────────────────────────────
7317
+
7318
+ data_skill = """---
7319
+ name: data-analysis-deep-dive
7320
+ aliases:
7321
+ - data-deep-dive
7322
+ - deep-data-analysis
7323
+ - advanced-tabular
7324
+ - data-science
7325
+ triggers:
7326
+ - data analysis
7327
+ - statistical analysis
7328
+ - data profiling
7329
+ - data visualization
7330
+ - dataset comparison
7331
+ - data quality
7332
+ - correlation analysis
7333
+ - 数据分析
7334
+ - 统计分析
7335
+ - 数据质量
7336
+ - 数据可视化
7337
+ - 数据对比
7338
+ - 相关性分析
7339
+ clouds_coder:
7340
+ preferred_tools:
7341
+ - bash
7342
+ - read_file
7343
+ - write_file
7344
+ - query_knowledge_library
7345
+ description: >
7346
+ Enhanced data analysis skill for CSV/XLSX files. Statistical analysis via Python,
7347
+ data profiling, visualization generation, quality assessment, cross-dataset comparison,
7348
+ and RAG-backed contextual analysis.
7349
+ TRIGGER when: user needs statistical analysis, data profiling, visualization, or dataset comparison.
7350
+ DO NOT TRIGGER for: simple file reading (use upload-tabular-parser),
7351
+ research workflows (use research-orchestrator-pro).
7352
+ ---
7353
+
7354
+ # Data Analysis Deep Dive
7355
+
7356
+ Advanced data analysis workflow beyond basic file parsing.
7357
+
7358
+ ## When to Use
7359
+ - Statistical analysis, hypothesis testing
7360
+ - Data profiling and quality assessment
7361
+ - Visualization / chart generation
7362
+ - Cross-dataset comparison
7363
+ - RAG-backed contextual analysis
7364
+
7365
+ ## When NOT to Use
7366
+ - Just reading a CSV/XLSX file → use `upload-tabular-parser`
7367
+ - Full research workflow → use `research-orchestrator-pro` Phase 2
7368
+
7369
+ ## Step 1: Data Ingestion
7370
+
7371
+ Read the `.parsed.md` for a quick preview, then load properly via Python:
7372
+ ```bash
7373
+ bash: python3 -c "
7374
+ import pandas as pd
7375
+ df = pd.read_csv('uploaded/<filename>.csv')
7376
+ print('Shape:', df.shape)
7377
+ print('Columns:', list(df.columns))
7378
+ print('Types:', df.dtypes.to_dict())
7379
+ print('Head:')
7380
+ print(df.head())
7381
+ "
7382
+ ```
7383
+
7384
+ For XLSX:
7385
+ ```bash
7386
+ bash: python3 -c "
7387
+ import pandas as pd
7388
+ xls = pd.ExcelFile('uploaded/<filename>.xlsx')
7389
+ print('Sheets:', xls.sheet_names)
7390
+ df = pd.read_excel(xls, sheet_name=0)
7391
+ print('Shape:', df.shape)
7392
+ print('Head:')
7393
+ print(df.head())
7394
+ "
7395
+ ```
7396
+
7397
+ Handle encoding/delimiter issues:
7398
+ ```bash
7399
+ # Auto-detect encoding
7400
+ bash: python3 -c "
7401
+ import chardet
7402
+ with open('uploaded/<filename>.csv', 'rb') as f:
7403
+ result = chardet.detect(f.read(10000))
7404
+ print(result)
7405
+ "
7406
+ ```
7407
+
7408
+ ## Step 2: Data Profiling
7409
+
7410
+ Quick automated profile:
7411
+ ```bash
7412
+ bash: python3 -c "
7413
+ import pandas as pd
7414
+ df = pd.read_csv('uploaded/<filename>.csv')
7415
+ print('=== Shape ===')
7416
+ print(df.shape)
7417
+ print('=== Missing Values ===')
7418
+ print(df.isnull().sum())
7419
+ print('=== Unique Counts ===')
7420
+ print(df.nunique())
7421
+ print('=== Descriptive Stats ===')
7422
+ print(df.describe(include='all').to_string())
7423
+ "
7424
+ ```
7425
+
7426
+ Key metrics to report:
7427
+ - Row count, column count
7428
+ - Missing values per column (count and percentage)
7429
+ - Unique values per column
7430
+ - Min/max/mean/std for numeric columns
7431
+ - Top-N value counts for categorical columns
7432
+
7433
+ ## Step 3: Statistical Analysis
7434
+
7435
+ Choose the right analysis based on the question:
7436
+
7437
+ ### Descriptive Statistics
7438
+ ```python
7439
+ df.describe(), df.corr(), df.groupby('category').mean()
7440
+ ```
7441
+
7442
+ ### Correlation Analysis
7443
+ ```bash
7444
+ bash: python3 -c "
7445
+ import pandas as pd
7446
+ df = pd.read_csv('uploaded/<filename>.csv')
7447
+ corr = df.select_dtypes(include='number').corr()
7448
+ print(corr.to_string())
7449
+ # Highlight strong correlations
7450
+ for col1 in corr.columns:
7451
+ for col2 in corr.columns:
7452
+ if col1 < col2 and abs(corr.loc[col1, col2]) > 0.7:
7453
+ print(f'Strong: {col1} <-> {col2}: {corr.loc[col1, col2]:.3f}')
7454
+ "
7455
+ ```
7456
+
7457
+ ### Hypothesis Testing
7458
+ ```bash
7459
+ bash: python3 -c "
7460
+ from scipy import stats
7461
+ import pandas as pd
7462
+ df = pd.read_csv('uploaded/<filename>.csv')
7463
+ # Example: t-test between two groups
7464
+ group_a = df[df['group'] == 'A']['value']
7465
+ group_b = df[df['group'] == 'B']['value']
7466
+ t_stat, p_value = stats.ttest_ind(group_a, group_b)
7467
+ print(f't-stat: {t_stat:.4f}, p-value: {p_value:.4f}')
7468
+ print('Significant' if p_value < 0.05 else 'Not significant')
7469
+ "
7470
+ ```
7471
+
7472
+ ## Step 4: Visualization
7473
+
7474
+ Generate charts and save to workspace:
7475
+
7476
+ ### Bar Chart (comparison)
7477
+ ```bash
7478
+ bash: python3 -c "
7479
+ import matplotlib
7480
+ matplotlib.use('Agg')
7481
+ import matplotlib.pyplot as plt
7482
+ import pandas as pd
7483
+ df = pd.read_csv('uploaded/<filename>.csv')
7484
+ df.groupby('category')['value'].mean().plot(kind='bar', title='Average Value by Category')
7485
+ plt.ylabel('Value')
7486
+ plt.tight_layout()
7487
+ plt.savefig('chart_comparison.png', dpi=150)
7488
+ print('Saved: chart_comparison.png')
7489
+ "
7490
+ ```
7491
+
7492
+ ### Line Chart (trend)
7493
+ ```bash
7494
+ bash: python3 -c "
7495
+ import matplotlib; matplotlib.use('Agg')
7496
+ import matplotlib.pyplot as plt; import pandas as pd
7497
+ df = pd.read_csv('uploaded/<filename>.csv')
7498
+ plt.figure(figsize=(10,5))
7499
+ plt.plot(df['date'], df['value'])
7500
+ plt.title('Value Trend'); plt.xlabel('Date'); plt.ylabel('Value')
7501
+ plt.xticks(rotation=45); plt.tight_layout()
7502
+ plt.savefig('chart_trend.png', dpi=150); print('Saved: chart_trend.png')
7503
+ "
7504
+ ```
7505
+
7506
+ ### Chart Selection Guide
7507
+ | Intent | Chart Type | matplotlib call |
7508
+ |--------|-----------|-----------------|
7509
+ | Comparison | Bar | `.plot(kind='bar')` |
7510
+ | Trend over time | Line | `plt.plot()` |
7511
+ | Distribution | Histogram | `.plot(kind='hist')` |
7512
+ | Correlation | Scatter | `plt.scatter()` |
7513
+ | Composition | Pie | `.plot(kind='pie')` |
7514
+ | Distribution shape | Box plot | `.plot(kind='box')` |
7515
+
7516
+ Always include: title, axis labels, legend (if multiple series), tight_layout.
7517
+
7518
+ ## Step 5: Data Quality Assessment
7519
+
7520
+ ```bash
7521
+ bash: python3 -c "
7522
+ import pandas as pd
7523
+ df = pd.read_csv('uploaded/<filename>.csv')
7524
+ print('=== Missing Data ===')
7525
+ missing = df.isnull().sum()
7526
+ missing_pct = (missing / len(df) * 100).round(2)
7527
+ print(pd.DataFrame({'count': missing, 'percent': missing_pct})[missing > 0].to_string())
7528
+
7529
+ print('\\n=== Duplicates ===')
7530
+ print(f'Duplicate rows: {df.duplicated().sum()} / {len(df)}')
7531
+
7532
+ print('\\n=== Outliers (IQR method) ===')
7533
+ for col in df.select_dtypes(include='number').columns:
7534
+ Q1, Q3 = df[col].quantile(0.25), df[col].quantile(0.75)
7535
+ IQR = Q3 - Q1
7536
+ outliers = ((df[col] < Q1 - 1.5*IQR) | (df[col] > Q3 + 1.5*IQR)).sum()
7537
+ if outliers > 0:
7538
+ print(f' {col}: {outliers} outliers')
7539
+ "
7540
+ ```
7541
+
7542
+ ## Step 6: Cross-Dataset Comparison
7543
+
7544
+ When comparing two datasets:
7545
+ 1. Load both: `df_a = pd.read_csv('a.csv')`, `df_b = pd.read_csv('b.csv')`
7546
+ 2. Compare schemas: column names, types, missing patterns
7547
+ 3. Compare distributions: mean, std, min, max for shared columns
7548
+ 4. Identify drift: significant differences in distributions
7549
+ 5. Output comparison matrix
7550
+
7551
+ ## RAG Context Integration
7552
+
7553
+ Use `query_knowledge_library` to ground your analysis in domain knowledge:
7554
+ ```
7555
+ query_knowledge_library(query="normal range for <metric>", route="hybrid")
7556
+ query_knowledge_library(query="<domain> benchmark data", route="hybrid")
7557
+ ```
7558
+
7559
+ This helps you:
7560
+ - Know what ranges are "normal" for the domain
7561
+ - Compare against published benchmarks
7562
+ - Understand what statistical tests are appropriate
7563
+ """
7564
+
7565
+ _write_text_if_changed(generated_root / "pdf-reading-comprehension" / "SKILL.md", pdf_skill)
7566
+ _write_text_if_changed(generated_root / "audio-comprehension" / "SKILL.md", audio_skill)
7567
+ _write_text_if_changed(generated_root / "video-comprehension" / "SKILL.md", video_skill)
7568
+ _write_text_if_changed(generated_root / "data-analysis-deep-dive" / "SKILL.md", data_skill)
7569
+ _write_text_if_changed(
7570
+ generated_root / "multimodal-comprehension-capabilities.json",
7571
+ json_dumps(
7572
+ {
7573
+ "generated_at": int(now_ts()),
7574
+ "skills": [
7575
+ "pdf-reading-comprehension",
7576
+ "audio-comprehension",
7577
+ "video-comprehension",
7578
+ "data-analysis-deep-dive",
7579
+ ],
7580
+ "focus": [
7581
+ "pdf_comprehension",
7582
+ "audio_comprehension",
7583
+ "video_comprehension",
7584
+ "advanced_data_analysis",
7585
+ ],
7586
+ },
7587
+ indent=2,
7588
+ ),
7589
+ )
7590
+
7591
+
6419
7592
  def ensure_generated_runtime_skills_manifest(skills_root: Path):
6420
7593
  generated_root = skills_root / "generated"
6421
7594
  tracked = [
@@ -6746,6 +7919,8 @@ def ensure_runtime_skills(skills_root: Path):
6746
7919
  ensure_generated_html_frontend_report_skills(skills_root)
6747
7920
  ensure_generated_deep_research_skills(skills_root)
6748
7921
  ensure_generated_research_scientific_skills(skills_root)
7922
+ ensure_generated_rag_mastery_skills(skills_root)
7923
+ ensure_generated_multimodal_comprehension_skills(skills_root)
6749
7924
  ensure_generated_runtime_skills_manifest(skills_root)
6750
7925
  ensure_embedded_clawhub_skills(skills_root)
6751
7926
 
@@ -7153,6 +8328,117 @@ class SkillStore:
7153
8328
  total_chars += len(text)
7154
8329
  return chosen
7155
8330
 
8331
+ def _provider_root_path(self, provider_id: str) -> Path | None:
8332
+ row = self.providers.get(str(provider_id or "").strip(), {})
8333
+ raw = str(row.get("root", "") or "").strip() if isinstance(row, dict) else ""
8334
+ if not raw:
8335
+ return None
8336
+ try:
8337
+ return Path(raw).resolve()
8338
+ except Exception:
8339
+ return None
8340
+
8341
+ def _provider_virtual_prefix(self, provider_id: str) -> str:
8342
+ pid = self._sanitize_provider_id(provider_id, "provider")
8343
+ return f"{SKILLS_VIRTUAL_PREFIX}/{SKILLS_EXTERNAL_MOUNT}/{pid}"
8344
+
8345
+ def _public_provider_path(self, provider_id: str, raw_locator: str | Path) -> str:
8346
+ raw = str(raw_locator or "").strip()
8347
+ if not raw:
8348
+ return ""
8349
+ if raw == "(builtin)" or _is_http_url(raw):
8350
+ return raw
8351
+ base, sep, frag = raw.partition("#")
8352
+ if _is_http_url(base):
8353
+ return raw
8354
+ try:
8355
+ candidate = Path(base)
8356
+ except Exception:
8357
+ return self._public_skill_locator(raw)
8358
+ try:
8359
+ resolved = candidate.resolve() if candidate.is_absolute() else (self.skills_root / candidate).resolve()
8360
+ except Exception:
8361
+ resolved = None
8362
+ if resolved is not None:
8363
+ try:
8364
+ rel = resolved.relative_to(self.skills_root.resolve()).as_posix()
8365
+ public = SKILLS_VIRTUAL_PREFIX if rel in {"", "."} else f"{SKILLS_VIRTUAL_PREFIX}/{rel}"
8366
+ public = public.replace("//", "/")
8367
+ return public + (f"#{frag}" if sep else "")
8368
+ except Exception:
8369
+ pass
8370
+ root = self._provider_root_path(provider_id)
8371
+ if root is not None:
8372
+ try:
8373
+ rel = resolved.relative_to(root).as_posix()
8374
+ prefix = self._provider_virtual_prefix(provider_id)
8375
+ public = prefix if rel in {"", "."} else f"{prefix}/{rel}"
8376
+ public = public.replace("//", "/")
8377
+ return public + (f"#{frag}" if sep else "")
8378
+ except Exception:
8379
+ pass
8380
+ return self._public_skill_locator(raw)
8381
+
8382
+ def resolve_virtual_skill_path(self, rel_path: str) -> Path:
8383
+ rel = str(PurePosixPath(str(rel_path or "").replace("\\", "/"))).strip().lstrip("/")
8384
+ parts = PurePosixPath(rel).parts
8385
+ if len(parts) >= 2 and parts[0] == SKILLS_EXTERNAL_MOUNT:
8386
+ provider_id = str(parts[1]).strip()
8387
+ root = self._provider_root_path(provider_id)
8388
+ if root is None:
8389
+ raise ValueError(f"unknown external skill provider: {provider_id}")
8390
+ tail = PurePosixPath(*parts[2:]).as_posix() if len(parts) > 2 else "."
8391
+ return safe_path(tail or ".", root)
8392
+ return safe_path(rel or ".", self.skills_root)
8393
+
8394
+ def public_virtual_skill_path_for_abs(self, path: Path) -> str:
8395
+ try:
8396
+ resolved = path.resolve()
8397
+ except Exception:
8398
+ return ""
8399
+ try:
8400
+ rel = resolved.relative_to(self.skills_root.resolve()).as_posix()
8401
+ public = SKILLS_VIRTUAL_PREFIX if rel in {"", "."} else f"{SKILLS_VIRTUAL_PREFIX}/{rel}"
8402
+ return public.replace("//", "/")
8403
+ except Exception:
8404
+ pass
8405
+ for provider_id in sorted(self.providers.keys()):
8406
+ root = self._provider_root_path(provider_id)
8407
+ if root is None:
8408
+ continue
8409
+ try:
8410
+ if root == self.skills_root.resolve() or root.is_relative_to(self.skills_root.resolve()):
8411
+ continue
8412
+ except Exception:
8413
+ pass
8414
+ try:
8415
+ rel = resolved.relative_to(root).as_posix()
8416
+ except Exception:
8417
+ continue
8418
+ prefix = self._provider_virtual_prefix(provider_id)
8419
+ public = prefix if rel in {"", "."} else f"{prefix}/{rel}"
8420
+ return public.replace("//", "/")
8421
+ return ""
8422
+
8423
+ def shell_virtual_mappings(self) -> list[tuple[str, str]]:
8424
+ rows: list[tuple[str, str]] = []
8425
+ try:
8426
+ skills_root = self.skills_root.resolve()
8427
+ except Exception:
8428
+ skills_root = self.skills_root
8429
+ for provider_id in sorted(self.providers.keys()):
8430
+ root = self._provider_root_path(provider_id)
8431
+ if root is None:
8432
+ continue
8433
+ try:
8434
+ if root == skills_root or root.is_relative_to(skills_root):
8435
+ continue
8436
+ except Exception:
8437
+ pass
8438
+ rows.append((self._provider_virtual_prefix(provider_id), str(root)))
8439
+ rows.sort(key=lambda x: len(x[0]), reverse=True)
8440
+ return rows
8441
+
7156
8442
  def _public_skill_locator(self, raw_locator: str) -> str:
7157
8443
  raw = str(raw_locator or "").strip()
7158
8444
  if not raw:
@@ -7214,12 +8500,15 @@ class SkillStore:
7214
8500
  def _public_attachment_locator(self, item: dict) -> tuple[str, str]:
7215
8501
  source = str(item.get("virtual_path", "") or "").strip()
7216
8502
  if not source:
7217
- source = self._public_skill_locator(str(item.get("abs_path", "") or ""))
8503
+ source = self._public_provider_locator(
8504
+ str(item.get("abs_path", "") or ""),
8505
+ str(item.get("provider_id", "") or ""),
8506
+ )
7218
8507
  virtual_path = source if source.startswith(f"{SKILLS_VIRTUAL_PREFIX}/") else ""
7219
8508
  return source, virtual_path
7220
8509
 
7221
- def _public_provider_locator(self, raw_locator: str) -> str:
7222
- public = self._public_skill_locator(raw_locator)
8510
+ def _public_provider_locator(self, raw_locator: str, provider_id: str = "") -> str:
8511
+ public = self._public_provider_path(provider_id, raw_locator)
7223
8512
  if public == "(external-local-path)":
7224
8513
  return ""
7225
8514
  return public
@@ -7255,6 +8544,7 @@ class SkillStore:
7255
8544
  self,
7256
8545
  skill_dir: Path,
7257
8546
  *,
8547
+ provider_id: str = "",
7258
8548
  skip_name: str = "SKILL.md",
7259
8549
  globs: list[str] | None = None,
7260
8550
  base_dir: Path | None = None,
@@ -7299,7 +8589,9 @@ class SkillStore:
7299
8589
  {
7300
8590
  "path": rel,
7301
8591
  "abs_path": str(fp.resolve()),
7302
- "virtual_path": f"{SKILLS_VIRTUAL_PREFIX}/{skill_rel}".replace("//", "/"),
8592
+ "virtual_path": self._public_provider_path(provider_id, fp.resolve())
8593
+ or f"{SKILLS_VIRTUAL_PREFIX}/{skill_rel}".replace("//", "/"),
8594
+ "provider_id": provider_id,
7303
8595
  "content": text or "",
7304
8596
  "text_available": text is not None,
7305
8597
  "size": size,
@@ -7338,7 +8630,7 @@ class SkillStore:
7338
8630
  "protocol_version": protocol_version,
7339
8631
  "meta": dict(meta or {}),
7340
8632
  "body": (body or "").strip(),
7341
- "skill_path": self._public_skill_locator(skill_path),
8633
+ "skill_path": self._public_provider_path(provider_id, skill_path),
7342
8634
  "skill_abs_path": self._absolute_skill_locator(skill_path),
7343
8635
  "attachments": attachments,
7344
8636
  }
@@ -7361,6 +8653,7 @@ class SkillStore:
7361
8653
  desc = str(meta.get("description", "-"))
7362
8654
  attachments = self._collect_attachments(
7363
8655
  skill_dir,
8656
+ provider_id=provider_id,
7364
8657
  skip_name=skill_file.name,
7365
8658
  globs=self._skill_attachment_globs(meta),
7366
8659
  entrypoints=self._skill_entrypoints(meta),
@@ -7871,8 +9164,9 @@ class SkillStore:
7871
9164
  out = []
7872
9165
  for provider in sorted(self.providers.values(), key=lambda x: x["provider_id"]):
7873
9166
  row = dict(provider)
7874
- row["root"] = self._public_provider_locator(str(row.get("root", "") or ""))
7875
- row["config_path"] = self._public_provider_locator(str(row.get("config_path", "") or ""))
9167
+ provider_id = str(row.get("provider_id", "") or "")
9168
+ row["root"] = self._public_provider_locator(str(row.get("root", "") or ""), provider_id)
9169
+ row["config_path"] = self._public_provider_locator(str(row.get("config_path", "") or ""), provider_id)
7876
9170
  out.append(row)
7877
9171
  return out
7878
9172
 
@@ -8006,8 +9300,10 @@ class SkillStore:
8006
9300
  if not skill_abs_path:
8007
9301
  raise ValueError("skill has no local absolute path")
8008
9302
  base = Path(skill_abs_path).resolve().parent
8009
- vp_rel = (base / rel).resolve().relative_to(self.skills_root.resolve()).as_posix()
8010
- virtual_path = f"{SKILLS_VIRTUAL_PREFIX}/{vp_rel}".replace("//", "/")
9303
+ virtual_path = self._public_provider_path(
9304
+ str(skill.get("provider_id", "") or ""),
9305
+ (base / rel).resolve(),
9306
+ )
8011
9307
  except Exception:
8012
9308
  virtual_path = str(row.get("virtual_path", "") or "")
8013
9309
  lines.append(
@@ -9276,6 +10572,116 @@ class OllamaClient:
9276
10572
  tool_calls = self._normalize_tool_calls(msg.get("tool_calls", []) if isinstance(msg, dict) else [])
9277
10573
  return {"content": content, "thinking": thinking_content, "tool_calls": tool_calls, "raw": raw}
9278
10574
 
10575
+ # ── Anthropic Messages API ─────────────────────────────────────
10576
+
10577
+ def _chat_anthropic(
10578
+ self,
10579
+ req_messages: list[dict],
10580
+ *,
10581
+ tools: list[dict] | None = None,
10582
+ max_tokens: int = 2000,
10583
+ temperature: float = 0.2,
10584
+ think: bool = False,
10585
+ ) -> dict:
10586
+ endpoint = (self.endpoint or "").strip()
10587
+ if not endpoint:
10588
+ base = (self.base_url or "").strip().rstrip("/")
10589
+ endpoint = f"{base}/v1/messages" if base else "https://api.anthropic.com/v1/messages"
10590
+ # Separate system messages (Anthropic uses a dedicated 'system' parameter)
10591
+ system_parts: list[str] = []
10592
+ messages: list[dict] = []
10593
+ for m in req_messages:
10594
+ role = str(m.get("role", "") or "").strip()
10595
+ content = m.get("content", "") or ""
10596
+ if role == "system":
10597
+ system_parts.append(str(content))
10598
+ else:
10599
+ # Convert OpenAI-style image_url parts to Anthropic image format
10600
+ if isinstance(content, list):
10601
+ converted: list[dict] = []
10602
+ for part in content:
10603
+ if not isinstance(part, dict):
10604
+ continue
10605
+ if part.get("type") == "image_url":
10606
+ url_data = part.get("image_url", {})
10607
+ url_str = str(url_data.get("url", "") if isinstance(url_data, dict) else url_data or "")
10608
+ dm = re.match(r"^data:([^;]+);base64,(.+)$", url_str, re.DOTALL)
10609
+ if dm:
10610
+ converted.append({
10611
+ "type": "image",
10612
+ "source": {"type": "base64", "media_type": dm.group(1), "data": dm.group(2)},
10613
+ })
10614
+ else:
10615
+ converted.append({"type": "image", "source": {"type": "url", "url": url_str}})
10616
+ else:
10617
+ converted.append(part)
10618
+ content = converted
10619
+ messages.append({"role": role, "content": content})
10620
+ payload: dict = {
10621
+ "model": self.model,
10622
+ "max_tokens": max_tokens,
10623
+ "temperature": temperature,
10624
+ "messages": messages,
10625
+ }
10626
+ if system_parts:
10627
+ payload["system"] = "\n\n".join(system_parts)
10628
+ if tools:
10629
+ payload["tools"] = self._convert_tools_to_anthropic(tools)
10630
+ headers = {
10631
+ "x-api-key": self.api_key,
10632
+ "anthropic-version": "2023-06-01",
10633
+ "content-type": "application/json",
10634
+ }
10635
+ raw = self._post_json_url(endpoint, payload, headers=headers)
10636
+ content, tool_calls, thinking_content = self._extract_anthropic_message(raw)
10637
+ return {"content": content, "thinking": thinking_content, "tool_calls": tool_calls, "raw": raw}
10638
+
10639
+ def _extract_anthropic_message(self, raw: dict) -> tuple[str, list[dict], str]:
10640
+ """Parse Anthropic Messages API response into (content, tool_calls, thinking)."""
10641
+ content_blocks = raw.get("content", [])
10642
+ if not isinstance(content_blocks, list):
10643
+ # Fallback: try OpenAI format
10644
+ if isinstance(raw.get("choices"), list):
10645
+ return self._extract_openai_message(raw)
10646
+ return str(content_blocks or ""), [], ""
10647
+ text_parts: list[str] = []
10648
+ thinking_parts: list[str] = []
10649
+ tool_calls: list[dict] = []
10650
+ for block in content_blocks:
10651
+ if not isinstance(block, dict):
10652
+ continue
10653
+ btype = str(block.get("type", "") or "").strip()
10654
+ if btype == "text":
10655
+ text_parts.append(str(block.get("text", "") or ""))
10656
+ elif btype == "thinking":
10657
+ thinking_parts.append(str(block.get("thinking", "") or ""))
10658
+ elif btype == "tool_use":
10659
+ tool_calls.append({
10660
+ "id": str(block.get("id") or make_id("tool")),
10661
+ "type": "function",
10662
+ "function": {
10663
+ "name": str(block.get("name", "") or ""),
10664
+ "arguments": json_dumps(block.get("input", {})),
10665
+ },
10666
+ })
10667
+ return "\n".join(text_parts), tool_calls, "\n".join(thinking_parts)
10668
+
10669
+ def _convert_tools_to_anthropic(self, openai_tools: list[dict]) -> list[dict]:
10670
+ """Convert OpenAI-format tool definitions to Anthropic format."""
10671
+ out: list[dict] = []
10672
+ for t in openai_tools:
10673
+ if not isinstance(t, dict):
10674
+ continue
10675
+ fn = t.get("function", {})
10676
+ if not isinstance(fn, dict):
10677
+ continue
10678
+ out.append({
10679
+ "name": str(fn.get("name", "") or ""),
10680
+ "description": str(fn.get("description", "") or ""),
10681
+ "input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
10682
+ })
10683
+ return out
10684
+
9279
10685
  def _chat_ollama_stream_native(
9280
10686
  self,
9281
10687
  req_messages: list[dict],
@@ -9401,6 +10807,10 @@ class OllamaClient:
9401
10807
  return self._chat_openai_compat(
9402
10808
  req_messages, tools=tools, max_tokens=max_tokens, temperature=temperature, think=False
9403
10809
  )
10810
+ if provider == "anthropic":
10811
+ return self._chat_anthropic(
10812
+ req_messages, tools=tools, max_tokens=max_tokens, temperature=temperature, think=False
10813
+ )
9404
10814
  if provider == "custom_http":
9405
10815
  return self._chat_custom_http(
9406
10816
  req_messages, tools=tools, max_tokens=max_tokens, temperature=temperature, think=False
@@ -10484,6 +11894,11 @@ class SessionState:
10484
11894
  if provider == "custom_http":
10485
11895
  endpoint = str(profile.get("endpoint", "") or "").strip()
10486
11896
  return bool(endpoint)
11897
+ if provider == "anthropic":
11898
+ api_key = str(profile.get("api_key", "") or "").strip()
11899
+ endpoint = str(profile.get("endpoint", "") or "").strip()
11900
+ base = str(profile.get("base_url", "") or "").strip()
11901
+ return bool(api_key and (endpoint or base))
10487
11902
  return False
10488
11903
 
10489
11904
  def _option_is_runnable(self, option: dict) -> bool:
@@ -14205,7 +15620,7 @@ class SessionState:
14205
15620
  normalized = self._normalize_tool_path_text(path_text)
14206
15621
  if normalized == ".__skills__" or normalized.startswith(".__skills__/"):
14207
15622
  rel = normalized[len(".__skills__") :].lstrip("/")
14208
- return safe_path(rel or ".", self.skills.skills_root)
15623
+ return self.skills.resolve_virtual_skill_path(rel or ".")
14209
15624
  if normalized == ".__js_lib__" or normalized.startswith(".__js_lib__/"):
14210
15625
  rel = normalized[len(".__js_lib__") :].lstrip("/")
14211
15626
  return safe_path(rel or ".", self.js_lib_root)
@@ -14224,10 +15639,9 @@ class SessionState:
14224
15639
  except Exception:
14225
15640
  pass
14226
15641
  try:
14227
- skills_root = self.skills.skills_root.resolve()
14228
- if target.is_relative_to(skills_root):
14229
- rel = target.relative_to(skills_root).as_posix()
14230
- return f"{SKILLS_VIRTUAL_PREFIX}/{rel}".replace("//", "/")
15642
+ virtual_skill_path = self.skills.public_virtual_skill_path_for_abs(target)
15643
+ if virtual_skill_path:
15644
+ return virtual_skill_path
14231
15645
  except Exception:
14232
15646
  pass
14233
15647
  return str(target)
@@ -16618,7 +18032,7 @@ class SessionState:
16618
18032
  js_lib_root = str(self.js_lib_root.resolve())
16619
18033
  except Exception:
16620
18034
  js_lib_root = str(self.js_lib_root)
16621
- mappings = [
18035
+ mappings = self.skills.shell_virtual_mappings() + [
16622
18036
  ("/workspace", workspace_root),
16623
18037
  (SKILLS_VIRTUAL_PREFIX, skills_root),
16624
18038
  ("/js_lib", js_lib_root),
@@ -19132,6 +20546,7 @@ class SessionState:
19132
20546
  clean_todos.append({
19133
20547
  "id": trim(str(pt.get("id", "") or ""), 20),
19134
20548
  "content": trim(str(pt.get("content", "") or ""), 400),
20549
+ "full_content": trim(str(pt.get("full_content", "") or ""), 1500),
19135
20550
  "status": str(pt.get("status", "pending") or "pending") if str(pt.get("status", "pending") or "pending") in ("pending", "in_progress", "completed") else "pending",
19136
20551
  "category": trim(str(pt.get("category", "") or ""), 40),
19137
20552
  "plan_step_index": int(pt.get("plan_step_index", -1)) if pt.get("plan_step_index") is not None else -1,
@@ -20226,9 +21641,30 @@ class SessionState:
20226
21641
  pass
20227
21642
  # Immediately sync todos so UI reflects plan step advancement
20228
21643
  self._sync_todos_from_blackboard(reason=f"plan-step-advanced:{cursor + 1}", board=bb)
21644
+ # Inject hint for the next step (works in both single and multi-agent mode)
20229
21645
  if next_step:
20230
21646
  try:
20231
- pass # Skills are loaded on-demand by the model via load_skill
21647
+ _ns_idx = int(next_step.get("plan_step_index", 0) or 0) + 1
21648
+ _ns_total = int(bb.get("plan_step_total", 0) or 0)
21649
+ _ns_text = trim(str(next_step.get("content", "") or ""), 200)
21650
+ _ns_id = str(next_step.get("id", "") or "")
21651
+ _ns_label = f"Step {_ns_idx}" + (f"/{_ns_total}" if _ns_total else "")
21652
+ _hint = (
21653
+ f"[plan-step-advance] Previous step completed. Now at {_ns_label}: {_ns_text}\n"
21654
+ f"Read updated plan: read_file {PLAN_FILE_RELATIVE_PATH}\n"
21655
+ f"Call TodoWrite to set subtasks for THIS step ONLY.\n"
21656
+ f"Each subtask MUST include parent_step_id='{_ns_id}'. "
21657
+ f"Create 3-5 items, one marked in_progress, others pending.\n"
21658
+ f"Do NOT create subtasks for other plan steps."
21659
+ )
21660
+ self.messages.append({"role": "system", "content": _hint, "ts": now_ts()})
21661
+ # Also inject into active agent context for multi-agent mode
21662
+ if self._is_multi_agent_mode():
21663
+ active_role = str(bb.get("active_agent", "") or actor)
21664
+ if active_role:
21665
+ self._append_agent_context_message(active_role, {
21666
+ "role": "system", "content": _hint, "ts": now_ts(), "agent_role": active_role,
21667
+ }, mirror_to_global=False)
20232
21668
  except Exception:
20233
21669
  pass
20234
21670
  return True
@@ -20249,11 +21685,30 @@ class SessionState:
20249
21685
  worker_produced_output = self._worker_step_has_evidence(worker_step)
20250
21686
  # 3. All subtasks for this step are completed
20251
21687
  subtasks_all_done = self._step_subtasks_all_completed(current)
20252
- # Advance only when evidence confirms step completion:
21688
+ # 4. File-evidence fallback: when worker doesn't call TodoWrite, use phase heuristics
21689
+ step_content = str(current.get("full_content", "") or current.get("content", "") or "").lower()
21690
+ phase = self._plan_step_phase_hint(step_content)
21691
+ results = worker_step.get("tool_results", []) or []
21692
+ wrote_files_count = sum(
21693
+ 1 for r in results
21694
+ if isinstance(r, dict) and r.get("ok", False)
21695
+ and str(r.get("name", "")) in ("write_file", "edit_file")
21696
+ )
21697
+ ran_bash_ok = any(
21698
+ isinstance(r, dict) and r.get("ok", False) and str(r.get("name", "")) == "bash"
21699
+ for r in results
21700
+ )
21701
+ file_evidence_strong = (
21702
+ phase in ("implement", "design") and wrote_files_count >= 2
21703
+ ) or (
21704
+ phase in ("test", "review") and ran_bash_ok
21705
+ )
21706
+ # Advance when:
20253
21707
  # - Manager requested AND worker produced output, OR
20254
- # - All subtasks completed AND worker produced output
21708
+ # - All subtasks completed AND worker produced output, OR
21709
+ # - Strong file evidence (fallback when worker forgets TodoWrite)
20255
21710
  has_strong_evidence = worker_produced_output and (
20256
- manager_requested or subtasks_all_done
21711
+ manager_requested or subtasks_all_done or file_evidence_strong
20257
21712
  )
20258
21713
  if has_strong_evidence:
20259
21714
  evidence = self._collect_step_evidence(current, worker_step)
@@ -20275,7 +21730,8 @@ class SessionState:
20275
21730
  )
20276
21731
 
20277
21732
  def _step_subtasks_all_completed(self, plan_step: dict) -> bool:
20278
- """Check if all worker subtasks linked to this plan step are completed."""
21733
+ """Check if all worker subtasks linked to this plan step are completed.
21734
+ Filters out cross-step subtasks (e.g., 2.1 under step 1) to prevent blocking."""
20279
21735
  step_id = str(plan_step.get("id", "") or "")
20280
21736
  if not step_id:
20281
21737
  return False
@@ -20288,6 +21744,23 @@ class SessionState:
20288
21744
  ]
20289
21745
  if not worker_items:
20290
21746
  return False
21747
+ # Extract major step number from plan step content (e.g., "1. Project init" → "1")
21748
+ import re
21749
+ step_content = str(plan_step.get("full_content", "") or plan_step.get("content", "") or "")
21750
+ _m = re.match(r"^(\d+)\.", step_content.strip())
21751
+ active_major = _m.group(1) if _m else ""
21752
+ # Filter out cross-step subtasks (N.M where N != active major)
21753
+ if active_major:
21754
+ _cross_re = re.compile(r"^(\d+)\.\d+\s")
21755
+ relevant = []
21756
+ for r in worker_items:
21757
+ rc = str(r.get("content", "") or "").strip()
21758
+ cm = _cross_re.match(rc)
21759
+ if cm and cm.group(1) != active_major:
21760
+ continue # Skip cross-step items — don't let them block advancement
21761
+ relevant.append(r)
21762
+ if relevant:
21763
+ worker_items = relevant
20291
21764
  return all(str(r.get("status", "")).lower() == "completed" for r in worker_items)
20292
21765
 
20293
21766
  def _collect_step_evidence(self, plan_step: dict, worker_step: dict) -> str:
@@ -20327,9 +21800,7 @@ class SessionState:
20327
21800
  self._sync_todos_from_blackboard(reason="single-agent-round")
20328
21801
  return
20329
21802
  # Heuristic: check if tool results indicate step completion
20330
- # - write_file/edit_file calls suggest implementation progress
20331
- # - successful bash calls suggest testing/verification
20332
- step_content = str(current.get("content", "") or "").lower()
21803
+ step_content = str(current.get("full_content", "") or current.get("content", "") or "").lower()
20333
21804
  phase = self._plan_step_phase_hint(step_content)
20334
21805
  wrote_files = any(
20335
21806
  str(r.get("name", "")) in ("write_file", "edit_file") and r.get("ok", False)
@@ -20339,19 +21810,23 @@ class SessionState:
20339
21810
  str(r.get("name", "")) == "bash" and r.get("ok", False)
20340
21811
  for r in tool_results
20341
21812
  )
20342
- # Auto-advance conditions based on phase:
21813
+ # Auto-advance conditions:
20343
21814
  should_advance = False
20344
- if phase in ("research", "design") and wrote_files:
20345
- # Research/design phase completed when files are produced
20346
- should_advance = True
20347
- elif phase == "implement" and wrote_files and ran_bash_ok:
20348
- # Implementation completed when files written and bash succeeds
20349
- should_advance = True
20350
- elif phase in ("test", "review") and ran_bash_ok and not any(
20351
- not r.get("ok", False) for r in tool_results if str(r.get("name", "")) == "bash"
20352
- ):
20353
- # Test/review completed when all bash calls succeed
21815
+ # Priority 1: Check if worker subtasks are all completed (most reliable signal)
21816
+ subtasks_done = self._step_subtasks_all_completed(current)
21817
+ if subtasks_done and (wrote_files or ran_bash_ok):
20354
21818
  should_advance = True
21819
+ # Priority 2: Phase-based heuristics (relaxed — wrote_files OR bash, not both)
21820
+ if not should_advance:
21821
+ if phase in ("research", "design") and wrote_files:
21822
+ should_advance = True
21823
+ elif phase == "implement" and wrote_files:
21824
+ # Relaxed: implement step done when files are written (don't require bash)
21825
+ should_advance = True
21826
+ elif phase in ("test", "review") and ran_bash_ok and not any(
21827
+ not r.get("ok", False) for r in tool_results if str(r.get("name", "")) == "bash"
21828
+ ):
21829
+ should_advance = True
20355
21830
  # Also check if the agent explicitly mentioned step completion
20356
21831
  if not should_advance:
20357
21832
  # Check last assistant message for step completion signals
@@ -20379,12 +21854,15 @@ class SessionState:
20379
21854
  _step_idx = int(_new_step.get("plan_step_index", 0) or 0) + 1
20380
21855
  _total = int(_bb_after.get("plan_step_total", 0) or 0)
20381
21856
  _step_text = trim(str(_new_step.get("content", "") or ""), 200)
21857
+ _step_id = str(_new_step.get("id", "") or "")
20382
21858
  _step_label = f"Step {_step_idx}" + (f"/{_total}" if _total else "")
20383
21859
  _hint = (
20384
21860
  f"[plan-step-advance] Previous step completed. Now at {_step_label}: {_step_text}\n"
20385
21861
  f"Read updated plan: read_file {PLAN_FILE_RELATIVE_PATH}\n"
20386
- "Call TodoWrite to set your task breakdown for this step "
20387
- "(3-5 subtask items, one marked in_progress) before proceeding."
21862
+ f"Call TodoWrite to set subtasks for THIS step ONLY.\n"
21863
+ f"Each subtask MUST include parent_step_id='{_step_id}'. "
21864
+ f"Create 3-5 items, one marked in_progress, others pending.\n"
21865
+ f"Do NOT create subtasks for other plan steps."
20388
21866
  )
20389
21867
  self.messages.append({"role": "system", "content": _hint, "ts": now_ts()})
20390
21868
  except Exception:
@@ -20445,6 +21923,58 @@ class SessionState:
20445
21923
  active_system = [r for r in system_rows if r.get("status") != "completed"]
20446
21924
  completed_system = [r for r in system_rows if r.get("status") == "completed"]
20447
21925
  trimmed_system = active_system + completed_system[-3:]
21926
+ # ── Subtask conflict guard ──
21927
+ # Remove worker subtasks whose content duplicates a plan step to prevent
21928
+ # the UI from showing the same task twice (once as plan step, once as subtask).
21929
+ if trimmed_system:
21930
+ import re as _re_dedup
21931
+ plan_content_set = set()
21932
+ for sr in trimmed_system:
21933
+ pc = str(sr.get("content", "") or "").strip().lower()
21934
+ if pc:
21935
+ plan_content_set.add(pc)
21936
+ # Also add first line only (sub-step headers match full step content)
21937
+ first_line = pc.split("\n")[0].strip()
21938
+ if first_line:
21939
+ plan_content_set.add(first_line)
21940
+ # Build stripped-prefix set for fuzzy matching ("步骤 1:XXX" → "XXX")
21941
+ _num_prefix_re = _re_dedup.compile(r"^(?:步骤\s*\d+[::]\s*|\d+\.\s*|step\s*\d+[::]\s*)", _re_dedup.IGNORECASE)
21942
+ plan_stripped_set = set()
21943
+ for sr in trimmed_system:
21944
+ pc = str(sr.get("content", "") or "").strip().lower()
21945
+ stripped = _num_prefix_re.sub("", pc).strip()
21946
+ if stripped and len(stripped) > 4:
21947
+ plan_stripped_set.add(stripped)
21948
+ # Find current active step major number for cross-step detection
21949
+ _active_major = ""
21950
+ for sr in trimmed_system:
21951
+ if sr.get("status") == "in_progress":
21952
+ mc = str(sr.get("content", "") or "")
21953
+ _am = _re_dedup.match(r"^(\d+)\.", mc)
21954
+ if _am:
21955
+ _active_major = _am.group(1)
21956
+ break
21957
+ _cross_step_re = _re_dedup.compile(r"^(\d+)\.\d+\s")
21958
+ deduped_worker = []
21959
+ for wr in worker_rows:
21960
+ wc = str(wr.get("content", "") or "").strip().lower()
21961
+ if not wc:
21962
+ deduped_worker.append(wr)
21963
+ continue
21964
+ # Layer 1: Exact match
21965
+ if wc in plan_content_set:
21966
+ continue
21967
+ # Layer 2: Stripped prefix match
21968
+ wc_stripped = _num_prefix_re.sub("", wc).strip()
21969
+ if wc_stripped and wc_stripped in plan_stripped_set:
21970
+ continue
21971
+ # Layer 3: Cross-step detection — reject N.M subtasks where N != active major
21972
+ if _active_major:
21973
+ cm = _cross_step_re.match(wc)
21974
+ if cm and cm.group(1) != _active_major:
21975
+ continue
21976
+ deduped_worker.append(wr)
21977
+ worker_rows = deduped_worker
20448
21978
  remaining_cap = max(0, 40 - len(trimmed_system) - len(worker_rows))
20449
21979
  merged = list(trimmed_system) + worker_rows + non_system_rows[:remaining_cap]
20450
21980
  try:
@@ -21240,6 +22770,13 @@ class SessionState:
21240
22770
  _prev_level_val = int(getattr(self, '_prev_applied_task_level', 0) or 0)
21241
22771
  if int(getattr(self, 'user_task_level_override', 0) or 0) > 0:
21242
22772
  level = int(self.user_task_level_override)
22773
+ # Floor protection: if plan was approved, do not allow downgrade below floor
22774
+ _level_floor = int(getattr(self, 'runtime_task_level_floor', 0) or 0)
22775
+ if _level_floor > 0 and int(level) < _level_floor:
22776
+ level = _level_floor
22777
+ _complexity_floor = str(getattr(self, 'runtime_complexity_floor', '') or '').strip()
22778
+ if _complexity_floor == "complex" and complexity == "simple":
22779
+ complexity = "complex"
21243
22780
  self.runtime_task_level = int(level)
21244
22781
  self._prev_applied_task_level = int(level)
21245
22782
  self.runtime_execution_mode = mode
@@ -21609,7 +23146,7 @@ class SessionState:
21609
23146
  idx = int(t.get("plan_step_index", 0) or 0) + 1
21610
23147
  status = t.get("status", "pending")
21611
23148
  mark = "✅" if status == "completed" else "👉" if status == "in_progress" else "⬜"
21612
- phase_hint = self._plan_step_phase_hint(str(t.get("content", "") or ""))
23149
+ phase_hint = self._plan_step_phase_hint(str(t.get("full_content", "") or t.get("content", "") or ""))
21613
23150
  phase_tag = f" [{phase_hint}]" if phase_hint else ""
21614
23151
  lines.append(f" {mark} Step {idx}: {trim(str(t.get('content', '') or ''), 160)}{phase_tag}")
21615
23152
  lines.append("Execute steps IN ORDER. Do NOT skip ahead. Mark current step done before advancing. ")
@@ -21672,7 +23209,7 @@ class SessionState:
21672
23209
  bb = self._ensure_blackboard()
21673
23210
  for t in bb.get("project_todos", []):
21674
23211
  if t.get("category") == "plan_step" and t.get("status") == "in_progress":
21675
- phase = self._plan_step_phase_hint(str(t.get("content", "") or ""))
23212
+ phase = self._plan_step_phase_hint(str(t.get("full_content", "") or t.get("content", "") or ""))
21676
23213
  if phase:
21677
23214
  return phase
21678
23215
  # Fallback: infer from blackboard state
@@ -23447,7 +24984,7 @@ class SessionState:
23447
24984
  if isinstance(plan_todos, list):
23448
24985
  for pt in plan_todos:
23449
24986
  if isinstance(pt, dict) and pt.get("category") == "plan_step" and pt.get("status") == "in_progress":
23450
- step_text = trim(str(pt.get("content", "") or ""), 300)
24987
+ step_text = trim(str(pt.get("full_content", "") or pt.get("content", "") or ""), 600)
23451
24988
  step_idx = int(pt.get("plan_step_index", 0) or 0) + 1
23452
24989
  current_plan_step_note = (
23453
24990
  f"CURRENT PLAN STEP (#{step_idx}): {step_text}\n"
@@ -23465,10 +25002,14 @@ class SessionState:
23465
25002
  _active_step_id = str(_pt.get("id", "") or "")
23466
25003
  break
23467
25004
  todo_update_note = (
23468
- f"TODO UPDATE: At the START of your work, call TodoWrite to set subtasks for this step.\n"
23469
- f"Each subtask MUST include parent_step_id='{_active_step_id}' to link it to this plan step.\n"
23470
- f"Format: 3-5 items, one marked in_progress, others pending.\n"
23471
- f"Mark each subtask completed as you finish it. When ALL subtasks are done, the step auto-advances.\n"
25005
+ f"TODO UPDATE: Call TodoWrite at the START to set subtasks for THIS step ONLY.\n"
25006
+ f"Each subtask MUST include parent_step_id='{_active_step_id}'.\n"
25007
+ f"CRITICAL SCOPE RULE:\n"
25008
+ f"- Create 3-5 subtasks that break down ONLY the current step's work.\n"
25009
+ f"- Do NOT create subtasks for other plan steps (do NOT list step 2, 3, 4 etc.).\n"
25010
+ f"- Do NOT duplicate the plan step titles as subtasks.\n"
25011
+ f"- Each subtask should be a concrete action within THIS step.\n"
25012
+ f"Mark each subtask completed as you finish it. When ALL are done, the step auto-advances.\n"
23472
25013
  )
23473
25014
  # Build step_files context note for cross-agent file visibility
23474
25015
  step_files_note = ""
@@ -26907,8 +28448,15 @@ class SessionState:
26907
28448
  self.runtime_plan_approved = True
26908
28449
  return
26909
28450
 
26910
- # Phase 3: Emit 方案到前端
28451
+ # Synthesis Step 1: 立即写 plan.md(与 synthesis 思维连续,避免信息丢失)
26911
28452
  self.runtime_plan_proposal = proposal
28453
+ try:
28454
+ self._write_plan_file(self._format_plan_file_preselection(proposal))
28455
+ except Exception:
28456
+ pass
28457
+ self._emit("status", {"summary": "plan-mode: plan.md written"})
28458
+
28459
+ # Synthesis Step 2: 更新 blackboard + 生成精简 bubble
26912
28460
  bb = self._ensure_blackboard()
26913
28461
  if not isinstance(bb.get("plan"), dict):
26914
28462
  bb["plan"] = {"phase": "awaiting_choice", "findings": []}
@@ -26916,13 +28464,7 @@ class SessionState:
26916
28464
  bb["plan"]["proposal"] = proposal
26917
28465
  self.blackboard = bb
26918
28466
 
26919
- # Write full plan to file for model consumption
26920
- try:
26921
- self._write_plan_file(self._format_plan_file_preselection(proposal))
26922
- except Exception:
26923
- pass
26924
-
26925
- # Condensed bubble for UI (under PLAN_BUBBLE_MAX_CHARS)
28467
+ # Phase 3: Emit bubble 到前端(纯输出,不做额外思考)
26926
28468
  bubble_text = self._format_plan_bubble_preselection(proposal)
26927
28469
  self.messages.append({
26928
28470
  "role": "assistant",
@@ -27445,7 +28987,38 @@ class SessionState:
27445
28987
  f"- When a loaded skill defines a specific workflow, follow that workflow's actual tools and scripts.\n"
27446
28988
  f"- For complex tasks, produce 8-15 detailed steps, not 3-5 vague ones\n"
27447
28989
  f"- Each step should be completable in 1-3 tool calls\n"
27448
- f"- Group related substeps under numbered headings (e.g., '2.1 Read report 1', '2.2 Read report 2')\n"
28990
+ f"\nSTEP STRUCTURE MAJOR STEPS WITH SUB-STEPS:\n"
28991
+ f"Organize steps into MAJOR numbered groups. Each major step has:\n"
28992
+ f" 1) A summary title line: \"N. Summary Title\" (e.g., \"1. Project Initialization\")\n"
28993
+ f" 2) Sub-steps: \"N.1 Sub-step title\\nConcrete details\\nN.2 Next sub-step\\nDetails\"\n"
28994
+ f"\nThe steps array should contain one string per MAJOR step. "
28995
+ f"Each string starts with the summary title, followed by sub-steps.\n"
28996
+ f"\nExample steps array with 3 major steps:\n"
28997
+ f"[\n"
28998
+ f" \"1. Project Initialization and Build\\n"
28999
+ f"1.1 Initialize project structure\\n"
29000
+ f"Create directories: src/python/, src/fortran/, tests/, docs/\\n"
29001
+ f"1.2 Configure build system\\n"
29002
+ f"Create CMakeLists.txt with Fortran compiler config\\n"
29003
+ f"Run: cmake -B build -S . && cmake --build build\",\n"
29004
+ f" \"2. Core Module Implementation\\n"
29005
+ f"2.1 Implement data model\\n"
29006
+ f"Create src/models/data.py with schema definitions\\n"
29007
+ f"2.2 Implement business logic\\n"
29008
+ f"Create src/services/processor.py\",\n"
29009
+ f" \"3. Testing and Documentation\\n"
29010
+ f"3.1 Unit tests\\n"
29011
+ f"Create tests/test_models.py\\n"
29012
+ f"Run: pytest tests/\\n"
29013
+ f"3.2 Write documentation\\n"
29014
+ f"Create docs/README.md\"\n"
29015
+ f"]\n"
29016
+ f"\nRules:\n"
29017
+ f"- Each major step = one array element with summary title + 2-5 sub-steps\n"
29018
+ f"- Summary title captures the THEME of that group (not just repeat the first sub-step)\n"
29019
+ f"- Sub-steps include specific file paths, commands, or expected outputs\n"
29020
+ f"- Aim for 5-8 major steps for complex tasks, 3-5 for simpler ones\n"
29021
+ f"\n"
27449
29022
  f"Make options meaningfully different (e.g. different approaches, scope levels, or trade-offs).\n"
27450
29023
  "\nVERIFICATION & TESTING:\n"
27451
29024
  "Judge from the task content and research findings whether the task involves writing, "
@@ -27627,8 +29200,22 @@ class SessionState:
27627
29200
  steps = opt.get("steps", [])
27628
29201
  if isinstance(steps, list) and steps:
27629
29202
  lines.append("\n### Steps")
29203
+ import re as _re_plan
29204
+ _mid_re = _re_plan.compile(r"(?<=\S)\s+(\d+\.\d+\s)")
27630
29205
  for i, s in enumerate(steps):
27631
- lines.append(f"{i + 1}. {s}")
29206
+ step_str = str(s or "").strip()
29207
+ # Normalize: split mid-string N.N sub-step numbers onto own lines
29208
+ step_str = _mid_re.sub(r"\n\1", step_str)
29209
+ if "\n" in step_str:
29210
+ # Multi-line step: first line as header, rest as nested list
29211
+ step_lines = step_str.split("\n")
29212
+ lines.append(f"{i + 1}. {step_lines[0]}")
29213
+ for sub in step_lines[1:]:
29214
+ stripped = sub.strip()
29215
+ if stripped:
29216
+ lines.append(f" - {stripped}")
29217
+ else:
29218
+ lines.append(f"{i + 1}. {step_str}")
27632
29219
  pros = str(opt.get("pros", "") or "").strip()
27633
29220
  if pros:
27634
29221
  lines.append(f"\n**Pros:** {pros}")
@@ -27668,14 +29255,22 @@ class SessionState:
27668
29255
  if summary:
27669
29256
  lines.append(f"## Summary\n{summary}\n")
27670
29257
  lines.append("## Steps\n")
29258
+ import re as _re_exec
29259
+ _mid_re_exec = _re_exec.compile(r"(?<=\S)\s+(\d+\.\d+\s)")
27671
29260
  for t in plan_todos:
27672
29261
  idx = int(t.get("plan_step_index", 0) or 0) + 1
27673
- text = str(t.get("content", "") or "").strip()
29262
+ full = str(t.get("full_content", "") or t.get("content", "")).strip()
29263
+ # Normalize: split concatenated N.N sub-steps onto own lines
29264
+ full = _mid_re_exec.sub(r"\n\1", full)
29265
+ header = full.split("\n")[0] if "\n" in full else full
29266
+ sub_lines = [ln for ln in full.split("\n")[1:] if ln.strip()] if "\n" in full else []
27674
29267
  status = str(t.get("status", "pending") or "pending")
27675
29268
  if status == "completed":
27676
29269
  actor = str(t.get("completed_by", "") or "")
27677
29270
  evidence = str(t.get("evidence", "") or "")
27678
- lines.append(f"- [x] Step {idx}: {text}")
29271
+ lines.append(f"- [x] Step {idx}: {header}")
29272
+ for sub in sub_lines:
29273
+ lines.append(f" - {sub.strip()}")
27679
29274
  meta_parts = []
27680
29275
  if actor:
27681
29276
  meta_parts.append(f"Completed by: {actor}")
@@ -27684,9 +29279,13 @@ class SessionState:
27684
29279
  if meta_parts:
27685
29280
  lines.append(f" > {' | '.join(meta_parts)}")
27686
29281
  elif status == "in_progress":
27687
- lines.append(f"- [>] Step {idx}: {text} <-- CURRENT")
29282
+ lines.append(f"- [>] Step {idx}: {header} <-- CURRENT")
29283
+ for sub in sub_lines:
29284
+ lines.append(f" - {sub.strip()}")
27688
29285
  else:
27689
- lines.append(f"- [ ] Step {idx}: {text}")
29286
+ lines.append(f"- [ ] Step {idx}: {header}")
29287
+ for sub in sub_lines:
29288
+ lines.append(f" - {sub.strip()}")
27690
29289
  return "\n".join(lines) + "\n"
27691
29290
 
27692
29291
  def _update_plan_file_step_status(self) -> bool:
@@ -27739,14 +29338,121 @@ class SessionState:
27739
29338
 
27740
29339
  def _plan_file_read_instruction(self) -> str:
27741
29340
  """Short instruction for models: read the plan file instead of embedding full plan text."""
29341
+ # Find active step id for parent_step_id linkage
29342
+ bb = self._ensure_blackboard()
29343
+ active_step_id = ""
29344
+ active_step_idx = 0
29345
+ for t in bb.get("project_todos", []):
29346
+ if isinstance(t, dict) and t.get("category") == "plan_step" and t.get("status") == "in_progress":
29347
+ active_step_id = str(t.get("id", "") or "")
29348
+ active_step_idx = int(t.get("plan_step_index", 0) or 0) + 1
29349
+ break
29350
+ todo_note = ""
29351
+ if active_step_id:
29352
+ todo_note = (
29353
+ f"\nTODO UPDATE: Call TodoWrite at the START to set subtasks for the current step (Step {active_step_idx}) ONLY.\n"
29354
+ f"Each subtask MUST include parent_step_id='{active_step_id}'.\n"
29355
+ f"Create 3-5 subtasks that break down ONLY the current step's work.\n"
29356
+ f"Do NOT create subtasks for other plan steps. Mark each subtask completed as you finish it.\n"
29357
+ )
27742
29358
  return (
27743
29359
  f"[plan-file] The approved execution plan is at `{PLAN_FILE_RELATIVE_PATH}`.\n"
27744
29360
  f"Use: read_file {PLAN_FILE_RELATIVE_PATH} to review full steps and live status.\n"
27745
29361
  "The plan file is the authoritative source for step ordering and completion status.\n"
27746
29362
  "Execute steps IN ORDER. Do NOT skip ahead. Mark current step done before advancing.\n"
27747
29363
  "If a step references a skill or workflow, call load_skill to load it before proceeding."
29364
+ f"{todo_note}"
27748
29365
  )
27749
29366
 
29367
+ @staticmethod
29368
+ def _group_plan_steps(raw_steps: list) -> list[str]:
29369
+ """Group flat step array by major step number (first digit of N.N).
29370
+
29371
+ All 1.x sub-steps merge into one logical step, all 2.x into another, etc.
29372
+ If a step has the format "N. Summary title" (single digit, no sub-number),
29373
+ it becomes the group header. Otherwise a header is synthesized from the
29374
+ first sub-step.
29375
+
29376
+ Handles LLM output where sub-steps may be separate array elements OR
29377
+ concatenated in one string with spaces/newlines.
29378
+
29379
+ Returns a list where each element is one major step (multi-line string):
29380
+ "1. Summary Title\\n1.1 Sub-step A\\nDetail...\\n1.2 Sub-step B\\n..."
29381
+ """
29382
+ import re
29383
+ if not raw_steps or not isinstance(raw_steps, list):
29384
+ return list(raw_steps or [])
29385
+ # Patterns
29386
+ sub_step_re = re.compile(r"^(\d+)\.(\d+)\s") # "N.M ..."
29387
+ major_step_re = re.compile(r"^(\d+)\.\s") # "N. ..." (summary line)
29388
+ mid_numbered_re = re.compile(r"(?<=\S)\s+(\d+\.\d+\s)")
29389
+ # Phase 0: Normalize — split mid-string N.N onto own lines
29390
+ normalized: list[str] = []
29391
+ for s in raw_steps:
29392
+ text = str(s or "").strip()
29393
+ if not text:
29394
+ continue
29395
+ fixed = mid_numbered_re.sub(r"\n\1", text)
29396
+ for line in fixed.split("\n"):
29397
+ stripped = line.strip()
29398
+ if stripped:
29399
+ normalized.append(stripped)
29400
+ if not normalized:
29401
+ return [str(s) for s in raw_steps if str(s or "").strip()]
29402
+ # Phase 1: Check if any N.N sub-step headers exist
29403
+ has_sub_steps = any(sub_step_re.match(ln) for ln in normalized)
29404
+ if not has_sub_steps:
29405
+ return normalized
29406
+ # Phase 2: Group by major number (first digit of N.N)
29407
+ from collections import OrderedDict
29408
+ major_groups: OrderedDict[str, list[str]] = OrderedDict()
29409
+ major_headers: dict[str, str] = {} # major_num → "N. Summary" if provided
29410
+ orphan_lines: list[str] = []
29411
+ current_major: str = ""
29412
+ for line in normalized:
29413
+ m_sub = sub_step_re.match(line)
29414
+ m_major = major_step_re.match(line)
29415
+ if m_sub:
29416
+ major_num = m_sub.group(1)
29417
+ current_major = major_num
29418
+ if major_num not in major_groups:
29419
+ major_groups[major_num] = []
29420
+ major_groups[major_num].append(line)
29421
+ elif m_major:
29422
+ # "N. Summary title" line → store as header for this major group
29423
+ major_num = m_major.group(1)
29424
+ current_major = major_num
29425
+ major_headers[major_num] = line
29426
+ if major_num not in major_groups:
29427
+ major_groups[major_num] = []
29428
+ else:
29429
+ # Detail line → attach to current major group
29430
+ if current_major and current_major in major_groups:
29431
+ major_groups[current_major].append(line)
29432
+ else:
29433
+ orphan_lines.append(line)
29434
+ # Phase 3: Build output — each major group becomes one step
29435
+ result: list[str] = []
29436
+ if orphan_lines:
29437
+ result.append("\n".join(orphan_lines))
29438
+ for major_num, lines in major_groups.items():
29439
+ if not lines:
29440
+ continue
29441
+ header = major_headers.get(major_num, "")
29442
+ if not header:
29443
+ # Synthesize header from first sub-step: "N. <first sub-step title>"
29444
+ first_sub = lines[0]
29445
+ m = sub_step_re.match(first_sub)
29446
+ if m:
29447
+ # Extract title part after "N.M "
29448
+ title_part = first_sub[m.end():].split("\n")[0].strip()
29449
+ # Remove detail after title (heuristic: title is before first Chinese/action verb)
29450
+ header = f"{major_num}. {title_part}" if title_part else f"{major_num}. Step {major_num}"
29451
+ else:
29452
+ header = f"{major_num}. Step {major_num}"
29453
+ result.append(header + "\n" + "\n".join(lines))
29454
+ return result
29455
+
27750
29456
  # ── (legacy) _format_plan_proposal_markdown ──────────────────────
27751
29457
 
27752
29458
  def _format_plan_proposal_markdown(self, proposal: dict) -> str:
@@ -27898,16 +29604,20 @@ class SessionState:
27898
29604
  self.runtime_complexity_floor = str(self.runtime_task_complexity or "complex")
27899
29605
  self.runtime_task_level_floor = int(self.runtime_task_level or 4)
27900
29606
  # Auto-create todos from plan steps → write into bb["project_todos"]
27901
- steps = chosen.get("steps", [])
29607
+ steps = self._group_plan_steps(chosen.get("steps", []))
27902
29608
  if steps and isinstance(steps, list):
27903
29609
  plan_todos = []
27904
29610
  for i, step in enumerate(steps):
27905
- step_text = trim(str(step or "").strip(), 600)
29611
+ step_text = trim(str(step or "").strip(), 1500)
27906
29612
  if not step_text:
27907
29613
  continue
29614
+ # Extract header (first line) for todo display; keep full text for plan.md rendering
29615
+ step_lines = step_text.split("\n")
29616
+ step_header = step_lines[0].strip()
27908
29617
  plan_todos.append({
27909
29618
  "id": f"pt:{i:03d}",
27910
- "content": step_text,
29619
+ "content": step_header,
29620
+ "full_content": step_text,
27911
29621
  "status": "in_progress" if i == 0 else "pending",
27912
29622
  "category": "plan_step",
27913
29623
  "plan_step_index": i,
@@ -30228,6 +31938,11 @@ class SessionManager:
30228
31938
  return bool(endpoint or complete_chat_endpoint(base))
30229
31939
  if provider == "custom_http":
30230
31940
  return bool(str(profile.get("endpoint", "") or "").strip())
31941
+ if provider == "anthropic":
31942
+ api_key = str(profile.get("api_key", "") or "").strip()
31943
+ endpoint = str(profile.get("endpoint", "") or "").strip()
31944
+ base = str(profile.get("base_url", "") or "").strip()
31945
+ return bool(api_key and (endpoint or base))
30231
31946
  return False
30232
31947
 
30233
31948
  def _option_is_runnable(self, option: dict) -> bool:
@@ -30604,16 +32319,22 @@ window.MathJax={
30604
32319
  <label id="llmProviderLabel">Provider</label>
30605
32320
  <select id="llmProvider">
30606
32321
  <option value="ollama">Ollama</option>
32322
+ <option value="vllm">vLLM (Local)</option>
32323
+ <option value="lmstudio">LM Studio (Local)</option>
30607
32324
  <option value="openai_compat">OpenAI Compatible</option>
32325
+ <option value="anthropic">Anthropic</option>
32326
+ <option value="glm">GLM</option>
32327
+ <option value="kimi">KIMI (Moonshot)</option>
32328
+ <option value="openrouter">OpenRouter</option>
30608
32329
  <option value="siliconflow">SiliconFlow</option>
30609
32330
  <option value="custom_http">Custom HTTP</option>
30610
32331
  </select>
30611
32332
  </div>
30612
32333
  <div id="llmFieldsContainer"></div>
30613
32334
  </div>
30614
- <div class="llm-modal-footer">
32335
+ <div class="llm-modal-footer" style="flex-direction:column;gap:8px">
32336
+ <label for="configInput" id="llmConfigImport" class="llm-modal-btn-secondary" style="cursor:pointer;margin:0;text-align:center">Import config</label>
30615
32337
  <button id="llmConfigConfirm" type="button" class="llm-modal-btn-primary">Confirm</button>
30616
- <label for="configInput" id="llmConfigImport" class="llm-modal-btn-secondary" style="cursor:pointer;margin:0">Import config</label>
30617
32338
  </div>
30618
32339
  </div>
30619
32340
  </div>
@@ -31146,9 +32867,10 @@ const CODE_KEYWORDS={default:new Set(['if','else','for','while','switch','case',
31146
32867
  S.staticMode=STATIC_UI;
31147
32868
  async function setTaskLevel(level){if(!S.activeId)return;const lvl=parseInt(level,10);try{await api('/api/sessions/'+S.activeId+'/config/task-level',{method:'POST',body:JSON.stringify({level:lvl})});updateLevelBtn(lvl);scheduleSnapshot({forceFull:false,delayMs:80,allowWhenFrozen:true})}catch(err){showError(err.message||String(err))}}
31148
32869
  function updateLevelBtn(level){const btn=E('levelBtn');if(!btn)return;if(!level||level===0){btn.textContent=t('btn_level')+': '+t('level_auto')}else{const labels={1:'L1',2:'L2',3:'L3',4:'L4',5:'L5'};btn.textContent=t('btn_level')+': '+(labels[level]||t('level_auto'))}}
31149
- const LLM_PROVIDER_FIELDS={ollama:[{key:'ollama_url',label:'Ollama URL',type:'url',placeholder:'http://127.0.0.1:11434',hint:'Ollama API endpoint'}],openai_compat:[{key:'openai_url',label:'API Base URL',type:'url',placeholder:'https://api.openai.com/v1',hint:'OpenAI-compatible endpoint'},{key:'openai_key',label:'API Key',type:'password',placeholder:'sk-...',hint:'Your API key'},{key:'openai_model',label:'Model',type:'text',placeholder:'gpt-4o-mini',hint:'e.g. gpt-4o, claude-sonnet-4-20250514'}],siliconflow:[{key:'siliconflow_url',label:'API URL',type:'url',placeholder:'https://api.siliconflow.cn/v1',hint:'SiliconFlow API endpoint'},{key:'siliconflow_key',label:'API Key',type:'password',placeholder:'sk-...',hint:'SiliconFlow API key'},{key:'siliconflow_model',label:'Model',type:'text',placeholder:'Qwen/Qwen3-Next-80B-A3B-Instruct',hint:'Model identifier'}],custom_http:[{key:'custom_url',label:'API Endpoint URL',type:'url',placeholder:'https://your-api.com/v1/chat/completions',hint:'Full API endpoint URL'},{key:'custom_key',label:'API Key',type:'password',placeholder:'sk-...',hint:'API key (optional)'},{key:'custom_model',label:'Model',type:'text',placeholder:'model-name',hint:'Model identifier'},{key:'custom_headers',label:'Custom Headers (JSON)',type:'textarea',placeholder:'{"Authorization":"Bearer token","X-Custom":"value"}',hint:'JSON object of additional HTTP headers'},{key:'custom_payload',label:'Payload Template (JSON)',type:'textarea',placeholder:'{"custom_param":"value","stream":true}',hint:'Extra fields merged into the request body'},{key:'temperature',label:'Temperature',type:'number',placeholder:'0.2',hint:'0.0-2.0, lower=deterministic'},{key:'request_timeout',label:'Request Timeout (seconds)',type:'number',placeholder:'3600',hint:'Max seconds per LLM request'}]};
31150
- function renderLlmFields(provider){const container=E('llmFieldsContainer');if(!container)return;let html='';if(provider==='ollama'){const fields=LLM_PROVIDER_FIELDS.ollama;for(const f of fields){html+='<div class=\"llm-field\"><label>'+esc(f.label)+'</label><input type=\"'+f.type+'\" id=\"llmF_'+f.key+'\" placeholder=\"'+esc(f.placeholder||'')+'\" value=\"\"><div class=\"llm-hint\">'+esc(f.hint||'')+'</div></div>'}html+='<div class=\"llm-field\"><label>'+esc(t('llm_model'))+'</label><div style=\"display:flex;gap:8px;align-items:center\"><select id=\"llmF_ollama_model\" style=\"flex:1\"><option value=\"\">-- '+esc(t('llm_scan_first'))+' --</option></select><button type=\"button\" id=\"ollamaScanBtn\" class=\"llm-modal-btn-secondary\" style=\"flex:none;padding:6px 12px\">'+esc(t('llm_scan'))+'</button></div><div class=\"llm-hint\" id=\"ollamaScanHint\">'+esc(t('llm_scan_hint'))+'</div></div>'}else{const fields=LLM_PROVIDER_FIELDS[provider]||[];for(const f of fields){if(f.type==='textarea'){html+='<div class=\"llm-field\"><label>'+esc(f.label)+'</label><textarea id=\"llmF_'+f.key+'\" placeholder=\"'+esc(f.placeholder||'')+'\" rows=\"3\" style=\"width:100%;padding:8px 10px;border:1px solid var(--line,#d9e1ec);border-radius:8px;font-size:.84rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical;box-sizing:border-box\"></textarea><div class=\"llm-hint\">'+esc(f.hint||'')+'</div></div>'}else{html+='<div class=\"llm-field\"><label>'+esc(f.label)+'</label><input type=\"'+(f.type==='number'?'text':f.type)+'\" id=\"llmF_'+f.key+'\" placeholder=\"'+esc(f.placeholder||'')+'\" value=\"\"><div class=\"llm-hint\">'+esc(f.hint||'')+'</div></div>'}}}html+='<div class=\"llm-field\"><label>'+esc(t('llm_thinking_stream'))+'</label><select id=\"llmF_thinking_stream\"><option value=\"true\">'+esc(t('llm_enabled'))+'</option><option value=\"false\">'+esc(t('llm_disabled'))+'</option></select></div>';container.innerHTML=html;if(provider==='ollama'){const scanBtn=E('ollamaScanBtn');if(scanBtn)scanBtn.onclick=()=>scanOllamaModels()}}
32870
+ const LLM_PROVIDER_FIELDS={ollama:[{key:'ollama_url',label:'Ollama URL',type:'url',placeholder:'http://127.0.0.1:11434',hint:'Ollama API endpoint'}],vllm:[{key:'vllm_url',label:'vLLM URL',type:'url',placeholder:'http://localhost:8000/v1',hint:'vLLM OpenAI-compat endpoint'},{key:'vllm_model',label:'Model',type:'text',placeholder:'(auto-detect)',hint:'Leave empty to auto-detect'},{key:'vllm_key',label:'API Key (optional)',type:'password',placeholder:'',hint:'Usually not required for local'}],lmstudio:[{key:'lmstudio_url',label:'LM Studio URL',type:'url',placeholder:'http://localhost:1234/v1',hint:'LM Studio server endpoint'},{key:'lmstudio_model',label:'Model',type:'text',placeholder:'(auto-detect)',hint:'Leave empty to auto-detect'}],openai_compat:[{key:'openai_url',label:'API Base URL',type:'url',placeholder:'https://api.openai.com/v1',hint:'OpenAI-compatible endpoint'},{key:'openai_key',label:'API Key',type:'password',placeholder:'sk-...',hint:'Your API key'},{key:'openai_model',label:'Model',type:'text',placeholder:'gpt-4o-mini',hint:'e.g. gpt-4o, claude-sonnet-4-20250514'}],anthropic:[{key:'anthropic_url',label:'API URL',type:'url',placeholder:'https://api.anthropic.com',hint:'Anthropic API endpoint'},{key:'anthropic_key',label:'API Key',type:'password',placeholder:'sk-ant-...',hint:'Anthropic API key'},{key:'anthropic_model',label:'Model',type:'text',placeholder:'claude-sonnet-4-20250514',hint:'e.g. claude-sonnet-4-20250514, claude-opus-4-20250514'}],glm:[{key:'glm_url',label:'API URL',type:'url',placeholder:'https://open.bigmodel.cn/api/paas/v4',hint:'GLM API endpoint'},{key:'glm_key',label:'API Key',type:'password',placeholder:'',hint:'GLM API Key'},{key:'glm_model',label:'Model',type:'text',placeholder:'glm-4-flash',hint:'e.g. glm-4-flash, glm-4-plus, glm-4v'}],kimi:[{key:'kimi_url',label:'API URL',type:'url',placeholder:'https://api.moonshot.cn/v1',hint:'KIMI/Moonshot API endpoint'},{key:'kimi_key',label:'API Key',type:'password',placeholder:'sk-...',hint:'Moonshot API Key'},{key:'kimi_model',label:'Model',type:'text',placeholder:'moonshot-v1-8k',hint:'e.g. moonshot-v1-8k, moonshot-v1-32k, moonshot-v1-128k'}],openrouter:[{key:'openrouter_url',label:'API URL',type:'url',placeholder:'https://openrouter.ai/api/v1',hint:'OpenRouter endpoint'},{key:'openrouter_key',label:'API Key',type:'password',placeholder:'sk-or-...',hint:'OpenRouter API Key'},{key:'openrouter_model',label:'Model',type:'text',placeholder:'meta-llama/llama-3.1-8b-instruct',hint:'Full model slug from openrouter.ai/models'}],siliconflow:[{key:'siliconflow_url',label:'API URL',type:'url',placeholder:'https://api.siliconflow.cn/v1',hint:'SiliconFlow API endpoint'},{key:'siliconflow_key',label:'API Key',type:'password',placeholder:'sk-...',hint:'SiliconFlow API key'},{key:'siliconflow_model',label:'Model',type:'text',placeholder:'Qwen/Qwen3-Next-80B-A3B-Instruct',hint:'Model identifier'}],custom_http:[{key:'custom_url',label:'API Endpoint URL',type:'url',placeholder:'https://your-api.com/v1/chat/completions',hint:'Full API endpoint URL'},{key:'custom_key',label:'API Key',type:'password',placeholder:'sk-...',hint:'API key (optional)'},{key:'custom_model',label:'Model',type:'text',placeholder:'model-name',hint:'Model identifier'},{key:'custom_headers',label:'Custom Headers (JSON)',type:'textarea',placeholder:'{"Authorization":"Bearer token","X-Custom":"value"}',hint:'JSON object of additional HTTP headers'},{key:'custom_payload',label:'Payload Template (JSON)',type:'textarea',placeholder:'{"custom_param":"value","stream":true}',hint:'Extra fields merged into the request body'},{key:'temperature',label:'Temperature',type:'number',placeholder:'0.2',hint:'0.0-2.0, lower=deterministic'},{key:'request_timeout',label:'Request Timeout (seconds)',type:'number',placeholder:'3600',hint:'Max seconds per LLM request'}]};
32871
+ function renderLlmFields(provider){const container=E('llmFieldsContainer');if(!container)return;let html='';const _localScanProviders=['ollama','vllm','lmstudio'];if(provider==='ollama'){const fields=LLM_PROVIDER_FIELDS.ollama;for(const f of fields){html+='<div class=\"llm-field\"><label>'+esc(f.label)+'</label><input type=\"'+f.type+'\" id=\"llmF_'+f.key+'\" placeholder=\"'+esc(f.placeholder||'')+'\" value=\"\"><div class=\"llm-hint\">'+esc(f.hint||'')+'</div></div>'}html+='<div class=\"llm-field\"><label>'+esc(t('llm_model'))+'</label><div style=\"display:flex;gap:8px;align-items:center\"><select id=\"llmF_ollama_model\" style=\"flex:1\"><option value=\"\">-- '+esc(t('llm_scan_first'))+' --</option></select><button type=\"button\" id=\"ollamaScanBtn\" class=\"llm-modal-btn-secondary\" style=\"flex:none;padding:6px 12px\">'+esc(t('llm_scan'))+'</button></div><div class=\"llm-hint\" id=\"ollamaScanHint\">'+esc(t('llm_scan_hint'))+'</div></div>'}else if(provider==='vllm'||provider==='lmstudio'){const fields=LLM_PROVIDER_FIELDS[provider]||[];for(const f of fields){html+='<div class=\"llm-field\"><label>'+esc(f.label)+'</label><input type=\"'+(f.type==='number'?'text':f.type)+'\" id=\"llmF_'+f.key+'\" placeholder=\"'+esc(f.placeholder||'')+'\" value=\"\"><div class=\"llm-hint\">'+esc(f.hint||'')+'</div></div>'}const urlKey=provider==='vllm'?'vllm_url':'lmstudio_url';const modelKey=provider==='vllm'?'vllm_model':'lmstudio_model';html+='<div class=\"llm-field\"><div style=\"display:flex;gap:8px;align-items:center\"><button type=\"button\" id=\"localScanBtn\" class=\"llm-modal-btn-secondary\" style=\"flex:none;padding:6px 12px\">'+esc(t('llm_scan'))+'</button></div><div class=\"llm-hint\" id=\"localScanHint\">'+esc(t('llm_scan_hint'))+'</div></div>'}else{const fields=LLM_PROVIDER_FIELDS[provider]||[];for(const f of fields){if(f.type==='textarea'){html+='<div class=\"llm-field\"><label>'+esc(f.label)+'</label><textarea id=\"llmF_'+f.key+'\" placeholder=\"'+esc(f.placeholder||'')+'\" rows=\"3\" style=\"width:100%;padding:8px 10px;border:1px solid var(--line,#d9e1ec);border-radius:8px;font-size:.84rem;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical;box-sizing:border-box\"></textarea><div class=\"llm-hint\">'+esc(f.hint||'')+'</div></div>'}else{html+='<div class=\"llm-field\"><label>'+esc(f.label)+'</label><input type=\"'+(f.type==='number'?'text':f.type)+'\" id=\"llmF_'+f.key+'\" placeholder=\"'+esc(f.placeholder||'')+'\" value=\"\"><div class=\"llm-hint\">'+esc(f.hint||'')+'</div></div>'}}}html+='<div class=\"llm-field\"><label>'+esc(t('llm_thinking_stream'))+'</label><select id=\"llmF_thinking_stream\"><option value=\"true\">'+esc(t('llm_enabled'))+'</option><option value=\"false\">'+esc(t('llm_disabled'))+'</option></select></div>';container.innerHTML=html;if(provider==='ollama'){const scanBtn=E('ollamaScanBtn');if(scanBtn)scanBtn.onclick=()=>scanOllamaModels()}if(provider==='vllm'||provider==='lmstudio'){const scanBtn=E('localScanBtn');if(scanBtn)scanBtn.onclick=()=>scanOpenAICompatModels(provider)}}
31151
32872
  async function scanOllamaModels(){const urlEl=E('llmF_ollama_url');const sel=E('llmF_ollama_model');const hint=E('ollamaScanHint');const baseUrl=(urlEl?.value||'').trim()||'http://127.0.0.1:11434';if(hint)hint.textContent=t('llm_scanning');try{const res=await fetch('/api/ollama/models?base_url='+encodeURIComponent(baseUrl));const data=await res.json();if(!data.ok||!data.models?.length){if(hint)hint.textContent=t('llm_scan_empty')+(data.error?' ('+data.error+')':'');return}if(sel){sel.innerHTML='';for(const m of data.models){const op=document.createElement('option');op.value=m;op.textContent=m;sel.appendChild(op)}}if(hint)hint.textContent=t('llm_scan_found').replace('{n}',String(data.models.length))}catch(err){if(hint)hint.textContent=t('llm_scan_error')+': '+(err.message||String(err))}}
32873
+ async function scanOpenAICompatModels(provider){const urlKey=provider==='vllm'?'vllm_url':'lmstudio_url';const modelKey=provider==='vllm'?'vllm_model':'lmstudio_model';const urlEl=E('llmF_'+urlKey);const modelEl=E('llmF_'+modelKey);const hint=E('localScanHint');const defaults={vllm:'http://localhost:8000/v1',lmstudio:'http://localhost:1234/v1'};const baseUrl=(urlEl?.value||'').trim()||defaults[provider]||'';const apiKey=(E('llmF_'+provider+'_key')?.value||'').trim();if(hint)hint.textContent=t('llm_scanning');try{let url='/api/openai_compat/models?base_url='+encodeURIComponent(baseUrl);if(apiKey)url+='&api_key='+encodeURIComponent(apiKey);const res=await fetch(url);const data=await res.json();if(!data.ok||!data.models?.length){if(hint)hint.textContent=t('llm_scan_empty')+(data.error?' ('+data.error+')':'');return}if(modelEl){modelEl.value=data.models[0]}if(hint)hint.textContent=t('llm_scan_found').replace('{n}',String(data.models.length))+': '+data.models.slice(0,3).join(', ')}catch(err){if(hint)hint.textContent=t('llm_scan_error')+': '+(err.message||String(err))}}
31152
32874
  function collectLlmConfig(){const provider=E('llmProvider')?.value||'ollama';const config={provider:provider};if(provider==='ollama'){config.ollama_url=(E('llmF_ollama_url')?.value||'').trim()||'http://127.0.0.1:11434';config.ollama_model=E('llmF_ollama_model')?.value||''}else if(provider==='custom_http'){const fields=LLM_PROVIDER_FIELDS.custom_http;for(const f of fields){const el=E('llmF_'+f.key);if(!el)continue;if(f.type==='textarea'){config[f.key]=el.value.trim()}else if(f.key==='temperature'){const v=parseFloat(el.value);if(!isNaN(v))config[f.key]=v}else if(f.key==='request_timeout'){const v=parseInt(el.value,10);if(!isNaN(v)&&v>0)config[f.key]=v}else{config[f.key]=el.value.trim()}}}else{const fields=LLM_PROVIDER_FIELDS[provider]||[];for(const f of fields){const el=E('llmF_'+f.key);if(el)config[f.key]=el.value.trim()}}config.thinking_stream=E('llmF_thinking_stream')?.value==='true';return config}
31153
32875
  async function submitLlmConfig(){if(!S.activeId){showError(t('select_session_first'));return}const config=collectLlmConfig();try{const payload={filename:'LLM.config.json',mime:'application/json',content_b64:btoa(unescape(encodeURIComponent(JSON.stringify(config,null,2))))};const out=await api('/api/sessions/'+S.activeId+'/uploads',{method:'POST',body:JSON.stringify(payload)});if(!out?.model_catalog){showError(t('config_uploaded_no_profiles'))}else{showError('')}const cat=out?.model_catalog||await loadModelCatalog();if(!applyModelCatalog(cat)){renderModelControls()}await refreshSnapshot({forceFull:true,allowWhenFrozen:true});E('llmConfigModal').style.display='none'}catch(err){showError(err.message||String(err))}}
31154
32876
  function openLlmConfigModal(){const modal=E('llmConfigModal');if(!modal)return;modal.style.display='flex';const prov=E('llmProvider');if(prov){renderLlmFields(prov.value)}}
@@ -43716,6 +45438,25 @@ class Handler(BaseHTTPRequestHandler):
43716
45438
  return self._send_json({"ok": True, "models": models, "base_url": ollama_url})
43717
45439
  except Exception as exc:
43718
45440
  return self._send_json({"ok": False, "models": [], "error": str(exc)[:300], "base_url": ollama_url})
45441
+ if path == "/api/openai_compat/models":
45442
+ base_url = str((query.get("base_url", [""]) or [""])[0]).strip()
45443
+ api_key = str((query.get("api_key", [""]) or [""])[0]).strip()
45444
+ if not base_url:
45445
+ return self._send_json({"ok": False, "models": [], "error": "base_url required"})
45446
+ try:
45447
+ import urllib.request
45448
+ models_url = base_url.rstrip("/") + "/models"
45449
+ req = urllib.request.Request(models_url, method="GET")
45450
+ req.add_header("Accept", "application/json")
45451
+ if api_key:
45452
+ req.add_header("Authorization", f"Bearer {api_key}")
45453
+ with urllib.request.urlopen(req, timeout=8) as resp:
45454
+ raw = json.loads(resp.read().decode("utf-8"))
45455
+ data = raw.get("data", [])
45456
+ model_ids = [str(m.get("id", "")) for m in data if isinstance(m, dict) and m.get("id")]
45457
+ return self._send_json({"ok": True, "models": model_ids, "base_url": base_url})
45458
+ except Exception as exc:
45459
+ return self._send_json({"ok": False, "models": [], "error": str(exc)[:300], "base_url": base_url})
43719
45460
  if path == "/api/skills":
43720
45461
  return self._send_json(self.app.skills_catalog())
43721
45462
  if path == "/api/skills/providers":
@@ -45001,8 +46742,10 @@ def main():
45001
46742
  startup_tags = list_ollama_models(bootstrap_base_url)
45002
46743
  if startup_tags:
45003
46744
  resolved_model = bootstrap_model if bootstrap_model in startup_tags else startup_tags[0]
46745
+ print(f"[web-agent] ollama: found {len(startup_tags)} model(s) at {bootstrap_base_url} — using '{resolved_model}'")
45004
46746
  else:
45005
46747
  resolved_model = bootstrap_model
46748
+ print(f"[web-agent] ollama: not available at {bootstrap_base_url} — using fallback '{resolved_model}'")
45006
46749
  resolved_thinking = False
45007
46750
  requested_ctx_limit = int(args.ctx_limit or TOKEN_THRESHOLD)
45008
46751
  resolved_ctx_limit = max(