npcpy 1.3.3__py3-none-any.whl → 1.3.5__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.
@@ -196,8 +196,19 @@ def init_kg_schema(engine: Engine):
196
196
  Column('value', Text),
197
197
  schema=None
198
198
  )
199
-
200
-
199
+
200
+ # NPC version history for rollback support
201
+ npc_versions = Table('npc_versions', metadata,
202
+ Column('id', Integer, primary_key=True, autoincrement=True),
203
+ Column('npc_name', String(255), nullable=False),
204
+ Column('team_path', Text, nullable=False), # path to npc_team directory
205
+ Column('version', Integer, nullable=False),
206
+ Column('content', Text, nullable=False), # full YAML content
207
+ Column('created_at', DateTime, default=datetime.utcnow),
208
+ Column('commit_message', Text), # optional description of change
209
+ schema=None
210
+ )
211
+
201
212
  metadata.create_all(engine, checkfirst=True)
202
213
 
203
214
  def load_kg_from_db(engine: Engine, team_name: str, npc_name: str, directory_path: str) -> Dict[str, Any]:
@@ -402,6 +413,100 @@ def save_kg_to_db(engine: Engine, kg_data: Dict[str, Any], team_name: str, npc_n
402
413
  except Exception as e:
403
414
  print(f"Failed to save KG for scope '({team_name}, {npc_name}, {directory_path})': {e}")
404
415
 
416
+
417
+ # ============== NPC Version History Functions ==============
418
+
419
+ def save_npc_version(engine: Engine, npc_name: str, team_path: str, content: str, commit_message: str = None) -> int:
420
+ """Save a new version of an NPC config. Returns the new version number."""
421
+ init_kg_schema(engine) # Ensure table exists
422
+
423
+ with engine.begin() as conn:
424
+ # Get current max version
425
+ result = conn.execute(text("""
426
+ SELECT COALESCE(MAX(version), 0) as max_version
427
+ FROM npc_versions
428
+ WHERE npc_name = :npc_name AND team_path = :team_path
429
+ """), {"npc_name": npc_name, "team_path": team_path})
430
+ row = result.fetchone()
431
+ new_version = (row[0] if row else 0) + 1
432
+
433
+ # Insert new version
434
+ conn.execute(text("""
435
+ INSERT INTO npc_versions (npc_name, team_path, version, content, created_at, commit_message)
436
+ VALUES (:npc_name, :team_path, :version, :content, :created_at, :commit_message)
437
+ """), {
438
+ "npc_name": npc_name,
439
+ "team_path": team_path,
440
+ "version": new_version,
441
+ "content": content,
442
+ "created_at": datetime.utcnow(),
443
+ "commit_message": commit_message
444
+ })
445
+
446
+ return new_version
447
+
448
+
449
+ def get_npc_versions(engine: Engine, npc_name: str, team_path: str) -> List[Dict[str, Any]]:
450
+ """Get all versions of an NPC config."""
451
+ init_kg_schema(engine)
452
+
453
+ with engine.connect() as conn:
454
+ result = conn.execute(text("""
455
+ SELECT id, version, created_at, commit_message
456
+ FROM npc_versions
457
+ WHERE npc_name = :npc_name AND team_path = :team_path
458
+ ORDER BY version DESC
459
+ """), {"npc_name": npc_name, "team_path": team_path})
460
+
461
+ return [
462
+ {
463
+ "id": row[0],
464
+ "version": row[1],
465
+ "created_at": row[2].isoformat() if row[2] else None,
466
+ "commit_message": row[3]
467
+ }
468
+ for row in result
469
+ ]
470
+
471
+
472
+ def get_npc_version_content(engine: Engine, npc_name: str, team_path: str, version: int = None) -> Optional[str]:
473
+ """Get the content of a specific NPC version. If version is None, get latest."""
474
+ init_kg_schema(engine)
475
+
476
+ with engine.connect() as conn:
477
+ if version is None:
478
+ result = conn.execute(text("""
479
+ SELECT content FROM npc_versions
480
+ WHERE npc_name = :npc_name AND team_path = :team_path
481
+ ORDER BY version DESC LIMIT 1
482
+ """), {"npc_name": npc_name, "team_path": team_path})
483
+ else:
484
+ result = conn.execute(text("""
485
+ SELECT content FROM npc_versions
486
+ WHERE npc_name = :npc_name AND team_path = :team_path AND version = :version
487
+ """), {"npc_name": npc_name, "team_path": team_path, "version": version})
488
+
489
+ row = result.fetchone()
490
+ return row[0] if row else None
491
+
492
+
493
+ def rollback_npc_to_version(engine: Engine, npc_name: str, team_path: str, version: int) -> Optional[str]:
494
+ """Rollback an NPC to a specific version. Returns the content if successful."""
495
+ content = get_npc_version_content(engine, npc_name, team_path, version)
496
+ if content is None:
497
+ return None
498
+
499
+ # Save as a new version with rollback message
500
+ save_npc_version(engine, npc_name, team_path, content, f"Rollback to version {version}")
501
+
502
+ # Write to file
503
+ file_path = os.path.join(team_path, f"{npc_name}.npc")
504
+ with open(file_path, 'w') as f:
505
+ f.write(content)
506
+
507
+ return content
508
+
509
+
405
510
  def generate_message_id() -> str:
406
511
  return str(uuid.uuid4())
407
512
 
@@ -871,7 +871,7 @@ def process_text_with_chroma(
871
871
  )
872
872
 
873
873
 
874
- facts = extract_facts(text, model=model, provider=provider, npc=npc)
874
+ facts = get_facts(text, model=model, provider=provider, npc=npc)
875
875
 
876
876
 
877
877
  for i in range(0, len(facts), batch_size):
npcpy/npc_compiler.py CHANGED
@@ -411,7 +411,12 @@ class Jinx:
411
411
 
412
412
  # _raw_steps will now hold the original, potentially templated, steps definition
413
413
  self._raw_steps = list(self.steps)
414
- self.steps = [] # Will be populated after first-pass rendering
414
+ # If steps are already valid dicts (not needing Jinja templating), keep them
415
+ # Otherwise clear for first-pass rendering to populate
416
+ if self.steps and all(isinstance(s, dict) for s in self.steps):
417
+ pass # Keep steps as-is for simple jinxes
418
+ else:
419
+ self.steps = [] # Will be populated after first-pass rendering
415
420
  self.parsed_files = {}
416
421
  if self.file_context:
417
422
  self.parsed_files = self._parse_file_patterns(self.file_context)
@@ -420,7 +425,8 @@ class Jinx:
420
425
  jinx_data = load_yaml_file(path)
421
426
  if not jinx_data:
422
427
  raise ValueError(f"Failed to load jinx from {path}")
423
- self._source_path = path
428
+ # Set _source_path in the data so it's preserved after _load_from_data
429
+ jinx_data['_source_path'] = path
424
430
  self._load_from_data(jinx_data)
425
431
 
426
432
 
@@ -638,7 +644,8 @@ class Jinx:
638
644
  extra_globals=extra_globals
639
645
  )
640
646
  # If an error occurred in a step, propagate it and stop execution
641
- if "error" in context.get("output", ""):
647
+ output_str = str(context.get("output", ""))
648
+ if "error" in output_str.lower():
642
649
  self._log_debug(f"DEBUG: Jinx '{self.jinx_name}' execution stopped due to error in step '{step.get('name', 'unnamed_step')}': {context['output']}")
643
650
  break
644
651
 
@@ -705,37 +712,54 @@ class Jinx:
705
712
 
706
713
  if extra_globals:
707
714
  exec_globals.update(extra_globals)
708
-
709
- exec_locals = {} # Locals for this specific exec call
715
+
716
+ # Add context values directly as variables so jinx code can use them without Jinja
717
+ exec_globals.update(context)
718
+
719
+ # NOTE: Using same dict for globals and locals because when they're
720
+ # separate, imports end up in locals but nested functions can only see globals.
721
+ # This caused "name 'X' is not defined" errors when functions used imported names.
722
+ exec_locals = exec_globals # Use same namespace so imports are visible in functions
710
723
 
711
724
  try:
712
725
  exec(rendered_code, exec_globals, exec_locals)
713
726
  except Exception as e:
714
727
  error_msg = (
715
- f"Error executing step '{step_name}': "
728
+ f"Error executing step '{step_name}' in jinx '{self.jinx_name}': "
716
729
  f"{type(e).__name__}: {e}"
717
730
  )
731
+ print(f"[JINX-ERROR] {error_msg}")
718
732
  context['output'] = error_msg
719
733
  self._log_debug(error_msg)
720
734
  return context
721
-
735
+
722
736
  # Update the main context with any variables set in exec_locals
737
+ # But preserve context['output'] if jinx set it via context['output'] = ...
738
+ context_output = context.get("output")
723
739
  context.update(exec_locals)
724
-
725
- if "output" in exec_locals:
740
+
741
+ # If jinx set context['output'] directly, preserve it
742
+ if context_output is not None:
743
+ context["output"] = context_output
744
+ context[step_name] = context_output
745
+
746
+ # Only use exec_locals output if it was explicitly set (not still None from init)
747
+ if "output" in exec_locals and exec_locals["output"] is not None:
726
748
  outp = exec_locals["output"]
727
749
  context["output"] = outp
728
750
  context[step_name] = outp
729
751
 
730
- if messages is not None:
731
- messages.append({
732
- 'role':'assistant',
733
- 'content': (
734
- f'Jinx {self.jinx_name} step {step_name} '
735
- f'executed: {outp}'
736
- )
737
- })
738
- context['messages'] = messages
752
+ # Append to messages if we have output
753
+ final_output = context.get("output")
754
+ if final_output is not None and messages is not None:
755
+ messages.append({
756
+ 'role':'assistant',
757
+ 'content': (
758
+ f'Jinx {self.jinx_name} step {step_name} '
759
+ f'executed: {final_output}'
760
+ )
761
+ })
762
+ context['messages'] = messages
739
763
 
740
764
  return context
741
765
 
@@ -925,6 +949,51 @@ def build_jinx_tool_catalog(jinxs: Dict[str, 'Jinx']) -> Dict[str, Dict[str, Any
925
949
  """Helper to build a name->tool_def catalog from a dict of Jinx objects."""
926
950
  return {name: jinx_to_tool_def(jinx_obj) for name, jinx_obj in jinxs.items()}
927
951
 
952
+ def match_jinx_spec_to_names(jinx_spec: str, team_jinxs_dict: Dict[str, 'Jinx'], jinxs_base_dir: str) -> List[str]:
953
+ """
954
+ Match a jinx spec pattern to actual jinx names from the team's jinxs_dict.
955
+
956
+ Args:
957
+ jinx_spec: A spec like 'lib/core/python', 'lib/computer_use/*', or just 'python'
958
+ team_jinxs_dict: Dict mapping jinx_name -> Jinx object
959
+ jinxs_base_dir: Base directory where team jinxs are stored (e.g., '/path/to/npc_team/jinxs')
960
+
961
+ Returns:
962
+ List of jinx names that match the spec
963
+ """
964
+ matched_names = []
965
+
966
+ # First, check if it's a direct jinx name match
967
+ if jinx_spec in team_jinxs_dict:
968
+ return [jinx_spec]
969
+
970
+ # Normalize the spec (add .jinx extension if not present, for path matching)
971
+ spec_pattern = jinx_spec
972
+ if not spec_pattern.endswith('.jinx') and not spec_pattern.endswith('*'):
973
+ spec_pattern += '.jinx'
974
+
975
+ # Handle glob patterns
976
+ for jinx_name, jinx_obj in team_jinxs_dict.items():
977
+ source_path = getattr(jinx_obj, '_source_path', None)
978
+ if not source_path:
979
+ continue
980
+
981
+ # Get relative path from jinxs base directory
982
+ try:
983
+ rel_path = os.path.relpath(source_path, jinxs_base_dir)
984
+ except ValueError:
985
+ # Can happen on Windows with different drives
986
+ continue
987
+
988
+ # Match using fnmatch for glob support
989
+ if fnmatch.fnmatch(rel_path, spec_pattern):
990
+ matched_names.append(jinx_name)
991
+ # Also try matching without .jinx for patterns like lib/core/python
992
+ elif fnmatch.fnmatch(rel_path, spec_pattern.replace('.jinx', '') + '.jinx'):
993
+ matched_names.append(jinx_name)
994
+
995
+ return matched_names
996
+
928
997
  def extract_jinx_inputs(args: List[str], jinx: Jinx) -> Dict[str, Any]:
929
998
  print(f"DEBUG extract_jinx_inputs called with args: {args}")
930
999
  print(f"DEBUG jinx.inputs: {jinx.inputs}")
@@ -1055,7 +1124,11 @@ class NPC:
1055
1124
  db_conn: Database connection
1056
1125
  """
1057
1126
  if not file and not name and not primary_directive:
1058
- raise ValueError("Either 'file' or 'name' and 'primary_directive' must be provided")
1127
+ raise ValueError("Either 'file' or 'name' and 'primary_directive' must be provided")
1128
+
1129
+ # Set team reference early so _load_from_file can use it for inheritance
1130
+ self.team = team
1131
+
1059
1132
  if file:
1060
1133
  if file.endswith(".npc"):
1061
1134
  self._load_from_file(file)
@@ -1076,7 +1149,6 @@ class NPC:
1076
1149
  self.jinxs_directory = None
1077
1150
  self.npc_directory = None
1078
1151
 
1079
- self.team = team # Store the team reference (can be None)
1080
1152
  # Only set jinxs_spec from parameter if it wasn't already set by _load_from_file
1081
1153
  if not hasattr(self, 'jinxs_spec') or jinxs is not None:
1082
1154
  self.jinxs_spec = jinxs or "*" # Store the jinx specification for later loading
@@ -1188,12 +1260,39 @@ class NPC:
1188
1260
  if self.team and hasattr(self.team, 'jinxs_dict') and self.team.jinxs_dict:
1189
1261
  self.jinxs_dict.update(self.team.jinxs_dict)
1190
1262
  else: # If specific jinxs are requested, try to get them from team
1191
- for jinx_name in self.jinxs_spec:
1192
- if self.team and jinx_name in self.team.jinxs_dict:
1193
- self.jinxs_dict[jinx_name] = self.team.jinxs_dict[jinx_name]
1194
-
1195
- # Load NPC's own jinxs (if not already covered by team or if specific ones are requested)
1263
+ if self.team and hasattr(self.team, 'jinxs_dict') and self.team.jinxs_dict:
1264
+ # Determine the jinxs base directory for path matching
1265
+ jinxs_base_dir = None
1266
+ if hasattr(self.team, 'team_path') and self.team.team_path:
1267
+ jinxs_base_dir = os.path.join(self.team.team_path, 'jinxs')
1268
+
1269
+ for jinx_spec in self.jinxs_spec:
1270
+ # Use the helper to match spec patterns (paths, globs) to jinx names
1271
+ if jinxs_base_dir:
1272
+ matched_names = match_jinx_spec_to_names(jinx_spec, self.team.jinxs_dict, jinxs_base_dir)
1273
+ else:
1274
+ # Fallback to direct name match if no base dir
1275
+ matched_names = [jinx_spec] if jinx_spec in self.team.jinxs_dict else []
1276
+
1277
+ for jinx_name in matched_names:
1278
+ if jinx_name in self.team.jinxs_dict:
1279
+ self.jinxs_dict[jinx_name] = self.team.jinxs_dict[jinx_name]
1280
+
1281
+ # Load NPC's own jinxs ONLY if:
1282
+ # 1. The NPC has no team (standalone NPC), OR
1283
+ # 2. The NPC's jinxs directory is different from the team's jinxs directory
1284
+ # This prevents team NPCs with specific jinxs_spec from loading all team jinxs
1285
+ should_load_from_directory = False
1196
1286
  if hasattr(self, 'npc_jinxs_directory') and self.npc_jinxs_directory and os.path.exists(self.npc_jinxs_directory):
1287
+ if not self.team:
1288
+ should_load_from_directory = True
1289
+ elif hasattr(self.team, 'team_path') and self.team.team_path:
1290
+ team_jinxs_dir = os.path.join(self.team.team_path, 'jinxs')
1291
+ # Only load if NPC has its own separate jinxs directory
1292
+ if os.path.normpath(self.npc_jinxs_directory) != os.path.normpath(team_jinxs_dir):
1293
+ should_load_from_directory = True
1294
+
1295
+ if should_load_from_directory:
1197
1296
  for jinx_obj in load_jinxs_from_directory(self.npc_jinxs_directory):
1198
1297
  if jinx_obj.jinx_name not in self.jinxs_dict: # Only add if not already added from team
1199
1298
  npc_jinxs_raw_list.append(jinx_obj)
@@ -1499,10 +1598,23 @@ class NPC:
1499
1598
  else:
1500
1599
  self.jinxs_spec = jinxs_spec
1501
1600
 
1601
+ # Get model/provider from NPC file, or inherit from team
1502
1602
  self.model = npc_data.get("model")
1503
1603
  self.provider = npc_data.get("provider")
1504
1604
  self.api_url = npc_data.get("api_url")
1505
1605
  self.api_key = npc_data.get("api_key")
1606
+
1607
+ # Inherit from team if not set on NPC
1608
+ if self.team:
1609
+ if not self.model and hasattr(self.team, 'model'):
1610
+ self.model = self.team.model
1611
+ if not self.provider and hasattr(self.team, 'provider'):
1612
+ self.provider = self.team.provider
1613
+ if not self.api_url and hasattr(self.team, 'api_url'):
1614
+ self.api_url = self.team.api_url
1615
+ if not self.api_key and hasattr(self.team, 'api_key'):
1616
+ self.api_key = self.team.api_key
1617
+
1506
1618
  self.name = npc_data.get("name", self.name)
1507
1619
 
1508
1620
  self.npc_path = file
@@ -1561,7 +1673,7 @@ class NPC:
1561
1673
  if use_core_tools:
1562
1674
  dynamic_core_tools_list = [
1563
1675
  self.think_step_by_step,
1564
- self.write_code
1676
+ self.write_code,
1565
1677
  ]
1566
1678
 
1567
1679
  if self.command_history:
@@ -1707,7 +1819,39 @@ class NPC:
1707
1819
  response = self.get_llm_response(thinking_prompt, tool_choice = False)
1708
1820
  return response.get('response', 'Unable to process thinking request')
1709
1821
 
1822
+ def write_code(self, task: str, language: str = "python") -> str:
1823
+ """Write code to accomplish a task.
1710
1824
 
1825
+ Args:
1826
+ task: Description of what the code should do
1827
+ language: Programming language to use (default: python)
1828
+
1829
+ Returns:
1830
+ The generated code as a string
1831
+ """
1832
+ code_prompt = f"""Write {language} code to accomplish the following task:
1833
+
1834
+ {task}
1835
+
1836
+ Requirements:
1837
+ - Write clean, well-commented code
1838
+ - Include error handling where appropriate
1839
+ - Make sure the code is complete and runnable
1840
+ - Only output the code, no explanations before or after
1841
+
1842
+ ```{language}
1843
+ """
1844
+
1845
+ response = self.get_llm_response(code_prompt, tool_choice=False)
1846
+ code = response.get('response', '')
1847
+
1848
+ # Clean up the response - extract code if wrapped in markdown
1849
+ if f'```{language}' in code:
1850
+ code = code.split(f'```{language}')[-1]
1851
+ if '```' in code:
1852
+ code = code.split('```')[0]
1853
+
1854
+ return code.strip()
1711
1855
 
1712
1856
 
1713
1857
  def create_planning_state(self, goal: str) -> Dict[str, Any]:
@@ -2348,18 +2492,18 @@ class Team:
2348
2492
  if not os.path.exists(self.team_path):
2349
2493
  raise ValueError(f"Team directory not found: {self.team_path}")
2350
2494
 
2351
- # 1. Load all NPCs first (without initializing their jinxs yet)
2495
+ # 1. Load team context first (model, provider, forenpc name, etc.)
2496
+ self._load_team_context_file()
2497
+
2498
+ # 2. Load all NPCs (they can now inherit team's model/provider)
2352
2499
  for filename in os.listdir(self.team_path):
2353
2500
  if filename.endswith(".npc"):
2354
2501
  npc_path = os.path.join(self.team_path, filename)
2355
2502
  # Pass 'self' to NPC constructor for team reference
2356
2503
  # Do NOT pass jinxs=... here, as it will be initialized later
2357
- npc = NPC(npc_path, db_conn=self.db_conn, team=self)
2504
+ npc = NPC(npc_path, db_conn=self.db_conn, team=self)
2358
2505
  self.npcs[npc.name] = npc
2359
-
2360
- # 2. Load team context and determine forenpc name (string)
2361
- self._load_team_context_file() # This populates self.model, self.provider, self.forenpc_name etc.
2362
-
2506
+
2363
2507
  # 3. Resolve and set self.forenpc (NPC object)
2364
2508
  if self.forenpc_name and self.forenpc_name in self.npcs:
2365
2509
  self.forenpc = self.npcs[self.forenpc_name]
npcpy/npc_sysenv.py CHANGED
@@ -848,17 +848,17 @@ The current date and time are : {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
848
848
  if team_preferences:
849
849
  system_message += f"Team preferences: {team_preferences}\n"
850
850
 
851
- # Add team members
851
+ # Add team members with their directives
852
852
  if hasattr(team, 'npcs') and team.npcs:
853
853
  members = []
854
854
  for name, member in team.npcs.items():
855
855
  if name != npc.name: # Don't list self
856
856
  directive = getattr(member, 'primary_directive', '')
857
- # Get first line or first 100 chars
858
- desc = directive.split('\n')[0][:100] if directive else ''
859
- members.append(f" - {name}: {desc}")
857
+ # Include full directive (up to 500 chars) for better delegation decisions
858
+ desc = directive[:500].strip() if directive else ''
859
+ members.append(f" - @{name}: {desc}")
860
860
  if members:
861
- system_message += "\nTeam members (use delegate tool to assign tasks):\n" + "\n".join(members) + "\n"
861
+ system_message += "\nTeam members available for delegation:\n" + "\n".join(members) + "\n"
862
862
 
863
863
  system_message += """
864
864
  IMPORTANT: