npcpy 1.2.34__py3-none-any.whl → 1.2.35__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,7 @@ import sys
8
8
  import traceback
9
9
  import glob
10
10
  import re
11
+ import time
11
12
 
12
13
  import io
13
14
  from flask_cors import CORS
@@ -43,13 +44,14 @@ from npcpy.memory.knowledge_graph import load_kg_from_db
43
44
  from npcpy.memory.search import execute_rag_command, execute_brainblast_command
44
45
  from npcpy.data.load import load_file_contents
45
46
  from npcpy.data.web import search_web
47
+
46
48
  from npcsh._state import get_relevant_memories, search_kg_facts
47
49
 
48
50
  import base64
49
51
  import shutil
50
52
  import uuid
51
53
 
52
- from npcpy.llm_funcs import gen_image
54
+ from npcpy.llm_funcs import gen_image, breathe
53
55
 
54
56
  from sqlalchemy import create_engine, text
55
57
  from sqlalchemy.orm import sessionmaker
@@ -84,6 +86,82 @@ cancellation_flags = {}
84
86
  cancellation_lock = threading.Lock()
85
87
 
86
88
 
89
+ class MCPServerManager:
90
+ """
91
+ Simple in-process tracker for launching/stopping MCP servers.
92
+ Currently uses subprocess.Popen to start a Python stdio MCP server script.
93
+ """
94
+
95
+ def __init__(self):
96
+ self._procs = {}
97
+ self._lock = threading.Lock()
98
+
99
+ def start(self, server_path: str):
100
+ server_path = os.path.expanduser(server_path)
101
+ abs_path = os.path.abspath(server_path)
102
+ if not os.path.exists(abs_path):
103
+ raise FileNotFoundError(f"MCP server script not found at {abs_path}")
104
+
105
+ with self._lock:
106
+ existing = self._procs.get(abs_path)
107
+ if existing and existing.poll() is None:
108
+ return {"status": "running", "pid": existing.pid, "serverPath": abs_path}
109
+
110
+ cmd = [sys.executable, abs_path]
111
+ proc = subprocess.Popen(
112
+ cmd,
113
+ cwd=os.path.dirname(abs_path) or ".",
114
+ stdout=subprocess.PIPE,
115
+ stderr=subprocess.PIPE,
116
+ )
117
+ self._procs[abs_path] = proc
118
+ return {"status": "started", "pid": proc.pid, "serverPath": abs_path}
119
+
120
+ def stop(self, server_path: str):
121
+ server_path = os.path.expanduser(server_path)
122
+ abs_path = os.path.abspath(server_path)
123
+ with self._lock:
124
+ proc = self._procs.get(abs_path)
125
+ if not proc:
126
+ return {"status": "not_found", "serverPath": abs_path}
127
+ if proc.poll() is None:
128
+ proc.terminate()
129
+ try:
130
+ proc.wait(timeout=5)
131
+ except subprocess.TimeoutExpired:
132
+ proc.kill()
133
+ del self._procs[abs_path]
134
+ return {"status": "stopped", "serverPath": abs_path}
135
+
136
+ def status(self, server_path: str):
137
+ server_path = os.path.expanduser(server_path)
138
+ abs_path = os.path.abspath(server_path)
139
+ with self._lock:
140
+ proc = self._procs.get(abs_path)
141
+ if not proc:
142
+ return {"status": "not_started", "serverPath": abs_path}
143
+ running = proc.poll() is None
144
+ return {
145
+ "status": "running" if running else "exited",
146
+ "serverPath": abs_path,
147
+ "pid": proc.pid,
148
+ "returncode": None if running else proc.returncode,
149
+ }
150
+
151
+ def running(self):
152
+ with self._lock:
153
+ return {
154
+ path: {
155
+ "pid": proc.pid,
156
+ "status": "running" if proc.poll() is None else "exited",
157
+ "returncode": None if proc.poll() is None else proc.returncode,
158
+ }
159
+ for path, proc in self._procs.items()
160
+ }
161
+
162
+
163
+ mcp_server_manager = MCPServerManager()
164
+
87
165
  def get_project_npc_directory(current_path=None):
88
166
  """
89
167
  Get the project NPC directory based on the current path
@@ -186,6 +264,34 @@ def get_db_session():
186
264
  Session = sessionmaker(bind=engine)
187
265
  return Session()
188
266
 
267
+
268
+ def resolve_mcp_server_path(current_path=None, explicit_path=None, force_global=False):
269
+ """
270
+ Resolve an MCP server path using npcsh.corca's helper when available.
271
+ Falls back to ~/.npcsh/npc_team/mcp_server.py.
272
+ """
273
+ if explicit_path:
274
+ abs_path = os.path.abspath(os.path.expanduser(explicit_path))
275
+ if os.path.exists(abs_path):
276
+ return abs_path
277
+ try:
278
+ from npcsh.corca import _resolve_and_copy_mcp_server_path
279
+ resolved = _resolve_and_copy_mcp_server_path(
280
+ explicit_path=explicit_path,
281
+ current_path=current_path,
282
+ team_ctx_mcp_servers=None,
283
+ interactive=False,
284
+ auto_copy_bypass=True,
285
+ force_global=force_global,
286
+ )
287
+ if resolved:
288
+ return os.path.abspath(resolved)
289
+ except Exception as e:
290
+ print(f"resolve_mcp_server_path: fallback path due to error: {e}")
291
+
292
+ fallback = os.path.expanduser("~/.npcsh/npc_team/mcp_server.py")
293
+ return fallback
294
+
189
295
  extension_map = {
190
296
  "PNG": "images",
191
297
  "JPG": "images",
@@ -441,8 +547,6 @@ def capture():
441
547
  return None
442
548
 
443
549
  return jsonify({"screenshot": screenshot})
444
-
445
-
446
550
  @app.route("/api/settings/global", methods=["GET", "OPTIONS"])
447
551
  def get_global_settings():
448
552
  if request.method == "OPTIONS":
@@ -451,22 +555,22 @@ def get_global_settings():
451
555
  try:
452
556
  npcshrc_path = os.path.expanduser("~/.npcshrc")
453
557
 
454
-
455
558
  global_settings = {
456
559
  "model": "llama3.2",
457
560
  "provider": "ollama",
458
561
  "embedding_model": "nomic-embed-text",
459
562
  "embedding_provider": "ollama",
460
563
  "search_provider": "perplexity",
461
- "NPC_STUDIO_LICENSE_KEY": "",
462
564
  "default_folder": os.path.expanduser("~/.npcsh/"),
565
+ "is_predictive_text_enabled": False, # Default value for the new setting
566
+ "predictive_text_model": "llama3.2", # Default predictive text model
567
+ "predictive_text_provider": "ollama", # Default predictive text provider
463
568
  }
464
569
  global_vars = {}
465
570
 
466
571
  if os.path.exists(npcshrc_path):
467
572
  with open(npcshrc_path, "r") as f:
468
573
  for line in f:
469
-
470
574
  line = line.split("#")[0].strip()
471
575
  if not line:
472
576
  continue
@@ -474,33 +578,35 @@ def get_global_settings():
474
578
  if "=" not in line:
475
579
  continue
476
580
 
477
-
478
581
  key, value = line.split("=", 1)
479
582
  key = key.strip()
480
583
  if key.startswith("export "):
481
584
  key = key[7:]
482
585
 
483
-
484
586
  value = value.strip()
485
587
  if value.startswith('"') and value.endswith('"'):
486
588
  value = value[1:-1]
487
589
  elif value.startswith("'") and value.endswith("'"):
488
590
  value = value[1:-1]
489
591
 
490
-
491
592
  key_mapping = {
492
593
  "NPCSH_MODEL": "model",
493
594
  "NPCSH_PROVIDER": "provider",
494
595
  "NPCSH_EMBEDDING_MODEL": "embedding_model",
495
596
  "NPCSH_EMBEDDING_PROVIDER": "embedding_provider",
496
597
  "NPCSH_SEARCH_PROVIDER": "search_provider",
497
- "NPC_STUDIO_LICENSE_KEY": "NPC_STUDIO_LICENSE_KEY",
498
598
  "NPCSH_STREAM_OUTPUT": "NPCSH_STREAM_OUTPUT",
499
599
  "NPC_STUDIO_DEFAULT_FOLDER": "default_folder",
600
+ "NPC_STUDIO_PREDICTIVE_TEXT_ENABLED": "is_predictive_text_enabled", # New mapping
601
+ "NPC_STUDIO_PREDICTIVE_TEXT_MODEL": "predictive_text_model", # New mapping
602
+ "NPC_STUDIO_PREDICTIVE_TEXT_PROVIDER": "predictive_text_provider", # New mapping
500
603
  }
501
604
 
502
605
  if key in key_mapping:
503
- global_settings[key_mapping[key]] = value
606
+ if key == "NPC_STUDIO_PREDICTIVE_TEXT_ENABLED":
607
+ global_settings[key_mapping[key]] = value.lower() == 'true'
608
+ else:
609
+ global_settings[key_mapping[key]] = value
504
610
  else:
505
611
  global_vars[key] = value
506
612
 
@@ -517,6 +623,7 @@ def get_global_settings():
517
623
  except Exception as e:
518
624
  print(f"Error in get_global_settings: {str(e)}")
519
625
  return jsonify({"error": str(e)}), 500
626
+
520
627
  def _get_jinx_files_recursively(directory):
521
628
  """Helper to recursively find all .jinx file paths."""
522
629
  jinx_paths = []
@@ -550,58 +657,7 @@ def get_available_jinxs():
550
657
  traceback.print_exc()
551
658
  return jsonify({'jinxs': [], 'error': str(e)}), 500
552
659
 
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
- })
660
+
605
661
  @app.route("/api/jinx/execute", methods=["POST"])
606
662
  def execute_jinx():
607
663
  """
@@ -823,8 +879,6 @@ def execute_jinx():
823
879
  return Response(final_output_string, mimetype="text/html")
824
880
  else:
825
881
  return Response(final_output_string, mimetype="text/plain")
826
-
827
-
828
882
  @app.route("/api/settings/global", methods=["POST", "OPTIONS"])
829
883
  def save_global_settings():
830
884
  if request.method == "OPTIONS":
@@ -840,35 +894,41 @@ def save_global_settings():
840
894
  "embedding_model": "NPCSH_EMBEDDING_MODEL",
841
895
  "embedding_provider": "NPCSH_EMBEDDING_PROVIDER",
842
896
  "search_provider": "NPCSH_SEARCH_PROVIDER",
843
- "NPC_STUDIO_LICENSE_KEY": "NPC_STUDIO_LICENSE_KEY",
844
897
  "NPCSH_STREAM_OUTPUT": "NPCSH_STREAM_OUTPUT",
845
898
  "default_folder": "NPC_STUDIO_DEFAULT_FOLDER",
899
+ "is_predictive_text_enabled": "NPC_STUDIO_PREDICTIVE_TEXT_ENABLED", # New mapping
900
+ "predictive_text_model": "NPC_STUDIO_PREDICTIVE_TEXT_MODEL", # New mapping
901
+ "predictive_text_provider": "NPC_STUDIO_PREDICTIVE_TEXT_PROVIDER", # New mapping
846
902
  }
847
903
 
848
904
  os.makedirs(os.path.dirname(npcshrc_path), exist_ok=True)
849
905
  print(data)
850
906
  with open(npcshrc_path, "w") as f:
851
-
907
+
852
908
  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")
909
+ if key in key_mapping and value is not None: # Check for None explicitly
910
+ # Handle boolean conversion for saving
911
+ if key == "is_predictive_text_enabled":
912
+ value_to_write = str(value).upper()
913
+ elif " " in str(value):
914
+ value_to_write = f'"{value}"'
915
+ else:
916
+ value_to_write = str(value)
917
+ f.write(f"export {key_mapping[key]}={value_to_write}\n")
858
918
 
859
-
860
919
  for key, value in data.get("global_vars", {}).items():
861
- if key and value:
920
+ if key and value is not None: # Check for None explicitly
862
921
  if " " in str(value):
863
- value = f'"{value}"'
864
- f.write(f"export {key}={value}\n")
922
+ value_to_write = f'"{value}"'
923
+ else:
924
+ value_to_write = str(value)
925
+ f.write(f"export {key}={value_to_write}\n")
865
926
 
866
927
  return jsonify({"message": "Global settings saved successfully", "error": None})
867
928
 
868
929
  except Exception as e:
869
930
  print(f"Error in save_global_settings: {str(e)}")
870
931
  return jsonify({"error": str(e)}), 500
871
-
872
932
  @app.route("/api/settings/project", methods=["GET", "OPTIONS"])
873
933
  def get_project_settings():
874
934
  if request.method == "OPTIONS":
@@ -1050,8 +1110,542 @@ def save_jinx():
1050
1110
  return jsonify({"status": "success"})
1051
1111
  except Exception as e:
1052
1112
  return jsonify({"error": str(e)}), 500
1113
+ def serialize_jinx_inputs(inputs):
1114
+ result = []
1115
+ for inp in inputs:
1116
+ if isinstance(inp, str):
1117
+ result.append(inp)
1118
+ elif isinstance(inp, dict):
1119
+ key = list(inp.keys())[0]
1120
+ result.append(key)
1121
+ else:
1122
+ result.append(str(inp))
1123
+ return result
1124
+
1125
+ @app.route("/api/jinx/test", methods=["POST"])
1126
+ def test_jinx():
1127
+ data = request.json
1128
+ jinx_data = data.get("jinx")
1129
+ test_inputs = data.get("inputs", {})
1130
+ current_path = data.get("currentPath")
1131
+
1132
+ if current_path:
1133
+ load_project_env(current_path)
1134
+
1135
+ jinx = Jinx(jinx_data=jinx_data)
1136
+
1137
+ from jinja2 import Environment
1138
+ temp_env = Environment()
1139
+ jinx.render_first_pass(temp_env, {})
1140
+
1141
+ conversation_id = f"jinx_test_{uuid.uuid4().hex[:8]}"
1142
+ command_history = CommandHistory(app.config.get('DB_PATH'))
1143
+
1144
+ # 1. Save user's test command to conversation_history to get a message_id
1145
+ user_test_command = f"Testing jinx /{jinx.jinx_name} with inputs: {test_inputs}"
1146
+ user_message_id = generate_message_id()
1147
+ save_conversation_message(
1148
+ command_history,
1149
+ conversation_id,
1150
+ "user",
1151
+ user_test_command,
1152
+ wd=current_path,
1153
+ model=None, # Or appropriate model/provider for the test context
1154
+ provider=None,
1155
+ npc=None,
1156
+ message_id=user_message_id
1157
+ )
1158
+
1159
+ # Jinx execution status and output are now part of the assistant's response
1160
+ jinx_execution_status = "success"
1161
+ jinx_error_message = None
1162
+ output = "Jinx execution did not complete." # Default output
1163
+
1164
+ try:
1165
+ result = jinx.execute(
1166
+ input_values=test_inputs,
1167
+ npc=None,
1168
+ messages=[],
1169
+ extra_globals={},
1170
+ jinja_env=temp_env
1171
+ )
1172
+ output = result.get('output', str(result))
1173
+ if result.get('error'): # Assuming jinx.execute might return an 'error' key
1174
+ jinx_execution_status = "failed"
1175
+ jinx_error_message = str(result.get('error'))
1176
+ except Exception as e:
1177
+ jinx_execution_status = "failed"
1178
+ jinx_error_message = str(e)
1179
+ output = f"Jinx execution failed: {e}"
1180
+
1181
+ # The jinx_executions table is populated by a trigger from conversation_history.
1182
+ # The details of the execution (inputs, output, status) are now expected to be
1183
+ # derived by analyzing the user's command and the subsequent assistant's response.
1184
+ # No explicit update to jinx_executions is needed here.
1185
+
1186
+ # 2. Save assistant's response to conversation_history
1187
+ assistant_response_message_id = generate_message_id() # ID for the assistant's response
1188
+ save_conversation_message(
1189
+ command_history,
1190
+ conversation_id,
1191
+ "assistant",
1192
+ output, # The jinx output is the assistant's response for the test
1193
+ wd=current_path,
1194
+ model=None,
1195
+ provider=None,
1196
+ npc=None,
1197
+ message_id=assistant_response_message_id
1198
+ )
1199
+
1200
+ return jsonify({
1201
+ "output": output,
1202
+ "conversation_id": conversation_id,
1203
+ "execution_id": user_message_id, # Return the user's message_id as the execution_id
1204
+ "error": jinx_error_message
1205
+ })
1206
+ from npcpy.ft.diff import train_diffusion, DiffusionConfig
1207
+ import threading
1208
+
1209
+ from npcpy.memory.knowledge_graph import (
1210
+ load_kg_from_db,
1211
+ save_kg_to_db # ADD THIS LINE to import the correct function
1212
+ )
1213
+
1214
+ from collections import defaultdict # ADD THIS LINE for collecting links if not already present
1215
+
1216
+ finetune_jobs = {}
1217
+
1218
+ def extract_and_store_memories(
1219
+ conversation_text,
1220
+ conversation_id,
1221
+ command_history,
1222
+ npc_name,
1223
+ team_name,
1224
+ current_path,
1225
+ model,
1226
+ provider,
1227
+ npc_object=None
1228
+ ):
1229
+ from npcpy.llm_funcs import get_facts
1230
+ from npcpy.memory.command_history import format_memory_context
1231
+ # Your CommandHistory.get_memory_examples_for_context returns a dict with 'approved' and 'rejected'
1232
+ memory_examples_dict = command_history.get_memory_examples_for_context(
1233
+ npc=npc_name,
1234
+ team=team_name,
1235
+ directory_path=current_path
1236
+ )
1237
+
1238
+ memory_context = format_memory_context(memory_examples_dict)
1239
+
1240
+ facts = get_facts(
1241
+ conversation_text,
1242
+ model=npc_object.model if npc_object else model,
1243
+ provider=npc_object.provider if npc_object else provider,
1244
+ npc=npc_object,
1245
+ context=memory_context
1246
+ )
1247
+
1248
+ memories_for_approval = []
1249
+
1250
+ # Initialize structures to collect KG data for a single save_kg_to_db call
1251
+ kg_facts_to_save = []
1252
+ kg_concepts_to_save = []
1253
+ fact_to_concept_links_temp = defaultdict(list)
1254
+
1255
+
1256
+ if facts:
1257
+ for i, fact in enumerate(facts):
1258
+ # Store memory in memory_lifecycle table
1259
+ memory_id = command_history.add_memory_to_database(
1260
+ message_id=f"{conversation_id}_{datetime.datetime.now().strftime('%H%M%S')}_{i}",
1261
+ conversation_id=conversation_id,
1262
+ npc=npc_name or "default",
1263
+ team=team_name or "default",
1264
+ directory_path=current_path or "/",
1265
+ initial_memory=fact.get('statement', str(fact)),
1266
+ status="pending_approval",
1267
+ model=npc_object.model if npc_object else model,
1268
+ provider=npc_object.provider if npc_object else provider,
1269
+ final_memory=None # Explicitly None for pending memories
1270
+ )
1271
+
1272
+ memories_for_approval.append({
1273
+ "memory_id": memory_id,
1274
+ "content": fact.get('statement', str(fact)),
1275
+ "type": fact.get('type', 'unknown'),
1276
+ "context": fact.get('source_text', ''),
1277
+ "npc": npc_name or "default"
1278
+ })
1279
+
1280
+ # Collect facts and concepts for the Knowledge Graph
1281
+ #if fact.get('type') == 'concept':
1282
+ # kg_concepts_to_save.append({
1283
+ # "name": fact.get('statement'),
1284
+ # "generation": current_kg_generation,
1285
+ # "origin": "organic" # Assuming 'organic' for extracted facts
1286
+ # })
1287
+ #else: # It's a fact (or unknown type, treat as fact for KG)
1288
+ # kg_facts_to_save.append({
1289
+ # "statement": fact.get('statement'),
1290
+ # "source_text": fact.get('source_text', conversation_text), # Use source_text if available, else conversation_text
1291
+ # "type": fact.get('type', 'fact'), # Default to 'fact' if type is unknown
1292
+ # "generation": current_kg_generation,
1293
+ # "origin": "organic"
1294
+ # })
1295
+ # if fact.get('concepts'): # If this fact has related concepts
1296
+ # for concept_name in fact.get('concepts'):
1297
+ # fact_to_concept_links_temp[fact.get('statement')].append(concept_name)
1298
+
1299
+ # After processing all facts, save them to the KG database in one go
1300
+ if kg_facts_to_save or kg_concepts_to_save:
1301
+ temp_kg_data = {
1302
+ "facts": kg_facts_to_save,
1303
+ "concepts": kg_concepts_to_save,
1304
+ "generation": current_kg_generation,
1305
+ "fact_to_concept_links": fact_to_concept_links_temp,
1306
+ "concept_links": [], # Assuming no concept-to-concept links from direct extraction
1307
+ "fact_to_fact_links": [] # Assuming no fact-to-fact links from direct extraction
1308
+ }
1309
+
1310
+ # Get the SQLAlchemy engine using your existing helper function
1311
+ db_engine = get_db_connection(app.config.get('DB_PATH'))
1312
+
1313
+ # Call the existing save_kg_to_db function
1314
+ save_kg_to_db(
1315
+ engine=db_engine,
1316
+ kg_data=temp_kg_data,
1317
+ team_name=team_name or "default",
1318
+ npc_name=npc_name or "default",
1319
+ directory_path=current_path or "/"
1320
+ )
1321
+
1322
+ return memories_for_approval
1323
+ @app.route('/api/finetuned_models', methods=['GET'])
1324
+ def get_finetuned_models():
1325
+ current_path = request.args.get("currentPath")
1326
+
1327
+ # Define a list of potential root directories where fine-tuned models might be saved.
1328
+ # We'll be very generous here, including both 'models' and 'images' directories
1329
+ # at both global and project levels, as the user's logs indicate saving to 'images'.
1330
+ potential_root_paths = [
1331
+ os.path.expanduser('~/.npcsh/models'), # Standard global models directory
1332
+ os.path.expanduser('~/.npcsh/images'), # Global images directory (where user's model was saved)
1333
+ ]
1334
+ if current_path:
1335
+ # Add project-specific model directories if a current_path is provided
1336
+ project_models_path = os.path.join(current_path, 'models')
1337
+ project_images_path = os.path.join(current_path, 'images') # Also check project images directory
1338
+ potential_root_paths.extend([project_models_path, project_images_path])
1339
+
1340
+ finetuned_models = []
1341
+
1342
+ print(f"🌋 Searching for fine-tuned models in potential root paths: {set(potential_root_paths)}") # Use set for unique paths
1343
+
1344
+ for root_path in set(potential_root_paths): # Iterate through unique potential root paths
1345
+ if not os.path.exists(root_path) or not os.path.isdir(root_path):
1346
+ print(f"🌋 Skipping non-existent or non-directory root path: {root_path}")
1347
+ continue
1348
+
1349
+ print(f"🌋 Scanning root path: {root_path}")
1350
+ for model_dir_name in os.listdir(root_path):
1351
+ full_model_path = os.path.join(root_path, model_dir_name)
1352
+
1353
+ if not os.path.isdir(full_model_path):
1354
+ print(f"🌋 Skipping {full_model_path}: Not a directory.")
1355
+ continue
1356
+
1357
+ # NEW STRATEGY: Check for user's specific output files
1358
+ # Look for 'model_final.pt' or the 'checkpoints' directory
1359
+ has_model_final_pt = os.path.exists(os.path.join(full_model_path, 'model_final.pt'))
1360
+ has_checkpoints_dir = os.path.isdir(os.path.join(full_model_path, 'checkpoints'))
1361
+
1362
+ if has_model_final_pt or has_checkpoints_dir:
1363
+ print(f"🌋 Identified fine-tuned model: {model_dir_name} at {full_model_path} (found model_final.pt or checkpoints dir)")
1364
+ finetuned_models.append({
1365
+ "value": full_model_path, # This is the path to the directory containing the .pt files
1366
+ "provider": "diffusers", # Provider is still "diffusers"
1367
+ "display_name": f"{model_dir_name} | Fine-tuned Diffuser"
1368
+ })
1369
+ continue # Move to the next model_dir_name found in this root_path
1370
+
1371
+ print(f"🌋 Skipping {full_model_path}: No model_final.pt or checkpoints directory found at root.")
1372
+
1373
+ print(f"🌋 Finished scanning. Found {len(finetuned_models)} fine-tuned models.")
1374
+ return jsonify({"models": finetuned_models, "error": None})
1375
+
1376
+ @app.route('/api/finetune_diffusers', methods=['POST'])
1377
+ def finetune_diffusers():
1378
+ data = request.json
1379
+ images = data.get('images', [])
1380
+ captions = data.get('captions', [])
1381
+ output_name = data.get('outputName', 'my_diffusion_model')
1382
+ num_epochs = data.get('epochs', 100)
1383
+ batch_size = data.get('batchSize', 4)
1384
+ learning_rate = data.get('learningRate', 1e-4)
1385
+ output_path = data.get('outputPath', '~/.npcsh/models')
1386
+
1387
+ print(f"🌋 Finetune Diffusers Request Received!")
1388
+ print(f" Images: {len(images)} files")
1389
+ print(f" Output Name: {output_name}")
1390
+ print(f" Epochs: {num_epochs}, Batch Size: {batch_size}, Learning Rate: {learning_rate}")
1391
+
1392
+ if not images:
1393
+ print("🌋 Error: No images provided for finetuning.")
1394
+ return jsonify({'error': 'No images provided'}), 400
1395
+
1396
+ if not captions or len(captions) != len(images):
1397
+ print("🌋 Warning: Captions not provided or mismatching image count. Using empty captions.")
1398
+ captions = [''] * len(images)
1399
+
1400
+ expanded_images = [os.path.expanduser(p) for p in images]
1401
+ output_dir = os.path.expanduser(
1402
+ os.path.join(output_path, output_name)
1403
+ )
1404
+
1405
+ job_id = f"ft_{int(time.time())}"
1406
+ finetune_jobs[job_id] = {
1407
+ 'status': 'running',
1408
+ 'output_dir': output_dir,
1409
+ 'epochs': num_epochs,
1410
+ 'current_epoch': 0,
1411
+ 'start_time': datetime.datetime.now().isoformat()
1412
+ }
1413
+ print(f"🌋 Finetuning job {job_id} initialized. Output directory: {output_dir}")
1414
+
1415
+ def run_training_async():
1416
+ print(f"🌋 Finetuning job {job_id}: Starting asynchronous training thread...")
1417
+ try:
1418
+ config = DiffusionConfig(
1419
+ num_epochs=num_epochs,
1420
+ batch_size=batch_size,
1421
+ learning_rate=learning_rate,
1422
+ output_model_path=output_dir
1423
+ )
1424
+
1425
+ print(f"🌋 Finetuning job {job_id}: Calling train_diffusion with config: {config}")
1426
+ # Assuming train_diffusion might print its own progress or allow callbacks
1427
+ # For more granular logging, you'd need to modify train_diffusion itself
1428
+ model_path = train_diffusion(
1429
+ expanded_images,
1430
+ captions,
1431
+ config=config
1432
+ )
1433
+
1434
+ finetune_jobs[job_id]['status'] = 'complete'
1435
+ finetune_jobs[job_id]['model_path'] = model_path
1436
+ finetune_jobs[job_id]['end_time'] = datetime.datetime.now().isoformat()
1437
+ print(f"🌋 Finetuning job {job_id}: Training complete! Model saved to: {model_path}")
1438
+ except Exception as e:
1439
+ finetune_jobs[job_id]['status'] = 'error'
1440
+ finetune_jobs[job_id]['error_msg'] = str(e)
1441
+ finetune_jobs[job_id]['end_time'] = datetime.datetime.now().isoformat()
1442
+ print(f"🌋 Finetuning job {job_id}: ERROR during training: {e}")
1443
+ traceback.print_exc()
1444
+ print(f"🌋 Finetuning job {job_id}: Asynchronous training thread finished.")
1445
+
1446
+ # Start the training in a separate thread
1447
+ thread = threading.Thread(target=run_training_async)
1448
+ thread.daemon = True # Allow the main program to exit even if this thread is still running
1449
+ thread.start()
1450
+
1451
+ print(f"🌋 Finetuning job {job_id} successfully launched in background. Returning initial status.")
1452
+ return jsonify({
1453
+ 'status': 'started',
1454
+ 'jobId': job_id,
1455
+ 'message': f"Finetuning job '{job_id}' started. Check /api/finetune_status/{job_id} for updates."
1456
+ })
1457
+
1458
+
1459
+ @app.route('/api/finetune_status/<job_id>', methods=['GET'])
1460
+ def finetune_status(job_id):
1461
+ if job_id not in finetune_jobs:
1462
+ return jsonify({'error': 'Job not found'}), 404
1463
+
1464
+ job = finetune_jobs[job_id]
1465
+
1466
+ if job['status'] == 'complete':
1467
+ return jsonify({
1468
+ 'complete': True,
1469
+ 'outputPath': job.get('model_path', job['output_dir'])
1470
+ })
1471
+ elif job['status'] == 'error':
1472
+ return jsonify({'error': job.get('error_msg', 'Unknown error')})
1473
+
1474
+ return jsonify({
1475
+ 'step': job.get('current_epoch', 0),
1476
+ 'total': job['epochs'],
1477
+ 'status': 'running'
1478
+ })
1479
+
1480
+ @app.route("/api/ml/train", methods=["POST"])
1481
+ def train_ml_model():
1482
+ import pickle
1483
+ import numpy as np
1484
+ from sklearn.linear_model import LinearRegression, LogisticRegression
1485
+ from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
1486
+ from sklearn.tree import DecisionTreeRegressor
1487
+ from sklearn.cluster import KMeans
1488
+ from sklearn.model_selection import train_test_split
1489
+ from sklearn.metrics import mean_squared_error, r2_score, accuracy_score
1490
+
1491
+ data = request.json
1492
+ model_name = data.get("name")
1493
+ model_type = data.get("type")
1494
+ target = data.get("target")
1495
+ features = data.get("features")
1496
+ training_data = data.get("data")
1497
+ hyperparams = data.get("hyperparameters", {})
1498
+
1499
+ df = pd.DataFrame(training_data)
1500
+ X = df[features].values
1501
+
1502
+ metrics = {}
1503
+ model = None
1504
+
1505
+ if model_type == "linear_regression":
1506
+ y = df[target].values
1507
+ X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
1508
+ model = LinearRegression()
1509
+ model.fit(X_train, y_train)
1510
+ y_pred = model.predict(X_test)
1511
+ metrics = {
1512
+ "r2_score": r2_score(y_test, y_pred),
1513
+ "rmse": np.sqrt(mean_squared_error(y_test, y_pred))
1514
+ }
1515
+
1516
+ elif model_type == "logistic_regression":
1517
+ y = df[target].values
1518
+ X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
1519
+ model = LogisticRegression(max_iter=1000)
1520
+ model.fit(X_train, y_train)
1521
+ y_pred = model.predict(X_test)
1522
+ metrics = {"accuracy": accuracy_score(y_test, y_pred)}
1523
+
1524
+ elif model_type == "random_forest":
1525
+ y = df[target].values
1526
+ X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
1527
+ model = RandomForestRegressor(n_estimators=100)
1528
+ model.fit(X_train, y_train)
1529
+ y_pred = model.predict(X_test)
1530
+ metrics = {
1531
+ "r2_score": r2_score(y_test, y_pred),
1532
+ "rmse": np.sqrt(mean_squared_error(y_test, y_pred))
1533
+ }
1534
+
1535
+ elif model_type == "clustering":
1536
+ n_clusters = hyperparams.get("n_clusters", 3)
1537
+ model = KMeans(n_clusters=n_clusters)
1538
+ labels = model.fit_predict(X)
1539
+ metrics = {"inertia": model.inertia_, "n_clusters": n_clusters}
1540
+
1541
+ elif model_type == "gradient_boost":
1542
+ y = df[target].values
1543
+ X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2)
1544
+ model = GradientBoostingRegressor()
1545
+ model.fit(X_train, y_train)
1546
+ y_pred = model.predict(X_test)
1547
+ metrics = {
1548
+ "r2_score": r2_score(y_test, y_pred),
1549
+ "rmse": np.sqrt(mean_squared_error(y_test, y_pred))
1550
+ }
1551
+
1552
+ model_id = f"{model_name}_{int(time.time())}"
1553
+ model_path = os.path.expanduser(f"~/.npcsh/models/{model_id}.pkl")
1554
+ os.makedirs(os.path.dirname(model_path), exist_ok=True)
1555
+
1556
+ with open(model_path, 'wb') as f:
1557
+ pickle.dump({
1558
+ "model": model,
1559
+ "features": features,
1560
+ "target": target,
1561
+ "type": model_type
1562
+ }, f)
1563
+
1564
+ return jsonify({
1565
+ "model_id": model_id,
1566
+ "metrics": metrics,
1567
+ "error": None
1568
+ })
1569
+
1570
+
1571
+ @app.route("/api/ml/predict", methods=["POST"])
1572
+ def ml_predict():
1573
+ import pickle
1574
+
1575
+ data = request.json
1576
+ model_name = data.get("model_name")
1577
+ input_data = data.get("input_data")
1578
+
1579
+ model_dir = os.path.expanduser("~/.npcsh/models/")
1580
+ model_files = [f for f in os.listdir(model_dir) if f.startswith(model_name)]
1581
+
1582
+ if not model_files:
1583
+ return jsonify({"error": f"Model {model_name} not found"})
1584
+
1585
+ model_path = os.path.join(model_dir, model_files[0])
1586
+
1587
+ with open(model_path, 'rb') as f:
1588
+ model_data = pickle.load(f)
1589
+
1590
+ model = model_data["model"]
1591
+ prediction = model.predict([input_data])
1592
+
1593
+ return jsonify({
1594
+ "prediction": prediction.tolist(),
1595
+ "error": None
1596
+ })
1597
+ @app.route("/api/jinx/executions/label", methods=["POST"])
1598
+ def label_jinx_execution():
1599
+ data = request.json
1600
+ execution_id = data.get("executionId")
1601
+ label = data.get("label")
1602
+
1603
+ command_history = CommandHistory(app.config.get('DB_PATH'))
1604
+ command_history.label_jinx_execution(execution_id, label)
1605
+
1606
+ return jsonify({"success": True, "error": None})
1607
+
1608
+
1609
+ @app.route("/api/npc/executions", methods=["GET"])
1610
+ def get_npc_executions():
1611
+ npc_name = request.args.get("npcName")
1612
+
1613
+
1614
+ command_history = CommandHistory(app.config.get('DB_PATH'))
1615
+ executions = command_history.get_npc_executions(npc_name)
1616
+
1617
+ return jsonify({"executions": executions, "error": None})
1618
+
1619
+
1620
+ @app.route("/api/npc/executions/label", methods=["POST"])
1621
+ def label_npc_execution():
1622
+ data = request.json
1623
+ execution_id = data.get("executionId")
1624
+ label = data.get("label")
1625
+
1626
+ command_history = CommandHistory(app.config.get('DB_PATH'))
1627
+ command_history.label_npc_execution(execution_id, label)
1628
+
1629
+ return jsonify({"success": True, "error": None})
1053
1630
 
1054
1631
 
1632
+ @app.route("/api/training/dataset", methods=["POST"])
1633
+ def build_training_dataset():
1634
+ data = request.json
1635
+ filters = data.get("filters", {})
1636
+
1637
+ command_history = CommandHistory(app.config.get('DB_PATH'))
1638
+ dataset = command_history.get_training_dataset(
1639
+ include_jinxs=filters.get("jinxs", True),
1640
+ include_npcs=filters.get("npcs", True),
1641
+ npc_names=filters.get("npc_names")
1642
+ )
1643
+
1644
+ return jsonify({
1645
+ "dataset": dataset,
1646
+ "count": len(dataset),
1647
+ "error": None
1648
+ })
1055
1649
  @app.route("/api/save_npc", methods=["POST"])
1056
1650
  def save_npc():
1057
1651
  try:
@@ -1092,137 +1686,147 @@ use_global_jinxs: {str(npc_data.get('use_global_jinxs', True)).lower()}
1092
1686
  print(f"Error saving NPC: {str(e)}")
1093
1687
  return jsonify({"error": str(e)}), 500
1094
1688
 
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")
1689
+ @app.route("/api/jinxs/global")
1690
+ def get_jinxs_global():
1691
+ global_jinx_directory = os.path.expanduser("~/.npcsh/npc_team/jinxs")
1692
+ jinx_data = []
1100
1693
 
1101
- npc_data = []
1694
+ if not os.path.exists(global_jinx_directory):
1695
+ return jsonify({"jinxs": [], "error": None})
1102
1696
 
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}"})
1697
+ for root, dirs, files in os.walk(global_jinx_directory):
1698
+ for file in files:
1699
+ if file.endswith(".jinx"):
1700
+ jinx_path = os.path.join(root, file)
1701
+ with open(jinx_path, 'r') as f:
1702
+ raw_data = yaml.safe_load(f)
1703
+
1704
+ inputs = []
1705
+ for inp in raw_data.get("inputs", []):
1706
+ if isinstance(inp, str):
1707
+ inputs.append(inp)
1708
+ elif isinstance(inp, dict):
1709
+ inputs.append(list(inp.keys())[0])
1710
+ else:
1711
+ inputs.append(str(inp))
1712
+
1713
+ rel_path = os.path.relpath(jinx_path, global_jinx_directory)
1714
+ path_without_ext = rel_path[:-5]
1715
+
1716
+ jinx_data.append({
1717
+ "jinx_name": raw_data.get("jinx_name", file[:-5]),
1718
+ "path": path_without_ext,
1719
+ "description": raw_data.get("description", ""),
1720
+ "inputs": inputs,
1721
+ "steps": raw_data.get("steps", [])
1722
+ })
1107
1723
 
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)
1724
+ return jsonify({"jinxs": jinx_data, "error": None})
1147
1725
 
1726
+ @app.route("/api/jinxs/project", methods=["GET"])
1727
+ def get_jinxs_project():
1728
+ project_dir = request.args.get("currentPath")
1729
+ if not project_dir:
1730
+ return jsonify({"jinxs": [], "error": "currentPath required"}), 400
1148
1731
 
1149
- return jsonify({"npcs": npc_data, "error": None})
1732
+ if not project_dir.endswith("jinxs"):
1733
+ project_dir = os.path.join(project_dir, "jinxs")
1150
1734
 
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)})
1735
+ jinx_data = []
1736
+ if not os.path.exists(project_dir):
1737
+ return jsonify({"jinxs": [], "error": None})
1155
1738
 
1739
+ for root, dirs, files in os.walk(project_dir):
1740
+ for file in files:
1741
+ if file.endswith(".jinx"):
1742
+ jinx_path = os.path.join(root, file)
1743
+ with open(jinx_path, 'r') as f:
1744
+ raw_data = yaml.safe_load(f)
1745
+
1746
+ inputs = []
1747
+ for inp in raw_data.get("inputs", []):
1748
+ if isinstance(inp, str):
1749
+ inputs.append(inp)
1750
+ elif isinstance(inp, dict):
1751
+ inputs.append(list(inp.keys())[0])
1752
+ else:
1753
+ inputs.append(str(inp))
1754
+
1755
+ rel_path = os.path.relpath(jinx_path, project_dir)
1756
+ path_without_ext = rel_path[:-5]
1757
+
1758
+ jinx_data.append({
1759
+ "jinx_name": raw_data.get("jinx_name", file[:-5]),
1760
+ "path": path_without_ext,
1761
+ "description": raw_data.get("description", ""),
1762
+ "inputs": inputs,
1763
+ "steps": raw_data.get("steps", [])
1764
+ })
1765
+ print(jinx_data)
1766
+ return jsonify({"jinxs": jinx_data, "error": None})
1156
1767
 
1157
- @app.route("/api/npc_team_project", methods=["GET"])
1158
- def get_npc_team_project():
1159
- try:
1160
- db_conn = get_db_connection()
1768
+ @app.route("/api/npc_team_global")
1769
+ def get_npc_team_global():
1770
+ global_npc_directory = os.path.expanduser("~/.npcsh/npc_team")
1771
+ npc_data = []
1161
1772
 
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
1773
+ if not os.path.exists(global_npc_directory):
1774
+ return jsonify({"npcs": [], "error": None})
1165
1775
 
1166
- if not project_npc_directory.endswith("npc_team"):
1167
- project_npc_directory = os.path.join(project_npc_directory, "npc_team")
1776
+ for file in os.listdir(global_npc_directory):
1777
+ if file.endswith(".npc"):
1778
+ npc_path = os.path.join(global_npc_directory, file)
1779
+ with open(npc_path, 'r') as f:
1780
+ raw_data = yaml.safe_load(f)
1781
+
1782
+ npc_data.append({
1783
+ "name": raw_data.get("name", file[:-4]),
1784
+ "primary_directive": raw_data.get("primary_directive", ""),
1785
+ "model": raw_data.get("model", ""),
1786
+ "provider": raw_data.get("provider", ""),
1787
+ "api_url": raw_data.get("api_url", ""),
1788
+ "use_global_jinxs": raw_data.get("use_global_jinxs", True),
1789
+ "jinxs": raw_data.get("jinxs", "*"),
1790
+ })
1168
1791
 
1169
- npc_data = []
1792
+ return jsonify({"npcs": npc_data, "error": None})
1170
1793
 
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}"})
1175
1794
 
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)
1795
+ @app.route("/api/npc_team_project", methods=["GET"])
1796
+ def get_npc_team_project():
1797
+ project_npc_directory = request.args.get("currentPath")
1798
+ if not project_npc_directory:
1799
+ return jsonify({"npcs": [], "error": "currentPath required"}), 400
1800
+
1801
+ if not project_npc_directory.endswith("npc_team"):
1802
+ project_npc_directory = os.path.join(
1803
+ project_npc_directory,
1804
+ "npc_team"
1805
+ )
1216
1806
 
1807
+ npc_data = []
1217
1808
 
1218
- print(f"Project NPC data: {npc_data}", file=sys.stderr) # Diagnostic print
1219
- return jsonify({"npcs": npc_data, "error": None})
1809
+ if not os.path.exists(project_npc_directory):
1810
+ return jsonify({"npcs": [], "error": None})
1220
1811
 
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)})
1812
+ for file in os.listdir(project_npc_directory):
1813
+ if file.endswith(".npc"):
1814
+ npc_path = os.path.join(project_npc_directory, file)
1815
+ with open(npc_path, 'r') as f:
1816
+ raw_npc_data = yaml.safe_load(f)
1817
+
1818
+ serialized_npc = {
1819
+ "name": raw_npc_data.get("name", file[:-4]),
1820
+ "primary_directive": raw_npc_data.get("primary_directive", ""),
1821
+ "model": raw_npc_data.get("model", ""),
1822
+ "provider": raw_npc_data.get("provider", ""),
1823
+ "api_url": raw_npc_data.get("api_url", ""),
1824
+ "use_global_jinxs": raw_npc_data.get("use_global_jinxs", True),
1825
+ "jinxs": raw_npc_data.get("jinxs", "*"),
1826
+ }
1827
+ npc_data.append(serialized_npc)
1225
1828
 
1829
+ return jsonify({"npcs": npc_data, "error": None})
1226
1830
 
1227
1831
  def get_last_used_model_and_npc_in_directory(directory_path):
1228
1832
  """
@@ -1542,11 +2146,62 @@ IMAGE_MODELS = {
1542
2146
  {"value": "runwayml/stable-diffusion-v1-5", "display_name": "Stable Diffusion v1.5"},
1543
2147
  ],
1544
2148
  }
2149
+ # In npcpy/serve.py, find the @app.route('/api/finetuned_models', methods=['GET'])
2150
+ # and replace the entire function with this:
2151
+
2152
+ # This is now an internal helper function, not a Flask route.
2153
+ def _get_finetuned_models_internal(current_path=None): # Renamed to indicate internal use
2154
+
2155
+ # Define a list of potential root directories where fine-tuned models might be saved.
2156
+ potential_root_paths = [
2157
+ os.path.expanduser('~/.npcsh/models'), # Standard global models directory
2158
+ os.path.expanduser('~/.npcsh/images'), # Global images directory (where user's model was saved)
2159
+ ]
2160
+ if current_path:
2161
+ # Add project-specific model directories if a current_path is provided
2162
+ project_models_path = os.path.join(current_path, 'models')
2163
+ project_images_path = os.path.join(current_path, 'images') # Also check project images directory
2164
+ potential_root_paths.extend([project_models_path, project_images_path])
2165
+
2166
+ finetuned_models = []
2167
+
2168
+ print(f"🌋 (Internal) Searching for fine-tuned models in potential root paths: {set(potential_root_paths)}")
2169
+
2170
+ for root_path in set(potential_root_paths):
2171
+ if not os.path.exists(root_path) or not os.path.isdir(root_path):
2172
+ print(f"🌋 (Internal) Skipping non-existent or non-directory root path: {root_path}")
2173
+ continue
1545
2174
 
2175
+ print(f"🌋 (Internal) Scanning root path: {root_path}")
2176
+ for model_dir_name in os.listdir(root_path):
2177
+ full_model_path = os.path.join(root_path, model_dir_name)
2178
+
2179
+ if not os.path.isdir(full_model_path):
2180
+ print(f"🌋 (Internal) Skipping {full_model_path}: Not a directory.")
2181
+ continue
2182
+
2183
+ # Check for 'model_final.pt' or the 'checkpoints' directory
2184
+ has_model_final_pt = os.path.exists(os.path.join(full_model_path, 'model_final.pt'))
2185
+ has_checkpoints_dir = os.path.isdir(os.path.join(full_model_path, 'checkpoints'))
2186
+
2187
+ if has_model_final_pt or has_checkpoints_dir:
2188
+ print(f"🌋 (Internal) Identified fine-tuned model: {model_dir_name} at {full_model_path} (found model_final.pt or checkpoints dir)")
2189
+ finetuned_models.append({
2190
+ "value": full_model_path, # This is the path to the directory containing the .pt files
2191
+ "provider": "diffusers", # Provider is still "diffusers"
2192
+ "display_name": f"{model_dir_name} | Fine-tuned Diffuser"
2193
+ })
2194
+ continue
2195
+
2196
+ print(f"🌋 (Internal) Skipping {full_model_path}: No model_final.pt or checkpoints directory found at root.")
2197
+
2198
+ print(f"🌋 (Internal) Finished scanning. Found {len(finetuned_models)} fine-tuned models.")
2199
+ # <--- CRITICAL FIX: Directly return the list of models, not a Flask Response
2200
+ return {"models": finetuned_models, "error": None} # Return a dict for consistency
1546
2201
  def get_available_image_models(current_path=None):
1547
2202
  """
1548
2203
  Retrieves available image generation models based on environment variables
1549
- and predefined configurations.
2204
+ and predefined configurations, including locally fine-tuned Diffusers models.
1550
2205
  """
1551
2206
 
1552
2207
  if current_path:
@@ -1554,7 +2209,7 @@ def get_available_image_models(current_path=None):
1554
2209
 
1555
2210
  all_image_models = []
1556
2211
 
1557
-
2212
+ # Add models configured via environment variables
1558
2213
  env_image_model = os.getenv("NPCSH_IMAGE_MODEL")
1559
2214
  env_image_provider = os.getenv("NPCSH_IMAGE_PROVIDER")
1560
2215
 
@@ -1565,9 +2220,8 @@ def get_available_image_models(current_path=None):
1565
2220
  "display_name": f"{env_image_model} | {env_image_provider} (Configured)"
1566
2221
  })
1567
2222
 
1568
-
2223
+ # Add predefined models (OpenAI, Gemini, and standard Diffusers)
1569
2224
  for provider_key, models_list in IMAGE_MODELS.items():
1570
-
1571
2225
  if provider_key == "openai":
1572
2226
  if os.environ.get("OPENAI_API_KEY"):
1573
2227
  all_image_models.extend([
@@ -1580,16 +2234,25 @@ def get_available_image_models(current_path=None):
1580
2234
  {**model, "provider": provider_key, "display_name": f"{model['display_name']} | {provider_key}"}
1581
2235
  for model in models_list
1582
2236
  ])
1583
- elif provider_key == "diffusers":
1584
-
1585
-
2237
+ elif provider_key == "diffusers": # This entry in IMAGE_MODELS is for standard diffusers
1586
2238
  all_image_models.extend([
1587
2239
  {**model, "provider": provider_key, "display_name": f"{model['display_name']} | {provider_key}"}
1588
2240
  for model in models_list
1589
2241
  ])
1590
2242
 
2243
+ # <--- CRITICAL FIX: Directly call the internal helper function for fine-tuned models
2244
+ try:
2245
+ finetuned_data_result = _get_finetuned_models_internal(current_path)
2246
+ if finetuned_data_result and finetuned_data_result.get("models"):
2247
+ all_image_models.extend(finetuned_data_result["models"])
2248
+ else:
2249
+ print(f"No fine-tuned models returned by internal helper or an error occurred internally.")
2250
+ if finetuned_data_result.get("error"):
2251
+ print(f"Internal error in _get_finetuned_models_internal: {finetuned_data_result['error']}")
2252
+ except Exception as e:
2253
+ print(f"Error calling _get_finetuned_models_internal: {e}")
1591
2254
 
1592
-
2255
+ # Deduplicate models
1593
2256
  seen_models = set()
1594
2257
  unique_models = []
1595
2258
  for model_entry in all_image_models:
@@ -1598,6 +2261,7 @@ def get_available_image_models(current_path=None):
1598
2261
  seen_models.add(key)
1599
2262
  unique_models.append(model_entry)
1600
2263
 
2264
+ # Return the combined, deduplicated list of models as a dictionary with a 'models' key
1601
2265
  return unique_models
1602
2266
 
1603
2267
  @app.route('/api/generative_fill', methods=['POST'])
@@ -1932,14 +2596,24 @@ def get_mcp_tools():
1932
2596
  It will try to use an existing client from corca_states if available and matching,
1933
2597
  otherwise it creates a temporary client.
1934
2598
  """
1935
- server_path = request.args.get("mcpServerPath")
2599
+ raw_server_path = request.args.get("mcpServerPath")
2600
+ current_path_arg = request.args.get("currentPath")
1936
2601
  conversation_id = request.args.get("conversationId")
1937
2602
  npc_name = request.args.get("npc")
2603
+ selected_filter = request.args.get("selected", "")
2604
+ selected_names = [s.strip() for s in selected_filter.split(",") if s.strip()]
1938
2605
 
1939
- if not server_path:
2606
+ if not raw_server_path:
1940
2607
  return jsonify({"error": "mcpServerPath parameter is required."}), 400
1941
2608
 
1942
-
2609
+ # Normalize/expand the provided path so cwd/tilde don't break imports
2610
+ resolved_path = resolve_mcp_server_path(
2611
+ current_path=current_path_arg,
2612
+ explicit_path=raw_server_path,
2613
+ force_global=False
2614
+ )
2615
+ server_path = os.path.abspath(os.path.expanduser(resolved_path))
2616
+
1943
2617
  try:
1944
2618
  from npcsh.corca import MCPClientNPC
1945
2619
  except ImportError:
@@ -1956,13 +2630,19 @@ def get_mcp_tools():
1956
2630
  and existing_corca_state.mcp_client.server_script_path == server_path:
1957
2631
  print(f"Using existing MCP client for {state_key} to fetch tools.")
1958
2632
  temp_mcp_client = existing_corca_state.mcp_client
1959
- return jsonify({"tools": temp_mcp_client.available_tools_llm, "error": None})
2633
+ tools = temp_mcp_client.available_tools_llm
2634
+ if selected_names:
2635
+ tools = [t for t in tools if t.get("function", {}).get("name") in selected_names]
2636
+ return jsonify({"tools": tools, "error": None})
1960
2637
 
1961
2638
 
1962
2639
  print(f"Creating a temporary MCP client to fetch tools for {server_path}.")
1963
2640
  temp_mcp_client = MCPClientNPC()
1964
2641
  if temp_mcp_client.connect_sync(server_path):
1965
- return jsonify({"tools": temp_mcp_client.available_tools_llm, "error": None})
2642
+ tools = temp_mcp_client.available_tools_llm
2643
+ if selected_names:
2644
+ tools = [t for t in tools if t.get("function", {}).get("name") in selected_names]
2645
+ return jsonify({"tools": tools, "error": None})
1966
2646
  else:
1967
2647
  return jsonify({"error": f"Failed to connect to MCP server at {server_path}."}), 500
1968
2648
  except FileNotFoundError as e:
@@ -1981,6 +2661,64 @@ def get_mcp_tools():
1981
2661
  temp_mcp_client.disconnect_sync()
1982
2662
 
1983
2663
 
2664
+ @app.route("/api/mcp/server/resolve", methods=["GET"])
2665
+ def api_mcp_resolve():
2666
+ current_path = request.args.get("currentPath")
2667
+ explicit = request.args.get("serverPath")
2668
+ try:
2669
+ resolved = resolve_mcp_server_path(current_path=current_path, explicit_path=explicit)
2670
+ return jsonify({"serverPath": resolved, "error": None})
2671
+ except Exception as e:
2672
+ return jsonify({"serverPath": None, "error": str(e)}), 500
2673
+
2674
+
2675
+ @app.route("/api/mcp/server/start", methods=["POST"])
2676
+ def api_mcp_start():
2677
+ data = request.get_json() or {}
2678
+ current_path = data.get("currentPath")
2679
+ explicit = data.get("serverPath")
2680
+ try:
2681
+ server_path = resolve_mcp_server_path(current_path=current_path, explicit_path=explicit)
2682
+ result = mcp_server_manager.start(server_path)
2683
+ return jsonify({**result, "error": None})
2684
+ except Exception as e:
2685
+ print(f"Error starting MCP server: {e}")
2686
+ traceback.print_exc()
2687
+ return jsonify({"error": str(e)}), 500
2688
+
2689
+
2690
+ @app.route("/api/mcp/server/stop", methods=["POST"])
2691
+ def api_mcp_stop():
2692
+ data = request.get_json() or {}
2693
+ explicit = data.get("serverPath")
2694
+ if not explicit:
2695
+ return jsonify({"error": "serverPath is required to stop a server."}), 400
2696
+ try:
2697
+ result = mcp_server_manager.stop(explicit)
2698
+ return jsonify({**result, "error": None})
2699
+ except Exception as e:
2700
+ print(f"Error stopping MCP server: {e}")
2701
+ traceback.print_exc()
2702
+ return jsonify({"error": str(e)}), 500
2703
+
2704
+
2705
+ @app.route("/api/mcp/server/status", methods=["GET"])
2706
+ def api_mcp_status():
2707
+ explicit = request.args.get("serverPath")
2708
+ current_path = request.args.get("currentPath")
2709
+ try:
2710
+ if explicit:
2711
+ result = mcp_server_manager.status(explicit)
2712
+ else:
2713
+ resolved = resolve_mcp_server_path(current_path=current_path, explicit_path=explicit)
2714
+ result = mcp_server_manager.status(resolved)
2715
+ return jsonify({**result, "running": result.get("status") == "running", "all": mcp_server_manager.running(), "error": None})
2716
+ except Exception as e:
2717
+ print(f"Error checking MCP server status: {e}")
2718
+ traceback.print_exc()
2719
+ return jsonify({"error": str(e)}), 500
2720
+
2721
+
1984
2722
  @app.route("/api/image_models", methods=["GET"])
1985
2723
  def get_image_models_api():
1986
2724
  """
@@ -1989,6 +2727,7 @@ def get_image_models_api():
1989
2727
  current_path = request.args.get("currentPath")
1990
2728
  try:
1991
2729
  image_models = get_available_image_models(current_path)
2730
+ print('image models', image_models)
1992
2731
  return jsonify({"models": image_models, "error": None})
1993
2732
  except Exception as e:
1994
2733
  print(f"Error getting available image models: {str(e)}")
@@ -2000,6 +2739,195 @@ def get_image_models_api():
2000
2739
 
2001
2740
 
2002
2741
 
2742
+ def _run_stream_post_processing(
2743
+ conversation_turn_text,
2744
+ conversation_id,
2745
+ command_history,
2746
+ npc_name,
2747
+ team_name,
2748
+ current_path,
2749
+ model,
2750
+ provider,
2751
+ npc_object,
2752
+ messages # For context compression
2753
+ ):
2754
+ """
2755
+ Runs memory extraction and context compression in a background thread.
2756
+ These operations will not block the main stream.
2757
+ """
2758
+ print(f"🌋 Background task started for conversation {conversation_id}!")
2759
+
2760
+ # Memory extraction and KG fact insertion
2761
+ try:
2762
+ if len(conversation_turn_text) > 50: # Only extract memories if the turn is substantial
2763
+ memories_for_approval = extract_and_store_memories(
2764
+ conversation_turn_text,
2765
+ conversation_id,
2766
+ command_history,
2767
+ npc_name,
2768
+ team_name,
2769
+ current_path,
2770
+ model,
2771
+ provider,
2772
+ npc_object
2773
+ )
2774
+ if memories_for_approval:
2775
+ print(f"🔥 Background: Extracted {len(memories_for_approval)} memories for approval for conversation {conversation_id}. Stored as pending in the database (table: memory_lifecycle).")
2776
+ else:
2777
+ print(f"Background: Conversation turn too short ({len(conversation_turn_text)} chars) for memory extraction. Skipping.")
2778
+ except Exception as e:
2779
+ print(f"🌋 Background: Error during memory extraction and KG insertion for conversation {conversation_id}: {e}")
2780
+ traceback.print_exc()
2781
+
2782
+ # Context compression using breathe from llm_funcs
2783
+ try:
2784
+ if len(messages) > 30: # Use the threshold specified in your request
2785
+ # Directly call breathe for summarization
2786
+ breathe_result = breathe(
2787
+ messages=messages,
2788
+ model=model,
2789
+ provider=provider,
2790
+ npc=npc_object # Pass npc for context if available
2791
+ )
2792
+ compressed_output = breathe_result.get('output', '')
2793
+
2794
+ if compressed_output:
2795
+ # Save the compressed context as a new system message in conversation_history
2796
+ compressed_message_id = generate_message_id()
2797
+ save_conversation_message(
2798
+ command_history,
2799
+ conversation_id,
2800
+ "system", # Role for compressed context
2801
+ f"[AUTOMATIC CONTEXT COMPRESSION]: {compressed_output}",
2802
+ wd=current_path,
2803
+ model=model, # Use the same model/provider that generated the summary
2804
+ provider=provider,
2805
+ npc=npc_name, # Associate with the NPC
2806
+ team=team_name, # Associate with the team
2807
+ message_id=compressed_message_id
2808
+ )
2809
+ print(f"💨 Background: Compressed context for conversation {conversation_id} saved as new system message: {compressed_output[:100]}...")
2810
+ else:
2811
+ print(f"Background: Context compression returned no output for conversation {conversation_id}. Skipping saving.")
2812
+ else:
2813
+ print(f"Background: Conversation messages count ({len(messages)}) below threshold for context compression. Skipping.")
2814
+ except Exception as e:
2815
+ print(f"🌋 Background: Error during context compression with breathe for conversation {conversation_id}: {e}")
2816
+ traceback.print_exc()
2817
+
2818
+ print(f"🌋 Background task finished for conversation {conversation_id}!")
2819
+
2820
+
2821
+
2822
+
2823
+ @app.route("/api/text_predict", methods=["POST"])
2824
+ def text_predict():
2825
+ data = request.json
2826
+
2827
+ stream_id = data.get("streamId")
2828
+ if not stream_id:
2829
+ stream_id = str(uuid.uuid4())
2830
+
2831
+ with cancellation_lock:
2832
+ cancellation_flags[stream_id] = False
2833
+
2834
+ print(f"Starting text prediction stream with ID: {stream_id}")
2835
+ print('data')
2836
+
2837
+
2838
+ text_content = data.get("text_content", "")
2839
+ cursor_position = data.get("cursor_position", len(text_content))
2840
+ current_path = data.get("currentPath")
2841
+ model = data.get("model")
2842
+ provider = data.get("provider")
2843
+ context_type = data.get("context_type", "general") # e.g., 'code', 'chat', 'general'
2844
+ file_path = data.get("file_path") # Optional: for code context
2845
+
2846
+ if current_path:
2847
+ load_project_env(current_path)
2848
+
2849
+ text_before_cursor = text_content[:cursor_position]
2850
+
2851
+
2852
+ if context_type == 'code':
2853
+ 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"
2854
+ system_prompt = "You are an AI code completion assistant. Only provide code. Do not add explanations or any other text."
2855
+ elif context_type == 'chat':
2856
+ 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"
2857
+ system_prompt = "You are an AI chat assistant. Only provide natural language completion. Do not add explanations or any other text."
2858
+ else: # general text prediction
2859
+ 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"
2860
+ system_prompt = "You are an AI text completion assistant. Only provide natural language completion. Do not add explanations or any other text."
2861
+
2862
+
2863
+ npc_object = None # For prediction, we don't necessarily use a specific NPC
2864
+
2865
+ messages_for_llm = [
2866
+ {"role": "system", "content": system_prompt},
2867
+ {"role": "user", "content": prompt_for_llm}
2868
+ ]
2869
+
2870
+ def event_stream_text_predict(current_stream_id):
2871
+ complete_prediction = []
2872
+ try:
2873
+ stream_response_generator = get_llm_response(
2874
+ prompt_for_llm,
2875
+ messages=messages_for_llm,
2876
+ model=model,
2877
+ provider=provider,
2878
+ npc=npc_object,
2879
+ stream=True,
2880
+ )
2881
+
2882
+ # get_llm_response returns a dict with 'response' as a generator when stream=True
2883
+ if isinstance(stream_response_generator, dict) and 'response' in stream_response_generator:
2884
+ stream_generator = stream_response_generator['response']
2885
+ else:
2886
+ # Fallback for non-streaming LLM responses or errors
2887
+ output_content = ""
2888
+ if isinstance(stream_response_generator, dict) and 'output' in stream_response_generator:
2889
+ output_content = stream_response_generator['output']
2890
+ elif isinstance(stream_response_generator, str):
2891
+ output_content = stream_response_generator
2892
+
2893
+ yield f"data: {json.dumps({'choices': [{'delta': {'content': output_content}}]})}\n\n"
2894
+ yield f"data: [DONE]\n\n"
2895
+ return
2896
+
2897
+
2898
+ for response_chunk in stream_generator:
2899
+ with cancellation_lock:
2900
+ if cancellation_flags.get(current_stream_id, False):
2901
+ print(f"Cancellation flag triggered for {current_stream_id}. Breaking loop.")
2902
+ break
2903
+
2904
+ chunk_content = ""
2905
+ # Handle different LLM API response formats
2906
+ if "hf.co" in model or (provider == 'ollama' and 'gpt-oss' not in model): # Heuristic for Ollama/HF models
2907
+ chunk_content = response_chunk["message"]["content"] if "message" in response_chunk and "content" in response_chunk["message"] else ""
2908
+ else: # Assume OpenAI-like streaming format
2909
+ chunk_content = "".join(choice.delta.content for choice in response_chunk.choices if choice.delta.content is not None)
2910
+
2911
+ print(chunk_content, end='')
2912
+
2913
+ if chunk_content:
2914
+ complete_prediction.append(chunk_content)
2915
+ yield f"data: {json.dumps({'choices': [{'delta': {'content': chunk_content}}]})}\n\n"
2916
+
2917
+ except Exception as e:
2918
+ print(f"\nAn exception occurred during text prediction streaming for {current_stream_id}: {e}")
2919
+ traceback.print_exc()
2920
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
2921
+
2922
+ finally:
2923
+ print(f"\nText prediction stream {current_stream_id} finished.")
2924
+ yield f"data: [DONE]\n\n" # Signal end of stream
2925
+ with cancellation_lock:
2926
+ if current_stream_id in cancellation_flags:
2927
+ del cancellation_flags[current_stream_id]
2928
+ print(f"Cleaned up cancellation flag for stream ID: {current_stream_id}")
2929
+
2930
+ return Response(event_stream_text_predict(stream_id), mimetype="text/event-stream")
2003
2931
 
2004
2932
  @app.route("/api/stream", methods=["POST"])
2005
2933
  def stream():
@@ -2195,7 +3123,9 @@ def stream():
2195
3123
  if 'tools' in tool_args and tool_args['tools']:
2196
3124
  tool_args['tool_choice'] = {"type": "auto"}
2197
3125
 
2198
-
3126
+ # Default stream response so closures below always have a value
3127
+ stream_response = {"output": "", "messages": messages}
3128
+
2199
3129
  exe_mode = data.get('executionMode','chat')
2200
3130
 
2201
3131
  if exe_mode == 'chat':
@@ -2270,23 +3200,18 @@ def stream():
2270
3200
  messages = state.messages
2271
3201
 
2272
3202
  elif exe_mode == 'corca':
2273
-
2274
3203
  try:
2275
3204
  from npcsh.corca import execute_command_corca, create_corca_state_and_mcp_client, MCPClientNPC
2276
3205
  from npcsh._state import initial_state as state
2277
3206
  except ImportError:
2278
-
2279
3207
  print("ERROR: npcsh.corca or MCPClientNPC not found. Corca mode is disabled.", file=sys.stderr)
2280
- state = None
3208
+ state = None
2281
3209
  stream_response = {"output": "Corca mode is not available due to missing dependencies.", "messages": messages}
2282
-
2283
-
2284
- if state is not None:
2285
-
3210
+
3211
+ if state is not None:
2286
3212
  mcp_server_path_from_request = data.get("mcpServerPath")
2287
3213
  selected_mcp_tools_from_request = data.get("selectedMcpTools", [])
2288
-
2289
-
3214
+
2290
3215
  effective_mcp_server_path = mcp_server_path_from_request
2291
3216
  if not effective_mcp_server_path and team_object and hasattr(team_object, 'team_ctx') and team_object.team_ctx:
2292
3217
  mcp_servers_list = team_object.team_ctx.get('mcp_servers', [])
@@ -2294,18 +3219,19 @@ def stream():
2294
3219
  first_server_obj = next((s for s in mcp_servers_list if isinstance(s, dict) and 'value' in s), None)
2295
3220
  if first_server_obj:
2296
3221
  effective_mcp_server_path = first_server_obj['value']
2297
- elif isinstance(team_object.team_ctx.get('mcp_server'), str):
3222
+ elif isinstance(team_object.team_ctx.get('mcp_server'), str):
2298
3223
  effective_mcp_server_path = team_object.team_ctx.get('mcp_server')
2299
3224
 
2300
-
3225
+ if effective_mcp_server_path:
3226
+ effective_mcp_server_path = os.path.abspath(os.path.expanduser(effective_mcp_server_path))
3227
+
2301
3228
  if not hasattr(app, 'corca_states'):
2302
3229
  app.corca_states = {}
2303
-
3230
+
2304
3231
  state_key = f"{conversation_id}_{npc_name or 'default'}"
2305
-
2306
- corca_state = None
2307
- if state_key not in app.corca_states:
2308
-
3232
+ corca_state = app.corca_states.get(state_key)
3233
+
3234
+ if corca_state is None:
2309
3235
  corca_state = create_corca_state_and_mcp_client(
2310
3236
  conversation_id=conversation_id,
2311
3237
  command_history=command_history,
@@ -2316,21 +3242,21 @@ def stream():
2316
3242
  )
2317
3243
  app.corca_states[state_key] = corca_state
2318
3244
  else:
2319
- corca_state = app.corca_states[state_key]
2320
3245
  corca_state.npc = npc_object
2321
3246
  corca_state.team = team_object
2322
3247
  corca_state.current_path = current_path
2323
3248
  corca_state.messages = messages
2324
3249
  corca_state.command_history = command_history
2325
3250
 
2326
-
2327
3251
  current_mcp_client_path = getattr(corca_state.mcp_client, 'server_script_path', None)
3252
+ if current_mcp_client_path:
3253
+ current_mcp_client_path = os.path.abspath(os.path.expanduser(current_mcp_client_path))
2328
3254
 
2329
3255
  if effective_mcp_server_path != current_mcp_client_path:
2330
3256
  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
3257
  if corca_state.mcp_client and corca_state.mcp_client.session:
2332
3258
  corca_state.mcp_client.disconnect_sync()
2333
- corca_state.mcp_client = None
3259
+ corca_state.mcp_client = None
2334
3260
 
2335
3261
  if effective_mcp_server_path:
2336
3262
  new_mcp_client = MCPClientNPC()
@@ -2340,20 +3266,19 @@ def stream():
2340
3266
  else:
2341
3267
  print(f"Failed to reconnect MCP client for {state_key} to {effective_mcp_server_path}. Corca will have no tools.")
2342
3268
  corca_state.mcp_client = None
2343
-
2344
-
2345
-
3269
+
2346
3270
  state, stream_response = execute_command_corca(
2347
3271
  commandstr,
2348
3272
  corca_state,
2349
3273
  command_history,
2350
- selected_mcp_tools_names=selected_mcp_tools_from_request
3274
+ selected_mcp_tools_names=selected_mcp_tools_from_request
2351
3275
  )
2352
-
2353
-
3276
+
2354
3277
  app.corca_states[state_key] = state
2355
- messages = state.messages
3278
+ messages = state.messages
2356
3279
 
3280
+ else:
3281
+ stream_response = {"output": f"Unsupported execution mode: {exe_mode}", "messages": messages}
2357
3282
 
2358
3283
  user_message_filled = ''
2359
3284
 
@@ -2394,44 +3319,44 @@ def stream():
2394
3319
  if isinstance(stream_response, str) :
2395
3320
  print('stream a str and not a gen')
2396
3321
  chunk_data = {
2397
- "id": None,
2398
- "object": None,
2399
- "created": datetime.datetime.now().strftime('YYYY-DD-MM-HHMMSS'),
3322
+ "id": None,
3323
+ "object": None,
3324
+ "created": datetime.datetime.now().strftime('YYYY-DD-MM-HHMMSS'),
2400
3325
  "model": model,
2401
3326
  "choices": [
2402
3327
  {
2403
- "index": 0,
2404
- "delta":
3328
+ "index": 0,
3329
+ "delta":
2405
3330
  {
2406
3331
  "content": stream_response,
2407
3332
  "role": "assistant"
2408
- },
3333
+ },
2409
3334
  "finish_reason": 'done'
2410
3335
  }
2411
3336
  ]
2412
3337
  }
2413
- yield f"data: {json.dumps(chunk_data)}"
3338
+ yield f"data: {json.dumps(chunk_data)}\n\n"
2414
3339
  return
2415
3340
  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')
3341
+ print('stream a str and not a gen')
2417
3342
  chunk_data = {
2418
- "id": None,
2419
- "object": None,
2420
- "created": datetime.datetime.now().strftime('YYYY-DD-MM-HHMMSS'),
3343
+ "id": None,
3344
+ "object": None,
3345
+ "created": datetime.datetime.now().strftime('YYYY-DD-MM-HHMMSS'),
2421
3346
  "model": model,
2422
3347
  "choices": [
2423
3348
  {
2424
- "index": 0,
2425
- "delta":
3349
+ "index": 0,
3350
+ "delta":
2426
3351
  {
2427
3352
  "content": stream_response.get('output') ,
2428
3353
  "role": "assistant"
2429
- },
3354
+ },
2430
3355
  "finish_reason": 'done'
2431
3356
  }
2432
3357
  ]
2433
3358
  }
2434
- yield f"data: {json.dumps(chunk_data)}"
3359
+ yield f"data: {json.dumps(chunk_data)}\n\n"
2435
3360
  return
2436
3361
  for response_chunk in stream_response.get('response', stream_response.get('output')):
2437
3362
  with cancellation_lock:
@@ -2459,8 +3384,8 @@ def stream():
2459
3384
  if chunk_content:
2460
3385
  complete_response.append(chunk_content)
2461
3386
  chunk_data = {
2462
- "id": None, "object": None,
2463
- "created": response_chunk["created_at"] or datetime.datetime.now(),
3387
+ "id": None, "object": None,
3388
+ "created": response_chunk["created_at"] or datetime.datetime.now(),
2464
3389
  "model": response_chunk["model"],
2465
3390
  "choices": [{"index": 0, "delta": {"content": chunk_content, "role": response_chunk["message"]["role"]}, "finish_reason": response_chunk.get("done_reason")}]
2466
3391
  }
@@ -2494,33 +3419,56 @@ def stream():
2494
3419
  print(f"\nAn exception occurred during streaming for {current_stream_id}: {e}")
2495
3420
  traceback.print_exc()
2496
3421
  interrupted = True
2497
-
3422
+
2498
3423
  finally:
2499
3424
  print(f"\nStream {current_stream_id} finished. Interrupted: {interrupted}")
2500
3425
  print('\r' + ' ' * dot_count*2 + '\r', end="", flush=True)
2501
3426
 
2502
3427
  final_response_text = ''.join(complete_response)
3428
+
3429
+ # Yield message_stop immediately so the client's stream ends quickly
2503
3430
  yield f"data: {json.dumps({'type': 'message_stop'})}\n\n"
2504
-
3431
+
3432
+ # Save assistant message to the database
2505
3433
  npc_name_to_save = npc_object.name if npc_object else ''
2506
3434
  save_conversation_message(
2507
- command_history,
2508
- conversation_id,
2509
- "assistant",
3435
+ command_history,
3436
+ conversation_id,
3437
+ "assistant",
2510
3438
  final_response_text,
2511
- wd=current_path,
2512
- model=model,
3439
+ wd=current_path,
3440
+ model=model,
2513
3441
  provider=provider,
2514
- npc=npc_name_to_save,
2515
- team=team,
3442
+ npc=npc_name_to_save,
3443
+ team=team,
2516
3444
  message_id=message_id,
2517
3445
  )
2518
3446
 
3447
+ # Start background tasks for memory extraction and context compression
3448
+ # These will run without blocking the main response stream.
3449
+ conversation_turn_text = f"User: {commandstr}\nAssistant: {final_response_text}"
3450
+ background_thread = threading.Thread(
3451
+ target=_run_stream_post_processing,
3452
+ args=(
3453
+ conversation_turn_text,
3454
+ conversation_id,
3455
+ command_history,
3456
+ npc_name,
3457
+ team, # Pass the team variable from the outer scope
3458
+ current_path,
3459
+ model,
3460
+ provider,
3461
+ npc_object,
3462
+ messages # Pass messages for context compression
3463
+ )
3464
+ )
3465
+ background_thread.daemon = True # Allow the main program to exit even if this thread is still running
3466
+ background_thread.start()
3467
+
2519
3468
  with cancellation_lock:
2520
3469
  if current_stream_id in cancellation_flags:
2521
3470
  del cancellation_flags[current_stream_id]
2522
3471
  print(f"Cleaned up cancellation flag for stream ID: {current_stream_id}")
2523
-
2524
3472
  return Response(event_stream(stream_id), mimetype="text/event-stream")
2525
3473
 
2526
3474
  @app.route('/api/delete_message', methods=['POST'])
@@ -2574,295 +3522,6 @@ def approve_memories():
2574
3522
 
2575
3523
 
2576
3524
 
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
3525
 
2867
3526
  @app.route("/api/interrupt", methods=["POST"])
2868
3527
  def interrupt_stream():