aline-ai 0.6.5__py3-none-any.whl → 0.6.6__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 (38) hide show
  1. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/METADATA +1 -1
  2. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/RECORD +38 -31
  3. realign/__init__.py +1 -1
  4. realign/agent_names.py +79 -0
  5. realign/claude_hooks/stop_hook.py +3 -0
  6. realign/claude_hooks/terminal_state.py +11 -0
  7. realign/claude_hooks/user_prompt_submit_hook.py +3 -0
  8. realign/cli.py +62 -0
  9. realign/codex_detector.py +1 -1
  10. realign/codex_home.py +46 -15
  11. realign/codex_terminal_linker.py +18 -7
  12. realign/commands/agent.py +109 -0
  13. realign/commands/export_shares.py +297 -0
  14. realign/commands/search.py +58 -29
  15. realign/dashboard/app.py +9 -9
  16. realign/dashboard/clipboard.py +54 -0
  17. realign/dashboard/screens/__init__.py +4 -0
  18. realign/dashboard/screens/agent_detail.py +333 -0
  19. realign/dashboard/screens/create_agent_info.py +133 -0
  20. realign/dashboard/screens/event_detail.py +6 -27
  21. realign/dashboard/styles/dashboard.tcss +67 -0
  22. realign/dashboard/widgets/__init__.py +2 -0
  23. realign/dashboard/widgets/agents_panel.py +1129 -0
  24. realign/dashboard/widgets/events_table.py +4 -27
  25. realign/dashboard/widgets/sessions_table.py +4 -27
  26. realign/dashboard/widgets/terminal_panel.py +40 -5
  27. realign/db/base.py +27 -0
  28. realign/db/locks.py +4 -0
  29. realign/db/schema.py +53 -2
  30. realign/db/sqlite_db.py +185 -2
  31. realign/events/agent_summarizer.py +157 -0
  32. realign/events/session_summarizer.py +25 -0
  33. realign/watcher_core.py +60 -3
  34. realign/worker_core.py +24 -1
  35. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/WHEEL +0 -0
  36. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/entry_points.txt +0 -0
  37. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/licenses/LICENSE +0 -0
  38. {aline_ai-0.6.5.dist-info → aline_ai-0.6.6.dist-info}/top_level.txt +0 -0
@@ -3,9 +3,6 @@
3
3
  import contextlib
4
4
  import io
5
5
  import json
6
- import os
7
- import shutil
8
- import subprocess
9
6
  import traceback
10
7
  from datetime import datetime
11
8
  from typing import Optional, Set
@@ -20,6 +17,7 @@ from textual.worker import Worker, WorkerState
20
17
  from textual.widgets import Button, DataTable, Static
21
18
 
22
19
  from ...logging_config import setup_logger
20
+ from ..clipboard import copy_text
23
21
  from .openable_table import OpenableDataTable
24
22
 
25
23
  logger = setup_logger("realign.dashboard.events", "dashboard.log")
@@ -613,33 +611,12 @@ class EventsTable(Container):
613
611
 
614
612
  # Build copy text
615
613
  if slack_message:
616
- copy_text = str(slack_message) + "\n\n" + str(share_link)
614
+ text_to_copy = str(slack_message) + "\n\n" + str(share_link)
617
615
  else:
618
- copy_text = str(share_link)
616
+ text_to_copy = str(share_link)
619
617
 
620
618
  # Copy to clipboard
621
- copied = False
622
- if os.environ.get("TMUX") and shutil.which("pbcopy"):
623
- try:
624
- copied = (
625
- subprocess.run(
626
- ["pbcopy"],
627
- input=copy_text,
628
- text=True,
629
- capture_output=False,
630
- check=False,
631
- ).returncode
632
- == 0
633
- )
634
- except Exception:
635
- copied = False
636
-
637
- if not copied:
638
- try:
639
- self.app.copy_to_clipboard(copy_text)
640
- copied = True
641
- except Exception:
642
- copied = False
619
+ copied = copy_text(self.app, text_to_copy)
643
620
 
644
621
  suffix = " (copied to clipboard)" if copied else ""
645
622
  self.app.notify(f"Share link created{suffix}", title="Share", timeout=4)
@@ -3,9 +3,6 @@
3
3
  import contextlib
