aline-ai 0.6.5__py3-none-any.whl → 0.6.7__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 (42) hide show
  1. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/METADATA +1 -1
  2. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/RECORD +41 -34
  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 +43 -1
  7. realign/claude_hooks/user_prompt_submit_hook.py +3 -0
  8. realign/cli.py +62 -0
  9. realign/codex_detector.py +18 -3
  10. realign/codex_home.py +65 -16
  11. realign/codex_terminal_linker.py +18 -7
  12. realign/commands/agent.py +109 -0
  13. realign/commands/doctor.py +74 -1
  14. realign/commands/export_shares.py +448 -0
  15. realign/commands/import_shares.py +203 -1
  16. realign/commands/search.py +58 -29
  17. realign/commands/sync_agent.py +347 -0
  18. realign/dashboard/app.py +9 -9
  19. realign/dashboard/clipboard.py +54 -0
  20. realign/dashboard/screens/__init__.py +4 -0
  21. realign/dashboard/screens/agent_detail.py +333 -0
  22. realign/dashboard/screens/create_agent_info.py +244 -0
  23. realign/dashboard/screens/event_detail.py +6 -27
  24. realign/dashboard/styles/dashboard.tcss +22 -28
  25. realign/dashboard/tmux_manager.py +36 -10
  26. realign/dashboard/widgets/__init__.py +2 -2
  27. realign/dashboard/widgets/agents_panel.py +1248 -0
  28. realign/dashboard/widgets/events_table.py +4 -27
  29. realign/dashboard/widgets/sessions_table.py +4 -27
  30. realign/db/base.py +69 -0
  31. realign/db/locks.py +4 -0
  32. realign/db/schema.py +111 -2
  33. realign/db/sqlite_db.py +360 -2
  34. realign/events/agent_summarizer.py +157 -0
  35. realign/events/session_summarizer.py +25 -0
  36. realign/watcher_core.py +193 -5
  37. realign/worker_core.py +59 -1
  38. realign/dashboard/widgets/terminal_panel.py +0 -1653
  39. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/WHEEL +0 -0
  40. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/entry_points.txt +0 -0
  41. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/licenses/LICENSE +0 -0
  42. {aline_ai-0.6.5.dist-info → aline_ai-0.6.7.dist-info}/top_level.txt +0 -0
realign/db/sqlite_db.py CHANGED
@@ -21,7 +21,9 @@ from .base import (
21
21
  EventRecord,
22
22
  LockRecord,
23
23
  AgentRecord,
24
+ AgentInfoRecord,
24
25
  AgentContextRecord,
26
+ WindowLinkRecord,
25
27
  UserRecord,
26
28
  )
27
29
  from .schema import (
@@ -293,6 +295,7 @@ class SQLiteDatabase(DatabaseInterface):
293
295
  started_at: datetime,
294
296
  workspace_path: Optional[str] = None,
295
297
  metadata: Optional[Dict[str, Any]] = None,
298
+ agent_id: Optional[str] = None,
296
299
  ) -> SessionRecord:
297
300
  """Get existing session or create new one."""
298
301
  conn = self._get_connection()
