npcpy 1.2.33__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
@@ -7,8 +7,10 @@ import uuid
7
7
  import sys
8
8
  import traceback
9
9
  import glob
10
+ import re
11
+ import time
10
12
 
11
-
13
+ import io
12
14
  from flask_cors import CORS
13
15
  import os
14
16
  import sqlite3
@@ -29,12 +31,27 @@ try:
29
31
  import ollama
30
32
  except:
31
33
  pass
34
+ from jinja2 import Environment, FileSystemLoader, Template, Undefined, DictLoader
35
+ class SilentUndefined(Undefined):
36
+ def _fail_with_undefined_error(self, *args, **kwargs):
37
+ return ""
38
+
39
+ # Import ShellState and helper functions from npcsh
40
+ from npcsh._state import ShellState
41
+
42
+
43
+ from npcpy.memory.knowledge_graph import load_kg_from_db
44
+ from npcpy.memory.search import execute_rag_command, execute_brainblast_command
45
+ from npcpy.data.load import load_file_contents
46
+ from npcpy.data.web import search_web
47
+
48
+ from npcsh._state import get_relevant_memories, search_kg_facts
32
49
 
33
50
  import base64
34
51
  import shutil
35
52
  import uuid
36
53
 
37
- from npcpy.llm_funcs import gen_image
54
+ from npcpy.llm_funcs import gen_image, breathe
38
55
 
39
56
  from sqlalchemy import create_engine, text
40
57
  from sqlalchemy.orm import sessionmaker
@@ -69,6 +86,82 @@ cancellation_flags = {}
69
86
  cancellation_lock = threading.Lock()
70
87
 
71
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
+
72
165
  def get_project_npc_directory(current_path=None):
73
166
  """
74
167
  Get the project NPC directory based on the current path
@@ -171,6 +264,34 @@ def get_db_session():
171
264
  Session = sessionmaker(bind=engine)
172
265
  return Session()
173
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
+
174
295
  extension_map = {
175
296
  "PNG": "images",
176
297
  "JPG": "images",
@@ -426,8 +547,6 @@ def capture():
426
547
  return None
427
548
 
428
549
  return jsonify({"screenshot": screenshot})
429
-
430
-
431
550
  @app.route("/api/settings/global", methods=["GET", "OPTIONS"])
432
551
  def get_global_settings():
433
552
  if request.method == "OPTIONS":
@@ -436,22 +555,22 @@ def get_global_settings():
436
555
  try:
437
556
  npcshrc_path = os.path.expanduser("~/.npcshrc")
438
557
 
439
-
440
558
  global_settings = {
441
559
  "model": "llama3.2",
442
560
  "provider": "ollama",
443
561
  "embedding_model": "nomic-embed-text",
444
562
  "embedding_provider": "ollama",
445
563
  "search_provider": "perplexity",
446
- "NPC_STUDIO_LICENSE_KEY": "",
447
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
448
568
  }
449
569
  global_vars = {}
450
570
 
451
571
  if os.path.exists(npcshrc_path):
452
572
  with open(npcshrc_path, "r") as f:
453
573
  for line in f:
454
-
455
574
  line = line.split("#")[0].strip()
456
575
  if not line:
457
576
  continue
@@ -459,33 +578,35 @@ def get_global_settings():
459
578
  if "=" not in line:
460
579
  continue
461
580
 
462
-
463
581
  key, value = line.split("=", 1)
464
582
  key = key.strip()
465
583
  if key.startswith("export "):
466
584
  key = key[7:]
467
585
 
468
-
469
586
  value = value.strip()
470
587
  if value.startswith('"') and value.endswith('"'):
471
588
  value = value[1:-1]
472
589
  elif value.startswith("'") and value.endswith("'"):
473
590
  value = value[1:-1]
474
591
 
475
-
476
592
  key_mapping = {
477
593
  "NPCSH_MODEL": "model",
478
594
  "NPCSH_PROVIDER": "provider",
479
595
  "NPCSH_EMBEDDING_MODEL": "embedding_model",
480
596
  "NPCSH_EMBEDDING_PROVIDER": "embedding_provider",
481
597
  "NPCSH_SEARCH_PROVIDER": "search_provider",
482
- "NPC_STUDIO_LICENSE_KEY": "NPC_STUDIO_LICENSE_KEY",
483
598
  "NPCSH_STREAM_OUTPUT": "NPCSH_STREAM_OUTPUT",
484
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
485
603
  }
486
604
 
487
605
  if key in key_mapping:
488
- 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
489
610
  else:
490
611
  global_vars[key] = value
491
612
 
@@ -502,6 +623,7 @@ def get_global_settings():
502
623
  except Exception as e:
503
624
  print(f"Error in get_global_settings: {str(e)}")
504
625
  return jsonify({"error": str(e)}), 500
626
+
505
627
  def _get_jinx_files_recursively(directory):
506
628
  """Helper to recursively find all .jinx file paths."""
507
629
  jinx_paths = []
@@ -535,46 +657,12 @@ def get_available_jinxs():
535
657
  traceback.print_exc()
536
658
  return jsonify({'jinxs': [], 'error': str(e)}), 500
537
659
 
538
- @app.route("/api/jinxs/global", methods=["GET"])
539
- def get_global_jinxs():
540
- jinxs_dir = os.path.join(os.path.expanduser("~"), ".npcsh", "npc_team", "jinxs")
541
- jinx_paths = _get_jinx_files_recursively(jinxs_dir)
542
- jinxs = []
543
- for path in jinx_paths:
544
- try:
545
- with open(path, "r") as f:
546
- jinx_data = yaml.safe_load(f)
547
- jinxs.append(jinx_data)
548
- except Exception as e:
549
- print(f"Error loading global jinx {path}: {e}")
550
- return jsonify({"jinxs": jinxs})
551
-
552
- @app.route("/api/jinxs/project", methods=["GET"])
553
- def get_project_jinxs():
554
- current_path = request.args.get("currentPath")
555
- if not current_path:
556
- return jsonify({"jinxs": []})
557
-
558
- if not current_path.endswith("npc_team"):
559
- current_path = os.path.join(current_path, "npc_team")
560
-
561
- jinxs_dir = os.path.join(current_path, "jinxs")
562
- jinx_paths = _get_jinx_files_recursively(jinxs_dir)
563
- jinxs = []
564
- for path in jinx_paths:
565
- try:
566
- with open(path, "r") as f:
567
- jinx_data = yaml.safe_load(f)
568
- jinxs.append(jinx_data)
569
- except Exception as e:
570
- print(f"Error loading project jinx {path}: {e}")
571
- return jsonify({"jinxs": jinxs})
572
660
 
573
661
  @app.route("/api/jinx/execute", methods=["POST"])
574
662
  def execute_jinx():
575
663
  """
576
664
  Execute a specific jinx with provided arguments.
577
- Streams the output back to the client.
665
+ Returns the output as a JSON response.
578
666
  """
579
667
  data = request.json
580
668
 
@@ -585,19 +673,18 @@ def execute_jinx():
585
673
  with cancellation_lock:
586
674
  cancellation_flags[stream_id] = False
587
675
 
588
- print(f"--- Jinx Execution Request for streamId: {stream_id} ---")
589
- print(f"Request Data: {json.dumps(data, indent=2)}")
676
+ print(f"--- Jinx Execution Request for streamId: {stream_id} ---", file=sys.stderr)
677
+ print(f"Request Data: {json.dumps(data, indent=2)}", file=sys.stderr)
590
678
 
591
679
  jinx_name = data.get("jinxName")
592
680
  jinx_args = data.get("jinxArgs", [])
593
- print(f"Jinx Name: {jinx_name}, Jinx Args: {jinx_args}")
681
+ print(f"Jinx Name: {jinx_name}, Jinx Args: {jinx_args}", file=sys.stderr)
594
682
  conversation_id = data.get("conversationId")
595
683
  model = data.get("model")
596
684
  provider = data.get("provider")
597
685
 
598
- # --- IMPORTANT: Ensure conversation_id is present for context persistence ---
599
686
  if not conversation_id:
600
- print("ERROR: conversationId is required for Jinx execution with persistent variables")
687
+ print("ERROR: conversationId is required for Jinx execution with persistent variables", file=sys.stderr)
601
688
  return jsonify({"error": "conversationId is required for Jinx execution with persistent variables"}), 400
602
689
 
603
690
  npc_name = data.get("npc")
@@ -605,223 +692,193 @@ def execute_jinx():
605
692
  current_path = data.get("currentPath")
606
693
 
607
694
  if not jinx_name:
608
- print("ERROR: jinxName is required")
695
+ print("ERROR: jinxName is required", file=sys.stderr)
609
696
  return jsonify({"error": "jinxName is required"}), 400
610
697
 
611
- # Load project environment if applicable
612
698
  if current_path:
613
699
  load_project_env(current_path)
614
700
 
615
- # Load the NPC
616
- npc_object = None
701
+ jinx = None
702
+
617
703
  if npc_name:
618
704
  db_conn = get_db_connection()
619
705
  npc_object = load_npc_by_name_and_source(npc_name, npc_source, db_conn, current_path)
620
706
  if not npc_object and npc_source == 'project':
621
707
  npc_object = load_npc_by_name_and_source(npc_name, 'global', db_conn)
708
+ else:
709
+ npc_object = None
622
710
 
623
- # Try to find the jinx
624
- jinx = None
625
-
626
- # Check NPC's jinxs
627
711
  if npc_object and hasattr(npc_object, 'jinxs_dict') and jinx_name in npc_object.jinxs_dict:
628
712
  jinx = npc_object.jinxs_dict[jinx_name]
713
+ print(f"Found jinx in NPC's jinxs_dict", file=sys.stderr)
629
714
 
630
- # Check team jinxs
631
715
  if not jinx and current_path:
632
- team_jinx_path = os.path.join(current_path, 'npc_team', 'jinxs', f'{jinx_name}.jinx')
633
- if os.path.exists(team_jinx_path):
634
- jinx = Jinx(jinx_path=team_jinx_path)
635
-
636
- # Check global jinxs
716
+ project_jinxs_base = os.path.join(current_path, 'npc_team', 'jinxs')
717
+ if os.path.exists(project_jinxs_base):
718
+ for root, dirs, files in os.walk(project_jinxs_base):
719
+ if f'{jinx_name}.jinx' in files:
720
+ project_jinx_path = os.path.join(root, f'{jinx_name}.jinx')
721
+ jinx = Jinx(jinx_path=project_jinx_path)
722
+ print(f"Found jinx at: {project_jinx_path}", file=sys.stderr)
723
+ break
724
+
637
725
  if not jinx:
638
- global_jinx_path = os.path.expanduser(f'~/.npcsh/npc_team/jinxs/{jinx_name}.jinx')
639
- if os.path.exists(global_jinx_path):
640
- jinx = Jinx(jinx_path=global_jinx_path)
726
+ global_jinxs_base = os.path.expanduser('~/.npcsh/npc_team/jinxs')
727
+ if os.path.exists(global_jinxs_base):
728
+ for root, dirs, files in os.walk(global_jinxs_base):
729
+ if f'{jinx_name}.jinx' in files:
730
+ global_jinx_path = os.path.join(root, f'{jinx_name}.jinx')
731
+ jinx = Jinx(jinx_path=global_jinx_path)
732
+ print(f"Found jinx at: {global_jinx_path}", file=sys.stderr)
733
+
734
+ # Initialize jinx steps by calling render_first_pass
735
+ from jinja2 import Environment
736
+ temp_env = Environment()
737
+ jinx.render_first_pass(temp_env, {})
738
+
739
+ break
641
740
 
642
741
  if not jinx:
643
- print(f"ERROR: Jinx '{jinx_name}' not found")
742
+ print(f"ERROR: Jinx '{jinx_name}' not found", file=sys.stderr)
743
+ searched_paths = []
744
+ if npc_object:
745
+ searched_paths.append(f"NPC {npc_name} jinxs_dict")
746
+ if current_path:
747
+ searched_paths.append(f"Project jinxs at {os.path.join(current_path, 'npc_team', 'jinxs')}")
748
+ searched_paths.append(f"Global jinxs at {os.path.expanduser('~/.npcsh/npc_team/jinxs')}")
749
+ print(f"Searched in: {', '.join(searched_paths)}", file=sys.stderr)
644
750
  return jsonify({"error": f"Jinx '{jinx_name}' not found"}), 404
645
751
 
646
- # Extract inputs from args
647
752
  from npcpy.npc_compiler import extract_jinx_inputs
648
753
 
649
- # Re-assemble arguments that were incorrectly split by spaces.
650
754
  fixed_args = []
651
755
  i = 0
652
- while i < len(jinx_args):
653
- arg = jinx_args[i]
756
+
757
+ # Filter out None values from jinx_args before processing
758
+ cleaned_jinx_args = [arg for arg in jinx_args if arg is not None]
759
+
760
+ while i < len(cleaned_jinx_args):
761
+ arg = cleaned_jinx_args[i]
654
762
  if arg.startswith('-'):
655
763
  fixed_args.append(arg)
656
764
  value_parts = []
657
765
  i += 1
658
- # Collect all subsequent parts until the next flag or the end of the list.
659
- while i < len(jinx_args) and not jinx_args[i].startswith('-'):
660
- value_parts.append(jinx_args[i])
766
+ while i < len(cleaned_jinx_args) and not cleaned_jinx_args[i].startswith('-'):
767
+ value_parts.append(cleaned_jinx_args[i])
661
768
  i += 1
662
769
 
663
770
  if value_parts:
664
- # Join the parts back into a single string.
665
771
  full_value = " ".join(value_parts)
666
- # Clean up the extraneous quotes that the initial bad split left behind.
667
772
  if full_value.startswith("'") and full_value.endswith("'"):
668
773
  full_value = full_value[1:-1]
669
774
  elif full_value.startswith('"') and full_value.endswith('"'):
670
775
  full_value = full_value[1:-1]
671
776
  fixed_args.append(full_value)
672
- # The 'i' counter is already advanced, so the loop continues from the next flag.
673
777
  else:
674
- # This handles positional arguments, just in case.
675
778
  fixed_args.append(arg)
676
779
  i += 1
677
780
 
678
- # Now, use the corrected arguments to extract inputs.
679
781
  input_values = extract_jinx_inputs(fixed_args, jinx)
680
782
 
681
- print(f'Executing jinx with input_values: {input_values}')
682
- # Get conversation history
783
+ print(f'Executing jinx with input_values: {input_values}', file=sys.stderr)
784
+
683
785
  command_history = CommandHistory(app.config.get('DB_PATH'))
684
786
  messages = fetch_messages_for_conversation(conversation_id)
685
787
 
686
- # Prepare jinxs_dict for execution
687
788
  all_jinxs = {}
688
789
  if npc_object and hasattr(npc_object, 'jinxs_dict'):
689
790
  all_jinxs.update(npc_object.jinxs_dict)
690
791
 
691
- # --- IMPORTANT: Retrieve or initialize the persistent Jinx context for this conversation ---
692
792
  if conversation_id not in app.jinx_conversation_contexts:
693
793
  app.jinx_conversation_contexts[conversation_id] = {}
694
794
  jinx_local_context = app.jinx_conversation_contexts[conversation_id]
695
795
 
696
- print(f"--- CONTEXT STATE (conversationId: {conversation_id}) ---")
697
- print(f"jinx_local_context BEFORE Jinx execution: {jinx_local_context}")
796
+ print(f"--- CONTEXT STATE (conversationId: {conversation_id}) ---", file=sys.stderr)
797
+ print(f"jinx_local_context BEFORE Jinx execution: {jinx_local_context}", file=sys.stderr)
698
798
 
699
- def event_stream(current_stream_id):
700
- try:
701
- # --- IMPORTANT: Pass the persistent context as 'extra_globals' ---
702
- result = jinx.execute(
703
- input_values=input_values,
704
- jinxs_dict=all_jinxs,
705
- jinja_env=npc_object.jinja_env if npc_object else None,
706
- npc=npc_object,
707
- messages=messages,
708
- extra_globals=jinx_local_context # <--- THIS IS WHERE THE PERSISTENT CONTEXT IS PASSED
709
- )
710
-
711
- # --- CRITICAL FIX: Capture and update local_vars from the Jinx's result ---
712
- # The Jinx.execute method returns its internal 'context' dictionary.
713
- # We need to update our persistent 'jinx_local_context' with the new variables
714
- # from the Jinx's returned context.
715
- if isinstance(result, dict):
716
- # We need to be careful not to overwrite core Jinx/NPC context keys
717
- # that are not meant for variable persistence.
718
- keys_to_exclude = ['output', 'llm_response', 'messages', 'results', 'npc', 'context', 'jinxs', 'team']
719
-
720
- # Update jinx_local_context with all non-excluded keys from the result
721
- for key, value in result.items():
722
- if key not in keys_to_exclude and not key.startswith('_'): # Exclude internal/temporary keys
723
- jinx_local_context[key] = value
724
-
725
- print(f"jinx_local_context UPDATED from Jinx result: {jinx_local_context}") # NEW LOG
726
-
727
- # Get output (this still comes from the 'output' key in the result)
728
- output = result.get('output', str(result))
729
- messages_updated = result.get('messages', messages)
799
+
800
+ # Create state object
801
+ state = ShellState(
802
+ npc=npc_object,
803
+ team=None,
804
+ conversation_id=conversation_id,
805
+ chat_model=model or os.getenv('NPCSH_CHAT_MODEL', 'gemma3:4b'),
806
+ chat_provider=provider or os.getenv('NPCSH_CHAT_PROVIDER', 'ollama'),
807
+ current_path=current_path or os.getcwd(),
808
+ search_provider=os.getenv('NPCSH_SEARCH_PROVIDER', 'duckduckgo'),
809
+ embedding_model=os.getenv('NPCSH_EMBEDDING_MODEL', 'nomic-embed-text'),
810
+ embedding_provider=os.getenv('NPCSH_EMBEDDING_PROVIDER', 'ollama'),
811
+ )
812
+
813
+ # Build extra_globals with state and all necessary functions
814
+ extra_globals_for_jinx = {
815
+ **jinx_local_context,
816
+ 'state': state,
817
+ 'CommandHistory': CommandHistory,
818
+ 'load_kg_from_db': load_kg_from_db,
819
+ 'execute_rag_command': execute_rag_command,
820
+ 'execute_brainblast_command': execute_brainblast_command,
821
+ 'load_file_contents': load_file_contents,
822
+ 'search_web': search_web,
823
+ 'get_relevant_memories': get_relevant_memories,
824
+ 'search_kg_facts': search_kg_facts,
825
+ }
730
826
 
731
- print(f"jinx_local_context AFTER Jinx execution (final state): {jinx_local_context}")
732
- print(f"Jinx execution result output: {output}")
733
-
734
- # Check for interruption
735
- with cancellation_lock:
736
- if cancellation_flags.get(current_stream_id, False):
737
- yield f"data: {json.dumps({'type': 'interrupted'})}\n\n"
738
- return
739
-
740
- # Stream the output in chunks for consistent UI experience
741
- if isinstance(output, str):
742
- chunk_size = 50 # Characters per chunk
743
- for i in range(0, len(output), chunk_size):
744
- chunk = output[i:i + chunk_size]
745
- chunk_data = {
746
- "id": None,
747
- "object": None,
748
- "created": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
749
- "model": model,
750
- "choices": [{
751
- "index": 0,
752
- "delta": {
753
- "content": chunk,
754
- "role": "assistant"
755
- },
756
- "finish_reason": None
757
- }]
758
- }
759
- yield f"data: {json.dumps(chunk_data)}\n\n"
760
- else:
761
- # Non-string output, send as single chunk
762
- chunk_data = {
763
- "id": None,
764
- "object": None,
765
- "created": datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
766
- "model": model,
767
- "choices": [{
768
- "index": 0,
769
- "delta": {
770
- "content": str(output),
771
- "role": "assistant"
772
- },
773
- "finish_reason": None
774
- }]
775
- }
776
- yield f"data: {json.dumps(chunk_data)}\n\n"
777
-
778
- # Send completion message
779
- yield f"data: {json.dumps({'type': 'message_stop'})}\n\n"
780
-
781
- # Save to conversation history
782
- message_id = generate_message_id()
783
- save_conversation_message(
784
- command_history,
785
- conversation_id,
786
- "user",
787
- f"/{jinx_name} {' '.join(jinx_args)}",
788
- wd=current_path,
789
- model=model,
790
- provider=provider,
791
- npc=npc_name,
792
- message_id=message_id
793
- )
794
-
795
- message_id = generate_message_id()
796
- save_conversation_message(
797
- command_history,
798
- conversation_id,
799
- "assistant",
800
- str(output),
801
- wd=current_path,
802
- model=model,
803
- provider=provider,
804
- npc=npc_name,
805
- message_id=message_id
806
- )
807
-
808
- except Exception as e:
809
- print(f"ERROR: Exception during jinx execution {jinx_name}: {str(e)}")
810
- traceback.print_exc()
811
- error_data = {
812
- "type": "error",
813
- "error": str(e)
814
- }
815
- yield f"data: {json.dumps(error_data)}\n\n"
816
-
817
- finally:
818
- with cancellation_lock:
819
- if current_stream_id in cancellation_flags:
820
- del cancellation_flags[current_stream_id]
821
- print(f"--- Jinx Execution Finished for streamId: {stream_id} ---")
827
+ jinx_execution_result = jinx.execute(
828
+ input_values=input_values,
829
+ jinja_env=npc_object.jinja_env if npc_object else None,
830
+ npc=npc_object,
831
+ messages=messages,
832
+ extra_globals=extra_globals_for_jinx
833
+ )
834
+
835
+ output_from_jinx_result = jinx_execution_result.get('output')
822
836
 
823
- return Response(event_stream(stream_id), mimetype="text/event-stream")
837
+ final_output_string = str(output_from_jinx_result) if output_from_jinx_result is not None else ""
838
+
839
+ if isinstance(jinx_execution_result, dict):
840
+ for key, value in jinx_execution_result.items():
841
+ jinx_local_context[key] = value
842
+
843
+ print(f"jinx_local_context AFTER Jinx execution (final state): {jinx_local_context}", file=sys.stderr)
844
+ print(f"Jinx execution result output: {output_from_jinx_result}", file=sys.stderr)
845
+
846
+ user_message_id = generate_message_id()
847
+
848
+ # Use cleaned_jinx_args for logging the user message
849
+ user_command_log = f"/{jinx_name} {' '.join(cleaned_jinx_args)}"
850
+ save_conversation_message(
851
+ command_history,
852
+ conversation_id,
853
+ "user",
854
+ user_command_log,
855
+ wd=current_path,
856
+ model=model,
857
+ provider=provider,
858
+ npc=npc_name,
859
+ message_id=user_message_id
860
+ )
861
+
862
+ assistant_message_id = generate_message_id()
863
+ save_conversation_message(
864
+ command_history,
865
+ conversation_id,
866
+ "assistant",
867
+ final_output_string,
868
+ wd=current_path,
869
+ model=model,
870
+ provider=provider,
871
+ npc=npc_name,
872
+ message_id=assistant_message_id
873
+ )
824
874
 
875
+ # Determine mimetype based on content
876
+ is_html = bool(re.search(r'<[a-z][\s\S]*>', final_output_string, re.IGNORECASE))
877
+
878
+ if is_html:
879
+ return Response(final_output_string, mimetype="text/html")
880
+ else:
881
+ return Response(final_output_string, mimetype="text/plain")
825
882
  @app.route("/api/settings/global", methods=["POST", "OPTIONS"])
826
883
  def save_global_settings():
827
884
  if request.method == "OPTIONS":
@@ -837,35 +894,41 @@ def save_global_settings():
837
894
  "embedding_model": "NPCSH_EMBEDDING_MODEL",
838
895
  "embedding_provider": "NPCSH_EMBEDDING_PROVIDER",
839
896
  "search_provider": "NPCSH_SEARCH_PROVIDER",
840
- "NPC_STUDIO_LICENSE_KEY": "NPC_STUDIO_LICENSE_KEY",
841
897
  "NPCSH_STREAM_OUTPUT": "NPCSH_STREAM_OUTPUT",
842
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
843
902
  }
844
903
 
845
904
  os.makedirs(os.path.dirname(npcshrc_path), exist_ok=True)
846
905
  print(data)
847
906
  with open(npcshrc_path, "w") as f:
848
-
907
+
849
908
  for key, value in data.get("global_settings", {}).items():
850
- if key in key_mapping and value:
851
-
852
- if " " in str(value):
853
- value = f'"{value}"'
854
- 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")
855
918
 
856
-
857
919
  for key, value in data.get("global_vars", {}).items():
858
- if key and value:
920
+ if key and value is not None: # Check for None explicitly
859
921
  if " " in str(value):
860
- value = f'"{value}"'
861
- 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")
862
926
 
863
927
  return jsonify({"message": "Global settings saved successfully", "error": None})
864
928
 
865
929
  except Exception as e:
866
930
  print(f"Error in save_global_settings: {str(e)}")
867
931
  return jsonify({"error": str(e)}), 500
868
-
869
932
  @app.route("/api/settings/project", methods=["GET", "OPTIONS"])
870
933
  def get_project_settings():
871
934
  if request.method == "OPTIONS":
@@ -1009,52 +1072,6 @@ def api_command(command):
1009
1072
  return jsonify(result)
1010
1073
  except Exception as e:
1011
1074
  return jsonify({"error": str(e)})
1012
- @app.route("/api/npc_team_global")
1013
- def get_npc_team_global():
1014
- try:
1015
- db_conn = get_db_connection()
1016
- global_npc_directory = os.path.expanduser("~/.npcsh/npc_team")
1017
-
1018
- npc_data = []
1019
-
1020
-
1021
- for file in os.listdir(global_npc_directory):
1022
- if file.endswith(".npc"):
1023
- npc_path = os.path.join(global_npc_directory, file)
1024
- npc = NPC(file=npc_path, db_conn=db_conn)
1025
-
1026
-
1027
- serialized_npc = {
1028
- "name": npc.name,
1029
- "primary_directive": npc.primary_directive,
1030
- "model": npc.model,
1031
- "provider": npc.provider,
1032
- "api_url": npc.api_url,
1033
- "use_global_jinxs": npc.use_global_jinxs,
1034
- "jinxs": [
1035
- {
1036
- "jinx_name": jinx.jinx_name,
1037
- "inputs": jinx.inputs,
1038
- "steps": [
1039
- {
1040
- "name": step.get("name", f"step_{i}"),
1041
- "engine": step.get("engine", "natural"),
1042
- "code": step.get("code", "")
1043
- }
1044
- for i, step in enumerate(jinx.steps)
1045
- ]
1046
- }
1047
- for jinx in npc.jinxs
1048
- ],
1049
- }
1050
- npc_data.append(serialized_npc)
1051
-
1052
- return jsonify({"npcs": npc_data, "error": None})
1053
-
1054
- except Exception as e:
1055
- print(f"Error loading global NPCs: {str(e)}")
1056
- return jsonify({"npcs": [], "error": str(e)})
1057
-
1058
1075
 
1059
1076
  @app.route("/api/jinxs/save", methods=["POST"])
1060
1077
  def save_jinx():
@@ -1093,101 +1110,727 @@ def save_jinx():
1093
1110
  return jsonify({"status": "success"})
1094
1111
  except Exception as e:
1095
1112
  return jsonify({"error": str(e)}), 500
1096
-
1097
-
1098
- @app.route("/api/save_npc", methods=["POST"])
1099
- def save_npc():
1100
- try:
1101
- data = request.json
1102
- npc_data = data.get("npc")
1103
- is_global = data.get("isGlobal")
1104
- current_path = data.get("currentPath")
1105
-
1106
- if not npc_data or "name" not in npc_data:
1107
- return jsonify({"error": "Invalid NPC data"}), 400
1108
-
1109
-
1110
- if is_global:
1111
- npc_directory = os.path.expanduser("~/.npcsh/npc_team")
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)
1112
1121
  else:
1113
- npc_directory = os.path.join(current_path, "npc_team")
1122
+ result.append(str(inp))
1123
+ return result
1114
1124
 
1115
-
1116
- os.makedirs(npc_directory, exist_ok=True)
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
+ )
1117
1158
 
1118
-
1119
- yaml_content = f"""name: {npc_data['name']}
1120
- primary_directive: "{npc_data['primary_directive']}"
1121
- model: {npc_data['model']}
1122
- provider: {npc_data['provider']}
1123
- api_url: {npc_data.get('api_url', '')}
1124
- use_global_jinxs: {str(npc_data.get('use_global_jinxs', True)).lower()}
1125
- """
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
1126
1163
 
1127
-
1128
- file_path = os.path.join(npc_directory, f"{npc_data['name']}.npc")
1129
- with open(file_path, "w") as f:
1130
- f.write(yaml_content)
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}"
1131
1180
 
1132
- return jsonify({"message": "NPC saved successfully", "error": None})
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.
1133
1185
 
1134
- except Exception as e:
1135
- print(f"Error saving NPC: {str(e)}")
1136
- return jsonify({"error": str(e)}), 500
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
+ )
1137
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
1138
1208
 
1139
- @app.route("/api/npc_team_project", methods=["GET"])
1140
- def get_npc_team_project():
1141
- try:
1142
- db_conn = get_db_connection()
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
+ )
1143
1213
 
1144
- project_npc_directory = request.args.get("currentPath")
1145
- if not project_npc_directory.endswith("npc_team"):
1146
- project_npc_directory = os.path.join(project_npc_directory, "npc_team")
1214
+ from collections import defaultdict # ADD THIS LINE for collecting links if not already present
1147
1215
 
1148
- npc_data = []
1216
+ finetune_jobs = {}
1149
1217
 
1150
- for file in os.listdir(project_npc_directory):
1151
- print(file)
1152
- if file.endswith(".npc"):
1153
- npc_path = os.path.join(project_npc_directory, file)
1154
- npc = NPC(file=npc_path, db_conn=db_conn)
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
1155
1343
 
1156
-
1157
- serialized_npc = {
1158
- "name": npc.name,
1159
- "primary_directive": npc.primary_directive,
1160
- "model": npc.model,
1161
- "provider": npc.provider,
1162
- "api_url": npc.api_url,
1163
- "use_global_jinxs": npc.use_global_jinxs,
1164
- "jinxs": [
1165
- {
1166
- "jinx_name": jinx.jinx_name,
1167
- "inputs": jinx.inputs,
1168
- "steps": [
1169
- {
1170
- "name": step.get("name", f"step_{i}"),
1171
- "engine": step.get("engine", "natural"),
1172
- "code": step.get("code", "")
1173
- }
1174
- for i, step in enumerate(jinx.steps)
1175
- ]
1176
- }
1177
- for jinx in npc.jinxs
1178
- ],
1179
- }
1180
- npc_data.append(serialized_npc)
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
1181
1348
 
1182
- print(npc_data)
1183
- return jsonify({"npcs": npc_data, "error": None})
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
1184
1370
 
1185
- except Exception as e:
1186
- print(f"Error fetching NPC team: {str(e)}")
1187
- return jsonify({"npcs": [], "error": str(e)})
1188
- def get_last_used_model_and_npc_in_directory(directory_path):
1189
- """
1190
- Fetches the model and NPC from the most recent message in any conversation
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})
1630
+
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
+ })
1649
+ @app.route("/api/save_npc", methods=["POST"])
1650
+ def save_npc():
1651
+ try:
1652
+ data = request.json
1653
+ npc_data = data.get("npc")
1654
+ is_global = data.get("isGlobal")
1655
+ current_path = data.get("currentPath")
1656
+
1657
+ if not npc_data or "name" not in npc_data:
1658
+ return jsonify({"error": "Invalid NPC data"}), 400
1659
+
1660
+
1661
+ if is_global:
1662
+ npc_directory = os.path.expanduser("~/.npcsh/npc_team")
1663
+ else:
1664
+ npc_directory = os.path.join(current_path, "npc_team")
1665
+
1666
+
1667
+ os.makedirs(npc_directory, exist_ok=True)
1668
+
1669
+
1670
+ yaml_content = f"""name: {npc_data['name']}
1671
+ primary_directive: "{npc_data['primary_directive']}"
1672
+ model: {npc_data['model']}
1673
+ provider: {npc_data['provider']}
1674
+ api_url: {npc_data.get('api_url', '')}
1675
+ use_global_jinxs: {str(npc_data.get('use_global_jinxs', True)).lower()}
1676
+ """
1677
+
1678
+
1679
+ file_path = os.path.join(npc_directory, f"{npc_data['name']}.npc")
1680
+ with open(file_path, "w") as f:
1681
+ f.write(yaml_content)
1682
+
1683
+ return jsonify({"message": "NPC saved successfully", "error": None})
1684
+
1685
+ except Exception as e:
1686
+ print(f"Error saving NPC: {str(e)}")
1687
+ return jsonify({"error": str(e)}), 500
1688
+
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 = []
1693
+
1694
+ if not os.path.exists(global_jinx_directory):
1695
+ return jsonify({"jinxs": [], "error": None})
1696
+
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
+ })
1723
+
1724
+ return jsonify({"jinxs": jinx_data, "error": None})
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
1731
+
1732
+ if not project_dir.endswith("jinxs"):
1733
+ project_dir = os.path.join(project_dir, "jinxs")
1734
+
1735
+ jinx_data = []
1736
+ if not os.path.exists(project_dir):
1737
+ return jsonify({"jinxs": [], "error": None})
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})
1767
+
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 = []
1772
+
1773
+ if not os.path.exists(global_npc_directory):
1774
+ return jsonify({"npcs": [], "error": None})
1775
+
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
+ })
1791
+
1792
+ return jsonify({"npcs": npc_data, "error": None})
1793
+
1794
+
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
+ )
1806
+
1807
+ npc_data = []
1808
+
1809
+ if not os.path.exists(project_npc_directory):
1810
+ return jsonify({"npcs": [], "error": None})
1811
+
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)
1828
+
1829
+ return jsonify({"npcs": npc_data, "error": None})
1830
+
1831
+ def get_last_used_model_and_npc_in_directory(directory_path):
1832
+ """
1833
+ Fetches the model and NPC from the most recent message in any conversation
1191
1834
  within the given directory.
1192
1835
  """
1193
1836
  engine = get_db_connection()
@@ -1503,11 +2146,62 @@ IMAGE_MODELS = {
1503
2146
  {"value": "runwayml/stable-diffusion-v1-5", "display_name": "Stable Diffusion v1.5"},
1504
2147
  ],
1505
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
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
1506
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
1507
2201
  def get_available_image_models(current_path=None):
1508
2202
  """
1509
2203
  Retrieves available image generation models based on environment variables
1510
- and predefined configurations.
2204
+ and predefined configurations, including locally fine-tuned Diffusers models.
1511
2205
  """
1512
2206
 
1513
2207
  if current_path:
@@ -1515,7 +2209,7 @@ def get_available_image_models(current_path=None):
1515
2209
 
1516
2210
  all_image_models = []
1517
2211
 
1518
-
2212
+ # Add models configured via environment variables
1519
2213
  env_image_model = os.getenv("NPCSH_IMAGE_MODEL")
1520
2214
  env_image_provider = os.getenv("NPCSH_IMAGE_PROVIDER")
1521
2215
 
@@ -1526,9 +2220,8 @@ def get_available_image_models(current_path=None):
1526
2220
  "display_name": f"{env_image_model} | {env_image_provider} (Configured)"
1527
2221
  })
1528
2222
 
1529
-
2223
+ # Add predefined models (OpenAI, Gemini, and standard Diffusers)
1530
2224
  for provider_key, models_list in IMAGE_MODELS.items():