4
4
  import io
5
5
  import json
6
- import os
7
- import shutil
8
- import subprocess
9
6
  import traceback
10
7
  from datetime import datetime
11
8
  from pathlib import Path
@@ -20,6 +17,7 @@ from textual.worker import Worker, WorkerState
20
17
  from textual.widgets import Button, DataTable, Select, Static
21
18
 
22
19
  from ...logging_config import setup_logger
20
+ from ..clipboard import copy_text
23
21
  from .openable_table import OpenableDataTable
24
22
 
25
23
  logger = setup_logger("realign.dashboard.sessions", "dashboard.log")
@@ -685,33 +683,12 @@ class SessionsTable(Container):
685
683
 
686
684
  # Build copy text
687
685
  if slack_message:
688
- copy_text = str(slack_message) + "\n\n" + str(share_link)
686
+ text_to_copy = str(slack_message) + "\n\n" + str(share_link)
689
687
  else:
690
- copy_text = str(share_link)
688
+ text_to_copy = str(share_link)
691
689
 
692
690
  # Copy to clipboard
693
- copied = False
694
- if os.environ.get("TMUX") and shutil.which("pbcopy"):
695
- try:
696
- copied = (
697
- subprocess.run(
698
- ["pbcopy"],
699
- input=copy_text,
700
- text=True,
701
- capture_output=False,
702
- check=False,
703
- ).returncode
704
- == 0
705
- )
706
- except Exception:
707
- copied = False
708
-
709
- if not copied:
710
- try:
711
- self.app.copy_to_clipboard(copy_text)
712
- copied = True
713
- except Exception:
714
- copied = False
691
+ copied = copy_text(self.app, text_to_copy)
715
692
 
716
693
  suffix = " (copied to clipboard)" if copied else ""
717
694
  self.app.notify(f"Share link created{suffix}", title="Share", timeout=4)
@@ -456,7 +456,7 @@ class TerminalPanel(Container, can_focus=True):
456
456
  try:
457
457
  from ...codex_terminal_linker import read_codex_session_meta
458
458
  from ...db import get_database
459
- from ...codex_home import codex_sessions_dir_for_terminal
459
+ from ...codex_home import codex_sessions_dir_for_terminal_or_agent
460
460
  except Exception:
461
461
  return
462
462
 
@@ -474,9 +474,12 @@ class TerminalPanel(Container, can_focus=True):
474
474
  return
475
475
 
476
476
  candidates: list[Path] = []
477
- sessions_root = codex_sessions_dir_for_terminal(terminal_id)
477
+ agent_info_id: str | None = None
478
+ if (agent.source or "").startswith("agent:"):
479
+ agent_info_id = agent.source[6:]
480
+ sessions_root = codex_sessions_dir_for_terminal_or_agent(terminal_id, agent_info_id)
478
481
  if sessions_root.exists():
479
- # Deterministic: isolated per-terminal CODEX_HOME.
482
+ # Deterministic: isolated per-terminal/per-agent CODEX_HOME.
480
483
  try:
481
484
  candidates = list(sessions_root.rglob("rollout-*.jsonl"))
482
485
  except Exception:
@@ -544,6 +547,9 @@ class TerminalPanel(Container, can_focus=True):
544
547
  return
545
548
 
546
549
  try:
550
+ source = "dashboard:auto-link"
551
+ if (agent.source or "").startswith("agent:"):
552
+ source = agent.source or source
547
553
  db.update_agent(
548
554
  terminal_id,
549
555
  provider="codex",
@@ -552,8 +558,13 @@ class TerminalPanel(Container, can_focus=True):
552
558
  transcript_path=str(best),
553
559
  cwd=cwd,
554
560
  project_dir=cwd,
555
- source="dashboard:auto-link",
561
+ source=source,
556
562
  )
563
+ if agent_info_id:
564
+ try:
565
+ db.update_session_agent_id(best.stem, agent_info_id)
566
+ except Exception:
567
+ pass
557
568
  except Exception:
558
569
  return
559
570
 
@@ -592,10 +603,16 @@ class TerminalPanel(Container, can_focus=True):
592
603
  try:
593
604
  controls_enabled = self.supported()
594
605
  with Horizontal(classes="summary"):
606
+ yield Button(
607
+ "+ New Agent",
608
+ id="quick-new-agent",
609
+ variant="primary",
610
+ disabled=not controls_enabled,
611
+ )
595
612
  yield Button(
596
613
  "+ Create",
597
614
  id="new-agent",
598
- variant="primary",
615
+ variant="default",
599
616
  disabled=not controls_enabled,
600
617
  )
601
618
  with Vertical(id="terminals", classes="list"):
@@ -1244,6 +1261,20 @@ class TerminalPanel(Container, can_focus=True):
1244
1261
  """Wrap a command to run in a specific directory."""
1245
1262
  return f"cd {shlex.quote(directory)} && {command}"
1246
1263
 
1264
+ async def _quick_create_claude_agent(self) -> None:
1265
+ """Quickly create a new Claude Code terminal with default settings.
1266
+
1267
+ Uses the last workspace (or cwd) with normal permissions and tracking enabled.
1268
+ """
1269
+ from ..screens.create_agent import _load_last_workspace
1270
+
1271
+ workspace = _load_last_workspace()
1272
+ self.run_worker(
1273
+ self._create_agent("claude", workspace, skip_permissions=False, no_track=False),
1274
+ group="terminal-panel-create",
1275
+ exclusive=True,
1276
+ )
1277
+
1247
1278
  def _on_create_agent_result(self, result: tuple[str, str, bool, bool] | None) -> None:
1248
1279
  """Handle the result from CreateAgentScreen modal."""
1249
1280
  if result is None:
@@ -1571,6 +1602,10 @@ class TerminalPanel(Container, can_focus=True):
1571
1602
  )
1572
1603
  return
1573
1604
 
1605
+ if button_id == "quick-new-agent":
1606
+ await self._quick_create_claude_agent()
1607
+ return
1608
+
1574
1609
  if button_id == "new-agent":
1575
1610
  from ..screens import CreateAgentScreen
1576
1611
 
realign/db/base.py CHANGED
@@ -57,6 +57,8 @@ class SessionRecord:
57
57
  total_turns: Optional[int] = None
58
58
  # V12: file mtime when total_turns was cached (for validation)
59
59
  total_turns_mtime: Optional[float] = None
60
+ # V19: agent association
61
+ agent_id: Optional[str] = None
60
62
 
61
63
 
62
64
  @dataclass
@@ -125,6 +127,18 @@ class AgentRecord:
125
127
  created_by: Optional[str] = None # Creator UID
126
128
 
127
129
 
130
+ @dataclass
131
+ class AgentInfoRecord:
132
+ """Agent profile/identity data (V20)."""
133
+
134
+ id: str
135
+ name: str
136
+ created_at: datetime
137
+ updated_at: datetime
138
+ description: Optional[str] = ""
139
+ visibility: str = "visible"
140
+
141
+
128
142
  @dataclass
129
143
  class AgentContextRecord:
130
144
  """Represents a context entry (V15: replaces load.json)."""
@@ -250,6 +264,19 @@ class DatabaseInterface(ABC):
250
264
  """
251
265
  pass
252
266
 
267
+ @abstractmethod
268
+ def get_sessions_by_agent_id(self, agent_id: str, limit: int = 1000) -> List[SessionRecord]:
269
+ """Get all sessions linked to an agent.
270
+
271
+ Args:
272
+ agent_id: The agent_info ID
273
+ limit: Maximum number of sessions to return
274
+
275
+ Returns:
276
+ List of SessionRecord objects for this agent
277
+ """
278
+ pass
279
+
253
280
  @abstractmethod
254
281
  def get_turn_by_hash(self, session_id: str, content_hash: str) -> Optional[TurnRecord]:
255
282
  """Check if a turn with this content hash already exists in the session."""
realign/db/locks.py CHANGED
@@ -31,6 +31,10 @@ def lock_key_for_event_summary(event_id: str) -> str:
31
31
  return f"event_summary:{event_id}"
32
32
 
33
33
 
34
+ def lock_key_for_agent_description(agent_id: str) -> str:
35
+ return f"agent_description:{agent_id}"
36
+
37
+
34
38
  @contextmanager
35
39
  def lease_lock(
36
40
  db: DatabaseInterface,
realign/db/schema.py CHANGED
@@ -76,9 +76,15 @@ Schema V18: UID refactor - created_by/shared_by with users table.
76
76
  - turns: drop uid and user_name (inherit from session)
77
77
  - agents: uid -> created_by, drop user_name
78
78
  - Update indexes accordingly
79
+
80
+ Schema V19: Agent association for sessions.
81
+ - sessions.agent_id: Logical agent entity association
82
+
83
+ Schema V20: Agent identity/profile table.
84
+ - agent_info table: name, description for agent profiles
79
85
  """
