npcpy 1.1.28__py3-none-any.whl → 1.2.32__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.
Files changed (44) hide show
  1. npcpy/data/audio.py +16 -38
  2. npcpy/data/image.py +29 -29
  3. npcpy/data/load.py +4 -3
  4. npcpy/data/text.py +28 -28
  5. npcpy/data/video.py +6 -6
  6. npcpy/data/web.py +49 -21
  7. npcpy/ft/__init__.py +0 -0
  8. npcpy/ft/diff.py +110 -0
  9. npcpy/ft/ge.py +115 -0
  10. npcpy/ft/memory_trainer.py +171 -0
  11. npcpy/ft/model_ensembler.py +357 -0
  12. npcpy/ft/rl.py +360 -0
  13. npcpy/ft/sft.py +248 -0
  14. npcpy/ft/usft.py +128 -0
  15. npcpy/gen/audio_gen.py +24 -0
  16. npcpy/gen/embeddings.py +13 -13
  17. npcpy/gen/image_gen.py +37 -15
  18. npcpy/gen/response.py +287 -111
  19. npcpy/gen/video_gen.py +10 -9
  20. npcpy/llm_funcs.py +447 -79
  21. npcpy/memory/command_history.py +201 -48
  22. npcpy/memory/kg_vis.py +74 -74
  23. npcpy/memory/knowledge_graph.py +482 -115
  24. npcpy/memory/memory_processor.py +81 -0
  25. npcpy/memory/search.py +70 -70
  26. npcpy/mix/debate.py +192 -3
  27. npcpy/npc_compiler.py +1541 -879
  28. npcpy/npc_sysenv.py +250 -78
  29. npcpy/serve.py +1036 -321
  30. npcpy/sql/ai_function_tools.py +257 -0
  31. npcpy/sql/database_ai_adapters.py +186 -0
  32. npcpy/sql/database_ai_functions.py +163 -0
  33. npcpy/sql/model_runner.py +19 -19
  34. npcpy/sql/npcsql.py +706 -507
  35. npcpy/sql/sql_model_compiler.py +156 -0
  36. npcpy/tools.py +20 -20
  37. npcpy/work/plan.py +8 -8
  38. npcpy/work/trigger.py +3 -3
  39. {npcpy-1.1.28.dist-info → npcpy-1.2.32.dist-info}/METADATA +169 -9
  40. npcpy-1.2.32.dist-info/RECORD +54 -0
  41. npcpy-1.1.28.dist-info/RECORD +0 -40
  42. {npcpy-1.1.28.dist-info → npcpy-1.2.32.dist-info}/WHEEL +0 -0
  43. {npcpy-1.1.28.dist-info → npcpy-1.2.32.dist-info}/licenses/LICENSE +0 -0
  44. {npcpy-1.1.28.dist-info → npcpy-1.2.32.dist-info}/top_level.txt +0 -0
npcpy/npc_compiler.py CHANGED
@@ -26,7 +26,7 @@ from npcpy.npc_sysenv import (
26
26
  get_system_message,
27
27
 
28
28
  )
29
- from npcpy.memory.command_history import CommandHistory
29
+ from npcpy.memory.command_history import CommandHistory, generate_message_id
30
30
 
31
31
  class SilentUndefined(Undefined):
32
32
  def _fail_with_undefined_error(self, *args, **kwargs):
@@ -36,6 +36,199 @@ import math
36
36
  from PIL import Image
37
37
 
38
38
 
39
+ def agent_pass_handler(command, extracted_data, **kwargs):
40
+ """Handler for agent pass action"""
41
+ npc = kwargs.get('npc')
42
+ team = kwargs.get('team')
43
+ if not team and npc and hasattr(npc, '_current_team'):
44
+ team = npc._current_team
45
+
46
+
47
+ if not npc or not team:
48
+ return {"messages": kwargs.get('messages', []), "output": f"Error: No NPC ({npc.name if npc else 'None'}) or team ({team.name if team else 'None'}) available for agent pass"}
49
+
50
+ target_npc_name = extracted_data.get('target_npc')
51
+ if not target_npc_name:
52
+ return {"messages": kwargs.get('messages', []), "output": "Error: No target NPC specified"}
53
+
54
+ messages = kwargs.get('messages', [])
55
+
56
+
57
+ pass_count = 0
58
+ recent_passes = []
59
+
60
+ for msg in messages[-10:]:
61
+ if 'NOTE: THIS COMMAND HAS BEEN PASSED FROM' in msg.get('content', ''):
62
+ pass_count += 1
63
+
64
+ if 'PASSED FROM' in msg.get('content', ''):
65
+ content = msg.get('content', '')
66
+ if 'PASSED FROM' in content and 'TO YOU' in content:
67
+ parts = content.split('PASSED FROM')[1].split('TO YOU')[0].strip()
68
+ recent_passes.append(parts)
69
+
70
+
71
+
72
+ target_npc = team.get_npc(target_npc_name)
73
+ if not target_npc:
74
+ available_npcs = list(team.npcs.keys()) if hasattr(team, 'npcs') else []
75
+ return {"messages": kwargs.get('messages', []),
76
+ "output": f"Error: NPC '{target_npc_name}' not found in team. Available: {available_npcs}"}
77
+
78
+
79
+
80
+ result = npc.handle_agent_pass(
81
+ target_npc,
82
+ command,
83
+ messages=kwargs.get('messages'),
84
+ context=kwargs.get('context'),
85
+ shared_context=getattr(team, 'shared_context', None),
86
+ stream=kwargs.get('stream', False),
87
+ team=team
88
+ )
89
+
90
+ return result
91
+
92
+
93
+ def create_or_replace_table(db_path, table_name, data):
94
+ """Creates or replaces a table in the SQLite database"""
95
+ conn = sqlite3.connect(os.path.expanduser(db_path))
96
+ try:
97
+ data.to_sql(table_name, conn, if_exists="replace", index=False)
98
+ print(f"Table '{table_name}' created/replaced successfully.")
99
+ return True
100
+ except Exception as e:
101
+ print(f"Error creating/replacing table '{table_name}': {e}")
102
+ return False
103
+ finally:
104
+ conn.close()
105
+
106
+ def find_file_path(filename, search_dirs, suffix=None):
107
+ """Find a file in multiple directories"""
108
+ if suffix and not filename.endswith(suffix):
109
+ filename += suffix
110
+
111
+ for dir_path in search_dirs:
112
+ file_path = os.path.join(os.path.expanduser(dir_path), filename)
113
+ if os.path.exists(file_path):
114
+ return file_path
115
+
116
+ return None
117
+
118
+
119
+
120
+ def get_log_entries(entity_id, entry_type=None, limit=10, db_path="~/npcsh_history.db"):
121
+ """Get log entries for an NPC or team"""
122
+ db_path = os.path.expanduser(db_path)
123
+ with sqlite3.connect(db_path) as conn:
124
+ query = "SELECT entry_type, content, metadata, timestamp FROM npc_log WHERE entity_id = ?"
125
+ params = [entity_id]
126
+
127
+ if entry_type:
128
+ query += " AND entry_type = ?"
129
+ params.append(entry_type)
130
+
131
+ query += " ORDER BY timestamp DESC LIMIT ?"
132
+ params.append(limit)
133
+
134
+ results = conn.execute(query, params).fetchall()
135
+
136
+ return [
137
+ {
138
+ "entry_type": r[0],
139
+ "content": json.loads(r[1]),
140
+ "metadata": json.loads(r[2]) if r[2] else None,
141
+ "timestamp": r[3]
142
+ }
143
+ for r in results
144
+ ]
145
+
146
+
147
+ def load_yaml_file(file_path):
148
+ """Load a YAML file with error handling"""
149
+ try:
150
+ with open(os.path.expanduser(file_path), 'r') as f:
151
+ return yaml.safe_load(f)
152
+ except Exception as e:
153
+ print(f"Error loading YAML file {file_path}: {e}")
154
+ return None
155
+
156
+ def log_entry(entity_id, entry_type, content, metadata=None, db_path="~/npcsh_history.db"):
157
+ """Log an entry for an NPC or team"""
158
+ db_path = os.path.expanduser(db_path)
159
+ with sqlite3.connect(db_path) as conn:
160
+ conn.execute(
161
+ "INSERT INTO npc_log (entity_id, entry_type, content, metadata) VALUES (?, ?, ?, ?)",
162
+ (entity_id, entry_type, json.dumps(content), json.dumps(metadata) if metadata else None)
163
+ )
164
+ conn.commit()
165
+
166
+
167
+
168
+ def initialize_npc_project(
169
+ directory=None,
170
+ templates=None,
171
+ context=None,
172
+ model=None,
173
+ provider=None,
174
+ ) -> str:
175
+ """Initialize an NPC project"""
176
+ if directory is None:
177
+ directory = os.getcwd()
178
+
179
+ npc_team_dir = os.path.join(directory, "npc_team")
180
+ os.makedirs(npc_team_dir, exist_ok=True)
181
+
182
+ for subdir in ["jinxs",
183
+ "assembly_lines",
184
+ "sql_models",
185
+ "jobs",
186
+ "triggers"]:
187
+ os.makedirs(os.path.join(npc_team_dir, subdir), exist_ok=True)
188
+
189
+ forenpc_path = os.path.join(npc_team_dir, "forenpc.npc")
190
+
191
+
192
+
193
+ if not os.path.exists(forenpc_path):
194
+
195
+ default_npc = {
196
+ "name": "forenpc",
197
+ "primary_directive": "You are the forenpc of an NPC team",
198
+ }
199
+ with open(forenpc_path, "w") as f:
200
+ yaml.dump(default_npc, f)
201
+ ctx_path = os.path.join(npc_team_dir, "team.ctx")
202
+ if not os.path.exists(ctx_path):
203
+ default_ctx = {
204
+ 'name': '',
205
+ 'context' : '',
206
+ 'preferences': '',
207
+ 'mcp_servers': '',
208
+ 'databases':'',
209
+ 'use_global_jinxs': True,
210
+ 'forenpc': 'forenpc'
211
+ }
212
+ with open(ctx_path, "w") as f:
213
+ yaml.dump(default_ctx, f)
214
+
215
+ return f"NPC project initialized in {npc_team_dir}"
216
+
217
+
218
+
219
+
220
+
221
+ def write_yaml_file(file_path, data):
222
+ """Write data to a YAML file"""
223
+ try:
224
+ with open(os.path.expanduser(file_path), 'w') as f:
225
+ yaml.dump(data, f)
226
+ return True
227
+ except Exception as e:
228
+ print(f"Error writing YAML file {file_path}: {e}")
229
+ return False
230
+
231
+
39
232
  class Jinx:
40
233
  '''
41
234
 
@@ -71,7 +264,6 @@ class Jinx:
71
264
  self.inputs = jinx_data.get("inputs", [])
72
265
  self.description = jinx_data.get("description", "")
73
266
  self.steps = self._parse_steps(jinx_data.get("steps", []))
74
-
75
267
  def _parse_steps(self, steps):
76
268
  """Parse steps from jinx definition"""
77
269
  parsed_steps = []
@@ -82,66 +274,71 @@ class Jinx:
82
274
  "engine": step.get("engine", "natural"),
83
275
  "code": step.get("code", "")
84
276
  }
277
+ if "mode" in step:
278
+ parsed_step["mode"] = step["mode"]
85
279
  parsed_steps.append(parsed_step)
86
280
  else:
87
281
  raise ValueError(f"Invalid step format: {step}")
88
282
  return parsed_steps
89
-
283
+
90
284
  def execute(self,
91
- input_values,
92
- jinxs_dict,
93
- jinja_env = None,
94
- npc = None,
95
- messages=None):
96
- """Execute the jinx with given inputs"""
285
+ input_values: Dict[str, Any],
286
+ jinxs_dict: Dict[str, 'Jinx'],
287
+ jinja_env: Optional[Environment] = None,
288
+ npc: Optional[Any] = None,
289
+ messages: Optional[List[Dict[str, str]]] = None,
290
+ **kwargs: Any):
291
+ """
292
+ Execute the jinx with given inputs.
293
+ **kwargs can be used to pass 'extra_globals' for the python engine.
294
+ """
97
295
  if jinja_env is None:
98
- # For standalone jinx execution, we don't need FileSystemLoader
99
- # Just use a basic environment with no file system dependencies
100
296
  from jinja2 import DictLoader
101
297
  jinja_env = Environment(
102
- loader=DictLoader({}), # Empty dict loader since we're not loading templates from files
298
+ loader=DictLoader({}),
103
299
  undefined=SilentUndefined,
104
300
  )
105
- # Create context with input values and jinxs
106
- context = (npc.shared_context.copy() if npc else {})
301
+
302
+ context = (npc.shared_context.copy() if npc and hasattr(npc, 'shared_context') else {})
107
303
  context.update(input_values)
108
304
  context.update({
109
305
  "jinxs": jinxs_dict,
110
306
  "llm_response": None,
111
- "output": None,
307
+ "output": None,
112
308
  "messages": messages,
113
309
  })
114
310
 
115
- # Process each step in sequence
311
+ # This is the key change: Extract 'extra_globals' from kwargs
312
+ extra_globals = kwargs.get('extra_globals')
313
+
116
314
  for i, step in enumerate(self.steps):
117
315
  context = self._execute_step(
118
- step,
316
+ step,
119
317
  context,
120
- jinja_env,
121
- npc=npc,
122
- messages=messages,
123
-
124
- )
318
+ jinja_env,
319
+ npc=npc,
320
+ messages=messages,
321
+ extra_globals=extra_globals # Pass it down to the step executor
322
+ )
125
323
 
126
324
  return context
127
-
325
+
128
326
  def _execute_step(self,
129
- step,
130
- context,
131
- jinja_env,
132
- npc=None,
133
- messages=None,
134
- ):
135
- """Execute a single step of the jinx"""
327
+ step: Dict[str, Any],
328
+ context: Dict[str, Any],
329
+ jinja_env: Environment,
330
+ npc: Optional[Any] = None,
331
+ messages: Optional[List[Dict[str, str]]] = None,
332
+ extra_globals: Optional[Dict[str, Any]] = None):
333
+ """
334
+ Execute a single step of the jinx.
335
+ """
136
336
  engine = step.get("engine", "natural")
137
337
  code = step.get("code", "")
138
338
  step_name = step.get("name", "unnamed_step")
139
-
140
-
141
-
339
+ mode = step.get("mode", "chat")
142
340
 
143
341
  try:
144
- #print(code)
145
342
  template = jinja_env.from_string(code)
146
343
  rendered_code = template.render(**context)
147
344
 
@@ -153,24 +350,32 @@ class Jinx:
153
350
  rendered_code = code
154
351
  rendered_engine = engine
155
352
 
156
- # Execute based on engine type
157
353
  if rendered_engine == "natural":
158
354
  if rendered_code.strip():
159
- # Handle streaming case
160
- response = npc.get_llm_response(
161
- rendered_code,
162
- context=context,
163
- messages=messages,
164
- )
165
- # print(response)
355
+ if mode == "agent":
356
+ response = npc.get_llm_response(
357
+ rendered_code,
358
+ context=context,
359
+ messages=messages,
360
+ auto_process_tool_calls=True,
361
+ use_core_tools=True
362
+ )
363
+ else:
364
+ response = npc.get_llm_response(
365
+ rendered_code,
366
+ context=context,
367
+ messages=messages,
368
+ )
369
+
166
370
  response_text = response.get("response", "")
167
371
  context['output'] = response_text
168
372
  context["llm_response"] = response_text
169
373
  context["results"] = response_text
170
374
  context[step_name] = response_text
171
375
  context['messages'] = response.get('messages')
376
+
172
377
  elif rendered_engine == "python":
173
- # Setup execution environment
378
+ # Base globals available to all python jinxes, defined within the library (npcpy)
174
379
  exec_globals = {
175
380
  "__builtins__": __builtins__,
176
381
  "npc": npc,
@@ -185,49 +390,57 @@ class Jinx:
185
390
  "fnmatch": fnmatch,
186
391
  "pathlib": pathlib,
187
392
  "subprocess": subprocess,
188
- "get_llm_response": npy.llm_funcs.get_llm_response,
189
-
190
- }
393
+ "get_llm_response": npy.llm_funcs.get_llm_response,
394
+ "CommandHistory": CommandHistory, # This is fine, it's part of npcpy
395
+ }
191
396
 
397
+ # This is the fix: Update the globals with the dictionary passed in from the application (npcsh)
398
+ if extra_globals:
399
+ exec_globals.update(extra_globals)
192
400
 
193
- # Execute the code
194
401
  exec_locals = {}
195
- exec(rendered_code, exec_globals, exec_locals)
196
-
197
- # Update context with results
402
+ try:
403
+ exec(rendered_code, exec_globals, exec_locals)
404
+ except Exception as e:
405
+ # Provide a clear error message in the output if execution fails
406
+ error_msg = f"Error executing jinx python code: {type(e).__name__}: {e}"
407
+ context['output'] = error_msg
408
+ return context
409
+
198
410
  context.update(exec_locals)
199
411
 
200
- # Handle explicit output
201
412
  if "output" in exec_locals:
202
413
  outp = exec_locals["output"]
203
414
  context["output"] = outp
204
415
  context[step_name] = outp
205
- messages.append({'role':'assistant',
206
- 'content': f'Jinx executed with following output: {outp}'})
207
- context['messages'] = messages
416
+ if messages is not None:
417
+ messages.append({'role':'assistant',
418
+ 'content': f'Jinx executed with following output: {outp}'})
419
+ context['messages'] = messages
208
420
 
209
421
  else:
210
- # Handle unknown engine
211
422
  context[step_name] = {"error": f"Unsupported engine: {rendered_engine}"}
212
423
 
213
424
  return context
214
-
215
425
  def to_dict(self):
216
426
  """Convert to dictionary representation"""
427
+ steps_list = []
428
+ for i, step in enumerate(self.steps):
429
+ step_dict = {
430
+ "name": step.get("name", f"step_{i}"),
431
+ "engine": step.get("engine"),
432
+ "code": step.get("code")
433
+ }
434
+ if "mode" in step:
435
+ step_dict["mode"] = step["mode"]
436
+ steps_list.append(step_dict)
437
+
217
438
  return {
218
439
  "jinx_name": self.jinx_name,
219
440
  "description": self.description,
220
441
  "inputs": self.inputs,
221
- "steps": [
222
- {
223
- "name": step.get("name", f"step_{i}"),
224
- "engine": step.get("engine"),
225
- "code": step.get("code")
226
- }
227
- for i, step in enumerate(self.steps)
228
- ]
442
+ "steps": steps_list
229
443
  }
230
-
231
444
  def save(self, directory):
232
445
  """Save jinx to file"""
233
446
  jinx_path = os.path.join(directory, f"{self.jinx_name}.jinx")
@@ -237,19 +450,19 @@ class Jinx:
237
450
  @classmethod
238
451
  def from_mcp(cls, mcp_tool):
239
452
  """Convert an MCP tool to NPC jinx format"""
240
- # Extract function info from MCP tool
453
+
241
454
  try:
242
455
  import inspect
243
456
 
244
- # Get basic info
457
+
245
458
  doc = mcp_tool.__doc__ or ""
246
459
  name = mcp_tool.__name__
247
460
  signature = inspect.signature(mcp_tool)
248
461
 
249
- # Extract inputs from signature
462
+
250
463
  inputs = []
251
464
  for param_name, param in signature.parameters.items():
252
- if param_name != 'self': # Skip self for methods
465
+ if param_name != 'self':
253
466
  param_type = param.annotation if param.annotation != inspect.Parameter.empty else None
254
467
  param_default = None if param.default == inspect.Parameter.empty else param.default
255
468
 
@@ -259,7 +472,7 @@ class Jinx:
259
472
  "default": param_default
260
473
  })
261
474
 
262
- # Create tool data
475
+
263
476
  jinx_data = {
264
477
  "jinx_name": name,
265
478
  "description": doc.strip(),
@@ -269,7 +482,7 @@ class Jinx:
269
482
  "name": "mcp_function_call",
270
483
  "engine": "python",
271
484
  "code": f"""
272
- # Call the MCP function
485
+
273
486
  import {mcp_tool.__module__}
274
487
  output = {mcp_tool.__module__}.{name}(
275
488
  {', '.join([f'{inp["name"]}=context.get("{inp["name"]}")' for inp in inputs])}
@@ -283,135 +496,83 @@ output = {mcp_tool.__module__}.{name}(
283
496
 
284
497
  except:
285
498
  pass
499
+
286
500
  def load_jinxs_from_directory(directory):
287
- """Load all jinxs from a directory"""
501
+ """Load all jinxs from a directory recursively"""
288
502
  jinxs = []
289
503
  directory = os.path.expanduser(directory)
290
504
 
291
505
  if not os.path.exists(directory):
292
506
  return jinxs
293
-
294
- for filename in os.listdir(directory):
295
- if filename.endswith(".jinx"):
296
- try:
297
- jinx_path = os.path.join(directory, filename)
298
- jinx = Jinx(jinx_path=jinx_path)
299
- jinxs.append(jinx)
300
- except Exception as e:
301
- print(f"Error loading jinx {filename}: {e}")
507
+
508
+ for root, dirs, files in os.walk(directory):
509
+ for filename in files:
510
+ if filename.endswith(".jinx"):
511
+ try:
512
+ jinx_path = os.path.join(root, filename)
513
+ jinx = Jinx(jinx_path=jinx_path)
514
+ jinxs.append(jinx)
515
+ except Exception as e:
516
+ print(f"Error loading jinx {filename}: {e}")
302
517
 
303
518
  return jinxs
304
- def agent_pass_handler(command, extracted_data, **kwargs):
305
- """Handler for agent pass action"""
306
- npc = kwargs.get('npc')
307
- team = kwargs.get('team')
308
-
309
- # If team isn't in kwargs, try to get it from the npc's context
310
- if not team and npc and hasattr(npc, '_current_team'):
311
- team = npc._current_team
312
-
313
- print(f"DEBUG agent_pass_handler: npc={npc.name if npc else None}, team={team.name if team else None}")
314
-
315
- if not npc or not team:
316
- return {"messages": kwargs.get('messages', []), "output": f"Error: No NPC ({npc.name if npc else 'None'}) or team ({team.name if team else 'None'}) available for agent pass"}
317
-
318
- target_npc_name = extracted_data.get('target_npc')
319
- if not target_npc_name:
320
- return {"messages": kwargs.get('messages', []), "output": "Error: No target NPC specified"}
321
-
322
- # PREVENT INFINITE LOOPS: Check if we're passing back to the same NPC
323
- messages = kwargs.get('messages', [])
324
-
325
- # Count how many times this has been passed around
326
- pass_count = 0
327
- recent_passes = []
328
-
329
- for msg in messages[-10:]: # Check last 10 messages
330
- if 'NOTE: THIS COMMAND HAS BEEN PASSED FROM' in msg.get('content', ''):
331
- pass_count += 1
332
- # Extract who passed it
333
- if 'PASSED FROM' in msg.get('content', ''):
334
- content = msg.get('content', '')
335
- if 'PASSED FROM' in content and 'TO YOU' in content:
336
- parts = content.split('PASSED FROM')[1].split('TO YOU')[0].strip()
337
- recent_passes.append(parts)
338
-
339
- print(f"DEBUG: Pass count: {pass_count}, Recent passes: {recent_passes}")
340
-
341
- # If we've been passing this around too much, force current NPC to handle it
342
- if pass_count >= 3:
343
- return {
344
- "messages": kwargs.get('messages', []),
345
- "output": f"Task has been passed around {pass_count} times. {npc.name} will handle it directly.\n\n" +
346
- "I'll create a simple document as requested. Here's a basic document structure:\n\n" +
347
- "# Simple Document\n\n" +
348
- "## Introduction\n" +
349
- "This document was created as requested.\n\n" +
350
- "## Content\n" +
351
- "Document content goes here.\n\n" +
352
- "## Conclusion\n" +
353
- "Document completed successfully."
354
- }
355
-
356
- # Check if we're trying to pass to ourselves (immediate loop)
357
- if target_npc_name == npc.name:
358
- return {
359
- "messages": kwargs.get('messages', []),
360
- "output": f"Cannot pass task to myself ({npc.name}). I'll handle this directly.\n\n" +
361
- f"Creating a simple document as requested:\n\n" +
362
- f"# Simple Document\n\n" +
363
- f"This document has been created by {npc.name}.\n" +
364
- f"Content and structure provided as requested."
365
- }
366
-
367
- print(f"DEBUG: Looking for target NPC: {target_npc_name}")
368
-
369
- # Get target NPC from team
370
- target_npc = team.get_npc(target_npc_name)
371
- if not target_npc:
372
- available_npcs = list(team.npcs.keys()) if hasattr(team, 'npcs') else []
373
- return {"messages": kwargs.get('messages', []), "output": f"Error: NPC '{target_npc_name}' not found in team. Available: {available_npcs}"}
374
-
375
- print(f"DEBUG: Found target NPC: {target_npc.name}")
376
-
377
- # Use handle_agent_pass
378
- result = npc.handle_agent_pass(
379
- target_npc,
380
- command,
381
- messages=kwargs.get('messages'),
382
- context=kwargs.get('context'),
383
- shared_context=getattr(team, 'shared_context', None),
384
- stream=kwargs.get('stream', False),
385
- team=team
386
- )
387
-
388
- print(f"DEBUG: Agent pass result: {type(result)}")
389
- return result
390
519
 
391
- # NPC-specific action space that extends the default
392
520
  def get_npc_action_space(npc=None, team=None):
393
- """Get action space for NPC including agent pass if team is available"""
521
+ """Get action space for NPC including memory CRUD and core capabilities"""
394
522
  actions = DEFAULT_ACTION_SPACE.copy()
395
523
 
396
- # Add agent pass action if we have a team
524
+ if npc:
525
+ core_tools = [
526
+ npc.think_step_by_step,
527
+ npc.write_code
528
+ ]
529
+
530
+ if npc.command_history:
531
+ core_tools.extend([
532
+ npc.search_my_conversations,
533
+ npc.search_my_memories,
534
+ npc.create_memory,
535
+ npc.read_memory,
536
+ npc.update_memory,
537
+ npc.delete_memory,
538
+ npc.search_memories,
539
+ npc.get_all_memories,
540
+ npc.archive_old_memories,
541
+ npc.get_memory_stats
542
+ ])
543
+
544
+ if npc.db_conn:
545
+ core_tools.append(npc.query_database)
546
+
547
+ if hasattr(npc, 'tools') and npc.tools:
548
+ core_tools.extend([func for func in npc.tool_map.values() if callable(func)])
549
+
550
+ if core_tools:
551
+ tools_schema, tool_map = auto_tools(core_tools)
552
+ actions.update({
553
+ f"use_{tool.__name__}": {
554
+ "description": f"Use {tool.__name__} capability",
555
+ "handler": tool,
556
+ "context": lambda **_: f"Available as automated capability",
557
+ "output_keys": {"result": {"description": "Tool execution result", "type": "string"}}
558
+ }
559
+ for tool in core_tools
560
+ })
561
+
397
562
  if team and hasattr(team, 'npcs') and len(team.npcs) > 1:
398
563
  available_npcs = [name for name in team.npcs.keys() if name != (npc.name if npc else None)]
399
564
 
400
- # Create a closure that captures the team reference
401
565
  def team_aware_handler(command, extracted_data, **kwargs):
402
- # Inject the team into kwargs if it's missing
403
566
  if 'team' not in kwargs or kwargs['team'] is None:
404
567
  kwargs['team'] = team
405
568
  return agent_pass_handler(command, extracted_data, **kwargs)
406
569
 
407
570
  actions["pass_to_npc"] = {
408
- "description": "Pass the request to another NPC in the team - BUT ONLY if the task truly requires their specific expertise and you cannot handle it yourself",
571
+ "description": "Pass request to another NPC - only when task requires their specific expertise",
409
572
  "handler": team_aware_handler,
410
573
  "context": lambda npc=npc, team=team, **_: (
411
- f"Use this SPARINGLY when the request absolutely requires another team member's expertise. "
412
574
  f"Available NPCs: {', '.join(available_npcs)}. "
413
- f"IMPORTANT: If you can handle the task yourself with your {npc.name if npc else 'current'} skills, DO NOT pass it. "
414
- f"Only pass when you genuinely cannot complete the task due to lack of domain expertise."
575
+ f"Only pass when you genuinely cannot complete the task."
415
576
  ),
416
577
  "output_keys": {
417
578
  "target_npc": {
@@ -422,6 +583,103 @@ def get_npc_action_space(npc=None, team=None):
422
583
  }
423
584
 
424
585
  return actions
586
+ def extract_jinx_inputs(args: List[str], jinx: Jinx) -> Dict[str, Any]:
587
+ print(f"DEBUG extract_jinx_inputs called with args: {args}")
588
+ print(f"DEBUG jinx.inputs: {jinx.inputs}")
589
+
590
+ inputs = {}
591
+
592
+ flag_mapping = {}
593
+ for input_ in jinx.inputs:
594
+ if isinstance(input_, str):
595
+ flag_mapping[f"-{input_[0]}"] = input_
596
+ flag_mapping[f"--{input_}"] = input_
597
+ elif isinstance(input_, dict):
598
+ key = list(input_.keys())[0]
599
+ flag_mapping[f"-{key[0]}"] = key
600
+ flag_mapping[f"--{key}"] = key
601
+
602
+ if len(jinx.inputs) > 1:
603
+ used_args = set()
604
+ for i, arg in enumerate(args):
605
+ if '=' in arg and arg != '=' and not arg.startswith('-'):
606
+ key, value = arg.split('=', 1)
607
+ key = key.strip().strip("'\"")
608
+ value = value.strip().strip("'\"")
609
+ inputs[key] = value
610
+ used_args.add(i)
611
+ else:
612
+ used_args = set()
613
+
614
+ for i, arg in enumerate(args):
615
+ if i in used_args:
616
+ continue
617
+
618
+ if arg in flag_mapping:
619
+ if i + 1 < len(args) and not args[i + 1].startswith('-'):
620
+ input_name = flag_mapping[arg]
621
+ inputs[input_name] = args[i + 1]
622
+ used_args.add(i)
623
+ used_args.add(i + 1)
624
+ else:
625
+ input_name = flag_mapping[arg]
626
+ inputs[input_name] = True
627
+ used_args.add(i)
628
+
629
+ unused_args = [arg for i, arg in enumerate(args) if i not in used_args]
630
+
631
+ print(f"DEBUG unused_args: {unused_args}")
632
+
633
+ # Find first required input (no default value)
634
+ first_required = None
635
+ for input_ in jinx.inputs:
636
+ if isinstance(input_, str):
637
+ first_required = input_
638
+ break
639
+
640
+ print(f"DEBUG first_required: {first_required}")
641
+
642
+ # Give all unused args to first required input
643
+ if first_required and unused_args:
644
+ inputs[first_required] = ' '.join(unused_args).strip()
645
+ print(f"DEBUG assigned to first_required: {inputs[first_required]}")
646
+ else:
647
+ # Fallback to original behavior
648
+ jinx_input_names = []
649
+ for input_ in jinx.inputs:
650
+ if isinstance(input_, str):
651
+ jinx_input_names.append(input_)
652
+ elif isinstance(input_, dict):
653
+ jinx_input_names.append(list(input_.keys())[0])
654
+
655
+ if len(jinx_input_names) == 1:
656
+ inputs[jinx_input_names[0]] = ' '.join(unused_args).strip()
657
+ else:
658
+ for i, arg in enumerate(unused_args):
659
+ if i < len(jinx_input_names):
660
+ input_name = jinx_input_names[i]
661
+ if input_name not in inputs:
662
+ inputs[input_name] = arg
663
+
664
+ for input_ in jinx.inputs:
665
+ if isinstance(input_, str):
666
+ if input_ not in inputs:
667
+ raise ValueError(f"Missing required input: {input_}")
668
+ elif isinstance(input_, dict):
669
+ key = list(input_.keys())[0]
670
+ default_value = input_[key]
671
+ if key not in inputs:
672
+ inputs[key] = default_value
673
+
674
+ print(f"DEBUG final inputs: {inputs}")
675
+ return inputs
676
+ from npcpy.memory.command_history import load_kg_from_db, save_kg_to_db
677
+ from npcpy.memory.knowledge_graph import kg_initial, kg_evolve_incremental, kg_sleep_process, kg_dream_process
678
+ from npcpy.llm_funcs import get_llm_response, breathe
679
+ import os
680
+ from datetime import datetime
681
+ import json
682
+
425
683
  class NPC:
426
684
  def __init__(
427
685
  self,
@@ -438,6 +696,7 @@ class NPC:
438
696
  api_key: str = None,
439
697
  db_conn=None,
440
698
  use_global_jinxs=False,
699
+ memory = False,
441
700
  **kwargs
442
701
  ):
443
702
  """
@@ -468,22 +727,19 @@ class NPC:
468
727
  self.provider = provider
469
728
  self.api_url = api_url
470
729
  self.api_key = api_key
471
- self.team = team
472
- #for these cases
473
- # if npcsh is initialized, use the ~/.npcsh/npc_team
474
- # otherwise imply
730
+
475
731
  if use_global_jinxs:
476
732
  self.jinxs_directory = os.path.expanduser('~/.npcsh/npc_team/jinxs/')
477
733
  else:
478
734
  self.jinxs_directory = None
479
- self.npc_directory = None # only makes sense when the input is also a file
480
- # keep the jinxs tho to enable easieros.path.abspath('./npc_team/')
735
+ self.npc_directory = None
481
736
 
737
+ self.team = team
482
738
  if tools is not None:
483
739
  tools_schema, tool_map = auto_tools(tools)
484
- self.tools = tools_schema # Always store OpenAI-compatible schema here
485
- self.tool_map = tool_map # Store the callable map
486
- self.tools_schema = tools_schema # For backward compatibility if needed
740
+ self.tools = tools_schema
741
+ self.tool_map = tool_map
742
+ self.tools_schema = tools_schema
487
743
  else:
488
744
  self.tools = []
489
745
  self.tool_map = {}
@@ -506,42 +762,271 @@ class NPC:
506
762
  undefined=SilentUndefined,
507
763
  )
508
764
 
509
- # Set up database connection
510
- self.db_conn = db_conn
511
- if self.db_conn:
512
- self._setup_db()
513
- self.command_history = CommandHistory(db=self.db_conn)
514
- self.memory = self._load_npc_memory()
515
- else:
516
- self.command_history = None
517
- self.memory = None
518
- self.tables = None
765
+ self.db_conn = db_conn
766
+
767
+ # these 4 get overwritten if the db conn
768
+ self.command_history = None
769
+ self.kg_data = None
770
+ self.tables = None
771
+ self.memory = None
772
+
773
+ if self.db_conn:
774
+ self._setup_db()
775
+ self.command_history = CommandHistory(db=self.db_conn)
776
+ if memory:
777
+ self.kg_data = self._load_npc_kg()
778
+ self.memory = self.get_memory_context()
779
+
780
+
781
+
782
+ self.jinxs = self._load_npc_jinxs(jinxs or "*")
783
+
784
+ self.shared_context = {
785
+ "dataframes": {},
786
+ "current_data": None,
787
+ "computation_results": [],
788
+ "memories":{}
789
+ }
790
+
791
+ for key, value in kwargs.items():
792
+ setattr(self, key, value)
793
+
794
+ if db_conn is not None:
795
+ init_db_tables()
796
+
797
+ def _load_npc_kg(self):
798
+ """Load knowledge graph data for this NPC from database"""
799
+ if not self.command_history:
800
+ return None
801
+
802
+ directory_path = os.getcwd()
803
+ team_name = getattr(self.team, 'name', 'default_team') if self.team else 'default_team'
804
+
805
+ kg_data = load_kg_from_db(
806
+ engine=self.command_history.engine,
807
+ team_name=team_name,
808
+ npc_name=self.name,
809
+ directory_path=directory_path
810
+ )
811
+ print('# of facts: ', len(kg_data['facts']))
812
+ print('# of facts: ', len(kg_data['concepts']))
813
+
814
+ if not kg_data.get('facts') and not kg_data.get('concepts'):
815
+ return self._initialize_kg_from_history()
816
+
817
+ return kg_data
818
+
819
+ def _initialize_kg_from_history(self):
820
+ """Initialize KG from conversation history if no KG exists"""
821
+ if not self.command_history:
822
+ return None
823
+
824
+ recent_messages = self.command_history.get_messages_by_npc(
825
+ self.name,
826
+ n_last=50
827
+ )
828
+ print(f'Recent messages from NPC: {recent_messages[0:10]}')
829
+
830
+ if not recent_messages:
831
+ return {
832
+ "generation": 0,
833
+ "facts": [],
834
+ "concepts": [],
835
+ "concept_links": [],
836
+ "fact_to_concept_links": {},
837
+ "fact_to_fact_links": []
838
+ }
839
+
840
+ content_text = "\n".join([
841
+ msg['content'] for msg in recent_messages
842
+ if msg['role'] == 'user' and isinstance(msg['content'], str)
843
+ ])
844
+
845
+ if not content_text.strip():
846
+ return {
847
+ "generation": 0,
848
+ "facts": [],
849
+ "concepts": [],
850
+ "concept_links": [],
851
+ "fact_to_concept_links": {},
852
+ "fact_to_fact_links": []
853
+ }
854
+
855
+ kg_data = kg_initial(
856
+ content_text,
857
+ model=self.model,
858
+ provider=self.provider,
859
+ npc=self,
860
+ context=getattr(self, 'shared_context', {})
861
+ )
862
+ self.kg_data = kg_data
863
+ self._save_kg()
864
+ return kg_data
865
+
866
+ def _save_kg(self):
867
+ """Save current KG data to database"""
868
+ if not self.kg_data or not self.command_history:
869
+ return False
870
+
871
+ directory_path = os.getcwd()
872
+ team_name = getattr(self.team, 'name', 'default_team') if self.team else 'default_team'
873
+ save_kg_to_db(
874
+ engine=self.command_history.engine,
875
+ kg_data=self.kg_data,
876
+ team_name=team_name,
877
+ npc_name=self.name,
878
+ directory_path=directory_path
879
+ )
880
+ return True
881
+
882
+ def get_memory_context(self):
883
+ """Get formatted memory context for system prompt"""
884
+ if not self.kg_data:
885
+ return ""
886
+
887
+ context_parts = []
888
+
889
+ recent_facts = self.kg_data.get('facts', [])[-10:]
890
+ if recent_facts:
891
+ context_parts.append("Recent memories:")
892
+ for fact in recent_facts:
893
+ context_parts.append(f"- {fact['statement']}")
894
+
895
+ concepts = self.kg_data.get('concepts', [])
896
+ if concepts:
897
+ concept_names = [c['name'] for c in concepts[:5]]
898
+ context_parts.append(f"Key concepts: {', '.join(concept_names)}")
899
+
900
+ return "\n".join(context_parts)
901
+
902
+ def update_memory(
903
+ self,
904
+ user_input: str,
905
+ assistant_response: str
906
+ ):
907
+ """Update NPC memory from conversation turn using KG evolution"""
908
+ conversation_turn = f"User: {user_input}\nAssistant: {assistant_response}"
909
+
910
+ if not self.kg_data:
911
+ self.kg_data = kg_initial(
912
+ content_text=conversation_turn,
913
+ model=self.model,
914
+ provider=self.provider,
915
+ npc=self
916
+ )
917
+ else:
918
+ self.kg_data, _ = kg_evolve_incremental(
919
+ existing_kg=self.kg_data,
920
+ new_content_text=conversation_turn,
921
+ model=self.model,
922
+ provider=self.provider,
923
+ npc=self,
924
+ get_concepts=True,
925
+ link_concepts_facts=False,
926
+ link_concepts_concepts=False,
927
+ link_facts_facts=False
928
+ )
929
+
930
+ self._save_kg()
931
+
932
+ def enter_tool_use_loop(
933
+ self,
934
+ prompt: str,
935
+ tools: list = None,
936
+ tool_map: dict = None,
937
+ max_iterations: int = 5,
938
+ stream: bool = False
939
+ ):
940
+ """Enter interactive tool use loop for complex tasks"""
941
+ if not tools:
942
+ tools = self.tools
943
+ if not tool_map:
944
+ tool_map = self.tool_map
945
+
946
+ messages = self.memory.copy() if self.memory else []
947
+ messages.append({"role": "user", "content": prompt})
948
+
949
+ for iteration in range(max_iterations):
950
+ response = get_llm_response(
951
+ prompt="",
952
+ model=self.model,
953
+ provider=self.provider,
954
+ npc=self,
955
+ messages=messages,
956
+ tools=tools,
957
+ tool_map=tool_map,
958
+ auto_process_tool_calls=True,
959
+ stream=stream
960
+ )
519
961
 
962
+ messages = response.get('messages', messages)
520
963
 
521
- # Load jinxs
522
- self.jinxs = self._load_npc_jinxs(jinxs or "*")
964
+ if not response.get('tool_calls'):
965
+ return {
966
+ "final_response": response.get('response'),
967
+ "messages": messages,
968
+ "iterations": iteration + 1
969
+ }
970
+
971
+ return {
972
+ "final_response": "Max iterations reached",
973
+ "messages": messages,
974
+ "iterations": max_iterations
975
+ }
976
+
977
+ def get_code_response(
978
+ self,
979
+ prompt: str,
980
+ language: str = "python",
981
+ execute: bool = False,
982
+ locals_dict: dict = None
983
+ ):
984
+ """Generate and optionally execute code responses"""
985
+ code_prompt = f"""Generate {language} code for: {prompt}
523
986
 
524
- # Set up shared context for NPC
525
- self.shared_context = {
526
- "dataframes": {},
527
- "current_data": None,
528
- "computation_results": [],
529
- "memories":{}
987
+ Provide ONLY executable {language} code without explanations.
988
+ Do not include markdown formatting or code blocks.
989
+ Begin directly with the code."""
990
+
991
+ response = get_llm_response(
992
+ prompt=code_prompt,
993
+ model=self.model,
994
+ provider=self.provider,
995
+ npc=self,
996
+ stream=False
997
+ )
998
+
999
+ generated_code = response.get('response', '')
1000
+
1001
+ result = {
1002
+ "code": generated_code,
1003
+ "executed": False,
1004
+ "output": None,
1005
+ "error": None
530
1006
  }
531
1007
 
532
- # Add any additional attributes
533
- for key, value in kwargs.items():
534
- setattr(self, key, value)
1008
+ if execute and language == "python":
1009
+ if locals_dict is None:
1010
+ locals_dict = {}
1011
+
1012
+ exec_globals = {"__builtins__": __builtins__}
1013
+ exec_globals.update(locals_dict)
535
1014
 
536
- if db_conn is not None:
537
- init_db_tables()
1015
+ exec_locals = {}
1016
+ exec(generated_code, exec_globals, exec_locals)
1017
+
1018
+ locals_dict.update(exec_locals)
1019
+ result["executed"] = True
1020
+ result["output"] = exec_locals.get("output", "Code executed successfully")
1021
+
1022
+ return result
1023
+
538
1024
  def _load_npc_memory(self):
1025
+ """Enhanced memory loading that includes KG context"""
539
1026
  memory = self.command_history.get_messages_by_npc(self.name, n_last=self.memory_length)
540
- #import pdb
541
- #pdb.set_trace()
542
1027
  memory = [{'role':mem['role'], 'content':mem['content']} for mem in memory]
543
-
544
1028
  return memory
1029
+
545
1030
  def _load_from_file(self, file):
546
1031
  """Load NPC configuration from file"""
547
1032
  if "~" in file:
@@ -553,19 +1038,15 @@ class NPC:
553
1038
  if not npc_data:
554
1039
  raise ValueError(f"Failed to load NPC from {file}")
555
1040
 
556
- # Extract core fields
557
1041
  self.name = npc_data.get("name")
558
1042
  if not self.name:
559
- # Fall back to filename if name not in file
560
1043
  self.name = os.path.splitext(os.path.basename(file))[0]
561
1044
 
562
1045
  self.primary_directive = npc_data.get("primary_directive")
563
1046
 
564
- # Handle wildcard jinxs specification
565
1047
  jinxs_spec = npc_data.get("jinxs", "*")
566
- #print(jinxs_spec)
1048
+
567
1049
  if jinxs_spec == "*":
568
- # Will be loaded in _load_npc_jinxs
569
1050
  self.jinxs_spec = "*"
570
1051
  else:
571
1052
  self.jinxs_spec = jinxs_spec
@@ -576,137 +1057,574 @@ class NPC:
576
1057
  self.api_key = npc_data.get("api_key")
577
1058
  self.name = npc_data.get("name", self.name)
578
1059
 
579
- # Store path for future reference
580
1060
  self.npc_path = file
581
-
582
- # Set NPC-specific jinxs directory path
583
1061
  self.npc_jinxs_directory = os.path.join(os.path.dirname(file), "jinxs")
1062
+
584
1063
  def get_system_prompt(self, simple=False):
1064
+ """Get system prompt for the NPC"""
585
1065
  if simple or self.plain_system_message:
586
1066
  return self.primary_directive
587
1067
  else:
588
-
589
1068
  return get_system_message(self, team=self.team)
1069
+
590
1070
  def _setup_db(self):
591
1071
  """Set up database tables and determine type"""
592
- try:
1072
+ dialect = self.db_conn.dialect.name
1073
+
1074
+ with self.db_conn.connect() as conn:
1075
+ if dialect == "postgresql":
1076
+ result = conn.execute(text("""
1077
+ SELECT table_name, obj_description((quote_ident(table_name))::regclass, 'pg_class')
1078
+ FROM information_schema.tables
1079
+ WHERE table_schema='public';
1080
+ """))
1081
+ self.tables = result.fetchall()
1082
+ self.db_type = "postgres"
1083
+
1084
+ elif dialect == "sqlite":
1085
+ result = conn.execute(text(
1086
+ "SELECT name, sql FROM sqlite_master WHERE type='table';"
1087
+ ))
1088
+ self.tables = result.fetchall()
1089
+ self.db_type = "sqlite"
593
1090
 
594
- dialect = self.db_conn.dialect.name
595
-
596
- with self.db_conn.connect() as conn:
597
- if dialect == "postgresql":
598
- result = conn.execute(text("""
599
- SELECT table_name, obj_description((quote_ident(table_name))::regclass, 'pg_class')
600
- FROM information_schema.tables
601
- WHERE table_schema='public';
602
- """))
603
- self.tables = result.fetchall()
604
- self.db_type = "postgres"
605
-
606
- elif dialect == "sqlite":
607
- result = conn.execute(text(
608
- "SELECT name, sql FROM sqlite_master WHERE type='table';"
609
- ))
610
- self.tables = result.fetchall()
611
- self.db_type = "sqlite"
612
-
613
- else:
614
- print(f"Unsupported DB dialect: {dialect}")
615
- self.tables = None
616
- self.db_type = None
1091
+ else:
1092
+ print(f"Unsupported DB dialect: {dialect}")
1093
+ self.tables = None
1094
+ self.db_type = None
617
1095
 
618
- except Exception as e:
619
- print(f"Error setting up database: {e}")
620
- self.tables = None
621
- self.db_type = None
622
1096
  def _load_npc_jinxs(self, jinxs):
623
1097
  """Load and process NPC-specific jinxs"""
624
1098
  npc_jinxs = []
625
1099
 
626
- if self.jinxs_directory is None:
627
- self.jinxs_dict = {}
628
- return None
629
- # Handle wildcard case - load all jinxs from the jinxs directory
630
1100
  if jinxs == "*":
631
- #print(f'loading all jinxs for {self.name}')
632
- # Try to find jinxs in NPC-specific jinxs dir first
633
- #print(self.npc_jinxs_directory)
634
- npc_jinxs.extend(load_jinxs_from_directory(self.jinxs_directory))
635
- #print(npc_jinxs)
636
- if os.path.exists(self.jinxs_directory):
637
- npc_jinxs.extend(load_jinxs_from_directory(self.jinxs_directory))
638
- # Return all loaded jinxs
1101
+ if self.team and hasattr(self.team, 'jinxs_dict'):
1102
+ for jinx in self.team.jinxs_dict.values():
1103
+ npc_jinxs.append(jinx)
1104
+ elif self.use_global_jinxs or (hasattr(self, 'jinxs_directory') and self.jinxs_directory):
1105
+ jinxs_dir = self.jinxs_directory or os.path.expanduser('~/.npcsh/npc_team/jinxs/')
1106
+ if os.path.exists(jinxs_dir):
1107
+ npc_jinxs.extend(load_jinxs_from_directory(jinxs_dir))
1108
+
639
1109
  self.jinxs_dict = {jinx.jinx_name: jinx for jinx in npc_jinxs}
640
- #print(npc_jinxs)
641
1110
  return npc_jinxs
642
-
643
1111
 
644
1112
  for jinx in jinxs:
645
- #need to add a block here for mcp jinxs.
646
-
647
1113
  if isinstance(jinx, Jinx):
648
1114
  npc_jinxs.append(jinx)
649
1115
  elif isinstance(jinx, dict):
650
1116
  npc_jinxs.append(Jinx(jinx_data=jinx))
651
-
652
- # Try to load from file
1117
+ elif isinstance(jinx, str):
653
1118
  jinx_path = None
654
1119
  jinx_name = jinx
655
1120
  if not jinx_name.endswith(".jinx"):
656
1121
  jinx_name += ".jinx"
657
1122
 
658
- # Check NPC-specific jinxs directory first
659
- if hasattr(self, 'jinxs_directory') and os.path.exists(self.jinxs_directory):
1123
+ if hasattr(self, 'jinxs_directory') and self.jinxs_directory and os.path.exists(self.jinxs_directory):
660
1124
  candidate_path = os.path.join(self.jinxs_directory, jinx_name)
661
1125
  if os.path.exists(candidate_path):
662
1126
  jinx_path = candidate_path
663
1127
 
664
1128
  if jinx_path:
665
- try:
666
- jinx_obj = Jinx(jinx_path=jinx_path)
667
- npc_jinxs.append(jinx_obj)
668
- except Exception as e:
669
- print(f"Error loading jinx {jinx_path}: {e}")
1129
+ jinx_obj = Jinx(jinx_path=jinx_path)
1130
+ npc_jinxs.append(jinx_obj)
670
1131
 
671
- # Update jinxs dictionary
672
1132
  self.jinxs_dict = {jinx.jinx_name: jinx for jinx in npc_jinxs}
1133
+ print(npc_jinxs)
673
1134
  return npc_jinxs
674
-
675
1135
  def get_llm_response(self,
676
- request,
677
- jinxs= None,
678
- tools=None,
679
- tool_map= None,
680
- tool_choice=None,
681
- messages: Optional[List[Dict[str, str]]] = None,
682
- auto_process_tool_calls: bool = True,
683
- **kwargs):
684
- """Get a response from the LLM"""
685
-
686
- if tools is None:
687
- if self.tools is not None:
688
- tools = self.tools
689
- tool_map = self.tool_map
1136
+ request,
1137
+ jinxs=None,
1138
+ tools: Optional[list] = None,
1139
+ tool_map: Optional[dict] = None,
1140
+ tool_choice=None,
1141
+ messages=None,
1142
+ auto_process_tool_calls=True,
1143
+ use_core_tools: bool = False,
1144
+ **kwargs):
1145
+ all_candidate_functions = []
1146
+
1147
+ if tools is not None and tool_map is not None:
1148
+ all_candidate_functions.extend([func for func in tool_map.values() if callable(func)])
1149
+ elif hasattr(self, 'tool_map') and self.tool_map:
1150
+ all_candidate_functions.extend([func for func in self.tool_map.values() if callable(func)])
1151
+
1152
+ if use_core_tools:
1153
+ dynamic_core_tools_list = [
1154
+ self.think_step_by_step,
1155
+ self.write_code
1156
+ ]
1157
+
1158
+ if self.command_history:
1159
+ dynamic_core_tools_list.extend([
1160
+ self.search_my_conversations,
1161
+ self.search_my_memories,
1162
+ self.create_memory,
1163
+ self.read_memory,
1164
+ self.update_memory,
1165
+ self.delete_memory,
1166
+ self.search_memories,
1167
+ self.get_all_memories,
1168
+ self.archive_old_memories,
1169
+ self.get_memory_stats
1170
+ ])
1171
+
1172
+ if self.db_conn:
1173
+ dynamic_core_tools_list.append(self.query_database)
1174
+
1175
+ all_candidate_functions.extend(dynamic_core_tools_list)
1176
+
1177
+ unique_functions = []
1178
+ seen_names = set()
1179
+ for func in all_candidate_functions:
1180
+ if func.__name__ not in seen_names:
1181
+ unique_functions.append(func)
1182
+ seen_names.add(func.__name__)
1183
+
1184
+ final_tools_schema = None
1185
+ final_tool_map_dict = None
1186
+
1187
+ if unique_functions:
1188
+ final_tools_schema, final_tool_map_dict = auto_tools(unique_functions)
1189
+
1190
+ if tool_choice is None:
1191
+ if final_tools_schema:
1192
+ tool_choice = "auto"
1193
+ else:
1194
+ tool_choice = "none"
690
1195
 
691
1196
  response = npy.llm_funcs.get_llm_response(
692
1197
  request,
693
- model=self.model,
694
- provider=self.provider,
695
1198
  npc=self,
696
1199
  jinxs=jinxs,
697
- tools=tools,
698
- tool_map=tool_map,
1200
+ tools=final_tools_schema,
1201
+ tool_map=final_tool_map_dict,
699
1202
  tool_choice=tool_choice,
700
1203
  auto_process_tool_calls=auto_process_tool_calls,
701
1204
  messages=self.memory if messages is None else messages,
702
1205
  **kwargs
703
1206
  )
704
-
1207
+
705
1208
  return response
706
1209
 
1210
+
1211
+
1212
+ def search_my_conversations(self, query: str, limit: int = 5) -> str:
1213
+ """Search through this NPC's conversation history for relevant information"""
1214
+ if not self.command_history:
1215
+ return "No conversation history available"
1216
+
1217
+ results = self.command_history.search_conversations(query)
1218
+
1219
+ if not results:
1220
+ return f"No conversations found matching '{query}'"
1221
+
1222
+ formatted_results = []
1223
+ for result in results[:limit]:
1224
+ timestamp = result.get('timestamp', 'Unknown time')
1225
+ content = result.get('content', '')[:200] + ('...' if len(result.get('content', '')) > 200 else '')
1226
+ formatted_results.append(f"[{timestamp}] {content}")
1227
+
1228
+ return f"Found {len(results)} conversations matching '{query}':\n" + "\n".join(formatted_results)
1229
+
1230
+ def search_my_memories(self, query: str, limit: int = 10) -> str:
1231
+ """Search through this NPC's knowledge graph memories for relevant facts and concepts"""
1232
+ if not self.kg_data:
1233
+ return "No memories available"
1234
+
1235
+ query_lower = query.lower()
1236
+ relevant_facts = []
1237
+ relevant_concepts = []
1238
+
1239
+ for fact in self.kg_data.get('facts', []):
1240
+ if query_lower in fact.get('statement', '').lower():
1241
+ relevant_facts.append(fact['statement'])
1242
+
1243
+ for concept in self.kg_data.get('concepts', []):
1244
+ if query_lower in concept.get('name', '').lower():
1245
+ relevant_concepts.append(concept['name'])
1246
+
1247
+ result_parts = []
1248
+ if relevant_facts:
1249
+ result_parts.append(f"Relevant memories: {'; '.join(relevant_facts[:limit])}")
1250
+ if relevant_concepts:
1251
+ result_parts.append(f"Related concepts: {', '.join(relevant_concepts[:limit])}")
1252
+
1253
+ return "\n".join(result_parts) if result_parts else f"No memories found matching '{query}'"
1254
+
1255
+ def query_database(self, sql_query: str) -> str:
1256
+ """Execute a SQL query against the available database"""
1257
+ if not self.db_conn:
1258
+ return "No database connection available"
1259
+
1260
+ try:
1261
+ with self.db_conn.connect() as conn:
1262
+ result = conn.execute(text(sql_query))
1263
+ rows = result.fetchall()
1264
+
1265
+ if not rows:
1266
+ return "Query executed successfully but returned no results"
1267
+
1268
+ columns = result.keys()
1269
+ formatted_rows = []
1270
+ for row in rows[:20]:
1271
+ row_dict = dict(zip(columns, row))
1272
+ formatted_rows.append(str(row_dict))
1273
+
1274
+ return f"Query results ({len(rows)} total rows, showing first 20):\n" + "\n".join(formatted_rows)
1275
+
1276
+ except Exception as e:
1277
+ return f"Database query error: {str(e)}"
1278
+
1279
+ def think_step_by_step(self, problem: str) -> str:
1280
+ """Think through a problem step by step using chain of thought reasoning"""
1281
+ thinking_prompt = f"""Think through this problem step by step:
1282
+
1283
+ {problem}
1284
+
1285
+ Break down your reasoning into clear steps:
1286
+ 1. First, I need to understand...
1287
+ 2. Then, I should consider...
1288
+ 3. Next, I need to...
1289
+ 4. Finally, I can conclude...
1290
+
1291
+ Provide your step-by-step analysis.
1292
+ Do not under any circumstances ask for feedback from a user. These thoughts are part of an agentic tool that is letting the agent
1293
+ break down a problem by thinking it through. they will review the results and use them accordingly.
1294
+
1295
+
1296
+ """
1297
+
1298
+ response = self.get_llm_response(thinking_prompt, tool_choice = False)
1299
+ return response.get('response', 'Unable to process thinking request')
1300
+
1301
+ def write_code(self, task_description: str, language: str = "python", show=True) -> str:
1302
+ """Generate and execute code for a specific task, returning the result"""
1303
+ if language.lower() != "python":
1304
+
1305
+ code_prompt = f"""Write {language} code for the following task:
1306
+ {task_description}
1307
+
1308
+ Provide clean, working code with brief explanations for key parts:"""
1309
+
1310
+ response = self.get_llm_response(code_prompt, tool_choice=False )
1311
+ return response.get('response', 'Unable to generate code')
1312
+
1313
+
1314
+ code_prompt = f"""Write Python code for the following task:
1315
+ {task_description}
1316
+
1317
+ Requirements:
1318
+ - Provide executable Python code
1319
+ - Store the final result in a variable called 'output'
1320
+ - Include any necessary imports
1321
+ - Handle errors gracefully
1322
+ - The code should be ready to execute without modification
1323
+
1324
+ Example format:
1325
+ ```python
1326
+ import pandas as pd
1327
+ # Your code here
1328
+ result = some_calculation()
1329
+ output = f"Task completed successfully: {{result}}"
1330
+ """
1331
+ response = self.get_llm_response(code_prompt, tool_choice= False)
1332
+ generated_code = response.get('response', '')
1333
+
1334
+
1335
+ if '```python' in generated_code:
1336
+ code_lines = generated_code.split('\n')
1337
+ start_idx = None
1338
+ end_idx = None
1339
+
1340
+ for i, line in enumerate(code_lines):
1341
+ if '```python' in line:
1342
+ start_idx = i + 1
1343
+ elif '```' in line and start_idx is not None:
1344
+ end_idx = i
1345
+ break
1346
+
1347
+ if start_idx is not None:
1348
+ if end_idx is not None:
1349
+ generated_code = '\n'.join(code_lines[start_idx:end_idx])
1350
+ else:
1351
+ generated_code = '\n'.join(code_lines[start_idx:])
1352
+
1353
+ try:
1354
+
1355
+ exec_globals = {
1356
+ "__builtins__": __builtins__,
1357
+ "npc": self,
1358
+ "context": self.shared_context,
1359
+ "pd": pd,
1360
+ "plt": plt,
1361
+ "np": np,
1362
+ "os": os,
1363
+ "re": re,
1364
+ "json": json,
1365
+ "Path": pathlib.Path,
1366
+ "fnmatch": fnmatch,
1367
+ "pathlib": pathlib,
1368
+ "subprocess": subprocess,
1369
+ "datetime": datetime,
1370
+ "hashlib": hashlib,
1371
+ "sqlite3": sqlite3,
1372
+ "yaml": yaml,
1373
+ "random": random,
1374
+ "math": math,
1375
+ }
1376
+
1377
+ exec_locals = {}
1378
+
1379
+
1380
+ exec(generated_code, exec_globals, exec_locals)
1381
+
1382
+ if show:
1383
+ print('Executing code', generated_code)
1384
+
1385
+
1386
+ if "output" in exec_locals:
1387
+ result = exec_locals["output"]
1388
+
1389
+ self.shared_context.update({k: v for k, v in exec_locals.items()
1390
+ if not k.startswith('_') and not callable(v)})
1391
+ return f"Code executed successfully. Result: {result}"
1392
+ else:
1393
+
1394
+ meaningful_vars = {k: v for k, v in exec_locals.items()
1395
+ if not k.startswith('_') and not callable(v)}
1396
+
1397
+ self.shared_context.update(meaningful_vars)
1398
+
1399
+ if meaningful_vars:
1400
+ last_var = list(meaningful_vars.items())[-1]
1401
+ return f"Code executed successfully. Last result: {last_var[0]} = {last_var[1]}"
1402
+ else:
1403
+ return "Code executed successfully (no explicit output generated)"
1404
+
1405
+ except Exception as e:
1406
+ error_msg = f"Error executing code: {str(e)}\n\nGenerated code was:\n{generated_code}"
1407
+ return error_msg
1408
+
1409
+
1410
+
1411
+ def create_planning_state(self, goal: str) -> Dict[str, Any]:
1412
+ """Create initial planning state for a goal"""
1413
+ return {
1414
+ "goal": goal,
1415
+ "todos": [],
1416
+ "constraints": [],
1417
+ "facts": [],
1418
+ "mistakes": [],
1419
+ "successes": [],
1420
+ "current_todo_index": 0,
1421
+ "current_subtodo_index": 0,
1422
+ "context_summary": ""
1423
+ }
1424
+
1425
+
1426
+ def generate_todos(self, user_goal: str, planning_state: Dict[str, Any], additional_context: str = "") -> List[Dict[str, Any]]:
1427
+ """Generate high-level todos for a goal"""
1428
+ prompt = f"""
1429
+ You are a high-level project planner. Structure tasks logically:
1430
+ 1. Understand current state
1431
+ 2. Make required changes
1432
+ 3. Verify changes work
1433
+
1434
+ User goal: {user_goal}
1435
+ {additional_context}
1436
+
1437
+ Generate 3-5 todos to accomplish this goal. Use specific actionable language.
1438
+ Each todo should be independent where possible and focused on a single component.
1439
+
1440
+ Return JSON:
1441
+ {{
1442
+ "todos": [
1443
+ {{"description": "todo description", "estimated_complexity": "simple|medium|complex"}},
1444
+ ...
1445
+ ]
1446
+ }}
1447
+ """
1448
+
1449
+ response = self.get_llm_response(prompt, format="json", tool_choice=False)
1450
+ todos_data = response.get("response", {}).get("todos", [])
1451
+ return todos_data
1452
+
1453
+ def should_break_down_todo(self, todo: Dict[str, Any]) -> bool:
1454
+ """Ask LLM if a todo needs breakdown"""
1455
+ prompt = f"""
1456
+ Todo: {todo['description']}
1457
+ Complexity: {todo.get('estimated_complexity', 'unknown')}
1458
+
1459
+ Should this be broken into smaller steps? Consider:
1460
+ - Is it complex enough to warrant breakdown?
1461
+ - Would breakdown make execution clearer?
1462
+ - Are there multiple distinct steps?
1463
+
1464
+ Return JSON: {{"should_break_down": true/false, "reason": "explanation"}}
1465
+ """
1466
+
1467
+ response = self.get_llm_response(prompt, format="json", tool_choice=False)
1468
+ result = response.get("response", {})
1469
+ return result.get("should_break_down", False)
1470
+
1471
+ def generate_subtodos(self, todo: Dict[str, Any]) -> List[Dict[str, Any]]:
1472
+ """Generate atomic subtodos for a complex todo"""
1473
+ prompt = f"""
1474
+ Parent todo: {todo['description']}
1475
+
1476
+ Break this into atomic, executable subtodos. Each should be:
1477
+ - A single, concrete action
1478
+ - Executable in one step
1479
+ - Clear and unambiguous
1480
+
1481
+ Return JSON:
1482
+ {{
1483
+ "subtodos": [
1484
+ {{"description": "subtodo description", "type": "action|verification|analysis"}},
1485
+ ...
1486
+ ]
1487
+ }}
1488
+ """
1489
+
1490
+ response = self.get_llm_response(prompt, format="json")
1491
+ return response.get("response", {}).get("subtodos", [])
1492
+
1493
+ def execute_planning_item(self, item: Dict[str, Any], planning_state: Dict[str, Any], context: str = "") -> Dict[str, Any]:
1494
+ """Execute a single planning item (todo or subtodo)"""
1495
+ context_summary = self.get_planning_context_summary(planning_state)
1496
+
1497
+ command = f"""
1498
+ Current context:
1499
+ {context_summary}
1500
+ {context}
1501
+
1502
+ Execute this task: {item['description']}
1503
+
1504
+ Constraints to follow:
1505
+ {chr(10).join([f"- {c}" for c in planning_state.get('constraints', [])])}
1506
+ """
1507
+
1508
+ result = self.check_llm_command(
1509
+ command,
1510
+ context=self.shared_context,
1511
+ stream=False
1512
+ )
1513
+
1514
+ return result
1515
+
1516
+ def get_planning_context_summary(self, planning_state: Dict[str, Any]) -> str:
1517
+ """Get lightweight context for planning prompts"""
1518
+ context = []
1519
+ facts = planning_state.get('facts', [])
1520
+ mistakes = planning_state.get('mistakes', [])
1521
+ successes = planning_state.get('successes', [])
1522
+
1523
+ if facts:
1524
+ context.append(f"Facts: {'; '.join(facts[:5])}")
1525
+ if mistakes:
1526
+ context.append(f"Recent mistakes: {'; '.join(mistakes[-3:])}")
1527
+ if successes:
1528
+ context.append(f"Recent successes: {'; '.join(successes[-3:])}")
1529
+ return "\n".join(context)
1530
+
1531
+
1532
+ def compress_planning_state(self, messages):
1533
+ if isinstance(messages, list):
1534
+ from npcpy.llm_funcs import breathe, get_facts
1535
+
1536
+ conversation_summary = breathe(messages=messages, npc=self)
1537
+ summary_data = conversation_summary.get('output', '')
1538
+
1539
+ conversation_text = "\n".join([msg['content'] for msg in messages])
1540
+ extracted_facts = get_facts(conversation_text, model=self.model, provider=self.provider, npc=self)
1541
+
1542
+ user_inputs = [msg['content'] for msg in messages if msg.get('role') == 'user']
1543
+ assistant_outputs = [msg['content'] for msg in messages if msg.get('role') == 'assistant']
1544
+
1545
+ planning_state = {
1546
+ "goal": summary_data,
1547
+ "facts": [fact['statement'] if isinstance(fact, dict) else str(fact) for fact in extracted_facts[-10:]],
1548
+ "successes": [output[:100] for output in assistant_outputs[-5:]],
1549
+ "mistakes": [],
1550
+ "todos": user_inputs[-3:],
1551
+ "constraints": []
1552
+ }
1553
+ else:
1554
+ planning_state = messages
1555
+
1556
+ todos = planning_state.get('todos', [])
1557
+ current_index = planning_state.get('current_todo_index', 0)
1558
+
1559
+ if todos and current_index < len(todos):
1560
+ current_focus = todos[current_index].get('description', todos[current_index]) if isinstance(todos[current_index], dict) else str(todos[current_index])
1561
+ else:
1562
+ current_focus = 'No current task'
1563
+
1564
+ compressed = {
1565
+ "goal": planning_state.get("goal", ""),
1566
+ "progress": f"{len(planning_state.get('successes', []))}/{len(todos)} todos completed",
1567
+ "context": self.get_planning_context_summary(planning_state),
1568
+ "current_focus": current_focus
1569
+ }
1570
+ return json.dumps(compressed, indent=2)
1571
+
1572
+ def decompress_planning_state(self, compressed_state: str) -> Dict[str, Any]:
1573
+ """Restore planning state from compressed string"""
1574
+ try:
1575
+ data = json.loads(compressed_state)
1576
+ return {
1577
+ "goal": data.get("goal", ""),
1578
+ "todos": [],
1579
+ "constraints": [],
1580
+ "facts": [],
1581
+ "mistakes": [],
1582
+ "successes": [],
1583
+ "current_todo_index": 0,
1584
+ "current_subtodo_index": 0,
1585
+ "compressed_context": data.get("context", "")
1586
+ }
1587
+ except json.JSONDecodeError:
1588
+ return self.create_planning_state("")
1589
+
1590
+ def run_planning_loop(self, user_goal: str, interactive: bool = True) -> Dict[str, Any]:
1591
+ """Run the full planning loop for a goal"""
1592
+ planning_state = self.create_planning_state(user_goal)
1593
+
1594
+ todos = self.generate_todos(user_goal, planning_state)
1595
+ planning_state["todos"] = todos
1596
+
1597
+ for i, todo in enumerate(todos):
1598
+ planning_state["current_todo_index"] = i
1599
+
1600
+ if self.should_break_down_todo(todo):
1601
+ subtodos = self.generate_subtodos(todo)
1602
+
1603
+ for j, subtodo in enumerate(subtodos):
1604
+ planning_state["current_subtodo_index"] = j
1605
+ result = self.execute_planning_item(subtodo, planning_state)
1606
+
1607
+ if result.get("output"):
1608
+ planning_state["successes"].append(f"Completed: {subtodo['description']}")
1609
+ else:
1610
+ planning_state["mistakes"].append(f"Failed: {subtodo['description']}")
1611
+ else:
1612
+ result = self.execute_planning_item(todo, planning_state)
1613
+
1614
+ if result.get("output"):
1615
+ planning_state["successes"].append(f"Completed: {todo['description']}")
1616
+ else:
1617
+ planning_state["mistakes"].append(f"Failed: {todo['description']}")
1618
+
1619
+ return {
1620
+ "planning_state": planning_state,
1621
+ "compressed_state": self.compress_planning_state(planning_state),
1622
+ "summary": f"Completed {len(planning_state['successes'])} tasks for goal: {user_goal}"
1623
+ }
1624
+
707
1625
  def execute_jinx(self, jinx_name, inputs, conversation_id=None, message_id=None, team_name=None):
708
1626
  """Execute a jinx by name"""
709
- # Find the jinx
1627
+
710
1628
  if jinx_name in self.jinxs_dict:
711
1629
  jinx = self.jinxs_dict[jinx_name]
712
1630
  elif jinx_name in self.jinxs_dict:
@@ -734,24 +1652,22 @@ class NPC:
734
1652
  team_name=team_name,
735
1653
  )
736
1654
  return result
1655
+
737
1656
  def check_llm_command(self,
738
- command,
739
- messages=None,
740
- context=None,
741
- team=None,
742
- stream=False):
1657
+ command,
1658
+ messages=None,
1659
+ context=None,
1660
+ team=None,
1661
+ stream=False):
743
1662
  """Check if a command is for the LLM"""
744
1663
  if context is None:
745
1664
  context = self.shared_context
746
1665
 
747
- # Store team reference on NPC for handler access
748
1666
  if team:
749
1667
  self._current_team = team
750
1668
 
751
- # Get NPC-specific action space
752
1669
  actions = get_npc_action_space(npc=self, team=team)
753
1670
 
754
- # Call the LLM command checker with NPC-specific actions
755
1671
  return npy.llm_funcs.check_llm_command(
756
1672
  command,
757
1673
  model=self.model,
@@ -765,13 +1681,13 @@ class NPC:
765
1681
  )
766
1682
 
767
1683
  def handle_agent_pass(self,
768
- npc_to_pass,
769
- command,
770
- messages=None,
771
- context=None,
772
- shared_context=None,
773
- stream=False,
774
- team=None): # Add team parameter
1684
+ npc_to_pass,
1685
+ command,
1686
+ messages=None,
1687
+ context=None,
1688
+ shared_context=None,
1689
+ stream=False,
1690
+ team=None):
775
1691
  """Pass a command to another NPC"""
776
1692
  print('handling agent pass')
777
1693
  if isinstance(npc_to_pass, NPC):
@@ -779,19 +1695,16 @@ class NPC:
779
1695
  else:
780
1696
  return {"error": "Invalid NPC to pass command to"}
781
1697
 
782
- # Update shared context
783
1698
  if shared_context is not None:
784
1699
  self.shared_context.update(shared_context)
785
1700
  target_npc.shared_context.update(shared_context)
786
1701
 
787
- # Add a note that this command was passed from another NPC
788
1702
  updated_command = (
789
1703
  command
790
1704
  + "\n\n"
791
1705
  + f"NOTE: THIS COMMAND HAS BEEN PASSED FROM {self.name} TO YOU, {target_npc.name}.\n"
792
1706
  + "PLEASE CHOOSE ONE OF THE OTHER OPTIONS WHEN RESPONDING."
793
1707
  )
794
-
795
1708
 
796
1709
  result = target_npc.check_llm_command(
797
1710
  updated_command,
@@ -841,15 +1754,195 @@ class NPC:
841
1754
  str_rep += f" - {jinx.jinx_name}\n"
842
1755
  else:
843
1756
  str_rep += "No jinxs available.\n"
844
- return str_rep
845
-
1757
+ return str_rep
1758
+
1759
+
1760
+
1761
+ def execute_jinx_command(self,
1762
+ jinx: Jinx,
1763
+ args: List[str],
1764
+ messages=None,
1765
+ ) -> Dict[str, Any]:
1766
+ """
1767
+ Execute a jinx command with the given arguments.
1768
+ """
1769
+
1770
+ input_values = extract_jinx_inputs(args, jinx)
1771
+
1772
+
1773
+
1774
+
1775
+ jinx_output = jinx.execute(
1776
+ input_values,
1777
+ jinx.jinx_name,
1778
+ npc=self,
1779
+ )
1780
+
1781
+ return {"messages": messages, "output": jinx_output}
1782
+ def create_memory(self, content: str, memory_type: str = "observation") -> Optional[int]:
1783
+ """Create a new memory entry"""
1784
+ if not self.command_history:
1785
+ return None
1786
+
1787
+ message_id = generate_message_id()
1788
+ conversation_id = self.command_history.get_most_recent_conversation_id()
1789
+ conversation_id = conversation_id.get('conversation_id') if conversation_id else 'direct_memory'
1790
+
1791
+ team_name = getattr(self.team, 'name', 'default_team') if self.team else 'default_team'
1792
+ directory_path = os.getcwd()
1793
+
1794
+ return self.command_history.add_memory_to_database(
1795
+ message_id=message_id,
1796
+ conversation_id=conversation_id,
1797
+ npc=self.name,
1798
+ team=team_name,
1799
+ directory_path=directory_path,
1800
+ initial_memory=content,
1801
+ status='active',
1802
+ model=self.model,
1803
+ provider=self.provider
1804
+ )
1805
+
1806
+ def read_memory(self, memory_id: int) -> Optional[Dict[str, Any]]:
1807
+ """Read a specific memory by ID"""
1808
+ if not self.command_history:
1809
+ return None
1810
+
1811
+ stmt = "SELECT * FROM memory_lifecycle WHERE id = :memory_id"
1812
+ return self.command_history._fetch_one(stmt, {"memory_id": memory_id})
1813
+
1814
+ def update_memory(self, memory_id: int, new_content: str = None, status: str = None) -> bool:
1815
+ """Update memory content or status"""
1816
+ if not self.command_history:
1817
+ return False
1818
+
1819
+ updates = []
1820
+ params = {"memory_id": memory_id}
1821
+
1822
+ if new_content is not None:
1823
+ updates.append("final_memory = :final_memory")
1824
+ params["final_memory"] = new_content
1825
+
1826
+ if status is not None:
1827
+ updates.append("status = :status")
1828
+ params["status"] = status
1829
+
1830
+ if not updates:
1831
+ return False
1832
+
1833
+ stmt = f"UPDATE memory_lifecycle SET {', '.join(updates)} WHERE id = :memory_id"
1834
+
1835
+ try:
1836
+ with self.command_history.engine.begin() as conn:
1837
+ conn.execute(text(stmt), params)
1838
+ return True
1839
+ except Exception as e:
1840
+ print(f"Error updating memory {memory_id}: {e}")
1841
+ return False
1842
+
1843
+ def delete_memory(self, memory_id: int) -> bool:
1844
+ """Delete a memory by ID"""
1845
+ if not self.command_history:
1846
+ return False
1847
+
1848
+ stmt = "DELETE FROM memory_lifecycle WHERE id = :memory_id AND npc = :npc"
1849
+
1850
+ try:
1851
+ with self.command_history.engine.begin() as conn:
1852
+ result = conn.execute(text(stmt), {"memory_id": memory_id, "npc": self.name})
1853
+ return result.rowcount > 0
1854
+ except Exception as e:
1855
+ print(f"Error deleting memory {memory_id}: {e}")
1856
+ return False
1857
+
1858
+ def search_memories(self, query: str, limit: int = 10, status_filter: str = None) -> List[Dict[str, Any]]:
1859
+ """Search memories with optional status filtering"""
1860
+ if not self.command_history:
1861
+ return []
1862
+
1863
+ team_name = getattr(self.team, 'name', 'default_team') if self.team else 'default_team'
1864
+ directory_path = os.getcwd()
1865
+
1866
+ return self.command_history.search_memory(
1867
+ query=query,
1868
+ npc=self.name,
1869
+ team=team_name,
1870
+ directory_path=directory_path,
1871
+ status_filter=status_filter,
1872
+ limit=limit
1873
+ )
1874
+
1875
+ def get_all_memories(self, limit: int = 50, status_filter: str = None) -> List[Dict[str, Any]]:
1876
+ """Get all memories for this NPC with optional status filtering"""
1877
+ if not self.command_history:
1878
+ return []
1879
+
1880
+ if limit is None:
1881
+ limit = 50
1882
+
1883
+ conditions = ["npc = :npc"]
1884
+ params = {"npc": self.name, "limit": limit}
1885
+
1886
+ if status_filter:
1887
+ conditions.append("status = :status")
1888
+ params["status"] = status_filter
1889
+
1890
+ stmt = f"""
1891
+ SELECT * FROM memory_lifecycle
1892
+ WHERE {' AND '.join(conditions)}
1893
+ ORDER BY created_at DESC
1894
+ LIMIT :limit
1895
+ """
1896
+
1897
+ return self.command_history._fetch_all(stmt, params)
1898
+
1899
+
1900
+ def archive_old_memories(self, days_old: int = 30) -> int:
1901
+ """Archive memories older than specified days"""
1902
+ if not self.command_history:
1903
+ return 0
1904
+
1905
+ stmt = """
1906
+ UPDATE memory_lifecycle
1907
+ SET status = 'archived'
1908
+ WHERE npc = :npc
1909
+ AND status = 'active'
1910
+ AND datetime(created_at) < datetime('now', '-{} days')
1911
+ """.format(days_old)
1912
+
1913
+ try:
1914
+ with self.command_history.engine.begin() as conn:
1915
+ result = conn.execute(text(stmt), {"npc": self.name})
1916
+ return result.rowcount
1917
+ except Exception as e:
1918
+ print(f"Error archiving memories: {e}")
1919
+ return 0
1920
+
1921
+ def get_memory_stats(self) -> Dict[str, int]:
1922
+ """Get memory statistics for this NPC"""
1923
+ if not self.command_history:
1924
+ return {}
1925
+
1926
+ stmt = """
1927
+ SELECT status, COUNT(*) as count
1928
+ FROM memory_lifecycle
1929
+ WHERE npc = :npc
1930
+ GROUP BY status
1931
+ """
1932
+
1933
+ results = self.command_history._fetch_all(stmt, {"npc": self.name})
1934
+ return {row['status']: row['count'] for row in results}
1935
+
1936
+
846
1937
  class Team:
847
1938
  def __init__(self,
848
- team_path=None,
849
- npcs=None,
850
- forenpc=None,
851
- jinxs=None,
852
- db_conn=None):
1939
+ team_path=None,
1940
+ npcs=None,
1941
+ forenpc=None,
1942
+ jinxs=None,
1943
+ db_conn=None,
1944
+ model = None,
1945
+ provider = None):
853
1946
  """
854
1947
  Initialize an NPC team from directory or list of NPCs
855
1948
 
@@ -858,6 +1951,9 @@ class Team:
858
1951
  npcs: List of NPC objects
859
1952
  db_conn: Database connection
860
1953
  """
1954
+ self.model = model
1955
+ self.provider = provider
1956
+
861
1957
  self.npcs = {}
862
1958
  self.sub_teams = {}
863
1959
  self.jinxs_dict = jinxs or {}
@@ -880,63 +1976,89 @@ class Team:
880
1976
  "dataframes": {},
881
1977
  "memories": {},
882
1978
  "execution_history": [],
883
- "npc_messages": {}
1979
+ "npc_messages": {},
1980
+ "context":''
884
1981
  }
885
1982
 
886
1983
  if team_path:
887
-
888
1984
  self._load_from_directory()
889
1985
 
890
1986
  elif npcs:
891
1987
  for npc in npcs:
892
1988
  self.npcs[npc.name] = npc
893
-
894
1989
 
895
-
896
1990
  self.jinja_env = Environment(undefined=SilentUndefined)
897
1991
 
898
-
899
1992
  if db_conn is not None:
900
1993
  init_db_tables()
1994
+
1995
+ def update_context(self, messages: list):
1996
+ """Update team context based on recent conversation patterns"""
1997
+ if len(messages) < 10:
1998
+ return
901
1999
 
2000
+ summary = breathe(
2001
+ messages=messages[-10:],
2002
+ npc=self.forenpc
2003
+ )
2004
+ characterization = summary.get('output')
902
2005
 
2006
+ if characterization:
2007
+ team_ctx_path = os.path.join(self.team_path, "team.ctx")
2008
+
2009
+ if os.path.exists(team_ctx_path):
2010
+ with open(team_ctx_path, 'r') as f:
2011
+ ctx_data = yaml.safe_load(f) or {}
2012
+ else:
2013
+ ctx_data = {}
2014
+
2015
+ current_context = ctx_data.get('context', '')
2016
+
2017
+ prompt = f"""Based on this characterization: {characterization},
2018
+ suggest changes to the team's context.
2019
+ Current Context: "{current_context}".
2020
+ Respond with JSON: {{"suggestion": "Your sentence."}}"""
2021
+
2022
+ response = get_llm_response(
2023
+ prompt=prompt,
2024
+ npc=self.forenpc,
2025
+ format="json"
2026
+ )
2027
+ suggestion = response.get("response", {}).get("suggestion")
2028
+
2029
+ if suggestion:
2030
+ new_context = (current_context + " " + suggestion).strip()
2031
+ user_approval = input(f"Update context to: {new_context}? [y/N]: ").strip().lower()
2032
+ if user_approval == 'y':
2033
+ ctx_data['context'] = new_context
2034
+ self.context = new_context
2035
+ with open(team_ctx_path, 'w') as f:
2036
+ yaml.dump(ctx_data, f)
2037
+
903
2038
  def _load_from_directory(self):
904
2039
  """Load team from directory"""
905
2040
  if not os.path.exists(self.team_path):
906
2041
  raise ValueError(f"Team directory not found: {self.team_path}")
907
2042
 
908
- # Load team context if available
909
-
910
2043
  for filename in os.listdir(self.team_path):
911
2044
  if filename.endswith(".npc"):
912
- try:
913
- npc_path = os.path.join(self.team_path, filename)
914
- npc = NPC(npc_path, db_conn=self.db_conn)
915
- self.npcs[npc.name] = npc
2045
+ npc_path = os.path.join(self.team_path, filename)
2046
+ npc = NPC(npc_path, db_conn=self.db_conn)
2047
+ self.npcs[npc.name] = npc
916
2048
 
917
- except Exception as e:
918
- print(f"Error loading NPC {filename}: {e}")
919
2049
  self.context = self._load_team_context()
920
-
921
- # Load jinxs from jinxs directory
2050
+
922
2051
  jinxs_dir = os.path.join(self.team_path, "jinxs")
923
2052
  if os.path.exists(jinxs_dir):
924
2053
  for jinx in load_jinxs_from_directory(jinxs_dir):
925
2054
  self.jinxs_dict[jinx.jinx_name] = jinx
926
2055
 
927
- # Load sub-teams (subfolders)
928
2056
  self._load_sub_teams()
929
2057
 
930
-
931
-
932
2058
  def _load_team_context(self):
933
2059
  """Load team context from .ctx file"""
934
-
935
-
936
- #check if any .ctx file exists
937
2060
  for fname in os.listdir(self.team_path):
938
2061
  if fname.endswith('.ctx'):
939
- # do stuff on the file
940
2062
  ctx_data = load_yaml_file(os.path.join(self.team_path, fname))
941
2063
  if ctx_data is not None:
942
2064
  if 'model' in ctx_data:
@@ -964,11 +2086,12 @@ class Team:
964
2086
  self.databases = ctx_data['databases']
965
2087
  else:
966
2088
  self.databases = []
967
- if 'context' in ctx_data:
968
- self.context = ctx_data['context']
969
- else:
970
- self.context = ''
971
-
2089
+
2090
+ base_context = ctx_data.get('context', '')
2091
+ self.shared_context['context'] = base_context
2092
+ if 'file_patterns' in ctx_data:
2093
+ file_cache = self._parse_file_patterns(ctx_data['file_patterns'])
2094
+ self.shared_context['files'] = file_cache
972
2095
  if 'preferences' in ctx_data:
973
2096
  self.preferences = ctx_data['preferences']
974
2097
  else:
@@ -978,7 +2101,7 @@ class Team:
978
2101
  else:
979
2102
  self.forenpc = self.npcs[list(self.npcs.keys())[0]] if self.npcs else None
980
2103
  for key, item in ctx_data.items():
981
- if key not in ['name', 'mcp_servers', 'databases', 'context']:
2104
+ if key not in ['name', 'mcp_servers', 'databases', 'context', 'file_patterns']:
982
2105
  self.shared_context[key] = item
983
2106
  return ctx_data
984
2107
  return {}
@@ -991,14 +2114,10 @@ class Team:
991
2114
  not item.startswith('.') and
992
2115
  item != "jinxs"):
993
2116
 
994
- # Check if directory contains NPCs
995
2117
  if any(f.endswith(".npc") for f in os.listdir(item_path)
996
- if os.path.isfile(os.path.join(item_path, f))):
997
- try:
998
- sub_team = Team(team_path=item_path, db_conn=self.db_conn)
999
- self.sub_teams[item] = sub_team
1000
- except Exception as e:
1001
- print(f"Error loading sub-team {item}: {e}")
2118
+ if os.path.isfile(os.path.join(item_path, f))):
2119
+ sub_team = Team(team_path=item_path, db_conn=self.db_conn)
2120
+ self.sub_teams[item] = sub_team
1002
2121
 
1003
2122
  def get_forenpc(self):
1004
2123
  """
@@ -1011,9 +2130,7 @@ class Team:
1011
2130
  if hasattr(self, 'context') and self.context and 'forenpc' in self.context:
1012
2131
  forenpc_ref = self.context['forenpc']
1013
2132
 
1014
- # Handle Jinja template references
1015
2133
  if '{{ref(' in forenpc_ref:
1016
- # Extract NPC name from {{ref('npc_name')}}
1017
2134
  match = re.search(r"{{\s*ref\('([^']+)'\)\s*}}", forenpc_ref)
1018
2135
  if match:
1019
2136
  forenpc_name = match.group(1)
@@ -1028,7 +2145,7 @@ class Team:
1028
2145
  forenpc_api_url=self.context.get('api_url', None)
1029
2146
 
1030
2147
  forenpc = NPC(name='forenpc',
1031
- primary_directive="""You are the forenpc of the team, coordinating activities
2148
+ primary_directive="""You are the forenpc of the team, coordinating activities
1032
2149
  between NPCs on the team, verifying that results from
1033
2150
  NPCs are high quality and can help to adequately answer
1034
2151
  user requests.""",
@@ -1041,20 +2158,19 @@ class Team:
1041
2158
  self.npcs[forenpc.name] = forenpc
1042
2159
  return forenpc
1043
2160
  return None
2161
+
1044
2162
  def get_npc(self, npc_ref):
1045
2163
  """Get NPC by name or reference with hierarchical lookup capability"""
1046
2164
  if isinstance(npc_ref, NPC):
1047
2165
  return npc_ref
1048
2166
  elif isinstance(npc_ref, str):
1049
- # First check direct NPCs
1050
2167
  if npc_ref in self.npcs:
1051
2168
  return self.npcs[npc_ref]
1052
2169
 
1053
- # Then check sub-teams (hierarchical capability)
1054
2170
  for sub_team_name, sub_team in self.sub_teams.items():
1055
2171
  if npc_ref in sub_team.npcs:
1056
2172
  return sub_team.npcs[npc_ref]
1057
- # Recursive search in sub-teams
2173
+
1058
2174
  result = sub_team.get_npc(npc_ref)
1059
2175
  if result:
1060
2176
  return result
@@ -1069,27 +2185,22 @@ class Team:
1069
2185
  if not forenpc:
1070
2186
  return {"error": "No forenpc available to coordinate the team"}
1071
2187
 
1072
- # Log the orchestration start
1073
2188
  log_entry(
1074
2189
  self.name,
1075
2190
  "orchestration_start",
1076
2191
  {"request": request}
1077
2192
  )
1078
2193
 
1079
- # Initial request goes to forenpc
1080
2194
  result = forenpc.check_llm_command(request,
1081
2195
  context=getattr(self, 'context', {}),
1082
2196
  team = self,
1083
2197
  )
1084
2198
 
1085
- # Track execution until complete
1086
2199
  while True:
1087
- # Save the result
1088
2200
  completion_prompt= ""
1089
2201
  if isinstance(result, dict):
1090
2202
  self.shared_context["execution_history"].append(result)
1091
2203
 
1092
- # Track messages by NPC
1093
2204
  if result.get("messages") and result.get("npc_name"):
1094
2205
  if result["npc_name"] not in self.shared_context["npc_messages"]:
1095
2206
  self.shared_context["npc_messages"][result["npc_name"]] = []
@@ -1134,7 +2245,7 @@ class Team:
1134
2245
  of misunderstanding, but as long as the response is clearly relevant
1135
2246
  to the input request and along the user's intended direction,
1136
2247
  it is considered relevant.
1137
-
2248
+
1138
2249
 
1139
2250
  If there is enough information to begin a fruitful conversation with the user,
1140
2251
  please consider the request relevant so that we do not
@@ -1149,7 +2260,7 @@ class Team:
1149
2260
  -'relevant' with boolean value
1150
2261
  -'explanation' for irrelevance with quoted citations in your explanation noting why it is irrelevant to user input must be a single string.
1151
2262
  Return only the JSON object."""
1152
- # Check if the result is complete
2263
+
1153
2264
  completion_check = npy.llm_funcs.get_llm_response(
1154
2265
  completion_prompt,
1155
2266
  model=forenpc.model,
@@ -1159,19 +2270,15 @@ class Team:
1159
2270
  npc=forenpc,
1160
2271
  format="json"
1161
2272
  )
1162
- # Extract completion status
2273
+
1163
2274
  if isinstance(completion_check.get("response"), dict):
1164
2275
  complete = completion_check["response"].get("relevant", False)
1165
2276
  explanation = completion_check["response"].get("explanation", "")
1166
2277
  else:
1167
- # Default to incomplete if format is wrong
1168
2278
  complete = False
1169
2279
  explanation = "Could not determine completion status"
1170
2280
 
1171
- #import pdb
1172
- #pdb.set_trace()
1173
2281
  if complete:
1174
-
1175
2282
  debrief = npy.llm_funcs.get_llm_response(
1176
2283
  f"""Context:
1177
2284
  Original request: {request}
@@ -1191,14 +2298,12 @@ class Team:
1191
2298
  format="json"
1192
2299
  )
1193
2300
 
1194
-
1195
2301
  return {
1196
2302
  "debrief": debrief.get("response"),
1197
2303
  "output": result.get("output"),
1198
2304
  "execution_history": self.shared_context["execution_history"],
1199
2305
  }
1200
2306
  else:
1201
- # Continue with updated request
1202
2307
  updated_request = (
1203
2308
  request
1204
2309
  + "\n\nThe request has not yet been fully completed. "
@@ -1206,9 +2311,7 @@ class Team:
1206
2311
  + "\nPlease address only the remaining parts of the request."
1207
2312
  )
1208
2313
  print('updating request', updated_request)
1209
-
1210
2314
 
1211
- # Call forenpc again
1212
2315
  result = forenpc.check_llm_command(
1213
2316
  updated_request,
1214
2317
  context=getattr(self, 'context', {}),
@@ -1235,542 +2338,101 @@ class Team:
1235
2338
  if not directory:
1236
2339
  raise ValueError("No directory specified for saving team")
1237
2340
 
1238
- # Create team directory
1239
2341
  ensure_dirs_exist(directory)
1240
2342
 
1241
- # Save context
1242
2343
  if hasattr(self, 'context') and self.context:
1243
2344
  ctx_path = os.path.join(directory, "team.ctx")
1244
2345
  write_yaml_file(ctx_path, self.context)
1245
2346
 
1246
- # Save NPCs
1247
2347
  for npc in self.npcs.values():
1248
2348
  npc.save(directory)
1249
2349
 
1250
- # Create jinxs directory
1251
2350
  jinxs_dir = os.path.join(directory, "jinxs")
1252
2351
  ensure_dirs_exist(jinxs_dir)
1253
2352
 
1254
- # Save jinxs
1255
2353
  for jinx in self.jinxs.values():
1256
2354
  jinx.save(jinxs_dir)
1257
2355
 
1258
- # Save sub-teams
1259
2356
  for team_name, team in self.sub_teams.items():
1260
2357
  team_dir = os.path.join(directory, team_name)
1261
2358
  team.save(team_dir)
1262
2359
 
1263
2360
  return True
1264
-
1265
- class Pipeline:
1266
- def __init__(self, pipeline_data=None, pipeline_path=None, npc_team=None):
1267
- """Initialize a pipeline from data or file path"""
1268
- self.npc_team = npc_team
1269
- self.steps = []
1270
-
1271
- if pipeline_path:
1272
- self._load_from_path(pipeline_path)
1273
- elif pipeline_data:
1274
- self.name = pipeline_data.get("name", "unnamed_pipeline")
1275
- self.steps = pipeline_data.get("steps", [])
1276
- else:
1277
- raise ValueError("Either pipeline_data or pipeline_path must be provided")
1278
-
1279
- def _load_from_path(self, path):
1280
- """Load pipeline from file"""
1281
- pipeline_data = load_yaml_file(path)
1282
- if not pipeline_data:
1283
- raise ValueError(f"Failed to load pipeline from {path}")
1284
-
1285
- self.name = os.path.splitext(os.path.basename(path))[0]
1286
- self.steps = pipeline_data.get("steps", [])
1287
- self.pipeline_path = path
1288
-
1289
- def execute(self, initial_context=None):
1290
- """Execute the pipeline with given context"""
1291
- context = initial_context or {}
1292
- results = {}
1293
-
1294
- # Initialize database tables
1295
- init_db_tables()
1296
-
1297
- # Generate pipeline hash for tracking
1298
- pipeline_hash = self._generate_hash()
1299
-
1300
- # Create results table specific to this pipeline
1301
- results_table = f"{self.name}_results"
1302
- self._ensure_results_table(results_table)
2361
+ def _parse_file_patterns(self, patterns_config):
2362
+ """Parse file patterns configuration and load matching files into KV cache"""
2363
+ if not patterns_config:
2364
+ return {}
1303
2365
 
1304
- # Create run entry
1305
- run_id = self._create_run_entry(pipeline_hash)
2366
+ file_cache = {}
1306
2367
 
1307
- # Add utility functions to context
1308
- context.update({
1309
- "ref": lambda step_name: results.get(step_name),
1310
- "source": self._fetch_data_from_source,
1311
- })
1312
-
1313
- # Execute each step
1314
- for step in self.steps:
1315
- step_name = step.get("step_name")
1316
- if not step_name:
1317
- raise ValueError(f"Missing step_name in step: {step}")
1318
-
1319
- # Get NPC for this step
1320
- npc_name = self._render_template(step.get("npc", ""), context)
1321
- npc = self._get_npc(npc_name)
1322
- if not npc:
1323
- raise ValueError(f"NPC {npc_name} not found for step {step_name}")
1324
-
1325
- # Render task template
1326
- task = self._render_template(step.get("task", ""), context)
1327
-
1328
- # Execute with appropriate NPC
1329
- model = step.get("model", npc.model)
1330
- provider = step.get("provider", npc.provider)
2368
+ for pattern_entry in patterns_config:
2369
+ if isinstance(pattern_entry, str):
2370
+ pattern_entry = {"pattern": pattern_entry}
1331
2371
 
1332
- # Check for special mixa (mixture of agents) mode
1333
- mixa = step.get("mixa", False)
1334
- if mixa:
1335
- response = self._execute_mixa_step(step, context, npc, model, provider)
1336
- else:
1337
- # Check for data source
1338
- source_matches = re.findall(r"{{\s*source\('([^']+)'\)\s*}}", task)
1339
- if source_matches:
1340
- response = self._execute_data_source_step(step, context, source_matches, npc, model, provider)
1341
- else:
1342
- # Standard LLM execution
1343
- llm_response = npy.llm_funcs.get_llm_response(task, model=model, provider=provider, npc=npc)
1344
- response = llm_response.get("response", "")
2372
+ pattern = pattern_entry.get("pattern", "")
2373
+ recursive = pattern_entry.get("recursive", False)
2374
+ base_path = pattern_entry.get("base_path", ".")
1345
2375
 
1346
- # Store result
1347
- results[step_name] = response
1348
- context[step_name] = response
2376
+ if not pattern:
2377
+ continue
2378
+
2379
+ base_path = os.path.expanduser(base_path)
2380
+ if not os.path.isabs(base_path):
2381
+ base_path = os.path.join(self.team_path or os.getcwd(), base_path)
1349
2382
 
1350
- # Save to database
1351
- self._store_step_result(run_id, step_name, npc_name, model, provider,
1352
- {"task": task}, response, results_table)
2383
+ matching_files = self._find_matching_files(pattern, base_path, recursive)
1353
2384
 
1354
- # Return all results
1355
- return {
1356
- "results": results,
1357
- "run_id": run_id
1358
- }
2385
+ for file_path in matching_files:
2386
+ file_content = self._load_file_content(file_path)
2387
+ if file_content:
2388
+ relative_path = os.path.relpath(file_path, base_path)
2389
+ file_cache[relative_path] = file_content
1359
2390
 
1360
- def _render_template(self, template_str, context):
1361
- """Render a template with the given context"""
1362
- if not template_str:
1363
- return ""
1364
-
1365
- try:
1366
- template = Template(template_str)
1367
- return template.render(**context)
1368
- except Exception as e:
1369
- print(f"Error rendering template: {e}")
1370
- return template_str
1371
-
1372
- def _get_npc(self, npc_name):
1373
- """Get NPC by name from team"""
1374
- if not self.npc_team:
1375
- raise ValueError("No NPC team available")
1376
-
1377
- return self.npc_team.get_npc(npc_name)
1378
-
1379
- def _generate_hash(self):
1380
- """Generate a hash for the pipeline"""
1381
- if hasattr(self, 'pipeline_path') and self.pipeline_path:
1382
- with open(self.pipeline_path, 'r') as f:
1383
- content = f.read()
1384
- return hashlib.sha256(content.encode()).hexdigest()
1385
- else:
1386
- # Generate hash from steps
1387
- content = json.dumps(self.steps)
1388
- return hashlib.sha256(content.encode()).hexdigest()
1389
-
1390
- def _ensure_results_table(self, table_name):
1391
- """Ensure results table exists"""
1392
- db_path = "~/npcsh_history.db"
1393
- with sqlite3.connect(os.path.expanduser(db_path)) as conn:
1394
- conn.execute(f"""
1395
- CREATE TABLE IF NOT EXISTS {table_name} (
1396
- result_id INTEGER PRIMARY KEY AUTOINCREMENT,
1397
- run_id INTEGER,
1398
- step_name TEXT,
1399
- npc_name TEXT,
1400
- model TEXT,
1401
- provider TEXT,
1402
- inputs TEXT,
1403
- outputs TEXT,
1404
- timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
1405
- FOREIGN KEY(run_id) REFERENCES pipeline_runs(run_id)
1406
- )
1407
- """)
1408
- conn.commit()
1409
-
1410
- def _create_run_entry(self, pipeline_hash):
1411
- """Create run entry in pipeline_runs table"""
1412
- db_path = "~/npcsh_history.db"
1413
- with sqlite3.connect(os.path.expanduser(db_path)) as conn:
1414
- cursor = conn.execute(
1415
- "INSERT INTO pipeline_runs (pipeline_name, pipeline_hash, timestamp) VALUES (?, ?, ?)",
1416
- (self.name, pipeline_hash, datetime.now())
1417
- )
1418
- conn.commit()
1419
- return cursor.lastrowid
1420
-
1421
- def _store_step_result(self, run_id, step_name, npc_name, model, provider, inputs, outputs, table_name):
1422
- """Store step result in database"""
1423
- db_path = "~/npcsh_history.db"
1424
- with sqlite3.connect(os.path.expanduser(db_path)) as conn:
1425
- conn.execute(
1426
- f"""
1427
- INSERT INTO {table_name}
1428
- (run_id, step_name, npc_name, model, provider, inputs, outputs)
1429
- VALUES (?, ?, ?, ?, ?, ?, ?)
1430
- """,
1431
- (
1432
- run_id,
1433
- step_name,
1434
- npc_name,
1435
- model,
1436
- provider,
1437
- json.dumps(self._clean_for_json(inputs)),
1438
- json.dumps(self._clean_for_json(outputs))
1439
- )
1440
- )
1441
- conn.commit()
1442
-
1443
- def _clean_for_json(self, obj):
1444
- """Clean an object for JSON serialization"""
1445
- if isinstance(obj, dict):
1446
- return {
1447
- k: self._clean_for_json(v)
1448
- for k, v in obj.items()
1449
- if not k.startswith("_") and not callable(v)
1450
- }
1451
- elif isinstance(obj, list):
1452
- return [self._clean_for_json(i) for i in obj]
1453
- elif isinstance(obj, (str, int, float, bool, type(None))):
1454
- return obj
1455
- else:
1456
- return str(obj)
1457
-
1458
- def _fetch_data_from_source(self, table_name):
1459
- """Fetch data from a database table"""
1460
- db_path = "~/npcsh_history.db"
1461
- try:
1462
- engine = create_engine(f"sqlite:///{os.path.expanduser(db_path)}")
1463
- df = pd.read_sql(f"SELECT * FROM {table_name}", engine)
1464
- return df.to_json(orient="records")
1465
- except Exception as e:
1466
- print(f"Error fetching data from {table_name}: {e}")
1467
- return "[]"
1468
-
1469
- def _execute_mixa_step(self, step, context, npc, model, provider):
1470
- """Execute a mixture of agents step"""
1471
- # Get task template
1472
- task = self._render_template(step.get("task", ""), context)
1473
-
1474
- # Get configuration
1475
- mixa_turns = step.get("mixa_turns", 5)
1476
- num_generating_agents = len(step.get("mixa_agents", []))
1477
- if num_generating_agents == 0:
1478
- num_generating_agents = 3 # Default
1479
-
1480
- num_voting_agents = len(step.get("mixa_voters", []))
1481
- if num_voting_agents == 0:
1482
- num_voting_agents = 3 # Default
1483
-
1484
- # Step 1: Initial Response Generation
1485
- round_responses = []
1486
- for _ in range(num_generating_agents):
1487
- response = npy.llm_funcs.get_llm_response(task, model=model, provider=provider, npc=npc)
1488
- round_responses.append(response.get("response", ""))
1489
-
1490
- # Loop for each round of voting and refining
1491
- for turn in range(1, mixa_turns + 1):
1492
- # Step 2: Voting by agents
1493
- votes = [0] * len(round_responses)
1494
- for _ in range(num_voting_agents):
1495
- voted_index = random.choice(range(len(round_responses)))
1496
- votes[voted_index] += 1
1497
-
1498
- # Step 3: Refinement feedback
1499
- refined_responses = []
1500
- for i, resp in enumerate(round_responses):
1501
- feedback = (
1502
- f"Current responses and their votes:\n" +
1503
- "\n".join([f"Response {j+1}: {r[:100]}... - Votes: {votes[j]}"
1504
- for j, r in enumerate(round_responses)]) +
1505
- f"\n\nRefine your response #{i+1}: {resp}"
1506
- )
1507
-
1508
- response = npy.llm_funcs.get_llm_response(feedback, model=model, provider=provider, npc=npc)
1509
- refined_responses.append(response.get("response", ""))
1510
-
1511
- # Update responses for next round
1512
- round_responses = refined_responses
1513
-
1514
- # Final synthesis
1515
- synthesis_prompt = (
1516
- "Synthesize these responses into a coherent answer:\n" +
1517
- "\n".join(round_responses)
1518
- )
1519
- final_response = npy.llm_funcs.get_llm_response(synthesis_prompt, model=model, provider=provider, npc=npc)
2391
+ return file_cache
2392
+
2393
+ def _find_matching_files(self, pattern, base_path, recursive=False):
2394
+ """Find files matching the given pattern"""
2395
+ matching_files = []
1520
2396
 
1521
- return final_response.get("response", "")
2397
+ if not os.path.exists(base_path):
2398
+ return matching_files
1522
2399
 
1523
- def _execute_data_source_step(self, step, context, source_matches, npc, model, provider):
1524
- """Execute a step with data source"""
1525
- task_template = step.get("task", "")
1526
- table_name = source_matches[0]
2400
+ if recursive:
2401
+ for root, dirs, files in os.walk(base_path):
2402
+ for filename in files:
2403
+ if fnmatch.fnmatch(filename, pattern):
2404
+ matching_files.append(os.path.join(root, filename))
2405
+ else:
2406
+ try:
2407
+ for item in os.listdir(base_path):
2408
+ item_path = os.path.join(base_path, item)
2409
+ if os.path.isfile(item_path) and fnmatch.fnmatch(item, pattern):
2410
+ matching_files.append(item_path)
2411
+ except PermissionError:
2412
+ print(f"Permission denied accessing {base_path}")
1527
2413
 
2414
+ return matching_files
2415
+
2416
+ def _load_file_content(self, file_path):
2417
+ """Load content from a file with error handling"""
1528
2418
  try:
1529
- # Fetch data
1530
- db_path = "~/npcsh_history.db"
1531
- engine = create_engine(f"sqlite:///{os.path.expanduser(db_path)}")
1532
- df = pd.read_sql(f"SELECT * FROM {table_name}", engine)
1533
-
1534
- # Handle batch mode vs. individual processing
1535
- if step.get("batch_mode", False):
1536
- # Replace source reference with all data
1537
- data_str = df.to_json(orient="records")
1538
- task = task_template.replace(f"{{{{ source('{table_name}') }}}}", data_str)
1539
- task = self._render_template(task, context)
1540
-
1541
- # Process all at once
1542
- response = npy.llm_funcs.get_llm_response(task, model=model, provider=provider, npc=npc)
1543
- return response.get("response", "")
1544
- else:
1545
- # Process each row individually
1546
- results = []
1547
- for _, row in df.iterrows():
1548
- # Replace source reference with row data
1549
- row_data = json.dumps(row.to_dict())
1550
- row_task = task_template.replace(f"{{{{ source('{table_name}') }}}}", row_data)
1551
- row_task = self._render_template(row_task, context)
1552
-
1553
- # Process row
1554
- response = npy.llm_funcs.get_llm_response(row_task, model=model, provider=provider, npc=npc)
1555
- results.append(response.get("response", ""))
1556
-
1557
- return results
2419
+ with open(file_path, 'r', encoding='utf-8') as f:
2420
+ return f.read()
1558
2421
  except Exception as e:
1559
- print(f"Error processing data source {table_name}: {e}")
1560
- return f"Error: {str(e)}"
1561
-
1562
-
2422
+ print(f"Error reading {file_path}: {e}")
2423
+ return None
1563
2424
 
1564
- def log_entry(entity_id, entry_type, content, metadata=None, db_path="~/npcsh_history.db"):
1565
- """Log an entry for an NPC or team"""
1566
- db_path = os.path.expanduser(db_path)
1567
- with sqlite3.connect(db_path) as conn:
1568
- conn.execute(
1569
- "INSERT INTO npc_log (entity_id, entry_type, content, metadata) VALUES (?, ?, ?, ?)",
1570
- (entity_id, entry_type, json.dumps(content), json.dumps(metadata) if metadata else None)
1571
- )
1572
- conn.commit()
1573
2425
 
1574
- def get_log_entries(entity_id, entry_type=None, limit=10, db_path="~/npcsh_history.db"):
1575
- """Get log entries for an NPC or team"""
1576
- db_path = os.path.expanduser(db_path)
1577
- with sqlite3.connect(db_path) as conn:
1578
- query = "SELECT entry_type, content, metadata, timestamp FROM npc_log WHERE entity_id = ?"
1579
- params = [entity_id]
1580
-
1581
- if entry_type:
1582
- query += " AND entry_type = ?"
1583
- params.append(entry_type)
1584
-
1585
- query += " ORDER BY timestamp DESC LIMIT ?"
1586
- params.append(limit)
2426
+ def _format_parsed_files_context(self, parsed_files):
2427
+ """Format parsed files into context string"""
2428
+ if not parsed_files:
2429
+ return ""
1587
2430
 
1588
- results = conn.execute(query, params).fetchall()
2431
+ context_parts = ["Additional context from files:"]
1589
2432
 
1590
- return [
1591
- {
1592
- "entry_type": r[0],
1593
- "content": json.loads(r[1]),
1594
- "metadata": json.loads(r[2]) if r[2] else None,
1595
- "timestamp": r[3]
1596
- }
1597
- for r in results
1598
- ]
1599
-
1600
-
1601
- def load_yaml_file(file_path):
1602
- """Load a YAML file with error handling"""
1603
- try:
1604
- with open(os.path.expanduser(file_path), 'r') as f:
1605
- return yaml.safe_load(f)
1606
- except Exception as e:
1607
- print(f"Error loading YAML file {file_path}: {e}")
1608
- return None
1609
-
1610
- def write_yaml_file(file_path, data):
1611
- """Write data to a YAML file"""
1612
- try:
1613
- with open(os.path.expanduser(file_path), 'w') as f:
1614
- yaml.dump(data, f)
1615
- return True
1616
- except Exception as e:
1617
- print(f"Error writing YAML file {file_path}: {e}")
1618
- return False
1619
-
1620
- def create_or_replace_table(db_path, table_name, data):
1621
- """Creates or replaces a table in the SQLite database"""
1622
- conn = sqlite3.connect(os.path.expanduser(db_path))
1623
- try:
1624
- data.to_sql(table_name, conn, if_exists="replace", index=False)
1625
- print(f"Table '{table_name}' created/replaced successfully.")
1626
- return True
1627
- except Exception as e:
1628
- print(f"Error creating/replacing table '{table_name}': {e}")
1629
- return False
1630
- finally:
1631
- conn.close()
1632
-
1633
- def find_file_path(filename, search_dirs, suffix=None):
1634
- """Find a file in multiple directories"""
1635
- if suffix and not filename.endswith(suffix):
1636
- filename += suffix
2433
+ for file_path, content in parsed_files.items():
2434
+ context_parts.append(f"\n--- {file_path} ---")
2435
+ context_parts.append(content)
2436
+ context_parts.append("")
1637
2437
 
1638
- for dir_path in search_dirs:
1639
- file_path = os.path.join(os.path.expanduser(dir_path), filename)
1640
- if os.path.exists(file_path):
1641
- return file_path
1642
-
1643
- return None
1644
-
1645
-
1646
-
1647
- def initialize_npc_project(
1648
- directory=None,
1649
- templates=None,
1650
- context=None,
1651
- model=None,
1652
- provider=None,
1653
- ) -> str:
1654
- """Initialize an NPC project"""
1655
- if directory is None:
1656
- directory = os.getcwd()
1657
-
1658
- npc_team_dir = os.path.join(directory, "npc_team")
1659
- os.makedirs(npc_team_dir, exist_ok=True)
1660
-
1661
- for subdir in ["jinxs",
1662
- "assembly_lines",
1663
- "sql_models",
1664
- "jobs",
1665
- "triggers"]:
1666
- os.makedirs(os.path.join(npc_team_dir, subdir), exist_ok=True)
1667
-
1668
- forenpc_path = os.path.join(npc_team_dir, "forenpc.npc")
1669
-
1670
-
1671
- # Always ensure default NPC exists
1672
- if not os.path.exists(forenpc_path):
1673
- # Use your new NPC class to create sibiji
1674
- default_npc = {
1675
- "name": "forenpc",
1676
- "primary_directive": "You are the forenpc of an NPC team",
1677
- }
1678
- with open(forenpc_path, "w") as f:
1679
- yaml.dump(default_npc, f)
1680
- ctx_path = os.path.join(npc_team_dir, "team.ctx")
1681
- if not os.path.exists(ctx_path):
1682
- default_ctx = {
1683
- 'name': '',
1684
- 'context' : '',
1685
- 'preferences': '',
1686
- 'mcp_servers': '',
1687
- 'databases':'',
1688
- 'use_global_jinxs': True,
1689
- 'forenpc': 'forenpc'
1690
- }
1691
- with open(ctx_path, "w") as f:
1692
- yaml.dump(default_ctx, f)
1693
-
1694
- return f"NPC project initialized in {npc_team_dir}"
1695
-
1696
-
1697
-
1698
-
1699
-
1700
- def execute_jinx_command(
1701
- jinx: Jinx,
1702
- args: List[str],
1703
- messages=None,
1704
- npc: NPC = None,
1705
- ) -> Dict[str, Any]:
1706
- """
1707
- Execute a jinx command with the given arguments.
1708
- """
1709
- # Extract inputs for the current jinx
1710
- input_values = extract_jinx_inputs(args, jinx)
1711
-
1712
- # print(f"Input values: {input_values}")
1713
- # Execute the jinx with the extracted inputs
1714
-
1715
- jinx_output = jinx.execute(
1716
- input_values,
1717
- jinx.jinx_name,
1718
- npc=npc,
1719
- )
1720
-
1721
- return {"messages": messages, "output": jinx_output}
1722
-
1723
-
1724
-
1725
- def extract_jinx_inputs(args: List[str], jinx: Jinx) -> Dict[str, Any]:
1726
- inputs = {}
1727
-
1728
- # Create flag mapping
1729
- flag_mapping = {}
1730
- for input_ in jinx.inputs:
1731
- if isinstance(input_, str):
1732
- flag_mapping[f"-{input_[0]}"] = input_
1733
- flag_mapping[f"--{input_}"] = input_
1734
- elif isinstance(input_, dict):
1735
- key = list(input_.keys())[0]
1736
- flag_mapping[f"-{key[0]}"] = key
1737
- flag_mapping[f"--{key}"] = key
1738
-
1739
- # Process arguments
1740
- used_args = set()
1741
- for i, arg in enumerate(args):
1742
- if arg in flag_mapping:
1743
- # If flag is found, next argument is its value
1744
- if i + 1 < len(args):
1745
- input_name = flag_mapping[arg]
1746
- inputs[input_name] = args[i + 1]
1747
- used_args.add(i)
1748
- used_args.add(i + 1)
1749
- else:
1750
- print(f"Warning: {arg} flag is missing a value.")
1751
-
1752
- # If no flags used, combine remaining args for first input
1753
- unused_args = [arg for i, arg in enumerate(args) if i not in used_args]
1754
- if unused_args and jinx.inputs:
1755
- first_input = jinx.inputs[0]
1756
- if isinstance(first_input, str):
1757
- inputs[first_input] = " ".join(unused_args)
1758
- elif isinstance(first_input, dict):
1759
- key = list(first_input.keys())[0]
1760
- inputs[key] = " ".join(unused_args)
1761
-
1762
- # Add default values for inputs not provided
1763
- for input_ in jinx.inputs:
1764
- if isinstance(input_, str):
1765
- if input_ not in inputs:
1766
- if any(args): # If we have any arguments at all
1767
- raise ValueError(f"Missing required input: {input_}")
1768
- else:
1769
- inputs[input_] = None # Allow None for completely empty calls
1770
- elif isinstance(input_, dict):
1771
- key = list(input_.keys())[0]
1772
- if key not in inputs:
1773
- inputs[key] = input_[key]
1774
-
1775
- return inputs
1776
-
2438
+ return "\n".join(context_parts)