@@ -317,8 +320,8 @@ class SQLiteDatabase(DatabaseInterface):
317
320
  INSERT INTO sessions (
318
321
  id, session_file_path, session_type, workspace_path,
319
322
  started_at, last_activity_at, created_at, updated_at, metadata,
320
- created_by
321
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
323
+ created_by, agent_id
324
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
322
325
  """,
323
326
  (
324
327
  session_id,
@@ -331,6 +334,7 @@ class SQLiteDatabase(DatabaseInterface):
331
334
  now,
332
335
  metadata_json,
333
336
  config.uid,
337
+ agent_id,
334
338
  ),
335
339
  )
336
340
  conn.commit()
@@ -353,7 +357,17 @@ class SQLiteDatabase(DatabaseInterface):
353
357
  metadata=metadata or {},
354
358
  workspace_path=workspace_path,
355
359
  created_by=config.uid,
360
+ agent_id=agent_id,
361
+ )
362
+
363
+ def update_session_agent_id(self, session_id: str, agent_id: Optional[str]) -> None:
364
+ """Set or update the agent_id on an existing session (V19)."""
365
+ conn = self._get_connection()
366
+ conn.execute(
367
+ "UPDATE sessions SET agent_id = ?, updated_at = ? WHERE id = ?",
368
+ (agent_id, datetime.now(), session_id),
356
369
  )
370
+ conn.commit()
357
371
 
358
372
  def update_session_activity(self, session_id: str, last_activity_at: datetime) -> None:
359
373
  """Update last activity timestamp."""
@@ -557,6 +571,30 @@ class SQLiteDatabase(DatabaseInterface):
557
571
  )
558
572
  return [self._row_to_session(row) for row in cursor.fetchall()]
559
573
 
574
+ def get_sessions_by_agent_id(self, agent_id: str, limit: int = 1000) -> List[SessionRecord]:
575
+ """Get all sessions linked to an agent.
576
+
577
+ Args:
578
+ agent_id: The agent_info ID
579
+ limit: Maximum number of sessions to return
580
+
581
+ Returns:
582
+ List of SessionRecord objects for this agent
583
+ """
584
+ conn = self._get_connection()
585
+ cursor = conn.cursor()
586
+ try:
587
+ cursor.execute(
588
+ """SELECT * FROM sessions
589
+ WHERE agent_id = ?
590
+ ORDER BY last_activity_at DESC
591
+ LIMIT ?""",
592
+ (agent_id, limit),
593
+ )
594
+ return [self._row_to_session(row) for row in cursor.fetchall()]
595
+ except sqlite3.OperationalError:
596
+ return []
597
+
560
598
  def get_turn_content(self, turn_id: str) -> Optional[str]:
561
599
  """Get the JSONL content for a turn."""
562
600
  conn = self._get_connection()
@@ -973,6 +1011,7 @@ class SQLiteDatabase(DatabaseInterface):
973
1011
  expected_turns: Optional[int] = None,
974
1012
  skip_dedup: bool = False,
975
1013
  no_track: bool = False,
1014
+ agent_id: Optional[str] = None,
976
1015
  ) -> str:
977
1016
  session_id = session_file_path.stem
978
1017
  dedupe_key = f"turn:{session_id}:{int(turn_number)}"
@@ -991,6 +1030,8 @@ class SQLiteDatabase(DatabaseInterface):
991
1030
  payload["skip_dedup"] = True
992
1031
  if no_track:
993
1032
  payload["no_track"] = True
1033
+ if agent_id:
1034
+ payload["agent_id"] = agent_id
994
1035
 
995
1036
  # For append-only session formats (Claude/Codex/Gemini), a turn is immutable once completed.
996
1037
  # Avoid re-running already-done turn jobs on repeated enqueue attempts.
@@ -1033,6 +1074,17 @@ class SQLiteDatabase(DatabaseInterface):
1033
1074
  requeue_done=True,
1034
1075
  )
1035
1076
 
1077
+ def enqueue_agent_description_job(self, *, agent_id: str) -> str:
1078
+ dedupe_key = f"agent_desc:{agent_id}"
1079
+ payload: Dict[str, Any] = {"agent_id": agent_id}
1080
+ return self.enqueue_job(
1081
+ kind="agent_description",
1082
+ dedupe_key=dedupe_key,
1083
+ payload=payload,
1084
+ priority=12,
1085
+ requeue_done=True,
1086
+ )
1087
+
1036
1088
  def claim_next_job(
1037
1089
  self,
1038
1090
  *,
@@ -2344,6 +2396,92 @@ class SQLiteDatabase(DatabaseInterface):
2344
2396
  except sqlite3.OperationalError:
2345
2397
  return []
2346
2398
 
2399
+ def insert_window_link(
2400
+ self,
2401
+ *,
2402
+ terminal_id: str,
2403
+ agent_id: Optional[str],
2404
+ session_id: Optional[str],
2405
+ provider: Optional[str],
2406
+ source: Optional[str],
2407
+ ts: Optional[float] = None,
2408
+ ) -> None:
2409
+ conn = self._get_connection()
2410
+ cursor = conn.cursor()
2411
+ ts_value = ts if ts is not None else time.time()
2412
+ try:
2413
+ cursor.execute(
2414
+ """
2415
+ INSERT INTO windowlink (
2416
+ terminal_id, agent_id, session_id, provider, source, ts
2417
+ ) VALUES (?, ?, ?, ?, ?, ?)
2418
+ """,
2419
+ (
2420
+ terminal_id,
2421
+ agent_id,
2422
+ session_id,
2423
+ provider,
2424
+ source,
2425
+ float(ts_value),
2426
+ ),
2427
+ )
2428
+ conn.commit()
2429
+ except sqlite3.OperationalError:
2430
+ conn.rollback()
2431
+
2432
+ def list_latest_window_links(
2433
+ self, *, agent_id: Optional[str] = None, limit: int = 1000
2434
+ ) -> List[WindowLinkRecord]:
2435
+ conn = self._get_connection()
2436
+ cursor = conn.cursor()
2437
+ params: List[Any] = []
2438
+ where_clause = ""
2439
+ if agent_id:
2440
+ where_clause = "WHERE agent_id = ?"
2441
+ params.append(agent_id)
2442
+ params.append(limit)
2443
+ try:
2444
+ cursor.execute(
2445
+ f"""
2446
+ SELECT terminal_id, agent_id, session_id, provider, source, ts, created_at
2447
+ FROM (
2448
+ SELECT terminal_id,
2449
+ agent_id,
2450
+ session_id,
2451
+ provider,
2452
+ source,
2453
+ ts,
2454
+ created_at,
2455
+ ROW_NUMBER() OVER (
2456
+ PARTITION BY terminal_id
2457
+ ORDER BY ts DESC, id DESC
2458
+ ) AS rn
2459
+ FROM windowlink
2460
+ {where_clause}
2461
+ ) AS ranked
2462
+ WHERE rn = 1
2463
+ LIMIT ?
2464
+ """,
2465
+ params,
2466
+ )
2467
+ rows = cursor.fetchall()
2468
+ out: List[WindowLinkRecord] = []
2469
+ for row in rows:
2470
+ out.append(
2471
+ WindowLinkRecord(
2472
+ terminal_id=row[0],
2473
+ agent_id=row[1],
2474
+ session_id=row[2],
2475
+ provider=row[3],
2476
+ source=row[4],
2477
+ ts=row[5],
2478
+ created_at=self._parse_datetime(row[6]) if row[6] else None,
2479
+ )
2480
+ )
2481
+ return out
2482
+ except sqlite3.OperationalError:
2483
+ return []
2484
+
2347
2485
  def delete_agent(self, agent_id: str) -> bool:
2348
2486
  """Delete an agent by ID."""
2349
2487
  conn = self._get_connection()
@@ -2770,6 +2908,13 @@ class SQLiteDatabase(DatabaseInterface):
2770
2908
  except (IndexError, KeyError):
2771
2909
  pass
2772
2910
 
2911
+ # V19: agent association
2912
+ agent_id = None
2913
+ try:
2914
+ agent_id = row["agent_id"]
2915
+ except (IndexError, KeyError):
2916
+ pass
2917
+
2773
2918
  return SessionRecord(
2774
2919
  id=row["id"],
2775
2920
  session_file_path=Path(row["session_file_path"]),
@@ -2790,6 +2935,7 @@ class SQLiteDatabase(DatabaseInterface):
2790
2935
  shared_by=shared_by,
2791
2936
  total_turns=total_turns,
2792
2937
  total_turns_mtime=total_turns_mtime,
2938
+ agent_id=agent_id,
2793
2939
  )
2794
2940
 
2795
2941
  def _row_to_turn(self, row: sqlite3.Row) -> TurnRecord:
@@ -3015,3 +3161,215 @@ class SQLiteDatabase(DatabaseInterface):
3015
3161
  except sqlite3.OperationalError:
3016
3162
  pass
3017
3163
  return None
3164
+
3165
+ # -------------------------------------------------------------------------
3166
+ # Agent info table methods (Schema V20)
3167
+ # -------------------------------------------------------------------------
3168
+
3169
+ def _row_to_agent_info(self, row: sqlite3.Row) -> AgentInfoRecord:
3170
+ """Convert a database row to an AgentInfoRecord."""
3171
+ keys = row.keys()
3172
+ visibility = "visible"
3173
+ try:
3174
+ if "visibility" in keys:
3175
+ visibility = row["visibility"] or "visible"
3176
+ except Exception:
3177
+ visibility = "visible"
3178
+
3179
+ def _safe_get(key: str, default=None):
3180
+ try:
3181
+ return row[key] if key in keys else default
3182
+ except Exception:
3183
+ return default
3184
+
3185
+ return AgentInfoRecord(
3186
+ id=row["id"],
3187
+ name=row["name"],
3188
+ description=row["description"] or "",
3189
+ created_at=self._parse_datetime(row["created_at"]),
3190
+ updated_at=self._parse_datetime(row["updated_at"]),
3191
+ visibility=visibility,
3192
+ share_id=_safe_get("share_id"),
3193
+ share_url=_safe_get("share_url"),
3194
+ share_admin_token=_safe_get("share_admin_token"),
3195
+ share_contributor_token=_safe_get("share_contributor_token"),
3196
+ share_expiry_at=_safe_get("share_expiry_at"),
3197
+ last_synced_at=_safe_get("last_synced_at"),
3198
+ sync_version=_safe_get("sync_version", 0) or 0,
3199
+ )
3200
+
3201
+ def get_or_create_agent_info(
3202
+ self, agent_id: str, name: Optional[str] = None
3203
+ ) -> AgentInfoRecord:
3204
+ """Get existing agent info or create a new one.
3205
+
3206
+ Args:
3207
+ agent_id: UUID for the agent.
3208
+ name: Display name (if None, caller should provide a generated name).
3209
+ """
3210
+ conn = self._get_connection()
3211
+ cursor = conn.cursor()
3212
+
3213
+ try:
3214
+ cursor.execute("SELECT * FROM agent_info WHERE id = ?", (agent_id,))
3215
+ row = cursor.fetchone()
3216
+ if row:
3217
+ return self._row_to_agent_info(row)
3218
+ except sqlite3.OperationalError:
3219
+ pass
3220
+
3221
+ display_name = name or agent_id[:8]
3222
+ cursor.execute(
3223
+ """
3224
+ INSERT INTO agent_info (id, name, description, visibility, created_at, updated_at)
3225
+ VALUES (?, ?, '', 'visible', datetime('now'), datetime('now'))
3226
+ """,
3227
+ (agent_id, display_name),
3228
+ )
3229
+ conn.commit()
3230
+
3231
+ cursor.execute("SELECT * FROM agent_info WHERE id = ?", (agent_id,))
3232
+ row = cursor.fetchone()
3233
+ return self._row_to_agent_info(row)
3234
+
3235
+ def get_agent_info(self, agent_id: str) -> Optional[AgentInfoRecord]:
3236
+ """Get agent info by ID, or None if not found."""
3237
+ conn = self._get_connection()
3238
+ try:
3239
+ cursor = conn.execute("SELECT * FROM agent_info WHERE id = ?", (agent_id,))
3240
+ row = cursor.fetchone()
3241
+ if row:
3242
+ return self._row_to_agent_info(row)
3243
+ except sqlite3.OperationalError:
3244
+ pass
3245
+ return None
3246
+
3247
+ def update_agent_info(
3248
+ self,
3249
+ agent_id: str,
3250
+ *,
3251
+ name: Optional[str] = None,
3252
+ description: Optional[str] = None,
3253
+ visibility: Optional[str] = None,
3254
+ ) -> Optional[AgentInfoRecord]:
3255
+ """Update agent info fields. Returns updated record or None if not found."""
3256
+ conn = self._get_connection()
3257
+ sets: list[str] = []
3258
+ params: list[Any] = []
3259
+
3260
+ if name is not None:
3261
+ sets.append("name = ?")
3262
+ params.append(name)
3263
+ if description is not None:
3264
+ sets.append("description = ?")
3265
+ params.append(description)
3266
+ if visibility is not None:
3267
+ sets.append("visibility = ?")
3268
+ params.append(visibility)
3269
+
3270
+ if not sets:
3271
+ return self.get_agent_info(agent_id)
3272
+
3273
+ sets.append("updated_at = datetime('now')")
3274
+ params.append(agent_id)
3275
+
3276
+ try:
3277
+ conn.execute(
3278
+ f"UPDATE agent_info SET {', '.join(sets)} WHERE id = ?",
3279
+ params,
3280
+ )
3281
+ conn.commit()
3282
+ except sqlite3.OperationalError:
3283
+ return None
3284
+
3285
+ return self.get_agent_info(agent_id)
3286
+
3287
+ def list_agent_info(self, *, include_invisible: bool = False) -> list[AgentInfoRecord]:
3288
+ """List agent info records, ordered by created_at descending."""
3289
+ conn = self._get_connection()
3290
+ try:
3291
+ if include_invisible:
3292
+ cursor = conn.execute("SELECT * FROM agent_info ORDER BY created_at DESC")
3293
+ else:
3294
+ try:
3295
+ cursor = conn.execute(
3296
+ "SELECT * FROM agent_info WHERE visibility = 'visible' ORDER BY created_at DESC"
3297
+ )
3298
+ except sqlite3.OperationalError:
3299
+ cursor = conn.execute("SELECT * FROM agent_info ORDER BY created_at DESC")
3300
+ return [self._row_to_agent_info(row) for row in cursor.fetchall()]
3301
+ except sqlite3.OperationalError:
3302
+ return []
3303
+
3304
+ def update_agent_sync_metadata(
3305
+ self,
3306
+ agent_id: str,
3307
+ *,
3308
+ share_id: Optional[str] = None,
3309
+ share_url: Optional[str] = None,
3310
+ share_admin_token: Optional[str] = None,
3311
+ share_contributor_token: Optional[str] = None,
3312
+ share_expiry_at: Optional[str] = None,
3313
+ last_synced_at: Optional[str] = None,
3314
+ sync_version: Optional[int] = None,
3315
+ ) -> Optional[AgentInfoRecord]:
3316
+ """Update sync-related metadata on an agent_info record."""
3317
+ conn = self._get_connection()
3318
+ sets: list[str] = []
3319
+ params: list[Any] = []
3320
+
3321
+ if share_id is not None:
3322
+ sets.append("share_id = ?")
3323
+ params.append(share_id)
3324
+ if share_url is not None:
3325
+ sets.append("share_url = ?")
3326
+ params.append(share_url)
3327
+ if share_admin_token is not None:
3328
+ sets.append("share_admin_token = ?")
3329
+ params.append(share_admin_token)
3330
+ if share_contributor_token is not None:
3331
+ sets.append("share_contributor_token = ?")
3332
+ params.append(share_contributor_token)
3333
+ if share_expiry_at is not None:
3334
+ sets.append("share_expiry_at = ?")
3335
+ params.append(share_expiry_at)
3336
+ if last_synced_at is not None:
3337
+ sets.append("last_synced_at = ?")
3338
+ params.append(last_synced_at)
3339
+ if sync_version is not None:
3340
+ sets.append("sync_version = ?")
3341
+ params.append(sync_version)
3342
+
3343
+ if not sets:
3344
+ return self.get_agent_info(agent_id)
3345
+
3346
+ sets.append("updated_at = datetime('now')")
3347
+ params.append(agent_id)
3348
+
3349
+ try:
3350
+ conn.execute(
3351
+ f"UPDATE agent_info SET {', '.join(sets)} WHERE id = ?",
3352
+ params,
3353
+ )
3354
+ conn.commit()
3355
+ except sqlite3.OperationalError:
3356
+ return None
3357
+
3358
+ return self.get_agent_info(agent_id)
3359
+
3360
+ def get_agent_content_hashes(self, agent_id: str) -> set[str]:
3361
+ """Return all content_hash values for turns in this agent's sessions."""
3362
+ conn = self._get_connection()
3363
+ try:
3364
+ cursor = conn.execute(
3365
+ """
3366
+ SELECT DISTINCT t.content_hash
3367
+ FROM turns t
3368
+ JOIN sessions s ON t.session_id = s.id
3369
+ WHERE s.agent_id = ?
3370
+ """,
3371
+ (agent_id,),
3372
+ )
3373
+ return {row["content_hash"] for row in cursor.fetchall()}
3374
+ except sqlite3.OperationalError:
3375
+ return set()
@@ -0,0 +1,157 @@
1
+ """Agent description generation from session summaries using LLM."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import List, Optional
6
+
7
+ from ..db.sqlite_db import SQLiteDatabase
8
+ from ..db.base import SessionRecord
9
+ from ..db.locks import lease_lock, lock_key_for_agent_description, make_lock_owner
10
+ from ..llm_client import call_llm_cloud
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ def schedule_agent_description_update(db: SQLiteDatabase, agent_id: str) -> None:
16
+ """
17
+ Enqueue an agent description update job (durable).
18
+
19
+ Call this after a session summary is updated for an agent-linked session.
20
+
21
+ Args:
22
+ db: Database instance
23
+ agent_id: ID of the agent to update
24
+ """
25
+ try:
26
+ db.enqueue_agent_description_job(agent_id=agent_id)
27
+ logger.debug(f"Enqueued agent description job for agent_id={agent_id}")
28
+ except Exception as e:
29
+ logger.debug(f"Failed to enqueue agent description job, falling back: {e}")
30
+ force_update_agent_description(db, agent_id)
31
+
32
+
33
+ def force_update_agent_description(db: SQLiteDatabase, agent_id: str) -> None:
34
+ """
35
+ Immediately update agent description, bypassing the job queue.
36
+
37
+ Args:
38
+ db: Database instance
39
+ agent_id: ID of the agent to update
40
+ """
41
+ _update_agent_description(db, agent_id)
42
+
43
+
44
+ def _update_agent_description(db: SQLiteDatabase, agent_id: str) -> None:
45
+ """
46
+ Actually perform the agent description update.
47
+
48
+ Args:
49
+ db: Database instance
50
+ agent_id: ID of the agent to update
51
+ """
52
+ logger.info(f"Updating agent description for agent_id={agent_id}")
53
+
54
+ owner = make_lock_owner("agent_description")
55
+ lock_key = lock_key_for_agent_description(agent_id)
56
+
57
+ with lease_lock(
58
+ db,
59
+ lock_key,
60
+ owner=owner,
61
+ ttl_seconds=10 * 60, # 10 minutes
62
+ wait_timeout_seconds=0.0,
63
+ ) as acquired:
64
+ if not acquired:
65
+ logger.debug(f"Agent description lock held by another process: agent_id={agent_id}")
66
+ return
67
+
68
+ sessions = db.get_sessions_by_agent_id(agent_id)
69
+ if not sessions:
70
+ logger.warning(f"No sessions found for agent_id={agent_id}")
71
+ return
72
+
73
+ # Filter to sessions with non-empty title or summary
74
+ sessions_with_content = [
75
+ s for s in sessions if s.session_title or s.session_summary
76
+ ]
77
+ if not sessions_with_content:
78
+ logger.warning(f"No sessions with summaries for agent_id={agent_id}")
79
+ return
80
+
81
+ description = _generate_agent_description_llm(sessions_with_content)
82
+ db.update_agent_info(agent_id, description=description)
83
+ logger.info(
84
+ f"Agent description updated: '{description[:60]}...' for agent_id={agent_id}"
85
+ )
86
+
87
+
88
+ def _generate_agent_description_llm(sessions: List[SessionRecord]) -> str:
89
+ """
90
+ Use LLM to generate an agent description from all its sessions.
91
+
92
+ Args:
93
+ sessions: List of sessions linked to the agent
94
+
95
+ Returns:
96
+ Description string
97
+ """
98
+ if not sessions:
99
+ return ""
100
+
101
+ # Build sessions payload
102
+ sessions_data = []
103
+ for i, session in enumerate(sessions):
104
+ sessions_data.append(
105
+ {
106
+ "session_number": i + 1,
107
+ "title": session.session_title or f"Session {session.id[:8]}",
108
+ "summary": session.session_summary or "(no summary)",
109
+ }
110
+ )
111
+
112
+ # Try cloud provider
113
+ try:
114
+ from ..auth import is_logged_in
115
+
116
+ if is_logged_in():
117
+ logger.debug("Attempting cloud LLM for agent description")
118
+ custom_prompt = None
119
+ user_prompt_path = Path.home() / ".aline" / "prompts" / "agent_description.md"
120
+ try:
121
+ if user_prompt_path.exists():
122
+ custom_prompt = user_prompt_path.read_text(encoding="utf-8").strip()
123
+ except Exception:
124
+ pass
125
+
126
+ _, result = call_llm_cloud(
127
+ task="agent_description",
128
+ payload={"sessions": sessions_data},
129
+ custom_prompt=custom_prompt,
130
+ silent=True,
131
+ )
132
+
133
+ if result:
134
+ description = result.get("description", "")
135
+ logger.info(f"Cloud LLM agent description success: {description[:60]}...")
136
+ return description
137
+ else:
138
+ logger.warning("Cloud LLM agent description failed, using fallback")
139
+ return _fallback_agent_description(sessions)
140
+ except ImportError:
141
+ logger.debug("Auth module not available, skipping cloud LLM")
142
+
143
+ logger.warning("Not logged in, cannot use cloud LLM for agent description")
144
+ return _fallback_agent_description(sessions)
145
+
146
+
147
+ def _fallback_agent_description(sessions: List[SessionRecord]) -> str:
148
+ """Fallback when LLM fails: simple concatenation of recent session summaries."""
149
+ summaries = [s.session_summary for s in sessions if s.session_summary]
150
+ if not summaries:
151
+ titles = [s.session_title for s in sessions if s.session_title]
152
+ if titles:
153
+ return f"Agent with {len(sessions)} sessions. Recent work: {'; '.join(titles[-3:])}"
154
+ return f"Agent with {len(sessions)} sessions."
155
+
156
+ recent = summaries[:3] # sessions already ordered by last_activity_at DESC
157
+ return f"Agent with {len(sessions)} sessions. " + " ".join(recent)
@@ -145,6 +145,9 @@ def update_session_summary_now(db: SQLiteDatabase, session_id: str) -> bool:
145
145
 
146
146
  # Trigger event summary updates for all parent events (if any)
147
147
  _trigger_parent_event_updates(db, session_id)
148
+
149
+ # Trigger agent description update if session is linked to an agent
150
+ _trigger_agent_description_update(db, session_id)
148
151
  return True
149
152
 
150
153
 
@@ -168,6 +171,28 @@ def _trigger_parent_event_updates(db: SQLiteDatabase, session_id: str) -> None:
168
171
  logger.warning(f"Failed to trigger event updates: {e}")
169
172
 
170
173
 
174
+ def _trigger_agent_description_update(db: SQLiteDatabase, session_id: str) -> None:
175
+ """
176
+ Trigger agent description update if the session is linked to an agent.
177
+
178
+ Args:
179
+ db: Database instance
180
+ session_id: ID of the session whose agent should be updated
181
+ """
182
+ try:
183
+ session = db.get_session_by_id(session_id)
184
+ if session and getattr(session, "agent_id", None):
185
+ from .agent_summarizer import schedule_agent_description_update
186
+
187
+ schedule_agent_description_update(db, session.agent_id)
188
+ logger.debug(
189
+ f"Triggered agent description update for agent_id={session.agent_id}"
190
+ )
191
+ except Exception as e:
192
+ # Don't fail session summary update if agent trigger fails
193
+ logger.warning(f"Failed to trigger agent description update: {e}")
194
+
195
+
171
196
  def _get_session_summary_prompt() -> str:
172
197
  """
173
198
  Load session summary prompt with user customization support.