raggiecode 0.2.1__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.
- Agent/__init__.py +0 -0
- Agent/agent.py +891 -0
- Agent/chat_history_db.py +1500 -0
- Agent/command.py +49 -0
- Agent/config.py +46 -0
- Agent/effort_levels.py +33 -0
- Agent/git_manager.py +727 -0
- Agent/tools.py +35 -0
- Commands/__init__.py +18 -0
- Commands/effort.py +42 -0
- Commands/global_todo.py +23 -0
- Commands/help.py +22 -0
- Commands/reasoning.py +24 -0
- Commands/redo.py +11 -0
- Commands/reindex.py +27 -0
- Commands/shell.py +28 -0
- Commands/stream.py +24 -0
- Commands/undo.py +13 -0
- Commands/unlimited_effort.py +8 -0
- Commands/window_size.py +29 -0
- RAG/__init__.py +0 -0
- RAG/document.py +119 -0
- RAG/find.py +408 -0
- RAG/graph.py +231 -0
- Tools/GetFileCodeStructure.py +43 -0
- Tools/GetSymbolSourceCode.py +27 -0
- Tools/__init__.py +39 -0
- Tools/ask_user.py +102 -0
- Tools/dispatch_subagent.py +215 -0
- Tools/document.py +35 -0
- Tools/edit_symbol.py +250 -0
- Tools/fuzzy_search.py +119 -0
- Tools/list_dir.py +51 -0
- Tools/read.py +49 -0
- Tools/read_image.py +75 -0
- Tools/remove.py +75 -0
- Tools/replace.py +305 -0
- Tools/search.py +41 -0
- Tools/shell.py +149 -0
- Tools/shell_kill.py +87 -0
- Tools/temp_background_service.py +113 -0
- Tools/todo_list.py +481 -0
- Tools/utils.py +116 -0
- Tools/view_changes.py +179 -0
- Tools/walk_call_tree.py +30 -0
- Tools/web_fetch.py +175 -0
- Tools/web_search.py +69 -0
- Tools/write.py +48 -0
- cli.py +111 -0
- config/__init__.py +0 -0
- config/coder_system_prompt.md +119 -0
- config/roles.json +43 -0
- config/tools.json +709 -0
- indexing/__init__.py +0 -0
- indexing/cli.py +128 -0
- indexing/code_index_sdk.py +832 -0
- indexing/code_indexer.py +1763 -0
- indexing/db_schema.py +396 -0
- indexing/export_to_json.py +346 -0
- indexing/extractors.py +189 -0
- indexing/file_utils.py +97 -0
- indexing/frontend/__init__.py +0 -0
- indexing/frontend/css_extractor.py +195 -0
- indexing/frontend/css_parser.py +387 -0
- indexing/frontend/css_selector_utils.py +226 -0
- indexing/frontend/edit_safety.py +573 -0
- indexing/frontend/graph.py +838 -0
- indexing/frontend/html_extractor.py +496 -0
- indexing/frontend/html_parser.py +314 -0
- indexing/frontend/jsx_extractor.py +1204 -0
- indexing/frontend/location_lookup.py +247 -0
- indexing/frontend/resolver.py +485 -0
- indexing/frontend/runtime_resolver.py +862 -0
- indexing/frontend/semantic_output.py +705 -0
- indexing/frontend/source_location.py +69 -0
- indexing/frontend_config.py +72 -0
- indexing/frontend_models.py +347 -0
- indexing/language_config.py +360 -0
- indexing/models.py +284 -0
- indexing/node_utils.py +1112 -0
- indexing/parse_worker.py +1082 -0
- indexing/queries.py +1542 -0
- indexing/sdk_examples.py +426 -0
- interactive.py +248 -0
- raggie.py +673 -0
- raggiecode-0.2.1.dist-info/METADATA +944 -0
- raggiecode-0.2.1.dist-info/RECORD +93 -0
- raggiecode-0.2.1.dist-info/WHEEL +5 -0
- raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
- raggiecode-0.2.1.dist-info/top_level.txt +10 -0
- skills/__init__.py +3 -0
- skills/manager.py +114 -0
- skills/tool.py +121 -0
Agent/chat_history_db.py
ADDED
|
@@ -0,0 +1,1500 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import sys
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Dict, Optional
|
|
7
|
+
from prompt_toolkit import prompt
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DB_PATH = Path(".raggie/.raggie.chat")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _get_conn():
|
|
14
|
+
conn = sqlite3.connect(DB_PATH)
|
|
15
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
16
|
+
return conn
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def init_db():
|
|
20
|
+
"""Initialize the chat history database with required tables."""
|
|
21
|
+
# Ensure parent directory exists
|
|
22
|
+
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
|
|
24
|
+
conn = _get_conn()
|
|
25
|
+
cursor = conn.cursor()
|
|
26
|
+
|
|
27
|
+
cursor.execute("""
|
|
28
|
+
CREATE TABLE IF NOT EXISTS chats (
|
|
29
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
30
|
+
role TEXT NOT NULL,
|
|
31
|
+
title TEXT,
|
|
32
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
33
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
34
|
+
)
|
|
35
|
+
""")
|
|
36
|
+
|
|
37
|
+
cursor.execute("""
|
|
38
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
39
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
40
|
+
chat_id INTEGER NOT NULL,
|
|
41
|
+
parent_session_id INTEGER,
|
|
42
|
+
redirect_session_id INTEGER,
|
|
43
|
+
toolcall_id TEXT,
|
|
44
|
+
effort INTEGER,
|
|
45
|
+
depth INTEGER DEFAULT 0,
|
|
46
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
47
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
48
|
+
FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE,
|
|
49
|
+
FOREIGN KEY (parent_session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
|
50
|
+
FOREIGN KEY (redirect_session_id) REFERENCES sessions(id) ON DELETE SET NULL
|
|
51
|
+
)
|
|
52
|
+
""")
|
|
53
|
+
|
|
54
|
+
# Migrate old sessions table: add missing columns
|
|
55
|
+
cursor.execute("PRAGMA table_info(sessions)")
|
|
56
|
+
session_columns = [col[1] for col in cursor.fetchall()]
|
|
57
|
+
if "redirect_session_id" not in session_columns:
|
|
58
|
+
cursor.execute("ALTER TABLE sessions ADD COLUMN redirect_session_id INTEGER REFERENCES sessions(id) ON DELETE SET NULL")
|
|
59
|
+
if "toolcall_id" not in session_columns:
|
|
60
|
+
cursor.execute("ALTER TABLE sessions ADD COLUMN toolcall_id TEXT")
|
|
61
|
+
if "effort" not in session_columns:
|
|
62
|
+
cursor.execute("ALTER TABLE sessions ADD COLUMN effort INTEGER")
|
|
63
|
+
if "depth" not in session_columns:
|
|
64
|
+
cursor.execute("ALTER TABLE sessions ADD COLUMN depth INTEGER DEFAULT 0")
|
|
65
|
+
|
|
66
|
+
cursor.execute("""
|
|
67
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
68
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
69
|
+
session_id INTEGER NOT NULL,
|
|
70
|
+
role TEXT NOT NULL,
|
|
71
|
+
content TEXT,
|
|
72
|
+
tool_calls TEXT,
|
|
73
|
+
tool_call_id TEXT,
|
|
74
|
+
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
75
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
76
|
+
)
|
|
77
|
+
""")
|
|
78
|
+
|
|
79
|
+
cursor.execute("""
|
|
80
|
+
CREATE TABLE IF NOT EXISTS skills (
|
|
81
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
82
|
+
role TEXT NOT NULL,
|
|
83
|
+
name TEXT NOT NULL,
|
|
84
|
+
content TEXT,
|
|
85
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
86
|
+
UNIQUE(role, name)
|
|
87
|
+
)
|
|
88
|
+
""")
|
|
89
|
+
|
|
90
|
+
# Migrate old schema (role as PRIMARY KEY, no name column) to new schema
|
|
91
|
+
cursor.execute("PRAGMA table_info(skills)")
|
|
92
|
+
columns = [col[1] for col in cursor.fetchall()]
|
|
93
|
+
if "name" not in columns and "role" in columns:
|
|
94
|
+
cursor.execute("ALTER TABLE skills RENAME TO skills_old")
|
|
95
|
+
cursor.execute("""
|
|
96
|
+
CREATE TABLE skills (
|
|
97
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
98
|
+
role TEXT NOT NULL,
|
|
99
|
+
name TEXT NOT NULL,
|
|
100
|
+
content TEXT,
|
|
101
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
102
|
+
UNIQUE(role, name)
|
|
103
|
+
)
|
|
104
|
+
""")
|
|
105
|
+
cursor.execute("""
|
|
106
|
+
INSERT INTO skills (role, name, content, updated_at)
|
|
107
|
+
SELECT role, role, content, updated_at FROM skills_old
|
|
108
|
+
""")
|
|
109
|
+
cursor.execute("DROP TABLE skills_old")
|
|
110
|
+
|
|
111
|
+
cursor.execute("""
|
|
112
|
+
CREATE TABLE IF NOT EXISTS changes (
|
|
113
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
114
|
+
prompt_id TEXT NOT NULL,
|
|
115
|
+
role TEXT NOT NULL,
|
|
116
|
+
session_id INTEGER,
|
|
117
|
+
change_type TEXT NOT NULL,
|
|
118
|
+
file_path TEXT,
|
|
119
|
+
description TEXT,
|
|
120
|
+
details TEXT,
|
|
121
|
+
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
122
|
+
)
|
|
123
|
+
""")
|
|
124
|
+
|
|
125
|
+
cursor.execute("""
|
|
126
|
+
CREATE TABLE IF NOT EXISTS todo_lists (
|
|
127
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
128
|
+
session_id INTEGER NOT NULL,
|
|
129
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
130
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
131
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
132
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
133
|
+
)
|
|
134
|
+
""")
|
|
135
|
+
|
|
136
|
+
cursor.execute("""
|
|
137
|
+
CREATE TABLE IF NOT EXISTS todo_tasks (
|
|
138
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
139
|
+
todo_list_id INTEGER NOT NULL,
|
|
140
|
+
goal TEXT NOT NULL,
|
|
141
|
+
requirements TEXT,
|
|
142
|
+
notes TEXT,
|
|
143
|
+
context TEXT,
|
|
144
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
145
|
+
order_index INTEGER NOT NULL,
|
|
146
|
+
toolcall_id TEXT,
|
|
147
|
+
cancel_reason TEXT,
|
|
148
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
149
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
150
|
+
FOREIGN KEY (todo_list_id) REFERENCES todo_lists(id) ON DELETE CASCADE
|
|
151
|
+
)
|
|
152
|
+
""")
|
|
153
|
+
|
|
154
|
+
# Migrate old todo_tasks table: add toolcall_id column if missing
|
|
155
|
+
cursor.execute("PRAGMA table_info(todo_tasks)")
|
|
156
|
+
task_columns = [col[1] for col in cursor.fetchall()]
|
|
157
|
+
if "toolcall_id" not in task_columns:
|
|
158
|
+
cursor.execute("ALTER TABLE todo_tasks ADD COLUMN toolcall_id TEXT")
|
|
159
|
+
if "cancel_reason" not in task_columns:
|
|
160
|
+
cursor.execute("ALTER TABLE todo_tasks ADD COLUMN cancel_reason TEXT")
|
|
161
|
+
|
|
162
|
+
cursor.execute("""
|
|
163
|
+
CREATE TABLE IF NOT EXISTS session_files (
|
|
164
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
165
|
+
session_id INTEGER NOT NULL,
|
|
166
|
+
file_path TEXT NOT NULL,
|
|
167
|
+
operation TEXT NOT NULL,
|
|
168
|
+
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
169
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
|
170
|
+
)
|
|
171
|
+
""")
|
|
172
|
+
|
|
173
|
+
cursor.execute("""
|
|
174
|
+
CREATE TABLE IF NOT EXISTS handovers (
|
|
175
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
176
|
+
session_id INTEGER NOT NULL,
|
|
177
|
+
new_session_id INTEGER,
|
|
178
|
+
handover_text TEXT NOT NULL,
|
|
179
|
+
token_usage INTEGER,
|
|
180
|
+
context_window INTEGER,
|
|
181
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
182
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE,
|
|
183
|
+
FOREIGN KEY (new_session_id) REFERENCES sessions(id) ON DELETE SET NULL
|
|
184
|
+
)
|
|
185
|
+
""")
|
|
186
|
+
|
|
187
|
+
conn.commit()
|
|
188
|
+
conn.close()
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def create_chat(role: str, title: Optional[str] = None) -> int:
|
|
192
|
+
"""Create a new chat for a given role and return its ID."""
|
|
193
|
+
conn = _get_conn()
|
|
194
|
+
cursor = conn.cursor()
|
|
195
|
+
|
|
196
|
+
# Use role as default title if none provided (will be updated on first message)
|
|
197
|
+
if title is None:
|
|
198
|
+
title = role
|
|
199
|
+
|
|
200
|
+
cursor.execute("""
|
|
201
|
+
INSERT INTO chats (role, title)
|
|
202
|
+
VALUES (?, ?)
|
|
203
|
+
""", (role, title))
|
|
204
|
+
|
|
205
|
+
chat_id = cursor.lastrowid
|
|
206
|
+
conn.commit()
|
|
207
|
+
conn.close()
|
|
208
|
+
|
|
209
|
+
return chat_id
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def update_chat_title(chat_id: int, title: str):
|
|
213
|
+
"""Update the title of a chat."""
|
|
214
|
+
conn = _get_conn()
|
|
215
|
+
cursor = conn.cursor()
|
|
216
|
+
|
|
217
|
+
cursor.execute("""
|
|
218
|
+
UPDATE chats
|
|
219
|
+
SET title = ?
|
|
220
|
+
WHERE id = ?
|
|
221
|
+
""", (title, chat_id))
|
|
222
|
+
|
|
223
|
+
conn.commit()
|
|
224
|
+
conn.close()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def generate_title(message: str) -> str:
|
|
228
|
+
"""Generate a title from a user message (first 50 chars + ... if longer)."""
|
|
229
|
+
if not message:
|
|
230
|
+
return "Untitled"
|
|
231
|
+
|
|
232
|
+
# Remove leading/trailing whitespace
|
|
233
|
+
message = message.strip()
|
|
234
|
+
|
|
235
|
+
# Take first 50 characters
|
|
236
|
+
if len(message) <= 50:
|
|
237
|
+
return message
|
|
238
|
+
else:
|
|
239
|
+
return message[:50] + "..."
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def list_chats(role: str) -> List[Dict]:
|
|
243
|
+
"""Get all chats for a given role with their titles and metadata."""
|
|
244
|
+
conn = _get_conn()
|
|
245
|
+
cursor = conn.cursor()
|
|
246
|
+
|
|
247
|
+
cursor.execute("""
|
|
248
|
+
DELETE FROM chats
|
|
249
|
+
WHERE id IN (
|
|
250
|
+
SELECT c.id
|
|
251
|
+
FROM chats c
|
|
252
|
+
JOIN sessions s ON c.id = s.chat_id
|
|
253
|
+
WHERE c.role = ?
|
|
254
|
+
AND s.parent_session_id IS NULL
|
|
255
|
+
AND NOT EXISTS (
|
|
256
|
+
SELECT 1
|
|
257
|
+
FROM messages m
|
|
258
|
+
WHERE m.session_id = s.id
|
|
259
|
+
AND m.role = 'user'
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
""", (role,))
|
|
263
|
+
|
|
264
|
+
cursor.execute("""
|
|
265
|
+
SELECT id, title, created_at, updated_at
|
|
266
|
+
FROM chats
|
|
267
|
+
WHERE role = ?
|
|
268
|
+
ORDER BY id DESC
|
|
269
|
+
""", (role,))
|
|
270
|
+
|
|
271
|
+
chats = []
|
|
272
|
+
for row in cursor.fetchall():
|
|
273
|
+
chats.append({
|
|
274
|
+
"id": row[0],
|
|
275
|
+
"title": row[1],
|
|
276
|
+
"created_at": row[2],
|
|
277
|
+
"updated_at": row[3]
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
conn.close()
|
|
281
|
+
return chats
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def select_chat(role: str) -> Optional[int]:
|
|
285
|
+
"""Let the user select a chat from available chats for a given role."""
|
|
286
|
+
chats = list_chats(role)
|
|
287
|
+
if not chats:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
print(f"Available chats for role '{role}':")
|
|
291
|
+
print("-" * 50)
|
|
292
|
+
for i, chat in enumerate(chats, 1):
|
|
293
|
+
title = chat['title'] if chat['title'] else 'Untitled'
|
|
294
|
+
print(f"{i}. {title}")
|
|
295
|
+
print("-" * 50)
|
|
296
|
+
print()
|
|
297
|
+
print("press ctrl+c to exit at any point")
|
|
298
|
+
print("Options: <number> to select, 'n' for new chat, 'del <number>' to delete, 'del all' to delete all")
|
|
299
|
+
|
|
300
|
+
while True:
|
|
301
|
+
try:
|
|
302
|
+
choice = prompt("Select a chat: ").strip()
|
|
303
|
+
except KeyboardInterrupt:
|
|
304
|
+
print()
|
|
305
|
+
print("Agent: Goodbye")
|
|
306
|
+
sys.exit(0)
|
|
307
|
+
except EOFError:
|
|
308
|
+
print()
|
|
309
|
+
print("Agent: Goodbye")
|
|
310
|
+
sys.exit(0)
|
|
311
|
+
|
|
312
|
+
if choice.lower() == 'n':
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
# Handle delete command: del <number> or del all
|
|
316
|
+
del_match = re.match(r'^del\s+(.+)$', choice, re.IGNORECASE)
|
|
317
|
+
if del_match:
|
|
318
|
+
target = del_match.group(1).strip().lower()
|
|
319
|
+
|
|
320
|
+
# del all - delete all chats for this role
|
|
321
|
+
if target == 'all':
|
|
322
|
+
try:
|
|
323
|
+
confirm = prompt(f"Delete ALL {len(chats)} chats for role '{role}'? (y/n): ").strip().lower()
|
|
324
|
+
except (KeyboardInterrupt, EOFError):
|
|
325
|
+
print("\nDelete cancelled.")
|
|
326
|
+
continue
|
|
327
|
+
if confirm in ('y', 'yes'):
|
|
328
|
+
for chat in chats:
|
|
329
|
+
delete_chat(chat['id'])
|
|
330
|
+
print(f"Deleted {len(chats)} chats.\n\n")
|
|
331
|
+
return None
|
|
332
|
+
else:
|
|
333
|
+
print("Delete cancelled.")
|
|
334
|
+
continue
|
|
335
|
+
|
|
336
|
+
# del <number> - delete a specific chat
|
|
337
|
+
try:
|
|
338
|
+
delete_idx = int(target) - 1
|
|
339
|
+
if 0 <= delete_idx < len(chats):
|
|
340
|
+
chat_to_delete = chats[delete_idx]
|
|
341
|
+
try:
|
|
342
|
+
confirm = prompt(f"Delete chat '{chat_to_delete['title']}'? (y/n): ").strip().lower()
|
|
343
|
+
except (KeyboardInterrupt, EOFError):
|
|
344
|
+
print("\nDelete cancelled.")
|
|
345
|
+
continue
|
|
346
|
+
if confirm in ('y', 'yes'):
|
|
347
|
+
delete_chat(chat_to_delete['id'])
|
|
348
|
+
print("Chat deleted.\n\n")
|
|
349
|
+
# Refresh chat list
|
|
350
|
+
chats = list_chats(role)
|
|
351
|
+
if not chats:
|
|
352
|
+
return None
|
|
353
|
+
print(f"Available chats for role '{role}':")
|
|
354
|
+
print("-" * 50)
|
|
355
|
+
for i, chat in enumerate(chats, 1):
|
|
356
|
+
title = chat['title'] if chat['title'] else 'Untitled'
|
|
357
|
+
print(f"{i}. {title}")
|
|
358
|
+
print("-" * 50)
|
|
359
|
+
print()
|
|
360
|
+
print("press ctrl+c to exit at any point")
|
|
361
|
+
print("Options: <number> to select, 'n' for new chat, 'del <number>' to delete, 'del all' to delete all")
|
|
362
|
+
continue
|
|
363
|
+
else:
|
|
364
|
+
print("Delete cancelled.")
|
|
365
|
+
continue
|
|
366
|
+
else:
|
|
367
|
+
print("Invalid chat number. Try again.")
|
|
368
|
+
except (ValueError, IndexError):
|
|
369
|
+
print("Invalid delete format. Use 'del <number>' (e.g., del 1) or 'del all'.")
|
|
370
|
+
continue
|
|
371
|
+
|
|
372
|
+
# Handle selection
|
|
373
|
+
try:
|
|
374
|
+
choice_idx = int(choice) - 1
|
|
375
|
+
if 0 <= choice_idx < len(chats):
|
|
376
|
+
return chats[choice_idx]['id']
|
|
377
|
+
else:
|
|
378
|
+
print("Invalid chat number. Try again.")
|
|
379
|
+
except ValueError:
|
|
380
|
+
print("Please enter a valid number, 'n', or 'del <number>'.")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def get_chat_role(chat_id: int) -> Optional[str]:
|
|
384
|
+
"""Get the role associated with a chat ID."""
|
|
385
|
+
conn = _get_conn()
|
|
386
|
+
cursor = conn.cursor()
|
|
387
|
+
|
|
388
|
+
cursor.execute("""
|
|
389
|
+
SELECT role FROM chats
|
|
390
|
+
WHERE id = ?
|
|
391
|
+
""", (chat_id,))
|
|
392
|
+
|
|
393
|
+
result = cursor.fetchone()
|
|
394
|
+
conn.close()
|
|
395
|
+
|
|
396
|
+
return result[0] if result else None
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def create_session(chat_id: int, parent_session_id: Optional[int] = None, toolcall_id: Optional[str] = None, effort: Optional[int] = None, depth: int = 0) -> int:
|
|
400
|
+
"""Create a new session for a given chat and return its ID."""
|
|
401
|
+
conn = _get_conn()
|
|
402
|
+
cursor = conn.cursor()
|
|
403
|
+
|
|
404
|
+
cursor.execute("""
|
|
405
|
+
INSERT INTO sessions (chat_id, parent_session_id, toolcall_id, effort, depth)
|
|
406
|
+
VALUES (?, ?, ?, ?, ?)
|
|
407
|
+
""", (chat_id, parent_session_id, toolcall_id, effort, depth))
|
|
408
|
+
|
|
409
|
+
session_id = cursor.lastrowid
|
|
410
|
+
conn.commit()
|
|
411
|
+
conn.close()
|
|
412
|
+
|
|
413
|
+
return session_id
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def get_latest_session(chat_id: int) -> Optional[int]:
|
|
417
|
+
"""Get the most recent main-agent session ID for a given chat, or None if no sessions exist."""
|
|
418
|
+
conn = _get_conn()
|
|
419
|
+
cursor = conn.cursor()
|
|
420
|
+
|
|
421
|
+
cursor.execute("""
|
|
422
|
+
SELECT id FROM sessions
|
|
423
|
+
WHERE chat_id = ? AND parent_session_id IS NULL
|
|
424
|
+
ORDER BY id DESC
|
|
425
|
+
LIMIT 1
|
|
426
|
+
""", (chat_id,))
|
|
427
|
+
|
|
428
|
+
result = cursor.fetchone()
|
|
429
|
+
conn.close()
|
|
430
|
+
|
|
431
|
+
return result[0] if result else None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def get_or_create_session(chat_id: int, parent_session_id: Optional[int] = None, effort: Optional[int] = None) -> int:
|
|
435
|
+
"""Get the active session for a chat (following redirects), or create one if none exists."""
|
|
436
|
+
session_id = get_active_session(chat_id)
|
|
437
|
+
if session_id is None:
|
|
438
|
+
session_id = create_session(chat_id, parent_session_id, effort=effort)
|
|
439
|
+
elif effort is not None:
|
|
440
|
+
set_session_effort(session_id, effort)
|
|
441
|
+
return session_id
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def save_message(session_id: int, message: Dict):
|
|
445
|
+
"""Save a message to the database."""
|
|
446
|
+
conn = _get_conn()
|
|
447
|
+
cursor = conn.cursor()
|
|
448
|
+
|
|
449
|
+
# Ensure all values are strings before saving
|
|
450
|
+
role = message.get("role")
|
|
451
|
+
if role is not None:
|
|
452
|
+
role = str(role)
|
|
453
|
+
else:
|
|
454
|
+
role = "user"
|
|
455
|
+
|
|
456
|
+
content = message.get("content")
|
|
457
|
+
if content is not None:
|
|
458
|
+
if isinstance(content, (list, dict)):
|
|
459
|
+
content = json.dumps(content)
|
|
460
|
+
else:
|
|
461
|
+
content = str(content)
|
|
462
|
+
else:
|
|
463
|
+
content = ""
|
|
464
|
+
|
|
465
|
+
tool_calls = message.get("tool_calls")
|
|
466
|
+
tool_call_id = message.get("tool_call_id")
|
|
467
|
+
if tool_call_id is not None:
|
|
468
|
+
tool_call_id = str(tool_call_id)
|
|
469
|
+
|
|
470
|
+
# Convert tool_calls to JSON string if present
|
|
471
|
+
tool_calls_json = json.dumps(tool_calls) if tool_calls else None
|
|
472
|
+
|
|
473
|
+
cursor.execute("""
|
|
474
|
+
INSERT INTO messages (session_id, role, content, tool_calls, tool_call_id)
|
|
475
|
+
VALUES (?, ?, ?, ?, ?)
|
|
476
|
+
""", (session_id, role, content, tool_calls_json, tool_call_id))
|
|
477
|
+
|
|
478
|
+
# Update session's updated_at timestamp
|
|
479
|
+
cursor.execute("""
|
|
480
|
+
UPDATE sessions
|
|
481
|
+
SET updated_at = CURRENT_TIMESTAMP
|
|
482
|
+
WHERE id = ?
|
|
483
|
+
""", (session_id,))
|
|
484
|
+
|
|
485
|
+
# Update chat's updated_at timestamp
|
|
486
|
+
cursor.execute("""
|
|
487
|
+
UPDATE chats
|
|
488
|
+
SET updated_at = CURRENT_TIMESTAMP
|
|
489
|
+
WHERE id = (SELECT chat_id FROM sessions WHERE id = ?)
|
|
490
|
+
""", (session_id,))
|
|
491
|
+
|
|
492
|
+
conn.commit()
|
|
493
|
+
conn.close()
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def load_messages(session_id: int) -> List[Dict]:
|
|
497
|
+
"""Load all messages for a given session, ordered by timestamp."""
|
|
498
|
+
conn = _get_conn()
|
|
499
|
+
cursor = conn.cursor()
|
|
500
|
+
|
|
501
|
+
cursor.execute("""
|
|
502
|
+
SELECT role, content, tool_calls, tool_call_id
|
|
503
|
+
FROM messages
|
|
504
|
+
WHERE session_id = ?
|
|
505
|
+
ORDER BY timestamp ASC
|
|
506
|
+
""", (session_id,))
|
|
507
|
+
|
|
508
|
+
messages = []
|
|
509
|
+
for row in cursor.fetchall():
|
|
510
|
+
content_str = str(row[1]) if row[1] is not None else ""
|
|
511
|
+
try:
|
|
512
|
+
parsed = json.loads(content_str)
|
|
513
|
+
if isinstance(parsed, (list, dict)):
|
|
514
|
+
content = parsed
|
|
515
|
+
else:
|
|
516
|
+
content = content_str
|
|
517
|
+
except (json.JSONDecodeError, TypeError):
|
|
518
|
+
content = content_str
|
|
519
|
+
|
|
520
|
+
message = {
|
|
521
|
+
"role": str(row[0]) if row[0] is not None else "user",
|
|
522
|
+
"content": content,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
# Parse tool_calls from JSON if present
|
|
526
|
+
if row[2]:
|
|
527
|
+
try:
|
|
528
|
+
message["tool_calls"] = json.loads(row[2])
|
|
529
|
+
except (json.JSONDecodeError, TypeError):
|
|
530
|
+
message["tool_calls"] = None
|
|
531
|
+
|
|
532
|
+
# Add tool_call_id if present
|
|
533
|
+
if row[3]:
|
|
534
|
+
message["tool_call_id"] = str(row[3])
|
|
535
|
+
|
|
536
|
+
# Skip empty messages (no content, no tool_calls, no tool_call_id)
|
|
537
|
+
if not message.get("content") and not message.get("tool_calls") and not message.get("tool_call_id"):
|
|
538
|
+
continue
|
|
539
|
+
|
|
540
|
+
messages.append(message)
|
|
541
|
+
|
|
542
|
+
conn.close()
|
|
543
|
+
return messages
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def delete_chat(chat_id: int):
|
|
547
|
+
"""Delete a chat and all its associated sessions and messages."""
|
|
548
|
+
conn = _get_conn()
|
|
549
|
+
cursor = conn.cursor()
|
|
550
|
+
|
|
551
|
+
# Delete messages for all sessions in this chat
|
|
552
|
+
cursor.execute("""
|
|
553
|
+
DELETE FROM messages
|
|
554
|
+
WHERE session_id IN (SELECT id FROM sessions WHERE chat_id = ?)
|
|
555
|
+
""", (chat_id,))
|
|
556
|
+
|
|
557
|
+
# Delete sessions for this chat
|
|
558
|
+
cursor.execute("""
|
|
559
|
+
DELETE FROM sessions
|
|
560
|
+
WHERE chat_id = ?
|
|
561
|
+
""", (chat_id,))
|
|
562
|
+
|
|
563
|
+
# Delete the chat itself
|
|
564
|
+
cursor.execute("""
|
|
565
|
+
DELETE FROM chats
|
|
566
|
+
WHERE id = ?
|
|
567
|
+
""", (chat_id,))
|
|
568
|
+
|
|
569
|
+
conn.commit()
|
|
570
|
+
conn.close()
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def get_session_role(session_id: int) -> Optional[str]:
|
|
574
|
+
"""Get the role associated with a session by joining sessions -> chats."""
|
|
575
|
+
conn = _get_conn()
|
|
576
|
+
cursor = conn.cursor()
|
|
577
|
+
cursor.execute("""
|
|
578
|
+
SELECT c.role FROM chats c
|
|
579
|
+
JOIN sessions s ON s.chat_id = c.id
|
|
580
|
+
WHERE s.id = ?
|
|
581
|
+
""", (session_id,))
|
|
582
|
+
result = cursor.fetchone()
|
|
583
|
+
conn.close()
|
|
584
|
+
return result[0] if result else None
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def is_subagent_session(session_id: int) -> bool:
|
|
588
|
+
"""Check if a session is a subagent session (has a parent_session_id)."""
|
|
589
|
+
conn = _get_conn()
|
|
590
|
+
cursor = conn.cursor()
|
|
591
|
+
cursor.execute("""
|
|
592
|
+
SELECT parent_session_id FROM sessions WHERE id = ?
|
|
593
|
+
""", (session_id,))
|
|
594
|
+
result = cursor.fetchone()
|
|
595
|
+
conn.close()
|
|
596
|
+
return result is not None and result[0] is not None
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def get_child_sessions(parent_session_id: int) -> List[Dict]:
|
|
600
|
+
"""Get all child (subagent) sessions for a given parent session, ordered by id ASC."""
|
|
601
|
+
conn = _get_conn()
|
|
602
|
+
cursor = conn.cursor()
|
|
603
|
+
cursor.execute("""
|
|
604
|
+
SELECT id, chat_id, parent_session_id
|
|
605
|
+
FROM sessions
|
|
606
|
+
WHERE parent_session_id = ?
|
|
607
|
+
ORDER BY id ASC
|
|
608
|
+
""", (parent_session_id,))
|
|
609
|
+
sessions = []
|
|
610
|
+
for row in cursor.fetchall():
|
|
611
|
+
sessions.append({
|
|
612
|
+
"id": row[0],
|
|
613
|
+
"chat_id": row[1],
|
|
614
|
+
"parent_session_id": row[2],
|
|
615
|
+
})
|
|
616
|
+
conn.close()
|
|
617
|
+
return sessions
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def get_child_session_by_toolcall(parent_session_id: int, toolcall_id: str) -> Optional[Dict]:
|
|
621
|
+
"""Get the child (subagent) session matching a specific toolcall_id."""
|
|
622
|
+
conn = _get_conn()
|
|
623
|
+
cursor = conn.cursor()
|
|
624
|
+
cursor.execute("""
|
|
625
|
+
SELECT id, chat_id, parent_session_id, toolcall_id
|
|
626
|
+
FROM sessions
|
|
627
|
+
WHERE parent_session_id = ? AND toolcall_id = ?
|
|
628
|
+
LIMIT 1
|
|
629
|
+
""", (parent_session_id, toolcall_id))
|
|
630
|
+
row = cursor.fetchone()
|
|
631
|
+
conn.close()
|
|
632
|
+
if not row:
|
|
633
|
+
return None
|
|
634
|
+
return {
|
|
635
|
+
"id": row[0],
|
|
636
|
+
"chat_id": row[1],
|
|
637
|
+
"parent_session_id": row[2],
|
|
638
|
+
"toolcall_id": row[3],
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def resolve_session_id(session_id: int) -> int:
|
|
643
|
+
"""Follow the redirect_session_id chain to find the active session.
|
|
644
|
+
|
|
645
|
+
Returns the session_id itself if it has no redirect.
|
|
646
|
+
"""
|
|
647
|
+
conn = _get_conn()
|
|
648
|
+
cursor = conn.cursor()
|
|
649
|
+
current_id = session_id
|
|
650
|
+
visited = set()
|
|
651
|
+
try:
|
|
652
|
+
while current_id is not None and current_id not in visited:
|
|
653
|
+
visited.add(current_id)
|
|
654
|
+
cursor.execute(
|
|
655
|
+
"SELECT redirect_session_id FROM sessions WHERE id = ?",
|
|
656
|
+
(current_id,),
|
|
657
|
+
)
|
|
658
|
+
result = cursor.fetchone()
|
|
659
|
+
if result and result[0] is not None:
|
|
660
|
+
current_id = result[0]
|
|
661
|
+
else:
|
|
662
|
+
break
|
|
663
|
+
finally:
|
|
664
|
+
conn.close()
|
|
665
|
+
return current_id
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def get_session_info(session_id: int) -> Optional[Dict]:
|
|
669
|
+
"""Get session metadata: parent_session_id, toolcall_id, and depth."""
|
|
670
|
+
conn = _get_conn()
|
|
671
|
+
cursor = conn.cursor()
|
|
672
|
+
cursor.execute(
|
|
673
|
+
"SELECT parent_session_id, toolcall_id, depth FROM sessions WHERE id = ?",
|
|
674
|
+
(session_id,),
|
|
675
|
+
)
|
|
676
|
+
result = cursor.fetchone()
|
|
677
|
+
conn.close()
|
|
678
|
+
if not result:
|
|
679
|
+
return None
|
|
680
|
+
return {
|
|
681
|
+
"parent_session_id": result[0],
|
|
682
|
+
"toolcall_id": result[1],
|
|
683
|
+
"depth": result[2] or 0,
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def is_session_finished(session_id: int) -> bool:
|
|
688
|
+
"""Check if a session has completed (last message is an assistant response without tool_calls).
|
|
689
|
+
|
|
690
|
+
Follows the redirect chain to check the active session.
|
|
691
|
+
"""
|
|
692
|
+
active_id = resolve_session_id(session_id)
|
|
693
|
+
messages = load_messages(active_id)
|
|
694
|
+
if not messages:
|
|
695
|
+
return False
|
|
696
|
+
last_msg = messages[-1]
|
|
697
|
+
return last_msg.get("role") == "assistant" and not last_msg.get("tool_calls")
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def add_change(prompt_id: str, role: str, session_id: Optional[int], change_type: str,
|
|
701
|
+
file_path: Optional[str], description: str, details: Optional[str] = None) -> int:
|
|
702
|
+
"""Add a change record to the database and return its ID."""
|
|
703
|
+
conn = _get_conn()
|
|
704
|
+
cursor = conn.cursor()
|
|
705
|
+
|
|
706
|
+
cursor.execute("""
|
|
707
|
+
INSERT INTO changes (prompt_id, role, session_id, change_type, file_path, description, details)
|
|
708
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
709
|
+
""", (prompt_id, role, session_id, change_type, file_path, description, details))
|
|
710
|
+
|
|
711
|
+
change_id = cursor.lastrowid
|
|
712
|
+
conn.commit()
|
|
713
|
+
conn.close()
|
|
714
|
+
|
|
715
|
+
return change_id
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def get_changes_by_prompt(prompt_id: str) -> List[Dict]:
|
|
719
|
+
"""Get all changes for a specific prompt ID."""
|
|
720
|
+
conn = _get_conn()
|
|
721
|
+
cursor = conn.cursor()
|
|
722
|
+
|
|
723
|
+
cursor.execute("""
|
|
724
|
+
SELECT id, prompt_id, role, session_id, change_type, file_path, description, details, timestamp
|
|
725
|
+
FROM changes
|
|
726
|
+
WHERE prompt_id = ?
|
|
727
|
+
ORDER BY timestamp ASC
|
|
728
|
+
""", (prompt_id,))
|
|
729
|
+
|
|
730
|
+
changes = []
|
|
731
|
+
for row in cursor.fetchall():
|
|
732
|
+
changes.append({
|
|
733
|
+
"id": row[0],
|
|
734
|
+
"prompt_id": row[1],
|
|
735
|
+
"role": row[2],
|
|
736
|
+
"session_id": row[3],
|
|
737
|
+
"change_type": row[4],
|
|
738
|
+
"file_path": row[5],
|
|
739
|
+
"description": row[6],
|
|
740
|
+
"details": row[7],
|
|
741
|
+
"timestamp": row[8]
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
conn.close()
|
|
745
|
+
return changes
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def get_all_changes(limit: Optional[int] = None, offset: int = 0) -> List[Dict]:
|
|
749
|
+
"""Get all changes globally, optionally with pagination."""
|
|
750
|
+
conn = _get_conn()
|
|
751
|
+
cursor = conn.cursor()
|
|
752
|
+
|
|
753
|
+
query = """
|
|
754
|
+
SELECT id, prompt_id, role, session_id, change_type, file_path, description, details, timestamp
|
|
755
|
+
FROM changes
|
|
756
|
+
ORDER BY timestamp DESC
|
|
757
|
+
"""
|
|
758
|
+
|
|
759
|
+
if limit:
|
|
760
|
+
query += f" LIMIT {limit} OFFSET {offset}"
|
|
761
|
+
|
|
762
|
+
cursor.execute(query)
|
|
763
|
+
|
|
764
|
+
changes = []
|
|
765
|
+
for row in cursor.fetchall():
|
|
766
|
+
changes.append({
|
|
767
|
+
"id": row[0],
|
|
768
|
+
"prompt_id": row[1],
|
|
769
|
+
"role": row[2],
|
|
770
|
+
"session_id": row[3],
|
|
771
|
+
"change_type": row[4],
|
|
772
|
+
"file_path": row[5],
|
|
773
|
+
"description": row[6],
|
|
774
|
+
"details": row[7],
|
|
775
|
+
"timestamp": row[8]
|
|
776
|
+
})
|
|
777
|
+
|
|
778
|
+
conn.close()
|
|
779
|
+
return changes
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def get_changes_by_role(role: str, limit: Optional[int] = None) -> List[Dict]:
|
|
783
|
+
"""Get all changes for a specific role."""
|
|
784
|
+
conn = _get_conn()
|
|
785
|
+
cursor = conn.cursor()
|
|
786
|
+
|
|
787
|
+
query = """
|
|
788
|
+
SELECT id, prompt_id, role, session_id, change_type, file_path, description, details, timestamp
|
|
789
|
+
FROM changes
|
|
790
|
+
WHERE role = ?
|
|
791
|
+
ORDER BY timestamp DESC
|
|
792
|
+
"""
|
|
793
|
+
|
|
794
|
+
if limit:
|
|
795
|
+
query += f" LIMIT {limit}"
|
|
796
|
+
|
|
797
|
+
cursor.execute(query, (role,))
|
|
798
|
+
|
|
799
|
+
changes = []
|
|
800
|
+
for row in cursor.fetchall():
|
|
801
|
+
changes.append({
|
|
802
|
+
"id": row[0],
|
|
803
|
+
"prompt_id": row[1],
|
|
804
|
+
"role": row[2],
|
|
805
|
+
"session_id": row[3],
|
|
806
|
+
"change_type": row[4],
|
|
807
|
+
"file_path": row[5],
|
|
808
|
+
"description": row[6],
|
|
809
|
+
"details": row[7],
|
|
810
|
+
"timestamp": row[8]
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
conn.close()
|
|
814
|
+
return changes
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def get_changes_by_session(session_id: int) -> List[Dict]:
|
|
818
|
+
"""Get all changes for a specific session."""
|
|
819
|
+
conn = _get_conn()
|
|
820
|
+
cursor = conn.cursor()
|
|
821
|
+
|
|
822
|
+
cursor.execute("""
|
|
823
|
+
SELECT id, prompt_id, role, session_id, change_type, file_path, description, details, timestamp
|
|
824
|
+
FROM changes
|
|
825
|
+
WHERE session_id = ?
|
|
826
|
+
ORDER BY timestamp ASC
|
|
827
|
+
""", (session_id,))
|
|
828
|
+
|
|
829
|
+
changes = []
|
|
830
|
+
for row in cursor.fetchall():
|
|
831
|
+
changes.append({
|
|
832
|
+
"id": row[0],
|
|
833
|
+
"prompt_id": row[1],
|
|
834
|
+
"role": row[2],
|
|
835
|
+
"session_id": row[3],
|
|
836
|
+
"change_type": row[4],
|
|
837
|
+
"file_path": row[5],
|
|
838
|
+
"description": row[6],
|
|
839
|
+
"details": row[7],
|
|
840
|
+
"timestamp": row[8]
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
conn.close()
|
|
844
|
+
return changes
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def get_all_changes_by_session_chain(session_id: int) -> List[Dict]:
|
|
848
|
+
"""Get all changes across the session redirect chain.
|
|
849
|
+
|
|
850
|
+
When a session is handed over, changes are recorded on different sessions
|
|
851
|
+
in the chain. This function gathers them all.
|
|
852
|
+
"""
|
|
853
|
+
conn = _get_conn()
|
|
854
|
+
cursor = conn.cursor()
|
|
855
|
+
|
|
856
|
+
# Collect all session IDs in the redirect chain
|
|
857
|
+
session_ids = [session_id]
|
|
858
|
+
current_id = session_id
|
|
859
|
+
visited = set()
|
|
860
|
+
try:
|
|
861
|
+
while current_id is not None and current_id not in visited:
|
|
862
|
+
visited.add(current_id)
|
|
863
|
+
cursor.execute(
|
|
864
|
+
"SELECT redirect_session_id FROM sessions WHERE id = ?",
|
|
865
|
+
(current_id,),
|
|
866
|
+
)
|
|
867
|
+
result = cursor.fetchone()
|
|
868
|
+
if result and result[0] is not None:
|
|
869
|
+
current_id = result[0]
|
|
870
|
+
session_ids.append(current_id)
|
|
871
|
+
else:
|
|
872
|
+
break
|
|
873
|
+
finally:
|
|
874
|
+
conn.close()
|
|
875
|
+
|
|
876
|
+
all_changes = []
|
|
877
|
+
for sid in session_ids:
|
|
878
|
+
all_changes.extend(get_changes_by_session(sid))
|
|
879
|
+
|
|
880
|
+
all_changes.sort(key=lambda c: c.get("timestamp", ""))
|
|
881
|
+
return all_changes
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def create_todo_list(session_id: int) -> int:
|
|
885
|
+
"""Create a new todo list for a session and return its ID."""
|
|
886
|
+
conn = _get_conn()
|
|
887
|
+
cursor = conn.cursor()
|
|
888
|
+
|
|
889
|
+
cursor.execute("""
|
|
890
|
+
INSERT INTO todo_lists (session_id, status)
|
|
891
|
+
VALUES (?, 'pending')
|
|
892
|
+
""", (session_id,))
|
|
893
|
+
|
|
894
|
+
todo_list_id = cursor.lastrowid
|
|
895
|
+
conn.commit()
|
|
896
|
+
conn.close()
|
|
897
|
+
|
|
898
|
+
return todo_list_id
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def add_todo_task(todo_list_id: int, goal: str, requirements: Optional[str] = None,
|
|
902
|
+
notes: Optional[str] = None, order_index: int = None,
|
|
903
|
+
context: Optional[str] = None, insert_after: Optional[int] = None) -> int:
|
|
904
|
+
"""Add a task to a todo list and return its ID.
|
|
905
|
+
|
|
906
|
+
Position is controlled by insert_after (1-based display number):
|
|
907
|
+
- insert_after omitted / None → append at end
|
|
908
|
+
- insert_after = 0 → insert at the beginning (before task 1)
|
|
909
|
+
- insert_after = N → insert after task N (shifts subsequent tasks down)
|
|
910
|
+
|
|
911
|
+
order_index is deprecated and ignored if insert_after is provided.
|
|
912
|
+
"""
|
|
913
|
+
conn = _get_conn()
|
|
914
|
+
cursor = conn.cursor()
|
|
915
|
+
|
|
916
|
+
if insert_after is not None:
|
|
917
|
+
# Get current tasks ordered by order_index to map display number → order_index
|
|
918
|
+
cursor.execute("""
|
|
919
|
+
SELECT order_index FROM todo_tasks
|
|
920
|
+
WHERE todo_list_id = ?
|
|
921
|
+
ORDER BY order_index ASC
|
|
922
|
+
""", (todo_list_id,))
|
|
923
|
+
rows = cursor.fetchall()
|
|
924
|
+
|
|
925
|
+
if not rows:
|
|
926
|
+
# Empty list — insert at index 0
|
|
927
|
+
new_order_index = 0
|
|
928
|
+
elif insert_after >= len(rows):
|
|
929
|
+
# Insert after the last task — append at end
|
|
930
|
+
new_order_index = rows[-1][0] + 1
|
|
931
|
+
elif insert_after <= 0:
|
|
932
|
+
# Insert at the beginning
|
|
933
|
+
new_order_index = rows[0][0]
|
|
934
|
+
cursor.execute("""
|
|
935
|
+
UPDATE todo_tasks SET order_index = order_index + 1
|
|
936
|
+
WHERE todo_list_id = ?
|
|
937
|
+
""", (todo_list_id,))
|
|
938
|
+
else:
|
|
939
|
+
# Insert after task N (1-based): get the order_index of the Nth task
|
|
940
|
+
target_order_index = rows[insert_after - 1][0]
|
|
941
|
+
new_order_index = target_order_index + 1
|
|
942
|
+
# Shift all tasks after the target down by 1
|
|
943
|
+
cursor.execute("""
|
|
944
|
+
UPDATE todo_tasks SET order_index = order_index + 1
|
|
945
|
+
WHERE todo_list_id = ? AND order_index > ?
|
|
946
|
+
""", (todo_list_id, target_order_index))
|
|
947
|
+
elif order_index is not None:
|
|
948
|
+
# Legacy: explicit order_index with auto-shift
|
|
949
|
+
cursor.execute("""
|
|
950
|
+
UPDATE todo_tasks SET order_index = order_index + 1
|
|
951
|
+
WHERE todo_list_id = ? AND order_index >= ?
|
|
952
|
+
""", (todo_list_id, order_index))
|
|
953
|
+
new_order_index = order_index
|
|
954
|
+
else:
|
|
955
|
+
# Default: append at end
|
|
956
|
+
cursor.execute("""
|
|
957
|
+
SELECT MAX(order_index) FROM todo_tasks WHERE todo_list_id = ?
|
|
958
|
+
""", (todo_list_id,))
|
|
959
|
+
result = cursor.fetchone()
|
|
960
|
+
new_order_index = (result[0] + 1) if result and result[0] is not None else 0
|
|
961
|
+
|
|
962
|
+
cursor.execute("""
|
|
963
|
+
INSERT INTO todo_tasks (todo_list_id, goal, requirements, notes, context, status, order_index)
|
|
964
|
+
VALUES (?, ?, ?, ?, ?, 'pending', ?)
|
|
965
|
+
""", (todo_list_id, goal, requirements, notes, context, new_order_index))
|
|
966
|
+
|
|
967
|
+
task_id = cursor.lastrowid
|
|
968
|
+
conn.commit()
|
|
969
|
+
conn.close()
|
|
970
|
+
|
|
971
|
+
return task_id
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def get_todo_list(todo_list_id: int) -> Optional[Dict]:
|
|
975
|
+
"""Get a todo list by ID."""
|
|
976
|
+
conn = _get_conn()
|
|
977
|
+
cursor = conn.cursor()
|
|
978
|
+
|
|
979
|
+
cursor.execute("""
|
|
980
|
+
SELECT id, session_id, status, created_at, updated_at
|
|
981
|
+
FROM todo_lists
|
|
982
|
+
WHERE id = ?
|
|
983
|
+
""", (todo_list_id,))
|
|
984
|
+
|
|
985
|
+
result = cursor.fetchone()
|
|
986
|
+
conn.close()
|
|
987
|
+
|
|
988
|
+
if result:
|
|
989
|
+
return {
|
|
990
|
+
"id": result[0],
|
|
991
|
+
"session_id": result[1],
|
|
992
|
+
"status": result[2],
|
|
993
|
+
"created_at": result[3],
|
|
994
|
+
"updated_at": result[4]
|
|
995
|
+
}
|
|
996
|
+
return None
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def get_todo_tasks(todo_list_id: int) -> List[Dict]:
|
|
1000
|
+
"""Get all tasks for a todo list, ordered by order_index."""
|
|
1001
|
+
conn = _get_conn()
|
|
1002
|
+
cursor = conn.cursor()
|
|
1003
|
+
|
|
1004
|
+
cursor.execute("""
|
|
1005
|
+
SELECT id, goal, requirements, notes, context, status, order_index, toolcall_id, cancel_reason, created_at, updated_at
|
|
1006
|
+
FROM todo_tasks
|
|
1007
|
+
WHERE todo_list_id = ?
|
|
1008
|
+
ORDER BY order_index ASC
|
|
1009
|
+
""", (todo_list_id,))
|
|
1010
|
+
|
|
1011
|
+
tasks = []
|
|
1012
|
+
for row in cursor.fetchall():
|
|
1013
|
+
tasks.append({
|
|
1014
|
+
"id": row[0],
|
|
1015
|
+
"goal": row[1],
|
|
1016
|
+
"requirements": row[2],
|
|
1017
|
+
"notes": row[3],
|
|
1018
|
+
"context": row[4],
|
|
1019
|
+
"status": row[5],
|
|
1020
|
+
"order_index": row[6],
|
|
1021
|
+
"toolcall_id": row[7],
|
|
1022
|
+
"cancel_reason": row[8],
|
|
1023
|
+
"created_at": row[9],
|
|
1024
|
+
"updated_at": row[10]
|
|
1025
|
+
})
|
|
1026
|
+
|
|
1027
|
+
conn.close()
|
|
1028
|
+
return tasks
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def get_active_todo_list(session_id: int) -> Optional[Dict]:
|
|
1032
|
+
"""Get the active (pending, in_progress, or rejected) todo list for a session."""
|
|
1033
|
+
conn = _get_conn()
|
|
1034
|
+
cursor = conn.cursor()
|
|
1035
|
+
|
|
1036
|
+
cursor.execute("""
|
|
1037
|
+
SELECT id, session_id, status, created_at, updated_at
|
|
1038
|
+
FROM todo_lists
|
|
1039
|
+
WHERE session_id = ? AND status IN ('pending', 'in_progress', 'rejected')
|
|
1040
|
+
ORDER BY id DESC
|
|
1041
|
+
LIMIT 1
|
|
1042
|
+
""", (session_id,))
|
|
1043
|
+
|
|
1044
|
+
result = cursor.fetchone()
|
|
1045
|
+
conn.close()
|
|
1046
|
+
|
|
1047
|
+
if result:
|
|
1048
|
+
return {
|
|
1049
|
+
"id": result[0],
|
|
1050
|
+
"session_id": result[1],
|
|
1051
|
+
"status": result[2],
|
|
1052
|
+
"created_at": result[3],
|
|
1053
|
+
"updated_at": result[4]
|
|
1054
|
+
}
|
|
1055
|
+
return None
|
|
1056
|
+
|
|
1057
|
+
|
|
1058
|
+
def get_chat_id_for_session(session_id: int) -> Optional[int]:
|
|
1059
|
+
"""Get the chat_id associated with a session."""
|
|
1060
|
+
conn = _get_conn()
|
|
1061
|
+
cursor = conn.cursor()
|
|
1062
|
+
cursor.execute("""
|
|
1063
|
+
SELECT chat_id FROM sessions WHERE id = ?
|
|
1064
|
+
""", (session_id,))
|
|
1065
|
+
result = cursor.fetchone()
|
|
1066
|
+
conn.close()
|
|
1067
|
+
return result[0] if result else None
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def migrate_todo_lists(old_session_id: int, new_session_id: int):
|
|
1071
|
+
"""Move all todo lists from old_session_id to new_session_id (used during handover)."""
|
|
1072
|
+
conn = _get_conn()
|
|
1073
|
+
cursor = conn.cursor()
|
|
1074
|
+
cursor.execute("""
|
|
1075
|
+
UPDATE todo_lists
|
|
1076
|
+
SET session_id = ?, updated_at = CURRENT_TIMESTAMP
|
|
1077
|
+
WHERE session_id = ?
|
|
1078
|
+
""", (new_session_id, old_session_id))
|
|
1079
|
+
conn.commit()
|
|
1080
|
+
conn.close()
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def update_todo_list_status(todo_list_id: int, status: str):
|
|
1084
|
+
"""Update the status of a todo list."""
|
|
1085
|
+
conn = _get_conn()
|
|
1086
|
+
cursor = conn.cursor()
|
|
1087
|
+
|
|
1088
|
+
cursor.execute("""
|
|
1089
|
+
UPDATE todo_lists
|
|
1090
|
+
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
|
1091
|
+
WHERE id = ?
|
|
1092
|
+
""", (status, todo_list_id))
|
|
1093
|
+
|
|
1094
|
+
conn.commit()
|
|
1095
|
+
conn.close()
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def update_task_status(task_id: int, status: str, cancel_reason: Optional[str] = None):
|
|
1099
|
+
"""Update the status of a task. Optionally store a cancel_reason when cancelling."""
|
|
1100
|
+
conn = _get_conn()
|
|
1101
|
+
cursor = conn.cursor()
|
|
1102
|
+
|
|
1103
|
+
if cancel_reason is not None:
|
|
1104
|
+
cursor.execute("""
|
|
1105
|
+
UPDATE todo_tasks
|
|
1106
|
+
SET status = ?, cancel_reason = ?, updated_at = CURRENT_TIMESTAMP
|
|
1107
|
+
WHERE id = ?
|
|
1108
|
+
""", (status, cancel_reason, task_id))
|
|
1109
|
+
else:
|
|
1110
|
+
cursor.execute("""
|
|
1111
|
+
UPDATE todo_tasks
|
|
1112
|
+
SET status = ?, updated_at = CURRENT_TIMESTAMP
|
|
1113
|
+
WHERE id = ?
|
|
1114
|
+
""", (status, task_id))
|
|
1115
|
+
|
|
1116
|
+
conn.commit()
|
|
1117
|
+
conn.close()
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def delete_todo_list(todo_list_id: int):
|
|
1121
|
+
"""Delete a todo list and all its tasks."""
|
|
1122
|
+
conn = _get_conn()
|
|
1123
|
+
cursor = conn.cursor()
|
|
1124
|
+
|
|
1125
|
+
# Delete all tasks first
|
|
1126
|
+
cursor.execute("""
|
|
1127
|
+
DELETE FROM todo_tasks
|
|
1128
|
+
WHERE todo_list_id = ?
|
|
1129
|
+
""", (todo_list_id,))
|
|
1130
|
+
|
|
1131
|
+
# Delete the todo list itself
|
|
1132
|
+
cursor.execute("""
|
|
1133
|
+
DELETE FROM todo_lists
|
|
1134
|
+
WHERE id = ?
|
|
1135
|
+
""", (todo_list_id,))
|
|
1136
|
+
|
|
1137
|
+
conn.commit()
|
|
1138
|
+
conn.close()
|
|
1139
|
+
|
|
1140
|
+
|
|
1141
|
+
def get_next_pending_task(todo_list_id: int) -> Optional[Dict]:
|
|
1142
|
+
"""Get the next pending or in_progress task for a todo list.
|
|
1143
|
+
|
|
1144
|
+
Returns in_progress tasks first (for crash recovery resumption),
|
|
1145
|
+
then pending tasks.
|
|
1146
|
+
"""
|
|
1147
|
+
conn = _get_conn()
|
|
1148
|
+
cursor = conn.cursor()
|
|
1149
|
+
|
|
1150
|
+
# Check for an in_progress task first (crash recovery)
|
|
1151
|
+
cursor.execute("""
|
|
1152
|
+
SELECT id, goal, requirements, notes, context, status, order_index, toolcall_id, cancel_reason, created_at, updated_at
|
|
1153
|
+
FROM todo_tasks
|
|
1154
|
+
WHERE todo_list_id = ? AND status = 'in_progress'
|
|
1155
|
+
ORDER BY order_index ASC
|
|
1156
|
+
LIMIT 1
|
|
1157
|
+
""", (todo_list_id,))
|
|
1158
|
+
|
|
1159
|
+
result = cursor.fetchone()
|
|
1160
|
+
|
|
1161
|
+
if not result:
|
|
1162
|
+
# No in_progress task — get the next pending one
|
|
1163
|
+
cursor.execute("""
|
|
1164
|
+
SELECT id, goal, requirements, notes, context, status, order_index, toolcall_id, cancel_reason, created_at, updated_at
|
|
1165
|
+
FROM todo_tasks
|
|
1166
|
+
WHERE todo_list_id = ? AND status = 'pending'
|
|
1167
|
+
ORDER BY order_index ASC
|
|
1168
|
+
LIMIT 1
|
|
1169
|
+
""", (todo_list_id,))
|
|
1170
|
+
|
|
1171
|
+
result = cursor.fetchone()
|
|
1172
|
+
|
|
1173
|
+
conn.close()
|
|
1174
|
+
|
|
1175
|
+
if result:
|
|
1176
|
+
return {
|
|
1177
|
+
"id": result[0],
|
|
1178
|
+
"goal": result[1],
|
|
1179
|
+
"requirements": result[2],
|
|
1180
|
+
"notes": result[3],
|
|
1181
|
+
"context": result[4],
|
|
1182
|
+
"status": result[5],
|
|
1183
|
+
"order_index": result[6],
|
|
1184
|
+
"toolcall_id": result[7],
|
|
1185
|
+
"cancel_reason": result[8],
|
|
1186
|
+
"created_at": result[9],
|
|
1187
|
+
"updated_at": result[10]
|
|
1188
|
+
}
|
|
1189
|
+
return None
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def set_task_toolcall_id(task_id: int, toolcall_id: str):
|
|
1193
|
+
"""Store the toolcall_id used when starting a task, for crash recovery resumption."""
|
|
1194
|
+
conn = _get_conn()
|
|
1195
|
+
cursor = conn.cursor()
|
|
1196
|
+
cursor.execute("""
|
|
1197
|
+
UPDATE todo_tasks SET toolcall_id = ?, updated_at = CURRENT_TIMESTAMP
|
|
1198
|
+
WHERE id = ?
|
|
1199
|
+
""", (toolcall_id, task_id))
|
|
1200
|
+
conn.commit()
|
|
1201
|
+
conn.close()
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def record_session_file(session_id: int, file_path: str, operation: str):
|
|
1205
|
+
"""Record a file modification in a session."""
|
|
1206
|
+
conn = _get_conn()
|
|
1207
|
+
cursor = conn.cursor()
|
|
1208
|
+
|
|
1209
|
+
cursor.execute("""
|
|
1210
|
+
INSERT INTO session_files (session_id, file_path, operation)
|
|
1211
|
+
VALUES (?, ?, ?)
|
|
1212
|
+
""", (session_id, file_path, operation))
|
|
1213
|
+
|
|
1214
|
+
conn.commit()
|
|
1215
|
+
conn.close()
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
def get_session_files(session_id: int) -> List[Dict]:
|
|
1219
|
+
"""Get all file modifications recorded for a session, ordered by timestamp."""
|
|
1220
|
+
conn = _get_conn()
|
|
1221
|
+
cursor = conn.cursor()
|
|
1222
|
+
|
|
1223
|
+
cursor.execute("""
|
|
1224
|
+
SELECT id, session_id, file_path, operation, timestamp
|
|
1225
|
+
FROM session_files
|
|
1226
|
+
WHERE session_id = ?
|
|
1227
|
+
ORDER BY timestamp ASC
|
|
1228
|
+
""", (session_id,))
|
|
1229
|
+
|
|
1230
|
+
files = []
|
|
1231
|
+
for row in cursor.fetchall():
|
|
1232
|
+
files.append({
|
|
1233
|
+
"id": row[0],
|
|
1234
|
+
"session_id": row[1],
|
|
1235
|
+
"file_path": row[2],
|
|
1236
|
+
"operation": row[3],
|
|
1237
|
+
"timestamp": row[4]
|
|
1238
|
+
})
|
|
1239
|
+
|
|
1240
|
+
conn.close()
|
|
1241
|
+
return files
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def get_all_session_files_chain(session_id: int) -> List[Dict]:
|
|
1245
|
+
"""Get all file modifications across the session redirect chain.
|
|
1246
|
+
|
|
1247
|
+
When a session is handed over, file modifications are recorded on different
|
|
1248
|
+
sessions in the chain. This function gathers them all.
|
|
1249
|
+
"""
|
|
1250
|
+
conn = _get_conn()
|
|
1251
|
+
cursor = conn.cursor()
|
|
1252
|
+
|
|
1253
|
+
session_ids = [session_id]
|
|
1254
|
+
current_id = session_id
|
|
1255
|
+
visited = set()
|
|
1256
|
+
try:
|
|
1257
|
+
while current_id is not None and current_id not in visited:
|
|
1258
|
+
visited.add(current_id)
|
|
1259
|
+
cursor.execute(
|
|
1260
|
+
"SELECT redirect_session_id FROM sessions WHERE id = ?",
|
|
1261
|
+
(current_id,),
|
|
1262
|
+
)
|
|
1263
|
+
result = cursor.fetchone()
|
|
1264
|
+
if result and result[0] is not None:
|
|
1265
|
+
current_id = result[0]
|
|
1266
|
+
session_ids.append(current_id)
|
|
1267
|
+
else:
|
|
1268
|
+
break
|
|
1269
|
+
finally:
|
|
1270
|
+
conn.close()
|
|
1271
|
+
|
|
1272
|
+
all_files = []
|
|
1273
|
+
for sid in session_ids:
|
|
1274
|
+
all_files.extend(get_session_files(sid))
|
|
1275
|
+
|
|
1276
|
+
all_files.sort(key=lambda f: f.get("timestamp", ""))
|
|
1277
|
+
return all_files
|
|
1278
|
+
|
|
1279
|
+
|
|
1280
|
+
def get_old_session_ids(chat_id: int, active_session_id: int) -> List[int]:
|
|
1281
|
+
"""Get session IDs for a chat that precede the active session, ordered oldest first.
|
|
1282
|
+
|
|
1283
|
+
These are sessions whose messages should be displayed as read-only context
|
|
1284
|
+
but NOT included in the agent's chat_history.
|
|
1285
|
+
"""
|
|
1286
|
+
conn = _get_conn()
|
|
1287
|
+
cursor = conn.cursor()
|
|
1288
|
+
cursor.execute("""
|
|
1289
|
+
SELECT id FROM sessions
|
|
1290
|
+
WHERE chat_id = ? AND id != ? AND parent_session_id IS NULL
|
|
1291
|
+
ORDER BY id ASC
|
|
1292
|
+
""", (chat_id, active_session_id))
|
|
1293
|
+
session_ids = [row[0] for row in cursor.fetchall()]
|
|
1294
|
+
conn.close()
|
|
1295
|
+
return session_ids
|
|
1296
|
+
|
|
1297
|
+
|
|
1298
|
+
def set_redirect_session_id(session_id: int, redirect_session_id: int):
|
|
1299
|
+
"""Set the redirect_session_id on a session, pointing to the new active session."""
|
|
1300
|
+
conn = _get_conn()
|
|
1301
|
+
cursor = conn.cursor()
|
|
1302
|
+
cursor.execute("""
|
|
1303
|
+
UPDATE sessions
|
|
1304
|
+
SET redirect_session_id = ?, updated_at = CURRENT_TIMESTAMP
|
|
1305
|
+
WHERE id = ?
|
|
1306
|
+
""", (redirect_session_id, session_id))
|
|
1307
|
+
conn.commit()
|
|
1308
|
+
conn.close()
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def get_session_effort(session_id: int) -> Optional[int]:
|
|
1312
|
+
"""Get the effort level stored on a session."""
|
|
1313
|
+
conn = _get_conn()
|
|
1314
|
+
cursor = conn.cursor()
|
|
1315
|
+
cursor.execute("SELECT effort FROM sessions WHERE id = ?", (session_id,))
|
|
1316
|
+
result = cursor.fetchone()
|
|
1317
|
+
conn.close()
|
|
1318
|
+
return result[0] if result else None
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def set_session_effort(session_id: int, effort: int):
|
|
1322
|
+
"""Set the effort level on a session."""
|
|
1323
|
+
conn = _get_conn()
|
|
1324
|
+
cursor = conn.cursor()
|
|
1325
|
+
cursor.execute("UPDATE sessions SET effort = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (effort, session_id))
|
|
1326
|
+
conn.commit()
|
|
1327
|
+
conn.close()
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def get_session_depth(session_id: int) -> int:
|
|
1331
|
+
"""Get the depth stored on a session (0 for main sessions, increments for subagents)."""
|
|
1332
|
+
conn = _get_conn()
|
|
1333
|
+
cursor = conn.cursor()
|
|
1334
|
+
cursor.execute("SELECT depth FROM sessions WHERE id = ?", (session_id,))
|
|
1335
|
+
result = cursor.fetchone()
|
|
1336
|
+
conn.close()
|
|
1337
|
+
return result[0] if result and result[0] is not None else 0
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def get_redirect_session_id(session_id: int) -> Optional[int]:
|
|
1341
|
+
"""Get the redirect_session_id for a session, if any."""
|
|
1342
|
+
conn = _get_conn()
|
|
1343
|
+
cursor = conn.cursor()
|
|
1344
|
+
cursor.execute("""
|
|
1345
|
+
SELECT redirect_session_id FROM sessions WHERE id = ?
|
|
1346
|
+
""", (session_id,))
|
|
1347
|
+
result = cursor.fetchone()
|
|
1348
|
+
conn.close()
|
|
1349
|
+
return result[0] if result and result[0] is not None else None
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def get_active_session(chat_id: int) -> Optional[int]:
|
|
1353
|
+
"""Get the active session for a chat by following the redirect chain.
|
|
1354
|
+
|
|
1355
|
+
Returns the latest session that does NOT have a redirect_session_id set,
|
|
1356
|
+
or the session at the end of the redirect chain.
|
|
1357
|
+
"""
|
|
1358
|
+
conn = _get_conn()
|
|
1359
|
+
cursor = conn.cursor()
|
|
1360
|
+
|
|
1361
|
+
# Get the latest main-agent session for this chat (exclude subagent sessions)
|
|
1362
|
+
cursor.execute("""
|
|
1363
|
+
SELECT id, redirect_session_id FROM sessions
|
|
1364
|
+
WHERE chat_id = ? AND parent_session_id IS NULL
|
|
1365
|
+
ORDER BY id DESC
|
|
1366
|
+
LIMIT 1
|
|
1367
|
+
""", (chat_id,))
|
|
1368
|
+
result = cursor.fetchone()
|
|
1369
|
+
conn.close()
|
|
1370
|
+
|
|
1371
|
+
if not result:
|
|
1372
|
+
return None
|
|
1373
|
+
|
|
1374
|
+
session_id = result[0]
|
|
1375
|
+
redirect_id = result[1]
|
|
1376
|
+
|
|
1377
|
+
# Follow the redirect chain
|
|
1378
|
+
visited = set()
|
|
1379
|
+
while redirect_id is not None and redirect_id not in visited:
|
|
1380
|
+
visited.add(redirect_id)
|
|
1381
|
+
conn = _get_conn()
|
|
1382
|
+
cursor = conn.cursor()
|
|
1383
|
+
cursor.execute("""
|
|
1384
|
+
SELECT id, redirect_session_id FROM sessions WHERE id = ?
|
|
1385
|
+
""", (redirect_id,))
|
|
1386
|
+
row = cursor.fetchone()
|
|
1387
|
+
conn.close()
|
|
1388
|
+
if not row:
|
|
1389
|
+
break
|
|
1390
|
+
session_id = row[0]
|
|
1391
|
+
redirect_id = row[1]
|
|
1392
|
+
|
|
1393
|
+
return session_id
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def save_handover(session_id: int, new_session_id: int, handover_text: str,
|
|
1397
|
+
token_usage: Optional[int] = None, context_window: Optional[int] = None) -> int:
|
|
1398
|
+
"""Save a handover record to the database and return its ID."""
|
|
1399
|
+
conn = _get_conn()
|
|
1400
|
+
cursor = conn.cursor()
|
|
1401
|
+
cursor.execute("""
|
|
1402
|
+
INSERT INTO handovers (session_id, new_session_id, handover_text, token_usage, context_window)
|
|
1403
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1404
|
+
""", (session_id, new_session_id, handover_text, token_usage, context_window))
|
|
1405
|
+
handover_id = cursor.lastrowid
|
|
1406
|
+
conn.commit()
|
|
1407
|
+
conn.close()
|
|
1408
|
+
return handover_id
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def get_handover(handover_id: int) -> Optional[Dict]:
|
|
1412
|
+
"""Get a handover record by ID."""
|
|
1413
|
+
conn = _get_conn()
|
|
1414
|
+
cursor = conn.cursor()
|
|
1415
|
+
cursor.execute("""
|
|
1416
|
+
SELECT id, session_id, new_session_id, handover_text, token_usage, context_window, created_at
|
|
1417
|
+
FROM handovers
|
|
1418
|
+
WHERE id = ?
|
|
1419
|
+
""", (handover_id,))
|
|
1420
|
+
row = cursor.fetchone()
|
|
1421
|
+
conn.close()
|
|
1422
|
+
if not row:
|
|
1423
|
+
return None
|
|
1424
|
+
return {
|
|
1425
|
+
"id": row[0],
|
|
1426
|
+
"session_id": row[1],
|
|
1427
|
+
"new_session_id": row[2],
|
|
1428
|
+
"handover_text": row[3],
|
|
1429
|
+
"token_usage": row[4],
|
|
1430
|
+
"context_window": row[5],
|
|
1431
|
+
"created_at": row[6],
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
|
|
1435
|
+
def get_handovers_by_session(session_id: int) -> List[Dict]:
|
|
1436
|
+
"""Get all handover records for a given session."""
|
|
1437
|
+
conn = _get_conn()
|
|
1438
|
+
cursor = conn.cursor()
|
|
1439
|
+
cursor.execute("""
|
|
1440
|
+
SELECT id, session_id, new_session_id, handover_text, token_usage, context_window, created_at
|
|
1441
|
+
FROM handovers
|
|
1442
|
+
WHERE session_id = ?
|
|
1443
|
+
ORDER BY created_at ASC
|
|
1444
|
+
""", (session_id,))
|
|
1445
|
+
handovers = []
|
|
1446
|
+
for row in cursor.fetchall():
|
|
1447
|
+
handovers.append({
|
|
1448
|
+
"id": row[0],
|
|
1449
|
+
"session_id": row[1],
|
|
1450
|
+
"new_session_id": row[2],
|
|
1451
|
+
"handover_text": row[3],
|
|
1452
|
+
"token_usage": row[4],
|
|
1453
|
+
"context_window": row[5],
|
|
1454
|
+
"created_at": row[6],
|
|
1455
|
+
})
|
|
1456
|
+
conn.close()
|
|
1457
|
+
return handovers
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def get_root_session_id(session_id: int) -> int:
|
|
1461
|
+
"""Traverse up the parent_session_id chain to find the root session.
|
|
1462
|
+
|
|
1463
|
+
Returns the session_id itself if it has no parent.
|
|
1464
|
+
"""
|
|
1465
|
+
conn = _get_conn()
|
|
1466
|
+
cursor = conn.cursor()
|
|
1467
|
+
current_id = session_id
|
|
1468
|
+
visited = set()
|
|
1469
|
+
try:
|
|
1470
|
+
while current_id is not None and current_id not in visited:
|
|
1471
|
+
visited.add(current_id)
|
|
1472
|
+
cursor.execute(
|
|
1473
|
+
"SELECT parent_session_id FROM sessions WHERE id = ?",
|
|
1474
|
+
(current_id,),
|
|
1475
|
+
)
|
|
1476
|
+
result = cursor.fetchone()
|
|
1477
|
+
if result and result[0] is not None:
|
|
1478
|
+
current_id = result[0]
|
|
1479
|
+
else:
|
|
1480
|
+
break
|
|
1481
|
+
finally:
|
|
1482
|
+
conn.close()
|
|
1483
|
+
return current_id
|
|
1484
|
+
|
|
1485
|
+
|
|
1486
|
+
def is_global_todo_enabled(session_id: int) -> bool:
|
|
1487
|
+
"""Check if globalTodo is enabled for the role associated with this session."""
|
|
1488
|
+
role = get_session_role(session_id)
|
|
1489
|
+
if not role:
|
|
1490
|
+
return False
|
|
1491
|
+
from Agent.config import load_roles
|
|
1492
|
+
roles = load_roles()
|
|
1493
|
+
return roles.get(role, {}).get("globalTodo", False)
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def resolve_todo_session_id(session_id: int) -> int:
|
|
1497
|
+
"""If globalTodo is enabled, return the root session ID; otherwise return session_id."""
|
|
1498
|
+
if is_global_todo_enabled(session_id):
|
|
1499
|
+
return get_root_session_id(session_id)
|
|
1500
|
+
return session_id
|