1531
-
1532
2225
  if provider_key == "openai":
1533
2226
  if os.environ.get("OPENAI_API_KEY"):
1534
2227
  all_image_models.extend([
@@ -1541,16 +2234,25 @@ def get_available_image_models(current_path=None):
1541
2234
  {**model, "provider": provider_key, "display_name": f"{model['display_name']} | {provider_key}"}
1542
2235
  for model in models_list
1543
2236
  ])
1544
- elif provider_key == "diffusers":
1545
-
1546
-
2237
+ elif provider_key == "diffusers": # This entry in IMAGE_MODELS is for standard diffusers
1547
2238
  all_image_models.extend([
1548
2239
  {**model, "provider": provider_key, "display_name": f"{model['display_name']} | {provider_key}"}
1549
2240
  for model in models_list
1550
2241
  ])
1551
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}")
1552
2254
 
1553
-
2255
+ # Deduplicate models
1554
2256
  seen_models = set()
1555
2257
  unique_models = []
1556
2258
  for model_entry in all_image_models:
@@ -1559,6 +2261,7 @@ def get_available_image_models(current_path=None):
1559
2261
  seen_models.add(key)
1560
2262
  unique_models.append(model_entry)
1561
2263
 
2264
+ # Return the combined, deduplicated list of models as a dictionary with a 'models' key
1562
2265
  return unique_models
1563
2266
 
1564
2267
  @app.route('/api/generative_fill', methods=['POST'])
@@ -1893,14 +2596,24 @@ def get_mcp_tools():
1893
2596
  It will try to use an existing client from corca_states if available and matching,
1894
2597
  otherwise it creates a temporary client.
1895
2598
  """
1896
- server_path = request.args.get("mcpServerPath")
2599
+ raw_server_path = request.args.get("mcpServerPath")
2600
+ current_path_arg = request.args.get("currentPath")
1897
2601
  conversation_id = request.args.get("conversationId")
1898
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()]
1899
2605
 
1900
- if not server_path:
2606
+ if not raw_server_path:
1901
2607
  return jsonify({"error": "mcpServerPath parameter is required."}), 400
1902
2608
 
1903
-
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
+
1904
2617
  try:
1905
2618
  from npcsh.corca import MCPClientNPC
1906
2619
  except ImportError:
@@ -1917,13 +2630,19 @@ def get_mcp_tools():
1917
2630
  and existing_corca_state.mcp_client.server_script_path == server_path:
1918
2631
  print(f"Using existing MCP client for {state_key} to fetch tools.")
1919
2632
  temp_mcp_client = existing_corca_state.mcp_client
1920
- 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})
1921
2637
 
1922
2638
 
1923
2639
  print(f"Creating a temporary MCP client to fetch tools for {server_path}.")
1924
2640
  temp_mcp_client = MCPClientNPC()
1925
2641
  if temp_mcp_client.connect_sync(server_path):
1926
- 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})
1927
2646
  else:
1928
2647
  return jsonify({"error": f"Failed to connect to MCP server at {server_path}."}), 500
1929
2648
  except FileNotFoundError as e:
@@ -1942,6 +2661,64 @@ def get_mcp_tools():
1942
2661
  temp_mcp_client.disconnect_sync()
1943
2662
 
1944
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
+
1945
2722
  @app.route("/api/image_models", methods=["GET"])
1946
2723
  def get_image_models_api():
1947
2724
  """
@@ -1950,6 +2727,7 @@ def get_image_models_api():
1950
2727
  current_path = request.args.get("currentPath")
1951
2728
  try:
1952
2729
  image_models = get_available_image_models(current_path)
2730
+ print('image models', image_models)
1953
2731
  return jsonify({"models": image_models, "error": None})
1954
2732
  except Exception as e:
1955
2733
  print(f"Error getting available image models: {str(e)}")
@@ -1961,34 +2739,224 @@ def get_image_models_api():
1961
2739
 
1962
2740
 
1963
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}!")
1964
2759
 
1965
- @app.route("/api/stream", methods=["POST"])
1966
- def stream():
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():
1967
2825
  data = request.json
1968
-
2826
+
1969
2827
  stream_id = data.get("streamId")
1970
2828
  if not stream_id:
1971
- import uuid
1972
2829
  stream_id = str(uuid.uuid4())
1973
2830
 
1974
2831
  with cancellation_lock:
1975
2832
  cancellation_flags[stream_id] = False
1976
- print(f"Starting stream with ID: {stream_id}")
1977
-
1978
- commandstr = data.get("commandstr")
1979
- conversation_id = data.get("conversationId")
1980
- model = data.get("model", None)
1981
- provider = data.get("provider", None)
1982
- if provider is None:
1983
- provider = available_models.get(model)
1984
-
1985
- npc_name = data.get("npc", None)
1986
- npc_source = data.get("npcSource", "global")
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))
1987
2840
  current_path = data.get("currentPath")
1988
-
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
+
1989
2846
  if current_path:
1990
- loaded_vars = load_project_env(current_path)
1991
- print(f"Loaded project env variables for stream request: {list(loaded_vars.keys())}")
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")
2931
+
2932
+ @app.route("/api/stream", methods=["POST"])
2933
+ def stream():
2934
+ data = request.json
2935
+
2936
+ stream_id = data.get("streamId")
2937
+ if not stream_id:
2938
+ import uuid
2939
+ stream_id = str(uuid.uuid4())
2940
+
2941
+ with cancellation_lock:
2942
+ cancellation_flags[stream_id] = False
2943
+ print(f"Starting stream with ID: {stream_id}")
2944
+
2945
+ commandstr = data.get("commandstr")
2946
+ conversation_id = data.get("conversationId")
2947
+ model = data.get("model", None)
2948
+ provider = data.get("provider", None)
2949
+ if provider is None:
2950
+ provider = available_models.get(model)
2951
+
2952
+ npc_name = data.get("npc", None)
2953
+ npc_source = data.get("npcSource", "global")
2954
+ current_path = data.get("currentPath")
2955
+ is_resend = data.get("isResend", False) # ADD THIS LINE
2956
+
2957
+ if current_path:
2958
+ loaded_vars = load_project_env(current_path)
2959
+ print(f"Loaded project env variables for stream request: {list(loaded_vars.keys())}")
1992
2960
 
1993
2961
  npc_object = None
1994
2962
  team_object = None
@@ -2155,7 +3123,9 @@ def stream():
2155
3123
  if 'tools' in tool_args and tool_args['tools']:
2156
3124
  tool_args['tool_choice'] = {"type": "auto"}
2157
3125
 
2158
-
3126
+ # Default stream response so closures below always have a value
3127
+ stream_response = {"output": "", "messages": messages}
3128
+
2159
3129
  exe_mode = data.get('executionMode','chat')
2160
3130
 
2161
3131
  if exe_mode == 'chat':
@@ -2230,23 +3200,18 @@ def stream():
2230
3200
  messages = state.messages
2231
3201
 
2232
3202
  elif exe_mode == 'corca':
2233
-
2234
3203
  try:
2235
3204
  from npcsh.corca import execute_command_corca, create_corca_state_and_mcp_client, MCPClientNPC
2236
3205
  from npcsh._state import initial_state as state
2237
3206
  except ImportError:
2238
-
2239
3207
  print("ERROR: npcsh.corca or MCPClientNPC not found. Corca mode is disabled.", file=sys.stderr)
2240
- state = None
3208
+ state = None
2241
3209
  stream_response = {"output": "Corca mode is not available due to missing dependencies.", "messages": messages}
2242
-
2243
-
2244
- if state is not None:
2245
-
3210
+
3211
+ if state is not None:
2246
3212
  mcp_server_path_from_request = data.get("mcpServerPath")
2247
3213
  selected_mcp_tools_from_request = data.get("selectedMcpTools", [])
2248
-
2249
-
3214
+
2250
3215
  effective_mcp_server_path = mcp_server_path_from_request
2251
3216
  if not effective_mcp_server_path and team_object and hasattr(team_object, 'team_ctx') and team_object.team_ctx:
2252
3217
  mcp_servers_list = team_object.team_ctx.get('mcp_servers', [])
@@ -2254,18 +3219,19 @@ def stream():
2254
3219
  first_server_obj = next((s for s in mcp_servers_list if isinstance(s, dict) and 'value' in s), None)
2255
3220
  if first_server_obj:
2256
3221
  effective_mcp_server_path = first_server_obj['value']
2257
- elif isinstance(team_object.team_ctx.get('mcp_server'), str):
3222
+ elif isinstance(team_object.team_ctx.get('mcp_server'), str):
2258
3223
  effective_mcp_server_path = team_object.team_ctx.get('mcp_server')
2259
3224
 
2260
-
3225
+ if effective_mcp_server_path:
3226
+ effective_mcp_server_path = os.path.abspath(os.path.expanduser(effective_mcp_server_path))
3227
+
2261
3228
  if not hasattr(app, 'corca_states'):
2262
3229
  app.corca_states = {}
2263
-
3230
+
2264
3231
  state_key = f"{conversation_id}_{npc_name or 'default'}"
2265
-
2266
- corca_state = None
2267
- if state_key not in app.corca_states:
2268
-
3232
+ corca_state = app.corca_states.get(state_key)
3233
+
3234
+ if corca_state is None:
2269
3235
  corca_state = create_corca_state_and_mcp_client(
2270
3236
  conversation_id=conversation_id,
2271
3237
  command_history=command_history,
@@ -2276,21 +3242,21 @@ def stream():
2276
3242
  )
2277
3243
  app.corca_states[state_key] = corca_state
2278
3244
  else:
2279
- corca_state = app.corca_states[state_key]
2280
3245
  corca_state.npc = npc_object
2281
3246
  corca_state.team = team_object
2282
3247
  corca_state.current_path = current_path
2283
3248
  corca_state.messages = messages
2284
3249
  corca_state.command_history = command_history
2285
3250
 
2286
-
2287
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))
2288
3254
 
2289
3255
  if effective_mcp_server_path != current_mcp_client_path:
2290
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'}.")
2291
3257
  if corca_state.mcp_client and corca_state.mcp_client.session:
2292
3258
  corca_state.mcp_client.disconnect_sync()
2293
- corca_state.mcp_client = None
3259
+ corca_state.mcp_client = None
2294
3260
 
2295
3261
  if effective_mcp_server_path:
2296
3262
  new_mcp_client = MCPClientNPC()
@@ -2300,20 +3266,19 @@ def stream():
2300
3266
  else:
2301
3267
  print(f"Failed to reconnect MCP client for {state_key} to {effective_mcp_server_path}. Corca will have no tools.")
2302
3268
  corca_state.mcp_client = None
2303
-
2304
-
2305
-
3269
+
2306
3270
  state, stream_response = execute_command_corca(
2307
3271
  commandstr,
2308
3272
  corca_state,
2309
3273
  command_history,
2310
- selected_mcp_tools_names=selected_mcp_tools_from_request
3274
+ selected_mcp_tools_names=selected_mcp_tools_from_request
2311
3275
  )
2312
-
2313
-
3276
+
2314
3277
  app.corca_states[state_key] = state
2315
- messages = state.messages
3278
+ messages = state.messages
2316
3279
 
3280
+ else:
3281
+ stream_response = {"output": f"Unsupported execution mode: {exe_mode}", "messages": messages}
2317
3282
 
2318
3283
  user_message_filled = ''
2319
3284
 
@@ -2321,20 +3286,25 @@ def stream():
2321
3286
  for cont in messages[-1].get('content'):
2322
3287
  txt = cont.get('text')
2323
3288
  if txt is not None:
2324
- user_message_filled +=txt
2325
- save_conversation_message(
2326
- command_history,
2327
- conversation_id,
2328
- "user",
2329
- user_message_filled if len(user_message_filled)>0 else commandstr,
2330
- wd=current_path,
2331
- model=model,
2332
- provider=provider,
2333
- npc=npc_name,
2334
- team=team,
2335
- attachments=attachments_for_db,
2336
- message_id=message_id,
2337
- )
3289
+ user_message_filled += txt
3290
+
3291
+ # Only save user message if it's NOT a resend
3292
+ if not is_resend: # ADD THIS CONDITION
3293
+ save_conversation_message(
3294
+ command_history,
3295
+ conversation_id,
3296
+ "user",
3297
+ user_message_filled if len(user_message_filled) > 0 else commandstr,
3298
+ wd=current_path,
3299
+ model=model,
3300
+ provider=provider,
3301
+ npc=npc_name,
3302
+ team=team,
3303
+ attachments=attachments_for_db,
3304
+ message_id=message_id,
3305
+ )
3306
+
3307
+
2338
3308
 
2339
3309
 
2340
3310
  message_id = generate_message_id()
@@ -2349,44 +3319,44 @@ def stream():
2349
3319
  if isinstance(stream_response, str) :
2350
3320
  print('stream a str and not a gen')
2351
3321
  chunk_data = {
2352
- "id": None,
2353
- "object": None,
2354
- "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'),
2355
3325
  "model": model,
2356
3326
  "choices": [
2357
3327
  {
2358
- "index": 0,
2359
- "delta":
3328
+ "index": 0,
3329
+ "delta":
2360
3330
  {
2361
3331
  "content": stream_response,
2362
3332
  "role": "assistant"
2363
- },
3333
+ },
2364
3334
  "finish_reason": 'done'
2365
3335
  }
2366
3336
  ]
2367
3337
  }
2368
- yield f"data: {json.dumps(chunk_data)}"
3338
+ yield f"data: {json.dumps(chunk_data)}\n\n"
2369
3339
  return
2370
3340
  elif isinstance(stream_response, dict) and 'output' in stream_response and isinstance(stream_response.get('output'), str):
2371
- print('stream a str and not a gen')
3341
+ print('stream a str and not a gen')
2372
3342
  chunk_data = {
2373
- "id": None,
2374
- "object": None,
2375
- "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'),
2376
3346
  "model": model,
2377
3347
  "choices": [
2378
3348
  {
2379
- "index": 0,
2380
- "delta":
3349
+ "index": 0,
3350
+ "delta":
2381
3351
  {
2382
3352
  "content": stream_response.get('output') ,
2383
3353
  "role": "assistant"
2384
- },
3354
+ },
2385
3355
  "finish_reason": 'done'
2386
3356
  }
2387
3357
  ]
2388
3358
  }
