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.
Files changed (93) hide show
  1. Agent/__init__.py +0 -0
  2. Agent/agent.py +891 -0
  3. Agent/chat_history_db.py +1500 -0
  4. Agent/command.py +49 -0
  5. Agent/config.py +46 -0
  6. Agent/effort_levels.py +33 -0
  7. Agent/git_manager.py +727 -0
  8. Agent/tools.py +35 -0
  9. Commands/__init__.py +18 -0
  10. Commands/effort.py +42 -0
  11. Commands/global_todo.py +23 -0
  12. Commands/help.py +22 -0
  13. Commands/reasoning.py +24 -0
  14. Commands/redo.py +11 -0
  15. Commands/reindex.py +27 -0
  16. Commands/shell.py +28 -0
  17. Commands/stream.py +24 -0
  18. Commands/undo.py +13 -0
  19. Commands/unlimited_effort.py +8 -0
  20. Commands/window_size.py +29 -0
  21. RAG/__init__.py +0 -0
  22. RAG/document.py +119 -0
  23. RAG/find.py +408 -0
  24. RAG/graph.py +231 -0
  25. Tools/GetFileCodeStructure.py +43 -0
  26. Tools/GetSymbolSourceCode.py +27 -0
  27. Tools/__init__.py +39 -0
  28. Tools/ask_user.py +102 -0
  29. Tools/dispatch_subagent.py +215 -0
  30. Tools/document.py +35 -0
  31. Tools/edit_symbol.py +250 -0
  32. Tools/fuzzy_search.py +119 -0
  33. Tools/list_dir.py +51 -0
  34. Tools/read.py +49 -0
  35. Tools/read_image.py +75 -0
  36. Tools/remove.py +75 -0
  37. Tools/replace.py +305 -0
  38. Tools/search.py +41 -0
  39. Tools/shell.py +149 -0
  40. Tools/shell_kill.py +87 -0
  41. Tools/temp_background_service.py +113 -0
  42. Tools/todo_list.py +481 -0
  43. Tools/utils.py +116 -0
  44. Tools/view_changes.py +179 -0
  45. Tools/walk_call_tree.py +30 -0
  46. Tools/web_fetch.py +175 -0
  47. Tools/web_search.py +69 -0
  48. Tools/write.py +48 -0
  49. cli.py +111 -0
  50. config/__init__.py +0 -0
  51. config/coder_system_prompt.md +119 -0
  52. config/roles.json +43 -0
  53. config/tools.json +709 -0
  54. indexing/__init__.py +0 -0
  55. indexing/cli.py +128 -0
  56. indexing/code_index_sdk.py +832 -0
  57. indexing/code_indexer.py +1763 -0
  58. indexing/db_schema.py +396 -0
  59. indexing/export_to_json.py +346 -0
  60. indexing/extractors.py +189 -0
  61. indexing/file_utils.py +97 -0
  62. indexing/frontend/__init__.py +0 -0
  63. indexing/frontend/css_extractor.py +195 -0
  64. indexing/frontend/css_parser.py +387 -0
  65. indexing/frontend/css_selector_utils.py +226 -0
  66. indexing/frontend/edit_safety.py +573 -0
  67. indexing/frontend/graph.py +838 -0
  68. indexing/frontend/html_extractor.py +496 -0
  69. indexing/frontend/html_parser.py +314 -0
  70. indexing/frontend/jsx_extractor.py +1204 -0
  71. indexing/frontend/location_lookup.py +247 -0
  72. indexing/frontend/resolver.py +485 -0
  73. indexing/frontend/runtime_resolver.py +862 -0
  74. indexing/frontend/semantic_output.py +705 -0
  75. indexing/frontend/source_location.py +69 -0
  76. indexing/frontend_config.py +72 -0
  77. indexing/frontend_models.py +347 -0
  78. indexing/language_config.py +360 -0
  79. indexing/models.py +284 -0
  80. indexing/node_utils.py +1112 -0
  81. indexing/parse_worker.py +1082 -0
  82. indexing/queries.py +1542 -0
  83. indexing/sdk_examples.py +426 -0
  84. interactive.py +248 -0
  85. raggie.py +673 -0
  86. raggiecode-0.2.1.dist-info/METADATA +944 -0
  87. raggiecode-0.2.1.dist-info/RECORD +93 -0
  88. raggiecode-0.2.1.dist-info/WHEEL +5 -0
  89. raggiecode-0.2.1.dist-info/entry_points.txt +2 -0
  90. raggiecode-0.2.1.dist-info/top_level.txt +10 -0
  91. skills/__init__.py +3 -0
  92. skills/manager.py +114 -0
  93. skills/tool.py +121 -0
@@ -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