npcpy 1.3.19__py3-none-any.whl → 1.3.21__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/data/web.py CHANGED
@@ -24,22 +24,118 @@ except:
24
24
 
25
25
 
26
26
 
27
- def search_exa(query:str,
28
- api_key:str = None,
27
+ def search_exa(query:str,
28
+ api_key:str = None,
29
29
  top_k = 5,
30
30
  **kwargs):
31
31
  from exa_py import Exa
32
32
  if api_key is None:
33
- api_key = os.environ.get('EXA_API_KEY')
33
+ api_key = os.environ.get('EXA_API_KEY')
34
34
  exa = Exa(api_key)
35
35
 
36
36
  results = exa.search_and_contents(
37
- query,
38
- text=True
37
+ query,
38
+ text=True
39
39
  )
40
40
  return results.results[0:top_k]
41
41
 
42
42
 
43
+ def search_searxng(query: str, num_results: int = 5, instance_url: str = None):
44
+ """Search using SearXNG public instances."""
45
+ instances = [instance_url] if instance_url else [
46
+ os.environ.get('SEARXNG_URL'),
47
+ 'https://search.sapti.me',
48
+ 'https://searx.work',
49
+ 'https://search.ononoki.org',
50
+ ]
51
+ instances = [i for i in instances if i]
52
+
53
+ for instance in instances:
54
+ try:
55
+ response = requests.get(
56
+ f"{instance}/search",
57
+ params={'q': query, 'format': 'json', 'categories': 'general'},
58
+ headers={'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0'},
59
+ timeout=10
60
+ )
61
+ if response.status_code == 200:
62
+ data = response.json()
63
+ results = []
64
+ for r in data.get('results', [])[:num_results]:
65
+ results.append({
66
+ 'title': r.get('title', ''),
67
+ 'link': r.get('url', ''),
68
+ 'content': r.get('content', '')
69
+ })
70
+ if results:
71
+ return results
72
+ except Exception as e:
73
+ print(f"SearXNG {instance} failed: {e}")
74
+ continue
75
+ return []
76
+
77
+
78
+ def search_startpage(query: str, num_results: int = 5):
79
+ """Search using Startpage (scraping)."""
80
+ try:
81
+ response = requests.post(
82
+ 'https://www.startpage.com/sp/search',
83
+ data={'query': query, 'cat': 'web'},
84
+ headers={
85
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
86
+ 'Accept': 'text/html',
87
+ },
88
+ timeout=10
89
+ )
90
+ if response.status_code == 200:
91
+ soup = BeautifulSoup(response.text, 'html.parser')
92
+ results = []
93
+ for item in soup.select('.result')[:num_results]:
94
+ title_el = item.select_one('h2') or item.select_one('a')
95
+ link_el = item.select_one('a[href^="http"]')
96
+ desc_el = item.select_one('p') or item.select_one('.description')
97
+ if link_el:
98
+ results.append({
99
+ 'title': title_el.get_text(strip=True) if title_el else '',
100
+ 'link': link_el.get('href', ''),
101
+ 'content': desc_el.get_text(strip=True) if desc_el else ''
102
+ })
103
+ return results
104
+ except Exception as e:
105
+ print(f"Startpage search failed: {e}")
106
+ return []
107
+
108
+
109
+ def search_brave(query: str, num_results: int = 5, api_key: str = None):
110
+ """Search using Brave Search API."""
111
+ if api_key is None:
112
+ api_key = os.environ.get('BRAVE_API_KEY')
113
+ if not api_key:
114
+ print("Brave API key not set")
115
+ return []
116
+
117
+ try:
118
+ response = requests.get(
119
+ 'https://api.search.brave.com/res/v1/web/search',
120
+ params={'q': query, 'count': num_results},
121
+ headers={'X-Subscription-Token': api_key, 'Accept': 'application/json'},
122
+ timeout=10
123
+ )
124
+ if response.status_code == 200:
125
+ data = response.json()
126
+ results = []
127
+ for r in data.get('web', {}).get('results', [])[:num_results]:
128
+ results.append({
129
+ 'title': r.get('title', ''),
130
+ 'link': r.get('url', ''),
131
+ 'content': r.get('description', '')
132
+ })
133
+ return results
134
+ except Exception as e:
135
+ print(f"Brave search failed: {e}")
136
+ return []
137
+
138
+
43
139
  def search_perplexity(
44
140
  query: str,
45
141
  api_key: str = None,
@@ -134,10 +230,19 @@ def search_web(
134
230
  print("DuckDuckGo search failed: ", e)
135
231
  urls = []
136
232
  results = []
137
- elif provider =='exa':
138
- return search_exa(query, api_key=api_key, )
233
+ elif provider == 'exa':
234
+ return search_exa(query, api_key=api_key, top_k=num_results)
235
+
236
+ elif provider == 'searxng':
237
+ results = search_searxng(query, num_results=num_results)
238
+
239
+ elif provider == 'startpage':
240
+ results = search_startpage(query, num_results=num_results)
241
+
242
+ elif provider == 'brave':
243
+ results = search_brave(query, num_results=num_results, api_key=api_key)
139
244
 
140
- elif provider =='google':
245
+ elif provider == 'google':
141
246
  urls = list(search(query, num_results=num_results))
142
247
 
143
248
 
npcpy/llm_funcs.py CHANGED
@@ -242,7 +242,8 @@ def get_llm_response(
242
242
  base_model, base_provider, base_api_url = _resolve_model_provider(npc, team, model, provider)
243
243
 
244
244
  def _run_single(run_model, run_provider, run_npc, run_team, run_context, extra_kwargs):
245
- system_message = get_system_message(run_npc, run_team) if run_npc is not None else "You are a helpful assistant."
245
+ _tool_capable = bool(extra_kwargs.get("tools"))
246
+ system_message = get_system_message(run_npc, run_team, tool_capable=_tool_capable) if run_npc is not None else "You are a helpful assistant."
246
247
  ctx_suffix = _context_suffix(run_context)
247
248
  run_messages = _build_messages(messages, system_message, prompt, ctx_suffix)
248
249
  return get_litellm_response(
@@ -499,12 +500,10 @@ def handle_request_input(
499
500
 
500
501
 
501
502
  def _get_jinxs(npc, team):
502
- """Get available jinxs from npc and team."""
503
+ """Get available jinxs from npc (already filtered by jinxs_spec)."""
503
504
  jinxs = {}
504
505
  if npc and hasattr(npc, 'jinxs_dict'):
505
506
  jinxs.update(npc.jinxs_dict)
506
- if team and hasattr(team, 'jinxs_dict'):
507
- jinxs.update(team.jinxs_dict)
508
507
  return jinxs
509
508
 
510
509
 
@@ -613,7 +613,11 @@ class CommandHistory:
613
613
  Column('tool_results', Text), # JSON array of tool call results
614
614
  Column('parent_message_id', String(50)), # Links assistant response to parent user message for broadcast grouping
615
615
  Column('device_id', String(255)), # UUID of the device that created this message
616
- Column('device_name', String(255)) # Human-readable device name
616
+ Column('device_name', String(255)), # Human-readable device name
617
+ Column('params', Text), # JSON object for LLM params: temperature, top_p, top_k, max_tokens, etc.
618
+ Column('input_tokens', Integer), # Input/prompt token count
619
+ Column('output_tokens', Integer), # Output/completion token count
620
+ Column('cost', String(50)) # Cost in USD (stored as string for precision)
617
621
  )
618
622
 
619
623
  Table('message_attachments', metadata,
@@ -679,13 +683,29 @@ class CommandHistory:
679
683
  metadata.create_all(self.engine, checkfirst=True)
680
684
  init_kg_schema(self.engine)
681
685
 
682
- # Add parent_message_id column if it doesn't exist (for broadcast grouping)
686
+ # Add columns if they don't exist (migrations for existing databases)
683
687
  if 'sqlite' in str(self.engine.url):
684
688
  with self.engine.begin() as conn:
685
689
  try:
686
690
  conn.execute(text("ALTER TABLE conversation_history ADD COLUMN parent_message_id VARCHAR(50)"))
687
691
  except Exception:
688
692
  pass # Column already exists
693
+ try:
694
+ conn.execute(text("ALTER TABLE conversation_history ADD COLUMN params TEXT"))
695
+ except Exception:
696
+ pass # Column already exists
697
+ try:
698
+ conn.execute(text("ALTER TABLE conversation_history ADD COLUMN input_tokens INTEGER"))
699
+ except Exception:
700
+ pass # Column already exists
701
+ try:
702
+ conn.execute(text("ALTER TABLE conversation_history ADD COLUMN output_tokens INTEGER"))
703
+ except Exception:
704
+ pass # Column already exists
705
+ try:
706
+ conn.execute(text("ALTER TABLE conversation_history ADD COLUMN cost VARCHAR(50)"))
707
+ except Exception:
708
+ pass # Column already exists
689
709
 
690
710
  def _setup_execution_triggers(self):
691
711
  if 'sqlite' in str(self.engine.url):
@@ -871,6 +891,10 @@ class CommandHistory:
871
891
  parent_message_id=None,
872
892
  device_id=None,
873
893
  device_name=None,
894
+ gen_params=None,
895
+ input_tokens=None,
896
+ output_tokens=None,
897
+ cost=None,
874
898
  ):
875
899
  if isinstance(content, (dict, list)):
876
900
  content = json.dumps(content, cls=CustomJSONEncoder)
@@ -880,21 +904,31 @@ class CommandHistory:
880
904
  tool_calls = json.dumps(tool_calls, cls=CustomJSONEncoder)
881
905
  if tool_results is not None and not isinstance(tool_results, str):
882
906
  tool_results = json.dumps(tool_results, cls=CustomJSONEncoder)
907
+ # Serialize gen_params as JSON
908
+ gen_params_json = None
909
+ if gen_params is not None and not isinstance(gen_params, str):
910
+ gen_params_json = json.dumps(gen_params, cls=CustomJSONEncoder)
911
+ elif isinstance(gen_params, str):
912
+ gen_params_json = gen_params
883
913
 
884
914
  # Normalize directory path for cross-platform compatibility
885
915
  normalized_directory_path = normalize_path_for_db(directory_path)
886
916
 
917
+ # Convert cost to string for precision
918
+ cost_str = str(cost) if cost is not None else None
919
+
887
920
  stmt = """
888
921
  INSERT INTO conversation_history
889
- (message_id, timestamp, role, content, conversation_id, directory_path, model, provider, npc, team, reasoning_content, tool_calls, tool_results, parent_message_id, device_id, device_name)
890
- VALUES (:message_id, :timestamp, :role, :content, :conversation_id, :directory_path, :model, :provider, :npc, :team, :reasoning_content, :tool_calls, :tool_results, :parent_message_id, :device_id, :device_name)
922
+ (message_id, timestamp, role, content, conversation_id, directory_path, model, provider, npc, team, reasoning_content, tool_calls, tool_results, parent_message_id, device_id, device_name, params, input_tokens, output_tokens, cost)
923
+ VALUES (:message_id, :timestamp, :role, :content, :conversation_id, :directory_path, :model, :provider, :npc, :team, :reasoning_content, :tool_calls, :tool_results, :parent_message_id, :device_id, :device_name, :params, :input_tokens, :output_tokens, :cost)
891
924
  """
892
925
  params = {
893
926
  "message_id": message_id, "timestamp": timestamp, "role": role, "content": content,
894
927
  "conversation_id": conversation_id, "directory_path": normalized_directory_path, "model": model,
895
928
  "provider": provider, "npc": npc, "team": team, "reasoning_content": reasoning_content,
896
929
  "tool_calls": tool_calls, "tool_results": tool_results, "parent_message_id": parent_message_id,
897
- "device_id": device_id, "device_name": device_name
930
+ "device_id": device_id, "device_name": device_name, "params": gen_params_json,
931
+ "input_tokens": input_tokens, "output_tokens": output_tokens, "cost": cost_str
898
932
  }
899
933
  with self.engine.begin() as conn:
900
934
  conn.execute(text(stmt), params)
@@ -1050,7 +1084,35 @@ class CommandHistory:
1050
1084
 
1051
1085
  with self.engine.begin() as conn:
1052
1086
  conn.execute(text(stmt), params)
1053
-
1087
+
1088
+ def get_approved_memories_by_scope(self):
1089
+ """Get all approved/edited memories grouped by (npc, team, path) scope."""
1090
+ stmt = """
1091
+ SELECT id, npc, team, directory_path, initial_memory, final_memory, status
1092
+ FROM memory_lifecycle
1093
+ WHERE status IN ('human-approved', 'human-edited')
1094
+ ORDER BY npc, team, directory_path, created_at
1095
+ """
1096
+ rows = self._fetch_all(stmt, {})
1097
+
1098
+ from collections import defaultdict
1099
+ memories_by_scope = defaultdict(list)
1100
+ for row in rows:
1101
+ statement = row.get('final_memory') or row.get('initial_memory')
1102
+ scope_key = (
1103
+ row.get('npc') or 'default',
1104
+ row.get('team') or 'global_team',
1105
+ row.get('directory_path') or os.getcwd()
1106
+ )
1107
+ memories_by_scope[scope_key].append({
1108
+ 'id': row.get('id'),
1109
+ 'statement': statement,
1110
+ 'source_text': '',
1111
+ 'type': 'explicit',
1112
+ 'generation': 0
1113
+ })
1114
+ return dict(memories_by_scope)
1115
+
1054
1116
  def add_attachment(self, message_id, name, attachment_type, data, size, file_path=None):
1055
1117
  timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1056
1118
  stmt = """
@@ -1239,7 +1301,7 @@ class CommandHistory:
1239
1301
  stmt = """
1240
1302
  SELECT id, message_id, timestamp, role, content, conversation_id,
1241
1303
  directory_path, model, provider, npc, team,
1242
- reasoning_content, tool_calls, tool_results
1304
+ reasoning_content, tool_calls, tool_results, parent_message_id, params
1243
1305
  FROM conversation_history WHERE conversation_id = :conversation_id
1244
1306
  ORDER BY timestamp ASC
1245
1307
  """
@@ -1260,6 +1322,11 @@ class CommandHistory:
1260
1322
  message_dict["tool_results"] = json.loads(message_dict["tool_results"])
1261
1323
  except (json.JSONDecodeError, TypeError):
1262
1324
  pass
1325
+ if message_dict.get("params"):
1326
+ try:
1327
+ message_dict["params"] = json.loads(message_dict["params"])
1328
+ except (json.JSONDecodeError, TypeError):
1329
+ pass
1263
1330
  return results
1264
1331
 
1265
1332
  def get_npc_conversation_stats(self, start_date=None, end_date=None) -> pd.DataFrame:
@@ -1468,10 +1535,15 @@ def save_conversation_message(
1468
1535
  skip_if_exists: bool = True,
1469
1536
  device_id: str = None,
1470
1537
  device_name: str = None,
1538
+ gen_params: Dict = None,
1539
+ input_tokens: int = None,
1540
+ output_tokens: int = None,
1541
+ cost: float = None,
1471
1542
  ):
1472
1543
  """
1473
1544
  Saves a conversation message linked to a conversation ID with optional attachments.
1474
- Now also supports reasoning_content, tool_calls, tool_results, and parent_message_id for broadcast grouping.
1545
+ Now also supports reasoning_content, tool_calls, tool_results, parent_message_id for broadcast grouping,
1546
+ and gen_params for temperature, top_p, top_k, max_tokens, etc.
1475
1547
  If skip_if_exists is True and message_id already exists, skip saving to prevent duplicates.
1476
1548
  """
1477
1549
  if wd is None:
@@ -1504,7 +1576,11 @@ def save_conversation_message(
1504
1576
  tool_results=tool_results,
1505
1577
  parent_message_id=parent_message_id,
1506
1578
  device_id=device_id,
1507
- device_name=device_name)
1579
+ device_name=device_name,
1580
+ gen_params=gen_params,
1581
+ input_tokens=input_tokens,
1582
+ output_tokens=output_tokens,
1583
+ cost=cost)
1508
1584
  def retrieve_last_conversation(
1509
1585
  command_history: CommandHistory, conversation_id: str
1510
1586
  ) -> str: