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/data/image.py +15 -15
- npcpy/data/web.py +2 -2
- npcpy/gen/image_gen.py +113 -62
- npcpy/gen/response.py +239 -0
- npcpy/llm_funcs.py +73 -71
- npcpy/memory/command_history.py +117 -69
- npcpy/memory/kg_vis.py +74 -74
- npcpy/npc_compiler.py +261 -26
- npcpy/npc_sysenv.py +4 -1
- npcpy/serve.py +393 -91
- npcpy/work/desktop.py +31 -5
- npcpy-1.3.23.dist-info/METADATA +416 -0
- {npcpy-1.3.22.dist-info → npcpy-1.3.23.dist-info}/RECORD +16 -16
- npcpy-1.3.22.dist-info/METADATA +0 -1039
- {npcpy-1.3.22.dist-info → npcpy-1.3.23.dist-info}/WHEEL +0 -0
- {npcpy-1.3.22.dist-info → npcpy-1.3.23.dist-info}/licenses/LICENSE +0 -0
- {npcpy-1.3.22.dist-info → npcpy-1.3.23.dist-info}/top_level.txt +0 -0
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
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
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.
|
|
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=
|
|
2205
|
-
error_message=
|
|
2206
|
-
duration_ms=
|
|
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",
|