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
@@ -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.
realign/watcher_core.py CHANGED
@@ -260,7 +260,7 @@ class DialogueWatcher:
260
260
  return
261
261
 
262
262
  try:
263
- from .codex_home import terminal_id_from_codex_session_file
263
+ from .codex_home import codex_home_owner_from_session_file
264
264
  from .codex_terminal_linker import read_codex_session_meta, select_agent_for_codex_session
265
265
  from .db import get_database
266
266
 
@@ -271,13 +271,50 @@ class DialogueWatcher:
271
271
  db = get_database(read_only=False)
272
272
  agents = db.list_agents(status="active", limit=1000)
273
273
  # Deterministic mapping: session file stored under ~/.aline/codex_homes/<terminal_id>/...
274
- agent_id = terminal_id_from_codex_session_file(session_file)
274
+ owner = codex_home_owner_from_session_file(session_file)
275
+ agent_id = None
276
+ agent_info_id = None
277
+ if owner:
278
+ if owner[0] == "terminal":
279
+ agent_id = owner[1]
280
+ elif owner[0] == "agent":
281
+ agent_info_id = owner[1]
282
+ scoped_agents = [
283
+ a
284
+ for a in agents
285
+ if getattr(a, "provider", "") == "codex"
286
+ and getattr(a, "status", "") == "active"
287
+ and (getattr(a, "source", "") or "") == f"agent:{agent_info_id}"
288
+ ]
289
+ agent_id = select_agent_for_codex_session(
290
+ scoped_agents, session=meta, max_time_delta_seconds=None
291
+ )
275
292
  if not agent_id:
276
293
  # Fallback heuristic mapping (legacy default ~/.codex/sessions).
277
294
  agent_id = select_agent_for_codex_session(agents, session=meta)
278
295
  if not agent_id:
279
296
  return
280
297
 
298
+ owner_agent_info_id = agent_info_id
299
+ # Get existing agent to preserve agent_info_id in source field
300
+ existing_agent = db.get_agent_by_id(agent_id)
301
+ agent_info_id = None
302
+ existing_source = None
303
+ if existing_agent:
304
+ existing_source = existing_agent.source or ""
305
+ if existing_source.startswith("agent:"):
306
+ agent_info_id = existing_source[6:]
307
+
308
+ if not agent_info_id and owner_agent_info_id:
309
+ agent_info_id = owner_agent_info_id
310
+
311
+ if existing_source:
312
+ source = existing_source
313
+ elif agent_info_id:
314
+ source = f"agent:{agent_info_id}"
315
+ else:
316
+ source = "codex:auto-link"
317
+
281
318
  db.update_agent(
282
319
  agent_id,
283
320
  provider="codex",
@@ -286,8 +323,15 @@ class DialogueWatcher:
286
323
  transcript_path=str(session_file),
287
324
  cwd=meta.cwd,
288
325
  project_dir=meta.cwd,
289
- source="codex:auto-link",
326
+ source=source,
290
327
  )
328
+
329
+ # Link session to agent_info if available (bidirectional linking)
330
+ if agent_info_id:
331
+ try:
332
+ db.update_session_agent_id(session_file.stem, agent_info_id)
333
+ except Exception:
334
+ pass
291
335
  except Exception:
292
336
  return
293
337
 
@@ -409,6 +453,7 @@ class DialogueWatcher:
409
453
  project_dir = signal_data.get("project_dir", "")
410
454
  transcript_path = signal_data.get("transcript_path", "")
411
455
  no_track = bool(signal_data.get("no_track", False))
456
+ agent_id = signal_data.get("agent_id", "")
412
457
 
413
458
  logger.info(f"Stop signal received for session {session_id}")
414
459
  print(f"[Watcher] Stop signal received for {session_id}", file=sys.stderr)
@@ -444,6 +489,7 @@ class DialogueWatcher:
444
489
  turn_number=target_turn,
445
490
  session_type=self._detect_session_type(session_file),
446
491
  no_track=no_track,
492
+ agent_id=agent_id if agent_id else None,
447
493
  )
448
494
  except Exception as e:
449
495
  logger.warning(
@@ -505,6 +551,7 @@ class DialogueWatcher:
505
551
  transcript_path = str(signal_data.get("transcript_path") or "")
506
552
  project_dir = str(signal_data.get("project_dir") or "")
507
553
  no_track = bool(signal_data.get("no_track", False))
554
+ agent_id = str(signal_data.get("agent_id") or "")
508
555
 
509
556
  session_file = None
510
557
  if transcript_path and Path(transcript_path).exists():
@@ -534,6 +581,16 @@ class DialogueWatcher:
534
581
  no_track,
535
582
  )
536
583
 
584
+ # Link session to agent if agent_id is provided
585
+ if agent_id and session_id:
586
+ try:
587
+ from .db import get_database
588
+
589
+ db = get_database()
590
+ db.update_session_agent_id(session_id, agent_id)
591
+ except Exception:
592
+ pass
593
+
537
594
  signal_file.unlink(missing_ok=True)
538
595
  except Exception as e:
539
596
  logger.error(f"Error checking user prompt signals: {e}", exc_info=True)
realign/worker_core.py CHANGED
@@ -4,6 +4,7 @@ Background worker for durable jobs queue.
4
4
  This process consumes jobs from the SQLite `jobs` table:
5
5
  - turn_summary: generate/store a turn (LLM + content snapshot)
6
6
  - session_summary: aggregate session title/summary from turns
7
+ - agent_description: regenerate agent description from session summaries
7
8
  """
8
9
 
9
10
  from __future__ import annotations
@@ -66,7 +67,7 @@ class AlineWorker:
66
67
  try:
67
68
  job = self.db.claim_next_job(
68
69
  worker_id=self.worker_id,
69
- kinds=["turn_summary", "session_summary"],
70
+ kinds=["turn_summary", "session_summary", "agent_description"],
70
71
  )
71
72
  if not job:
72
73
  await asyncio.sleep(self.poll_interval_seconds)
@@ -134,6 +135,11 @@ class AlineWorker:
134
135
  )
135
136
  return
136
137
 
138
+ if kind == "agent_description":
139
+ await self._process_agent_description_job(payload)
140
+ self.db.finish_job(job_id=job_id, worker_id=self.worker_id, success=True)
141
+ return
142
+
137
143
  # Unknown job kind: mark as permanently failed to avoid infinite loops.
138
144
  self.db.finish_job(
139
145
  job_id=job_id,
@@ -187,6 +193,7 @@ class AlineWorker:
187
193
  expected_turns = int(expected_turns_raw) if expected_turns_raw is not None else None
188
194
  skip_dedup = bool(payload.get("skip_dedup") or False)
189
195
  no_track = bool(payload.get("no_track") or False)
196
+ agent_id = str(payload.get("agent_id") or "")
190
197
 
191
198
  if not session_id or turn_number <= 0 or not session_file_path:
192
199
  raise ValueError(f"Invalid turn_summary payload: {payload}")
@@ -216,6 +223,13 @@ class AlineWorker:
216
223
  no_track=no_track,
217
224
  )
218
225
 
226
+ # Link session to agent after commit ensures session exists in DB
227
+ if agent_id and session_id:
228
+ try:
229
+ self.db.update_session_agent_id(session_id, agent_id)
230
+ except Exception:
231
+ pass
232
+
219
233
  if created:
220
234
  if expected_turns:
221
235
  self._enqueue_session_summary_if_complete(session_id, expected_turns)
@@ -255,3 +269,12 @@ class AlineWorker:
255
269
  from .events.session_summarizer import update_session_summary_now
256
270
 
257
271
  return bool(update_session_summary_now(self.db, session_id))
272
+
273
+ async def _process_agent_description_job(self, payload: Dict[str, Any]) -> None:
274
+ agent_id = str(payload.get("agent_id") or "")
275
+ if not agent_id:
276
+ raise ValueError(f"Invalid agent_description payload: {payload}")
277
+
278
+ from .events.agent_summarizer import force_update_agent_description
279
+
280
+ force_update_agent_description(self.db, agent_id)