80
86
 
81
- SCHEMA_VERSION = 18
87
+ SCHEMA_VERSION = 21
82
88
 
83
89
  FTS_EVENTS_SCRIPTS = [
84
90
  # Full Text Search for Events
@@ -150,7 +156,8 @@ INIT_SCRIPTS = [
150
156
  created_by TEXT, -- V18: Creator UID (FK to users.uid)
151
157
  shared_by TEXT, -- V18: Sharer UID (who imported this)
152
158
  total_turns INTEGER DEFAULT 0, -- V10: Cached total turn count (avoids reading files)
153
- total_turns_mtime REAL -- V12: File mtime when total_turns was cached (for validation)
159
+ total_turns_mtime REAL, -- V12: File mtime when total_turns was cached (for validation)
160
+ agent_id TEXT -- V19: Logical agent association
154
161
  );
155
162
  """,
156
163
  # Turns table (corresponds to git commits, V18: uid/user_name removed)
@@ -222,6 +229,7 @@ INIT_SCRIPTS = [
222
229
  "CREATE INDEX IF NOT EXISTS idx_sessions_activity ON sessions(last_activity_at DESC);",
223
230
  "CREATE INDEX IF NOT EXISTS idx_sessions_type ON sessions(session_type);",
224
231
  "CREATE INDEX IF NOT EXISTS idx_sessions_created_by ON sessions(created_by);", # V18
232
+ "CREATE INDEX IF NOT EXISTS idx_sessions_agent_id ON sessions(agent_id);", # V19
225
233
  "CREATE INDEX IF NOT EXISTS idx_turns_session ON turns(session_id);",
226
234
  "CREATE INDEX IF NOT EXISTS idx_turns_timestamp ON turns(timestamp DESC);",
227
235
  "CREATE INDEX IF NOT EXISTS idx_turns_hash ON turns(content_hash);",
@@ -335,6 +343,17 @@ INIT_SCRIPTS = [
335
343
  updated_at TEXT DEFAULT (datetime('now'))
336
344
  );
337
345
  """,
346
+ # Agent identity/profile table (V20)
347
+ """
348
+ CREATE TABLE IF NOT EXISTS agent_info (
349
+ id TEXT PRIMARY KEY,
350
+ name TEXT NOT NULL,
351
+ description TEXT DEFAULT '',
352
+ visibility TEXT NOT NULL DEFAULT 'visible',
353
+ created_at TEXT DEFAULT (datetime('now')),
354
+ updated_at TEXT DEFAULT (datetime('now'))
355
+ );
356
+ """,
338
357
  *FTS_EVENTS_SCRIPTS,
339
358
  ]
340
359
 
@@ -652,6 +671,29 @@ MIGRATION_V17_TO_V18 = [
652
671
  ]
653
672
 
654
673
 
674
+ MIGRATION_V18_TO_V19 = [
675
+ "ALTER TABLE sessions ADD COLUMN agent_id TEXT;",
676
+ "CREATE INDEX IF NOT EXISTS idx_sessions_agent_id ON sessions(agent_id);",
677
+ ]
678
+
679
+ MIGRATION_V19_TO_V20 = [
680
+ """
681
+ CREATE TABLE IF NOT EXISTS agent_info (
682
+ id TEXT PRIMARY KEY,
683
+ name TEXT NOT NULL,
684
+ description TEXT DEFAULT '',
685
+ visibility TEXT NOT NULL DEFAULT 'visible',
686
+ created_at TEXT DEFAULT (datetime('now')),
687
+ updated_at TEXT DEFAULT (datetime('now'))
688
+ );
689
+ """,
690
+ ]
691
+
692
+ MIGRATION_V20_TO_V21 = [
693
+ "ALTER TABLE agent_info ADD COLUMN visibility TEXT NOT NULL DEFAULT 'visible';",
694
+ ]
695
+
696
+
655
697
  def get_migration_scripts(from_version: int, to_version: int) -> list:
656
698
  """Get migration scripts for upgrading between versions."""
657
699
  scripts = []
@@ -716,4 +758,13 @@ def get_migration_scripts(from_version: int, to_version: int) -> list:
716
758
  if from_version < 18 and to_version >= 18:
717
759
  scripts.extend(MIGRATION_V17_TO_V18)
718
760
 
761
+ if from_version < 19 and to_version >= 19:
762
+ scripts.extend(MIGRATION_V18_TO_V19)
763
+
764
+ if from_version < 20 and to_version >= 20:
765
+ scripts.extend(MIGRATION_V19_TO_V20)
766
+
767
+ if from_version < 21 and to_version >= 21:
768
+ scripts.extend(MIGRATION_V20_TO_V21)
769
+
719
770
  return scripts
realign/db/sqlite_db.py CHANGED
@@ -21,6 +21,7 @@ from .base import (
21
21
  EventRecord,
22
22
  LockRecord,
23
23
  AgentRecord,
24
+ AgentInfoRecord,
24
25
  AgentContextRecord,
25
26
  UserRecord,
26
27
  )
@@ -293,6 +294,7 @@ class SQLiteDatabase(DatabaseInterface):
293
294
  started_at: datetime,
294
295
  workspace_path: Optional[str] = None,
295
296
  metadata: Optional[Dict[str, Any]] = None,
297
+ agent_id: Optional[str] = None,
296
298
  ) -> SessionRecord:
297
299
  """Get existing session or create new one."""
298
300
  conn = self._get_connection()
@@ -317,8 +319,8 @@ class SQLiteDatabase(DatabaseInterface):
317
319
  INSERT INTO sessions (
318
320
  id, session_file_path, session_type, workspace_path,
319
321
  started_at, last_activity_at, created_at, updated_at, metadata,
320
- created_by
321
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
322
+ created_by, agent_id
323
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
322
324
  """,
323
325
  (
324
326
  session_id,
@@ -331,6 +333,7 @@ class SQLiteDatabase(DatabaseInterface):
331
333
  now,
332
334
  metadata_json,
333
335
  config.uid,
336
+ agent_id,
334
337
  ),
335
338
  )
336
339
  conn.commit()
@@ -353,8 +356,18 @@ class SQLiteDatabase(DatabaseInterface):
353
356
  metadata=metadata or {},
354
357
  workspace_path=workspace_path,
355
358
  created_by=config.uid,
359
+ agent_id=agent_id,
356
360
  )
357
361
 
362
+ def update_session_agent_id(self, session_id: str, agent_id: Optional[str]) -> None:
363
+ """Set or update the agent_id on an existing session (V19)."""
364
+ conn = self._get_connection()
365
+ conn.execute(
366
+ "UPDATE sessions SET agent_id = ?, updated_at = ? WHERE id = ?",
367
+ (agent_id, datetime.now(), session_id),
368
+ )
369
+ conn.commit()
370
+
358
371
  def update_session_activity(self, session_id: str, last_activity_at: datetime) -> None:
359
372
  """Update last activity timestamp."""
360
373
  conn = self._get_connection()
@@ -557,6 +570,30 @@ class SQLiteDatabase(DatabaseInterface):
557
570
  )
558
571
  return [self._row_to_session(row) for row in cursor.fetchall()]
559
572
 
573
+ def get_sessions_by_agent_id(self, agent_id: str, limit: int = 1000) -> List[SessionRecord]:
574
+ """Get all sessions linked to an agent.
575
+
576
+ Args:
577
+ agent_id: The agent_info ID
578
+ limit: Maximum number of sessions to return
579
+
580
+ Returns:
581
+ List of SessionRecord objects for this agent
582
+ """
583
+ conn = self._get_connection()
584
+ cursor = conn.cursor()
585
+ try:
586
+ cursor.execute(
587
+ """SELECT * FROM sessions
588
+ WHERE agent_id = ?
589
+ ORDER BY last_activity_at DESC
590
+ LIMIT ?""",
591
+ (agent_id, limit),
592
+ )
593
+ return [self._row_to_session(row) for row in cursor.fetchall()]
594
+ except sqlite3.OperationalError:
595
+ return []
596
+
560
597
  def get_turn_content(self, turn_id: str) -> Optional[str]:
561
598
  """Get the JSONL content for a turn."""
562
599
  conn = self._get_connection()
@@ -973,6 +1010,7 @@ class SQLiteDatabase(DatabaseInterface):
973
1010
  expected_turns: Optional[int] = None,
974
1011
  skip_dedup: bool = False,
975
1012
  no_track: bool = False,
1013
+ agent_id: Optional[str] = None,
976
1014
  ) -> str:
977
1015
  session_id = session_file_path.stem
978
1016
  dedupe_key = f"turn:{session_id}:{int(turn_number)}"
@@ -991,6 +1029,8 @@ class SQLiteDatabase(DatabaseInterface):
991
1029
  payload["skip_dedup"] = True
992
1030
  if no_track:
993
1031
  payload["no_track"] = True
1032
+ if agent_id:
1033
+ payload["agent_id"] = agent_id
994
1034
 
995
1035
  # For append-only session formats (Claude/Codex/Gemini), a turn is immutable once completed.
996
1036
  # Avoid re-running already-done turn jobs on repeated enqueue attempts.
@@ -1033,6 +1073,17 @@ class SQLiteDatabase(DatabaseInterface):
1033
1073
  requeue_done=True,
1034
1074
  )
1035
1075
 
1076
+ def enqueue_agent_description_job(self, *, agent_id: str) -> str:
1077
+ dedupe_key = f"agent_desc:{agent_id}"
1078
+ payload: Dict[str, Any] = {"agent_id": agent_id}
1079
+ return self.enqueue_job(
1080
+ kind="agent_description",
1081
+ dedupe_key=dedupe_key,
1082
+ payload=payload,
1083
+ priority=12,
1084
+ requeue_done=True,
1085
+ )
1086
+
1036
1087
  def claim_next_job(
1037
1088
  self,
1038
1089
  *,
@@ -2770,6 +2821,13 @@ class SQLiteDatabase(DatabaseInterface):
2770
2821
  except (IndexError, KeyError):
2771
2822
  pass
2772
2823
 
2824
+ # V19: agent association
2825
+ agent_id = None
2826
+ try:
2827
+ agent_id = row["agent_id"]
2828
+ except (IndexError, KeyError):
2829
+ pass
2830
+
2773
2831
  return SessionRecord(
2774
2832
  id=row["id"],
2775
2833
  session_file_path=Path(row["session_file_path"]),
@@ -2790,6 +2848,7 @@ class SQLiteDatabase(DatabaseInterface):
2790
2848
  shared_by=shared_by,
2791
2849
  total_turns=total_turns,
2792
2850
  total_turns_mtime=total_turns_mtime,
2851
+ agent_id=agent_id,
2793
2852
  )
2794
2853
 
2795
2854
  def _row_to_turn(self, row: sqlite3.Row) -> TurnRecord:
@@ -3015,3 +3074,127 @@ class SQLiteDatabase(DatabaseInterface):
3015
3074
  except sqlite3.OperationalError:
3016
3075
  pass
3017
3076
  return None
3077
+
3078
+ # -------------------------------------------------------------------------
3079
+ # Agent info table methods (Schema V20)
3080
+ # -------------------------------------------------------------------------
3081
+
3082
+ def _row_to_agent_info(self, row: sqlite3.Row) -> AgentInfoRecord:
3083
+ """Convert a database row to an AgentInfoRecord."""
3084
+ visibility = "visible"
3085
+ try:
3086
+ if "visibility" in row.keys():
3087
+ visibility = row["visibility"] or "visible"
3088
+ except Exception:
3089
+ visibility = "visible"
3090
+ return AgentInfoRecord(
3091
+ id=row["id"],
3092
+ name=row["name"],
3093
+ description=row["description"] or "",
3094
+ created_at=self._parse_datetime(row["created_at"]),
3095
+ updated_at=self._parse_datetime(row["updated_at"]),
3096
+ visibility=visibility,
3097
+ )
3098
+
3099
+ def get_or_create_agent_info(
3100
+ self, agent_id: str, name: Optional[str] = None
3101
+ ) -> AgentInfoRecord:
3102
+ """Get existing agent info or create a new one.
3103
+
3104
+ Args:
3105
+ agent_id: UUID for the agent.
3106
+ name: Display name (if None, caller should provide a generated name).
3107
+ """
3108
+ conn = self._get_connection()
3109
+ cursor = conn.cursor()
3110
+
3111
+ try:
3112
+ cursor.execute("SELECT * FROM agent_info WHERE id = ?", (agent_id,))
3113
+ row = cursor.fetchone()
3114
+ if row:
3115
+ return self._row_to_agent_info(row)
3116
+ except sqlite3.OperationalError:
3117
+ pass
3118
+
3119
+ display_name = name or agent_id[:8]
3120
+ cursor.execute(
3121
+ """
3122
+ INSERT INTO agent_info (id, name, description, visibility, created_at, updated_at)
3123
+ VALUES (?, ?, '', 'visible', datetime('now'), datetime('now'))
3124
+ """,
3125
+ (agent_id, display_name),
3126
+ )
3127
+ conn.commit()
3128
+
3129
+ cursor.execute("SELECT * FROM agent_info WHERE id = ?", (agent_id,))
3130
+ row = cursor.fetchone()
3131
+ return self._row_to_agent_info(row)
3132
+
3133
+ def get_agent_info(self, agent_id: str) -> Optional[AgentInfoRecord]:
3134
+ """Get agent info by ID, or None if not found."""
3135
+ conn = self._get_connection()
3136
+ try:
3137
+ cursor = conn.execute("SELECT * FROM agent_info WHERE id = ?", (agent_id,))
3138
+ row = cursor.fetchone()
3139
+ if row:
3140
+ return self._row_to_agent_info(row)
3141
+ except sqlite3.OperationalError:
3142
+ pass
3143
+ return None
3144
+
3145
+ def update_agent_info(
3146
+ self,
3147
+ agent_id: str,
3148
+ *,
3149
+ name: Optional[str] = None,
3150
+ description: Optional[str] = None,
3151
+ visibility: Optional[str] = None,
3152
+ ) -> Optional[AgentInfoRecord]:
3153
+ """Update agent info fields. Returns updated record or None if not found."""
3154
+ conn = self._get_connection()
3155
+ sets: list[str] = []
3156
+ params: list[Any] = []
3157
+
3158
+ if name is not None:
3159
+ sets.append("name = ?")
3160
+ params.append(name)
3161
+ if description is not None:
3162
+ sets.append("description = ?")
3163
+ params.append(description)
3164
+ if visibility is not None:
3165
+ sets.append("visibility = ?")
3166
+ params.append(visibility)
3167
+
3168
+ if not sets:
3169
+ return self.get_agent_info(agent_id)
3170
+
3171
+ sets.append("updated_at = datetime('now')")
3172
+ params.append(agent_id)
3173
+
3174
+ try:
3175
+ conn.execute(
3176
+ f"UPDATE agent_info SET {', '.join(sets)} WHERE id = ?",
3177
+ params,
3178
+ )
3179
+ conn.commit()
3180
+ except sqlite3.OperationalError:
3181
+ return None
3182
+
3183
+ return self.get_agent_info(agent_id)
3184
+
3185
+ def list_agent_info(self, *, include_invisible: bool = False) -> list[AgentInfoRecord]:
3186
+ """List agent info records, ordered by created_at descending."""
3187
+ conn = self._get_connection()
3188
+ try:
3189
+ if include_invisible:
3190
+ cursor = conn.execute("SELECT * FROM agent_info ORDER BY created_at DESC")
3191
+ else:
3192
+ try:
3193
+ cursor = conn.execute(
3194
+ "SELECT * FROM agent_info WHERE visibility = 'visible' ORDER BY created_at DESC"
3195
+ )
3196
+ except sqlite3.OperationalError:
3197
+ cursor = conn.execute("SELECT * FROM agent_info ORDER BY created_at DESC")
3198
+ return [self._row_to_agent_info(row) for row in cursor.fetchall()]
3199
+ except sqlite3.OperationalError:
3200
+ return []