npcpy 1.2.34__py3-none-any.whl → 1.2.36__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
npcpy/serve.py CHANGED
@@ -8,6 +8,10 @@ import sys
8
8
  import traceback
9
9
  import glob
10
10
  import re
11
+ import time
12
+ import asyncio
13
+ from typing import Optional, List, Dict, Callable, Any
14
+ from contextlib import AsyncExitStack
11
15
 
12
16
  import io
13
17
  from flask_cors import CORS
@@ -17,6 +21,8 @@ import json
17
21
  from pathlib import Path
18
22
  import yaml
19
23
  from dotenv import load_dotenv
24
+ from mcp import ClientSession, StdioServerParameters
25
+ from mcp.client.stdio import stdio_client
20
26
 
21
27
  from PIL import Image
22
28
  from PIL import ImageFile
@@ -43,13 +49,14 @@ from npcpy.memory.knowledge_graph import load_kg_from_db
43
49
  from npcpy.memory.search import execute_rag_command, execute_brainblast_command
44
50
  from npcpy.data.load import load_file_contents
45
51
  from npcpy.data.web import search_web
52
+
46
53
  from npcsh._state import get_relevant_memories, search_kg_facts
47
54
 
48
55
  import base64
49
56
  import shutil
50
57
  import uuid
51
58
 
52
- from npcpy.llm_funcs import gen_image
59
+ from npcpy.llm_funcs import gen_image, breathe
53
60
 
54
61
  from sqlalchemy import create_engine, text
55
62
  from sqlalchemy.orm import sessionmaker
@@ -60,14 +67,12 @@ from npcpy.memory.command_history import (
60
67
  save_conversation_message,
61
68
  generate_message_id,
62
69
  )
63
- from npcpy.npc_compiler import Jinx, NPC, Team
70
+ from npcpy.npc_compiler import Jinx, NPC, Team, load_jinxs_from_directory, build_jinx_tool_catalog
64
71
 
65
72
  from npcpy.llm_funcs import (
66
73
  get_llm_response, check_llm_command
67
74
  )
68
- from npcpy.npc_compiler import NPC
69
- import base64
70
-
75
+ from termcolor import cprint
71
76
  from npcpy.tools import auto_tools
72
77
 
73
78
  import json
@@ -84,6 +89,235 @@ cancellation_flags = {}
84
89
  cancellation_lock = threading.Lock()
85
90
 
86
91
 
92
+ # Minimal MCP client (inlined from npcsh corca to avoid corca import)
93
+ class MCPClientNPC:
94
+ def __init__(self, debug: bool = True):
95
+ self.debug = debug
96
+ self.session: Optional[ClientSession] = None
97
+ try:
98
+ self._loop = asyncio.get_event_loop()
99
+ if self._loop.is_closed():
100
+ self._loop = asyncio.new_event_loop()
101
+ asyncio.set_event_loop(self._loop)
102
+ except RuntimeError:
103
+ self._loop = asyncio.new_event_loop()
104
+ asyncio.set_event_loop(self._loop)
105
+ self._exit_stack = self._loop.run_until_complete(AsyncExitStack().__aenter__())
106
+ self.available_tools_llm: List[Dict[str, Any]] = []
107
+ self.tool_map: Dict[str, Callable] = {}
108
+ self.server_script_path: Optional[str] = None
109
+
110
+ def _log(self, message: str, color: str = "cyan") -> None:
111
+ if self.debug:
112
+ cprint(f"[MCP Client] {message}", color, file=sys.stderr)
113
+
114
+ async def _connect_async(self, server_script_path: str) -> None:
115
+ self._log(f"Attempting to connect to MCP server: {server_script_path}")
116
+ self.server_script_path = server_script_path
117
+ abs_path = os.path.abspath(server_script_path)
118
+ if not os.path.exists(abs_path):
119
+ raise FileNotFoundError(f"MCP server script not found: {abs_path}")
120
+
121
+ if abs_path.endswith('.py'):
122
+ cmd_parts = [sys.executable, abs_path]
123
+ elif os.access(abs_path, os.X_OK):
124
+ cmd_parts = [abs_path]
125
+ else:
126
+ raise ValueError(f"Unsupported MCP server script type or not executable: {abs_path}")
127
+
128
+ server_params = StdioServerParameters(
129
+ command=cmd_parts[0],
130
+ args=[abs_path],
131
+ env=os.environ.copy(),
132
+ cwd=os.path.dirname(abs_path) or "."
133
+ )
134
+ if self.session:
135
+ await self._exit_stack.aclose()
136
+
137
+ self._exit_stack = AsyncExitStack()
138
+
139
+ stdio_transport = await self._exit_stack.enter_async_context(stdio_client(server_params))
140
+ self.session = await self._exit_stack.enter_async_context(ClientSession(*stdio_transport))
141
+ await self.session.initialize()
142
+
143
+ response = await self.session.list_tools()
144
+ self.available_tools_llm = []
145
+ self.tool_map = {}
146
+
147
+ if response.tools:
148
+ for mcp_tool in response.tools:
149
+ tool_def = {
150
+ "type": "function",
151
+ "function": {
152
+ "name": mcp_tool.name,
153
+ "description": mcp_tool.description or f"MCP tool: {mcp_tool.name}",
154
+ "parameters": getattr(mcp_tool, "inputSchema", {"type": "object", "properties": {}})
155
+ }
156
+ }
157
+ self.available_tools_llm.append(tool_def)
158
+
159
+ def make_tool_func(tool_name_closure):
160
+ async def tool_func(**kwargs):
161
+ if not self.session:
162
+ return {"error": "No MCP session"}
163
+ self._log(f"About to call MCP tool {tool_name_closure}")
164
+ try:
165
+ cleaned_kwargs = {k: (None if v == 'None' else v) for k, v in kwargs.items()}
166
+ result = await asyncio.wait_for(
167
+ self.session.call_tool(tool_name_closure, cleaned_kwargs),
168
+ timeout=30.0
169
+ )
170
+ self._log(f"MCP tool {tool_name_closure} returned: {type(result)}")
171
+ return result
172
+ except asyncio.TimeoutError:
173
+ self._log(f"Tool {tool_name_closure} timed out after 30 seconds", "red")
174
+ return {"error": f"Tool {tool_name_closure} timed out"}
175
+ except Exception as e:
176
+ self._log(f"Tool {tool_name_closure} error: {e}", "red")
177
+ return {"error": str(e)}
178
+
179
+ def sync_wrapper(**kwargs):
180
+ self._log(f"Sync wrapper called for {tool_name_closure}")
181
+ return self._loop.run_until_complete(tool_func(**kwargs))
182
+
183
+ return sync_wrapper
184
+
185
+ self.tool_map[mcp_tool.name] = make_tool_func(mcp_tool.name)
186
+ tool_names = list(self.tool_map.keys())
187
+ self._log(f"Connection successful. Tools: {', '.join(tool_names) if tool_names else 'None'}")
188
+
189
+ def connect_sync(self, server_script_path: str) -> bool:
190
+ loop = self._loop
191
+ if loop.is_closed():
192
+ self._loop = asyncio.new_event_loop()
193
+ asyncio.set_event_loop(self._loop)
194
+ loop = self._loop
195
+ try:
196
+ loop.run_until_complete(self._connect_async(server_script_path))
197
+ return True
198
+ except Exception as e:
199
+ cprint(f"MCP connection failed: {e}", "red", file=sys.stderr)
200
+ return False
201
+
202
+ def disconnect_sync(self):
203
+ if self.session:
204
+ self._log("Disconnecting MCP session.")
205
+ loop = self._loop
206
+ if not loop.is_closed():
207
+ try:
208
+ async def close_session():
209
+ await self.session.close()
210
+ await self._exit_stack.aclose()
211
+ loop.run_until_complete(close_session())
212
+ except RuntimeError:
213
+ pass
214
+ except Exception as e:
215
+ print(f"Error during MCP client disconnect: {e}", file=sys.stderr)
216
+ self.session = None
217
+ self._exit_stack = None
218
+
219
+
220
+ def get_llm_response_with_handling(prompt, npc, messages, tools, stream, team, context=None):
221
+ """Unified LLM response with basic exception handling (inlined from corca to avoid that dependency)."""
222
+ try:
223
+ return get_llm_response(
224
+ prompt=prompt,
225
+ npc=npc,
226
+ messages=messages,
227
+ tools=tools,
228
+ auto_process_tool_calls=False,
229
+ stream=stream,
230
+ team=team,
231
+ context=context
232
+ )
233
+ except Exception:
234
+ # Fallback retry without context compression logic to keep it simple here.
235
+ return get_llm_response(
236
+ prompt=prompt,
237
+ npc=npc,
238
+ messages=messages,
239
+ tools=tools,
240
+ auto_process_tool_calls=False,
241
+ stream=stream,
242
+ team=team,
243
+ context=context
244
+ )
245
+ class MCPServerManager:
246
+ """
247
+ Simple in-process tracker for launching/stopping MCP servers.
248
+ Currently uses subprocess.Popen to start a Python stdio MCP server script.
249
+ """
250
+
251
+ def __init__(self):
252
+ self._procs = {}
253
+ self._lock = threading.Lock()
254
+
255
+ def start(self, server_path: str):
256
+ server_path = os.path.expanduser(server_path)
257
+ abs_path = os.path.abspath(server_path)
258
+ if not os.path.exists(abs_path):
259
+ raise FileNotFoundError(f"MCP server script not found at {abs_path}")
260
+
261
+ with self._lock:
262
+ existing = self._procs.get(abs_path)
263
+ if existing and existing.poll() is None:
264
+ return {"status": "running", "pid": existing.pid, "serverPath": abs_path}
265
+
266
+ cmd = [sys.executable, abs_path]
267
+ proc = subprocess.Popen(
268
+ cmd,
269
+ cwd=os.path.dirname(abs_path) or ".",
270
+ stdout=subprocess.PIPE,
271
+ stderr=subprocess.PIPE,
272
+ )
273
+ self._procs[abs_path] = proc
274
+ return {"status": "started", "pid": proc.pid, "serverPath": abs_path}
275
+
276
+ def stop(self, server_path: str):
277
+ server_path = os.path.expanduser(server_path)
278
+ abs_path = os.path.abspath(server_path)
279
+ with self._lock:
280
+ proc = self._procs.get(abs_path)
281
+ if not proc:
282
+ return {"status": "not_found", "serverPath": abs_path}
283
+ if proc.poll() is None:
284
+ proc.terminate()
285
+ try:
286
+ proc.wait(timeout=5)
287
+ except subprocess.TimeoutExpired:
288
+ proc.kill()
289
+ del self._procs[abs_path]
290
+ return {"status": "stopped", "serverPath": abs_path}
291
+
292
+ def status(self, server_path: str):
293
+ server_path = os.path.expanduser(server_path)
294
+ abs_path = os.path.abspath(server_path)
295
+ with self._lock:
296
+ proc = self._procs.get(abs_path)
297
+ if not proc:
298
+ return {"status": "not_started", "serverPath": abs_path}
299
+ running = proc.poll() is None
300
+ return {
301
+ "status": "running" if running else "exited",
302
+ "serverPath": abs_path,
303
+ "pid": proc.pid,
304
+ "returncode": None if running else proc.returncode,
305
+ }
306
+
307
+ def running(self):
308
+ with self._lock:
309
+ return {
310
+ path: {
311
+ "pid": proc.pid,
312
+ "status": "running" if proc.poll() is None else "exited",
313
+ "returncode": None if proc.poll() is None else proc.returncode,
314
+ }
315
+ for path, proc in self._procs.items()
316
+ }
317
+
318
+
319
+ mcp_server_manager = MCPServerManager()
320
+
87
321
  def get_project_npc_directory(current_path=None):
88
322
  """
89
323
  Get the project NPC directory based on the current path
@@ -186,6 +420,34 @@ def get_db_session():
186
420
  Session = sessionmaker(bind=engine)
187
421
  return Session()
188
422
 
423
+
424
+ def resolve_mcp_server_path(current_path=None, explicit_path=None, force_global=False):
425
+ """
426
+ Resolve an MCP server path using npcsh.corca's helper when available.
427
+ Falls back to ~/.npcsh/npc_team/mcp_server.py.
428
+ """
429
+ if explicit_path:
430
+ abs_path = os.path.abspath(os.path.expanduser(explicit_path))
431
+ if os.path.exists(abs_path):
432
+ return abs_path
433
+ try:
434
+ from npcsh.corca import _resolve_and_copy_mcp_server_path
435
+ resolved = _resolve_and_copy_mcp_server_path(
436
+ explicit_path=explicit_path,
437
+ current_path=current_path,
438
+ team_ctx_mcp_servers=None,
439
+ interactive=False,
440
+ auto_copy_bypass=True,
441
+ force_global=force_global,
442
+ )
443
+ if resolved:
444
+ return os.path.abspath(resolved)
445
+ except Exception as e:
446
+ print(f"resolve_mcp_server_path: fallback path due to error: {e}")
447
+
448
+ fallback = os.path.expanduser("~/.npcsh/npc_team/mcp_server.py")
449
+ return fallback
450
+
189
451
  extension_map = {
190
452
  "PNG": "images",
191
453
  "JPG": "images",
@@ -441,8 +703,6 @@ def capture():
441
703
  return None
442
704
 
443
705
  return jsonify({"screenshot": screenshot})
444
-
445
-
446
706
  @app.route("/api/settings/global", methods=["GET", "OPTIONS"])
447
707
  def get_global_settings():
448
708
  if request.method == "OPTIONS":
@@ -451,22 +711,22 @@ def get_global_settings():
451
711
  try:
452
712
  npcshrc_path = os.path.expanduser("~/.npcshrc")
453
713
 
454
-
455
714
  global_settings = {
456
715
  "model": "llama3.2",
457
716
  "provider": "ollama",
458
717
  "embedding_model": "nomic-embed-text",
459
718
  "embedding_provider": "ollama",
460
719
  "search_provider": "perplexity",
461
- "NPC_STUDIO_LICENSE_KEY": "",
462
720
  "default_folder": os.path.expanduser("~/.npcsh/"),
721
+ "is_predictive_text_enabled": False, # Default value for the new setting
722
+ "predictive_text_model": "llama3.2", # Default predictive text model
723
+ "predictive_text_provider": "ollama", # Default predictive text provider
463
724
  }
464
725
  global_vars = {}
465
726
 
466
727
  if os.path.exists(npcshrc_path):
467
728
  with open(npcshrc_path, "r") as f:
468
729
  for line in f:
469
-
470
730
  line = line.split("#")[0].strip()
471
731
  if not line:
472
732
  continue
@@ -474,33 +734,35 @@ def get_global_settings():
474
734
  if "=" not in line:
475
735
  continue
476
736
 
477
-
478
737
  key, value = line.split("=", 1)
479
738
  key = key.strip()
480
739
  if key.startswith("export "):
481
740
  key = key[7:]
482
741
 
483
-
484
742
  value = value.strip()
485
743
  if value.startswith('"') and value.endswith('"'):
486
744
  value = value[1:-1]
487
745
  elif value.startswith("'") and value.endswith("'"):
488
746
  value = value[1:-1]
489
747
 
490
-
491
748
  key_mapping = {
492
749
  "NPCSH_MODEL": "model",
493
750
  "NPCSH_PROVIDER": "provider",
494
751
  "NPCSH_EMBEDDING_MODEL": "embedding_model",
495
752
  "NPCSH_EMBEDDING_PROVIDER": "embedding_provider",
496
753
  "NPCSH_SEARCH_PROVIDER": "search_provider",
497
- "NPC_STUDIO_LICENSE_KEY": "NPC_STUDIO_LICENSE_KEY",
498
754
  "NPCSH_STREAM_OUTPUT": "NPCSH_STREAM_OUTPUT",
499
755
  "NPC_STUDIO_DEFAULT_FOLDER": "default_folder",
756
+ "NPC_STUDIO_PREDICTIVE_TEXT_ENABLED": "is_predictive_text_enabled", # New mapping
757
+ "NPC_STUDIO_PREDICTIVE_TEXT_MODEL": "predictive_text_model", # New mapping
758
+ "NPC_STUDIO_PREDICTIVE_TEXT_PROVIDER": "predictive_text_provider", # New mapping
500
759
  }
501
760
 
502
761
  if key in key_mapping:
503
- global_settings[key_mapping[key]] = value
762
+ if key == "NPC_STUDIO_PREDICTIVE_TEXT_ENABLED":
763
+ global_settings[key_mapping[key]] = value.lower() == 'true'
764
+ else:
765
+ global_settings[key_mapping[key]] = value
504
766
  else:
505
767
  global_vars[key] = value
506
768
 
@@ -517,6 +779,7 @@ def get_global_settings():
517
779
  except Exception as e:
518
780
  print(f"Error in get_global_settings: {str(e)}")
519
781
  return jsonify({"error": str(e)}), 500
782
+
520
783
  def _get_jinx_files_recursively(directory):
521
784
  """Helper to recursively find all .jinx file paths."""
522
785
  jinx_paths = []
@@ -550,58 +813,7 @@ def get_available_jinxs():
550
813
  traceback.print_exc()
551
814
  return jsonify({'jinxs': [], 'error': str(e)}), 500
552
815
 
553
- @app.route('/api/jinxs/global', methods=['GET'])
554
- def get_global_jinxs():
555
- global_jinxs_dir = os.path.expanduser('~/.npcsh/npc_team/jinxs')
556
-
557
- # Directories to exclude entirely
558
- excluded_dirs = ['core', 'npc_studio']
559
-
560
- code_jinxs = []
561
- mode_jinxs = []
562
- util_jinxs = []
563
-
564
- if os.path.exists(global_jinxs_dir):
565
- for root, dirs, files in os.walk(global_jinxs_dir):
566
- # Filter out excluded directories
567
- dirs[:] = [d for d in dirs if d not in excluded_dirs]
568
-
569
- for filename in files:
570
- if filename.endswith('.jinx'):
571
- try:
572
- jinx_path = os.path.join(root, filename)
573
- with open(jinx_path, 'r') as f:
574
- jinx_data = yaml.safe_load(f)
575
-
576
- if jinx_data:
577
- jinx_name = jinx_data.get('jinx_name', filename[:-5])
578
-
579
- jinx_obj = {
580
- 'name': jinx_name,
581
- 'display_name': jinx_data.get('description', jinx_name),
582
- 'description': jinx_data.get('description', ''),
583
- 'inputs': jinx_data.get('inputs', []),
584
- 'path': jinx_path
585
- }
586
-
587
- # Categorize based on directory
588
- rel_path = os.path.relpath(root, global_jinxs_dir)
589
-
590
- if rel_path.startswith('code'):
591
- code_jinxs.append(jinx_obj)
592
- elif rel_path.startswith('modes'):
593
- mode_jinxs.append(jinx_obj)
594
- elif rel_path.startswith('utils'):
595
- util_jinxs.append(jinx_obj)
596
-
597
- except Exception as e:
598
- print(f"Error loading jinx {filename}: {e}")
599
-
600
- return jsonify({
601
- 'code': code_jinxs,
602
- 'modes': mode_jinxs,
603
- 'utils': util_jinxs
604
- })
816
+
605
817
  @app.route("/api/jinx/execute", methods=["POST"])
606
818
  def execute_jinx():
607
819
  """
@@ -823,8 +1035,6 @@ def execute_jinx():
823
1035
  return Response(final_output_string, mimetype="text/html")
824
1036
  else:
825
1037
  return Response(final_output_string, mimetype="text/plain")
826
-
827
-
828
1038
  @app.route("/api/settings/global", methods=["POST", "OPTIONS"])
829
1039
  def save_global_settings():
830
1040
  if request.method == "OPTIONS":
@@ -840,35 +1050,41 @@ def save_global_settings():
840
1050
  "embedding_model": "NPCSH_EMBEDDING_MODEL",
841
1051
  "embedding_provider": "NPCSH_EMBEDDING_PROVIDER",
842
1052
  "search_provider": "NPCSH_SEARCH_PROVIDER",
843
- "NPC_STUDIO_LICENSE_KEY": "NPC_STUDIO_LICENSE_KEY",
844
1053
  "NPCSH_STREAM_OUTPUT": "NPCSH_STREAM_OUTPUT",
845
1054
  "default_folder": "NPC_STUDIO_DEFAULT_FOLDER",
1055
+ "is_predictive_text_enabled": "NPC_STUDIO_PREDICTIVE_TEXT_ENABLED", # New mapping
1056
+ "predictive_text_model": "NPC_STUDIO_PREDICTIVE_TEXT_MODEL", # New mapping
1057
+ "predictive_text_provider": "NPC_STUDIO_PREDICTIVE_TEXT_PROVIDER", # New mapping
846
1058
  }
847
1059
 
848
1060
  os.makedirs(os.path.dirname(npcshrc_path), exist_ok=True)
849
1061
  print(data)
850
1062
  with open(npcshrc_path, "w") as f:
851
-
1063
+
852
1064
  for key, value in data.get("global_settings", {}).items():
853
- if key in key_mapping and value:
854
-
855
- if " " in str(value):
856
- value = f'"{value}"'
857
- f.write(f"export {key_mapping[key]}={value}\n")
1065
+ if key in key_mapping and value is not None: # Check for None explicitly
1066
+ # Handle boolean conversion for saving
1067
+ if key == "is_predictive_text_enabled":
1068
+ value_to_write = str(value).upper()
1069
+ elif " " in str(value):
1070
+ value_to_write = f'"{value}"'
1071
+ else:
1072
+ value_to_write = str(value)
1073
+ f.write(f"export {key_mapping[key]}={value_to_write}\n")
858
1074
 
859
-
860
1075
  for key, value in data.get("global_vars", {}).items():
861
- if key and value:
1076
+ if key and value is not None: # Check for None explicitly
862
1077
  if " " in str(value):
863
- value = f'"{value}"'
864
- f.write(f"export {key}={value}\n")
1078
+ value_to_write = f'"{value}"'
1079
+ else:
1080
+ value_to_write = str(value)
1081
+ f.write(f"export {key}={value_to_write}\n")
865
1082
 
866
1083
  return jsonify({"message": "Global settings saved successfully", "error": None})
867
1084
 
868
1085
  except Exception as e:
869
1086
  print(f"Error in save_global_settings: {str(e)}")
870
1087
  return jsonify({"error": str(e)}), 500
871
-
872
1088
  @app.route("/api/settings/project", methods=["GET", "OPTIONS"])
873
1089
  def get_project_settings():
874
1090
  if request.method == "OPTIONS":
@@ -1050,8 +1266,542 @@ def save_jinx():
1050
1266
  return jsonify({"status": "success"})
1051
1267
  except Exception as e:
1052
1268
  return jsonify({"error": str(e)}), 500
1269
+ def serialize_jinx_inputs(inputs):
1270
+ result = []
1271
+ for inp in inputs:
1272
+ if isinstance(inp, str):
1273
+ result.append(inp)
1274
+ elif isinstance(inp, dict):
1275
+ key = list(inp.keys())[0]
1276
+ result.append(key)
1277
+ else:
1278
+ result.append(str(inp))
1279
+ return result
1280
+
1281
+ @app.route("/api/jinx/test", methods=["POST"])
1282
+ def test_jinx():
1283
+ data = request.json
1284
+ jinx_data = data.get("jinx")
1285
+ test_inputs = data.get("inputs", {})
1286
+ current_path = data.get("currentPath")
1287
+
1288
+ if current_path:
1289
+ load_project_env(current_path)
1290
+
1291
+ jinx = Jinx(jinx_data=jinx_data)
1292
+
1293
+ from jinja2 import Environment
1294
+ temp_env = Environment()
1295
+ jinx.render_first_pass(temp_env, {})
1296
+
1297
+ conversation_id = f"jinx_test_{uuid.uuid4().hex[:8]}"
1298
+ command_history = CommandHistory(app.config.get('DB_PATH'))
1299
+
1300
+ # 1. Save user's test command to conversation_history to get a message_id
1301
+ user_test_command = f"Testing jinx /{jinx.jinx_name} with inputs: {test_inputs}"
1302
+ user_message_id = generate_message_id()
1303
+ save_conversation_message(
1304
+ command_history,
1305
+ conversation_id,
1306
+ "user",
1307
+ user_test_command,
1308
+ wd=current_path,
1309
+ model=None, # Or appropriate model/provider for the test context
1310
+ provider=None,
1311
+ npc=None,
1312
+ message_id=user_message_id
1313
+ )
1314
+
1315
+ # Jinx execution status and output are now part of the assistant's response
1316
+ jinx_execution_status = "success"
1317
+ jinx_error_message = None
1318
+ output = "Jinx execution did not complete." # Default output
1319
+
1320
+ try:
1321
+ result = jinx.execute(
1322
+ input_values=test_inputs,
1323
+ npc=None,
1324
+ messages=[],
1325
+ extra_globals={},
1326
+ jinja_env=temp_env
1327
+ )
1328
+ output = result.get('output', str(result))
1329
+ if result.get('error'): # Assuming jinx.execute might return an 'error' key
1330
+ jinx_execution_status = "failed"
1331
+ jinx_error_message = str(result.get('error'))
1332
+ except Exception as e:
1333
+ jinx_execution_status = "failed"
1334
+ jinx_error_message = str(e)
1335
+ output = f"Jinx execution failed: {e}"
1336
+
1337
+ # The jinx_executions table is populated by a trigger from conversation_history.
1338
+ # The details of the execution (inputs, output, status) are now expected to be
1339
+ # derived by analyzing the user's command and the subsequent assistant's response.
1340
+ # No explicit update to jinx_executions is needed here.
1341
+
1342
+ # 2. Save assistant's response to conversation_history
1343
+ assistant_response_message_id = generate_message_id() # ID for the assistant's response
1344
+ save_conversation_message(
1345
+ command_history,
1346
+ conversation_id,
1347
+ "assistant",
1348
+ output, # The jinx output is the assistant's response for the test
1349
+ wd=current_path,
1350
+ model=None,
1351
+ provider=None,
1352
+ npc=None,
1353
+ message_id=assistant_response_message_id
1354
+ )
1355
+
1356
+ return jsonify({
1357
+ "output": output,
1358
+ "conversation_id": conversation_id,
1359
+ "execution_id": user_message_id, # Return the user's message_id as the execution_id
1360
+ "error": jinx_error_message
1361
+ })
1362
+ from npcpy.ft.diff import train_diffusion, DiffusionConfig
1363
+ import threading
1364
+
1365
+ from npcpy.memory.knowledge_graph import (
1366
+ load_kg_from_db,
1367
+ save_kg_to_db # ADD THIS LINE to import the correct function
1368
+ )
1369
+
1370
+ from collections import defaultdict # ADD THIS LINE for collecting links if not already present
1371
+
1372
+ finetune_jobs = {}
1373
+
1374
+ def extract_and_store_memories(
1375
+ conversation_text,
1376
+ conversation_id,
1377
+ command_history,
1378
+ npc_name,
1379
+ team_name,
1380
+ current_path,
1381
+ model,
1382
+ provider,
1383
+ npc_object=None
1384
+ ):
1385
+ from npcpy.llm_funcs import get_facts
1386
+ from npcpy.memory.command_history import format_memory_context
1387
+ # Your CommandHistory.get_memory_examples_for_context returns a dict with 'approved' and 'rejected'
1388
+ memory_examples_dict = command_history.get_memory_examples_for_context(
1389
+ npc=npc_name,
1390
+ team=team_name,
1391
+ directory_path=current_path
1392
+ )
1393
+
1394
+ memory_context = format_memory_context(memory_examples_dict)
1395
+
1396
+ facts = get_facts(
1397
+ conversation_text,
1398
+ model=npc_object.model if npc_object else model,
1399
+ provider=npc_object.provider if npc_object else provider,
1400
+ npc=npc_object,
1401
+ context=memory_context
1402
+ )
1403
+
1404
+ memories_for_approval = []
1405
+
1406
+ # Initialize structures to collect KG data for a single save_kg_to_db call
1407
+ kg_facts_to_save = []
1408
+ kg_concepts_to_save = []
1409
+ fact_to_concept_links_temp = defaultdict(list)
1410
+
1411
+
1412
+ if facts:
1413
+ for i, fact in enumerate(facts):
1414
+ # Store memory in memory_lifecycle table
1415
+ memory_id = command_history.add_memory_to_database(
1416
+ message_id=f"{conversation_id}_{datetime.datetime.now().strftime('%H%M%S')}_{i}",
1417
+ conversation_id=conversation_id,
1418
+ npc=npc_name or "default",
1419
+ team=team_name or "default",
1420
+ directory_path=current_path or "/",
1421
+ initial_memory=fact.get('statement', str(fact)),
1422
+ status="pending_approval",
1423
+ model=npc_object.model if npc_object else model,
1424
+ provider=npc_object.provider if npc_object else provider,
1425
+ final_memory=None # Explicitly None for pending memories
1426
+ )
1427
+
1428
+ memories_for_approval.append({
1429
+ "memory_id": memory_id,
1430
+ "content": fact.get('statement', str(fact)),
1431
+ "type": fact.get('type', 'unknown'),
1432
+ "context": fact.get('source_text', ''),
1433
+ "npc": npc_name or "default"
1434
+ })
1435
+
1436
+ # Collect facts and concepts for the Knowledge Graph
1437
+ #if fact.get('type') == 'concept':
1438
+ # kg_concepts_to_save.append({
1439
+ # "name": fact.get('statement'),
1440
+ # "generation": current_kg_generation,
1441
+ # "origin": "organic" # Assuming 'organic' for extracted facts
1442
+ # })
1443
+ #else: # It's a fact (or unknown type, treat as fact for KG)
1444
+ # kg_facts_to_save.append({
1445
+ # "statement": fact.get('statement'),
1446
+ # "source_text": fact.get('source_text', conversation_text), # Use source_text if available, else conversation_text
1447
+ # "type": fact.get('type', 'fact'), # Default to 'fact' if type is unknown
1448
+ # "generation": current_kg_generation,
1449
+ # "origin": "organic"
1450
+ # })
1451
+ # if fact.get('concepts'): # If this fact has related concepts
1452
+ # for concept_name in fact.get('concepts'):
1453
+ # fact_to_concept_links_temp[fact.get('statement')].append(concept_name)
1454
+
1455
+ # After processing all facts, save them to the KG database in one go
1456
+ if kg_facts_to_save or kg_concepts_to_save:
1457
+ temp_kg_data = {
1458
+ "facts": kg_facts_to_save,
1459
+ "concepts": kg_concepts_to_save,
1460
+ "generation": current_kg_generation,
1461
+ "fact_to_concept_links": fact_to_concept_links_temp,
1462
+ "concept_links": [], # Assuming no concept-to-concept links from direct extraction
1463
+ "fact_to_fact_links": [] # Assuming no fact-to-fact links from direct extraction
1464
+ }
1465
+
1466
+ # Get the SQLAlchemy engine using your existing helper function
1467
+ db_engine = get_db_connection(app.config.get('DB_PATH'))
1468
+
1469
+ # Call the existing save_kg_to_db function
1470
+ save_kg_to_db(
1471
+ engine=db_engine,
1472
+ kg_data=temp_kg_data,
1473
+ team_name=team_name or "default",
1474
+ npc_name=npc_name or "default",
1475
+ directory_path=current_path or "/"
1476
+ )
1477
+
1478
+ return memories_for_approval
1479
+ @app.route('/api/finetuned_models', methods=['GET'])
1480
+ def get_finetuned_models():
1481
+ current_path = request.args.get("currentPath")
1482
+
1483
+ # Define a list of potential root directories where fine-tuned models might be saved.
1484
+ # We'll be very generous here, including both 'models' and 'images' directories
1485
+ # at both global and project levels, as the user's logs indicate saving to 'images'.
1486
+ potential_root_paths = [
1487
+ os.path.expanduser('~/.npcsh/models'), # Standard global models directory
1488
+ os.path.expanduser('~/.npcsh/images'), # Global images directory (where user's model was saved)
1489
+ ]
1490
+ if current_path:
1491
+ # Add project-specific model directories if a current_path is provided
1492
+ project_models_path = os.path.join(current_path, 'models')
1493
+ project_images_path = os.path.join(current_path, 'images') # Also check project images directory
1494
+ potential_root_paths.extend([project_models_path, project_images_path])
1495
+
1496
+ finetuned_models = []
1497
+
1498
+ print(f"🌋 Searching for fine-tuned models in potential root paths: {set(potential_root_paths)}") # Use set for unique paths
1499
+
1500
+ for root_path in set(potential_root_paths): # Iterate through unique potential root paths
1501
+ if not os.path.exists(root_path) or not os.path.isdir(root_path):
1502
+ print(f"🌋 Skipping non-existent or non-directory root path: {root_path}")
1503
+ continue
1504
+
1505
+ print(f"🌋 Scanning root path: {root_path}")
1506
+ for model_dir_name in os.listdir(root_path):
1507
+ full_model_path = os.path.join(root_path, model_dir_name)
1508
+
1509
+ if not os.path.isdir(full_model_path):
1510
+ print(f"🌋 Skipping {full_model_path}: Not a directory.")
1511
+ continue
1512
+
1513
+ # NEW STRATEGY: Check for user's specific output files
1514
+ # Look for 'model_final.pt' or the 'checkpoints' directory
1515
+ has_model_final_pt = os.path.exists(os.path.join(full_model_path, 'model_final.pt'))
1516
+ has_checkpoints_dir = os.path.isdir(os.path.join(full_model_path, 'checkpoints'))
1517
+
1518
+ if has_model_final_pt or has_checkpoints_dir:
1519
+ print(f"🌋 Identified fine-tuned model: {model_dir_name} at {full_model_path} (found model_final.pt or checkpoints dir)")
1520
+ finetuned_models.append({
1521
+ "value": full_model_path, # This is the path to the directory containing the .pt files
1522
+ "provider": "diffusers", # Provider is still "diffusers"
1523
+ "display_name": f"{model_dir_name} | Fine-tuned Diffuser"
1524
+ })
1525
+ continue # Move to the next model_dir_name found in this root_path
1526
+
1527
+ print(f"🌋 Skipping {full_model_path}: No model_final.pt or checkpoints directory found at root.")
1528
+
1529
+ print(f"🌋 Finished scanning. Found {len(finetuned_models)} fine-tuned models.")
1530
+ return jsonify({"models": finetuned_models, "error": None})
1531
+
1532
+ @app.route('/api/finetune_diffusers', methods=['POST'])
1533
+ def finetune_diffusers():
1534
+ data = request.json
1535
+ images = data.get('images', [])
1536
+ captions = data.get('captions', [])
1537
+ output_name = data.get('outputName', 'my_diffusion_model')
1538
+ num_epochs = data.get('epochs', 100)
1539
+ batch_size = data.get('batchSize', 4)
1540
+ learning_rate = data.get('learningRate', 1e-4)
1541
+ output_path = data.get('outputPath', '~/.npcsh/models')
1542
+
1543
+ print(f"🌋 Finetune Diffusers Request Received!")
1544
+ print(f" Images: {len(images)} files")
1545
+ print(f" Output Name: {output_name}")
1546
+ print(f" Epochs: {num_epochs}, Batch Size: {batch_size}, Learning Rate: {learning_rate}")
1547
+
1548
+ if not images:
1549
+ print("🌋 Error: No images provided for finetuning.")
1550
+ return jsonify({'error': 'No images provided'}), 400
1551
+
1552
+ if not captions or len(captions) != len(images):
1553
+ print("🌋 Warning: Captions not provided or mismatching image count. Using empty captions.")
1554
+ captions = [''] * len(images)
1555
+
1556
+ expanded_images = [os.path.expanduser(p) for p in images]
1557
+ output_dir = os.path.expanduser(
1558
+ os.path.join(output_path, output_name)
1559
+ )
1560
+
1561
+ job_id = f"ft_{int(time.time())}"
1562
+ finetune_jobs[job_id] = {
1563
+ 'status': 'running',
1564
+ 'output_dir': output_dir,
1565
+ 'epochs': num_epochs,
1566
+ 'current_epoch': 0,
1567
+ 'start_time': datetime.datetime.now().isoformat()
1568
+ }
1569
+ print(f"🌋 Finetuning job {job_id} initialized. Output directory: {output_dir}")
1570
+
1571
+ def run_training_async():
1572
+ print(f"🌋 Finetuning job {job_id}: Starting asynchronous training thread...")
1573
+ try:
1574
+ config = DiffusionConfig(
1575
+ num_epochs=num_epochs,
1576
+ batch_size=batch_size,
1577
+ learning_rate=learning_rate,
1578
+ output_model_path=output_dir
1579
+ )
1580
+
1581
+ print(f"🌋 Finetuning job {job_id}: Calling train_diffusion with config: {config}")
1582
+ # Assuming train_diffusion might print its own progress or allow callbacks
1583
+ # For more granular logging, you'd need to modify train_diffusion itself
1584
+ model_path = train_diffusion(
1585
+ expanded_images,
1586
+ captions,
1587
+ config=config
1588
+ )
1589
+
1590
+ finetune_jobs[job_id]['status'] = 'complete'
1591
+ finetune_jobs[job_id]['model_path'] = model_path
1592
+ finetune_jobs[job_id]['end_time'] = datetime.datetime.now().isoformat()
1593
+ print(f"🌋 Finetuning job {job_id}: Training complete! Model saved to: {model_path}")
1594
+ except Exception as e:
1595
+ finetune_jobs[job_id]['status'] = 'error'
1596
+ finetune_jobs[job_id]['error_msg'] = str(e)
1597
+ finetune_jobs[job_id]['end_time'] = datetime.datetime.now().isoformat()
1598
+ print(f"🌋 Finetuning job {job_id}: ERROR during training: {e}")
1599
+ traceback.print_exc()
1600
+ print(f"🌋 Finetuning job {job_id}: Asynchronous training thread finished.")
1601
+
1602
+ # Start the training in a separate thread
1603
+ thread = threading.Thread(target=run_training_async)
1604
+ thread.daemon = True # Allow the main program to exit even if this thread is still running
1605
+ thread.start()
1606
+
1607
+ print(f"🌋 Finetuning job {job_id} successfully launched in background. Returning initial status.")
1608
+ return jsonify({
1609
+ 'status': 'started',
1610
+ 'jobId': job_id,
1611
+ 'message': f"Finetuning job '{job_id}' started. Check /api/finetune_status/{job_id} for updates."
1612
+ })
1613
+
1614
+
1615
+ @app.route('/api/finetune_status/<job_id>', methods=['GET'])
1616
+ def finetune_status(job_id):
1617
+ if job_id not in finetune_jobs:
1618
+ return jsonify({'error': 'Job not found'}), 404
1619
+
1620
+ job = finetune_jobs[job_id]
1621
+
1622
+ if job['status'] == 'complete':
1623
+ return jsonify({
1624
+ 'complete': True,
1625
+ 'outputPath': job.get('model_path', job['output_dir'])
1626
+ })
1627
+ elif job['status'] == 'error':
1628
+ return jsonify({'error': job.get('error_msg', 'Unknown error')})
1629
+
1630
+ return jsonify({
1631
+ 'step': job.get('current_epoch', 0),
1632
+ 'total': job['epochs'],
1633
+ 'status': 'running'
1634
+ })
1635
+
1636
+ @app.route("/api/ml/train", methods=["POST"])
1637
+ def train_ml_model():
1638
+ import pickle
1639
+ import numpy as np
1640
+ from sklearn.linear_model import LinearRegression, LogisticRegression
1641
+ from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
1642
+ from sklearn.tree import DecisionTreeRegressor
1643
+ from sklearn.cluster import KMeans
1644
+ from sklearn.model_selection import train_test_split
1645
+ from sklearn.metrics import mean_squared_error, r2_score, accuracy_score
1646
+
1647
+ data = request.json
1648
+ model_name = data.get("name")
1649
+ model_type = data.get("type")
1650
+ target = data.get("target")
1651
+ features = data.get("features")
1652
+ training_data = data.get("data")
1653
+ hyperparams = data.get("hyperparameters", {})
1654
+
1655
+ df = pd.DataFrame(training_data)
1656
+ X = df[features].values
1657
+
1658
+ metrics = {}
1659
+ model = None
1660
+
1661
+ if model_type == "linear_regression":
1662
+ y = df[target].values
1663
+ X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
1664
+ model = LinearRegression()
1665
+ model.fit(X_train, y_train)
1666
+ y_pred = model.predict(X_test)
1667
+ metrics = {
1668
+ "r2_score": r2_score(y_test, y_pred),
1669
+ "rmse": np.sqrt(mean_squared_error(y_test, y_pred))
1670
+ }
1671
+
1672
+ elif model_type == "logistic_regression":
1673
+ y = df[target].values
1674
+ X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
1675
+ model = LogisticRegression(max_iter=1000)
1676
+ model.fit(X_train, y_train)
1677
+ y_pred = model.predict(X_test)
1678
+ metrics = {"accuracy": accuracy_score(y_test, y_pred)}
1679
+
1680
+ elif model_type == "random_forest":
1681
+ y = df[target].values
1682
+ X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
1683
+ model = RandomForestRegressor(n_estimators=100)
1684
+ model.fit(X_train, y_train)
1685
+ y_pred = model.predict(X_test)
1686
+ metrics = {
1687
+ "r2_score": r2_score(y_test, y_pred),
1688
+ "rmse": np.sqrt(mean_squared_error(y_test, y_pred))
1689
+ }
1690
+
1691
+ elif model_type == "clustering":
1692
+ n_clusters = hyperparams.get("n_clusters", 3)
1693
+ model = KMeans(n_clusters=n_clusters)
1694
+ labels = model.fit_predict(X)
1695
+ metrics = {"inertia": model.inertia_, "n_clusters": n_clusters}
1696
+
1697
+ elif model_type == "gradient_boost":
1698
+ y = df[target].values
1699
+ X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
1700
+ model = GradientBoostingRegressor()
1701
+ model.fit(X_train, y_train)
1702
+ y_pred = model.predict(X_test)
1703
+ metrics = {
1704
+ "r2_score": r2_score(y_test, y_pred),
1705
+ "rmse": np.sqrt(mean_squared_error(y_test, y_pred))
1706
+ }
1707
+
1708
+ model_id = f"{model_name}_{int(time.time())}"
1709
+ model_path = os.path.expanduser(f"~/.npcsh/models/{model_id}.pkl")
1710
+ os.makedirs(os.path.dirname(model_path), exist_ok=True)
1711
+
1712
+ with open(model_path, 'wb') as f:
1713
+ pickle.dump({
1714
+ "model": model,
1715
+ "features": features,
1716
+ "target": target,
1717
+ "type": model_type
1718
+ }, f)
1719
+
1720
+ return jsonify({
1721
+ "model_id": model_id,
1722
+ "metrics": metrics,
1723
+ "error": None
1724
+ })
1053
1725
 
1054
1726
 
1727
+ @app.route("/api/ml/predict", methods=["POST"])
1728
+ def ml_predict():
1729
+ import pickle
1730
+
1731
+ data = request.json
1732
+ model_name = data.get("model_name")
1733
+ input_data = data.get("input_data")
1734
+
1735
+ model_dir = os.path.expanduser("~/.npcsh/models/")
1736
+ model_files = [f for f in os.listdir(model_dir) if f.startswith(model_name)]
1737
+
1738
+ if not model_files:
1739
+ return jsonify({"error": f"Model {model_name} not found"})
1740
+
1741
+ model_path = os.path.join(model_dir, model_files[0])
1742
+
1743
+ with open(model_path, 'rb') as f:
1744
+ model_data = pickle.load(f)
1745
+
1746
+ model = model_data["model"]
1747
+ prediction = model.predict([input_data])
1748
+
1749
+ return jsonify({
1750
+ "prediction": prediction.tolist(),
1751
+ "error": None
1752
+ })
1753
+ @app.route("/api/jinx/executions/label", methods=["POST"])
1754
+ def label_jinx_execution():
1755
+ data = request.json
1756
+ execution_id = data.get("executionId")
1757
+ label = data.get("label")
1758
+
1759
+ command_history = CommandHistory(app.config.get('DB_PATH'))
1760
+ command_history.label_jinx_execution(execution_id, label)
1761
+
1762
+ return jsonify({"success": True, "error": None})
1763
+
1764
+
1765
+ @app.route("/api/npc/executions", methods=["GET"])
1766
+ def get_npc_executions():
1767
+ npc_name = request.args.get("npcName")
1768
+
1769
+
1770
+ command_history = CommandHistory(app.config.get('DB_PATH'))
1771
+ executions = command_history.get_npc_executions(npc_name)
1772
+
1773
+ return jsonify({"executions": executions, "error": None})
1774
+
1775
+
1776
+ @app.route("/api/npc/executions/label", methods=["POST"])
1777
+ def label_npc_execution():
1778
+ data = request.json
1779
+ execution_id = data.get("executionId")
1780
+ label = data.get("label")
1781
+
1782
+ command_history = CommandHistory(app.config.get('DB_PATH'))
1783
+ command_history.label_npc_execution(execution_id, label)
1784
+
1785
+ return jsonify({"success": True, "error": None})
1786
+
1787
+
1788
+ @app.route("/api/training/dataset", methods=["POST"])
1789
+ def build_training_dataset():
1790
+ data = request.json
1791
+ filters = data.get("filters", {})
1792
+
1793
+ command_history = CommandHistory(app.config.get('DB_PATH'))
1794
+ dataset = command_history.get_training_dataset(
1795
+ include_jinxs=filters.get("jinxs", True),
1796
+ include_npcs=filters.get("npcs", True),
1797
+ npc_names=filters.get("npc_names")
1798
+ )
1799
+
1800
+ return jsonify({
1801
+ "dataset": dataset,
1802
+ "count": len(dataset),
1803
+ "error": None
1804
+ })
1055
1805
  @app.route("/api/save_npc", methods=["POST"])