2389
- yield f"data: {json.dumps(chunk_data)}"
3359
+ yield f"data: {json.dumps(chunk_data)}\n\n"
2390
3360
  return
2391
3361
  for response_chunk in stream_response.get('response', stream_response.get('output')):
2392
3362
  with cancellation_lock:
@@ -2414,8 +3384,8 @@ def stream():
2414
3384
  if chunk_content:
2415
3385
  complete_response.append(chunk_content)
2416
3386
  chunk_data = {
2417
- "id": None, "object": None,
2418
- "created": response_chunk["created_at"] or datetime.datetime.now(),
3387
+ "id": None, "object": None,
3388
+ "created": response_chunk["created_at"] or datetime.datetime.now(),
2419
3389
  "model": response_chunk["model"],
2420
3390
  "choices": [{"index": 0, "delta": {"content": chunk_content, "role": response_chunk["message"]["role"]}, "finish_reason": response_chunk.get("done_reason")}]
2421
3391
  }
@@ -2449,36 +3419,85 @@ def stream():
2449
3419
  print(f"\nAn exception occurred during streaming for {current_stream_id}: {e}")
2450
3420
  traceback.print_exc()
2451
3421
  interrupted = True
2452
-
3422
+
2453
3423
  finally:
2454
3424
  print(f"\nStream {current_stream_id} finished. Interrupted: {interrupted}")
2455
3425
  print('\r' + ' ' * dot_count*2 + '\r', end="", flush=True)
2456
3426
 
2457
3427
  final_response_text = ''.join(complete_response)
3428
+
3429
+ # Yield message_stop immediately so the client's stream ends quickly
2458
3430
  yield f"data: {json.dumps({'type': 'message_stop'})}\n\n"
2459
-
3431
+
3432
+ # Save assistant message to the database
2460
3433
  npc_name_to_save = npc_object.name if npc_object else ''
2461
3434
  save_conversation_message(
2462
- command_history,
2463
- conversation_id,
2464
- "assistant",
3435
+ command_history,
3436
+ conversation_id,
3437
+ "assistant",
2465
3438
  final_response_text,
2466
- wd=current_path,
2467
- model=model,
3439
+ wd=current_path,
3440
+ model=model,
2468
3441
  provider=provider,
2469
- npc=npc_name_to_save,
2470
- team=team,
3442
+ npc=npc_name_to_save,
3443
+ team=team,
2471
3444
  message_id=message_id,
2472
3445
  )
2473
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
+
2474
3468
  with cancellation_lock:
2475
3469
  if current_stream_id in cancellation_flags:
2476
3470
  del cancellation_flags[current_stream_id]
2477
3471
  print(f"Cleaned up cancellation flag for stream ID: {current_stream_id}")
2478
-
2479
3472
  return Response(event_stream(stream_id), mimetype="text/event-stream")
2480
3473
 
2481
-
3474
+ @app.route('/api/delete_message', methods=['POST'])
3475
+ def delete_message():
3476
+ data = request.json
3477
+ conversation_id = data.get('conversationId')
3478
+ message_id = data.get('messageId')
3479
+
3480
+ if not conversation_id or not message_id:
3481
+ return jsonify({"error": "Missing conversationId or messageId"}), 400
3482
+
3483
+ try:
3484
+ command_history = CommandHistory(app.config.get('DB_PATH'))
3485
+
3486
+ # Delete the message from the database
3487
+ result = command_history.delete_message(conversation_id, message_id)
3488
+
3489
+ print(f"[DELETE_MESSAGE] Deleted message {message_id} from conversation {conversation_id}. Rows affected: {result}")
3490
+
3491
+ return jsonify({
3492
+ "success": True,
3493
+ "deletedMessageId": message_id,
3494
+ "rowsAffected": result
3495
+ }), 200
3496
+
3497
+ except Exception as e:
3498
+ print(f"[DELETE_MESSAGE] Error: {e}")
3499
+ traceback.print_exc()
3500
+ return jsonify({"error": str(e)}), 500
2482
3501
 
2483
3502
  @app.route("/api/memory/approve", methods=["POST"])
2484
3503
  def approve_memories():
@@ -2503,295 +3522,6 @@ def approve_memories():
2503
3522
 
2504
3523
 
2505
3524
 
2506
- @app.route("/api/execute", methods=["POST"])
2507
- def execute():
2508
- data = request.json
2509
-
2510
-
2511
- stream_id = data.get("streamId")
2512
- if not stream_id:
2513
- import uuid
2514
- stream_id = str(uuid.uuid4())
2515
-
2516
-
2517
- with cancellation_lock:
2518
- cancellation_flags[stream_id] = False
2519
- print(f"Starting execute stream with ID: {stream_id}")
2520
-
2521
-
2522
- commandstr = data.get("commandstr")
2523
- conversation_id = data.get("conversationId")
2524
- model = data.get("model", 'llama3.2')
2525
- provider = data.get("provider", 'ollama')
2526
- if provider is None:
2527
- provider = available_models.get(model)
2528
-
2529
-
2530
- npc_name = data.get("npc", "sibiji")
2531
- npc_source = data.get("npcSource", "global")
2532
- team = data.get("team", None)
2533
- current_path = data.get("currentPath")
2534
-
2535
- if current_path:
2536
- loaded_vars = load_project_env(current_path)
2537
- print(f"Loaded project env variables for stream request: {list(loaded_vars.keys())}")
2538
-
2539
- npc_object = None
2540
- team_object = None
2541
-
2542
-
2543
- if team:
2544
- print(team)
2545
- if hasattr(app, 'registered_teams') and team in app.registered_teams:
2546
- team_object = app.registered_teams[team]
2547
- print(f"Using registered team: {team}")
2548
- else:
2549
- print(f"Warning: Team {team} not found in registered teams")
2550
-
2551
-
2552
- if npc_name:
2553
-
2554
- if team and hasattr(app, 'registered_teams') and team in app.registered_teams:
2555
- team_object = app.registered_teams[team]
2556
- print('team', team_object)
2557
-
2558
- if hasattr(team_object, 'npcs'):
2559
- team_npcs = team_object.npcs
2560
- if isinstance(team_npcs, dict):
2561
- if npc_name in team_npcs:
2562
- npc_object = team_npcs[npc_name]
2563
- print(f"Found NPC {npc_name} in registered team {team}")
2564
- elif isinstance(team_npcs, list):
2565
- for npc in team_npcs:
2566
- if hasattr(npc, 'name') and npc.name == npc_name:
2567
- npc_object = npc
2568
- print(f"Found NPC {npc_name} in registered team {team}")
2569
- break
2570
-
2571
- if not npc_object and hasattr(team_object, 'forenpc') and hasattr(team_object.forenpc, 'name'):
2572
- if team_object.forenpc.name == npc_name:
2573
- npc_object = team_object.forenpc
2574
- print(f"Found NPC {npc_name} as forenpc in team {team}")
2575
-
2576
-
2577
- if not npc_object and hasattr(app, 'registered_npcs') and npc_name in app.registered_npcs:
2578
- npc_object = app.registered_npcs[npc_name]
2579
- print(f"Found NPC {npc_name} in registered NPCs")
2580
-
2581
-
2582
- if not npc_object:
2583
- db_conn = get_db_connection()
2584
- npc_object = load_npc_by_name_and_source(npc_name, npc_source, db_conn, current_path)
2585
-
2586
- if not npc_object and npc_source == 'project':
2587
- print(f"NPC {npc_name} not found in project directory, trying global...")
2588
- npc_object = load_npc_by_name_and_source(npc_name, 'global', db_conn)
2589
-
2590
- if npc_object:
2591
- print(f"Successfully loaded NPC {npc_name} from {npc_source} directory")
2592
- else:
2593
- print(f"Warning: Could not load NPC {npc_name}")
2594
-
2595
- attachments = data.get("attachments", [])
2596
- command_history = CommandHistory(app.config.get('DB_PATH'))
2597
- images = []
2598
- attachments_loaded = []
2599
-
2600
-
2601
- if attachments:
2602
- for attachment in attachments:
2603
- extension = attachment["name"].split(".")[-1]
2604
- extension_mapped = extension_map.get(extension.upper(), "others")
2605
- file_path = os.path.expanduser("~/.npcsh/" + extension_mapped + "/" + attachment["name"])
2606
- if extension_mapped == "images":
2607
- ImageFile.LOAD_TRUNCATED_IMAGES = True
2608
- img = Image.open(attachment["path"])
2609
- img_byte_arr = BytesIO()
2610
- img.save(img_byte_arr, format="PNG")
2611
- img_byte_arr.seek(0)
2612
- img.save(file_path, optimize=True, quality=50)
2613
- images.append(file_path)
2614
- attachments_loaded.append({
2615
- "name": attachment["name"], "type": extension_mapped,
2616
- "data": img_byte_arr.read(), "size": os.path.getsize(file_path)
2617
- })
2618
-
2619
- messages = fetch_messages_for_conversation(conversation_id)
2620
- if len(messages) == 0 and npc_object is not None:
2621
- messages = [{'role': 'system', 'content': npc_object.get_system_prompt()}]
2622
- elif len(messages)>0 and messages[0]['role'] != 'system' and npc_object is not None:
2623
- messages.insert(0, {'role': 'system', 'content': npc_object.get_system_prompt()})
2624
- elif len(messages) > 0 and npc_object is not None:
2625
- messages[0]['content'] = npc_object.get_system_prompt()
2626
- if npc_object is not None and messages and messages[0]['role'] == 'system':
2627
- messages[0]['content'] = npc_object.get_system_prompt()
2628
-
2629
- message_id = generate_message_id()
2630
- save_conversation_message(
2631
- command_history, conversation_id, "user", commandstr,
2632
- wd=current_path, model=model, provider=provider, npc=npc_name,
2633
- team=team, attachments=attachments_loaded, message_id=message_id,
2634
- )
2635
- response_gen = check_llm_command(
2636
- commandstr, messages=messages, images=images, model=model,
2637
- provider=provider, npc=npc_object, team=team_object, stream=True
2638
- )
2639
- print(response_gen)
2640
-
2641
- message_id = generate_message_id()
2642
-
2643
- def event_stream(current_stream_id):
2644
- complete_response = []
2645
- dot_count = 0
2646
- interrupted = False
2647
- tool_call_data = {"id": None, "function_name": None, "arguments": ""}
2648
- memory_data = None
2649
-
2650
- try:
2651
- for response_chunk in stream_response.get('response', stream_response.get('output')):
2652
- with cancellation_lock:
2653
- if cancellation_flags.get(current_stream_id, False):
2654
- print(f"Cancellation flag triggered for {current_stream_id}. Breaking loop.")
2655
- interrupted = True
2656
- break
2657
-
2658
- print('.', end="", flush=True)
2659
- dot_count += 1
2660
-
2661
- if "hf.co" in model or provider == 'ollama':
2662
- chunk_content = response_chunk["message"]["content"] if "message" in response_chunk and "content" in response_chunk["message"] else ""
2663
- if "message" in response_chunk and "tool_calls" in response_chunk["message"]:
2664
- for tool_call in response_chunk["message"]["tool_calls"]:
2665
- if "id" in tool_call:
2666
- tool_call_data["id"] = tool_call["id"]
2667
- if "function" in tool_call:
2668
- if "name" in tool_call["function"]:
2669
- tool_call_data["function_name"] = tool_call["function"]["name"]
2670
- if "arguments" in tool_call["function"]:
2671
- arg_val = tool_call["function"]["arguments"]
2672
- if isinstance(arg_val, dict):
2673
- arg_val = json.dumps(arg_val)
2674
- tool_call_data["arguments"] += arg_val
2675
- if chunk_content:
2676
- complete_response.append(chunk_content)
2677
- chunk_data = {
2678
- "id": None, "object": None, "created": response_chunk["created_at"], "model": response_chunk["model"],
2679
- "choices": [{"index": 0, "delta": {"content": chunk_content, "role": response_chunk["message"]["role"]}, "finish_reason": response_chunk.get("done_reason")}]
2680
- }
2681
- yield f"data: {json.dumps(chunk_data)}\n\n"
2682
- else:
2683
- chunk_content = ""
2684
- reasoning_content = ""
2685
- for choice in response_chunk.choices:
2686
- if hasattr(choice.delta, "tool_calls") and choice.delta.tool_calls:
2687
- for tool_call in choice.delta.tool_calls:
2688
- if tool_call.id:
2689
- tool_call_data["id"] = tool_call.id
2690
- if tool_call.function:
2691
- if hasattr(tool_call.function, "name") and tool_call.function.name:
2692
- tool_call_data["function_name"] = tool_call.function.name
2693
- if hasattr(tool_call.function, "arguments") and tool_call.function.arguments:
2694
- tool_call_data["arguments"] += tool_call.function.arguments
2695
- for choice in response_chunk.choices:
2696
- if hasattr(choice.delta, "reasoning_content"):
2697
- reasoning_content += choice.delta.reasoning_content
2698
- chunk_content = "".join(choice.delta.content for choice in response_chunk.choices if choice.delta.content is not None)
2699
- if chunk_content:
2700
- complete_response.append(chunk_content)
2701
- chunk_data = {
2702
- "id": response_chunk.id, "object": response_chunk.object, "created": response_chunk.created, "model": response_chunk.model,
2703
- "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]
2704
- }
2705
- yield f"data: {json.dumps(chunk_data)}\n\n"
2706
-
2707
- except Exception as e:
2708
- print(f"\nAn exception occurred during streaming for {current_stream_id}: {e}")
2709
- traceback.print_exc()
2710
- interrupted = True
2711
-
2712
- finally:
2713
- print(f"\nStream {current_stream_id} finished. Interrupted: {interrupted}")
2714
- print('\r' + ' ' * dot_count*2 + '\r', end="", flush=True)
2715
-
2716
- final_response_text = ''.join(complete_response)
2717
-
2718
- conversation_turn_text = f"User: {commandstr}\nAssistant: {final_response_text}"
2719
-
2720
- try:
2721
- memory_examples = command_history.get_memory_examples_for_context(
2722
- npc=npc_name,
2723
- team=team,
2724
- directory_path=current_path
2725
- )
2726
-
2727
- memory_context = format_memory_context(memory_examples)
2728
-
2729
- facts = get_facts(
2730
- conversation_turn_text,
2731
- model=npc_object.model if npc_object else model,
2732
- provider=npc_object.provider if npc_object else provider,
2733
- npc=npc_object,
2734
- context=memory_context
2735
- )
2736
-
2737
- if facts:
2738
- memories_for_approval = []
2739
- for i, fact in enumerate(facts):
2740
- memory_id = command_history.add_memory_to_database(
2741
- message_id=f"{conversation_id}_{datetime.now().strftime('%H%M%S')}_{i}",
2742
- conversation_id=conversation_id,
2743
- npc=npc_name or "default",
2744
- team=team or "default",
2745
- directory_path=current_path or "/",
2746
- initial_memory=fact['statement'],
2747
- status="pending_approval",
2748
- model=npc_object.model if npc_object else model,
2749
- provider=npc_object.provider if npc_object else provider
2750
- )
2751
-
2752
- memories_for_approval.append({
2753
- "memory_id": memory_id,
2754
- "content": fact['statement'],
2755
- "context": f"Type: {fact.get('type', 'unknown')}, Source: {fact.get('source_text', '')}",
2756
- "npc": npc_name or "default"
2757
- })
2758
-
2759
- memory_data = {
2760
- "type": "memory_approval",
2761
- "memories": memories_for_approval,
2762
- "conversation_id": conversation_id
2763
- }
2764
-
2765
- except Exception as e:
2766
- print(f"Memory generation error: {e}")
2767
-
2768
- if memory_data:
2769
- yield f"data: {json.dumps(memory_data)}\n\n"
2770
-
2771
- yield f"data: {json.dumps({'type': 'message_stop'})}\n\n"
2772
-
2773
- npc_name_to_save = npc_object.name if npc_object else ''
2774
- save_conversation_message(
2775
- command_history,
2776
- conversation_id,
2777
- "assistant",
2778
- final_response_text,
2779
- wd=current_path,
2780
- model=model,
2781
- provider=provider,
2782
- npc=npc_name_to_save,
2783
- team=team,
2784
- message_id=message_id,
2785
- )
2786
-
2787
- with cancellation_lock:
2788
- if current_stream_id in cancellation_flags:
2789
- del cancellation_flags[current_stream_id]
2790
- print(f"Cleaned up cancellation flag for stream ID: {current_stream_id}")
2791
-
2792
-
2793
-
2794
- return Response(event_stream(stream_id), mimetype="text/event-stream")
2795
3525
 
2796
3526
  @app.route("/api/interrupt", methods=["POST"])
2797
3527
  def interrupt_stream():