npcpy 1.3.10__py3-none-any.whl → 1.3.12__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
@@ -572,7 +572,7 @@ def fetch_messages_for_conversation(conversation_id):
572
572
  try:
573
573
  with engine.connect() as conn:
574
574
  query = text("""
575
- SELECT role, content, timestamp
575
+ SELECT role, content, timestamp, tool_calls, tool_results
576
576
  FROM conversation_history
577
577
  WHERE conversation_id = :conversation_id
578
578
  ORDER BY timestamp ASC
@@ -580,14 +580,45 @@ def fetch_messages_for_conversation(conversation_id):
580
580
  result = conn.execute(query, {"conversation_id": conversation_id})
581
581
  messages = result.fetchall()
582
582
 
583
- return [
584
- {
585
- "role": message[0],
586
- "content": message[1],
587
- "timestamp": message[2],
583
+ parsed_messages = []
584
+ for message in messages:
585
+ role = message[0]
586
+ content = message[1]
587
+
588
+ msg_dict = {
589
+ "role": role,
590
+ "content": content,
591
+ "timestamp": message[2],
588
592
  }
589
- for message in messages
590
- ]
593
+
594
+ # Handle tool messages - extract tool_call_id from content JSON
595
+ if role == "tool" and content:
596
+ try:
597
+ content_parsed = json.loads(content) if isinstance(content, str) else content
598
+ if isinstance(content_parsed, dict):
599
+ if "tool_call_id" in content_parsed:
600
+ msg_dict["tool_call_id"] = content_parsed["tool_call_id"]
601
+ if "tool_name" in content_parsed:
602
+ msg_dict["name"] = content_parsed["tool_name"]
603
+ if "content" in content_parsed:
604
+ msg_dict["content"] = content_parsed["content"]
605
+ except (json.JSONDecodeError, TypeError):
606
+ pass
607
+
608
+ # Parse tool_calls JSON if present (for assistant messages)
609
+ if message[3]:
610
+ try:
611
+ msg_dict["tool_calls"] = json.loads(message[3]) if isinstance(message[3], str) else message[3]
612
+ except (json.JSONDecodeError, TypeError):
613
+ pass
614
+ # Parse tool_results JSON if present
615
+ if message[4]:
616
+ try:
617
+ msg_dict["tool_results"] = json.loads(message[4]) if isinstance(message[4], str) else message[4]
618
+ except (json.JSONDecodeError, TypeError):
619
+ pass
620
+ parsed_messages.append(msg_dict)
621
+ return parsed_messages
591
622
  except Exception as e:
592
623
  print(f"Error fetching messages for conversation: {e}")
593
624
  return []
@@ -815,19 +846,44 @@ def _get_jinx_files_recursively(directory):
815
846
  @app.route("/api/jinxs/available", methods=["GET"])
816
847
  def get_available_jinxs():
817
848
  try:
849
+ import yaml
818
850
  current_path = request.args.get('currentPath')
819
851
  jinx_names = set()
820
852
 
853
+ def get_jinx_name_from_file(filepath):
854
+ """Read jinx_name from file, fallback to filename."""
855
+ try:
856
+ with open(filepath, 'r') as f:
857
+ data = yaml.safe_load(f)
858
+ if data and 'jinx_name' in data:
859
+ return data['jinx_name']
860
+ except:
861
+ pass
862
+ return os.path.basename(filepath)[:-5]
863
+
864
+ # 1. Project jinxs
821
865
  if current_path:
822
866
  team_jinxs_dir = os.path.join(current_path, 'npc_team', 'jinxs')
823
867
  jinx_paths = _get_jinx_files_recursively(team_jinxs_dir)
824
868
  for path in jinx_paths:
825
- jinx_names.add(os.path.basename(path)[:-5])
869
+ jinx_names.add(get_jinx_name_from_file(path))
826
870
 
871
+ # 2. Global user jinxs (~/.npcsh)
827
872
  global_jinxs_dir = os.path.expanduser('~/.npcsh/npc_team/jinxs')
828
873
  jinx_paths = _get_jinx_files_recursively(global_jinxs_dir)
829
874
  for path in jinx_paths:
830
- jinx_names.add(os.path.basename(path)[:-5])
875
+ jinx_names.add(get_jinx_name_from_file(path))
876
+
877
+ # 3. Package built-in jinxs (from npcsh package)
878
+ try:
879
+ import npcsh
880
+ package_dir = os.path.dirname(npcsh.__file__)
881
+ package_jinxs_dir = os.path.join(package_dir, 'npc_team', 'jinxs')
882
+ jinx_paths = _get_jinx_files_recursively(package_jinxs_dir)
883
+ for path in jinx_paths:
884
+ jinx_names.add(get_jinx_name_from_file(path))
885
+ except Exception as pkg_err:
886
+ print(f"Could not load package jinxs: {pkg_err}")
831
887
 
832
888
  return jsonify({'jinxs': sorted(list(jinx_names)), 'error': None})
833
889
  except Exception as e:
@@ -1871,14 +1927,8 @@ def get_jinxs_global():
1871
1927
  with open(jinx_path, 'r') as f:
1872
1928
  raw_data = yaml.safe_load(f)
1873
1929
 
1874
- inputs = []
1875
- for inp in raw_data.get("inputs", []):
1876
- if isinstance(inp, str):
1877
- inputs.append(inp)
1878
- elif isinstance(inp, dict):
1879
- inputs.append(list(inp.keys())[0])
1880
- else:
1881
- inputs.append(str(inp))
1930
+ # Preserve full input definitions including defaults
1931
+ inputs = raw_data.get("inputs", [])
1882
1932
 
1883
1933
  rel_path = os.path.relpath(jinx_path, global_jinx_directory)
1884
1934
  path_without_ext = rel_path[:-5]
@@ -1913,14 +1963,8 @@ def get_jinxs_project():
1913
1963
  with open(jinx_path, 'r') as f:
1914
1964
  raw_data = yaml.safe_load(f)
1915
1965
 
1916
- inputs = []
1917
- for inp in raw_data.get("inputs", []):
1918
- if isinstance(inp, str):
1919
- inputs.append(inp)
1920
- elif isinstance(inp, dict):
1921
- inputs.append(list(inp.keys())[0])
1922
- else:
1923
- inputs.append(str(inp))
1966
+ # Preserve full input definitions including defaults
1967
+ inputs = raw_data.get("inputs", [])
1924
1968
 
1925
1969
  rel_path = os.path.relpath(jinx_path, project_dir)
1926
1970
  path_without_ext = rel_path[:-5]
@@ -2372,6 +2416,66 @@ def check_npcsh_folder():
2372
2416
  print(f"Error checking npcsh: {e}")
2373
2417
  return jsonify({"error": str(e)}), 500
2374
2418
 
2419
+ @app.route("/api/npcsh/package-contents", methods=["GET"])
2420
+ def get_package_contents():
2421
+ """Get NPCs and jinxs available in the npcsh package for installation."""
2422
+ try:
2423
+ from npcsh._state import get_package_dir
2424
+ package_dir = get_package_dir()
2425
+ package_npc_team_dir = os.path.join(package_dir, "npc_team")
2426
+
2427
+ npcs = []
2428
+ jinxs = []
2429
+
2430
+ if os.path.exists(package_npc_team_dir):
2431
+ # Get NPCs
2432
+ for f in os.listdir(package_npc_team_dir):
2433
+ if f.endswith('.npc'):
2434
+ npc_path = os.path.join(package_npc_team_dir, f)
2435
+ try:
2436
+ with open(npc_path, 'r') as file:
2437
+ npc_data = yaml.safe_load(file) or {}
2438
+ npcs.append({
2439
+ "name": npc_data.get("name", f[:-4]),
2440
+ "primary_directive": npc_data.get("primary_directive", ""),
2441
+ "model": npc_data.get("model", ""),
2442
+ "provider": npc_data.get("provider", ""),
2443
+ })
2444
+ except Exception as e:
2445
+ print(f"Error reading NPC {f}: {e}")
2446
+
2447
+ # Get jinxs recursively
2448
+ jinxs_dir = os.path.join(package_npc_team_dir, "jinxs")
2449
+ if os.path.exists(jinxs_dir):
2450
+ for root, dirs, files in os.walk(jinxs_dir):
2451
+ for f in files:
2452
+ if f.endswith('.jinx'):
2453
+ jinx_path = os.path.join(root, f)
2454
+ rel_path = os.path.relpath(jinx_path, jinxs_dir)
2455
+ try:
2456
+ with open(jinx_path, 'r') as file:
2457
+ jinx_data = yaml.safe_load(file) or {}
2458
+ jinxs.append({
2459
+ "name": f[:-5],
2460
+ "path": rel_path[:-5],
2461
+ "description": jinx_data.get("description", ""),
2462
+ })
2463
+ except Exception as e:
2464
+ print(f"Error reading jinx {f}: {e}")
2465
+
2466
+ return jsonify({
2467
+ "npcs": npcs,
2468
+ "jinxs": jinxs,
2469
+ "package_dir": package_dir,
2470
+ "error": None
2471
+ })
2472
+ except Exception as e:
2473
+ print(f"Error getting package contents: {e}")
2474
+ import traceback
2475
+ traceback.print_exc()
2476
+ return jsonify({"error": str(e), "npcs": [], "jinxs": []}), 500
2477
+
2478
+
2375
2479
  @app.route("/api/npcsh/init", methods=["POST"])
2376
2480
  def init_npcsh_folder():
2377
2481
  """Initialize npcsh with config and default npc_team."""
@@ -3416,7 +3520,13 @@ def stream():
3416
3520
  npc_name = data.get("npc", None)
3417
3521
  npc_source = data.get("npcSource", "global")
3418
3522
  current_path = data.get("currentPath")
3419
- is_resend = data.get("isResend", False) # ADD THIS LINE
3523
+ is_resend = data.get("isResend", False)
3524
+ parent_message_id = data.get("parentMessageId", None)
3525
+ # Accept frontend-generated message IDs to maintain parent-child relationships after reload
3526
+ frontend_user_message_id = data.get("userMessageId", None)
3527
+ frontend_assistant_message_id = data.get("assistantMessageId", None)
3528
+ # For sub-branches: the parent of the user message (points to an assistant message)
3529
+ user_parent_message_id = data.get("userParentMessageId", None)
3420
3530
 
3421
3531
  if current_path:
3422
3532
  loaded_vars = load_project_env(current_path)
@@ -3512,55 +3622,63 @@ def stream():
3512
3622
 
3513
3623
 
3514
3624
  attachments = data.get("attachments", [])
3625
+ print(f"[DEBUG] Received attachments: {attachments}")
3515
3626
  command_history = CommandHistory(app.config.get('DB_PATH'))
3516
- images = []
3627
+ images = []
3517
3628
  attachments_for_db = []
3518
3629
  attachment_paths_for_llm = []
3519
3630
 
3520
- message_id = generate_message_id()
3631
+ # Use frontend-provided ID if available, otherwise generate new one
3632
+ message_id = frontend_user_message_id if frontend_user_message_id else generate_message_id()
3521
3633
  if attachments:
3522
- attachment_dir = os.path.expanduser(f"~/.npcsh/attachments/{conversation_id+message_id}/")
3523
- os.makedirs(attachment_dir, exist_ok=True)
3634
+ print(f"[DEBUG] Processing {len(attachments)} attachments")
3524
3635
 
3525
3636
  for attachment in attachments:
3526
3637
  try:
3527
3638
  file_name = attachment["name"]
3528
-
3529
3639
  extension = file_name.split(".")[-1].upper() if "." in file_name else ""
3530
3640
  extension_mapped = extension_map.get(extension, "others")
3531
-
3532
- save_path = os.path.join(attachment_dir, file_name)
3533
3641
 
3534
- if "data" in attachment and attachment["data"]:
3535
- decoded_data = base64.b64decode(attachment["data"])
3536
- with open(save_path, "wb") as f:
3537
- f.write(decoded_data)
3538
-
3539
- elif "path" in attachment and attachment["path"]:
3540
- shutil.copy(attachment["path"], save_path)
3541
-
3542
- else:
3642
+ file_path = None
3643
+ file_content_bytes = None
3644
+
3645
+ # Use original path directly if available
3646
+ if "path" in attachment and attachment["path"]:
3647
+ file_path = attachment["path"]
3648
+ if os.path.exists(file_path):
3649
+ with open(file_path, "rb") as f:
3650
+ file_content_bytes = f.read()
3651
+
3652
+ # Fall back to base64 data if no path
3653
+ elif "data" in attachment and attachment["data"]:
3654
+ file_content_bytes = base64.b64decode(attachment["data"])
3655
+ # Save to temp file for LLM processing
3656
+ import tempfile
3657
+ temp_dir = tempfile.mkdtemp()
3658
+ file_path = os.path.join(temp_dir, file_name)
3659
+ with open(file_path, "wb") as f:
3660
+ f.write(file_content_bytes)
3661
+
3662
+ if not file_path:
3543
3663
  continue
3544
3664
 
3545
- attachment_paths_for_llm.append(save_path)
3665
+ attachment_paths_for_llm.append(file_path)
3546
3666
 
3547
3667
  if extension_mapped == "images":
3548
- images.append(save_path)
3549
-
3550
- with open(save_path, "rb") as f:
3551
- file_content_bytes = f.read()
3668
+ images.append(file_path)
3552
3669
 
3553
3670
  attachments_for_db.append({
3554
3671
  "name": file_name,
3555
- "path": save_path,
3672
+ "path": file_path,
3556
3673
  "type": extension_mapped,
3557
3674
  "data": file_content_bytes,
3558
- "size": os.path.getsize(save_path)
3675
+ "size": len(file_content_bytes) if file_content_bytes else 0
3559
3676
  })
3560
3677
 
3561
3678
  except Exception as e:
3562
3679
  print(f"Error processing attachment {attachment.get('name', 'N/A')}: {e}")
3563
3680
  traceback.print_exc()
3681
+ print(f"[DEBUG] After processing - images: {images}, attachment_paths_for_llm: {attachment_paths_for_llm}")
3564
3682
  messages = fetch_messages_for_conversation(conversation_id)
3565
3683
  if len(messages) == 0 and npc_object is not None:
3566
3684
  messages = [{'role': 'system',
@@ -3602,16 +3720,17 @@ def stream():
3602
3720
  api_url = None
3603
3721
 
3604
3722
  if exe_mode == 'chat':
3723
+ print(f"[DEBUG] Calling get_llm_response with images={images}, attachments={attachment_paths_for_llm}")
3605
3724
  stream_response = get_llm_response(
3606
- commandstr,
3607
- messages=messages,
3608
- images=images,
3725
+ commandstr,
3726
+ messages=messages,
3727
+ images=images,
3609
3728
  model=model,
3610
- provider=provider,
3611
- npc=npc_object,
3729
+ provider=provider,
3730
+ npc=npc_object,
3612
3731
  api_url = api_url,
3613
3732
  team=team_object,
3614
- stream=True,
3733
+ stream=True,
3615
3734
  attachments=attachment_paths_for_llm,
3616
3735
  auto_process_tool_calls=True,
3617
3736
  **tool_args
@@ -3847,7 +3966,7 @@ def stream():
3847
3966
  input_values=tool_args if isinstance(tool_args, dict) else {},
3848
3967
  npc=npc_object
3849
3968
  )
3850
- tool_content = str(jinx_ctx)
3969
+ tool_content = str(jinx_ctx.get('output', '')) if isinstance(jinx_ctx, dict) else str(jinx_ctx)
3851
3970
  except Exception as e:
3852
3971
  tool_content = f"Jinx execution error: {str(e)}"
3853
3972
  else:
@@ -3923,25 +4042,27 @@ def stream():
3923
4042
  user_message_filled += txt
3924
4043
 
3925
4044
  # Only save user message if it's NOT a resend
3926
- if not is_resend: # ADD THIS CONDITION
4045
+ if not is_resend:
3927
4046
  save_conversation_message(
3928
- command_history,
3929
- conversation_id,
3930
- "user",
3931
- user_message_filled if len(user_message_filled) > 0 else commandstr,
3932
- wd=current_path,
3933
- model=model,
3934
- provider=provider,
4047
+ command_history,
4048
+ conversation_id,
4049
+ "user",
4050
+ user_message_filled if len(user_message_filled) > 0 else commandstr,
4051
+ wd=current_path,
4052
+ model=model,
4053
+ provider=provider,
3935
4054
  npc=npc_name,
3936
- team=team,
3937
- attachments=attachments_for_db,
4055
+ team=team,
4056
+ attachments=attachments_for_db,
3938
4057
  message_id=message_id,
4058
+ parent_message_id=user_parent_message_id, # For sub-branches: points to assistant message
3939
4059
  )
3940
4060
 
3941
4061
 
3942
4062
 
3943
4063
 
3944
- message_id = generate_message_id()
4064
+ # Use frontend-provided assistant message ID if available
4065
+ message_id = frontend_assistant_message_id if frontend_assistant_message_id else generate_message_id()
3945
4066
 
3946
4067
  def event_stream(current_stream_id):
3947
4068
  complete_response = []
@@ -4208,6 +4329,7 @@ def stream():
4208
4329
  reasoning_content=''.join(complete_reasoning) if complete_reasoning else None,
4209
4330
  tool_calls=accumulated_tool_calls if accumulated_tool_calls else None,
4210
4331
  tool_results=tool_results_for_db if tool_results_for_db else None,
4332
+ parent_message_id=parent_message_id,
4211
4333
  )
4212
4334
 
4213
4335
  # Start background tasks for memory extraction and context compression
@@ -4387,6 +4509,7 @@ def get_conversation_messages(conversation_id):
4387
4509
  ch.reasoning_content,
4388
4510
  ch.tool_calls,
4389
4511
  ch.tool_results,
4512
+ ch.parent_message_id,
4390
4513
  GROUP_CONCAT(ma.id) as attachment_ids,
4391
4514
  ROW_NUMBER() OVER (
4392
4515
  PARTITION BY ch.role, strftime('%s', ch.timestamp)
@@ -4430,9 +4553,10 @@ def get_conversation_messages(conversation_id):
4430
4553
  "reasoningContent": msg[11] if len(msg) > 11 else None,
4431
4554
  "toolCalls": parse_json_field(msg[12]) if len(msg) > 12 else None,
4432
4555
  "toolResults": parse_json_field(msg[13]) if len(msg) > 13 else None,
4556
+ "parentMessageId": msg[14] if len(msg) > 14 else None,
4433
4557
  "attachments": (
4434
4558
  get_message_attachments(msg[1])
4435
- if len(msg) > 1 and msg[14] # attachment_ids is at index 14
4559
+ if len(msg) > 1 and msg[15] # attachment_ids is now at index 15
4436
4560
  else []
4437
4561
  ),
4438
4562
  }
@@ -4447,6 +4571,157 @@ def get_conversation_messages(conversation_id):
4447
4571
  return jsonify({"error": str(e), "messages": []}), 500
4448
4572
 
4449
4573
 
4574
+ # ==================== CONVERSATION BRANCHES ====================
4575
+
4576
+ @app.route("/api/conversation/<conversation_id>/branches", methods=["GET"])
4577
+ def get_conversation_branches(conversation_id):
4578
+ """Get all branches for a conversation."""
4579
+ try:
4580
+ engine = get_db_connection()
4581
+ with engine.connect() as conn:
4582
+ query = text("""
4583
+ SELECT id, name, parent_branch_id, branch_from_message_id, created_at, metadata
4584
+ FROM conversation_branches
4585
+ WHERE conversation_id = :conversation_id
4586
+ ORDER BY created_at ASC
4587
+ """)
4588
+ result = conn.execute(query, {"conversation_id": conversation_id})
4589
+ branches = result.fetchall()
4590
+
4591
+ return jsonify({
4592
+ "branches": [
4593
+ {
4594
+ "id": b[0],
4595
+ "name": b[1],
4596
+ "parentBranchId": b[2],
4597
+ "branchFromMessageId": b[3],
4598
+ "createdAt": b[4],
4599
+ "metadata": json.loads(b[5]) if b[5] else None
4600
+ }
4601
+ for b in branches
4602
+ ],
4603
+ "error": None
4604
+ })
4605
+ except Exception as e:
4606
+ print(f"Error getting branches: {e}")
4607
+ return jsonify({"branches": [], "error": str(e)}), 500
4608
+
4609
+
4610
+ @app.route("/api/conversation/<conversation_id>/branches", methods=["POST"])
4611
+ def create_conversation_branch(conversation_id):
4612
+ """Create a new branch for a conversation."""
4613
+ try:
4614
+ data = request.get_json()
4615
+ branch_id = data.get("id") or generate_message_id()
4616
+ name = data.get("name", f"Branch {branch_id[:8]}")
4617
+ parent_branch_id = data.get("parentBranchId", "main")
4618
+ branch_from_message_id = data.get("branchFromMessageId")
4619
+ created_at = data.get("createdAt") or datetime.now().isoformat()
4620
+ metadata = json.dumps(data.get("metadata")) if data.get("metadata") else None
4621
+
4622
+ engine = get_db_connection()
4623
+ with engine.connect() as conn:
4624
+ query = text("""
4625
+ INSERT INTO conversation_branches
4626
+ (id, conversation_id, name, parent_branch_id, branch_from_message_id, created_at, metadata)
4627
+ VALUES (:id, :conversation_id, :name, :parent_branch_id, :branch_from_message_id, :created_at, :metadata)
4628
+ """)
4629
+ conn.execute(query, {
4630
+ "id": branch_id,
4631
+ "conversation_id": conversation_id,
4632
+ "name": name,
4633
+ "parent_branch_id": parent_branch_id,
4634
+ "branch_from_message_id": branch_from_message_id,
4635
+ "created_at": created_at,
4636
+ "metadata": metadata
4637
+ })
4638
+ conn.commit()
4639
+
4640
+ return jsonify({"success": True, "branchId": branch_id})
4641
+ except Exception as e:
4642
+ print(f"Error creating branch: {e}")
4643
+ return jsonify({"success": False, "error": str(e)}), 500
4644
+
4645
+
4646
+ @app.route("/api/conversation/<conversation_id>/branches/<branch_id>", methods=["DELETE"])
4647
+ def delete_conversation_branch(conversation_id, branch_id):
4648
+ """Delete a branch."""
4649
+ try:
4650
+ engine = get_db_connection()
4651
+ with engine.connect() as conn:
4652
+ # Delete branch metadata
4653
+ query = text("DELETE FROM conversation_branches WHERE id = :branch_id AND conversation_id = :conversation_id")
4654
+ conn.execute(query, {"branch_id": branch_id, "conversation_id": conversation_id})
4655
+
4656
+ # Optionally delete messages on this branch (or leave them orphaned)
4657
+ # For now, we leave them - they just won't be displayed
4658
+ conn.commit()
4659
+
4660
+ return jsonify({"success": True})
4661
+ except Exception as e:
4662
+ print(f"Error deleting branch: {e}")
4663
+ return jsonify({"success": False, "error": str(e)}), 500
4664
+
4665
+
4666
+ @app.route("/api/conversation/<conversation_id>/messages/branch/<branch_id>", methods=["GET"])
4667
+ def get_branch_messages(conversation_id, branch_id):
4668
+ """Get messages for a specific branch."""
4669
+ try:
4670
+ engine = get_db_connection()
4671
+ with engine.connect() as conn:
4672
+ # For 'main' branch, get messages with NULL or 'main' branch_id
4673
+ if branch_id == 'main':
4674
+ query = text("""
4675
+ SELECT message_id, timestamp, role, content, model, provider, npc, reasoning_content, tool_calls, tool_results
4676
+ FROM conversation_history
4677
+ WHERE conversation_id = :conversation_id
4678
+ AND (branch_id IS NULL OR branch_id = 'main')
4679
+ ORDER BY timestamp ASC, id ASC
4680
+ """)
4681
+ else:
4682
+ query = text("""
4683
+ SELECT message_id, timestamp, role, content, model, provider, npc, reasoning_content, tool_calls, tool_results
4684
+ FROM conversation_history
4685
+ WHERE conversation_id = :conversation_id
4686
+ AND branch_id = :branch_id
4687
+ ORDER BY timestamp ASC, id ASC
4688
+ """)
4689
+
4690
+ result = conn.execute(query, {"conversation_id": conversation_id, "branch_id": branch_id})
4691
+ messages = result.fetchall()
4692
+
4693
+ def parse_json_field(value):
4694
+ if not value:
4695
+ return None
4696
+ try:
4697
+ return json.loads(value)
4698
+ except:
4699
+ return None
4700
+
4701
+ return jsonify({
4702
+ "messages": [
4703
+ {
4704
+ "message_id": m[0],
4705
+ "timestamp": m[1],
4706
+ "role": m[2],
4707
+ "content": m[3],
4708
+ "model": m[4],
4709
+ "provider": m[5],
4710
+ "npc": m[6],
4711
+ "reasoningContent": m[7],
4712
+ "toolCalls": parse_json_field(m[8]),
4713
+ "toolResults": parse_json_field(m[9])
4714
+ }
4715
+ for m in messages
4716
+ ],
4717
+ "error": None
4718
+ })
4719
+ except Exception as e:
4720
+ print(f"Error getting branch messages: {e}")
4721
+ return jsonify({"messages": [], "error": str(e)}), 500
4722
+
4723
+
4724
+ # ==================== END CONVERSATION BRANCHES ====================
4450
4725
 
4451
4726
  @app.after_request
4452
4727
  def after_request(response):
@@ -4939,6 +5214,140 @@ def download_hf_model():
4939
5214
  return jsonify({'error': str(e)}), 500
4940
5215
 
4941
5216
 
5217
+ @app.route('/api/models/hf/search', methods=['GET'])
5218
+ def search_hf_models():
5219
+ """Search HuggingFace for GGUF models."""
5220
+ query = request.args.get('q', '')
5221
+ limit = int(request.args.get('limit', 20))
5222
+
5223
+ if not query:
5224
+ return jsonify({'models': [], 'error': 'No search query provided'})
5225
+
5226
+ try:
5227
+ from huggingface_hub import HfApi
5228
+
5229
+ api = HfApi()
5230
+ # Search for models with GGUF in name or tags
5231
+ models = api.list_models(
5232
+ search=query,
5233
+ filter="gguf",
5234
+ limit=limit,
5235
+ sort="downloads",
5236
+ direction=-1
5237
+ )
5238
+
5239
+ results = []
5240
+ for model in models:
5241
+ results.append({
5242
+ 'id': model.id,
5243
+ 'author': model.author,
5244
+ 'downloads': model.downloads,
5245
+ 'likes': model.likes,
5246
+ 'tags': model.tags[:10] if model.tags else [],
5247
+ 'last_modified': model.last_modified.isoformat() if model.last_modified else None,
5248
+ })
5249
+
5250
+ return jsonify({'models': results, 'error': None})
5251
+ except ImportError:
5252
+ return jsonify({'error': 'huggingface_hub not installed. Run: pip install huggingface_hub'}), 500
5253
+ except Exception as e:
5254
+ print(f"Error searching HF models: {e}")
5255
+ return jsonify({'error': str(e)}), 500
5256
+
5257
+
5258
+ @app.route('/api/models/hf/files', methods=['GET'])
5259
+ def list_hf_model_files():
5260
+ """List GGUF files in a HuggingFace repository."""
5261
+ repo_id = request.args.get('repo_id', '')
5262
+
5263
+ if not repo_id:
5264
+ return jsonify({'files': [], 'error': 'No repo_id provided'})
5265
+
5266
+ try:
5267
+ from huggingface_hub import list_repo_files, repo_info
5268
+
5269
+ # Get repo info
5270
+ info = repo_info(repo_id)
5271
+
5272
+ # List all files
5273
+ all_files = list_repo_files(repo_id)
5274
+
5275
+ # Filter for GGUF files and get their sizes
5276
+ gguf_files = []
5277
+ for f in all_files:
5278
+ if f.endswith('.gguf'):
5279
+ # Try to get file size from siblings
5280
+ size = None
5281
+ for sibling in info.siblings or []:
5282
+ if sibling.rfilename == f:
5283
+ size = sibling.size
5284
+ break
5285
+
5286
+ # Parse quantization from filename
5287
+ quant = 'unknown'
5288
+ for q in ['Q2_K', 'Q3_K_S', 'Q3_K_M', 'Q3_K_L', 'Q4_0', 'Q4_1', 'Q4_K_S', 'Q4_K_M', 'Q5_0', 'Q5_1', 'Q5_K_S', 'Q5_K_M', 'Q6_K', 'Q8_0', 'F16', 'F32', 'IQ1', 'IQ2', 'IQ3', 'IQ4']:
5289
+ if q.lower() in f.lower() or q in f:
5290
+ quant = q
5291
+ break
5292
+
5293
+ gguf_files.append({
5294
+ 'filename': f,
5295
+ 'size': size,
5296
+ 'size_gb': round(size / (1024**3), 2) if size else None,
5297
+ 'quantization': quant,
5298
+ })
5299
+
5300
+ # Sort by quantization quality (Q4_K_M is usually best balance)
5301
+ quant_order = {'Q4_K_M': 0, 'Q4_K_S': 1, 'Q5_K_M': 2, 'Q5_K_S': 3, 'Q3_K_M': 4, 'Q6_K': 5, 'Q8_0': 6}
5302
+ gguf_files.sort(key=lambda x: quant_order.get(x['quantization'], 99))
5303
+
5304
+ return jsonify({
5305
+ 'repo_id': repo_id,
5306
+ 'files': gguf_files,
5307
+ 'total_files': len(all_files),
5308
+ 'gguf_count': len(gguf_files),
5309
+ 'error': None
5310
+ })
5311
+ except ImportError:
5312
+ return jsonify({'error': 'huggingface_hub not installed. Run: pip install huggingface_hub'}), 500
5313
+ except Exception as e:
5314
+ print(f"Error listing HF files: {e}")
5315
+ return jsonify({'error': str(e)}), 500
5316
+
5317
+
5318
+ @app.route('/api/models/hf/download_file', methods=['POST'])
5319
+ def download_hf_file():
5320
+ """Download a specific file from a HuggingFace repository."""
5321
+ data = request.json
5322
+ repo_id = data.get('repo_id', '')
5323
+ filename = data.get('filename', '')
5324
+ target_dir = data.get('target_dir', '~/.npcsh/models/gguf')
5325
+
5326
+ if not repo_id or not filename:
5327
+ return jsonify({'error': 'repo_id and filename are required'}), 400
5328
+
5329
+ target_dir = os.path.expanduser(target_dir)
5330
+ os.makedirs(target_dir, exist_ok=True)
5331
+
5332
+ try:
5333
+ from huggingface_hub import hf_hub_download
5334
+
5335
+ print(f"Downloading {filename} from {repo_id} to {target_dir}")
5336
+ path = hf_hub_download(
5337
+ repo_id=repo_id,
5338
+ filename=filename,
5339
+ local_dir=target_dir,
5340
+ local_dir_use_symlinks=False
5341
+ )
5342
+
5343
+ return jsonify({'path': path, 'error': None})
5344
+ except ImportError:
5345
+ return jsonify({'error': 'huggingface_hub not installed. Run: pip install huggingface_hub'}), 500
5346
+ except Exception as e:
5347
+ print(f"Error downloading HF file: {e}")
5348
+ return jsonify({'error': str(e)}), 500
5349
+
5350
+
4942
5351
  # ============== Local Model Provider Status ==============
4943
5352
  @app.route('/api/models/local/scan', methods=['GET'])
4944
5353
  def scan_local_models():
@@ -5002,6 +5411,213 @@ def get_local_model_status():
5002
5411
  return jsonify({'status': 'unknown', 'running': False, 'error': f'Unknown provider: {provider}'})
5003
5412
 
5004
5413
 
5414
+ # ============== Audio / Voice ==============
5415
+ @app.route('/api/audio/tts', methods=['POST'])
5416
+ def text_to_speech_endpoint():
5417
+ """Convert text to speech and return audio file."""
5418
+ try:
5419
+ import base64
5420
+ from npcpy.gen.audio_gen import (
5421
+ text_to_speech, get_available_engines,
5422
+ pcm16_to_wav, KOKORO_VOICES
5423
+ )
5424
+
5425
+ data = request.json or {}
5426
+ text = data.get('text', '')
5427
+ engine = data.get('engine', 'kokoro') # kokoro, elevenlabs, openai, gemini, gtts
5428
+ voice = data.get('voice', 'af_heart')
5429
+
5430
+ if not text:
5431
+ return jsonify({'success': False, 'error': 'No text provided'}), 400
5432
+
5433
+ # Check engine availability
5434
+ engines = get_available_engines()
5435
+ if engine not in engines:
5436
+ return jsonify({'success': False, 'error': f'Unknown engine: {engine}'}), 400
5437
+
5438
+ if not engines[engine]['available']:
5439
+ # Try fallback to kokoro or gtts
5440
+ if engines.get('kokoro', {}).get('available'):
5441
+ engine = 'kokoro'
5442
+ elif engines.get('gtts', {}).get('available'):
5443
+ engine = 'gtts'
5444
+ voice = 'en'
5445
+ else:
5446
+ return jsonify({
5447
+ 'success': False,
5448
+ 'error': f'{engine} not available. Install: {engines[engine].get("install", engines[engine].get("requires", ""))}'
5449
+ }), 400
5450
+
5451
+ # Generate audio
5452
+ audio_bytes = text_to_speech(text, engine=engine, voice=voice)
5453
+
5454
+ # Determine format
5455
+ if engine in ['kokoro']:
5456
+ audio_format = 'wav'
5457
+ elif engine in ['elevenlabs', 'gtts']:
5458
+ audio_format = 'mp3'
5459
+ elif engine in ['openai', 'gemini']:
5460
+ # These return PCM16, convert to WAV
5461
+ audio_bytes = pcm16_to_wav(audio_bytes, sample_rate=24000)
5462
+ audio_format = 'wav'
5463
+ else:
5464
+ audio_format = 'wav'
5465
+
5466
+ audio_data = base64.b64encode(audio_bytes).decode('utf-8')
5467
+
5468
+ return jsonify({
5469
+ 'success': True,
5470
+ 'audio': audio_data,
5471
+ 'format': audio_format,
5472
+ 'engine': engine,
5473
+ 'voice': voice
5474
+ })
5475
+
5476
+ except ImportError as e:
5477
+ return jsonify({'success': False, 'error': f'TTS dependency not installed: {e}'}), 500
5478
+ except Exception as e:
5479
+ print(f"TTS error: {e}")
5480
+ traceback.print_exc()
5481
+ return jsonify({'success': False, 'error': str(e)}), 500
5482
+
5483
+
5484
+ @app.route('/api/audio/stt', methods=['POST'])
5485
+ def speech_to_text_endpoint():
5486
+ """Convert speech audio to text using various STT engines."""
5487
+ try:
5488
+ import tempfile
5489
+ import base64
5490
+ from npcpy.data.audio import speech_to_text, get_available_stt_engines
5491
+
5492
+ data = request.json or {}
5493
+ audio_data = data.get('audio') # Base64 encoded audio
5494
+ audio_format = data.get('format', 'webm') # webm, wav, mp3
5495
+ language = data.get('language') # None for auto-detect
5496
+ engine = data.get('engine', 'whisper') # whisper, openai, gemini, elevenlabs, groq
5497
+ model_size = data.get('model', 'base') # For whisper: tiny, base, small, medium, large
5498
+
5499
+ if not audio_data:
5500
+ return jsonify({'success': False, 'error': 'No audio data provided'}), 400
5501
+
5502
+ # Decode base64 audio
5503
+ audio_bytes = base64.b64decode(audio_data)
5504
+
5505
+ # Convert to wav if needed
5506
+ wav_bytes = audio_bytes
5507
+ if audio_format != 'wav':
5508
+ with tempfile.NamedTemporaryFile(suffix=f'.{audio_format}', delete=False) as f:
5509
+ f.write(audio_bytes)
5510
+ temp_path = f.name
5511
+
5512
+ wav_path = temp_path.replace(f'.{audio_format}', '.wav')
5513
+ converted = False
5514
+
5515
+ # Try ffmpeg first
5516
+ try:
5517
+ subprocess.run([
5518
+ 'ffmpeg', '-y', '-i', temp_path,
5519
+ '-acodec', 'pcm_s16le', '-ac', '1', '-ar', '16000',
5520
+ wav_path
5521
+ ], check=True, capture_output=True)
5522
+ with open(wav_path, 'rb') as f:
5523
+ wav_bytes = f.read()
5524
+ converted = True
5525
+ os.unlink(wav_path)
5526
+ except FileNotFoundError:
5527
+ pass
5528
+ except subprocess.CalledProcessError:
5529
+ pass
5530
+
5531
+ # Try pydub as fallback
5532
+ if not converted:
5533
+ try:
5534
+ from pydub import AudioSegment
5535
+ audio = AudioSegment.from_file(temp_path, format=audio_format)
5536
+ audio = audio.set_frame_rate(16000).set_channels(1)
5537
+ import io
5538
+ wav_buffer = io.BytesIO()
5539
+ audio.export(wav_buffer, format='wav')
5540
+ wav_bytes = wav_buffer.getvalue()
5541
+ converted = True
5542
+ except ImportError:
5543
+ pass
5544
+ except Exception as e:
5545
+ print(f"pydub conversion failed: {e}")
5546
+
5547
+ os.unlink(temp_path)
5548
+
5549
+ if not converted:
5550
+ return jsonify({
5551
+ 'success': False,
5552
+ 'error': 'Audio conversion failed. Install ffmpeg: sudo apt-get install ffmpeg'
5553
+ }), 500
5554
+
5555
+ # Use the unified speech_to_text function
5556
+ result = speech_to_text(
5557
+ wav_bytes,
5558
+ engine=engine,
5559
+ language=language,
5560
+ model_size=model_size
5561
+ )
5562
+
5563
+ return jsonify({
5564
+ 'success': True,
5565
+ 'text': result.get('text', ''),
5566
+ 'language': result.get('language', language or 'en'),
5567
+ 'segments': result.get('segments', [])
5568
+ })
5569
+
5570
+ except Exception as e:
5571
+ print(f"STT error: {e}")
5572
+ traceback.print_exc()
5573
+ return jsonify({'success': False, 'error': str(e)}), 500
5574
+
5575
+
5576
+ @app.route('/api/audio/stt/engines', methods=['GET'])
5577
+ def get_stt_engines_endpoint():
5578
+ """Get available STT engines."""
5579
+ try:
5580
+ from npcpy.data.audio import get_available_stt_engines
5581
+ engines = get_available_stt_engines()
5582
+ return jsonify({'success': True, 'engines': engines})
5583
+ except Exception as e:
5584
+ print(f"Error getting STT engines: {e}")
5585
+ return jsonify({'success': False, 'error': str(e)}), 500
5586
+
5587
+
5588
+ @app.route('/api/audio/voices', methods=['GET'])
5589
+ def get_available_voices_endpoint():
5590
+ """Get available TTS voices/engines."""
5591
+ try:
5592
+ from npcpy.gen.audio_gen import get_available_engines, get_available_voices
5593
+
5594
+ engines_info = get_available_engines()
5595
+ result = {}
5596
+
5597
+ for engine_id, info in engines_info.items():
5598
+ voices = get_available_voices(engine_id) if info['available'] else []
5599
+ result[engine_id] = {
5600
+ 'name': info['name'],
5601
+ 'type': info.get('type', 'unknown'),
5602
+ 'available': info['available'],
5603
+ 'description': info.get('description', ''),
5604
+ 'default': engine_id == 'kokoro',
5605
+ 'voices': voices
5606
+ }
5607
+ if not info['available']:
5608
+ if 'install' in info:
5609
+ result[engine_id]['install'] = info['install']
5610
+ if 'requires' in info:
5611
+ result[engine_id]['requires'] = info['requires']
5612
+
5613
+ return jsonify({'success': True, 'engines': result})
5614
+
5615
+ except Exception as e:
5616
+ print(f"Error getting voices: {e}")
5617
+ traceback.print_exc()
5618
+ return jsonify({'success': False, 'error': str(e)}), 500
5619
+
5620
+
5005
5621
  # ============== Activity Tracking ==============
5006
5622
  @app.route('/api/activity/track', methods=['POST'])
5007
5623
  def track_activity():
@@ -5017,6 +5633,56 @@ def track_activity():
5017
5633
  return jsonify({'success': False, 'error': str(e)}), 500
5018
5634
 
5019
5635
 
5636
+ # ============== Studio Action Results ==============
5637
+ # Storage for pending action results that agents are waiting for
5638
+ _studio_action_results = {}
5639
+
5640
+ @app.route('/api/studio/action_result', methods=['POST'])
5641
+ def studio_action_result():
5642
+ """
5643
+ Receive action results from the frontend after executing studio.* tool calls.
5644
+ This allows the agent to continue with the result of UI actions.
5645
+ """
5646
+ try:
5647
+ data = request.json or {}
5648
+ stream_id = data.get('streamId')
5649
+ tool_id = data.get('toolId')
5650
+ result = data.get('result', {})
5651
+
5652
+ if not stream_id or not tool_id:
5653
+ return jsonify({'success': False, 'error': 'Missing streamId or toolId'}), 400
5654
+
5655
+ # Store the result keyed by stream_id and tool_id
5656
+ key = f"{stream_id}_{tool_id}"
5657
+ _studio_action_results[key] = result
5658
+
5659
+ print(f"[Studio] Received action result for {key}: {result.get('success', False)}")
5660
+ return jsonify({'success': True, 'stored': key})
5661
+ except Exception as e:
5662
+ print(f"Error storing studio action result: {e}")
5663
+ return jsonify({'success': False, 'error': str(e)}), 500
5664
+
5665
+
5666
+ @app.route('/api/studio/action_result/<stream_id>/<tool_id>', methods=['GET'])
5667
+ def get_studio_action_result(stream_id, tool_id):
5668
+ """
5669
+ Retrieve a pending action result for the agent to continue.
5670
+ """
5671
+ try:
5672
+ key = f"{stream_id}_{tool_id}"
5673
+ result = _studio_action_results.get(key)
5674
+
5675
+ if result is None:
5676
+ return jsonify({'success': False, 'pending': True}), 202
5677
+
5678
+ # Remove the result after retrieval (one-time use)
5679
+ del _studio_action_results[key]
5680
+ return jsonify({'success': True, 'result': result})
5681
+ except Exception as e:
5682
+ print(f"Error retrieving studio action result: {e}")
5683
+ return jsonify({'success': False, 'error': str(e)}), 500
5684
+
5685
+
5020
5686
  def start_flask_server(
5021
5687
  port=5337,
5022
5688
  cors_origins=None,
@@ -5070,8 +5736,22 @@ if __name__ == "__main__":
5070
5736
 
5071
5737
  SETTINGS_FILE = Path(os.path.expanduser("~/.npcshrc"))
5072
5738
 
5073
-
5074
- db_path = os.path.expanduser("~/npcsh_history.db")
5739
+ # Use standard npcsh paths
5740
+ db_path = os.path.expanduser("~/.npcsh/npcsh_history.db")
5075
5741
  user_npc_directory = os.path.expanduser("~/.npcsh/npc_team")
5076
5742
 
5077
- start_flask_server(db_path=db_path, user_npc_directory=user_npc_directory)
5743
+ # Ensure directories exist
5744
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
5745
+ os.makedirs(user_npc_directory, exist_ok=True)
5746
+
5747
+ # Initialize base NPCs if needed (creates ~/.npcsh structure)
5748
+ try:
5749
+ initialize_base_npcs_if_needed(db_path)
5750
+ print(f"[SERVE] Base NPCs initialized")
5751
+ except Exception as e:
5752
+ print(f"[SERVE] Warning: Failed to initialize base NPCs: {e}")
5753
+
5754
+ # Get port from environment or use default
5755
+ port = int(os.environ.get('INCOGNIDE_PORT', 5337))
5756
+
5757
+ start_flask_server(db_path=db_path, user_npc_directory=user_npc_directory, port=port)