1056
1806
  def save_npc():
1057
1807
  try:
@@ -1092,137 +1842,147 @@ use_global_jinxs: {str(npc_data.get('use_global_jinxs', True)).lower()}
1092
1842
  print(f"Error saving NPC: {str(e)}")
1093
1843
  return jsonify({"error": str(e)}), 500
1094
1844
 
1095
- @app.route("/api/npc_team_global")
1096
- def get_npc_team_global():
1097
- try:
1098
- db_conn = get_db_connection()
1099
- global_npc_directory = os.path.expanduser("~/.npcsh/npc_team")
1100
-
1101
- npc_data = []
1845
+ @app.route("/api/jinxs/global")
1846
+ def get_jinxs_global():
1847
+ global_jinx_directory = os.path.expanduser("~/.npcsh/npc_team/jinxs")
1848
+ jinx_data = []
1102
1849
 
1103
- # Ensure the directory exists before listing
1104
- if not os.path.exists(global_npc_directory):
1105
- print(f"Global NPC directory not found: {global_npc_directory}", file=sys.stderr)
1106
- return jsonify({"npcs": [], "error": f"Global NPC directory not found: {global_npc_directory}"})
1850
+ if not os.path.exists(global_jinx_directory):
1851
+ return jsonify({"jinxs": [], "error": None})
1107
1852
 
1108
- for file in os.listdir(global_npc_directory):
1109
- if file.endswith(".npc"):
1110
- npc_path = os.path.join(global_npc_directory, file)
1111
- try:
1112
- npc = NPC(file=npc_path, db_conn=db_conn)
1113
-
1114
- # Ensure jinxs are initialized after NPC creation if not already
1115
- # This is crucial for populating npc.jinxs_dict
1116
- if not npc.jinxs_dict and hasattr(npc, 'initialize_jinxs'):
1117
- npc.initialize_jinxs()
1118
-
1119
- serialized_npc = {
1120
- "name": npc.name,
1121
- "primary_directive": npc.primary_directive,
1122
- "model": npc.model,
1123
- "provider": npc.provider,
1124
- "api_url": npc.api_url,
1125
- "use_global_jinxs": npc.use_global_jinxs,
1126
- # CRITICAL FIX: Iterate over npc.jinxs_dict.values() which contains Jinx objects
1127
- "jinxs": [
1128
- {
1129
- "jinx_name": jinx.jinx_name,
1130
- "inputs": jinx.inputs,
1131
- "steps": [
1132
- {
1133
- "name": step.get("name", f"step_{i}"),
1134
- "engine": step.get("engine", "natural"),
1135
- "code": step.get("code", "")
1136
- }
1137
- for i, step in enumerate(jinx.steps)
1138
- ]
1139
- }
1140
- for jinx in npc.jinxs_dict.values() # Use jinxs_dict here
1141
- ] if hasattr(npc, 'jinxs_dict') else [], # Defensive check
1142
- }
1143
- npc_data.append(serialized_npc)
1144
- except Exception as e:
1145
- print(f"Error loading or serializing NPC {file}: {str(e)}", file=sys.stderr)
1146
- traceback.print_exc(file=sys.stderr)
1853
+ for root, dirs, files in os.walk(global_jinx_directory):
1854
+ for file in files:
1855
+ if file.endswith(".jinx"):
1856
+ jinx_path = os.path.join(root, file)
1857
+ with open(jinx_path, 'r') as f:
1858
+ raw_data = yaml.safe_load(f)
1859
+
1860
+ inputs = []
1861
+ for inp in raw_data.get("inputs", []):
1862
+ if isinstance(inp, str):
1863
+ inputs.append(inp)
1864
+ elif isinstance(inp, dict):
1865
+ inputs.append(list(inp.keys())[0])
1866
+ else:
1867
+ inputs.append(str(inp))
1868
+
1869
+ rel_path = os.path.relpath(jinx_path, global_jinx_directory)
1870
+ path_without_ext = rel_path[:-5]
1871
+
1872
+ jinx_data.append({
1873
+ "jinx_name": raw_data.get("jinx_name", file[:-5]),
1874
+ "path": path_without_ext,
1875
+ "description": raw_data.get("description", ""),
1876
+ "inputs": inputs,
1877
+ "steps": raw_data.get("steps", [])
1878
+ })
1147
1879
 
1880
+ return jsonify({"jinxs": jinx_data, "error": None})
1148
1881
 
1149
- return jsonify({"npcs": npc_data, "error": None})
1882
+ @app.route("/api/jinxs/project", methods=["GET"])
1883
+ def get_jinxs_project():
1884
+ project_dir = request.args.get("currentPath")
1885
+ if not project_dir:
1886
+ return jsonify({"jinxs": [], "error": "currentPath required"}), 400
1150
1887
 
1151
- except Exception as e:
1152
- print(f"Error fetching global NPC team: {str(e)}", file=sys.stderr)
1153
- traceback.print_exc(file=sys.stderr)
1154
- return jsonify({"npcs": [], "error": str(e)})
1888
+ if not project_dir.endswith("jinxs"):
1889
+ project_dir = os.path.join(project_dir, "jinxs")
1155
1890
 
1891
+ jinx_data = []
1892
+ if not os.path.exists(project_dir):
1893
+ return jsonify({"jinxs": [], "error": None})
1156
1894
 
1157
- @app.route("/api/npc_team_project", methods=["GET"])
1158
- def get_npc_team_project():
1159
- try:
1160
- db_conn = get_db_connection()
1895
+ for root, dirs, files in os.walk(project_dir):
1896
+ for file in files:
1897
+ if file.endswith(".jinx"):
1898
+ jinx_path = os.path.join(root, file)
1899
+ with open(jinx_path, 'r') as f:
1900
+ raw_data = yaml.safe_load(f)
1901
+
1902
+ inputs = []
1903
+ for inp in raw_data.get("inputs", []):
1904
+ if isinstance(inp, str):
1905
+ inputs.append(inp)
1906
+ elif isinstance(inp, dict):
1907
+ inputs.append(list(inp.keys())[0])
1908
+ else:
1909
+ inputs.append(str(inp))
1910
+
1911
+ rel_path = os.path.relpath(jinx_path, project_dir)
1912
+ path_without_ext = rel_path[:-5]
1913
+
1914
+ jinx_data.append({
1915
+ "jinx_name": raw_data.get("jinx_name", file[:-5]),
1916
+ "path": path_without_ext,
1917
+ "description": raw_data.get("description", ""),
1918
+ "inputs": inputs,
1919
+ "steps": raw_data.get("steps", [])
1920
+ })
1921
+ print(jinx_data)
1922
+ return jsonify({"jinxs": jinx_data, "error": None})
1161
1923
 
1162
- project_npc_directory = request.args.get("currentPath")
1163
- if not project_npc_directory:
1164
- return jsonify({"npcs": [], "error": "currentPath is required for project NPCs"}), 400
1924
+ @app.route("/api/npc_team_global")
1925
+ def get_npc_team_global():
1926
+ global_npc_directory = os.path.expanduser("~/.npcsh/npc_team")
1927
+ npc_data = []
1165
1928
 
1166
- if not project_npc_directory.endswith("npc_team"):
1167
- project_npc_directory = os.path.join(project_npc_directory, "npc_team")
1929
+ if not os.path.exists(global_npc_directory):
1930
+ return jsonify({"npcs": [], "error": None})
1168
1931
 
1169
- npc_data = []
1932
+ for file in os.listdir(global_npc_directory):
1933
+ if file.endswith(".npc"):
1934
+ npc_path = os.path.join(global_npc_directory, file)
1935
+ with open(npc_path, 'r') as f:
1936
+ raw_data = yaml.safe_load(f)
1937
+
1938
+ npc_data.append({
1939
+ "name": raw_data.get("name", file[:-4]),
1940
+ "primary_directive": raw_data.get("primary_directive", ""),
1941
+ "model": raw_data.get("model", ""),
1942
+ "provider": raw_data.get("provider", ""),
1943
+ "api_url": raw_data.get("api_url", ""),
1944
+ "use_global_jinxs": raw_data.get("use_global_jinxs", True),
1945
+ "jinxs": raw_data.get("jinxs", "*"),
1946
+ })
1170
1947
 
1171
- # Ensure the directory exists before listing
1172
- if not os.path.exists(project_npc_directory):
1173
- print(f"Project NPC directory not found: {project_npc_directory}", file=sys.stderr)
1174
- return jsonify({"npcs": [], "error": f"Project NPC directory not found: {project_npc_directory}"})
1948
+ return jsonify({"npcs": npc_data, "error": None})
1175
1949
 
1176
- for file in os.listdir(project_npc_directory):
1177
- print(f"Processing project NPC file: {file}", file=sys.stderr) # Diagnostic print
1178
- if file.endswith(".npc"):
1179
- npc_path = os.path.join(project_npc_directory, file)
1180
- try:
1181
- npc = NPC(file=npc_path, db_conn=db_conn)
1182
-
1183
- # Ensure jinxs are initialized after NPC creation if not already
1184
- # This is crucial for populating npc.jinxs_dict
1185
- if not npc.jinxs_dict and hasattr(npc, 'initialize_jinxs'):
1186
- npc.initialize_jinxs()
1187
-
1188
- serialized_npc = {
1189
- "name": npc.name,
1190
- "primary_directive": npc.primary_directive,
1191
- "model": npc.model,
1192
- "provider": npc.provider,
1193
- "api_url": npc.api_url,
1194
- "use_global_jinxs": npc.use_global_jinxs,
1195
- # CRITICAL FIX: Iterate over npc.jinxs_dict.values() which contains Jinx objects
1196
- "jinxs": [
1197
- {
1198
- "jinx_name": jinx.jinx_name,
1199
- "inputs": jinx.inputs,
1200
- "steps": [
1201
- {
1202
- "name": step.get("name", f"step_{i}"),
1203
- "engine": step.get("engine", "natural"),
1204
- "code": step.get("code", "")
1205
- }
1206
- for i, step in enumerate(jinx.steps)
1207
- ]
1208
- }
1209
- for jinx in npc.jinxs_dict.values() # Use jinxs_dict here
1210
- ] if hasattr(npc, 'jinxs_dict') else [], # Defensive check
1211
- }
1212
- npc_data.append(serialized_npc)
1213
- except Exception as e:
1214
- print(f"Error loading or serializing NPC {file}: {str(e)}", file=sys.stderr)
1215
- traceback.print_exc(file=sys.stderr)
1216
1950
 
1951
+ @app.route("/api/npc_team_project", methods=["GET"])
1952
+ def get_npc_team_project():
1953
+ project_npc_directory = request.args.get("currentPath")
1954
+ if not project_npc_directory:
1955
+ return jsonify({"npcs": [], "error": "currentPath required"}), 400
1956
+
1957
+ if not project_npc_directory.endswith("npc_team"):
1958
+ project_npc_directory = os.path.join(
1959
+ project_npc_directory,
1960
+ "npc_team"
1961
+ )
1217
1962
 
1218
- print(f"Project NPC data: {npc_data}", file=sys.stderr) # Diagnostic print
1219
- return jsonify({"npcs": npc_data, "error": None})
1963
+ npc_data = []
1220
1964
 
1221
- except Exception as e:
1222
- print(f"Error fetching NPC team: {str(e)}", file=sys.stderr)
1223
- traceback.print_exc(file=sys.stderr)
1224
- return jsonify({"npcs": [], "error": str(e)})
1965
+ if not os.path.exists(project_npc_directory):
1966
+ return jsonify({"npcs": [], "error": None})
1225
1967
 
1968
+ for file in os.listdir(project_npc_directory):
1969
+ if file.endswith(".npc"):
1970
+ npc_path = os.path.join(project_npc_directory, file)
1971
+ with open(npc_path, 'r') as f:
1972
+ raw_npc_data = yaml.safe_load(f)
1973
+
1974
+ serialized_npc = {
1975
+ "name": raw_npc_data.get("name", file[:-4]),
1976
+ "primary_directive": raw_npc_data.get("primary_directive", ""),
1977
+ "model": raw_npc_data.get("model", ""),
1978
+ "provider": raw_npc_data.get("provider", ""),
1979
+ "api_url": raw_npc_data.get("api_url", ""),
1980
+ "use_global_jinxs": raw_npc_data.get("use_global_jinxs", True),
1981
+ "jinxs": raw_npc_data.get("jinxs", "*"),
1982
+ }
1983
+ npc_data.append(serialized_npc)
1984
+
1985
+ return jsonify({"npcs": npc_data, "error": None})
1226
1986
 
1227
1987
  def get_last_used_model_and_npc_in_directory(directory_path):
1228
1988
  """
@@ -1542,11 +2302,62 @@ IMAGE_MODELS = {
1542
2302
  {"value": "runwayml/stable-diffusion-v1-5", "display_name": "Stable Diffusion v1.5"},
1543
2303
  ],
1544
2304
  }
2305
+ # In npcpy/serve.py, find the @app.route('/api/finetuned_models', methods=['GET'])
2306
+ # and replace the entire function with this:
1545
2307
 
2308
+ # This is now an internal helper function, not a Flask route.
2309
+ def _get_finetuned_models_internal(current_path=None): # Renamed to indicate internal use
2310
+
2311
+ # Define a list of potential root directories where fine-tuned models might be saved.
2312
+ potential_root_paths = [
2313
+ os.path.expanduser('~/.npcsh/models'), # Standard global models directory
2314
+ os.path.expanduser('~/.npcsh/images'), # Global images directory (where user's model was saved)
2315
+ ]
2316
+ if current_path:
2317
+ # Add project-specific model directories if a current_path is provided
2318
+ project_models_path = os.path.join(current_path, 'models')
2319
+ project_images_path = os.path.join(current_path, 'images') # Also check project images directory
2320
+ potential_root_paths.extend([project_models_path, project_images_path])
2321
+
2322
+ finetuned_models = []
2323
+
2324
+ print(f"🌋 (Internal) Searching for fine-tuned models in potential root paths: {set(potential_root_paths)}")
2325
+
2326
+ for root_path in set(potential_root_paths):
2327
+ if not os.path.exists(root_path) or not os.path.isdir(root_path):
2328
+ print(f"🌋 (Internal) Skipping non-existent or non-directory root path: {root_path}")
2329
+ continue
2330
+
2331
+ print(f"🌋 (Internal) Scanning root path: {root_path}")
2332
+ for model_dir_name in os.listdir(root_path):
2333
+ full_model_path = os.path.join(root_path, model_dir_name)
2334
+
2335
+ if not os.path.isdir(full_model_path):
2336
+ print(f"🌋 (Internal) Skipping {full_model_path}: Not a directory.")
2337
+ continue
2338
+
2339
+ # Check for 'model_final.pt' or the 'checkpoints' directory
2340
+ has_model_final_pt = os.path.exists(os.path.join(full_model_path, 'model_final.pt'))
2341
+ has_checkpoints_dir = os.path.isdir(os.path.join(full_model_path, 'checkpoints'))
2342
+
2343
+ if has_model_final_pt or has_checkpoints_dir:
2344
+ print(f"🌋 (Internal) Identified fine-tuned model: {model_dir_name} at {full_model_path} (found model_final.pt or checkpoints dir)")
2345
+ finetuned_models.append({
2346
+ "value": full_model_path, # This is the path to the directory containing the .pt files
2347
+ "provider": "diffusers", # Provider is still "diffusers"
2348
+ "display_name": f"{model_dir_name} | Fine-tuned Diffuser"
2349
+ })
2350
+ continue
2351
+
2352
+ print(f"🌋 (Internal) Skipping {full_model_path}: No model_final.pt or checkpoints directory found at root.")
2353
+
2354
+ print(f"🌋 (Internal) Finished scanning. Found {len(finetuned_models)} fine-tuned models.")
2355
+ # <--- CRITICAL FIX: Directly return the list of models, not a Flask Response
2356
+ return {"models": finetuned_models, "error": None} # Return a dict for consistency
1546
2357
  def get_available_image_models(current_path=None):
1547
2358
  """
1548
2359
  Retrieves available image generation models based on environment variables
1549
- and predefined configurations.
2360
+ and predefined configurations, including locally fine-tuned Diffusers models.
1550
2361
  """
1551
2362
 
1552
2363
  if current_path:
@@ -1554,7 +2365,7 @@ def get_available_image_models(current_path=None):
1554
2365
 
1555
2366
  all_image_models = []
1556
2367
 
1557
-
2368
+ # Add models configured via environment variables
1558
2369
  env_image_model = os.getenv("NPCSH_IMAGE_MODEL")
1559
2370
  env_image_provider = os.getenv("NPCSH_IMAGE_PROVIDER")
1560
2371
 
@@ -1565,9 +2376,8 @@ def get_available_image_models(current_path=None):
1565
2376
  "display_name": f"{env_image_model} | {env_image_provider} (Configured)"
1566
2377
  })
1567
2378
 
1568
-
2379
+ # Add predefined models (OpenAI, Gemini, and standard Diffusers)
1569
2380
  for provider_key, models_list in IMAGE_MODELS.items():
1570
-
1571
2381
  if provider_key == "openai":
1572
2382
  if os.environ.get("OPENAI_API_KEY"):
1573
2383
  all_image_models.extend([
@@ -1580,16 +2390,25 @@ def get_available_image_models(current_path=None):
1580
2390
  {**model, "provider": provider_key, "display_name": f"{model['display_name']} | {provider_key}"}
1581
2391
  for model in models_list
1582
2392
  ])
1583
- elif provider_key == "diffusers":
1584
-
1585
-
2393
+ elif provider_key == "diffusers": # This entry in IMAGE_MODELS is for standard diffusers
1586
2394
  all_image_models.extend([
1587
2395
  {**model, "provider": provider_key, "display_name": f"{model['display_name']} | {provider_key}"}
1588
2396
  for model in models_list
1589
2397
  ])
1590
2398
 
2399
+ # <--- CRITICAL FIX: Directly call the internal helper function for fine-tuned models
2400
+ try:
2401
+ finetuned_data_result = _get_finetuned_models_internal(current_path)
2402
+ if finetuned_data_result and finetuned_data_result.get("models"):
2403
+ all_image_models.extend(finetuned_data_result["models"])
2404
+ else:
2405
+ print(f"No fine-tuned models returned by internal helper or an error occurred internally.")
2406
+ if finetuned_data_result.get("error"):
2407
+ print(f"Internal error in _get_finetuned_models_internal: {finetuned_data_result['error']}")
2408
+ except Exception as e:
2409
+ print(f"Error calling _get_finetuned_models_internal: {e}")
1591
2410
 
1592
-
2411
+ # Deduplicate models
1593
2412
  seen_models = set()
1594
2413
  unique_models = []
1595
2414
  for model_entry in all_image_models:
@@ -1598,6 +2417,7 @@ def get_available_image_models(current_path=None):
1598
2417
  seen_models.add(key)
1599
2418
  unique_models.append(model_entry)
1600
2419
 
2420
+ # Return the combined, deduplicated list of models as a dictionary with a 'models' key
1601
2421
  return unique_models
1602
2422
 
1603
2423
  @app.route('/api/generative_fill', methods=['POST'])
@@ -1824,11 +2644,13 @@ def generate_images():
1824
2644
  if os.path.exists(image_path):
1825
2645
  try:
1826
2646
  pil_img = Image.open(image_path)
2647
+ pil_img = pil_img.convert("RGB")
2648
+ pil_img.thumbnail((1024, 1024))
1827
2649
  input_images.append(pil_img)
1828
2650
 
1829
-
1830
- with open(image_path, 'rb') as f:
1831
- img_data = f.read()
2651
+ compressed_bytes = BytesIO()
2652
+ pil_img.save(compressed_bytes, format="JPEG", quality=85, optimize=True)
2653
+ img_data = compressed_bytes.getvalue()
1832
2654
  attachments_loaded.append({
1833
2655
  "name": os.path.basename(image_path),
1834
2656
  "type": "images",
@@ -1932,20 +2754,31 @@ def get_mcp_tools():
1932
2754
  It will try to use an existing client from corca_states if available and matching,
1933
2755
  otherwise it creates a temporary client.
1934
2756
  """
1935
- server_path = request.args.get("mcpServerPath")
2757
+ raw_server_path = request.args.get("mcpServerPath")
2758
+ current_path_arg = request.args.get("currentPath")
1936
2759
  conversation_id = request.args.get("conversationId")
1937
2760
  npc_name = request.args.get("npc")
2761
+ selected_filter = request.args.get("selected", "")
2762
+ selected_names = [s.strip() for s in selected_filter.split(",") if s.strip()]
1938
2763
 
1939
- if not server_path:
2764
+ if not raw_server_path:
1940
2765
  return jsonify({"error": "mcpServerPath parameter is required."}), 400
1941
2766
 
1942
-
2767
+ # Normalize/expand the provided path so cwd/tilde don't break imports
2768
+ resolved_path = resolve_mcp_server_path(
2769
+ current_path=current_path_arg,
2770
+ explicit_path=raw_server_path,
2771
+ force_global=False
2772
+ )
2773
+ server_path = os.path.abspath(os.path.expanduser(resolved_path))
2774
+
1943
2775
  try:
1944
2776
  from npcsh.corca import MCPClientNPC
1945
2777
  except ImportError:
1946
2778
  return jsonify({"error": "MCP Client (npcsh.corca) not available. Ensure npcsh.corca is installed and importable."}), 500
1947
2779
 
1948
2780
  temp_mcp_client = None
2781
+ jinx_tools = []
1949
2782
  try:
1950
2783
 
1951
2784
  if conversation_id and npc_name and hasattr(app, 'corca_states'):
@@ -1956,13 +2789,38 @@ def get_mcp_tools():
1956
2789
  and existing_corca_state.mcp_client.server_script_path == server_path:
1957
2790
  print(f"Using existing MCP client for {state_key} to fetch tools.")
1958
2791
  temp_mcp_client = existing_corca_state.mcp_client
1959
- return jsonify({"tools": temp_mcp_client.available_tools_llm, "error": None})
2792
+ tools = temp_mcp_client.available_tools_llm
2793
+ if selected_names:
2794
+ tools = [t for t in tools if t.get("function", {}).get("name") in selected_names]
2795
+ return jsonify({"tools": tools, "error": None})
1960
2796
 
1961
2797
 
1962
2798
  print(f"Creating a temporary MCP client to fetch tools for {server_path}.")
1963
2799
  temp_mcp_client = MCPClientNPC()
1964
2800
  if temp_mcp_client.connect_sync(server_path):
1965
- return jsonify({"tools": temp_mcp_client.available_tools_llm, "error": None})
2801
+ tools = temp_mcp_client.available_tools_llm
2802
+ # Append Jinx-derived tools discovered from global/project jinxs
2803
+ try:
2804
+ jinx_dirs = []
2805
+ if current_path_arg:
2806
+ proj_jinx_dir = os.path.join(os.path.abspath(current_path_arg), "npc_team", "jinxs")
2807
+ if os.path.isdir(proj_jinx_dir):
2808
+ jinx_dirs.append(proj_jinx_dir)
2809
+ global_jinx_dir = os.path.expanduser("~/.npcsh/npc_team/jinxs")
2810
+ if os.path.isdir(global_jinx_dir):
2811
+ jinx_dirs.append(global_jinx_dir)
2812
+ all_jinxs = []
2813
+ for d in jinx_dirs:
2814
+ all_jinxs.extend(load_jinxs_from_directory(d))
2815
+ if all_jinxs:
2816
+ jinx_tools = list(build_jinx_tool_catalog({j.jinx_name: j for j in all_jinxs}).values())
2817
+ print(f"[MCP] Discovered {len(jinx_tools)} Jinx tools for listing.")
2818
+ tools = tools + jinx_tools
2819
+ except Exception as e:
2820
+ print(f"[MCP] Error discovering Jinx tools for listing: {e}")
2821
+ if selected_names:
2822
+ tools = [t for t in tools if t.get("function", {}).get("name") in selected_names]
2823
+ return jsonify({"tools": tools, "error": None})
1966
2824
  else:
1967
2825
  return jsonify({"error": f"Failed to connect to MCP server at {server_path}."}), 500
1968
2826
  except FileNotFoundError as e:
@@ -1981,6 +2839,64 @@ def get_mcp_tools():
1981
2839
  temp_mcp_client.disconnect_sync()
1982
2840
 
1983
2841
 
2842
+ @app.route("/api/mcp/server/resolve", methods=["GET"])
2843
+ def api_mcp_resolve():
2844
+ current_path = request.args.get("currentPath")
2845
+ explicit = request.args.get("serverPath")
2846
+ try:
2847
+ resolved = resolve_mcp_server_path(current_path=current_path, explicit_path=explicit)
2848
+ return jsonify({"serverPath": resolved, "error": None})
2849
+ except Exception as e:
2850
+ return jsonify({"serverPath": None, "error": str(e)}), 500
2851
+
2852
+
2853
+ @app.route("/api/mcp/server/start", methods=["POST"])
2854
+ def api_mcp_start():
2855
+ data = request.get_json() or {}
2856
+ current_path = data.get("currentPath")
2857
+ explicit = data.get("serverPath")
2858
+ try:
2859
+ server_path = resolve_mcp_server_path(current_path=current_path, explicit_path=explicit)
2860
+ result = mcp_server_manager.start(server_path)
2861
+ return jsonify({**result, "error": None})
2862
+ except Exception as e:
2863
+ print(f"Error starting MCP server: {e}")
2864
+ traceback.print_exc()
2865
+ return jsonify({"error": str(e)}), 500
2866
+
2867
+
2868
+ @app.route("/api/mcp/server/stop", methods=["POST"])
2869
+ def api_mcp_stop():
2870
+ data = request.get_json() or {}
2871
+ explicit = data.get("serverPath")
2872
+ if not explicit:
2873
+ return jsonify({"error": "serverPath is required to stop a server."}), 400
2874
+ try:
2875
+ result = mcp_server_manager.stop(explicit)
2876
+ return jsonify({**result, "error": None})
2877
+ except Exception as e:
2878
+ print(f"Error stopping MCP server: {e}")
2879
+ traceback.print_exc()
2880
+ return jsonify({"error": str(e)}), 500
2881
+
2882
+
2883
+ @app.route("/api/mcp/server/status", methods=["GET"])
2884
+ def api_mcp_status():
2885
+ explicit = request.args.get("serverPath")
2886
+ current_path = request.args.get("currentPath")
2887
+ try:
2888
+ if explicit:
2889
+ result = mcp_server_manager.status(explicit)
2890
+ else:
2891
+ resolved = resolve_mcp_server_path(current_path=current_path, explicit_path=explicit)
2892
+ result = mcp_server_manager.status(resolved)
2893
+ return jsonify({**result, "running": result.get("status") == "running", "all": mcp_server_manager.running(), "error": None})
2894
+ except Exception as e:
2895
+ print(f"Error checking MCP server status: {e}")
2896
+ traceback.print_exc()
2897
+ return jsonify({"error": str(e)}), 500
2898
+
2899
+
1984
2900
  @app.route("/api/image_models", methods=["GET"])
1985
2901
  def get_image_models_api():
1986
2902
  """
@@ -1989,6 +2905,7 @@ def get_image_models_api():
1989
2905
  current_path = request.args.get("currentPath")
1990
2906
  try:
1991
2907
  image_models = get_available_image_models(current_path)
2908
+ print('image models', image_models)
1992
2909
  return jsonify({"models": image_models, "error": None})
1993
2910
  except Exception as e:
1994
2911
  print(f"Error getting available image models: {str(e)}")
@@ -2000,6 +2917,195 @@ def get_image_models_api():
2000
2917
 
2001
2918
 
2002
2919
 
2920
+ def _run_stream_post_processing(
2921
+ conversation_turn_text,
2922
+ conversation_id,
2923
+ command_history,
2924
+ npc_name,
2925
+ team_name,
2926
+ current_path,
2927
+ model,
2928
+ provider,
2929
+ npc_object,
2930
+ messages # For context compression
2931
+ ):
2932
+ """
2933
+ Runs memory extraction and context compression in a background thread.
2934
+ These operations will not block the main stream.
2935
+ """
2936
+ print(f"🌋 Background task started for conversation {conversation_id}!")
2937
+
2938
+ # Memory extraction and KG fact insertion
2939
+ try:
2940
+ if len(conversation_turn_text) > 50: # Only extract memories if the turn is substantial
2941
+ memories_for_approval = extract_and_store_memories(
2942
+ conversation_turn_text,
2943
+ conversation_id,
2944
+ command_history,
2945
+ npc_name,
2946
+ team_name,
2947
+ current_path,
2948
+ model,
2949
+ provider,
2950
+ npc_object
2951
+ )
2952
+ if memories_for_approval:
2953
+ print(f"🔥 Background: Extracted {len(memories_for_approval)} memories for approval for conversation {conversation_id}. Stored as pending in the database (table: memory_lifecycle).")
2954
+ else:
2955
+ print(f"Background: Conversation turn too short ({len(conversation_turn_text)} chars) for memory extraction. Skipping.")
2956
+ except Exception as e:
2957
+ print(f"🌋 Background: Error during memory extraction and KG insertion for conversation {conversation_id}: {e}")
2958
+ traceback.print_exc()
2959
+
2960
+ # Context compression using breathe from llm_funcs
2961
+ try:
2962
+ if len(messages) > 30: # Use the threshold specified in your request
2963
+ # Directly call breathe for summarization
2964
+ breathe_result = breathe(
2965
+ messages=messages,
2966
+ model=model,
2967
+ provider=provider,
2968
+ npc=npc_object # Pass npc for context if available
2969
+ )
2970
+ compressed_output = breathe_result.get('output', '')
2971
+
2972
+ if compressed_output:
2973
+ # Save the compressed context as a new system message in conversation_history
2974
+ compressed_message_id = generate_message_id()
2975
+ save_conversation_message(
2976
+ command_history,
2977
+ conversation_id,
2978
+ "system", # Role for compressed context
2979
+ f"[AUTOMATIC CONTEXT COMPRESSION]: {compressed_output}",
2980
+ wd=current_path,
2981
+ model=model, # Use the same model/provider that generated the summary
2982
+ provider=provider,
2983
+ npc=npc_name, # Associate with the NPC
2984
+ team=team_name, # Associate with the team
2985
+ message_id=compressed_message_id
2986
+ )
2987
+ print(f"💨 Background: Compressed context for conversation {conversation_id} saved as new system message: {compressed_output[:100]}...")
2988
+ else:
2989
+ print(f"Background: Context compression returned no output for conversation {conversation_id}. Skipping saving.")
2990
+ else:
2991
+ print(f"Background: Conversation messages count ({len(messages)}) below threshold for context compression. Skipping.")
2992
+ except Exception as e:
2993
+ print(f"🌋 Background: Error during context compression with breathe for conversation {conversation_id}: {e}")
2994
+ traceback.print_exc()
2995
+
2996
+ print(f"🌋 Background task finished for conversation {conversation_id}!")
2997
+
2998
+
2999
+
3000
+
3001
+ @app.route("/api/text_predict", methods=["POST"])
3002
+ def text_predict():
3003
+ data = request.json
3004
+
3005
+ stream_id = data.get("streamId")
3006
+ if not stream_id:
3007
+ stream_id = str(uuid.uuid4())
3008
+
3009
+ with cancellation_lock:
3010
+ cancellation_flags[stream_id] = False
3011
+
3012
+ print(f"Starting text prediction stream with ID: {stream_id}")
3013
+ print('data')
3014
+
3015
+
3016
+ text_content = data.get("text_content", "")
3017
+ cursor_position = data.get("cursor_position", len(text_content))
3018
+ current_path = data.get("currentPath")
3019
+ model = data.get("model")
3020
+ provider = data.get("provider")
3021
+ context_type = data.get("context_type", "general") # e.g., 'code', 'chat', 'general'
3022
+ file_path = data.get("file_path") # Optional: for code context
3023
+
3024
+ if current_path:
3025
+ load_project_env(current_path)
3026
+
3027
+ text_before_cursor = text_content[:cursor_position]
3028
+
3029
+
3030
+ if context_type == 'code':
3031
+ prompt_for_llm = f"You are an AI code completion assistant. Your task is to complete the provided code snippet.\nYou MUST ONLY output the code that directly completes the snippet.\nDO NOT include any explanations, comments, or additional text.\nDO NOT wrap the completion in markdown code blocks.\n\nHere is the code context where the completion should occur (file: {file_path or 'unknown'}):\n\n{text_before_cursor}\n\nPlease provide the completion starting from the end of the last line shown.\n"
3032
+ system_prompt = "You are an AI code completion assistant. Only provide code. Do not add explanations or any other text."
3033
+ elif context_type == 'chat':
3034
+ prompt_for_llm = f"You are an AI chat assistant. Your task is to provide a natural and helpful completion to the user's ongoing message.\nYou MUST ONLY output the text that directly completes the message.\nDO NOT include any explanations or additional text.\n\nHere is the message context where the completion should occur:\n\n{text_before_cursor}\n\nPlease provide the completion starting from the end of the last line shown.\n"
3035
+ system_prompt = "You are an AI chat assistant. Only provide natural language completion. Do not add explanations or any other text."
3036
+ else: # general text prediction
3037
+ prompt_for_llm = f"You are an AI text completion assistant. Your task is to provide a natural and helpful completion to the user's ongoing text.\nYou MUST ONLY output the text that directly completes the snippet.\nDO NOT include any explanations or additional text.\n\nHere is the text context where the completion should occur:\n\n{text_before_cursor}\n\nPlease provide the completion starting from the end of the last line shown.\n"
3038
+ system_prompt = "You are an AI text completion assistant. Only provide natural language completion. Do not add explanations or any other text."
3039
+
3040
+
3041
+ npc_object = None # For prediction, we don't necessarily use a specific NPC
3042
+
3043
+ messages_for_llm = [
3044
+ {"role": "system", "content": system_prompt},
3045
+ {"role": "user", "content": prompt_for_llm}
3046
+ ]
3047
+
3048
+ def event_stream_text_predict(current_stream_id):
3049
+ complete_prediction = []
3050
+ try:
3051
+ stream_response_generator = get_llm_response(
3052
+ prompt_for_llm,
3053
+ messages=messages_for_llm,
3054
+ model=model,
3055
+ provider=provider,
3056
+ npc=npc_object,
3057
+ stream=True,
3058
+ )
3059
+
3060
+ # get_llm_response returns a dict with 'response' as a generator when stream=True
3061
+ if isinstance(stream_response_generator, dict) and 'response' in stream_response_generator:
3062
+ stream_generator = stream_response_generator['response']
3063
+ else:
3064
+ # Fallback for non-streaming LLM responses or errors
3065
+ output_content = ""
3066
+ if isinstance(stream_response_generator, dict) and 'output' in stream_response_generator:
3067
+ output_content = stream_response_generator['output']
3068
+ elif isinstance(stream_response_generator, str):
3069
+ output_content = stream_response_generator
3070
+
3071
+ yield f"data: {json.dumps({'choices': [{'delta': {'content': output_content}}]})}\n\n"
3072
+ yield f"data: [DONE]\n\n"
3073
+ return
3074
+
3075
+
3076
+ for response_chunk in stream_generator:
3077
+ with cancellation_lock:
3078
+ if cancellation_flags.get(current_stream_id, False):
3079
+ print(f"Cancellation flag triggered for {current_stream_id}. Breaking loop.")
3080
+ break
3081
+
3082
+ chunk_content = ""
3083
+ # Handle different LLM API response formats
3084
+ if "hf.co" in model or (provider == 'ollama' and 'gpt-oss' not in model): # Heuristic for Ollama/HF models
3085
+ chunk_content = response_chunk["message"]["content"] if "message" in response_chunk and "content" in response_chunk["message"] else ""
3086
+ else: # Assume OpenAI-like streaming format
3087
+ chunk_content = "".join(choice.delta.content for choice in response_chunk.choices if choice.delta.content is not None)
3088
+
3089
+ print(chunk_content, end='')
3090
+
3091
+ if chunk_content:
3092
+ complete_prediction.append(chunk_content)
3093
+ yield f"data: {json.dumps({'choices': [{'delta': {'content': chunk_content}}]})}\n\n"
3094
+
3095
+ except Exception as e:
3096
+ print(f"\nAn exception occurred during text prediction streaming for {current_stream_id}: {e}")
3097
+ traceback.print_exc()
3098
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
3099
+
3100
+ finally:
3101
+ print(f"\nText prediction stream {current_stream_id} finished.")
3102
+ yield f"data: [DONE]\n\n" # Signal end of stream
3103
+ with cancellation_lock:
3104
+ if current_stream_id in cancellation_flags:
3105
+ del cancellation_flags[current_stream_id]
3106
+ print(f"Cleaned up cancellation flag for stream ID: {current_stream_id}")
3107
+
3108
+ return Response(event_stream_text_predict(stream_id), mimetype="text/event-stream")
2003
3109
 
2004
3110
  @app.route("/api/stream", methods=["POST"])
2005
3111
  def stream():
@@ -2016,6 +3122,8 @@ def stream():
2016
3122
 
2017
3123
  commandstr = data.get("commandstr")
2018
3124
  conversation_id = data.get("conversationId")
3125
+ if not conversation_id:
3126
+ return jsonify({"error": "conversationId is required"}), 400
2019
3127
  model = data.get("model", None)
2020
3128
  provider = data.get("provider", None)
2021
3129
  if provider is None:
@@ -2033,6 +3141,7 @@ def stream():
2033
3141
  npc_object = None
2034
3142
  team_object = None
2035
3143
  team = None
3144
+ tool_results_for_db = []
2036
3145
  if npc_name:
2037
3146
  if hasattr(app, 'registered_teams'):
2038
3147
  for team_name, team_object in app.registered_teams.items():
@@ -2195,7 +3304,9 @@ def stream():
2195
3304
  if 'tools' in tool_args and tool_args['tools']:
2196
3305
  tool_args['tool_choice'] = {"type": "auto"}
2197
3306
 
2198
-
3307
+ # Default stream response so closures below always have a value
3308
+ stream_response = {"output": "", "messages": messages}
3309
+
2199
3310
  exe_mode = data.get('executionMode','chat')
2200
3311
 
2201
3312
  if exe_mode == 'chat':
@@ -2269,91 +3380,260 @@ def stream():
2269
3380
  )
2270
3381
  messages = state.messages
2271
3382
 
2272
- elif exe_mode == 'corca':
2273
-
2274
- try:
2275
- from npcsh.corca import execute_command_corca, create_corca_state_and_mcp_client, MCPClientNPC
2276
- from npcsh._state import initial_state as state
2277
- except ImportError:
2278
-
2279
- print("ERROR: npcsh.corca or MCPClientNPC not found. Corca mode is disabled.", file=sys.stderr)
2280
- state = None
2281
- stream_response = {"output": "Corca mode is not available due to missing dependencies.", "messages": messages}
2282
-
2283
-
2284
- if state is not None:
2285
-
2286
- mcp_server_path_from_request = data.get("mcpServerPath")
2287
- selected_mcp_tools_from_request = data.get("selectedMcpTools", [])
2288
-
2289
-
2290
- effective_mcp_server_path = mcp_server_path_from_request
2291
- if not effective_mcp_server_path and team_object and hasattr(team_object, 'team_ctx') and team_object.team_ctx:
2292
- mcp_servers_list = team_object.team_ctx.get('mcp_servers', [])
2293
- if mcp_servers_list and isinstance(mcp_servers_list, list):
2294
- first_server_obj = next((s for s in mcp_servers_list if isinstance(s, dict) and 'value' in s), None)
2295
- if first_server_obj:
2296
- effective_mcp_server_path = first_server_obj['value']
2297
- elif isinstance(team_object.team_ctx.get('mcp_server'), str):
2298
- effective_mcp_server_path = team_object.team_ctx.get('mcp_server')
3383
+ elif exe_mode == 'tool_agent':
3384
+ mcp_server_path_from_request = data.get("mcpServerPath")
3385
+ selected_mcp_tools_from_request = data.get("selectedMcpTools", [])
3386
+
3387
+ # Resolve MCP server path (explicit -> team ctx -> default resolver)
3388
+ effective_mcp_server_path = mcp_server_path_from_request
3389
+ if not effective_mcp_server_path and team_object and hasattr(team_object, 'team_ctx') and team_object.team_ctx:
3390
+ mcp_servers_list = team_object.team_ctx.get('mcp_servers', [])
3391
+ if mcp_servers_list and isinstance(mcp_servers_list, list):
3392
+ first_server_obj = next((s for s in mcp_servers_list if isinstance(s, dict) and 'value' in s), None)
3393
+ if first_server_obj:
3394
+ effective_mcp_server_path = first_server_obj['value']
3395
+ elif isinstance(team_object.team_ctx.get('mcp_server'), str):
3396
+ effective_mcp_server_path = team_object.team_ctx.get('mcp_server')
3397
+
3398
+ effective_mcp_server_path = resolve_mcp_server_path(
3399
+ current_path=current_path,
3400
+ explicit_path=effective_mcp_server_path,
3401
+ force_global=False
3402
+ )
3403
+ print(f"[MCP] effective server path: {effective_mcp_server_path}")
3404
+
3405
+ if not hasattr(app, 'mcp_clients'):
3406
+ app.mcp_clients = {}
3407
+
3408
+ state_key = f"{conversation_id}_{npc_name or 'default'}"
3409
+ client_entry = app.mcp_clients.get(state_key)
3410
+
3411
+ if not client_entry or not client_entry.get("client") or not client_entry["client"].session \
3412
+ or client_entry.get("server_path") != effective_mcp_server_path:
3413
+ mcp_client = MCPClientNPC()
3414
+ if effective_mcp_server_path and mcp_client.connect_sync(effective_mcp_server_path):
3415
+ print(f"[MCP] connected client for {state_key} to {effective_mcp_server_path}")
3416
+ app.mcp_clients[state_key] = {
3417
+ "client": mcp_client,
3418
+ "server_path": effective_mcp_server_path,
3419
+ "messages": messages
3420
+ }
3421
+ else:
3422
+ print(f"[MCP] Failed to connect client for {state_key} to {effective_mcp_server_path}")
3423
+ app.mcp_clients[state_key] = {
3424
+ "client": None,
3425
+ "server_path": effective_mcp_server_path,
3426
+ "messages": messages
3427
+ }
2299
3428
 
2300
-
2301
- if not hasattr(app, 'corca_states'):
2302
- app.corca_states = {}
2303
-
2304
- state_key = f"{conversation_id}_{npc_name or 'default'}"
2305
-
2306
- corca_state = None
2307
- if state_key not in app.corca_states:
2308
-
2309
- corca_state = create_corca_state_and_mcp_client(
2310
- conversation_id=conversation_id,
2311
- command_history=command_history,
3429
+ mcp_client = app.mcp_clients[state_key]["client"]
3430
+ messages = app.mcp_clients[state_key].get("messages", messages)
3431
+
3432
+ def stream_mcp_sse():
3433
+ nonlocal messages
3434
+ iteration = 0
3435
+ prompt = commandstr
3436
+ while iteration < 10:
3437
+ iteration += 1
3438
+ print(f"[MCP] iteration {iteration} prompt len={len(prompt)}")
3439
+ jinx_tool_catalog = {}
3440
+ if npc_object and hasattr(npc_object, "jinx_tool_catalog"):
3441
+ jinx_tool_catalog = npc_object.jinx_tool_catalog or {}
3442
+ tools_for_llm = []
3443
+ if mcp_client:
3444
+ tools_for_llm.extend(mcp_client.available_tools_llm)
3445
+ # append Jinx-derived tools
3446
+ tools_for_llm.extend(list(jinx_tool_catalog.values()))
3447
+ if selected_mcp_tools_from_request:
3448
+ tools_for_llm = [t for t in tools_for_llm if t["function"]["name"] in selected_mcp_tools_from_request]
3449
+ print(f"[MCP] tools_for_llm: {[t['function']['name'] for t in tools_for_llm]}")
3450
+
3451
+ llm_response = get_llm_response_with_handling(
3452
+ prompt=prompt,
2312
3453
  npc=npc_object,
3454
+ messages=messages,
3455
+ tools=tools_for_llm,
3456
+ stream=True,
2313
3457
  team=team_object,
2314
- current_path=current_path,
2315
- mcp_server_path=effective_mcp_server_path
3458
+ context=f' The users working directory is {current_path}'
2316
3459
  )
2317
- app.corca_states[state_key] = corca_state
2318
- else:
2319
- corca_state = app.corca_states[state_key]
2320
- corca_state.npc = npc_object
2321
- corca_state.team = team_object
2322
- corca_state.current_path = current_path
2323
- corca_state.messages = messages
2324
- corca_state.command_history = command_history
2325
3460
 
2326
-
2327
- current_mcp_client_path = getattr(corca_state.mcp_client, 'server_script_path', None)
2328
-
2329
- if effective_mcp_server_path != current_mcp_client_path:
2330
- print(f"MCP server path changed/updated for {state_key}. Disconnecting old client (if any) and reconnecting to {effective_mcp_server_path or 'None'}.")
2331
- if corca_state.mcp_client and corca_state.mcp_client.session:
2332
- corca_state.mcp_client.disconnect_sync()
2333
- corca_state.mcp_client = None
2334
-
2335
- if effective_mcp_server_path:
2336
- new_mcp_client = MCPClientNPC()
2337
- if new_mcp_client.connect_sync(effective_mcp_server_path):
2338
- corca_state.mcp_client = new_mcp_client
2339
- print(f"Successfully reconnected MCP client for {state_key} to {effective_mcp_server_path}.")
3461
+ stream = llm_response.get("response", [])
3462
+ messages = llm_response.get("messages", messages)
3463
+ collected_content = ""
3464
+ collected_tool_calls = []
3465
+
3466
+ for response_chunk in stream:
3467
+ with cancellation_lock:
3468
+ if cancellation_flags.get(stream_id, False):
3469
+ yield {"type": "interrupt"}
3470
+ return
3471
+
3472
+ if hasattr(response_chunk, "choices") and response_chunk.choices:
3473
+ delta = response_chunk.choices[0].delta
3474
+ if hasattr(delta, "content") and delta.content:
3475
+ collected_content += delta.content
3476
+ chunk_data = {
3477
+ "id": getattr(response_chunk, "id", None),
3478
+ "object": getattr(response_chunk, "object", None),
3479
+ "created": getattr(response_chunk, "created", datetime.datetime.now().strftime('YYYY-DD-MM-HHMMSS')),
3480
+ "model": getattr(response_chunk, "model", model),
3481
+ "choices": [
3482
+ {
3483
+ "index": 0,
3484
+ "delta": {
3485
+ "content": delta.content,
3486
+ "role": "assistant"
3487
+ },
3488
+ "finish_reason": None
3489
+ }
3490
+ ]
3491
+ }
3492
+ yield chunk_data
3493
+
3494
+ if hasattr(delta, "tool_calls") and delta.tool_calls:
3495
+ for tool_call_delta in delta.tool_calls:
3496
+ idx = getattr(tool_call_delta, "index", 0)
3497
+ while len(collected_tool_calls) <= idx:
3498
+ collected_tool_calls.append({
3499
+ "id": "",
3500
+ "type": "function",
3501
+ "function": {"name": "", "arguments": ""}
3502
+ })
3503
+ if getattr(tool_call_delta, "id", None):
3504
+ collected_tool_calls[idx]["id"] = tool_call_delta.id
3505
+ if hasattr(tool_call_delta, "function"):
3506
+ fn = tool_call_delta.function
3507
+ if getattr(fn, "name", None):
3508
+ collected_tool_calls[idx]["function"]["name"] = fn.name
3509
+ if getattr(fn, "arguments", None):
3510
+ collected_tool_calls[idx]["function"]["arguments"] += fn.arguments
3511
+
3512
+ if not collected_tool_calls:
3513
+ print("[MCP] no tool calls, finishing streaming loop")
3514
+ break
3515
+
3516
+ print(f"[MCP] collected tool calls: {[tc['function']['name'] for tc in collected_tool_calls]}")
3517
+ yield {
3518
+ "type": "tool_execution_start",
3519
+ "tool_calls": [
3520
+ {
3521
+ "name": tc["function"]["name"],
3522
+ "id": tc["id"],
3523
+ "function": {
3524
+ "name": tc["function"]["name"],
3525
+ "arguments": tc["function"].get("arguments", "")
3526
+ }
3527
+ } for tc in collected_tool_calls
3528
+ ]
3529
+ }
3530
+
3531
+ tool_results = []
3532
+ for tc in collected_tool_calls:
3533
+ tool_name = tc["function"]["name"]
3534
+ tool_args = tc["function"]["arguments"]
3535
+ tool_id = tc["id"]
3536
+
3537
+ if isinstance(tool_args, str):
3538
+ try:
3539
+ tool_args = json.loads(tool_args) if tool_args.strip() else {}
3540
+ except json.JSONDecodeError:
3541
+ tool_args = {}
3542
+
3543
+ print(f"[MCP] tool_start {tool_name} args={tool_args}")
3544
+ yield {"type": "tool_start", "name": tool_name, "id": tool_id, "args": tool_args}
3545
+ try:
3546
+ tool_content = ""
3547
+ # First, try local Jinx execution
3548
+ if npc_object and hasattr(npc_object, "jinxs_dict") and tool_name in npc_object.jinxs_dict:
3549
+ jinx_obj = npc_object.jinxs_dict[tool_name]
3550
+ try:
3551
+ jinx_ctx = jinx_obj.execute(
3552
+ input_values=tool_args if isinstance(tool_args, dict) else {},
3553
+ npc=npc_object,
3554
+ messages=messages
3555
+ )
3556
+ tool_content = str(jinx_ctx.get("output", jinx_ctx))
3557
+ print(f"[MCP] jinx tool_complete {tool_name}")
3558
+ except Exception as e:
3559
+ raise Exception(f"Jinx execution failed: {e}")
2340
3560
  else:
2341
- print(f"Failed to reconnect MCP client for {state_key} to {effective_mcp_server_path}. Corca will have no tools.")
2342
- corca_state.mcp_client = None
2343
-
2344
-
2345
-
2346
- state, stream_response = execute_command_corca(
2347
- commandstr,
2348
- corca_state,
2349
- command_history,
2350
- selected_mcp_tools_names=selected_mcp_tools_from_request
2351
- )
2352
-
2353
-
2354
- app.corca_states[state_key] = state
2355
- messages = state.messages
3561
+ try:
3562
+ loop = asyncio.get_event_loop()
3563
+ except RuntimeError:
3564
+ loop = asyncio.new_event_loop()
3565
+ asyncio.set_event_loop(loop)
3566
+ if loop.is_closed():
3567
+ loop = asyncio.new_event_loop()
3568
+ asyncio.set_event_loop(loop)
3569
+ mcp_result = loop.run_until_complete(
3570
+ mcp_client.session.call_tool(tool_name, tool_args)
3571
+ ) if mcp_client else {"error": "No MCP client"}
3572
+ if hasattr(mcp_result, "content") and mcp_result.content:
3573
+ for content_item in mcp_result.content:
3574
+ if hasattr(content_item, "text"):
3575
+ tool_content += content_item.text
3576
+ elif hasattr(content_item, "data"):
3577
+ tool_content += str(content_item.data)
3578
+ else:
3579
+ tool_content += str(content_item)
3580
+ else:
3581
+ tool_content = str(mcp_result)
3582
+
3583
+ tool_results.append({
3584
+ "role": "tool",
3585
+ "tool_call_id": tool_id,
3586
+ "name": tool_name,
3587
+ "content": tool_content
3588
+ })
3589
+
3590
+ print(f"[MCP] tool_complete {tool_name}")
3591
+ yield {"type": "tool_complete", "name": tool_name, "id": tool_id, "result_preview": tool_content[:4000]}
3592
+ except Exception as e:
3593
+ err_msg = f"Error executing {tool_name}: {e}"
3594
+ tool_results.append({
3595
+ "role": "tool",
3596
+ "tool_call_id": tool_id,
3597
+ "name": tool_name,
3598
+ "content": err_msg
3599
+ })
3600
+ print(f"[MCP] tool_error {tool_name}: {e}")
3601
+ yield {"type": "tool_error", "name": tool_name, "id": tool_id, "error": str(e)}
3602
+
3603
+ serialized_tool_calls = []
3604
+ for tc in collected_tool_calls:
3605
+ parsed_args = tc["function"]["arguments"]
3606
+ # Gemini/LLM expects arguments as JSON string, not dict
3607
+ if isinstance(parsed_args, dict):
3608
+ args_for_message = json.dumps(parsed_args)
3609
+ else:
3610
+ args_for_message = str(parsed_args)
3611
+ serialized_tool_calls.append({
3612
+ "id": tc["id"],
3613
+ "type": tc["type"],
3614
+ "function": {
3615
+ "name": tc["function"]["name"],
3616
+ "arguments": args_for_message
3617
+ }
3618
+ })
3619
+
3620
+ messages.append({
3621
+ "role": "assistant",
3622
+ "content": collected_content,
3623
+ "tool_calls": serialized_tool_calls
3624
+ })
3625
+ messages.extend(tool_results)
3626
+ tool_results_for_db = tool_results
3627
+
3628
+ prompt = ""
2356
3629
 
3630
+ app.mcp_clients[state_key]["messages"] = messages
3631
+ return
3632
+
3633
+ stream_response = stream_mcp_sse()
3634
+
3635
+ else:
3636
+ stream_response = {"output": f"Unsupported execution mode: {exe_mode}", "messages": messages}
2357
3637
 
2358
3638
  user_message_filled = ''
2359
3639
 
@@ -2391,47 +3671,77 @@ def stream():
2391
3671
  tool_call_data = {"id": None, "function_name": None, "arguments": ""}
2392
3672
 
2393
3673
  try:
3674
+ # New: handle generators (tool_agent streaming)
3675
+ if hasattr(stream_response, "__iter__") and not isinstance(stream_response, (dict, str)):
3676
+ for chunk in stream_response:
3677
+ with cancellation_lock:
3678
+ if cancellation_flags.get(current_stream_id, False):
3679
+ interrupted = True
3680
+ break
3681
+ if chunk is None:
3682
+ continue
3683
+ if isinstance(chunk, dict):
3684
+ if chunk.get("type") == "interrupt":
3685
+ interrupted = True
3686
+ break
3687
+ yield f"data: {json.dumps(chunk)}\n\n"
3688
+ if chunk.get("choices"):
3689
+ for choice in chunk["choices"]:
3690
+ delta = choice.get("delta", {})
3691
+ content_piece = delta.get("content")
3692
+ if content_piece:
3693
+ complete_response.append(content_piece)
3694
+ continue
3695
+ yield f"data: {json.dumps({'choices':[{'delta':{'content': str(chunk), 'role': 'assistant'},'finish_reason':None}]})}\n\n"
3696
+ # ensure stream termination and cleanup for generator flows
3697
+ yield "data: [DONE]\n\n"
3698
+ with cancellation_lock:
3699
+ if current_stream_id in cancellation_flags:
3700
+ del cancellation_flags[current_stream_id]
3701
+ print(f"Cleaned up cancellation flag for stream ID: {current_stream_id}")
3702
+ return
3703
+
2394
3704
  if isinstance(stream_response, str) :
2395
3705
  print('stream a str and not a gen')
2396
3706
  chunk_data = {
2397
- "id": None,
2398
- "object": None,
2399
- "created": datetime.datetime.now().strftime('YYYY-DD-MM-HHMMSS'),
3707
+ "id": None,
3708
+ "object": None,
3709
+ "created": datetime.datetime.now().strftime('YYYY-DD-MM-HHMMSS'),
2400
3710
  "model": model,
2401
3711
  "choices": [
2402
3712
  {
2403
- "index": 0,
2404
- "delta":
3713
+ "index": 0,
3714
+ "delta":
2405
3715
  {
2406
3716
  "content": stream_response,
2407
3717
  "role": "assistant"
2408
- },
3718
+ },
2409
3719
  "finish_reason": 'done'
2410
3720
  }
2411
3721
  ]
2412
3722
  }
2413
- yield f"data: {json.dumps(chunk_data)}"
3723
+ yield f"data: {json.dumps(chunk_data)}\n\n"
2414
3724
  return
2415
3725
  elif isinstance(stream_response, dict) and 'output' in stream_response and isinstance(stream_response.get('output'), str):
2416
- print('stream a str and not a gen')
3726
+ print('stream a str and not a gen')
2417
3727
  chunk_data = {
2418
- "id": None,
2419
- "object": None,
2420
- "created": datetime.datetime.now().strftime('YYYY-DD-MM-HHMMSS'),
3728
+ "id": None,
3729
+ "object": None,
3730
+ "created": datetime.datetime.now().strftime('YYYY-DD-MM-HHMMSS'),
2421
3731
  "model": model,
2422
3732
  "choices": [
2423
3733
  {
2424
- "index": 0,
2425
- "delta":
3734
+ "index": 0,
3735
+ "delta":
2426
3736
  {
2427
3737
  "content": stream_response.get('output') ,
2428
3738
  "role": "assistant"
2429
- },
3739
+ },
2430
3740
  "finish_reason": 'done'
2431
3741
  }
2432
3742
  ]
2433
3743
  }
2434
- yield f"data: {json.dumps(chunk_data)}"
3744
+ yield f"data: {json.dumps(chunk_data)}\n\n"
2435
3745
  return
2436
3746
  for response_chunk in stream_response.get('response', stream_response.get('output')):
2437
3747
  with cancellation_lock:
@@ -2459,8 +3769,8 @@ def stream():
2459
3769
  if chunk_content:
2460
3770
  complete_response.append(chunk_content)
2461
3771
  chunk_data = {
2462
- "id": None, "object": None,
2463
- "created": response_chunk["created_at"] or datetime.datetime.now(),
3772
+ "id": None, "object": None,
3773
+ "created": response_chunk["created_at"] or datetime.datetime.now(),
2464
3774
  "model": response_chunk["model"],
2465
3775
  "choices": [{"index": 0, "delta": {"content": chunk_content, "role": response_chunk["message"]["role"]}, "finish_reason": response_chunk.get("done_reason")}]
2466
3776
  }
@@ -2494,33 +3804,86 @@ def stream():
2494
3804
  print(f"\nAn exception occurred during streaming for {current_stream_id}: {e}")
2495
3805
  traceback.print_exc()
2496
3806
  interrupted = True
2497
-
3807
+
2498
3808
  finally:
2499
3809
  print(f"\nStream {current_stream_id} finished. Interrupted: {interrupted}")
2500
3810
  print('\r' + ' ' * dot_count*2 + '\r', end="", flush=True)
2501
3811
 
2502
3812
  final_response_text = ''.join(complete_response)
3813
+
3814
+ # Yield message_stop immediately so the client's stream ends quickly
2503
3815
  yield f"data: {json.dumps({'type': 'message_stop'})}\n\n"
2504
-
3816
+
3817
+ # Persist tool call metadata and results before final assistant content
3818
+ if tool_call_data.get("function_name") or tool_call_data.get("arguments"):
3819
+ save_conversation_message(
3820
+ command_history,
3821
+ conversation_id,
3822
+ "assistant",
3823
+ {"tool_call": tool_call_data},
3824
+ wd=current_path,
3825
+ model=model,
3826
+ provider=provider,
3827
+ npc=npc_name,
3828
+ team=team,
3829
+ message_id=generate_message_id(),
3830
+ )
3831
+
3832
+ if tool_results_for_db:
3833
+ for tr in tool_results_for_db:
3834
+ save_conversation_message(
3835
+ command_history,
3836
+ conversation_id,
3837
+ "tool",
3838
+ {"tool_name": tr.get("name"), "tool_call_id": tr.get("tool_call_id"), "content": tr.get("content")},
3839
+ wd=current_path,
3840
+ model=model,
3841
+ provider=provider,
3842
+ npc=npc_name,
3843
+ team=team,
3844
+ message_id=generate_message_id(),
3845
+ )
3846
+
3847
+ # Save assistant message to the database
2505
3848
  npc_name_to_save = npc_object.name if npc_object else ''
2506
3849
  save_conversation_message(
2507
- command_history,
2508
- conversation_id,
2509
- "assistant",
3850
+ command_history,
3851
+ conversation_id,
3852
+ "assistant",
2510
3853
  final_response_text,
2511
- wd=current_path,
2512
- model=model,
3854
+ wd=current_path,
3855
+ model=model,
2513
3856
  provider=provider,
2514
- npc=npc_name_to_save,
2515
- team=team,
3857
+ npc=npc_name_to_save,
3858
+ team=team,
2516
3859
  message_id=message_id,
2517
3860
  )
2518
3861
 
3862
+ # Start background tasks for memory extraction and context compression
3863
+ # These will run without blocking the main response stream.
3864
+ conversation_turn_text = f"User: {commandstr}\nAssistant: {final_response_text}"
3865
+ background_thread = threading.Thread(
3866
+ target=_run_stream_post_processing,
3867
+ args=(
3868
+ conversation_turn_text,
3869
+ conversation_id,
3870
+ command_history,
3871
+ npc_name,
3872
+ team, # Pass the team variable from the outer scope
3873
+ current_path,
3874
+ model,
3875
+ provider,
3876
+ npc_object,
3877
+ messages # Pass messages for context compression
3878
+ )
3879
+ )
3880
+ background_thread.daemon = True # Allow the main program to exit even if this thread is still running
3881
+ background_thread.start()
3882
+
2519
3883
  with cancellation_lock:
2520
3884
  if current_stream_id in cancellation_flags:
2521
3885
  del cancellation_flags[current_stream_id]
2522
3886
  print(f"Cleaned up cancellation flag for stream ID: {current_stream_id}")
2523
-
2524
3887
  return Response(event_stream(stream_id), mimetype="text/event-stream")
2525
3888
 
2526
3889
  @app.route('/api/delete_message', methods=['POST'])
@@ -2574,295 +3937,6 @@ def approve_memories():
2574
3937
 
2575
3938
 
2576
3939
 
2577
- @app.route("/api/execute", methods=["POST"])
2578
- def execute():
2579
- data = request.json
2580
-
2581
-
2582
- stream_id = data.get("streamId")
2583
- if not stream_id:
2584
- import uuid
2585
- stream_id = str(uuid.uuid4())
2586
-
2587
-
2588
- with cancellation_lock:
2589
- cancellation_flags[stream_id] = False
2590
- print(f"Starting execute stream with ID: {stream_id}")
2591
-
2592
-
2593
- commandstr = data.get("commandstr")
2594
- conversation_id = data.get("conversationId")
2595
- model = data.get("model", 'llama3.2')
2596
- provider = data.get("provider", 'ollama')
2597
- if provider is None:
2598
- provider = available_models.get(model)
2599
-
2600
-
2601
- npc_name = data.get("npc", "sibiji")
2602
- npc_source = data.get("npcSource", "global")
2603
- team = data.get("team", None)
2604
- current_path = data.get("currentPath")
2605
-
2606
- if current_path:
2607
- loaded_vars = load_project_env(current_path)
2608
- print(f"Loaded project env variables for stream request: {list(loaded_vars.keys())}")
2609
-
2610
- npc_object = None
2611
- team_object = None
2612
-
2613
-
2614
- if team:
2615
- print(team)
2616
- if hasattr(app, 'registered_teams') and team in app.registered_teams:
2617
- team_object = app.registered_teams[team]
2618
- print(f"Using registered team: {team}")
2619
- else:
2620
- print(f"Warning: Team {team} not found in registered teams")
2621
-
2622
-
2623
- if npc_name:
2624
-
2625
- if team and hasattr(app, 'registered_teams') and team in app.registered_teams:
2626
- team_object = app.registered_teams[team]
2627
- print('team', team_object)
2628
-
2629
- if hasattr(team_object, 'npcs'):
2630
- team_npcs = team_object.npcs
2631
- if isinstance(team_npcs, dict):
2632
- if npc_name in team_npcs:
2633
- npc_object = team_npcs[npc_name]
2634
- print(f"Found NPC {npc_name} in registered team {team}")
2635
- elif isinstance(team_npcs, list):
2636
- for npc in team_npcs:
2637
- if hasattr(npc, 'name') and npc.name == npc_name:
2638
- npc_object = npc
2639
- print(f"Found NPC {npc_name} in registered team {team}")
2640
- break
2641
-
2642
- if not npc_object and hasattr(team_object, 'forenpc') and hasattr(team_object.forenpc, 'name'):
2643
- if team_object.forenpc.name == npc_name:
2644
- npc_object = team_object.forenpc
2645
- print(f"Found NPC {npc_name} as forenpc in team {team}")
2646
-
2647
-
2648
- if not npc_object and hasattr(app, 'registered_npcs') and npc_name in app.registered_npcs:
2649
- npc_object = app.registered_npcs[npc_name]
2650
- print(f"Found NPC {npc_name} in registered NPCs")
2651
-
2652
-
2653
- if not npc_object:
2654
- db_conn = get_db_connection()
2655
- npc_object = load_npc_by_name_and_source(npc_name, npc_source, db_conn, current_path)
2656
-
2657
- if not npc_object and npc_source == 'project':
2658
- print(f"NPC {npc_name} not found in project directory, trying global...")
2659
- npc_object = load_npc_by_name_and_source(npc_name, 'global', db_conn)
2660
-
2661
- if npc_object:
2662
- print(f"Successfully loaded NPC {npc_name} from {npc_source} directory")
2663
- else:
2664
- print(f"Warning: Could not load NPC {npc_name}")
2665
-
2666
- attachments = data.get("attachments", [])
2667
- command_history = CommandHistory(app.config.get('DB_PATH'))
2668
- images = []
2669
- attachments_loaded = []
2670
-
2671
-
2672
- if attachments:
2673
- for attachment in attachments:
2674
- extension = attachment["name"].split(".")[-1]
2675
- extension_mapped = extension_map.get(extension.upper(), "others")
2676
- file_path = os.path.expanduser("~/.npcsh/" + extension_mapped + "/" + attachment["name"])
2677
- if extension_mapped == "images":
2678
- ImageFile.LOAD_TRUNCATED_IMAGES = True
2679
- img = Image.open(attachment["path"])
2680
- img_byte_arr = BytesIO()
2681
- img.save(img_byte_arr, format="PNG")
2682
- img_byte_arr.seek(0)
2683
- img.save(file_path, optimize=True, quality=50)
2684
- images.append(file_path)
2685
- attachments_loaded.append({
2686
- "name": attachment["name"], "type": extension_mapped,
2687
- "data": img_byte_arr.read(), "size": os.path.getsize(file_path)
2688
- })
2689
-
2690
- messages = fetch_messages_for_conversation(conversation_id)
2691
- if len(messages) == 0 and npc_object is not None:
2692
- messages = [{'role': 'system', 'content': npc_object.get_system_prompt()}]
2693
- elif len(messages)>0 and messages[0]['role'] != 'system' and npc_object is not None:
2694
- messages.insert(0, {'role': 'system', 'content': npc_object.get_system_prompt()})
2695
- elif len(messages) > 0 and npc_object is not None:
2696
- messages[0]['content'] = npc_object.get_system_prompt()
2697
- if npc_object is not None and messages and messages[0]['role'] == 'system':
2698
- messages[0]['content'] = npc_object.get_system_prompt()
2699
-
2700
- message_id = generate_message_id()
2701
- save_conversation_message(
2702
- command_history, conversation_id, "user", commandstr,
2703
- wd=current_path, model=model, provider=provider, npc=npc_name,
2704
- team=team, attachments=attachments_loaded, message_id=message_id,
2705
- )
2706
- response_gen = check_llm_command(
2707
- commandstr, messages=messages, images=images, model=model,
2708
- provider=provider, npc=npc_object, team=team_object, stream=True
2709
- )
2710
- print(response_gen)
2711
-
2712
- message_id = generate_message_id()
2713
-
2714
- def event_stream(current_stream_id):
2715
- complete_response = []
2716
- dot_count = 0
2717
- interrupted = False
2718
- tool_call_data = {"id": None, "function_name": None, "arguments": ""}
2719
- memory_data = None
2720
-
2721
- try:
2722
- for response_chunk in stream_response.get('response', stream_response.get('output')):
2723
- with cancellation_lock:
2724
- if cancellation_flags.get(current_stream_id, False):
2725
- print(f"Cancellation flag triggered for {current_stream_id}. Breaking loop.")
2726
- interrupted = True
2727
- break
2728
-
2729
- print('.', end="", flush=True)
2730
- dot_count += 1
2731
-
2732
- if "hf.co" in model or provider == 'ollama':
2733
- chunk_content = response_chunk["message"]["content"] if "message" in response_chunk and "content" in response_chunk["message"] else ""
2734
- if "message" in response_chunk and "tool_calls" in response_chunk["message"]:
2735
- for tool_call in response_chunk["message"]["tool_calls"]:
2736
- if "id" in tool_call:
2737
- tool_call_data["id"] = tool_call["id"]
2738
- if "function" in tool_call:
2739
- if "name" in tool_call["function"]:
2740
- tool_call_data["function_name"] = tool_call["function"]["name"]
2741
- if "arguments" in tool_call["function"]:
2742
- arg_val = tool_call["function"]["arguments"]
2743
- if isinstance(arg_val, dict):
2744
- arg_val = json.dumps(arg_val)
2745
- tool_call_data["arguments"] += arg_val
2746
- if chunk_content:
2747
- complete_response.append(chunk_content)
2748
- chunk_data = {
2749
- "id": None, "object": None, "created": response_chunk["created_at"], "model": response_chunk["model"],
2750
- "choices": [{"index": 0, "delta": {"content": chunk_content, "role": response_chunk["message"]["role"]}, "finish_reason": response_chunk.get("done_reason")}]
2751
- }
2752
- yield f"data: {json.dumps(chunk_data)}\n\n"
2753
- else:
2754
- chunk_content = ""
2755
- reasoning_content = ""
2756
- for choice in response_chunk.choices:
2757
- if hasattr(choice.delta, "tool_calls") and choice.delta.tool_calls:
2758
- for tool_call in choice.delta.tool_calls:
2759
- if tool_call.id:
2760
- tool_call_data["id"] = tool_call.id
2761
- if tool_call.function:
2762
- if hasattr(tool_call.function, "name") and tool_call.function.name:
2763
- tool_call_data["function_name"] = tool_call.function.name
2764
- if hasattr(tool_call.function, "arguments") and tool_call.function.arguments:
2765
- tool_call_data["arguments"] += tool_call.function.arguments
2766
- for choice in response_chunk.choices:
2767
- if hasattr(choice.delta, "reasoning_content"):
2768
- reasoning_content += choice.delta.reasoning_content
2769
- chunk_content = "".join(choice.delta.content for choice in response_chunk.choices if choice.delta.content is not None)
2770
- if chunk_content:
2771
- complete_response.append(chunk_content)
2772
- chunk_data = {
2773
- "id": response_chunk.id, "object": response_chunk.object, "created": response_chunk.created, "model": response_chunk.model,
2774
- "choices": [{"index": choice.index, "delta": {"content": choice.delta.content, "role": choice.delta.role, "reasoning_content": reasoning_content if hasattr(choice.delta, "reasoning_content") else None}, "finish_reason": choice.finish_reason} for choice in response_chunk.choices]
2775
- }
2776
- yield f"data: {json.dumps(chunk_data)}\n\n"
2777
-
2778
- except Exception as e:
2779
- print(f"\nAn exception occurred during streaming for {current_stream_id}: {e}")
2780
- traceback.print_exc()
2781
- interrupted = True
2782
-
2783
- finally:
2784
- print(f"\nStream {current_stream_id} finished. Interrupted: {interrupted}")
2785
- print('\r' + ' ' * dot_count*2 + '\r', end="", flush=True)
2786
-
2787
- final_response_text = ''.join(complete_response)
2788
-
2789
- conversation_turn_text = f"User: {commandstr}\nAssistant: {final_response_text}"
2790
-
2791
- try:
2792
- memory_examples = command_history.get_memory_examples_for_context(
2793
- npc=npc_name,
2794
- team=team,
2795
- directory_path=current_path
2796
- )
2797
-
2798
- memory_context = format_memory_context(memory_examples)
2799
-
2800
- facts = get_facts(
2801
- conversation_turn_text,
2802
- model=npc_object.model if npc_object else model,
2803
- provider=npc_object.provider if npc_object else provider,
2804
- npc=npc_object,
2805
- context=memory_context
2806
- )
2807
-
2808
- if facts:
2809
- memories_for_approval = []
2810
- for i, fact in enumerate(facts):
2811
- memory_id = command_history.add_memory_to_database(
2812
- message_id=f"{conversation_id}_{datetime.now().strftime('%H%M%S')}_{i}",
2813
- conversation_id=conversation_id,
2814
- npc=npc_name or "default",
2815
- team=team or "default",
2816
- directory_path=current_path or "/",
2817
- initial_memory=fact['statement'],
2818
- status="pending_approval",
2819
- model=npc_object.model if npc_object else model,
2820
- provider=npc_object.provider if npc_object else provider
2821
- )
2822
-
2823
- memories_for_approval.append({
2824
- "memory_id": memory_id,
2825
- "content": fact['statement'],
2826
- "context": f"Type: {fact.get('type', 'unknown')}, Source: {fact.get('source_text', '')}",
2827
- "npc": npc_name or "default"
2828
- })
2829
-
2830
- memory_data = {
2831
- "type": "memory_approval",
2832
- "memories": memories_for_approval,
2833
- "conversation_id": conversation_id
2834
- }
2835
-
2836
- except Exception as e:
2837
- print(f"Memory generation error: {e}")
2838
-
2839
- if memory_data:
2840
- yield f"data: {json.dumps(memory_data)}\n\n"
2841
-
2842
- yield f"data: {json.dumps({'type': 'message_stop'})}\n\n"
2843
-
2844
- npc_name_to_save = npc_object.name if npc_object else ''
2845
- save_conversation_message(
2846
- command_history,
2847
- conversation_id,
2848
- "assistant",
2849
- final_response_text,
2850
- wd=current_path,
2851
- model=model,
2852
- provider=provider,
2853
- npc=npc_name_to_save,
2854
- team=team,
2855
- message_id=message_id,
2856
- )
2857
-
2858
- with cancellation_lock:
2859
- if current_stream_id in cancellation_flags:
2860
- del cancellation_flags[current_stream_id]
2861
- print(f"Cleaned up cancellation flag for stream ID: {current_stream_id}")
2862
-
2863
-
2864
-
2865
- return Response(event_stream(stream_id), mimetype="text/event-stream")
2866
3940
 
2867
3941
  @app.route("/api/interrupt", methods=["POST"])
2868
3942
  def interrupt_stream():
@@ -3023,6 +4097,37 @@ def ollama_status():
3023
4097
  return jsonify({"status": "not_found"})
3024
4098
 
3025
4099
 
4100
+ @app.route("/api/ollama/tool_models", methods=["GET"])
4101
+ def get_ollama_tool_models():
4102
+ """
4103
+ Best-effort detection of Ollama models whose templates include tool-call support.
4104
+ We scan templates for tool placeholders; if none are found we assume tools are unsupported.
4105
+ """
4106
+ try:
4107
+ detected = []
4108
+ listing = ollama.list()
4109
+ for model in listing.get("models", []):
4110
+ name = getattr(model, "model", None) or model.get("name") if isinstance(model, dict) else None
4111
+ if not name:
4112
+ continue
4113
+ try:
4114
+ details = ollama.show(name)
4115
+ tmpl = details.get("template") or ""
4116
+ if "{{- if .Tools" in tmpl or "{{- range .Tools" in tmpl or "{{- if .ToolCalls" in tmpl:
4117
+ detected.append(name)
4118
+ continue
4119
+ metadata = details.get("metadata") or {}
4120
+ if metadata.get("tools") or metadata.get("tool_calls"):
4121
+ detected.append(name)
4122
+ except Exception as inner_e:
4123
+ print(f"Warning: could not inspect ollama model {name} for tool support: {inner_e}")
4124
+ continue
4125
+ return jsonify({"models": detected, "error": None})
4126
+ except Exception as e:
4127
+ print(f"Error listing Ollama tool-capable models: {e}")
4128
+ return jsonify({"models": [], "error": str(e)}), 500
4129
+
4130
+
3026
4131
  @app.route('/api/ollama/models', methods=['GET'])
3027
4132
  def get_ollama_models():
3028
4133
  response = ollama.list()