npcpy 1.3.22__py3-none-any.whl → 1.3.23__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
npcpy/npc_compiler.py CHANGED
@@ -56,8 +56,9 @@ from jinja2.sandbox import SandboxedEnvironment
56
56
  from sqlalchemy import create_engine, text
57
57
  import npcpy as npy
58
58
  from npcpy.tools import auto_tools
59
- import math
59
+ import math
60
60
  import random
61
+ import base64
61
62
  from npcpy.npc_sysenv import (
62
63
  ensure_dirs_exist,
63
64
  init_db_tables,
@@ -276,6 +277,7 @@ def initialize_npc_project(
276
277
  os.makedirs(npc_team_dir, exist_ok=True)
277
278
 
278
279
  for subdir in ["jinxs",
280
+ "jinxs/skills",
279
281
  "assembly_lines",
280
282
  "sql_models",
281
283
  "jobs",
@@ -1016,14 +1018,212 @@ output = {mcp_tool.__module__}.{name}(
1016
1018
 
1017
1019
 
1018
1020
 
1021
+ def _parse_skill_md(path):
1022
+ """Parse a skill markdown file with YAML frontmatter and ## sections.
1023
+
1024
+ Expected format:
1025
+ ---
1026
+ name: skill-name
1027
+ description: What it does. Use when ...
1028
+ ---
1029
+ # Skill Title
1030
+
1031
+ ## section-one
1032
+ Content for section one...
1033
+
1034
+ ## section-two
1035
+ Content for section two...
1036
+
1037
+ Returns dict with name, description, sections, frontmatter or None on failure.
1038
+ """
1039
+ with open(path, 'r', encoding='utf-8') as f:
1040
+ content = f.read()
1041
+
1042
+ if not content.startswith('---'):
1043
+ return None
1044
+
1045
+ parts = content.split('---', 2)
1046
+ if len(parts) < 3:
1047
+ return None
1048
+
1049
+ frontmatter = yaml.safe_load(parts[1])
1050
+ if not frontmatter or not isinstance(frontmatter, dict):
1051
+ return None
1052
+
1053
+ body = parts[2].strip()
1054
+
1055
+ # Parse ## sections from markdown body
1056
+ sections = {}
1057
+ current_section = None
1058
+ current_content = []
1059
+
1060
+ for line in body.split('\n'):
1061
+ if line.startswith('## '):
1062
+ if current_section:
1063
+ sections[current_section] = '\n'.join(current_content).strip()
1064
+ current_section = line[3:].strip()
1065
+ current_content = []
1066
+ elif current_section is not None:
1067
+ current_content.append(line)
1068
+
1069
+ if current_section:
1070
+ sections[current_section] = '\n'.join(current_content).strip()
1071
+
1072
+ # For SKILL.md files the skill name should come from frontmatter or the
1073
+ # parent folder name, not from the filename "SKILL".
1074
+ basename = os.path.splitext(os.path.basename(path))[0]
1075
+ if basename.upper() == 'SKILL':
1076
+ # Folder-based skill: use parent directory name as fallback
1077
+ default_name = os.path.basename(os.path.dirname(path))
1078
+ else:
1079
+ default_name = basename
1080
+
1081
+ return {
1082
+ 'name': frontmatter.get('name', default_name),
1083
+ 'description': frontmatter.get('description', ''),
1084
+ 'sections': sections,
1085
+ 'frontmatter': frontmatter
1086
+ }
1087
+
1088
+
1089
+ def _compile_skill_to_jinx(skill_data, source_path=None):
1090
+ """Compile skill data into a Jinx whose step is ``engine: skill``.
1091
+
1092
+ Everything about the skill — name, description, sections, scripts,
1093
+ references, assets — is passed as structured data into ``skill.jinx``.
1094
+ The sub-jinx owns the full representation; the compiler just parses the
1095
+ source format and hands it off.
1096
+
1097
+ Sections content is base64-encoded to survive the two-pass Jinja pipeline
1098
+ without mangling. Metadata lists (scripts, references, assets) are passed
1099
+ as JSON strings.
1100
+ """
1101
+ name = skill_data.get('jinx_name', skill_data.get('name', ''))
1102
+ description = skill_data.get('description', '')
1103
+ sections = skill_data.get('sections', {})
1104
+
1105
+ # Encode sections as b64 to protect from Jinja rendering
1106
+ sections_json = json.dumps(sections, ensure_ascii=False)
1107
+ content_b64 = base64.b64encode(sections_json.encode('utf-8')).decode('ascii')
1108
+
1109
+ section_names = list(sections.keys())
1110
+ desc_suffix = f" [Sections: {', '.join(section_names)}]" if section_names else ""
1111
+
1112
+ # Collect scripts/references/assets lists for the structured representation
1113
+ scripts_list = []
1114
+ references_list = []
1115
+ assets_list = []
1116
+
1117
+ # Build file_context entries so the Jinx runtime can load files
1118
+ file_context = list(skill_data.get('file_context', []))
1119
+ for subdir, collector in (('scripts', scripts_list),
1120
+ ('references', references_list),
1121
+ ('assets', assets_list)):
1122
+ entries = skill_data.get(subdir)
1123
+ if isinstance(entries, list):
1124
+ # Explicit file list from .jinx YAML
1125
+ collector.extend(entries)
1126
+ for entry in entries:
1127
+ if isinstance(entry, str):
1128
+ file_context.append({
1129
+ 'pattern': entry,
1130
+ 'base_path': os.path.join('.', subdir) if source_path else '.',
1131
+ })
1132
+ elif isinstance(entry, dict):
1133
+ file_context.append(entry)
1134
+ elif entries is None and source_path:
1135
+ # Folder-based skill: auto-discover if the subdir exists
1136
+ skill_dir = os.path.dirname(source_path)
1137
+ subdir_path = os.path.join(skill_dir, subdir)
1138
+ if os.path.isdir(subdir_path):
1139
+ # Collect actual filenames for the structured representation
1140
+ for r, _d, fnames in os.walk(subdir_path):
1141
+ for fn in fnames:
1142
+ rel = os.path.relpath(os.path.join(r, fn), skill_dir)
1143
+ collector.append(rel)
1144
+ file_context.append({
1145
+ 'pattern': '*',
1146
+ 'base_path': subdir,
1147
+ 'recursive': True,
1148
+ })
1149
+
1150
+ jinx_data = {
1151
+ 'jinx_name': name,
1152
+ 'description': (description + desc_suffix) if description else f"Skill: {name}{desc_suffix}",
1153
+ 'inputs': [{'section': 'all'}],
1154
+ 'steps': [{
1155
+ 'engine': 'skill',
1156
+ 'skill_name': name,
1157
+ 'skill_description': description,
1158
+ 'sections': content_b64,
1159
+ 'scripts_json': json.dumps(scripts_list),
1160
+ 'references_json': json.dumps(references_list),
1161
+ 'assets_json': json.dumps(assets_list),
1162
+ 'section': '{{section}}'
1163
+ }],
1164
+ 'file_context': file_context,
1165
+ '_source_path': source_path
1166
+ }
1167
+
1168
+ return Jinx(jinx_data=jinx_data)
1169
+
1170
+
1171
+ def _load_skill_from_md(path):
1172
+ """Load a skill from a SKILL.md file with YAML frontmatter and ## sections.
1173
+
1174
+ The skill name defaults to the parent folder name (matching the Anthropic
1175
+ skill-folder convention), falling back to the frontmatter ``name`` field.
1176
+
1177
+ Sibling directories (``scripts/``, ``references/``, ``assets/``) are
1178
+ auto-discovered and attached as ``file_context``.
1179
+ """
1180
+ parsed = _parse_skill_md(path)
1181
+ if not parsed or not parsed.get('sections'):
1182
+ return None
1183
+
1184
+ # Skill name: prefer frontmatter 'name', fall back to parent directory name
1185
+ parent_dir = os.path.basename(os.path.dirname(path))
1186
+ name = parsed['name'] or parent_dir
1187
+
1188
+ skill_data = {
1189
+ 'jinx_name': name,
1190
+ 'description': parsed['description'],
1191
+ 'sections': parsed['sections'],
1192
+ }
1193
+
1194
+ # Forward any extra frontmatter keys the author set (scripts, references, etc.)
1195
+ fm = parsed.get('frontmatter', {})
1196
+ for key in ('scripts', 'references', 'assets', 'file_context'):
1197
+ if key in fm:
1198
+ skill_data[key] = fm[key]
1199
+
1200
+ return _compile_skill_to_jinx(skill_data, source_path=path)
1201
+
1202
+
1019
1203
  def load_jinxs_from_directory(directory):
1020
- """Load all jinxs from a directory recursively"""
1204
+ """Load all jinxs from a directory recursively.
1205
+
1206
+ Handles two file types:
1207
+
1208
+ 1. **.jinx** — regular jinxs loaded as-is. Skill-type jinxs are just
1209
+ regular ``.jinx`` files whose steps use ``engine: skill`` with the
1210
+ full structured args (sections, scripts, references, assets).
1211
+ 2. **SKILL.md inside a skill folder** — Anthropic-style skill folders::
1212
+
1213
+ jinxs/skills/code-review/SKILL.md
1214
+ jinxs/skills/code-review/scripts/
1215
+ jinxs/skills/code-review/references/
1216
+
1217
+ The folder name becomes the skill name. The SKILL.md must have YAML
1218
+ frontmatter (``---`` delimiters) and ``##`` section headers.
1219
+ These are compiled into jinxs with ``engine: skill`` steps.
1220
+ """
1021
1221
  jinxs = []
1022
1222
  directory = os.path.expanduser(directory)
1023
-
1223
+
1024
1224
  if not os.path.exists(directory):
1025
1225
  return jinxs
1026
-
1226
+
1027
1227
  for root, dirs, files in os.walk(directory):
1028
1228
  for filename in files:
1029
1229
  if filename.endswith(".jinx"):
@@ -1033,7 +1233,17 @@ def load_jinxs_from_directory(directory):
1033
1233
  jinxs.append(jinx)
1034
1234
  except Exception as e:
1035
1235
  print(f"Error loading jinx {filename}: {e}")
1036
-
1236
+ elif filename == "SKILL.md":
1237
+ # Anthropic-style skill folder: skills/my-skill/SKILL.md
1238
+ try:
1239
+ md_path = os.path.join(root, filename)
1240
+ jinx = _load_skill_from_md(md_path)
1241
+ if jinx:
1242
+ jinxs.append(jinx)
1243
+ except Exception as e:
1244
+ skill_folder = os.path.basename(root)
1245
+ print(f"Error loading skill from {skill_folder}/SKILL.md: {e}")
1246
+
1037
1247
  return jinxs
1038
1248
 
1039
1249
  def jinx_to_tool_def(jinx_obj: 'Jinx') -> Dict[str, Any]:
@@ -2171,11 +2381,11 @@ Requirements:
2171
2381
  }
2172
2382
 
2173
2383
  def execute_jinx(
2174
- self,
2175
- jinx_name,
2176
- inputs,
2177
- conversation_id=None,
2178
- message_id=None,
2384
+ self,
2385
+ jinx_name,
2386
+ inputs,
2387
+ conversation_id=None,
2388
+ message_id=None,
2179
2389
  team_name=None,
2180
2390
  extra_globals=None
2181
2391
  ):
@@ -2183,27 +2393,38 @@ Requirements:
2183
2393
  jinx = self.jinxs_dict[jinx_name]
2184
2394
  else:
2185
2395
  return {"error": f"jinx '{jinx_name}' not found"}
2186
-
2187
- result = jinx.execute(
2188
- input_values=inputs,
2189
- npc=self,
2190
- # messages=messages, # messages should be passed from the calling context if available
2191
- extra_globals=extra_globals,
2192
- jinja_env=self.jinja_env # Pass the NPC's second-pass Jinja env
2193
- )
2194
-
2195
- # Log jinx call if we have a command_history with add_jinx_call method
2196
- if self.command_history is not None and hasattr(self.command_history, 'add_jinx_call'):
2396
+
2397
+ import time as _time
2398
+ _start = _time.monotonic()
2399
+ _status = "success"
2400
+ _error = None
2401
+
2402
+ try:
2403
+ result = jinx.execute(
2404
+ input_values=inputs,
2405
+ npc=self,
2406
+ extra_globals=extra_globals,
2407
+ jinja_env=self.jinja_env
2408
+ )
2409
+ except Exception as e:
2410
+ _status = "error"
2411
+ _error = str(e)
2412
+ result = {"error": str(e)}
2413
+
2414
+ _duration_ms = int((_time.monotonic() - _start) * 1000)
2415
+
2416
+ # Log jinx execution
2417
+ if self.command_history is not None and hasattr(self.command_history, 'save_jinx_execution'):
2197
2418
  try:
2198
- self.command_history.add_jinx_call(
2419
+ self.command_history.save_jinx_execution(
2199
2420
  triggering_message_id=message_id,
2200
2421
  conversation_id=conversation_id,
2201
2422
  jinx_name=jinx_name,
2202
2423
  jinx_inputs=inputs,
2203
2424
  jinx_output=result,
2204
- status="success",
2205
- error_message=None,
2206
- duration_ms=None,
2425
+ status=_status,
2426
+ error_message=_error,
2427
+ duration_ms=_duration_ms,
2207
2428
  npc_name=self.name,
2208
2429
  team_name=team_name,
2209
2430
  )
@@ -2532,6 +2753,7 @@ class Team:
2532
2753
 
2533
2754
  self.forenpc: Optional['NPC'] = None # Will be set to an NPC object by end of __init__
2534
2755
  self.forenpc_name: Optional[str] = None # Temporary storage for name from context (if loaded from .ctx)
2756
+ self.skills_directory: Optional[str] = None # External skills directory from .ctx SKILLS_DIRECTORY
2535
2757
 
2536
2758
  if team_path:
2537
2759
  self.name = os.path.basename(os.path.abspath(team_path))
@@ -2623,6 +2845,18 @@ class Team:
2623
2845
  for jinx_obj in load_jinxs_from_directory(jinxs_dir):
2624
2846
  self._raw_jinxs_list.append(jinx_obj)
2625
2847
 
2848
+ # 4.5. Load skills from external SKILLS_DIRECTORY if specified in .ctx
2849
+ if hasattr(self, 'skills_directory') and self.skills_directory:
2850
+ skills_path = os.path.expanduser(self.skills_directory)
2851
+ if not os.path.isabs(skills_path):
2852
+ skills_path = os.path.join(self.team_path, skills_path)
2853
+ if os.path.exists(skills_path):
2854
+ for jinx_obj in load_jinxs_from_directory(skills_path):
2855
+ self._raw_jinxs_list.append(jinx_obj)
2856
+ print(f"[TEAM] Loaded skills from SKILLS_DIRECTORY: {skills_path}")
2857
+ else:
2858
+ print(f"[TEAM] Warning: SKILLS_DIRECTORY not found: {skills_path}")
2859
+
2626
2860
  # 5. Load sub-teams
2627
2861
  self._load_sub_teams()
2628
2862
 
@@ -2640,6 +2874,7 @@ class Team:
2640
2874
  self.mcp_servers = ctx_data.get('mcp_servers', [])
2641
2875
  self.databases = ctx_data.get('databases', [])
2642
2876
  self.forenpc_name = ctx_data.get('forenpc', self.forenpc_name) # Set forenpc_name (string)
2877
+ self.skills_directory = ctx_data.get('SKILLS_DIRECTORY', None)
2643
2878
  return ctx_data
2644
2879
  return {}
2645
2880
 
@@ -2657,7 +2892,7 @@ class Team:
2657
2892
  self.shared_context['files'] = file_cache
2658
2893
  # All other keys (including preferences) are treated as generic context
2659
2894
  for key, item in ctx_data.items():
2660
- if key not in ['name', 'mcp_servers', 'databases', 'context', 'file_patterns', 'forenpc', 'model', 'provider', 'api_url', 'env']:
2895
+ if key not in ['name', 'mcp_servers', 'databases', 'context', 'file_patterns', 'forenpc', 'model', 'provider', 'api_url', 'env', 'SKILLS_DIRECTORY']:
2661
2896
  self.shared_context[key] = item
2662
2897
  return # Only load the first .ctx file found
2663
2898
 
npcpy/npc_sysenv.py CHANGED
@@ -1203,7 +1203,10 @@ def lookup_provider(model: str) -> str:
1203
1203
 
1204
1204
  if model == "deepseek-chat" or model == "deepseek-reasoner":
1205
1205
  return "deepseek"
1206
-
1206
+
1207
+ if model.startswith("airllm-"):
1208
+ return "airllm"
1209
+
1207
1210
  ollama_prefixes = [
1208
1211
  "llama", "deepseek", "qwen", "llava",
1209
1212
  "phi", "mistral", "mixtral", "dolphin",