npcpy 1.3.6__tar.gz → 1.3.7__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. {npcpy-1.3.6/npcpy.egg-info → npcpy-1.3.7}/PKG-INFO +1 -1
  2. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/gen/response.py +126 -11
  3. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/llm_funcs.py +6 -5
  4. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/memory/command_history.py +32 -11
  5. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/memory/knowledge_graph.py +1 -1
  6. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/npc_sysenv.py +27 -1
  7. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/serve.py +227 -20
  8. {npcpy-1.3.6 → npcpy-1.3.7/npcpy.egg-info}/PKG-INFO +1 -1
  9. {npcpy-1.3.6 → npcpy-1.3.7}/setup.py +1 -1
  10. {npcpy-1.3.6 → npcpy-1.3.7}/LICENSE +0 -0
  11. {npcpy-1.3.6 → npcpy-1.3.7}/MANIFEST.in +0 -0
  12. {npcpy-1.3.6 → npcpy-1.3.7}/README.md +0 -0
  13. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/__init__.py +0 -0
  14. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/build_funcs.py +0 -0
  15. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/data/__init__.py +0 -0
  16. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/data/audio.py +0 -0
  17. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/data/data_models.py +0 -0
  18. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/data/image.py +0 -0
  19. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/data/load.py +0 -0
  20. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/data/text.py +0 -0
  21. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/data/video.py +0 -0
  22. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/data/web.py +0 -0
  23. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/ft/__init__.py +0 -0
  24. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/ft/diff.py +0 -0
  25. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/ft/ge.py +0 -0
  26. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/ft/memory_trainer.py +0 -0
  27. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/ft/model_ensembler.py +0 -0
  28. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/ft/rl.py +0 -0
  29. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/ft/sft.py +0 -0
  30. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/ft/usft.py +0 -0
  31. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/gen/__init__.py +0 -0
  32. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/gen/audio_gen.py +0 -0
  33. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/gen/embeddings.py +0 -0
  34. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/gen/image_gen.py +0 -0
  35. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/gen/ocr.py +0 -0
  36. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/gen/video_gen.py +0 -0
  37. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/gen/world_gen.py +0 -0
  38. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/main.py +0 -0
  39. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/memory/__init__.py +0 -0
  40. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/memory/kg_vis.py +0 -0
  41. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/memory/memory_processor.py +0 -0
  42. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/memory/search.py +0 -0
  43. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/mix/__init__.py +0 -0
  44. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/mix/debate.py +0 -0
  45. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/ml_funcs.py +0 -0
  46. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/npc_array.py +0 -0
  47. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/npc_compiler.py +0 -0
  48. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/npcs.py +0 -0
  49. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/sql/__init__.py +0 -0
  50. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/sql/ai_function_tools.py +0 -0
  51. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/sql/database_ai_adapters.py +0 -0
  52. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/sql/database_ai_functions.py +0 -0
  53. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/sql/model_runner.py +0 -0
  54. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/sql/npcsql.py +0 -0
  55. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/sql/sql_model_compiler.py +0 -0
  56. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/tools.py +0 -0
  57. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/work/__init__.py +0 -0
  58. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/work/browser.py +0 -0
  59. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/work/desktop.py +0 -0
  60. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/work/plan.py +0 -0
  61. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy/work/trigger.py +0 -0
  62. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy.egg-info/SOURCES.txt +0 -0
  63. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy.egg-info/dependency_links.txt +0 -0
  64. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy.egg-info/requires.txt +0 -0
  65. {npcpy-1.3.6 → npcpy-1.3.7}/npcpy.egg-info/top_level.txt +0 -0
  66. {npcpy-1.3.6 → npcpy-1.3.7}/setup.cfg +0 -0
  67. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_audio.py +0 -0
  68. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_command_history.py +0 -0
  69. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_image.py +0 -0
  70. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_llm_funcs.py +0 -0
  71. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_load.py +0 -0
  72. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_npc_array.py +0 -0
  73. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_npc_compiler.py +0 -0
  74. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_npcsql.py +0 -0
  75. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_response.py +0 -0
  76. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_serve.py +0 -0
  77. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_text.py +0 -0
  78. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_tools.py +0 -0
  79. {npcpy-1.3.6 → npcpy-1.3.7}/tests/test_web.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: npcpy
3
- Version: 1.3.6
3
+ Version: 1.3.7
4
4
  Summary: npcpy is the premier open-source library for integrating LLMs and Agents into python systems.
5
5
  Home-page: https://github.com/NPC-Worldwide/npcpy
6
6
  Author: Christopher Agostino
@@ -570,6 +570,106 @@ def get_ollama_response(
570
570
  import time
571
571
 
572
572
 
573
+ def get_llamacpp_response(
574
+ prompt: str = None,
575
+ model: str = None,
576
+ images: List[str] = None,
577
+ tools: list = None,
578
+ tool_choice: Dict = None,
579
+ tool_map: Dict = None,
580
+ think=None,
581
+ format: Union[str, BaseModel] = None,
582
+ messages: List[Dict[str, str]] = None,
583
+ stream: bool = False,
584
+ attachments: List[str] = None,
585
+ auto_process_tool_calls: bool = False,
586
+ **kwargs,
587
+ ) -> Dict[str, Any]:
588
+ """
589
+ Generate response using llama-cpp-python for local GGUF/GGML files.
590
+ """
591
+ try:
592
+ from llama_cpp import Llama
593
+ except ImportError:
594
+ return {
595
+ "response": "",
596
+ "messages": messages or [],
597
+ "error": "llama-cpp-python not installed. Install with: pip install llama-cpp-python"
598
+ }
599
+
600
+ result = {
601
+ "response": None,
602
+ "messages": messages.copy() if messages else [],
603
+ "raw_response": None,
604
+ "tool_calls": [],
605
+ "tool_results": []
606
+ }
607
+
608
+ if prompt:
609
+ if messages and messages[-1]["role"] == "user":
610
+ messages[-1]["content"] = prompt
611
+ else:
612
+ if not messages:
613
+ messages = []
614
+ messages.append({"role": "user", "content": prompt})
615
+
616
+ try:
617
+ # Load model
618
+ n_ctx = kwargs.get("n_ctx", 4096)
619
+ n_gpu_layers = kwargs.get("n_gpu_layers", -1) # -1 = all layers on GPU if available
620
+
621
+ llm = Llama(
622
+ model_path=model,
623
+ n_ctx=n_ctx,
624
+ n_gpu_layers=n_gpu_layers,
625
+ verbose=False
626
+ )
627
+
628
+ # Build params
629
+ params = {
630
+ "messages": messages,
631
+ "stream": stream,
632
+ }
633
+ if kwargs.get("temperature"):
634
+ params["temperature"] = kwargs["temperature"]
635
+ if kwargs.get("max_tokens"):
636
+ params["max_tokens"] = kwargs["max_tokens"]
637
+ if kwargs.get("top_p"):
638
+ params["top_p"] = kwargs["top_p"]
639
+ if kwargs.get("stop"):
640
+ params["stop"] = kwargs["stop"]
641
+
642
+ if stream:
643
+ response = llm.create_chat_completion(**params)
644
+
645
+ def generate():
646
+ for chunk in response:
647
+ # Yield the full chunk dict for proper streaming handling
648
+ yield chunk
649
+
650
+ result["response"] = generate()
651
+ else:
652
+ response = llm.create_chat_completion(**params)
653
+ result["raw_response"] = response
654
+
655
+ if response.get("choices"):
656
+ content = response["choices"][0].get("message", {}).get("content", "")
657
+ result["response"] = content
658
+ result["messages"].append({"role": "assistant", "content": content})
659
+
660
+ if response.get("usage"):
661
+ result["usage"] = {
662
+ "input_tokens": response["usage"].get("prompt_tokens", 0),
663
+ "output_tokens": response["usage"].get("completion_tokens", 0),
664
+ }
665
+
666
+ except Exception as e:
667
+ result["error"] = f"llama.cpp error: {str(e)}"
668
+ result["response"] = ""
669
+
670
+ return result
671
+
672
+
573
673
  def get_litellm_response(
574
674
  prompt: str = None,
575
675
  model: str = None,
@@ -614,22 +714,37 @@ def get_litellm_response(
614
714
  )
615
715
  elif provider=='transformers':
616
716
  return get_transformers_response(
617
- prompt,
618
- model,
619
- images=images,
620
- tools=tools,
621
- tool_choice=tool_choice,
717
+ prompt,
718
+ model,
719
+ images=images,
720
+ tools=tools,
721
+ tool_choice=tool_choice,
622
722
  tool_map=tool_map,
623
723
  think=think,
624
- format=format,
625
- messages=messages,
626
- stream=stream,
627
- attachments=attachments,
628
- auto_process_tool_calls=auto_process_tool_calls,
724
+ format=format,
725
+ messages=messages,
726
+ stream=stream,
727
+ attachments=attachments,
728
+ auto_process_tool_calls=auto_process_tool_calls,
629
729
  **kwargs
630
730
 
631
731
  )
632
-
732
+ elif provider == 'llamacpp':
733
+ return get_llamacpp_response(
734
+ prompt,
735
+ model,
736
+ images=images,
737
+ tools=tools,
738
+ tool_choice=tool_choice,
739
+ tool_map=tool_map,
740
+ think=think,
741
+ format=format,
742
+ messages=messages,
743
+ stream=stream,
744
+ attachments=attachments,
745
+ auto_process_tool_calls=auto_process_tool_calls,
746
+ **kwargs
747
+ )
633
748
 
634
749
  if attachments:
635
750
  for attachment in attachments:
@@ -775,18 +775,19 @@ Instructions:
775
775
  required_inputs = []
776
776
 
777
777
  if required_inputs:
778
- # Get parameter names, distinguishing required (string) from optional with defaults (dict)
779
- required_names = [] # Params without defaults - truly required
780
- optional_names = [] # Params with defaults - not required
778
+ # Get just the parameter names (handle both string and dict formats)
779
+ # String inputs are required, dict inputs have defaults and are optional
780
+ required_names = []
781
+ optional_names = []
781
782
  for inp in required_inputs:
782
783
  if isinstance(inp, str):
783
784
  # String inputs have no default, so they're required
784
785
  required_names.append(inp)
785
786
  elif isinstance(inp, dict):
786
- # Dict inputs have defaults (e.g., "backup: true"), so they're optional
787
+ # Dict inputs have default values, so they're optional
787
788
  optional_names.extend(inp.keys())
788
789
 
789
- # Only check truly required params (those without defaults)
790
+ # Check which required params are missing (only string inputs, not dict inputs with defaults)
790
791
  missing = [p for p in required_names if p not in inputs or not inputs.get(p)]
791
792
  provided = list(inputs.keys())
792
793
  if missing:
@@ -30,7 +30,22 @@ except NameError as e:
30
30
  chromadb = None
31
31
 
32
32
 
33
- import logging
33
+ import logging
34
+
35
+
36
+ def normalize_path_for_db(path_str):
37
+ """
38
+ Normalize a path for consistent database storage.
39
+ Converts backslashes to forward slashes for cross-platform compatibility.
40
+ """
41
+ if not path_str:
42
+ return path_str
43
+ # Convert backslashes to forward slashes
44
+ normalized = path_str.replace('\\', '/')
45
+ # Remove trailing slashes for consistency
46
+ normalized = normalized.rstrip('/')
47
+ return normalized
48
+
34
49
 
35
50
  def flush_messages(n: int, messages: list) -> dict:
36
51
  if n <= 0:
@@ -852,6 +867,9 @@ class CommandHistory:
852
867
  if tool_results is not None and not isinstance(tool_results, str):
853
868
  tool_results = json.dumps(tool_results, cls=CustomJSONEncoder)
854
869
 
870
+ # Normalize directory path for cross-platform compatibility
871
+ normalized_directory_path = normalize_path_for_db(directory_path)
872
+
855
873
  stmt = """
856
874
  INSERT INTO conversation_history
857
875
  (message_id, timestamp, role, content, conversation_id, directory_path, model, provider, npc, team, reasoning_content, tool_calls, tool_results)
@@ -859,7 +877,7 @@ class CommandHistory:
859
877
  """
860
878
  params = {
861
879
  "message_id": message_id, "timestamp": timestamp, "role": role, "content": content,
862
- "conversation_id": conversation_id, "directory_path": directory_path, "model": model,
880
+ "conversation_id": conversation_id, "directory_path": normalized_directory_path, "model": model,
863
881
  "provider": provider, "npc": npc, "team": team, "reasoning_content": reasoning_content,
864
882
  "tool_calls": tool_calls, "tool_results": tool_results
865
883
  }
@@ -879,28 +897,31 @@ class CommandHistory:
879
897
 
880
898
  return message_id
881
899
 
882
- def add_memory_to_database(self, message_id: str, conversation_id: str, npc: str, team: str,
883
- directory_path: str, initial_memory: str, status: str,
900
+ def add_memory_to_database(self, message_id: str, conversation_id: str, npc: str, team: str,
901
+ directory_path: str, initial_memory: str, status: str,
884
902
  model: str = None, provider: str = None, final_memory: str = None):
885
903
  """Store a memory entry in the database"""
886
904
  timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
887
-
905
+
906
+ # Normalize directory path for cross-platform compatibility
907
+ normalized_directory_path = normalize_path_for_db(directory_path)
908
+
888
909
  stmt = """
889
- INSERT INTO memory_lifecycle
890
- (message_id, conversation_id, npc, team, directory_path, timestamp,
910
+ INSERT INTO memory_lifecycle
911
+ (message_id, conversation_id, npc, team, directory_path, timestamp,
891
912
  initial_memory, final_memory, status, model, provider)
892
- VALUES (:message_id, :conversation_id, :npc, :team, :directory_path,
913
+ VALUES (:message_id, :conversation_id, :npc, :team, :directory_path,
893
914
  :timestamp, :initial_memory, :final_memory, :status, :model, :provider)
894
915
  """
895
-
916
+
896
917
  params = {
897
918
  "message_id": message_id, "conversation_id": conversation_id,
898
- "npc": npc, "team": team, "directory_path": directory_path,
919
+ "npc": npc, "team": team, "directory_path": normalized_directory_path,
899
920
  "timestamp": timestamp, "initial_memory": initial_memory,
900
921
  "final_memory": final_memory, "status": status,
901
922
  "model": model, "provider": provider
902
923
  }
903
-
924
+
904
925
  return self._execute_returning_id(stmt, params)
905
926
  def get_memories_for_scope(
906
927
  self,
@@ -594,7 +594,7 @@ def kg_dream_process(existing_kg,
594
594
  return existing_kg, {}
595
595
  print(f" - Generated Dream: '{dream_text[:150]}...'")
596
596
 
597
- dream_kg, _ = kg_evolve_incremental(existing_kg, dream_text, model, provider, npc, context)
597
+ dream_kg, _ = kg_evolve_incremental(existing_kg, new_content_text=dream_text, model=model, provider=provider, npc=npc, context=context)
598
598
 
599
599
  original_fact_stmts = {f['statement'] for f in existing_kg['facts']}
600
600
  for fact in dream_kg['facts']:
@@ -305,7 +305,33 @@ def get_locally_available_models(project_directory, airplane_mode=False):
305
305
  available_models[mod] = "ollama"
306
306
  except (ImportError, concurrent.futures.TimeoutError, Exception) as e:
307
307
  logging.info(f"Error loading Ollama models or timed out: {e}")
308
-
308
+
309
+ # Scan for local GGUF/GGML models
310
+ gguf_dirs = [
311
+ os.path.expanduser('~/.npcsh/models/gguf'),
312
+ os.path.expanduser('~/.npcsh/models'),
313
+ os.path.expanduser('~/models'),
314
+ os.path.expanduser('~/.cache/huggingface/hub'),
315
+ ]
316
+ env_gguf_dir = os.environ.get('NPCSH_GGUF_DIR')
317
+ if env_gguf_dir:
318
+ gguf_dirs.insert(0, os.path.expanduser(env_gguf_dir))
319
+
320
+ seen_paths = set()
321
+ for scan_dir in gguf_dirs:
322
+ if not os.path.isdir(scan_dir):
323
+ continue
324
+ try:
325
+ for root, dirs, files in os.walk(scan_dir):
326
+ for f in files:
327
+ if f.endswith(('.gguf', '.ggml')) and not f.startswith('.'):
328
+ full_path = os.path.join(root, f)
329
+ if full_path not in seen_paths:
330
+ seen_paths.add(full_path)
331
+ available_models[full_path] = "llamacpp"
332
+ except Exception as e:
333
+ logging.info(f"Error scanning GGUF directory {scan_dir}: {e}")
334
+
309
335
  return available_models
310
336
 
311
337
 
@@ -88,6 +88,21 @@ cancellation_flags = {}
88
88
  cancellation_lock = threading.Lock()
89
89
 
90
90
 
91
+ def normalize_path_for_db(path_str):
92
+ """
93
+ Normalize a path for consistent database storage/querying.
94
+ Converts backslashes to forward slashes for cross-platform compatibility.
95
+ This ensures Windows paths match Unix paths in the database.
96
+ """
97
+ if not path_str:
98
+ return path_str
99
+ # Convert backslashes to forward slashes
100
+ normalized = path_str.replace('\\', '/')
101
+ # Remove trailing slashes for consistency
102
+ normalized = normalized.rstrip('/')
103
+ return normalized
104
+
105
+
91
106
  # Minimal MCP client (inlined from npcsh corca to avoid corca import)
92
107
  class MCPClientNPC:
93
108
  def __init__(self, debug: bool = True):
@@ -1172,14 +1187,9 @@ def get_models():
1172
1187
  )
1173
1188
 
1174
1189
  display_model = m
1175
- if "claude-3-5-haiku-latest" in m:
1176
- display_model = "claude-3.5-haiku"
1177
- elif "claude-3-5-sonnet-latest" in m:
1178
- display_model = "claude-3.5-sonnet"
1179
- elif "gemini-1.5-flash" in m:
1180
- display_model = "gemini-1.5-flash"
1181
- elif "gemini-2.0-flash-lite-preview-02-05" in m:
1182
- display_model = "gemini-2.0-flash-lite-preview"
1190
+ if m.endswith(('.gguf', '.ggml')):
1191
+ # For local GGUF/GGML files, show just the filename
1192
+ display_model = os.path.basename(m)
1183
1193
 
1184
1194
  display_name = f"{display_model} | {p} {text_only}".strip()
1185
1195
 
@@ -2120,16 +2130,18 @@ def get_last_used_model_and_npc_in_directory(directory_path):
2120
2130
  engine = get_db_connection()
2121
2131
  try:
2122
2132
  with engine.connect() as conn:
2133
+ # Normalize path for cross-platform compatibility
2123
2134
  query = text("""
2124
2135
  SELECT model, npc
2125
2136
  FROM conversation_history
2126
- WHERE directory_path = :directory_path
2127
- AND model IS NOT NULL AND npc IS NOT NULL
2137
+ WHERE REPLACE(RTRIM(directory_path, '/\\'), '\\', '/') = :normalized_path
2138
+ AND model IS NOT NULL AND npc IS NOT NULL
2128
2139
  AND model != '' AND npc != ''
2129
2140
  ORDER BY timestamp DESC, id DESC
2130
2141
  LIMIT 1
2131
2142
  """)
2132
- result = conn.execute(query, {"directory_path": directory_path}).fetchone()
2143
+ normalized_path = normalize_path_for_db(directory_path)
2144
+ result = conn.execute(query, {"normalized_path": normalized_path}).fetchone()
2133
2145
  return {"model": result[0], "npc": result[1]} if result else {"model": None, "npc": None}
2134
2146
  except Exception as e:
2135
2147
  print(f"Error getting last used model/NPC for directory {directory_path}: {e}")
@@ -3359,7 +3371,7 @@ def stream():
3359
3371
  provider = data.get("provider", None)
3360
3372
  if provider is None:
3361
3373
  provider = available_models.get(model)
3362
-
3374
+
3363
3375
  npc_name = data.get("npc", None)
3364
3376
  npc_source = data.get("npcSource", "global")
3365
3377
  current_path = data.get("currentPath")
@@ -3986,7 +3998,27 @@ def stream():
3986
3998
 
3987
3999
  print('.', end="", flush=True)
3988
4000
  dot_count += 1
3989
- if "hf.co" in model or provider == 'ollama' and 'gpt-oss' not in model:
4001
+ if provider == 'llamacpp':
4002
+ # llama-cpp-python returns OpenAI-format dicts
4003
+ chunk_content = ""
4004
+ reasoning_content = None
4005
+ if isinstance(response_chunk, dict) and response_chunk.get("choices"):
4006
+ delta = response_chunk["choices"][0].get("delta", {})
4007
+ chunk_content = delta.get("content", "") or ""
4008
+ reasoning_content = delta.get("reasoning_content")
4009
+ if chunk_content:
4010
+ complete_response.append(chunk_content)
4011
+ if reasoning_content:
4012
+ complete_reasoning.append(reasoning_content)
4013
+ chunk_data = {
4014
+ "id": response_chunk.get("id"),
4015
+ "object": response_chunk.get("object"),
4016
+ "created": response_chunk.get("created"),
4017
+ "model": response_chunk.get("model", model),
4018
+ "choices": [{"index": 0, "delta": {"content": chunk_content, "role": "assistant", "reasoning_content": reasoning_content}, "finish_reason": response_chunk.get("choices", [{}])[0].get("finish_reason")}]
4019
+ }
4020
+ yield f"data: {json.dumps(chunk_data)}\n\n"
4021
+ elif "hf.co" in model or provider == 'ollama' and 'gpt-oss' not in model:
3990
4022
  # Ollama returns ChatResponse objects - support both attribute and dict access
3991
4023
  msg = getattr(response_chunk, "message", None) or response_chunk.get("message", {}) if hasattr(response_chunk, "get") else {}
3992
4024
  chunk_content = getattr(msg, "content", None) or (msg.get("content") if hasattr(msg, "get") else "") or ""
@@ -4235,24 +4267,24 @@ def get_conversations():
4235
4267
  engine = get_db_connection()
4236
4268
  try:
4237
4269
  with engine.connect() as conn:
4270
+ # Use REPLACE to normalize paths in the query for cross-platform compatibility
4271
+ # This handles both forward slashes and backslashes stored in the database
4238
4272
  query = text("""
4239
4273
  SELECT DISTINCT conversation_id,
4240
4274
  MIN(timestamp) as start_time,
4241
4275
  MAX(timestamp) as last_message_timestamp,
4242
4276
  GROUP_CONCAT(content) as preview
4243
4277
  FROM conversation_history
4244
- WHERE directory_path = :path_without_slash OR directory_path = :path_with_slash
4278
+ WHERE REPLACE(RTRIM(directory_path, '/\\'), '\\', '/') = :normalized_path
4245
4279
  GROUP BY conversation_id
4246
4280
  ORDER BY MAX(timestamp) DESC
4247
4281
  """)
4248
4282
 
4249
-
4250
- path_without_slash = path.rstrip('/')
4251
- path_with_slash = path_without_slash + '/'
4252
-
4283
+ # Normalize the input path (convert backslashes to forward slashes, strip trailing slashes)
4284
+ normalized_path = normalize_path_for_db(path)
4285
+
4253
4286
  result = conn.execute(query, {
4254
- "path_without_slash": path_without_slash,
4255
- "path_with_slash": path_with_slash
4287
+ "normalized_path": normalized_path
4256
4288
  })
4257
4289
  conversations = result.fetchall()
4258
4290
 
@@ -4746,6 +4778,181 @@ def openai_list_models():
4746
4778
  })
4747
4779
 
4748
4780
 
4781
+ # ============== GGUF/GGML Model Scanning ==============
4782
+ @app.route('/api/models/gguf/scan', methods=['GET'])
4783
+ def scan_gguf_models():
4784
+ """Scan for GGUF/GGML model files in specified or default directories."""
4785
+ directory = request.args.get('directory')
4786
+
4787
+ # Default directories to scan
4788
+ default_dirs = [
4789
+ os.path.expanduser('~/.npcsh/models/gguf'),
4790
+ os.path.expanduser('~/.npcsh/models'),
4791
+ os.path.expanduser('~/models'),
4792
+ os.path.expanduser('~/.cache/huggingface/hub'),
4793
+ ]
4794
+
4795
+ # Add env var directory if set
4796
+ env_dir = os.environ.get('NPCSH_GGUF_DIR')
4797
+ if env_dir:
4798
+ default_dirs.insert(0, os.path.expanduser(env_dir))
4799
+
4800
+ dirs_to_scan = [os.path.expanduser(directory)] if directory else default_dirs
4801
+
4802
+ models = []
4803
+ seen_paths = set()
4804
+
4805
+ for scan_dir in dirs_to_scan:
4806
+ if not os.path.isdir(scan_dir):
4807
+ continue
4808
+
4809
+ for root, dirs, files in os.walk(scan_dir):
4810
+ for f in files:
4811
+ if f.endswith(('.gguf', '.ggml', '.bin')) and not f.startswith('.'):
4812
+ full_path = os.path.join(root, f)
4813
+ if full_path not in seen_paths:
4814
+ seen_paths.add(full_path)
4815
+ try:
4816
+ size = os.path.getsize(full_path)
4817
+ models.append({
4818
+ 'name': f,
4819
+ 'path': full_path,
4820
+ 'size': size,
4821
+ 'size_gb': round(size / (1024**3), 2)
4822
+ })
4823
+ except OSError:
4824
+ pass
4825
+
4826
+ return jsonify({'models': models, 'error': None})
4827
+
4828
+
4829
+ @app.route('/api/models/hf/download', methods=['POST'])
4830
+ def download_hf_model():
4831
+ """Download a GGUF model from HuggingFace."""
4832
+ data = request.json
4833
+ url = data.get('url', '')
4834
+ target_dir = data.get('target_dir', '~/.npcsh/models/gguf')
4835
+
4836
+ target_dir = os.path.expanduser(target_dir)
4837
+ os.makedirs(target_dir, exist_ok=True)
4838
+
4839
+ try:
4840
+ # Parse HuggingFace URL or model ID
4841
+ # Formats:
4842
+ # - TheBloke/Llama-2-7B-GGUF
4843
+ # - https://huggingface.co/TheBloke/Llama-2-7B-GGUF/resolve/main/llama-2-7b.Q4_K_M.gguf
4844
+
4845
+ if url.startswith('http'):
4846
+ # Direct URL - download the file
4847
+ import requests
4848
+ filename = url.split('/')[-1].split('?')[0]
4849
+ target_path = os.path.join(target_dir, filename)
4850
+
4851
+ print(f"Downloading {url} to {target_path}")
4852
+ response = requests.get(url, stream=True)
4853
+ response.raise_for_status()
4854
+
4855
+ with open(target_path, 'wb') as f:
4856
+ for chunk in response.iter_content(chunk_size=8192):
4857
+ f.write(chunk)
4858
+
4859
+ return jsonify({'path': target_path, 'error': None})
4860
+ else:
4861
+ # Model ID - use huggingface_hub to download
4862
+ try:
4863
+ from huggingface_hub import hf_hub_download, list_repo_files
4864
+
4865
+ # List files in repo to find GGUF files
4866
+ files = list_repo_files(url)
4867
+ gguf_files = [f for f in files if f.endswith('.gguf')]
4868
+
4869
+ if not gguf_files:
4870
+ return jsonify({'error': 'No GGUF files found in repository'}), 400
4871
+
4872
+ # Download the first/smallest Q4 quantized version or first available
4873
+ q4_files = [f for f in gguf_files if 'Q4' in f or 'q4' in f]
4874
+ file_to_download = q4_files[0] if q4_files else gguf_files[0]
4875
+
4876
+ print(f"Downloading {file_to_download} from {url}")
4877
+ path = hf_hub_download(
4878
+ repo_id=url,
4879
+ filename=file_to_download,
4880
+ local_dir=target_dir,
4881
+ local_dir_use_symlinks=False
4882
+ )
4883
+
4884
+ return jsonify({'path': path, 'error': None})
4885
+ except ImportError:
4886
+ return jsonify({'error': 'huggingface_hub not installed. Run: pip install huggingface_hub'}), 500
4887
+
4888
+ except Exception as e:
4889
+ print(f"Error downloading HF model: {e}")
4890
+ return jsonify({'error': str(e)}), 500
4891
+
4892
+
4893
+ # ============== Local Model Provider Status ==============
4894
+ @app.route('/api/models/local/scan', methods=['GET'])
4895
+ def scan_local_models():
4896
+ """Scan for models from local providers (LM Studio, llama.cpp)."""
4897
+ provider = request.args.get('provider', '')
4898
+
4899
+ if provider == 'lmstudio':
4900
+ # LM Studio typically runs on port 1234
4901
+ try:
4902
+ import requests
4903
+ response = requests.get('http://127.0.0.1:1234/v1/models', timeout=2)
4904
+ if response.ok:
4905
+ data = response.json()
4906
+ models = [{'name': m.get('id', m.get('name', 'unknown'))} for m in data.get('data', [])]
4907
+ return jsonify({'models': models, 'error': None})
4908
+ except:
4909
+ pass
4910
+ return jsonify({'models': [], 'error': 'LM Studio not running or not accessible'})
4911
+
4912
+ elif provider == 'llamacpp':
4913
+ # llama.cpp server typically runs on port 8080
4914
+ try:
4915
+ import requests
4916
+ response = requests.get('http://127.0.0.1:8080/v1/models', timeout=2)
4917
+ if response.ok:
4918
+ data = response.json()
4919
+ models = [{'name': m.get('id', m.get('name', 'unknown'))} for m in data.get('data', [])]
4920
+ return jsonify({'models': models, 'error': None})
4921
+ except:
4922
+ pass
4923
+ return jsonify({'models': [], 'error': 'llama.cpp server not running or not accessible'})
4924
+
4925
+ return jsonify({'models': [], 'error': f'Unknown provider: {provider}'})
4926
+
4927
+
4928
+ @app.route('/api/models/local/status', methods=['GET'])
4929
+ def get_local_model_status():
4930
+ """Check if a local model provider is running."""
4931
+ provider = request.args.get('provider', '')
4932
+
4933
+ if provider == 'lmstudio':
4934
+ try:
4935
+ import requests
4936
+ response = requests.get('http://127.0.0.1:1234/v1/models', timeout=2)
4937
+ if response.ok:
4938
+ return jsonify({'status': 'running'})
4939
+ except:
4940
+ pass
4941
+ return jsonify({'status': 'not_running'})
4942
+
4943
+ elif provider == 'llamacpp':
4944
+ try:
4945
+ import requests
4946
+ response = requests.get('http://127.0.0.1:8080/v1/models', timeout=2)
4947
+ if response.ok:
4948
+ return jsonify({'status': 'running'})
4949
+ except:
4950
+ pass
4951
+ return jsonify({'status': 'not_running'})
4952
+
4953
+ return jsonify({'status': 'unknown', 'error': f'Unknown provider: {provider}'})
4954
+
4955
+
4749
4956
  def start_flask_server(
4750
4957
  port=5337,
4751
4958
  cors_origins=None,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: npcpy
3
- Version: 1.3.6
3
+ Version: 1.3.7
4
4
  Summary: npcpy is the premier open-source library for integrating LLMs and Agents into python systems.
5
5
  Home-page: https://github.com/NPC-Worldwide/npcpy
6
6
  Author: Christopher Agostino
@@ -83,7 +83,7 @@ extra_files = package_files("npcpy/npc_team/")
83
83
 
84
84
  setup(
85
85
  name="npcpy",
86
- version="1.3.6",
86
+ version="1.3.7",
87
87
  packages=find_packages(exclude=["tests*"]),
88
88
  install_requires=base_requirements,
89
89
  extras_require={
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes