multi-agent-platform 0.1.0__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 (144) hide show
  1. cli/__init__.py +0 -0
  2. cli/action_item_escalation.py +177 -0
  3. cli/agent_client.py +554 -0
  4. cli/bridge_state.py +43 -0
  5. cli/commands/__init__.py +13 -0
  6. cli/commands/action.py +142 -0
  7. cli/commands/agent.py +117 -0
  8. cli/commands/audit.py +68 -0
  9. cli/commands/docs.py +179 -0
  10. cli/commands/experiment.py +755 -0
  11. cli/commands/feedback.py +106 -0
  12. cli/commands/notification.py +213 -0
  13. cli/commands/persona.py +63 -0
  14. cli/commands/project.py +87 -0
  15. cli/commands/runtime.py +105 -0
  16. cli/commands/topic.py +361 -0
  17. cli/e2e_collab.py +602 -0
  18. cli/git_checkpoint.py +68 -0
  19. cli/host_worker_types.py +151 -0
  20. cli/main.py +1553 -0
  21. cli/map_command_client.py +497 -0
  22. cli/participant_worker.py +255 -0
  23. cli/reviewer_worker.py +263 -0
  24. cli/runtime/__init__.py +5 -0
  25. cli/runtime/run_lock.py +497 -0
  26. cli/runtime_chat.py +317 -0
  27. cli/session_wake_log.py +235 -0
  28. cli/simple_waker.py +950 -0
  29. cli/table_render.py +113 -0
  30. cli/wake_backend.py +236 -0
  31. cli/worker_cycle_log.py +36 -0
  32. map_client/__init__.py +37 -0
  33. map_client/bootstrap.py +193 -0
  34. map_client/client.py +1045 -0
  35. map_client/config.py +21 -0
  36. map_client/errors.py +283 -0
  37. map_client/exceptions.py +130 -0
  38. map_client/plan_evidence.py +159 -0
  39. map_client/project_config.py +153 -0
  40. map_client/result_template.py +167 -0
  41. map_client/testing.py +27 -0
  42. map_mcp/__init__.py +4 -0
  43. map_mcp/_utils.py +28 -0
  44. map_mcp/auth.py +34 -0
  45. map_mcp/config.py +50 -0
  46. map_mcp/context.py +39 -0
  47. map_mcp/main.py +75 -0
  48. map_mcp/server.py +573 -0
  49. map_mcp/session.py +79 -0
  50. map_sdk/__init__.py +29 -0
  51. map_sdk/evidence.py +68 -0
  52. map_types/__init__.py +203 -0
  53. map_types/enums.py +199 -0
  54. map_types/schemas.py +1351 -0
  55. multi_agent_platform-0.1.0.dist-info/METADATA +298 -0
  56. multi_agent_platform-0.1.0.dist-info/RECORD +144 -0
  57. multi_agent_platform-0.1.0.dist-info/WHEEL +5 -0
  58. multi_agent_platform-0.1.0.dist-info/entry_points.txt +6 -0
  59. multi_agent_platform-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. multi_agent_platform-0.1.0.dist-info/top_level.txt +6 -0
  61. server/__init__.py +0 -0
  62. server/__version__.py +14 -0
  63. server/api/__init__.py +0 -0
  64. server/api/action_items.py +138 -0
  65. server/api/agents.py +412 -0
  66. server/api/audit.py +54 -0
  67. server/api/background_tasks.py +18 -0
  68. server/api/common.py +117 -0
  69. server/api/deps.py +30 -0
  70. server/api/experiments.py +858 -0
  71. server/api/feedback.py +75 -0
  72. server/api/notifications.py +22 -0
  73. server/api/projects.py +209 -0
  74. server/api/router.py +25 -0
  75. server/api/status.py +33 -0
  76. server/api/topics.py +302 -0
  77. server/api/webhooks.py +74 -0
  78. server/auth/__init__.py +8 -0
  79. server/auth/experiment_access.py +66 -0
  80. server/config.py +38 -0
  81. server/db/__init__.py +3 -0
  82. server/db/base.py +5 -0
  83. server/db/deadlock_retry.py +146 -0
  84. server/db/session.py +41 -0
  85. server/domain/__init__.py +3 -0
  86. server/domain/encrypted_types.py +63 -0
  87. server/domain/models.py +713 -0
  88. server/domain/schemas.py +3 -0
  89. server/domain/state_machine.py +79 -0
  90. server/domain/topic_ack_constants.py +9 -0
  91. server/main.py +148 -0
  92. server/scripts/__init__.py +0 -0
  93. server/scripts/migrate_notification_unique.py +231 -0
  94. server/scripts/purge_audit_pollution.py +116 -0
  95. server/services/__init__.py +0 -0
  96. server/services/_lookups.py +26 -0
  97. server/services/acceptance_service.py +90 -0
  98. server/services/action_item_migration_service.py +190 -0
  99. server/services/action_item_service.py +200 -0
  100. server/services/agent_work_service.py +405 -0
  101. server/services/archive_lint_service.py +156 -0
  102. server/services/audit_service.py +457 -0
  103. server/services/auth.py +66 -0
  104. server/services/comment_service.py +173 -0
  105. server/services/errors.py +65 -0
  106. server/services/escalation_resolver.py +248 -0
  107. server/services/evidence_service.py +88 -0
  108. server/services/experiment_capabilities_service.py +277 -0
  109. server/services/inbound_event_service.py +111 -0
  110. server/services/lock_service.py +273 -0
  111. server/services/log_service.py +202 -0
  112. server/services/mention_service.py +730 -0
  113. server/services/notification_service.py +939 -0
  114. server/services/notification_stream.py +138 -0
  115. server/services/permissions.py +147 -0
  116. server/services/persona_activity_service.py +108 -0
  117. server/services/phase_owner_resolver.py +95 -0
  118. server/services/phase_service.py +381 -0
  119. server/services/plan_marker_service.py +235 -0
  120. server/services/plan_service.py +186 -0
  121. server/services/platform_feedback_service.py +114 -0
  122. server/services/project_service.py +534 -0
  123. server/services/project_status_service.py +132 -0
  124. server/services/review_service.py +707 -0
  125. server/services/secret_encryption.py +97 -0
  126. server/services/similarity_service.py +119 -0
  127. server/services/sse_event_schemas.py +17 -0
  128. server/services/status_service.py +68 -0
  129. server/services/template_service.py +134 -0
  130. server/services/text_utils.py +19 -0
  131. server/services/thread_activity.py +180 -0
  132. server/services/todo_persona_filter.py +73 -0
  133. server/services/todo_service.py +604 -0
  134. server/services/topic_ack_service.py +312 -0
  135. server/services/topic_action_item_ops.py +538 -0
  136. server/services/topic_comment_kind.py +14 -0
  137. server/services/topic_comment_service.py +237 -0
  138. server/services/topic_helpers.py +32 -0
  139. server/services/topic_lifecycle_service.py +478 -0
  140. server/services/topic_progress_service.py +40 -0
  141. server/services/topic_resolve_service.py +234 -0
  142. server/services/topic_service.py +102 -0
  143. server/services/topic_work_item_service.py +570 -0
  144. server/services/webhook_service.py +273 -0
cli/runtime_chat.py ADDED
@@ -0,0 +1,317 @@
1
+ """Interactive MAP runtime chat — resume a persona session from the terminal.
2
+
3
+ Unlike map-runtime-waker (one prompt per wake), ``map runtime chat`` keeps the
4
+ Claude SDK connection open and accepts multiple user turns. Manual mode does not
5
+ call the D6 ``inbound-event record`` server gate.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import subprocess
12
+ import sys
13
+ from collections.abc import Callable
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import typer
18
+ from map_client.project_config import find_map_dir
19
+
20
+ from cli.agent_client import PersonaAgentClient, WakeUpEvent
21
+ from cli.bridge_state import load_bridge_state, save_bridge_state
22
+ from cli.host_worker_types import WorkerError
23
+ from cli.wake_backend import sync_runtime_skills
24
+
25
+ DEFAULT_STATE_TEMPLATE = ".map/runtime-waker-state-{persona}.json"
26
+ DEFAULT_RUNTIME_HOME_TEMPLATE = ".map/claude-runtime-home-{persona}"
27
+
28
+ _REPL_HELP = """\
29
+ Commands:
30
+ /help, /? Show this help
31
+ /session Print resumed Claude session id
32
+ /quit, /exit End chat (Ctrl+D also quits)
33
+ """
34
+
35
+
36
+ def resolve_project_root(project_root: Path | None) -> Path:
37
+ if project_root is not None:
38
+ return project_root.resolve()
39
+ map_dir = find_map_dir(None)
40
+ return map_dir.parent.resolve()
41
+
42
+
43
+ def default_state_file(project_root: Path, persona: str) -> Path:
44
+ return project_root / DEFAULT_STATE_TEMPLATE.format(persona=persona)
45
+
46
+
47
+ def default_runtime_home(project_root: Path, persona: str) -> Path:
48
+ return project_root / DEFAULT_RUNTIME_HOME_TEMPLATE.format(persona=persona)
49
+
50
+
51
+ def load_runtime_state(state_file: Path) -> dict[str, Any]:
52
+ return load_bridge_state(
53
+ state_file,
54
+ bridge_name="runtime-waker",
55
+ default_collections=("personas",),
56
+ )
57
+
58
+
59
+ def persona_agent_state(state: dict[str, Any], persona: str) -> dict[str, Any]:
60
+ personas = state.setdefault("personas", {})
61
+ if not isinstance(personas, dict):
62
+ personas = {}
63
+ state["personas"] = personas
64
+ persona_state = personas.setdefault(persona, {})
65
+ if not isinstance(persona_state, dict):
66
+ persona_state = {}
67
+ personas[persona] = persona_state
68
+ return persona_state
69
+
70
+
71
+ def resolve_session_id(
72
+ persona_state: dict[str, Any],
73
+ *,
74
+ session_id: str | None,
75
+ new_session: bool,
76
+ ) -> str | None:
77
+ if new_session:
78
+ return None
79
+ if session_id:
80
+ return session_id
81
+ for key in ("claude_session_id", "runtime_session_id"):
82
+ value = persona_state.get(key)
83
+ if value:
84
+ return str(value)
85
+ return None
86
+
87
+
88
+ def find_runtime_waker_pids(persona: str) -> list[int]:
89
+ # simple-waker 是默认 waker,实际进程是
90
+ # `python3 -m cli.simple_waker --persona <persona>`(legacy runtime-waker
91
+ # SSE 路径已下线)。
92
+ pattern = rf"cli\.simple_waker.*--persona {persona}"
93
+ result = subprocess.run(
94
+ ["pgrep", "-f", pattern],
95
+ check=False,
96
+ capture_output=True,
97
+ text=True,
98
+ )
99
+ if result.returncode != 0:
100
+ return []
101
+ pids: list[int] = []
102
+ for line in result.stdout.splitlines():
103
+ line = line.strip()
104
+ if not line:
105
+ continue
106
+ try:
107
+ pids.append(int(line))
108
+ except ValueError:
109
+ continue
110
+ return pids
111
+
112
+
113
+ def ensure_waker_not_running(*, persona: str, ignore_waker: bool) -> None:
114
+ if ignore_waker:
115
+ return
116
+ pids = find_runtime_waker_pids(persona)
117
+ if not pids:
118
+ return
119
+ pid_list = ", ".join(str(pid) for pid in pids)
120
+ raise WorkerError(
121
+ f"runtime waker for persona={persona!r} is already running (pid(s): {pid_list}). "
122
+ "Stop it before `map runtime chat` to avoid session conflicts, or pass --ignore-waker."
123
+ )
124
+
125
+
126
+ def format_session_banner(
127
+ *,
128
+ persona: str,
129
+ session_id: str | None,
130
+ state_file: Path,
131
+ runtime_home: Path,
132
+ resumed: bool,
133
+ ) -> str:
134
+ session_label = session_id or "<new session>"
135
+ mode = "resume" if resumed else "new"
136
+ return (
137
+ f"MAP runtime chat · persona={persona} · {mode} · session={session_label}\n"
138
+ f"state={state_file}\n"
139
+ f"runtime_home={runtime_home}\n"
140
+ "Type /help for commands. Empty line is ignored.\n"
141
+ )
142
+
143
+
144
+ def _default_on_event(event: WakeUpEvent) -> None:
145
+ if event.get("type") == "text":
146
+ sys.stdout.write(str(event.get("content") or ""))
147
+ sys.stdout.flush()
148
+
149
+
150
+ async def run_chat_loop(
151
+ client: PersonaAgentClient,
152
+ *,
153
+ initial_prompt: str | None,
154
+ input_fn: Callable[[str], str] = input,
155
+ on_event: Callable[[WakeUpEvent], None] | None = None,
156
+ ) -> None:
157
+ emit = on_event or _default_on_event
158
+
159
+ async def turn(prompt: str) -> str:
160
+ return await client.wake_up(
161
+ prompt,
162
+ on_event=emit,
163
+ event_source="manual",
164
+ )
165
+
166
+ await client.connect()
167
+
168
+ if initial_prompt:
169
+ typer.echo(f"\n[prompt] {initial_prompt}\n")
170
+ status = await turn(initial_prompt)
171
+ if status == "error":
172
+ raise WorkerError("Claude returned error status for initial prompt")
173
+ typer.echo("\n")
174
+
175
+ while True:
176
+ try:
177
+ line = input_fn("map> ").strip()
178
+ except EOFError:
179
+ typer.echo("")
180
+ break
181
+ if not line:
182
+ continue
183
+ lowered = line.lower()
184
+ if lowered in {"/quit", "/exit", "/q"}:
185
+ break
186
+ if lowered in {"/help", "/?"}:
187
+ typer.echo(_REPL_HELP)
188
+ continue
189
+ if lowered == "/session":
190
+ sid = client.state.get("claude_session_id") or client.state.get("runtime_session_id")
191
+ typer.echo(sid or "<none>")
192
+ continue
193
+
194
+ typer.echo("")
195
+ status = await turn(line)
196
+ if status == "error":
197
+ typer.echo("\n[error] Claude returned error status for this turn.", err=True)
198
+ typer.echo("\n")
199
+
200
+ await client.disconnect()
201
+
202
+
203
+ async def run_runtime_chat_async(
204
+ *,
205
+ persona: str,
206
+ project_root: Path | None,
207
+ state_file: Path | None,
208
+ runtime_home: Path | None,
209
+ session_id: str | None,
210
+ new_session: bool,
211
+ initial_prompt: str | None,
212
+ ignore_waker: bool,
213
+ model: str | None,
214
+ ) -> None:
215
+ root = resolve_project_root(project_root)
216
+ resolved_state_file = (state_file or default_state_file(root, persona)).resolve()
217
+ resolved_runtime_home = (runtime_home or default_runtime_home(root, persona)).resolve()
218
+
219
+ ensure_waker_not_running(persona=persona, ignore_waker=ignore_waker)
220
+
221
+ state = load_runtime_state(resolved_state_file)
222
+ agent_state = persona_agent_state(state, persona)
223
+ resume_id = resolve_session_id(agent_state, session_id=session_id, new_session=new_session)
224
+ if new_session:
225
+ agent_state.pop("claude_session_id", None)
226
+ agent_state.pop("runtime_session_id", None)
227
+
228
+ sync_runtime_skills(project_root=root, runtime_home=resolved_runtime_home)
229
+ resolved_runtime_home.mkdir(parents=True, exist_ok=True)
230
+
231
+ def save_state() -> None:
232
+ save_bridge_state(resolved_state_file, state)
233
+
234
+ extra_env = {
235
+ "HOME": str(resolved_runtime_home),
236
+ "MAP_RUNTIME_CHAT_PERSONA": persona,
237
+ }
238
+
239
+ client = PersonaAgentClient(
240
+ persona=persona,
241
+ state=agent_state,
242
+ save_state_fn=save_state,
243
+ project_root=root,
244
+ extra_env=extra_env,
245
+ model=model,
246
+ integration="manual",
247
+ )
248
+
249
+ if resume_id and not new_session:
250
+ client.state["claude_session_id"] = resume_id
251
+ client.state["runtime_session_id"] = resume_id
252
+
253
+ typer.echo(
254
+ format_session_banner(
255
+ persona=persona,
256
+ session_id=resume_id,
257
+ state_file=resolved_state_file,
258
+ runtime_home=resolved_runtime_home,
259
+ resumed=bool(resume_id),
260
+ )
261
+ )
262
+
263
+ try:
264
+ await run_chat_loop(client, initial_prompt=initial_prompt)
265
+ finally:
266
+ save_state()
267
+
268
+
269
+ def run_runtime_chat(**kwargs: Any) -> None:
270
+ try:
271
+ asyncio.run(run_runtime_chat_async(**kwargs))
272
+ except WorkerError as exc:
273
+ typer.echo(f"Error: {exc}", err=True)
274
+ raise typer.Exit(1) from exc
275
+ except KeyboardInterrupt:
276
+ typer.echo("\nInterrupted.")
277
+ raise typer.Exit(130) from None
278
+
279
+
280
+ def dump_runtime_chat_status(
281
+ *,
282
+ persona: str,
283
+ project_root: Path | None,
284
+ state_file: Path | None,
285
+ ) -> dict[str, Any]:
286
+ root = resolve_project_root(project_root)
287
+ resolved_state_file = (state_file or default_state_file(root, persona)).resolve()
288
+ state = load_runtime_state(resolved_state_file)
289
+ agent_state = persona_agent_state(state, persona)
290
+ session_id = resolve_session_id(agent_state, session_id=None, new_session=False)
291
+ return {
292
+ "persona": persona,
293
+ "project_root": str(root),
294
+ "state_file": str(resolved_state_file),
295
+ "runtime_home": str(default_runtime_home(root, persona)),
296
+ "session_id": session_id,
297
+ "waker_pids": find_runtime_waker_pids(persona),
298
+ "last_wakeup_at": agent_state.get("last_wakeup_at"),
299
+ "last_wakeup_status": agent_state.get("last_wakeup_status"),
300
+ }
301
+
302
+
303
+ __all__ = [
304
+ "default_runtime_home",
305
+ "default_state_file",
306
+ "dump_runtime_chat_status",
307
+ "ensure_waker_not_running",
308
+ "find_runtime_waker_pids",
309
+ "format_session_banner",
310
+ "load_runtime_state",
311
+ "persona_agent_state",
312
+ "resolve_project_root",
313
+ "resolve_session_id",
314
+ "run_chat_loop",
315
+ "run_runtime_chat",
316
+ "run_runtime_chat_async",
317
+ ]
@@ -0,0 +1,235 @@
1
+ """Append-only per-session wake logs for PersonaAgentClient."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from zoneinfo import ZoneInfo
12
+
13
+ DEFAULT_SESSION_LOG_DIR = Path(".map/runtime-waker-sessions")
14
+ DEFAULT_LOG_TIMEZONE = "Asia/Shanghai"
15
+ RESPONSE_PREVIEW_MAX = 200
16
+
17
+ _SAFE_SESSION_ID_RE = re.compile(r"[^\w.\-]+")
18
+
19
+
20
+ def log_timezone() -> ZoneInfo:
21
+ """Timezone for human-readable session log timestamps (default: Asia/Shanghai)."""
22
+ return ZoneInfo(os.environ.get("MAP_LOG_TIMEZONE", DEFAULT_LOG_TIMEZONE))
23
+
24
+
25
+ def log_now() -> datetime:
26
+ return datetime.now(log_timezone())
27
+
28
+
29
+ def safe_session_log_name(session_id: str) -> str:
30
+ """Turn a session id into a single path segment safe for log filenames."""
31
+ safe = _SAFE_SESSION_ID_RE.sub("_", session_id.strip())
32
+ return safe or "unknown"
33
+
34
+
35
+ def resolve_session_log_path(log_dir: Path, session_id: str, persona: str) -> Path:
36
+ """Resolve the JSONL path for a session (reuse existing file across resume wakes)."""
37
+ safe_id = safe_session_log_name(session_id)
38
+ safe_persona = safe_session_log_name(persona)
39
+ # Persona must be part of the lookup — multiple wakers can share the same
40
+ # provisional ``new-YYYYMMDDTHHMMSS`` id when they wake in the same second.
41
+ stamped = sorted(
42
+ log_dir.glob(f"*_{safe_persona}_{safe_id}.jsonl"),
43
+ key=lambda path: path.stat().st_mtime,
44
+ )
45
+ if stamped:
46
+ return stamped[-1]
47
+ legacy = log_dir / f"{safe_id}.jsonl"
48
+ if legacy.exists():
49
+ return legacy
50
+ ts = log_now().strftime("%Y%m%d-%H%M%S")
51
+ return log_dir / f"{ts}_{safe_persona}_{safe_id}.jsonl"
52
+
53
+
54
+ def response_preview(text: str, *, max_chars: int = RESPONSE_PREVIEW_MAX) -> str:
55
+ if max_chars <= 0:
56
+ return ""
57
+ return text[:max_chars]
58
+
59
+
60
+ EVENT_SUMMARY_MAX = 160
61
+
62
+ # Scalar input fields that best describe what a tool call is doing, in priority
63
+ # order — keeps tool_use summaries short and human-scannable (which command,
64
+ # which file) instead of dumping the whole input dict.
65
+ _TOOL_INPUT_PRIORITY: tuple[str, ...] = (
66
+ "command",
67
+ "description",
68
+ "file_path",
69
+ "path",
70
+ "pattern",
71
+ "url",
72
+ "query",
73
+ "body",
74
+ )
75
+
76
+
77
+ def _scalar_preview(value: Any, *, max_chars: int) -> str:
78
+ if value is None:
79
+ return ""
80
+ if isinstance(value, str):
81
+ return value[:max_chars]
82
+ return json.dumps(value, ensure_ascii=False)[:max_chars]
83
+
84
+
85
+ def text_summary(text: str | None, *, max_chars: int = EVENT_SUMMARY_MAX) -> str:
86
+ """One-line preview of assistant text (newlines collapsed)."""
87
+ if not text:
88
+ return ""
89
+ return " ".join(text.split())[:max_chars]
90
+
91
+
92
+ def tool_use_summary(
93
+ name: str,
94
+ tool_input: Any,
95
+ *,
96
+ max_chars: int = EVENT_SUMMARY_MAX,
97
+ ) -> str:
98
+ """Short summary of a tool call, e.g. ``Bash: pytest tests/ -q``."""
99
+ if isinstance(tool_input, dict):
100
+ for key in _TOOL_INPUT_PRIORITY:
101
+ if tool_input.get(key) is not None:
102
+ return f"{name}: {_scalar_preview(tool_input[key], max_chars=max_chars)}"
103
+ for value in tool_input.values():
104
+ if isinstance(value, (str, int, float, bool)):
105
+ return f"{name}: {_scalar_preview(value, max_chars=max_chars)}"
106
+ return str(name)
107
+ return f"{name}: {_scalar_preview(tool_input, max_chars=max_chars)}"
108
+
109
+
110
+ def tool_result_summary(
111
+ content: Any,
112
+ is_error: bool | None,
113
+ *,
114
+ max_chars: int = EVENT_SUMMARY_MAX,
115
+ ) -> str:
116
+ """Short summary of a tool result, e.g. ``ok: 45 passed`` or ``error: ...``."""
117
+ label = "error" if is_error else "ok"
118
+ if content is None:
119
+ return label
120
+ if isinstance(content, str):
121
+ body = content
122
+ elif isinstance(content, list):
123
+ parts: list[str] = []
124
+ for item in content:
125
+ if isinstance(item, dict):
126
+ parts.append(str(item.get("text") or item.get("content") or ""))
127
+ else:
128
+ parts.append(str(item))
129
+ body = " ".join(p for p in parts if p)
130
+ else:
131
+ body = str(content)
132
+ body = " ".join(body.split())
133
+ if not body:
134
+ return label
135
+ return f"{label}: {body[:max_chars]}"
136
+
137
+
138
+ def append_session_wake_log(
139
+ *,
140
+ log_dir: Path,
141
+ session_id: str,
142
+ persona: str,
143
+ integration: str,
144
+ prompt: str,
145
+ response_text: str,
146
+ status: str,
147
+ preview_chars: int = RESPONSE_PREVIEW_MAX,
148
+ event_id: str | None = None,
149
+ event_source: str = "polling",
150
+ fingerprint: str | None = None,
151
+ log_path: Path | None = None,
152
+ ) -> Path:
153
+ """Append one JSON line to the session's JSONL file under ``log_dir``.
154
+
155
+ ``event_id`` and ``event_source`` are the join keys for the A3 audit
156
+ three-way join (notification ↔ inbound_event ↔ sessions jsonl). Default
157
+ ``event_source="polling"`` matches Phase 1 reality; Phase 2 will start
158
+ emitting ``sse`` once the event-driven path is wired.
159
+ ``fingerprint`` is optional — included when the caller has it on hand so
160
+ A3 join fallbacks can pivot on it without consulting inbound_event.
161
+ """
162
+ log_dir.mkdir(parents=True, exist_ok=True)
163
+ # When the caller already resolved a log_path (e.g. wake_up writes live
164
+ # events into one file and wants the result summary in that same file),
165
+ # honor it instead of re-resolving by session_id.
166
+ path = log_path if log_path is not None else resolve_session_log_path(log_dir, session_id, persona)
167
+ entry: dict[str, Any] = {
168
+ "ts": log_now().isoformat(),
169
+ "session_id": session_id,
170
+ "persona": persona,
171
+ "integration": integration,
172
+ "status": status,
173
+ "event_source": event_source,
174
+ "event_id": event_id,
175
+ "fingerprint": fingerprint,
176
+ "prompt": prompt,
177
+ "response_preview": response_preview(response_text, max_chars=preview_chars),
178
+ "response_chars": len(response_text),
179
+ }
180
+ with path.open("a", encoding="utf-8") as handle:
181
+ handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
182
+ return path
183
+
184
+
185
+ def append_session_event(
186
+ *,
187
+ log_path: Path,
188
+ persona: str,
189
+ integration: str,
190
+ event: str,
191
+ summary: str,
192
+ event_id: str | None = None,
193
+ event_source: str = "polling",
194
+ fingerprint: str | None = None,
195
+ ) -> None:
196
+ """Append one real-time event line to a session's JSONL file.
197
+
198
+ Called live during ``PersonaAgentClient.wake_up()`` for each assistant text
199
+ block, tool call, and tool result, so an agent stuck mid-turn is visible
200
+ from the timestamp of the last written event. ``event`` is one of
201
+ ``wake``/``text``/``tool_use``/``tool_result``/``result``. The caller
202
+ resolves ``log_path`` once up front (via :func:`resolve_session_log_path`)
203
+ so every event of one wake lands in the same file.
204
+ """
205
+ log_path.parent.mkdir(parents=True, exist_ok=True)
206
+ entry: dict[str, Any] = {
207
+ "ts": log_now().isoformat(),
208
+ "persona": persona,
209
+ "integration": integration,
210
+ "event": event,
211
+ "summary": summary,
212
+ "event_source": event_source,
213
+ "event_id": event_id,
214
+ "fingerprint": fingerprint,
215
+ }
216
+ with log_path.open("a", encoding="utf-8") as handle:
217
+ handle.write(json.dumps(entry, ensure_ascii=False) + "\n")
218
+
219
+
220
+ __all__ = [
221
+ "DEFAULT_LOG_TIMEZONE",
222
+ "DEFAULT_SESSION_LOG_DIR",
223
+ "EVENT_SUMMARY_MAX",
224
+ "RESPONSE_PREVIEW_MAX",
225
+ "append_session_event",
226
+ "append_session_wake_log",
227
+ "log_now",
228
+ "log_timezone",
229
+ "resolve_session_log_path",
230
+ "response_preview",
231
+ "safe_session_log_name",
232
+ "text_summary",
233
+ "tool_result_summary",
234
+ "tool_use_summary",
235
+ ]