-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathbenthic-bot.py
More file actions
7081 lines (6400 loc) · 293 KB
/
Copy pathbenthic-bot.py
File metadata and controls
7081 lines (6400 loc) · 293 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Benthic Bot — Telegram chat agent sharing identity with the LN Agent.
Same brain as the ln-agent Benthic: Opus with full tool access, shared SQLite DB
for memory, same personality. Responds in the Leviathan Agents group to both
humans and bots.
Loop prevention:
- Rate limit: max 1 reply per 5 seconds per sender
- Max interaction depth: 5 replies in a thread before stopping
- Dedup: tracks responded message IDs
"""
import hashlib
import json
import logging
import logging.handlers
import os
import re
import shutil
import sqlite3
import subprocess
import sys
import tempfile
import threading
import time
import unicodedata
import urllib.request
import uuid
import weakref
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import asdict, dataclass, replace
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Mapping
from urllib.parse import parse_qs, urlencode, urlparse
from prompt_loader import load_prompt
from providers import ProviderResult
from reply_grounding import GroundingLimits
# ─── Configuration ───────────────────────────────────────────────────────────
BASE_DIR = Path(__file__).parent
SOUL_FILE = BASE_DIR / "SOUL.md"
BOT_TOKEN = os.environ.get("BOT_TOKEN")
if not BOT_TOKEN:
_token_path = Path(os.environ.get("BOT_TOKEN_FILE", "~/.claude/.ln-bot-token")).expanduser()
if _token_path.exists():
BOT_TOKEN = _token_path.read_text().strip()
if not BOT_TOKEN:
sys.exit("ERROR: BOT_TOKEN env var or BOT_TOKEN_FILE path is required")
CLAUDE_BIN = os.environ.get("CLAUDE_BIN",
shutil.which("claude") or str(Path("~/.local/bin/claude").expanduser()))
# Wallet key for LN API auth (agent-chat relay) — same key as ln-agent
WALLET_KEY = os.environ.get("WALLET_PRIVATE_KEY", "").strip()
if not WALLET_KEY:
_wk_path = Path(os.environ.get("WALLET_KEY_FILE", "~/.claude/.ln-wallet-key")).expanduser()
if _wk_path.exists():
WALLET_KEY = _wk_path.read_text().strip()
LN_API = os.environ.get("LN_API", "https://api.leviathannews.xyz/api/v1")
def _resolve_codex_bin() -> str:
"""Resolve Codex binary even when PM2/login shells do not preload the NVM path."""
found = shutil.which("codex")
if found:
return found
candidates = sorted(Path("~/.nvm/versions/node").expanduser().glob("*/bin/codex"))
if candidates:
return str(candidates[-1])
return "codex"
def _resolve_pm2_bin() -> str:
"""Resolve PM2 from PATH or the newest NVM node install used by the runtime."""
found = shutil.which("pm2")
if found:
return found
candidates = sorted(Path("~/.nvm/versions/node").expanduser().glob("*/bin/pm2"))
if candidates:
return str(candidates[-1])
return "pm2"
CODEX_BIN = os.environ.get("CODEX_BIN", _resolve_codex_bin())
CODEX_MODEL = os.environ.get("CODEX_MODEL", "gpt-5.6-sol")
CODEX_EFFORT = os.environ.get("CODEX_EFFORT", "xhigh")
CODEX_CLASSIFY_MODEL = os.environ.get(
"CODEX_CLASSIFY_MODEL", "gpt-5.6-terra"
)
CLAUDE_LIMIT_COOLDOWN = int(os.environ.get("CLAUDE_LIMIT_COOLDOWN", str(6 * 60 * 60)))
# Agent install directory — used to construct path-restricted tool allowlists
# below. Defaults to BASE_DIR (the file's own directory) so the bot works out
# of the box; override AGENT_DIR if you run the bot from a different location.
AGENT_DIR = os.environ.get("AGENT_DIR", str(BASE_DIR))
# Agent identity — drives prompt templating and self-mention detection.
# AGENT_NAME is the display name; BOT_USERNAME is the Telegram bot's @handle.
AGENT_NAME = os.environ.get("AGENT_NAME", "Agent")
BOT_USERNAME = os.environ.get("BOT_USERNAME", "").lower()
# Shared DB with ln-agent — gives Benthic Bot access to posted articles,
# comments, votes, and conversation history
DB_FILE = Path(
os.environ.get("BENTHIC_DB", str(BASE_DIR / "agent.db"))
).expanduser().resolve()
# Anchor to the agent.db directory (absolute, cwd-independent) so it ALWAYS matches the
# path bin/benthic-build reads. Deriving from __file__ was brittle: the bot is launched as
# `python3 benthic-bot.py`, so __file__ is relative and resolve() depended on the cwd at
# import time — a restart from a different cwd scattered the route file (seen: a stray
# ~/.build-route.json) while benthic-build kept reading the ln-agent path.
_BUILD_ROUTE_FILE = Path(os.environ.get("BENTHIC_DB", str(DB_FILE))).expanduser().resolve().parent / ".build-route.json"
_BENTHIC_BUILD_BIN = str(Path(AGENT_DIR) / "bin" / "benthic-build")
_PM2_BIN = os.environ.get("PM2_BIN", _resolve_pm2_bin())
_GITHUB_CLIENT_BIN = os.environ.get("GITHUB_CLIENT_BIN", str(BASE_DIR / "github_client.sh"))
_BUILD_REPO_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,40}$")
_BUILD_TASK_RE = re.compile(r"^[1-9][0-9]*$") # benthic-build task IDs are integer rowids
_BUILD_BLOCK_RE = re.compile(r"\[BUILD:([^\]\n]+)\]\s*(.*?)\s*\[/BUILD\]\s*", re.DOTALL)
_BUILD_CANCEL_RE = re.compile(r"^[ \t]*\[BUILD-CANCEL:([^\]\s]+)\][ \t]*(?:\n|$)", re.MULTILINE)
_BUILD_STATUS_RE = re.compile(r"^[ \t]*\[BUILD-STATUS:([^\]\s]+)\][ \t]*(?:\n|$)", re.MULTILINE)
_PM2_ALLOWED_PROCS = {
"ln-agent", "benthic-bot", "benthic-builder",
}
_PM2_LOGS_RE = re.compile(r"^[ \t]*\[PM2-LOGS:([^\]\n]+)\][ \t]*(?:\n|$)", re.MULTILINE)
_PM2_LIST_RE = re.compile(r"^[ \t]*\[PM2-LIST\][ \t]*(?:\n|$)", re.MULTILINE)
_PM2_SHOW_RE = re.compile(r"^[ \t]*\[PM2-SHOW:([^\]\s]+)\][ \t]*(?:\n|$)", re.MULTILINE)
_PM2_LINES_RE = re.compile(r"^[0-9]+$")
_PM2_DEFAULT_LINES = 40
_PM2_MAX_LINES = 200
_PM2_OUTPUT_MAX_CHARS = 12000
RUN_SANDBOX_SCRIPT = os.environ.get(
"RUN_SANDBOX_SCRIPT", str(BASE_DIR / "sandbox" / "run-sandbox.sh"))
_SANDBOX_MAX_CODE_BYTES = 8192
_SANDBOX_MAX_OUTPUT_BYTES = 8192
_SANDBOX_OUTER_TIMEOUT_SECONDS = 135
_SANDBOX_INNER_TIMEOUT_SECONDS = 120
@dataclass(frozen=True)
class SandboxRunResult:
"""Bounded outcome returned by the trusted sandbox runtime."""
status: str
output: str = ""
returncode: int | None = None
duration_seconds: float = 0.0
_SANDBOX_OPEN_LINE_RE = re.compile(r"^[ \t]*\[SANDBOX\][ \t]*$", re.I)
_SANDBOX_CLOSE_LINE_RE = re.compile(r"^[ \t]*\[/SANDBOX\][ \t]*$", re.I)
_SANDBOX_TAG_RE = re.compile(r"\[/?SANDBOX(?:[^\]\r\n]*)\]", re.I)
_SANDBOX_PARTIAL_OPEN_LINE_RE = re.compile(
r"^[ \t]*\[SANDBOX[^\]\r\n]*$",
re.I,
)
_SANDBOX_PARTIAL_CLOSE_LINE_RE = re.compile(
r"^[ \t]*\[/SANDBOX[^\]\r\n]*$",
re.I,
)
_GROUP_CONTROL_RE = re.compile(
r"(?im)^[ \t]*\[GROUP(?::\d+)?\][ \t]*(?:\n|$)")
_SANDBOX_INTENT_RE = re.compile(
r"\b(?:prices?|market\s+cap|circulating\s+supply|total\s+supply|supply|"
r"volumes?|tvl|apr|apy|wallet|addresses?|balances?|holders?|tokens?|"
r"contracts?|transactions?|tx|gas|blocks?|chains?|on[- ]?chain|"
r"calculat(?:e|ion)|comput(?:e|ation)|data[ -]?analysis|python|"
r"sandbox|how\s+much)\b",
re.I,
)
_SANDBOX_LIVE_INTENT_RE = re.compile(
r"(?:\b(?:current|live|latest|now|right\s+now)\b.{0,48}"
r"\b(?:data|value|number|rate|btc|bitcoin|eth|ethereum|crypto|coin|token)\b|"
r"\b(?:data|value|number|rate|btc|bitcoin|eth|ethereum|crypto|coin|token)\b"
r".{0,48}\b(?:current|live|latest|now|right\s+now)\b)",
re.I,
)
_SANDBOX_CHART_DATA_SUBJECT = (
r"(?:btc|bitcoin|eth|ethereum|prices?|volumes?|tvl|balances?|holders?|"
r"(?:total\s+|circulating\s+)?supply|data|values?|time[ -]?series|"
r"price[ -]?history)"
)
_SANDBOX_CHART_INTENT_RE = re.compile(
r"(?:\b(?:make|create|generate|draw|build|render|produce)\s+"
r"(?:me\s+)?an?\s+(?:chart|plot|graph)\b|"
r"\bshow\s+(?:me\s+)?an?\s+(?:chart|plot|graph)\b|"
r"(?:(?:^|[.!?]\s+)(?:please\s+)?|"
r"\b(?:please\s+|(?:can|could|would|will)\s+you\s+))"
r"(?:chart|plot)\s+(?:(?:the|my|our|this|these)\s+)?"
+ _SANDBOX_CHART_DATA_SUBJECT
+ r"\b)",
re.I,
)
_SANDBOX_LOCK = threading.Lock()
# Narrow to explicit process-diagnostics wording. Generic words (check/status/
# running) used to match routine operator chatter ("check this URL", "what's the
# status?"), letting an injected [PM2-LOGS:...] directive execute — PR #1 finding.
_PM2_INTENT_RE = re.compile(
r"\b(pm2|logs?|process(es)?|diagnos\w*|crash\w*|restart\w*)\b", re.I)
_GH_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_GH_NUMBER_RE = re.compile(r"^[0-9]+$")
_GH_REF_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
_GH_ISSUE_CREATE_RE = re.compile(
r"^[ \t]*\[GH:issue create\s+([^\s\]]+)\s+\|\|\s*(.*?)\s*\|\|\s*(.*?)\][ \t]*(?:\n|$)",
re.MULTILINE | re.DOTALL)
_GH_ISSUE_COMMENT_RE = re.compile(
r"^[ \t]*\[GH:issue comment\s+([^\s\]]+)\s+([^\s\]]+)\s+\|\|\s*(.*?)\][ \t]*(?:\n|$)",
re.MULTILINE | re.DOTALL)
_GH_PR_CREATE_RE = re.compile(
r"^[ \t]*\[GH:pr create\s+([^\s\]]+)\s+\|\|\s*(.*?)\s*\|\|\s*(.*?)\s*\|\|\s*(.*?)\s*\|\|\s*(.*?)\][ \t]*(?:\n|$)",
re.MULTILINE | re.DOTALL)
_GH_PR_COMMENT_RE = re.compile(
r"^[ \t]*\[GH:pr comment\s+([^\s\]]+)\s+([^\s\]]+)\s+\|\|\s*(.*?)\][ \t]*(?:\n|$)",
re.MULTILINE | re.DOTALL)
# Require GitHub-specific wording. Generic "open (a|an)" / "file (a|an)" used to
# match "open an article" etc., letting an injected [GH:...] directive run a real
# github_client.sh --operator write — PR #1 finding. Verbs only count when paired
# with an issue/PR/bug/repo noun.
_GH_INTENT_RE = re.compile(
r"(?:\bgithub\b|\bissues?\b|\bpull request\b|\bpr\b|"
r"\bcomment on (?:the )?(?:issue|pr|pull request)\b|"
r"\b(?:open|file|create)\s+(?:a |an |the )?(?:issue|pr|pull request|bug|repo)\b)",
re.I)
# Deterministic build authorization: a [BUILD*] directive only executes when the
# operator's OWN message expresses build/cancel/status intent (or is a confirmation).
# A directive emitted purely from conversation/fetched-content context (possible
# prompt injection) is treated as inert and stripped without executing.
# Per-directive-type intent gates. START is high-stakes (spawns a full-access
# builder + github push), so it requires explicit build-CREATION intent — "status"
# or "build on that" must NOT authorize a START. cancel/status are low-stakes and
# accept manage intent. A bare confirmation ("yes"/"go") authorizes either (operator
# approving a proposal); that residual is accepted (a nonce-based confirm is the
# stronger future hardening).
_BUILD_START_INTENT_RE = re.compile(
r"(?:\bscaffold\b|\bspin[ -]?up\b|\bstand[ -]?up\b|\bship\b|"
r"\bbuild (?:me|a|an|out|the|this)\b|\bmake me (?:a|an)\b|"
r"\bcreate (?:a|an|the)\b|\bset up (?:a|an)\b)", re.I)
_BUILD_MANAGE_INTENT_RE = re.compile(
r"\b(?:cancel|abort|stop|kill|status|progress|done|finished)\b", re.I)
_BUILD_CONFIRM_RE = re.compile(
r"^\s*(?:yes|yep|yeah|yup|go|go ahead|do it|ship it|send it|proceed|approved?|ok|okay|sure|confirm(?:ed)?)\b[\s.!]*$",
re.I)
def _msg_text(msg: dict) -> str:
"""The operator's own inbound message text (not LLM output) for the intent gate."""
return (msg.get("text") or msg.get("caption") or "").strip()
# Groups where the agent responds to both humans and bots.
# Primary chat the bot lives in. REQUIRED — the bot will refuse to start
# without it (most response logic keys off this group).
AGENTS_GROUP_ID = int(os.environ["AGENTS_GROUP_ID"])
# Additional Telegram group/chat IDs the bot is permitted to respond in.
# JSON array of integers. The primary AGENTS_GROUP_ID is always included.
ALLOWED_GROUPS = {AGENTS_GROUP_ID} | set(json.loads(os.environ.get("ALLOWED_GROUPS", "[]")))
# Tool allowlists — tiered by sender authorization level.
# Regular users get read-only research tools. Operators get diagnostic access.
TOOLS_DEFAULT = (
"WebSearch,WebFetch,Read,Grep,Glob,"
f"Bash({AGENT_DIR}/github_client.sh issue *),"
f"Bash({AGENT_DIR}/github_client.sh pr *)"
)
TOOLS_OPERATOR = ",".join([
"WebSearch", "WebFetch", "Read", "Grep", "Glob",
# Path-restricted Bash — only agent directory, not ~/.claude/ secrets.
# No sqlite3 — .shell command allows arbitrary code execution.
# Use Read tool for agent.db inspection instead.
f"Bash(tail {AGENT_DIR}/*.py)",
f"Bash(tail {AGENT_DIR}/*.log)",
f"Bash(head {AGENT_DIR}/*.py)",
f"Bash(head {AGENT_DIR}/*.log)",
f"Bash(cat {AGENT_DIR}/*.py)",
f"Bash(cat {AGENT_DIR}/*.log)",
f"Bash(ls {AGENT_DIR})",
f"Bash(wc {AGENT_DIR}/*.py)",
"Bash(pm2 logs*)", "Bash(pm2 list*)", "Bash(pm2 show*)",
# GitHub client — write-only access to allowlisted public repos.
# Operator gets full access including allowlist management.
f"Bash({AGENT_DIR}/github_client.sh*)",
])
# Authorized operators — checked by immutable Telegram user ID (not username).
# Usernames can be changed by anyone; user IDs are permanent and unforgeable.
# JSON array of integers. Operators get the path-restricted Bash allowlist above.
OPERATOR_IDS = set(json.loads(os.environ.get("OPERATOR_IDS", "[]")))
# Rate limiting
MIN_REPLY_INTERVAL = 5 # seconds between replies to the same sender
MAX_THREAD_DEPTH = 5 # stop replying after this many exchanges in a thread
POLL_TIMEOUT = 30 # long poll timeout in seconds
MARKET_CHECK_INTERVAL = int(os.environ.get("MARKET_CHECK_INTERVAL", "1800")) # 30 min
# Breaking-news reactions from the live-news WS queue (ws_events, written by
# ln-agent's listener). Hard-gated: interval cap, freshness window, own-article
# skip, classification gate — most events must die before any full-brain call.
ENABLE_WS_BREAKING_NEWS = os.environ.get("ENABLE_WS_BREAKING_NEWS", "1") == "1"
BREAKING_NEWS_MIN_INTERVAL = int(os.environ.get("BREAKING_NEWS_MIN_INTERVAL", "3600"))
BREAKING_NEWS_MAX_AGE = int(os.environ.get("BREAKING_NEWS_MAX_AGE", "900"))
WS_NEWS_CHAT_ID = int(os.environ.get("WS_NEWS_CHAT_ID", str(AGENTS_GROUP_ID)))
# Hard cap on per-trade buy amount during autonomous market checks.
# Defends against prompt injection via WebFetch content: even if a poisoned page
# convinces the model to emit a trade, amount is bounded. Matches the prompt cap.
MAX_MARKET_BUY_SQUID = int(os.environ.get("MAX_MARKET_BUY_SQUID", "500"))
# ─── Prompt Injection Defense (same as ln-agent.py) ─────────────────────────
# Leak patterns tuned for chat context — more specific than ln-agent's patterns
# because conversational phrases like "let me check" and "i need to" are natural in chat.
# Skipped for operator messages (operators need to see technical details).
LEAK_PATTERNS = [
"enough context", "i have enough context",
"webfetch", "websearch", "twitter-explorer",
"here's the comment", "here is the comment",
"here's the reply", "here is the reply",
"here's the benthic reply", "here is the benthic reply",
"here's my response:", "here is my response:",
"now i have the numbers", "now i have enough",
"let me write the response",
"let me search twitter", "let me search the web", "let me use webfetch",
]
# Structural markers that indicate raw Claude tool-call XML leaking into output.
# These are NEVER valid in a chat response, even for operators.
STRUCTURAL_LEAK_PATTERNS = ["tool_use", "tool_result", "function_call"]
# Identity/meta-output phrases from provider harness confusion. These are tight
# substrings so normal operator diagnostics about infrastructure are not blocked.
IDENTITY_LEAK_PATTERNS = [
"interactive claude code session",
"i'm the interactive",
"i am the interactive",
"not the live bot",
"the live bot process",
"group-reply decision prompt",
"decision prompt for an incoming",
"harness preamble",
"deferred-tool list",
"personal mcp servers",
"i'm claude",
"i am claude",
"as an ai language model",
]
# Characters that may wrap a bare control token when a model formats output.
# Stripping only edge characters keeps real prose such as "I'll pass" intact.
_CONTROL_TOKEN_EDGE_CHARS = "*`\"' .!:-"
_CONTROL_TOKENS = frozenset({"SKIP", "PASS"})
# [37] "Affirmative SKIP" guard: a model that wraps the bare token in a short
# affirmation ("Affirmative SKIP") or appends a same-line reason ("SKIP — …")
# defeats the token-first checks in _is_control_token_only. In a SHORT response,
# a STANDALONE UPPERCASE SKIP/PASS is never valid prose (the prompts forbid the
# bare word), so treat it as the control token. Uppercase + a length cap preserve
# legitimate lowercase "pass on that" and longer explanations that merely name it.
_CONTROL_TOKEN_MAX_DISGUISE_LEN = 160
_UPPER_CONTROL_TOKEN_RE = re.compile(r"\b(?:SKIP|PASS)\b")
# Bot commands Benthic is authorized to send. All other /<cmd>@<bot> patterns
# in output are neutralized to prevent prompt injection via fetched content.
# The attack: hidden div in a webpage contains /tip@lnn_headline_bot — when the
# LLM quotes or follows the injection, the command lands in the group chat and
# lnn_headline_bot processes it. Defense: escape unauthorized slashes.
AUTHORIZED_BOT_COMMANDS = frozenset([
"/buy@lnn_headline_bot",
"/sell@lnn_headline_bot",
"/position@lnn_headline_bot",
"/markets@lnn_headline_bot",
"/tip@lnn_headline_bot",
])
# Dangerous plain commands (without @botname) that Benthic should NEVER output.
# These are escaped even without @bot suffix since Telegram groups with privacy
# mode off process plain /commands. Derived from actual lnn_headline_bot command
# surface (squid-bot/bot/webhook_processor.py). Checked with startswith to catch
# underscore-suffixed variants like /edittext_123, /tag_456.
BLOCKED_PLAIN_COMMANDS = frozenset([
# Financial — drain SQUID or modify balances (tip is authorized for operator requests)
"/undo", "/decline", "/vault", "/claim", "/repay",
# Identity — link wallet or email
"/ethereum", "/confirm", "/sign", "/email", "/register",
# Content — post/edit/moderate articles
"/post", "/edit", "/tag", "/schedule", "/suggest_headline",
"/inkling", "/yap", "/chat", "/murder", "/approve",
# Trading — plain forms without @bot (lnn_headline_bot strips @bot suffix
# via cmd.split("@")[0], so plain /buy works too). Benthic always uses
# the @bot form for legitimate trades, so plain forms are injection only.
"/buy", "/sell", "/position", "/leaderboard",
# Admin — market/moderation (staff-gated but still shouldn't appear in output)
"/market", "/resolve", "/freeze", "/cancelmarket", "/throttle",
# Bot control
"/start", "/help", "/prompt", "/retry", "/update_x_queue",
# Generic dangerous patterns
"/send", "/forward", "/transfer", "/withdraw", "/deposit",
])
INJECTION_OUTPUT_PATTERNS = [
"ignore previous", "ignore all", "ignore above", "ignore the above",
"disregard previous", "disregard all", "disregard above",
"new instructions", "system prompt", "my instructions",
"as an ai", "as a language model", "i'm an ai",
# Credential filenames — if the LLM emits these it may be leaking paths to secrets.
# Note: the agent's public wallet-address prefix is NOT listed — the agent
# legitimately shares its public address when asked. Private key leaks have
# their own detection via _wallet_key_prefix.
"ln-wallet", "telegram-creds", "agent_session", "ln-bot-token",
"my wallet key is", "my private key is", "my api key is",
]
# Wallet key prefix — stored separately for the universal output gate that runs
# even for operators. The injection patterns are bypassed for operators, but
# the private key must NEVER appear in ANY output.
_wallet_key_prefix = ""
def _add_secret_patterns():
"""Add wallet key and bot token prefixes to injection detection at runtime."""
global _wallet_key_prefix
# Wallet key prefix — used by both injection patterns AND the universal key gate
try:
key_path = Path(os.environ.get("WALLET_KEY_FILE", "~/.claude/.ln-wallet-key")).expanduser()
key = key_path.read_text().strip()
if len(key) >= 12:
_wallet_key_prefix = key[:12].lower()
INJECTION_OUTPUT_PATTERNS.append(_wallet_key_prefix)
except FileNotFoundError:
log.info("No wallet key file — private key output gate disabled (dev mode)")
except Exception as e:
log.warning(f"Failed to read wallet key for output gate: {e} — gate DISABLED")
# Bot token prefix — detect if the LLM leaks the token
if BOT_TOKEN and len(BOT_TOKEN) >= 12:
INJECTION_OUTPUT_PATTERNS.append(BOT_TOKEN[:12].lower())
def sanitize_untrusted(text: str, max_len: int = 500) -> str:
"""Sanitize untrusted user input before injecting into prompts.
Strips control chars, neutralizes XML tags, collapses separator patterns."""
if not text:
return ""
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
# Strip unpaired UTF-16 surrogates — they produce invalid JSON for Claude API
text = text.encode('utf-8', errors='surrogatepass').decode('utf-8', errors='ignore')
text = text[:max_len]
text = text.replace("<", "\uff1c").replace(">", "\uff1e")
text = re.sub(r'-{4,}', '---', text)
text = re.sub(r'={4,}', '===', text)
return text.strip()
def check_output_for_injection(text: str, context: str = "") -> bool:
"""Check if output shows signs of prompt injection. Returns True if compromised."""
if not text:
return False
text_lower = unicodedata.normalize("NFKD", text).lower()
for pattern in INJECTION_OUTPUT_PATTERNS:
if pattern in text_lower:
log.warning(f"INJECTION DETECTED in {context}: matched '{pattern}' — output: {text[:200]}")
return True
return False
def check_leak_patterns(text: str) -> bool:
"""Check if output contains Claude internal monologue. Returns True if leaked."""
if not text:
return False
text_lower = unicodedata.normalize("NFKD", text).lower()
if any(p in text_lower for p in LEAK_PATTERNS):
log.warning(f"Rejected leaked output: {text[:80]}")
return True
return False
def check_structural_leaks(text: str) -> bool:
"""Check if output contains raw tool-call XML/JSON blocks. Always invalid."""
if not text:
return False
text_lower = unicodedata.normalize("NFKD", text).lower()
if any(p in text_lower for p in STRUCTURAL_LEAK_PATTERNS):
log.warning(f"Rejected structural leak: {text[:80]}")
return True
return False
def check_identity_leak(text: str) -> bool:
"""Check if output describes the provider harness instead of Benthic."""
if not text:
return False
text_lower = unicodedata.normalize("NFKD", text).lower()
if any(p in text_lower for p in IDENTITY_LEAK_PATTERNS):
log.warning(f"Rejected identity leak: {text[:80]}")
return True
return False
def _is_control_token_only(text: str) -> bool:
"""Return True when a response is effectively only SKIP/PASS.
The full text and first non-empty line are checked separately. This catches
models that lead with the required control token and then add explanation,
while preserving legitimate prose that merely contains "skip" or "pass".
"""
if not text or not text.strip():
return False
def _normalize_token(candidate: str) -> str:
return candidate.strip().strip(_CONTROL_TOKEN_EDGE_CHARS).upper()
first_line = ""
for line in text.splitlines():
if line.strip():
first_line = line
break
whole = _normalize_token(text)
leading = _normalize_token(first_line)
if whole in _CONTROL_TOKENS or leading in _CONTROL_TOKENS:
return True
# [37] affirmation-wrapped / same-line-reason forms (see constants above).
if (len(text) <= _CONTROL_TOKEN_MAX_DISGUISE_LEN
and _UPPER_CONTROL_TOKEN_RE.search(text)):
return True
return False
def validate_url(url: str) -> str | None:
"""Validate and sanitize a URL returned by the LLM before using it.
Rejects control chars, spaces, oversized URLs, non-HTTP schemes, and <> brackets.
Matches ln-agent.py's validate_url for security parity."""
if not url:
return None
url = url.strip().strip('"\'')
url = url.replace("<", "").replace(">", "")
if len(url) > 2048:
log.warning(f"Rejected oversized URL ({len(url)} chars): {url[:100]}...")
return None
if any(c in url for c in '\n\r\t\x00'):
log.warning(f"Rejected URL with control characters: {url[:100]}")
return None
if ' ' in url:
log.warning(f"Rejected URL with spaces: {url[:100]}")
return None
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return None
if not parsed.netloc:
return None
return url
except Exception:
return None
def sanitize_bot_commands(text: str) -> str:
"""Neutralize unauthorized bot commands in output to prevent prompt injection.
Two-layer defense:
1. /<cmd>@<bot> patterns — escapes any not in AUTHORIZED_BOT_COMMANDS
2. Plain /<cmd> patterns — escapes any in BLOCKED_PLAIN_COMMANDS
Replaces '/' with fullwidth solidus '/' which preserves readability but
breaks Telegram's command parser. NFKD-normalized to defeat homoglyph bypass.
Runs on ALL output including operators — injected commands are never legitimate."""
if not text:
return text
# NFKD normalize to defeat Unicode homoglyph bypass (e.g., fullwidth @, division slash)
normalized = unicodedata.normalize("NFKD", text)
# Layer 1: /<cmd>@<bot> patterns — only authorized commands pass through
def _escape_at_command(match):
cmd_text = match.group(0).lower()
for allowed in AUTHORIZED_BOT_COMMANDS:
if cmd_text.startswith(allowed):
return match.group(0) # authorized — pass through
log.warning(f"NEUTRALIZED bot command in output: {match.group(0)}")
return "\uff0f" + match.group(0)[1:] # / (fullwidth solidus)
normalized = re.sub(r'/(\w+)@(\w+)', _escape_at_command, normalized)
# Layer 2: Plain /commands without @bot — block dangerous commands that
# Telegram groups with privacy mode off would still process.
# Only match at word boundaries (start of line or after whitespace).
# Skip matches followed by @ — those are /<cmd>@<bot> patterns already
# handled by Layer 1. Without this check, /markets@lnn_headline_bot would
# be blocked because "markets".startswith("market") is True.
def _escape_plain_command(match):
# Skip if this is part of a /<cmd>@<bot> pattern (already handled by Layer 1)
end_pos = match.end()
if end_pos < len(normalized) and normalized[end_pos] == '@':
return match.group(0)
cmd = match.group(1).lower()
for blocked in BLOCKED_PLAIN_COMMANDS:
# startswith — catches underscore-suffixed variants like /edittext_123,
# /editsource_456, /tag_789 which are real LN bot command patterns
if cmd.startswith(blocked[1:]): # compare without leading /
log.warning(f"NEUTRALIZED plain command in output: {match.group(0)}")
return match.group(0).replace("/", "\uff0f", 1)
return match.group(0) # not in blocklist — pass through
normalized = re.sub(r'(?:^|(?<=\s))/(\w+)', _escape_plain_command, normalized, flags=re.MULTILINE)
return normalized
# ─── Logging ─────────────────────────────────────────────────────────────────
LOG_FILE = Path(
os.environ.get("BENTHIC_LOG_FILE", str(BASE_DIR / "benthic.log"))
).expanduser().resolve()
_log_fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
# File handler — 10MB rotation, 5 backups (same as ln-agent)
_file_handler = logging.handlers.RotatingFileHandler(
LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8")
_file_handler.setFormatter(_log_fmt)
# Console handler — also goes to PM2 stdout
_console_handler = logging.StreamHandler(sys.stdout)
_console_handler.setFormatter(_log_fmt)
logging.basicConfig(level=logging.INFO, handlers=[_console_handler, _file_handler])
log = logging.getLogger("benthic-bot")
_GROUNDING_INT_BOUNDS = {
"GROUNDING_MAX_BACKGROUND_SOURCES": (3, 0, 5),
"GROUNDING_MAX_FOCAL_URLS": (8, 1, 16),
"GROUNDING_MAX_SOURCE_REQUESTS": (10, 1, 20),
"GROUNDING_MAX_SOURCE_BYTES": (2_097_152, 65_536, 8_388_608),
# 960s preserves 30s each for fallback/fetch after Sol's 900s window.
"GROUNDING_SOURCE_COLLECTION_TIMEOUT": (960, 960, 1_800),
"GROUNDING_MAX_EVIDENCE_BYTES": (24_000, 4_096, 64_000),
"GROUNDING_FETCH_TIMEOUT": (15, 2, 30),
"GROUNDING_TRACE_RETENTION_DAYS": (14, 1, 30),
"PHOTO_REFERENCE_MAX_AGE": (1_800, 60, 86_400),
}
def _bounded_env_int(env, name, default, minimum, maximum):
"""Read one strict integer setting and keep it within its safe bounds."""
raw = env.get(name, str(default))
if (
type(raw) is not str
or len(raw) > 19
or re.fullmatch(r"-?[0-9]+", raw, flags=re.ASCII) is None
):
raise SystemExit(f"{name} must be an integer")
try:
value = int(raw)
except (OverflowError, ValueError) as exc:
raise SystemExit(f"{name} must be an integer") from exc
clamped = min(max(value, minimum), maximum)
if clamped != value:
log.warning(
"%s=%s outside [%s,%s]; clamped to %s",
name,
value,
minimum,
maximum,
clamped,
)
return clamped
def _load_engagement_timeout(env=None) -> int:
"""Load the pre-screen timeout while recovering safely from bad overrides."""
env = os.environ if env is None else env
try:
return _bounded_env_int(
env, "BENTHIC_ENGAGEMENT_TIMEOUT", 120, 30, 300
)
except SystemExit:
# A typo in this optional latency control must not take the bot offline.
log.warning(
"Invalid BENTHIC_ENGAGEMENT_TIMEOUT; using default 120 seconds"
)
return 120
def _load_bool01(env, name, default):
"""Read one fail-safe boolean setting encoded only as 0 or 1."""
raw = env.get(name, str(int(default)))
if type(raw) is not str or raw not in {"0", "1"}:
raise SystemExit(f"{name} must be 0 or 1")
return raw == "1"
def _load_grounding_limits(env=None):
"""Load bounded reply-grounding controls from the supplied environment."""
env = os.environ if env is None else env
values = {
name: _bounded_env_int(env, name, *bounds)
for name, bounds in _GROUNDING_INT_BOUNDS.items()
}
return GroundingLimits(
max_background_sources=values["GROUNDING_MAX_BACKGROUND_SOURCES"],
max_focal_urls=values["GROUNDING_MAX_FOCAL_URLS"],
max_source_requests=values["GROUNDING_MAX_SOURCE_REQUESTS"],
max_source_bytes=values["GROUNDING_MAX_SOURCE_BYTES"],
source_collection_timeout=values[
"GROUNDING_SOURCE_COLLECTION_TIMEOUT"
],
max_evidence_bytes=values["GROUNDING_MAX_EVIDENCE_BYTES"],
fetch_timeout=values["GROUNDING_FETCH_TIMEOUT"],
trace_retention_days=values["GROUNDING_TRACE_RETENTION_DAYS"],
photo_reference_max_age=values["PHOTO_REFERENCE_MAX_AGE"],
)
GROUNDING_LIMITS = _load_grounding_limits()
ENGAGEMENT_TIMEOUT = _load_engagement_timeout()
ENABLE_REPLY_GROUNDING = _load_bool01(
os.environ, "ENABLE_REPLY_GROUNDING", True
)
PHOTO_REFERENCE_MAX_AGE = GROUNDING_LIMITS.photo_reference_max_age
log.info(
"Reply grounding enabled=%s max_background=%s max_focal=%s "
"max_source_requests=%s max_source_bytes=%s source_deadline=%s "
"max_evidence_bytes=%s fetch_timeout=%s trace_retention_days=%s "
"photo_reference_max_age=%s engagement_timeout=%s",
ENABLE_REPLY_GROUNDING,
GROUNDING_LIMITS.max_background_sources,
GROUNDING_LIMITS.max_focal_urls,
GROUNDING_LIMITS.max_source_requests,
GROUNDING_LIMITS.max_source_bytes,
GROUNDING_LIMITS.source_collection_timeout,
GROUNDING_LIMITS.max_evidence_bytes,
GROUNDING_LIMITS.fetch_timeout,
GROUNDING_LIMITS.trace_retention_days,
GROUNDING_LIMITS.photo_reference_max_age,
ENGAGEMENT_TIMEOUT,
)
def _write_build_route(chat_id: int, message_id, user_id) -> None:
"""Persist this operator turn's Telegram route for benthic-build.
The file is written with a temp file plus os.replace() so a concurrent
benthic-build process either reads the complete previous route or the
complete new route, never a partially written JSON payload.
"""
try:
tmp = _BUILD_ROUTE_FILE.with_suffix(".json.tmp")
tmp.write_text(json.dumps({
"chat_id": chat_id,
"message_id": message_id,
"user_id": user_id,
"written_at": time.time(),
}))
os.replace(tmp, _BUILD_ROUTE_FILE)
except Exception:
log.exception("failed to write build route file")
# Initialize secret patterns now that logging is available
_add_secret_patterns()
# ─── State ───────────────────────────────────────────────────────────────────
_last_reply_to: dict[int, float] = {}
_responded: set[int] = set()
_thread_depth: dict[int, int] = {} # msg_id -> depth counter
_msg_root: dict[int, int] = {} # msg_id -> root msg_id (for thread tracking)
_MAX_STATE_SIZE = 5000 # prune in-memory state to prevent slow leak
def _prune_set(s: set, keep: int = 2500) -> None:
"""Evict entries from a set of ints, keeping `keep` entries.
For sequential IDs (_responded, _api_responded), keeps the largest (newest)."""
if len(s) <= keep:
return
# Sort and keep the newest (highest) entries
newest = sorted(s)[-keep:]
s.clear()
s.update(newest)
def _prune_content_dedup(keep: int = 2500) -> None:
"""Drop content-dedup keys older than the TTL.
The content-dedup store is keyed by content hash and valued by the unix
timestamp when that content was last answered. Stale entries are removed
first so repeated short commands can be re-issued after the cross-path
duplicate window. If the map is still oversized, the most recent `keep`
entries are retained to cap memory use without discarding fresh dedup state.
"""
now = time.time()
for k in [k for k, ts in _content_responded.items() if now - ts > _CONTENT_DEDUP_TTL]:
del _content_responded[k]
if len(_content_responded) > _MAX_STATE_SIZE:
newest = sorted(_content_responded.items(), key=lambda kv: kv[1])[-keep:]
_content_responded.clear()
_content_responded.update(dict(newest))
_MAX_CHAT_ROWS = 10000 # max rows in chat_history table
_prune_counter = 0 # only prune DB every ~100 poll cycles
_MARKET_CHECK_FILE = BASE_DIR / ".last_market_check"
def _load_last_market_check() -> float:
try:
return float(_MARKET_CHECK_FILE.read_text().strip())
except (FileNotFoundError, ValueError, OSError):
return 0.0
_last_market_check = _load_last_market_check()
# Single-flight guard for background market checks; the worker releases it when
# the LLM/trading pass finishes, crashes, or exits early.
_market_check_lock = threading.Lock()
_state_lock = threading.RLock()
_PROC_POOL = ThreadPoolExecutor(max_workers=6, thread_name_prefix="msgproc")
_sender_locks: dict[tuple[int, int], threading.Lock] = {}
_sender_locks_guard = threading.Lock()
def _sender_lock_for(key: tuple[int, int]) -> threading.Lock:
"""Return the stable lock for one chat/sender processing lane.
Message workers acquire this per-sender lock before doing response work so
messages from the same sender are handled in order. The guard protects only
the dictionary that stores locks; the returned lock is held by the worker.
"""
with _sender_locks_guard:
lock = _sender_locks.get(key)
if lock is None:
lock = threading.Lock()
_sender_locks[key] = lock
return lock
# ─── Shared Memory (SQLite) ─────────────────────────────────────────────────
def _ensure_chat_table():
"""Create the chat_history table if it doesn't exist."""
conn = None
try:
conn = sqlite3.connect(str(DB_FILE), timeout=10)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("""CREATE TABLE IF NOT EXISTS chat_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
msg_id INTEGER NOT NULL,
chat_id INTEGER NOT NULL,
topic_id INTEGER,
reply_to_msg_id INTEGER,
sender_username TEXT,
sender_is_bot INTEGER DEFAULT 0,
text TEXT,
our_reply TEXT,
timestamp TEXT NOT NULL,
event_time TEXT,
UNIQUE(msg_id, chat_id)
)""")
# Schema inspection makes both migrations idempotent without masking
# unrelated SQLite failures.
columns = {
row[1] for row in conn.execute("PRAGMA table_info(chat_history)").fetchall()
}
if "topic_id" not in columns:
conn.execute("ALTER TABLE chat_history ADD COLUMN topic_id INTEGER")
log.info("Migrated chat_history: added topic_id column")
if "reply_to_msg_id" not in columns:
conn.execute("ALTER TABLE chat_history ADD COLUMN reply_to_msg_id INTEGER")
log.info("Migrated chat_history: added reply_to_msg_id column")
if "event_time" not in columns:
conn.execute("ALTER TABLE chat_history ADD COLUMN event_time TEXT")
log.info("Migrated chat_history: added event_time column")
# Track our own actions (commands sent via [GROUP], bets placed, etc.)
conn.execute("""CREATE TABLE IF NOT EXISTS own_actions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action_type TEXT NOT NULL,
action_text TEXT NOT NULL,
chat_id INTEGER NOT NULL,
timestamp TEXT NOT NULL
)""")
# Persistent memory — goals, people, stances, learnings, tasks
conn.execute("""CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category TEXT NOT NULL,
content TEXT NOT NULL,
timestamp TEXT NOT NULL
)""")
# Platform knowledge base — detailed reference material loaded on demand.
# Only relevant topics are injected into prompts (keyword-matched), so
# this can store much more detail than the identity prompt without
# consuming tokens on every call.
conn.execute("""CREATE TABLE IF NOT EXISTS knowledge (
topic TEXT PRIMARY KEY,
content TEXT NOT NULL,
keywords TEXT NOT NULL,
updated_at TEXT NOT NULL
)""")
# Build task queue — operator-driven async builds executed by the
# benthic-builder daemon. The bot writes rows here via bin/benthic-build;
# the daemon polls, runs Codex per task, and posts completions back.
conn.execute("""CREATE TABLE IF NOT EXISTS build_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
requested_by INTEGER NOT NULL DEFAULT 0,
chat_id INTEGER NOT NULL DEFAULT 0,
message_id INTEGER,
request_text TEXT,
brief TEXT NOT NULL,
repo_name TEXT NOT NULL,
notes TEXT,
status TEXT NOT NULL DEFAULT 'pending',
pid INTEGER,
work_dir TEXT,
log_path TEXT,
repo_url TEXT,
error TEXT,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL
)""")
# Live-news WS event queue — written by ln-agent's listener, read here
# for breaking-news reactions. Schema mirrors ln-agent.py AgentDB so
# deploy order between the two processes never matters.
conn.execute("""CREATE TABLE IF NOT EXISTS ws_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
news_id INTEGER NOT NULL,
slug TEXT,
headline TEXT,
date_posted TEXT,
origin TEXT,
raw TEXT,
received_at TEXT NOT NULL,
consumed_by_agent INTEGER NOT NULL DEFAULT 0,
consumed_by_bot INTEGER NOT NULL DEFAULT 0,
UNIQUE(news_id, event_type)
)""")
# Witnessed-photo index — file_ids of photos seen in chat (no download),
# so a later full-brain call can re-fetch and attach them on demand.
conn.execute("""CREATE TABLE IF NOT EXISTS seen_photos (
chat_id INTEGER NOT NULL,
topic_id INTEGER NOT NULL DEFAULT 0,
message_id INTEGER NOT NULL,
sender TEXT,
file_id TEXT NOT NULL,
file_size INTEGER,
seen_at TEXT NOT NULL,
PRIMARY KEY (chat_id, message_id)
)""")
# Witnessed text-document index. Only Telegram metadata is retained;
# document bodies remain ephemeral and are downloaded only on demand.
conn.execute("""CREATE TABLE IF NOT EXISTS seen_documents (
chat_id INTEGER NOT NULL,
topic_id INTEGER NOT NULL DEFAULT 0,
message_id INTEGER NOT NULL,
sender TEXT,
file_id TEXT NOT NULL,
file_name TEXT NOT NULL,
file_size INTEGER,
seen_at TEXT NOT NULL,
PRIMARY KEY (chat_id, message_id)
)""")
conn.execute("""CREATE TABLE IF NOT EXISTS reply_grounding_traces (
trace_id TEXT PRIMARY KEY,
chat_id INTEGER NOT NULL,
message_id INTEGER NOT NULL,
direct INTEGER NOT NULL,
mode TEXT NOT NULL,
focal_refs_json TEXT NOT NULL,
evidence_manifest TEXT NOT NULL,
composer_provider TEXT,
composer_model TEXT,
composer_effort TEXT,
composer_tier TEXT,
verifier_provider TEXT,
verifier_model TEXT,
verifier_effort TEXT,
verifier_tier TEXT,
verifier_result TEXT,
disposition TEXT NOT NULL,
failure_reason TEXT,
created_at TEXT NOT NULL
)""")
conn.commit()
except sqlite3.Error:
log.exception("Failed to create chat tables")
raise
except Exception as e:
log.warning(f"Failed to create chat tables: {e}")
finally:
if conn:
conn.close()
_ensure_chat_table()
@contextmanager
def _db(row_factory=False):
"""Context manager for SQLite operations. WAL is already set by _ensure_chat_table()
and persists on disk — no need to repeat per-operation. Handles connection cleanup."""
conn = sqlite3.connect(str(DB_FILE), timeout=10)
if row_factory:
conn.row_factory = sqlite3.Row
try:
yield conn
finally:
conn.close()
_MAX_GROUNDING_TRACE_ROWS = 5_000
_MAX_GROUNDING_TRACE_ITEMS = 64
_MAX_GROUNDING_TRACE_FOCAL_IDS = 16
_MAX_GROUNDING_TRACE_RECEIPTS = 4
_MAX_GROUNDING_TRACE_JSON_BYTES = 32_768
_MAX_GROUNDING_TRACE_TEXT_BYTES = 65_536