projectmemory-cli 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.
@@ -0,0 +1,277 @@
1
+ """Session commands: pm session status, pm session end."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import questionary
7
+ import typer
8
+
9
+ from projectmemory_cli import config, output
10
+ from projectmemory_cli.api.client import APIClient
11
+ from projectmemory_cli.api.errors import APIError, SessionAlreadyFinalizedError
12
+ from projectmemory_cli.models.api import ActiveSession
13
+ from projectmemory_cli.project_context import guard_project_session
14
+
15
+ app = typer.Typer(help="Session commands.", no_args_is_help=True)
16
+
17
+
18
+ def _require_client() -> APIClient:
19
+ from projectmemory_cli import credentials
20
+ token = credentials.get_token()
21
+ if not token:
22
+ output.error("Not logged in. Run: pm login")
23
+ raise SystemExit(2)
24
+ return APIClient.from_env()
25
+
26
+
27
+ def _review_data(response: dict) -> dict:
28
+ return response.get("review", response)
29
+
30
+
31
+ def _short_text(value: str, limit: int = 72) -> str:
32
+ value = (value or "").replace("\n", " ").strip()
33
+ return value if len(value) <= limit else value[: limit - 1].rstrip() + "…"
34
+
35
+
36
+ def _item_title(item: dict) -> str:
37
+ return item.get("title") or item.get("description") or item.get("message") or item.get("path") or f"#{item.get('id', '?')}"
38
+
39
+
40
+ def _display_compact_section(title: str, items: list[dict], *, icon: str = "•", limit: int = 5) -> None:
41
+ output.header(f"{title} ({len(items)})")
42
+ if not items:
43
+ output.hint("(none)")
44
+ return
45
+ for item in items[:limit]:
46
+ prefix = ""
47
+ if item.get("number"):
48
+ prefix = f"#{item['number']} "
49
+ elif item.get("sha"):
50
+ prefix = f"{str(item['sha'])[:8]} "
51
+ output.info(f" {icon} {prefix}{_short_text(_item_title(item))}")
52
+ if len(items) > limit:
53
+ output.hint(f"+{len(items) - limit} more")
54
+
55
+
56
+ @app.command("status")
57
+ def session_status(json: bool = typer.Option(False, "--json")) -> None:
58
+ """Show active sessions."""
59
+ output.configure(json_mode=json)
60
+ client = _require_client()
61
+ try:
62
+ data = client.get("/api/cli/session/status/")
63
+ except APIError as exc:
64
+ output.error(str(exc))
65
+ raise SystemExit(exc.exit_code)
66
+
67
+ if json:
68
+ output.print_json(data)
69
+ return
70
+
71
+ sessions = data.get("active_sessions", [])
72
+ current_id = config.get_current_project_id()
73
+ if not sessions:
74
+ output.info("No active sessions.")
75
+ output.hint("Run: pm resume")
76
+ return
77
+
78
+ output.blank()
79
+ output.header("Active Sessions")
80
+ current = [s for s in sessions if str(s.get("project")) == str(current_id)]
81
+ other = [s for s in sessions if str(s.get("project")) != str(current_id)]
82
+
83
+ def line(s: dict) -> str:
84
+ name = s.get("project_name", f"Project #{s.get('project', '?')}")
85
+ started = output.format_ago(s.get("started_at", ""))
86
+ duration = output.format_duration(int(s.get("duration_seconds") or 0))
87
+ return f"[bold]{name}[/bold] [dim]started {started} ({duration})[/dim]"
88
+
89
+ if current:
90
+ output.info("Current:")
91
+ for s in current:
92
+ output.info(f" {line(s)}")
93
+ if other:
94
+ if current:
95
+ output.blank()
96
+ output.info("Other:")
97
+ for s in other:
98
+ output.info(f" {line(s)}")
99
+ output.blank()
100
+
101
+
102
+ @app.command("end")
103
+ def session_end(
104
+ project_arg: Optional[str] = typer.Option(None, "--project", "-p"),
105
+ note: Optional[str] = typer.Option(None, "--note", "-n", help="Additional session note."),
106
+ progress: Optional[str] = typer.Option(None, "--progress", help="Override generated session summary."),
107
+ no_review: bool = typer.Option(False, "--no-review", help="Skip session review display."),
108
+ no_memory: bool = typer.Option(False, "--no-memory", help="Skip memory update prompts."),
109
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip final confirmation."),
110
+ json: bool = typer.Option(False, "--json"),
111
+ ) -> None:
112
+ """End the current work session."""
113
+ output.configure(json_mode=json)
114
+ client = _require_client()
115
+ project, session = guard_project_session(client, project_arg=project_arg)
116
+ _run_end_session(
117
+ client,
118
+ project.id,
119
+ session,
120
+ interactive=not json,
121
+ note=note,
122
+ progress=progress,
123
+ no_review=no_review,
124
+ no_memory=no_memory,
125
+ assume_yes=yes,
126
+ json_mode=json,
127
+ )
128
+
129
+
130
+ def _run_end_session(
131
+ client: APIClient,
132
+ project_id: int,
133
+ session: ActiveSession,
134
+ *,
135
+ interactive: bool = True,
136
+ note: Optional[str] = None,
137
+ progress: Optional[str] = None,
138
+ no_review: bool = False,
139
+ no_memory: bool = False,
140
+ assume_yes: bool = False,
141
+ json_mode: bool = False,
142
+ ) -> None:
143
+ """Core session end flow. Called from session end command and resume switching."""
144
+ session_client = APIClient(base_url=client.base_url, token=client.token, session_id=session.id)
145
+ review: dict = {}
146
+ if not no_review:
147
+ try:
148
+ review_response = client.get("/api/cli/session/review/", params={"session_id": str(session.id)})
149
+ review = _review_data(review_response)
150
+ if interactive:
151
+ _display_review(review)
152
+ except APIError as exc:
153
+ if interactive:
154
+ output.warn(f"Could not load session review: {exc}")
155
+
156
+ payload: dict = {"session_id": session.id}
157
+ changed_memory: list[str] = []
158
+
159
+ if interactive:
160
+ if note is None:
161
+ note = questionary.text("Anything else worth remembering? (optional):").ask() or ""
162
+ if not no_memory:
163
+ update_memory = questionary.confirm(
164
+ "Review Project Memory before ending?", default=False
165
+ ).ask()
166
+ if update_memory:
167
+ changed_memory = _gather_memory_update(client, session, review=review)
168
+ if not assume_yes:
169
+ confirmed = questionary.confirm("End session?", default=True).ask()
170
+ if not confirmed:
171
+ output.info("Cancelled.")
172
+ raise SystemExit(7)
173
+
174
+ if note:
175
+ payload["session_note"] = note
176
+ if progress:
177
+ payload["session_progress"] = progress
178
+
179
+ try:
180
+ data = session_client.post("/api/cli/session/end/", json=payload)
181
+ except SessionAlreadyFinalizedError:
182
+ output.warn("This session was already ended from another client.")
183
+ output.hint("Run: pm resume")
184
+ raise SystemExit(8)
185
+ except APIError as exc:
186
+ output.error(str(exc))
187
+ raise SystemExit(exc.exit_code)
188
+
189
+ if json_mode:
190
+ output.print_json(data)
191
+ return
192
+
193
+ output.blank()
194
+ output.success("Session ended.")
195
+ output.success("Session summary saved.")
196
+ if changed_memory:
197
+ output.hint("Updated Project Memory: " + ", ".join(changed_memory))
198
+ output.blank()
199
+
200
+
201
+ def _display_review(review: dict) -> None:
202
+ project = review.get("project", {})
203
+ project_name = project.get("name", "Project")
204
+ duration = output.format_duration(int(review.get("duration_seconds") or 0))
205
+ counts = review.get("counts", {})
206
+
207
+ output.blank()
208
+ output.rule("Session Review")
209
+ output.kv("Project", f"[bold]{project_name}[/bold]")
210
+ output.kv("Duration", duration)
211
+
212
+ summary_parts = []
213
+ labels = [
214
+ ("completed_tasks", "task completed", "tasks completed"),
215
+ ("resolved_issues", "issue resolved", "issues resolved"),
216
+ ("github_commits", "commit", "commits"),
217
+ ("github_issues_closed", "GitHub issue closed", "GitHub issues closed"),
218
+ ("changed_files", "file changed", "files changed"),
219
+ ]
220
+ for key, singular, plural in labels:
221
+ count = int(counts.get(key) or len(review.get(key, [])) or 0)
222
+ if count:
223
+ summary_parts.append(f"{count} {singular if count == 1 else plural}")
224
+ if summary_parts:
225
+ output.kv("Activity", " • ".join(summary_parts))
226
+
227
+ _display_compact_section("Completed Tasks", review.get("completed_tasks", []), icon="✓")
228
+ _display_compact_section("Resolved Project Memory Issues", review.get("resolved_issues", []), icon="✓")
229
+ _display_compact_section("GitHub Commits", review.get("github_commits", []), icon="•")
230
+ _display_compact_section("GitHub Issues Closed", review.get("github_issues_closed", []), icon="✓")
231
+ _display_compact_section("Files Changed", review.get("changed_files", []), icon="•", limit=5)
232
+
233
+ memory = review.get("memory", {})
234
+ if memory:
235
+ output.header("Current Project Memory")
236
+ for label, key in [("Next Action", "next_action"), ("Current Status", "current_status"), ("Important Context", "important_context")]:
237
+ field = memory.get(key, {}) or {}
238
+ title = field.get("title") or field.get("text") or "(none)"
239
+ output.kv(label, _short_text(title, 100))
240
+ output.blank()
241
+
242
+
243
+ def _gather_memory_update(client: APIClient, session: ActiveSession, *, review: dict | None = None) -> list[str]:
244
+ """Optionally update memory fields using normal backend operations."""
245
+ from projectmemory_cli.commands.memory import memory_context_set, memory_status_set
246
+ from projectmemory_cli.commands.next_action import run_next_action_menu
247
+ from projectmemory_cli.project_context import fetch_projects, find_project_by_name_or_id
248
+
249
+ projects = fetch_projects(client)
250
+ project = find_project_by_name_or_id(projects, str(session.project))
251
+ if not project:
252
+ output.warn("Could not resolve project for memory update.")
253
+ return []
254
+
255
+ changed: list[str] = []
256
+ while True:
257
+ choice = questionary.select(
258
+ "Update Project Memory:",
259
+ choices=[
260
+ questionary.Choice("Next Action", value="next"),
261
+ questionary.Choice("Current Status", value="status"),
262
+ questionary.Choice("Important Context", value="context"),
263
+ questionary.Choice("Done", value="done"),
264
+ ],
265
+ ).ask()
266
+ if not choice or choice == "done":
267
+ break
268
+ if choice == "next":
269
+ if run_next_action_menu(client, project, session):
270
+ changed.append("Next Action")
271
+ elif choice == "status":
272
+ memory_status_set(text=None, editor=False, stdin=False, project_arg=str(project.id))
273
+ changed.append("Current Status")
274
+ elif choice == "context":
275
+ memory_context_set(text=None, editor=False, stdin=False, project_arg=str(project.id))
276
+ changed.append("Important Context")
277
+ return changed