synapse-cli-agent 0.1.13__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.
- synapse/__init__.py +13 -0
- synapse/__main__.py +6 -0
- synapse/app/__init__.py +1 -0
- synapse/app/agent.py +492 -0
- synapse/app/agent_md.py +107 -0
- synapse/cli.py +750 -0
- synapse/commands/__init__.py +1 -0
- synapse/commands/compression.py +573 -0
- synapse/commands/helpers.py +22 -0
- synapse/commands/mcp.py +406 -0
- synapse/commands/model.py +173 -0
- synapse/commands/result.py +34 -0
- synapse/commands/sessions.py +443 -0
- synapse/commands/slash_cmds.py +521 -0
- synapse/commands/slash_complete.py +816 -0
- synapse/commands/theme.py +99 -0
- synapse/config.py +27 -0
- synapse/content/__init__.py +1 -0
- synapse/content/input_history.py +122 -0
- synapse/content/multimodal.py +733 -0
- synapse/content/prompts.py +249 -0
- synapse/content/skills_catalog.py +128 -0
- synapse/integrations/__init__.py +1 -0
- synapse/integrations/checkpoint_seed.py +281 -0
- synapse/integrations/codex_history.py +375 -0
- synapse/integrations/codex_import.py +393 -0
- synapse/integrations/codex_sessions.py +629 -0
- synapse/integrations/describe_image.py +370 -0
- synapse/integrations/http_clients.py +199 -0
- synapse/integrations/llm_openai_compat.py +90 -0
- synapse/integrations/llm_openai_websocket.py +187 -0
- synapse/integrations/mcp_client.py +646 -0
- synapse/integrations/vision_middleware.py +62 -0
- synapse/models/__init__.py +5 -0
- synapse/models/config.py +240 -0
- synapse/models/helpers.py +206 -0
- synapse/models/profile.py +59 -0
- synapse/models/registry.py +722 -0
- synapse/models_registry.py +7 -0
- synapse/observability/__init__.py +1 -0
- synapse/observability/startup_trace.py +127 -0
- synapse/runtime/__init__.py +1 -0
- synapse/runtime/async_runtime.py +176 -0
- synapse/runtime/backends.py +458 -0
- synapse/runtime/context_compact.py +249 -0
- synapse/runtime/execute_capture.py +48 -0
- synapse/runtime/fs_permissions.py +79 -0
- synapse/runtime/harness.py +57 -0
- synapse/runtime/hitl.py +197 -0
- synapse/runtime/interaction_ledger.py +82 -0
- synapse/runtime/middleware.py +802 -0
- synapse/runtime/model_request_compression_middleware.py +745 -0
- synapse/runtime/pathing.py +146 -0
- synapse/runtime/safety.py +184 -0
- synapse/runtime/steer.py +240 -0
- synapse/runtime/subagents.py +207 -0
- synapse/runtime/tool_ignore.py +221 -0
- synapse/runtime/tool_output_eval.py +118 -0
- synapse/runtime/tool_output_middleware.py +585 -0
- synapse/runtime/tool_output_usage_middleware.py +60 -0
- synapse/sessions/__init__.py +31 -0
- synapse/sessions/cancel_repair.py +208 -0
- synapse/sessions/session_recap.py +174 -0
- synapse/sessions/store.py +695 -0
- synapse/sessions/transcript.py +754 -0
- synapse/settings/__init__.py +5 -0
- synapse/settings/config_paths.py +184 -0
- synapse/settings/schema.py +464 -0
- synapse/tool_output/__init__.py +59 -0
- synapse/tool_output/detection.py +170 -0
- synapse/tool_output/metrics.py +32 -0
- synapse/tool_output/models.py +173 -0
- synapse/tool_output/pipeline.py +330 -0
- synapse/tool_output/repository.py +721 -0
- synapse/tool_output/transformers.py +648 -0
- synapse/tools/__init__.py +5 -0
- synapse/tools/session_tools.py +204 -0
- synapse/ui/__init__.py +10 -0
- synapse/ui/bottombar/__init__.py +73 -0
- synapse/ui/bottombar/components/__init__.py +143 -0
- synapse/ui/bottombar/components/key_hints.py +30 -0
- synapse/ui/bottombar/components/mcp.py +64 -0
- synapse/ui/bottombar/components/mode.py +24 -0
- synapse/ui/bottombar/components/model.py +28 -0
- synapse/ui/bottombar/components/thread.py +29 -0
- synapse/ui/bottombar/context.py +36 -0
- synapse/ui/bottombar/core.py +74 -0
- synapse/ui/dialogs/__init__.py +25 -0
- synapse/ui/dialogs/base.py +362 -0
- synapse/ui/dialogs/codex_session_list.py +84 -0
- synapse/ui/dialogs/compression_diagnostics.py +210 -0
- synapse/ui/dialogs/git_explore.py +702 -0
- synapse/ui/dialogs/mcp_panel.py +407 -0
- synapse/ui/dialogs/model_picker.py +128 -0
- synapse/ui/dialogs/safety_panel.py +63 -0
- synapse/ui/dialogs/session_list.py +98 -0
- synapse/ui/dialogs/theme_designer.py +863 -0
- synapse/ui/dialogs/theme_picker.py +113 -0
- synapse/ui/git_explore/__init__.py +31 -0
- synapse/ui/git_explore/engine.py +82 -0
- synapse/ui/git_explore/provider.py +242 -0
- synapse/ui/git_explore/unified.py +85 -0
- synapse/ui/rendering.py +350 -0
- synapse/ui/sink.py +70 -0
- synapse/ui/steer_widget.py +367 -0
- synapse/ui/stream.py +1207 -0
- synapse/ui/stream_events.py +421 -0
- synapse/ui/stream_runtime.py +252 -0
- synapse/ui/theme.py +1154 -0
- synapse/ui/timeline.py +621 -0
- synapse/ui/topbar/__init__.py +97 -0
- synapse/ui/topbar/components/__init__.py +150 -0
- synapse/ui/topbar/components/branch.py +41 -0
- synapse/ui/topbar/components/title.py +24 -0
- synapse/ui/topbar/components/tool_output.py +24 -0
- synapse/ui/topbar/components/usage.py +24 -0
- synapse/ui/topbar/components/workspace.py +32 -0
- synapse/ui/topbar/context.py +32 -0
- synapse/ui/topbar/core.py +979 -0
- synapse/ui/topbar/git_changes_popover.py +178 -0
- synapse/ui/topbar/git_chrome.py +475 -0
- synapse/ui/topbar/tool_output_popover.py +84 -0
- synapse/ui/topbar/widget.py +474 -0
- synapse/ui/tui.py +5717 -0
- synapse/ui/turn_rail.py +71 -0
- synapse/ui/user_turn.py +83 -0
- synapse/ui/welcome.py +261 -0
- synapse_cli_agent-0.1.13.dist-info/METADATA +412 -0
- synapse_cli_agent-0.1.13.dist-info/RECORD +131 -0
- synapse_cli_agent-0.1.13.dist-info/WHEEL +4 -0
- synapse_cli_agent-0.1.13.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,721 @@
|
|
|
1
|
+
"""SQLite persistence for reversible tool-output references and diagnostics."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sqlite3
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
import zlib
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from synapse.tool_output.metrics import notify_metrics_changed
|
|
18
|
+
from synapse.tool_output.models import (
|
|
19
|
+
ModelRequestCompressionEvent,
|
|
20
|
+
ToolOutputRecord,
|
|
21
|
+
TransformEvent,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_REFERENCE_PREFIX = "tool-output://"
|
|
25
|
+
_ERROR_LINE = re.compile(r"\b(error|fatal|failed|failure|exception|traceback|critical)\b", re.I)
|
|
26
|
+
_TOKEN = re.compile(r"[\w.-]+", re.UNICODE)
|
|
27
|
+
|
|
28
|
+
def content_to_text(content: Any) -> str:
|
|
29
|
+
"""Produce stable UTF-8 text for a ToolMessage content payload."""
|
|
30
|
+
if isinstance(content, str):
|
|
31
|
+
return content
|
|
32
|
+
if isinstance(content, bytes | bytearray):
|
|
33
|
+
return bytes(content).decode("utf-8", errors="replace")
|
|
34
|
+
try:
|
|
35
|
+
return json.dumps(content, ensure_ascii=False, sort_keys=True, default=str)
|
|
36
|
+
except Exception: # noqa: BLE001
|
|
37
|
+
return str(content)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ToolOutputRepository:
|
|
41
|
+
"""Content-addressed SQLite store for rewritten tool outputs.
|
|
42
|
+
|
|
43
|
+
Blobs are zlib-compressed and deduplicated by SHA-256. References remain
|
|
44
|
+
thread-scoped, so a valid reference cannot be used to read another thread.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(self, path: Path | str) -> None:
|
|
48
|
+
self.path = Path(path).expanduser().resolve()
|
|
49
|
+
self._lock = threading.RLock()
|
|
50
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
self._setup()
|
|
52
|
+
|
|
53
|
+
@contextmanager
|
|
54
|
+
def _connection(self) -> Iterator[sqlite3.Connection]:
|
|
55
|
+
conn = sqlite3.connect(self.path, timeout=10, check_same_thread=False)
|
|
56
|
+
conn.row_factory = sqlite3.Row
|
|
57
|
+
try:
|
|
58
|
+
yield conn
|
|
59
|
+
conn.commit()
|
|
60
|
+
finally:
|
|
61
|
+
conn.close()
|
|
62
|
+
|
|
63
|
+
def _setup(self) -> None:
|
|
64
|
+
with self._lock, self._connection() as conn:
|
|
65
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
66
|
+
conn.executescript(
|
|
67
|
+
"""
|
|
68
|
+
CREATE TABLE IF NOT EXISTS tool_output_blobs (
|
|
69
|
+
sha256 TEXT PRIMARY KEY,
|
|
70
|
+
content BLOB NOT NULL,
|
|
71
|
+
size_bytes INTEGER NOT NULL,
|
|
72
|
+
created_at TEXT NOT NULL
|
|
73
|
+
);
|
|
74
|
+
CREATE TABLE IF NOT EXISTS tool_output_refs (
|
|
75
|
+
ref TEXT PRIMARY KEY,
|
|
76
|
+
thread_id TEXT NOT NULL,
|
|
77
|
+
checkpoint_ns TEXT NOT NULL,
|
|
78
|
+
tool_call_id TEXT NOT NULL,
|
|
79
|
+
tool_name TEXT NOT NULL,
|
|
80
|
+
status TEXT NOT NULL,
|
|
81
|
+
sha256 TEXT NOT NULL REFERENCES tool_output_blobs(sha256),
|
|
82
|
+
created_at TEXT NOT NULL
|
|
83
|
+
);
|
|
84
|
+
CREATE INDEX IF NOT EXISTS idx_tool_output_refs_thread
|
|
85
|
+
ON tool_output_refs(thread_id);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS tool_output_events (
|
|
87
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
88
|
+
thread_id TEXT NOT NULL,
|
|
89
|
+
ref TEXT,
|
|
90
|
+
event_json TEXT NOT NULL,
|
|
91
|
+
created_at TEXT NOT NULL
|
|
92
|
+
);
|
|
93
|
+
CREATE INDEX IF NOT EXISTS idx_tool_output_events_thread
|
|
94
|
+
ON tool_output_events(thread_id);
|
|
95
|
+
CREATE TABLE IF NOT EXISTS tool_output_retrieval_events (
|
|
96
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
97
|
+
thread_id TEXT NOT NULL,
|
|
98
|
+
ref TEXT NOT NULL,
|
|
99
|
+
mode TEXT NOT NULL,
|
|
100
|
+
returned_bytes INTEGER NOT NULL,
|
|
101
|
+
duration_ms REAL NOT NULL,
|
|
102
|
+
created_at TEXT NOT NULL
|
|
103
|
+
);
|
|
104
|
+
CREATE INDEX IF NOT EXISTS idx_tool_output_retrieval_events_thread
|
|
105
|
+
ON tool_output_retrieval_events(thread_id);
|
|
106
|
+
CREATE TABLE IF NOT EXISTS tool_output_model_reuse_events (
|
|
107
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
108
|
+
thread_id TEXT NOT NULL,
|
|
109
|
+
estimated_avoided_tokens INTEGER NOT NULL,
|
|
110
|
+
created_at TEXT NOT NULL
|
|
111
|
+
);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_tool_output_model_reuse_thread
|
|
113
|
+
ON tool_output_model_reuse_events(thread_id);
|
|
114
|
+
CREATE TABLE IF NOT EXISTS model_request_compression_events (
|
|
115
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
116
|
+
request_id TEXT NOT NULL UNIQUE,
|
|
117
|
+
thread_id TEXT NOT NULL,
|
|
118
|
+
event_json TEXT NOT NULL,
|
|
119
|
+
created_at TEXT NOT NULL
|
|
120
|
+
);
|
|
121
|
+
CREATE INDEX IF NOT EXISTS idx_model_request_compression_thread
|
|
122
|
+
ON model_request_compression_events(thread_id, id);
|
|
123
|
+
CREATE TABLE IF NOT EXISTS interaction_events (
|
|
124
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
125
|
+
thread_id TEXT NOT NULL,
|
|
126
|
+
event_json TEXT NOT NULL,
|
|
127
|
+
created_at TEXT NOT NULL
|
|
128
|
+
);
|
|
129
|
+
CREATE INDEX IF NOT EXISTS idx_interaction_events_thread
|
|
130
|
+
ON interaction_events(thread_id, id);
|
|
131
|
+
"""
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def parse_ref(ref: str) -> str | None:
|
|
136
|
+
if not isinstance(ref, str) or not ref.startswith(_REFERENCE_PREFIX):
|
|
137
|
+
return None
|
|
138
|
+
value = ref[len(_REFERENCE_PREFIX) :]
|
|
139
|
+
return value if value and "/" not in value else None
|
|
140
|
+
|
|
141
|
+
def put(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
thread_id: str,
|
|
145
|
+
checkpoint_ns: str = "",
|
|
146
|
+
tool_call_id: str = "",
|
|
147
|
+
tool_name: str = "tool",
|
|
148
|
+
status: str = "success",
|
|
149
|
+
content: str,
|
|
150
|
+
) -> ToolOutputRecord:
|
|
151
|
+
raw = content.encode("utf-8")
|
|
152
|
+
digest = hashlib.sha256(raw).hexdigest()
|
|
153
|
+
ref = f"{_REFERENCE_PREFIX}{uuid.uuid4().hex}"
|
|
154
|
+
created = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
155
|
+
with self._lock, self._connection() as conn:
|
|
156
|
+
conn.execute(
|
|
157
|
+
"INSERT OR IGNORE INTO tool_output_blobs("
|
|
158
|
+
"sha256, content, size_bytes, created_at) VALUES (?, ?, ?, ?)",
|
|
159
|
+
(digest, sqlite3.Binary(zlib.compress(raw, level=6)), len(raw), created),
|
|
160
|
+
)
|
|
161
|
+
conn.execute(
|
|
162
|
+
"INSERT INTO tool_output_refs("
|
|
163
|
+
"ref, thread_id, checkpoint_ns, tool_call_id, tool_name, status, "
|
|
164
|
+
"sha256, created_at) "
|
|
165
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
166
|
+
(ref, thread_id, checkpoint_ns, tool_call_id, tool_name, status, digest, created),
|
|
167
|
+
)
|
|
168
|
+
return ToolOutputRecord(
|
|
169
|
+
ref,
|
|
170
|
+
thread_id,
|
|
171
|
+
checkpoint_ns,
|
|
172
|
+
tool_call_id,
|
|
173
|
+
tool_name,
|
|
174
|
+
status,
|
|
175
|
+
content,
|
|
176
|
+
len(raw),
|
|
177
|
+
digest,
|
|
178
|
+
created,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def get(self, ref: str, *, expected_thread_id: str | None = None) -> ToolOutputRecord | None:
|
|
182
|
+
if self.parse_ref(ref) is None:
|
|
183
|
+
return None
|
|
184
|
+
with self._lock, self._connection() as conn:
|
|
185
|
+
row = conn.execute(
|
|
186
|
+
"""SELECT r.ref, r.thread_id, r.checkpoint_ns, r.tool_call_id, r.tool_name,
|
|
187
|
+
r.status, r.sha256, r.created_at, b.content, b.size_bytes
|
|
188
|
+
FROM tool_output_refs r JOIN tool_output_blobs b ON b.sha256 = r.sha256
|
|
189
|
+
WHERE r.ref = ?""",
|
|
190
|
+
(ref,),
|
|
191
|
+
).fetchone()
|
|
192
|
+
if row is None or (expected_thread_id and row["thread_id"] != expected_thread_id):
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
raw = zlib.decompress(row["content"])
|
|
196
|
+
except zlib.error:
|
|
197
|
+
return None
|
|
198
|
+
if hashlib.sha256(raw).hexdigest() != row["sha256"]:
|
|
199
|
+
return None
|
|
200
|
+
return ToolOutputRecord(
|
|
201
|
+
ref=row["ref"],
|
|
202
|
+
thread_id=row["thread_id"],
|
|
203
|
+
checkpoint_ns=row["checkpoint_ns"],
|
|
204
|
+
tool_call_id=row["tool_call_id"],
|
|
205
|
+
tool_name=row["tool_name"],
|
|
206
|
+
status=row["status"],
|
|
207
|
+
content=raw.decode("utf-8", errors="replace"),
|
|
208
|
+
size_bytes=row["size_bytes"],
|
|
209
|
+
sha256=row["sha256"],
|
|
210
|
+
created_at=row["created_at"],
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
def record_event(
|
|
214
|
+
self, thread_id: str, event: TransformEvent, *, ref: str | None = None
|
|
215
|
+
) -> None:
|
|
216
|
+
with self._lock, self._connection() as conn:
|
|
217
|
+
conn.execute(
|
|
218
|
+
"INSERT INTO tool_output_events(thread_id, ref, event_json, created_at) "
|
|
219
|
+
"VALUES (?, ?, ?, ?)",
|
|
220
|
+
(
|
|
221
|
+
thread_id,
|
|
222
|
+
ref,
|
|
223
|
+
json.dumps(event.as_dict(), ensure_ascii=False),
|
|
224
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
notify_metrics_changed(thread_id)
|
|
228
|
+
|
|
229
|
+
def record_retrieval(
|
|
230
|
+
self,
|
|
231
|
+
*,
|
|
232
|
+
thread_id: str,
|
|
233
|
+
ref: str,
|
|
234
|
+
mode: str,
|
|
235
|
+
returned_bytes: int,
|
|
236
|
+
duration_ms: float,
|
|
237
|
+
) -> None:
|
|
238
|
+
with self._lock, self._connection() as conn:
|
|
239
|
+
conn.execute(
|
|
240
|
+
"INSERT INTO tool_output_retrieval_events("
|
|
241
|
+
"thread_id, ref, mode, returned_bytes, duration_ms, created_at) "
|
|
242
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
243
|
+
(
|
|
244
|
+
thread_id,
|
|
245
|
+
ref,
|
|
246
|
+
mode,
|
|
247
|
+
max(0, int(returned_bytes)),
|
|
248
|
+
max(0.0, float(duration_ms)),
|
|
249
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
notify_metrics_changed(thread_id)
|
|
253
|
+
|
|
254
|
+
def record_model_reuse(self, *, thread_id: str, estimated_avoided_tokens: int) -> None:
|
|
255
|
+
"""Record estimated token savings when transformed outputs re-enter a model call."""
|
|
256
|
+
avoided = max(0, int(estimated_avoided_tokens or 0))
|
|
257
|
+
if not thread_id or avoided <= 0:
|
|
258
|
+
return
|
|
259
|
+
with self._lock, self._connection() as conn:
|
|
260
|
+
conn.execute(
|
|
261
|
+
"INSERT INTO tool_output_model_reuse_events("
|
|
262
|
+
"thread_id, estimated_avoided_tokens, created_at) VALUES (?, ?, ?)",
|
|
263
|
+
(thread_id, avoided, time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())),
|
|
264
|
+
)
|
|
265
|
+
notify_metrics_changed(thread_id)
|
|
266
|
+
|
|
267
|
+
def record_interaction(self, *, thread_id: str, event: dict[str, Any]) -> None:
|
|
268
|
+
"""Persist one model/tool interaction independently of compression eligibility."""
|
|
269
|
+
if not thread_id:
|
|
270
|
+
return
|
|
271
|
+
created = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
272
|
+
with self._lock, self._connection() as conn:
|
|
273
|
+
conn.execute(
|
|
274
|
+
"INSERT INTO interaction_events(thread_id, event_json, created_at) "
|
|
275
|
+
"VALUES (?, ?, ?)",
|
|
276
|
+
(thread_id, json.dumps(event, ensure_ascii=False), created),
|
|
277
|
+
)
|
|
278
|
+
notify_metrics_changed(thread_id)
|
|
279
|
+
|
|
280
|
+
def interaction_events(
|
|
281
|
+
self, *, thread_id: str | None = None, limit: int = 500
|
|
282
|
+
) -> list[dict[str, Any]]:
|
|
283
|
+
where, params = (" WHERE thread_id = ?", (thread_id,)) if thread_id else ("", ())
|
|
284
|
+
bounded = max(1, min(5000, int(limit)))
|
|
285
|
+
with self._lock, self._connection() as conn:
|
|
286
|
+
rows = conn.execute(
|
|
287
|
+
"SELECT id, thread_id, event_json, created_at "
|
|
288
|
+
f"FROM interaction_events{where} ORDER BY id DESC LIMIT ?",
|
|
289
|
+
(*params, bounded),
|
|
290
|
+
).fetchall()
|
|
291
|
+
return [
|
|
292
|
+
{
|
|
293
|
+
"id": int(row["id"]),
|
|
294
|
+
"thread_id": row["thread_id"],
|
|
295
|
+
"created_at": row["created_at"],
|
|
296
|
+
**json.loads(row["event_json"]),
|
|
297
|
+
}
|
|
298
|
+
for row in rows
|
|
299
|
+
]
|
|
300
|
+
|
|
301
|
+
def latest_request_position(self, *, thread_id: str) -> tuple[int, int]:
|
|
302
|
+
with self._lock, self._connection() as conn:
|
|
303
|
+
row = conn.execute(
|
|
304
|
+
"SELECT event_json FROM model_request_compression_events "
|
|
305
|
+
"WHERE thread_id = ? ORDER BY id DESC LIMIT 1",
|
|
306
|
+
(thread_id,),
|
|
307
|
+
).fetchone()
|
|
308
|
+
if row is None:
|
|
309
|
+
return 0, 0
|
|
310
|
+
event = json.loads(row["event_json"])
|
|
311
|
+
return int(event.get("turn_index", 0) or 0), int(
|
|
312
|
+
event.get("model_call_index", 0) or 0
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def record_model_request(
|
|
316
|
+
self, *, thread_id: str, event: ModelRequestCompressionEvent
|
|
317
|
+
) -> None:
|
|
318
|
+
"""Persist one completed model-call compression accounting event."""
|
|
319
|
+
if not thread_id or not event.request_id:
|
|
320
|
+
return
|
|
321
|
+
with self._lock, self._connection() as conn:
|
|
322
|
+
conn.execute(
|
|
323
|
+
"INSERT OR REPLACE INTO model_request_compression_events("
|
|
324
|
+
"request_id, thread_id, event_json, created_at) VALUES (?, ?, ?, ?)",
|
|
325
|
+
(
|
|
326
|
+
event.request_id,
|
|
327
|
+
thread_id,
|
|
328
|
+
json.dumps(event.as_dict(), ensure_ascii=False),
|
|
329
|
+
time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
notify_metrics_changed(thread_id)
|
|
333
|
+
|
|
334
|
+
def model_request_events(
|
|
335
|
+
self, *, thread_id: str | None = None, limit: int = 50
|
|
336
|
+
) -> list[dict[str, Any]]:
|
|
337
|
+
"""Return recent model request compression accounting events."""
|
|
338
|
+
where, params = (" WHERE thread_id = ?", (thread_id,)) if thread_id else ("", ())
|
|
339
|
+
bounded = max(1, min(500, int(limit)))
|
|
340
|
+
with self._lock, self._connection() as conn:
|
|
341
|
+
rows = conn.execute(
|
|
342
|
+
"SELECT id, thread_id, event_json, created_at "
|
|
343
|
+
f"FROM model_request_compression_events{where} ORDER BY id DESC LIMIT ?",
|
|
344
|
+
(*params, bounded),
|
|
345
|
+
).fetchall()
|
|
346
|
+
return [
|
|
347
|
+
{
|
|
348
|
+
"id": int(row["id"]),
|
|
349
|
+
"thread_id": row["thread_id"],
|
|
350
|
+
"created_at": row["created_at"],
|
|
351
|
+
**json.loads(row["event_json"]),
|
|
352
|
+
}
|
|
353
|
+
for row in rows
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
def estimated_active_saved_tokens(self, *, thread_id: str) -> int:
|
|
357
|
+
"""Sum estimated savings of transformed tool outputs currently in graph state."""
|
|
358
|
+
with self._lock, self._connection() as conn:
|
|
359
|
+
rows = conn.execute(
|
|
360
|
+
"SELECT event_json FROM tool_output_events "
|
|
361
|
+
"WHERE thread_id = ? AND ref IS NOT NULL",
|
|
362
|
+
(thread_id,),
|
|
363
|
+
).fetchall()
|
|
364
|
+
return sum(
|
|
365
|
+
max(
|
|
366
|
+
0,
|
|
367
|
+
int(json.loads(row["event_json"]).get("estimated_saved_tokens", 0) or 0),
|
|
368
|
+
)
|
|
369
|
+
for row in rows
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
def events(self, *, thread_id: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
|
|
373
|
+
"""Return recent transformation decisions with linked retrieval totals."""
|
|
374
|
+
where, params = (" WHERE thread_id = ?", (thread_id,)) if thread_id else ("", ())
|
|
375
|
+
bounded_limit = max(1, min(500, int(limit)))
|
|
376
|
+
with self._lock, self._connection() as conn:
|
|
377
|
+
rows = conn.execute(
|
|
378
|
+
"SELECT id, thread_id, ref, event_json, created_at "
|
|
379
|
+
f"FROM tool_output_events{where} ORDER BY id DESC LIMIT ?",
|
|
380
|
+
(*params, bounded_limit),
|
|
381
|
+
).fetchall()
|
|
382
|
+
result: list[dict[str, Any]] = []
|
|
383
|
+
for row in rows:
|
|
384
|
+
event = json.loads(row["event_json"])
|
|
385
|
+
retrieval = 0
|
|
386
|
+
if row["ref"]:
|
|
387
|
+
retrieval_row = conn.execute(
|
|
388
|
+
"SELECT COALESCE(SUM(returned_bytes), 0) AS bytes "
|
|
389
|
+
"FROM tool_output_retrieval_events WHERE ref = ?",
|
|
390
|
+
(row["ref"],),
|
|
391
|
+
).fetchone()
|
|
392
|
+
retrieval = int(retrieval_row["bytes"])
|
|
393
|
+
result.append(
|
|
394
|
+
{
|
|
395
|
+
"id": int(row["id"]),
|
|
396
|
+
"thread_id": row["thread_id"],
|
|
397
|
+
"ref": row["ref"],
|
|
398
|
+
"created_at": row["created_at"],
|
|
399
|
+
"retrieval_bytes": retrieval,
|
|
400
|
+
**event,
|
|
401
|
+
}
|
|
402
|
+
)
|
|
403
|
+
return result
|
|
404
|
+
|
|
405
|
+
def export_diagnostics(self, *, thread_id: str) -> dict[str, Any]:
|
|
406
|
+
"""Return complete compression diagnostics for one thread without output blobs."""
|
|
407
|
+
with self._lock, self._connection() as conn:
|
|
408
|
+
tool_rows = conn.execute(
|
|
409
|
+
"SELECT id, thread_id, ref, event_json, created_at "
|
|
410
|
+
"FROM tool_output_events WHERE thread_id = ? ORDER BY id ASC",
|
|
411
|
+
(thread_id,),
|
|
412
|
+
).fetchall()
|
|
413
|
+
request_rows = conn.execute(
|
|
414
|
+
"SELECT id, thread_id, event_json, created_at "
|
|
415
|
+
"FROM model_request_compression_events WHERE thread_id = ? ORDER BY id ASC",
|
|
416
|
+
(thread_id,),
|
|
417
|
+
).fetchall()
|
|
418
|
+
retrieval_rows = conn.execute(
|
|
419
|
+
"SELECT id, thread_id, ref, mode, returned_bytes, duration_ms, created_at "
|
|
420
|
+
"FROM tool_output_retrieval_events WHERE thread_id = ? ORDER BY id ASC",
|
|
421
|
+
(thread_id,),
|
|
422
|
+
).fetchall()
|
|
423
|
+
reuse_rows = conn.execute(
|
|
424
|
+
"SELECT id, thread_id, estimated_avoided_tokens, created_at "
|
|
425
|
+
"FROM tool_output_model_reuse_events WHERE thread_id = ? ORDER BY id ASC",
|
|
426
|
+
(thread_id,),
|
|
427
|
+
).fetchall()
|
|
428
|
+
interaction_rows = conn.execute(
|
|
429
|
+
"SELECT id, thread_id, event_json, created_at "
|
|
430
|
+
"FROM interaction_events WHERE thread_id = ? ORDER BY id ASC",
|
|
431
|
+
(thread_id,),
|
|
432
|
+
).fetchall()
|
|
433
|
+
|
|
434
|
+
tool_events = [
|
|
435
|
+
{
|
|
436
|
+
"id": int(row["id"]),
|
|
437
|
+
"thread_id": row["thread_id"],
|
|
438
|
+
"ref": row["ref"],
|
|
439
|
+
"created_at": row["created_at"],
|
|
440
|
+
**json.loads(row["event_json"]),
|
|
441
|
+
}
|
|
442
|
+
for row in tool_rows
|
|
443
|
+
]
|
|
444
|
+
request_events = [
|
|
445
|
+
{
|
|
446
|
+
"id": int(row["id"]),
|
|
447
|
+
"thread_id": row["thread_id"],
|
|
448
|
+
"created_at": row["created_at"],
|
|
449
|
+
**json.loads(row["event_json"]),
|
|
450
|
+
}
|
|
451
|
+
for row in request_rows
|
|
452
|
+
]
|
|
453
|
+
retrieval_events = [
|
|
454
|
+
{
|
|
455
|
+
"id": int(row["id"]),
|
|
456
|
+
"thread_id": row["thread_id"],
|
|
457
|
+
"ref": row["ref"],
|
|
458
|
+
"mode": row["mode"],
|
|
459
|
+
"returned_bytes": int(row["returned_bytes"]),
|
|
460
|
+
"duration_ms": float(row["duration_ms"]),
|
|
461
|
+
"created_at": row["created_at"],
|
|
462
|
+
}
|
|
463
|
+
for row in retrieval_rows
|
|
464
|
+
]
|
|
465
|
+
model_reuse_events = [
|
|
466
|
+
{
|
|
467
|
+
"id": int(row["id"]),
|
|
468
|
+
"thread_id": row["thread_id"],
|
|
469
|
+
"estimated_avoided_tokens": int(row["estimated_avoided_tokens"]),
|
|
470
|
+
"created_at": row["created_at"],
|
|
471
|
+
}
|
|
472
|
+
for row in reuse_rows
|
|
473
|
+
]
|
|
474
|
+
interaction_events = [
|
|
475
|
+
{
|
|
476
|
+
"id": int(row["id"]),
|
|
477
|
+
"thread_id": row["thread_id"],
|
|
478
|
+
"created_at": row["created_at"],
|
|
479
|
+
**json.loads(row["event_json"]),
|
|
480
|
+
}
|
|
481
|
+
for row in interaction_rows
|
|
482
|
+
]
|
|
483
|
+
return {
|
|
484
|
+
"summary": self.stats(thread_id=thread_id),
|
|
485
|
+
"model_request_events": request_events,
|
|
486
|
+
"interaction_events": interaction_events,
|
|
487
|
+
"tool_output_events": tool_events,
|
|
488
|
+
"retrieval_events": retrieval_events,
|
|
489
|
+
"model_reuse_events": model_reuse_events,
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
def stats(self, *, thread_id: str | None = None) -> dict[str, Any]:
|
|
493
|
+
where, params = (" WHERE thread_id = ?", (thread_id,)) if thread_id else ("", ())
|
|
494
|
+
with self._lock, self._connection() as conn:
|
|
495
|
+
rows = conn.execute(
|
|
496
|
+
f"SELECT event_json FROM tool_output_events{where}", params
|
|
497
|
+
).fetchall()
|
|
498
|
+
with self._lock, self._connection() as conn:
|
|
499
|
+
retrieval_rows = conn.execute(
|
|
500
|
+
f"SELECT returned_bytes FROM tool_output_retrieval_events{where}", params
|
|
501
|
+
).fetchall()
|
|
502
|
+
reuse_rows = conn.execute(
|
|
503
|
+
f"SELECT estimated_avoided_tokens FROM tool_output_model_reuse_events{where}",
|
|
504
|
+
params,
|
|
505
|
+
).fetchall()
|
|
506
|
+
request_rows = conn.execute(
|
|
507
|
+
f"SELECT event_json FROM model_request_compression_events{where}", params
|
|
508
|
+
).fetchall()
|
|
509
|
+
interaction_rows = conn.execute(
|
|
510
|
+
f"SELECT event_json FROM interaction_events{where}", params
|
|
511
|
+
).fetchall()
|
|
512
|
+
events = [json.loads(row["event_json"]) for row in rows]
|
|
513
|
+
request_events = [json.loads(row["event_json"]) for row in request_rows]
|
|
514
|
+
interaction_events = [json.loads(row["event_json"]) for row in interaction_rows]
|
|
515
|
+
turn_ids = {str(item.get("turn_id") or "") for item in request_events}
|
|
516
|
+
turn_ids.discard("")
|
|
517
|
+
tool_calls = [item for item in interaction_events if item.get("event_type") == "tool_call"]
|
|
518
|
+
live_zone_tokens: dict[str, int] = {}
|
|
519
|
+
schema_tokens_by_tool: dict[str, int] = {}
|
|
520
|
+
cache_bust_suspected = 0
|
|
521
|
+
for request_event in request_events:
|
|
522
|
+
for key, value in dict(request_event.get("live_zone_tokens") or {}).items():
|
|
523
|
+
live_zone_tokens[str(key)] = live_zone_tokens.get(str(key), 0) + int(value or 0)
|
|
524
|
+
if (request_event.get("cache_diagnostics") or {}).get("cache_bust_suspected"):
|
|
525
|
+
cache_bust_suspected += 1
|
|
526
|
+
for profile in request_event.get("tool_schema_profiles") or []:
|
|
527
|
+
name = str(profile.get("tool_name") or "unknown")
|
|
528
|
+
schema_tokens_by_tool[name] = schema_tokens_by_tool.get(name, 0) + int(
|
|
529
|
+
profile.get("estimated_tokens", 0) or 0
|
|
530
|
+
)
|
|
531
|
+
retrieval_bytes = sum(int(row["returned_bytes"]) for row in retrieval_rows)
|
|
532
|
+
estimated_reused_tokens = sum(
|
|
533
|
+
int(row["estimated_avoided_tokens"]) for row in reuse_rows
|
|
534
|
+
)
|
|
535
|
+
original = sum(int(item["original_bytes"]) for item in events)
|
|
536
|
+
visible = sum(int(item["visible_bytes"]) for item in events)
|
|
537
|
+
transformed = sum(item["outcome"] == "transformed" for item in events)
|
|
538
|
+
estimated_original_tokens = sum(
|
|
539
|
+
int(item.get("estimated_original_tokens", 0) or 0) for item in events
|
|
540
|
+
)
|
|
541
|
+
estimated_visible_tokens = sum(
|
|
542
|
+
int(item.get("estimated_visible_tokens", 0) or 0) for item in events
|
|
543
|
+
)
|
|
544
|
+
estimated_saved_tokens = max(
|
|
545
|
+
0, estimated_original_tokens - estimated_visible_tokens
|
|
546
|
+
)
|
|
547
|
+
critical_total = sum(int(item["critical_total"]) for item in events)
|
|
548
|
+
critical_retained = sum(int(item["critical_retained"]) for item in events)
|
|
549
|
+
execution_paths: dict[str, int] = {}
|
|
550
|
+
decisions: dict[str, int] = {}
|
|
551
|
+
reasons: dict[str, int] = {}
|
|
552
|
+
tokens_by_reason: dict[str, int] = {}
|
|
553
|
+
bytes_by_reason: dict[str, int] = {}
|
|
554
|
+
request_input_before = sum(
|
|
555
|
+
int(item.get("input_tokens_before", 0) or 0) for item in request_events
|
|
556
|
+
)
|
|
557
|
+
request_input_after = sum(
|
|
558
|
+
int(item.get("input_tokens_after", 0) or 0) for item in request_events
|
|
559
|
+
)
|
|
560
|
+
request_saved_tokens = sum(
|
|
561
|
+
int(item.get("total_saved_tokens", 0) or 0) for item in request_events
|
|
562
|
+
)
|
|
563
|
+
provider_input_tokens = sum(
|
|
564
|
+
int(item.get("provider_input_tokens", 0) or 0) for item in request_events
|
|
565
|
+
)
|
|
566
|
+
cache_read_tokens = sum(
|
|
567
|
+
int(item.get("cache_read_tokens", 0) or 0) for item in request_events
|
|
568
|
+
)
|
|
569
|
+
cache_write_tokens = sum(
|
|
570
|
+
int(item.get("cache_write_tokens", 0) or 0) for item in request_events
|
|
571
|
+
)
|
|
572
|
+
uncached_input_tokens = sum(
|
|
573
|
+
int(item.get("uncached_input_tokens", 0) or 0) for item in request_events
|
|
574
|
+
)
|
|
575
|
+
request_output_tokens = sum(
|
|
576
|
+
int(item.get("output_tokens", 0) or 0) for item in request_events
|
|
577
|
+
)
|
|
578
|
+
content_breakdown: dict[str, int] = {}
|
|
579
|
+
opportunities: dict[str, int] = {}
|
|
580
|
+
protected_breakdown: dict[str, int] = {}
|
|
581
|
+
for request_event in request_events:
|
|
582
|
+
for key, value in dict(request_event.get("content_breakdown") or {}).items():
|
|
583
|
+
content_breakdown[str(key)] = content_breakdown.get(str(key), 0) + int(
|
|
584
|
+
value or 0
|
|
585
|
+
)
|
|
586
|
+
for key, value in dict(
|
|
587
|
+
request_event.get("opportunity_tokens_by_reason") or {}
|
|
588
|
+
).items():
|
|
589
|
+
opportunities[str(key)] = opportunities.get(str(key), 0) + int(value or 0)
|
|
590
|
+
for key, value in dict(
|
|
591
|
+
request_event.get("protected_tokens_by_reason") or {}
|
|
592
|
+
).items():
|
|
593
|
+
protected_breakdown[str(key)] = protected_breakdown.get(str(key), 0) + int(
|
|
594
|
+
value or 0
|
|
595
|
+
)
|
|
596
|
+
for item in events:
|
|
597
|
+
path = str(item.get("execution_path", "legacy_unknown"))
|
|
598
|
+
execution_paths[path] = execution_paths.get(path, 0) + 1
|
|
599
|
+
decision = str(
|
|
600
|
+
item.get("decision")
|
|
601
|
+
or ("transformed" if item.get("outcome") == "transformed" else "fallback")
|
|
602
|
+
)
|
|
603
|
+
reason = str(
|
|
604
|
+
item.get("reason_code")
|
|
605
|
+
or ("compressed" if decision == "transformed" else "legacy_passthrough")
|
|
606
|
+
)
|
|
607
|
+
decisions[decision] = decisions.get(decision, 0) + 1
|
|
608
|
+
reasons[reason] = reasons.get(reason, 0) + 1
|
|
609
|
+
if decision != "transformed":
|
|
610
|
+
tokens_by_reason[reason] = tokens_by_reason.get(reason, 0) + int(
|
|
611
|
+
item.get("estimated_original_tokens", 0) or 0
|
|
612
|
+
)
|
|
613
|
+
bytes_by_reason[reason] = bytes_by_reason.get(reason, 0) + int(
|
|
614
|
+
item.get("original_bytes", 0) or 0
|
|
615
|
+
)
|
|
616
|
+
return {
|
|
617
|
+
"outputs_considered": len(events),
|
|
618
|
+
"transformed": transformed,
|
|
619
|
+
"original_bytes": original,
|
|
620
|
+
"visible_bytes": visible,
|
|
621
|
+
"saved_bytes": max(0, original - visible),
|
|
622
|
+
"estimated_original_tokens": estimated_original_tokens,
|
|
623
|
+
"estimated_visible_tokens": estimated_visible_tokens,
|
|
624
|
+
"estimated_saved_tokens": estimated_saved_tokens,
|
|
625
|
+
"estimated_reused_tokens": estimated_reused_tokens,
|
|
626
|
+
"retrieval_bytes": retrieval_bytes,
|
|
627
|
+
"effective_saved_bytes": max(0, original - visible - retrieval_bytes),
|
|
628
|
+
"savings_ratio": round(1 - visible / original, 4) if original else 0.0,
|
|
629
|
+
"effective_savings_ratio": (
|
|
630
|
+
round(max(0, original - visible - retrieval_bytes) / original, 4)
|
|
631
|
+
if original
|
|
632
|
+
else 0.0
|
|
633
|
+
),
|
|
634
|
+
"critical_retention": round(critical_retained / critical_total, 4)
|
|
635
|
+
if critical_total
|
|
636
|
+
else 1.0,
|
|
637
|
+
"execution_paths": execution_paths,
|
|
638
|
+
"decisions": decisions,
|
|
639
|
+
"reasons": reasons,
|
|
640
|
+
"tokens_by_reason": tokens_by_reason,
|
|
641
|
+
"bytes_by_reason": bytes_by_reason,
|
|
642
|
+
"skipped": decisions.get("skipped", 0),
|
|
643
|
+
"fallback": decisions.get("fallback", 0),
|
|
644
|
+
"model_requests": len(request_events),
|
|
645
|
+
"turns": len(turn_ids),
|
|
646
|
+
"tool_calls": len(tool_calls),
|
|
647
|
+
"compression_managed_tool_calls": sum(
|
|
648
|
+
bool(item.get("compression_managed")) for item in tool_calls
|
|
649
|
+
),
|
|
650
|
+
"live_zone_tokens": live_zone_tokens,
|
|
651
|
+
"cache_bust_suspected_requests": cache_bust_suspected,
|
|
652
|
+
"schema_tokens_by_tool": schema_tokens_by_tool,
|
|
653
|
+
"top_schema_tools": sorted(
|
|
654
|
+
schema_tokens_by_tool.items(), key=lambda item: item[1], reverse=True
|
|
655
|
+
)[:10],
|
|
656
|
+
"request_input_tokens_before": request_input_before,
|
|
657
|
+
"request_input_tokens_after": request_input_after,
|
|
658
|
+
"request_saved_tokens": request_saved_tokens,
|
|
659
|
+
"provider_input_tokens": provider_input_tokens,
|
|
660
|
+
"cache_read_tokens": cache_read_tokens,
|
|
661
|
+
"cache_write_tokens": cache_write_tokens,
|
|
662
|
+
"uncached_input_tokens": uncached_input_tokens,
|
|
663
|
+
"request_output_tokens": request_output_tokens,
|
|
664
|
+
"whole_request_savings_ratio": (
|
|
665
|
+
round(request_saved_tokens / request_input_before, 4)
|
|
666
|
+
if request_input_before
|
|
667
|
+
else 0.0
|
|
668
|
+
),
|
|
669
|
+
"new_input_savings_ratio": (
|
|
670
|
+
round(
|
|
671
|
+
request_saved_tokens
|
|
672
|
+
/ (uncached_input_tokens + cache_write_tokens + request_saved_tokens),
|
|
673
|
+
4,
|
|
674
|
+
)
|
|
675
|
+
if uncached_input_tokens + cache_write_tokens > 0
|
|
676
|
+
else 0.0
|
|
677
|
+
),
|
|
678
|
+
"content_breakdown": content_breakdown,
|
|
679
|
+
"opportunity_tokens_by_reason": opportunities,
|
|
680
|
+
"protected_tokens_by_reason": protected_breakdown,
|
|
681
|
+
"top_opportunities": sorted(
|
|
682
|
+
opportunities.items(), key=lambda item: item[1], reverse=True
|
|
683
|
+
)[:10],
|
|
684
|
+
"top_protected_sources": sorted(
|
|
685
|
+
protected_breakdown.items(), key=lambda item: item[1], reverse=True
|
|
686
|
+
)[:10],
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
def search(
|
|
690
|
+
self,
|
|
691
|
+
ref: str,
|
|
692
|
+
query: str,
|
|
693
|
+
*,
|
|
694
|
+
expected_thread_id: str | None = None,
|
|
695
|
+
max_results: int = 20,
|
|
696
|
+
context_lines: int = 2,
|
|
697
|
+
) -> list[tuple[int, str]]:
|
|
698
|
+
record = self.get(ref, expected_thread_id=expected_thread_id)
|
|
699
|
+
terms = {item.casefold() for item in _TOKEN.findall(query) if len(item) > 1}
|
|
700
|
+
if record is None or not terms:
|
|
701
|
+
return []
|
|
702
|
+
lines = record.content.splitlines()
|
|
703
|
+
scored = []
|
|
704
|
+
for index, line in enumerate(lines):
|
|
705
|
+
text = line.casefold()
|
|
706
|
+
score = sum(term in text for term in terms) + (4 if _ERROR_LINE.search(line) else 0)
|
|
707
|
+
if score:
|
|
708
|
+
scored.append((score, index))
|
|
709
|
+
selected: list[tuple[int, str]] = []
|
|
710
|
+
seen: set[int] = set()
|
|
711
|
+
for _, index in sorted(scored, reverse=True)[: max(1, min(50, max_results))]:
|
|
712
|
+
start, end = (
|
|
713
|
+
max(0, index - max(0, context_lines)),
|
|
714
|
+
min(len(lines), index + context_lines + 1),
|
|
715
|
+
)
|
|
716
|
+
for line_no in range(start, end):
|
|
717
|
+
if line_no not in seen:
|
|
718
|
+
seen.add(line_no)
|
|
719
|
+
selected.append((line_no, lines[line_no]))
|
|
720
|
+
return sorted(selected)
|
|
721
|
+
|