pcode 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 (163) hide show
  1. pcode/__init__.py +7 -0
  2. pcode/_job_supervisor.py +65 -0
  3. pcode/agent.py +592 -0
  4. pcode/anthropic_oauth.py +469 -0
  5. pcode/app.py +3165 -0
  6. pcode/appearance.py +181 -0
  7. pcode/aside.py +541 -0
  8. pcode/aside_guard.py +39 -0
  9. pcode/aside_ui.py +683 -0
  10. pcode/auth.py +113 -0
  11. pcode/block.py +39 -0
  12. pcode/browser.py +393 -0
  13. pcode/cache_diagnostics.py +336 -0
  14. pcode/cache_settings.py +58 -0
  15. pcode/cache_warnings.py +139 -0
  16. pcode/claude_sdk/__init__.py +96 -0
  17. pcode/claude_sdk/cli.py +52 -0
  18. pcode/claude_sdk/errors.py +61 -0
  19. pcode/claude_sdk/messages.py +154 -0
  20. pcode/claude_sdk/model.py +179 -0
  21. pcode/claude_sdk/resume.py +114 -0
  22. pcode/claude_sdk/session.py +547 -0
  23. pcode/claude_sdk/session_pool.py +251 -0
  24. pcode/claude_sdk/workspace.py +34 -0
  25. pcode/cli.py +73 -0
  26. pcode/clipboard.py +76 -0
  27. pcode/code_mode.py +43 -0
  28. pcode/codex_login.py +189 -0
  29. pcode/command_transcript.py +71 -0
  30. pcode/commands.py +145 -0
  31. pcode/compaction.py +457 -0
  32. pcode/completion.py +263 -0
  33. pcode/config.py +223 -0
  34. pcode/context_breakdown.py +178 -0
  35. pcode/context_usage.py +52 -0
  36. pcode/controller.py +3496 -0
  37. pcode/conversation_ids.py +27 -0
  38. pcode/conversation_tree.py +160 -0
  39. pcode/copy_ui.py +172 -0
  40. pcode/delegation.py +236 -0
  41. pcode/delta.py +295 -0
  42. pcode/diagnostics.py +226 -0
  43. pcode/edit_preview.py +158 -0
  44. pcode/edit_transcript.py +287 -0
  45. pcode/edit_ui.py +850 -0
  46. pcode/edits.py +162 -0
  47. pcode/email_remote/__init__.py +14 -0
  48. pcode/email_remote/cli.py +133 -0
  49. pcode/email_remote/listener.py +695 -0
  50. pcode/email_remote/mailbox.py +336 -0
  51. pcode/email_remote/outbound.py +75 -0
  52. pcode/email_remote/parsing.py +226 -0
  53. pcode/email_remote/render.py +175 -0
  54. pcode/email_remote/state.py +79 -0
  55. pcode/error_report.py +38 -0
  56. pcode/ext.py +602 -0
  57. pcode/extension_guide.md +311 -0
  58. pcode/extensions/browser.py +284 -0
  59. pcode/extensions/ponytail.py +217 -0
  60. pcode/extensions/sandbox.py +138 -0
  61. pcode/extensions/session_history.py +170 -0
  62. pcode/extensions/web_research.py +89 -0
  63. pcode/file_refs.py +265 -0
  64. pcode/filesystem.py +138 -0
  65. pcode/frame.py +149 -0
  66. pcode/gateway.py +465 -0
  67. pcode/git_diff.py +497 -0
  68. pcode/history.py +589 -0
  69. pcode/history_cursor.py +147 -0
  70. pcode/history_embeddings.py +102 -0
  71. pcode/host.py +941 -0
  72. pcode/host_protocol.py +291 -0
  73. pcode/host_ui.py +225 -0
  74. pcode/input_keys.py +35 -0
  75. pcode/inspection.py +289 -0
  76. pcode/inspector_ui.py +448 -0
  77. pcode/isolated_delegation.py +425 -0
  78. pcode/job_notices.py +54 -0
  79. pcode/jobs.py +584 -0
  80. pcode/jobs_ui.py +265 -0
  81. pcode/keymap.py +215 -0
  82. pcode/layout_speed.py +103 -0
  83. pcode/links.py +90 -0
  84. pcode/links_ui.py +200 -0
  85. pcode/live.py +1301 -0
  86. pcode/llm_proxy.py +44 -0
  87. pcode/logins.py +233 -0
  88. pcode/mcp.py +683 -0
  89. pcode/mcp_notice.py +91 -0
  90. pcode/mcp_oauth.py +381 -0
  91. pcode/meridian.py +233 -0
  92. pcode/meridian_process.py +337 -0
  93. pcode/meridian_reminders.py +107 -0
  94. pcode/meridian_setup.py +356 -0
  95. pcode/model_metadata.py +451 -0
  96. pcode/model_ui.py +168 -0
  97. pcode/models.py +209 -0
  98. pcode/native_results.py +119 -0
  99. pcode/oauth_pages.py +66 -0
  100. pcode/output_limits.py +41 -0
  101. pcode/paste.py +45 -0
  102. pcode/path_completion.py +66 -0
  103. pcode/plan_preview.py +131 -0
  104. pcode/plan_sizes.py +35 -0
  105. pcode/planning.py +161 -0
  106. pcode/popup_ui.py +873 -0
  107. pcode/preferences.py +1176 -0
  108. pcode/prefix_keys.py +490 -0
  109. pcode/profile_benchmark.py +332 -0
  110. pcode/profiling.py +566 -0
  111. pcode/project_trust.py +94 -0
  112. pcode/prompt_keys.py +286 -0
  113. pcode/remote.py +717 -0
  114. pcode/remote_print.py +125 -0
  115. pcode/remote_profile.py +237 -0
  116. pcode/replay_benchmark.py +319 -0
  117. pcode/repo_context.py +158 -0
  118. pcode/retries.py +28 -0
  119. pcode/rpc.py +280 -0
  120. pcode/runtime.py +244 -0
  121. pcode/sandbox.py +454 -0
  122. pcode/session_naming.py +82 -0
  123. pcode/session_ui.py +550 -0
  124. pcode/sessions.py +892 -0
  125. pcode/shell.py +113 -0
  126. pcode/shell_mode.py +172 -0
  127. pcode/shell_tools.py +459 -0
  128. pcode/skills.py +161 -0
  129. pcode/stall_log.py +187 -0
  130. pcode/startup_input.py +96 -0
  131. pcode/steering.py +27 -0
  132. pcode/stream_display.py +188 -0
  133. pcode/stream_events.py +437 -0
  134. pcode/strict_tools.py +130 -0
  135. pcode/syntax.py +43 -0
  136. pcode/syntax_colors.py +206 -0
  137. pcode/task_prompt.py +28 -0
  138. pcode/task_worktrees.py +482 -0
  139. pcode/terminal_notify.py +383 -0
  140. pcode/theme.py +135 -0
  141. pcode/theme_gallery.py +88 -0
  142. pcode/thinking_markdown.py +41 -0
  143. pcode/token_accounting.py +67 -0
  144. pcode/tool_display.py +784 -0
  145. pcode/tool_output_limits.py +108 -0
  146. pcode/tool_panel.py +377 -0
  147. pcode/transcript_log.py +127 -0
  148. pcode/transcript_notice.py +103 -0
  149. pcode/tree_ui.py +288 -0
  150. pcode/turn.py +44 -0
  151. pcode/ui.py +3745 -0
  152. pcode/usage.py +293 -0
  153. pcode/word_wrap.py +107 -0
  154. pcode/worker_ui.py +235 -0
  155. pcode/workers.py +136 -0
  156. pcode/workspace.py +57 -0
  157. pcode/workspace_filesystem.py +72 -0
  158. pcode/worktree.py +650 -0
  159. pcode-0.1.0.dist-info/METADATA +145 -0
  160. pcode-0.1.0.dist-info/RECORD +163 -0
  161. pcode-0.1.0.dist-info/WHEEL +4 -0
  162. pcode-0.1.0.dist-info/entry_points.txt +3 -0
  163. pcode-0.1.0.dist-info/licenses/LICENSE +21 -0
pcode/history.py ADDED
@@ -0,0 +1,589 @@
1
+ """Read-only, bounded retrieval over saved conversations, independent of the UI."""
2
+
3
+ import math
4
+ import re
5
+ from collections import Counter
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Literal
9
+
10
+ from pcode.diagnostics import redact
11
+ from pcode.history_cursor import JournalReader, advance
12
+ from pcode.sessions import SessionInfo, SessionReadBudget, list_sessions
13
+ from pcode.worktree import WORKTREES_DIR, project_checkout
14
+
15
+ Scope = Literal["session", "project", "workspace", "all"]
16
+ KINDS = (
17
+ "turn_started",
18
+ "Message",
19
+ "ToolSummary",
20
+ "turn_completed",
21
+ "turn_failed",
22
+ "turn_cancelled",
23
+ "tree_selected",
24
+ "compaction_checkpoint",
25
+ "auto_compacted",
26
+ "steering",
27
+ )
28
+ CHUNK_CHARS = 4000
29
+ MAX_CHUNKS = 10_000
30
+ # Journal bytes read, not searchable text: most of a transcript is streaming
31
+ # deltas that the record filter discards. Scanning is ~75 MB/s on a warm cache,
32
+ # so this bounds journal I/O per call; `after` resumes without rereading prefixes.
33
+ MAX_SCAN_BYTES = 256 * 1024 * 1024
34
+ # Ranking is chunk-level, so one long session can otherwise fill the whole limit.
35
+ PER_SESSION_LIMIT = 3
36
+ # Enough occurrences to find prose without scanning a pathological chunk repeatedly.
37
+ MAX_MATCH_POSITIONS = 200
38
+ EXCERPT_CHARS = 800
39
+ CONCLUSION_CHARS = 240
40
+
41
+
42
+ @dataclass
43
+ class HistoryTurn:
44
+ id: str
45
+ parent: str | None
46
+ time: str
47
+ status: str = "incomplete"
48
+ active: bool | None = False
49
+ # Auto-compaction dropped part of this turn from the model's own context,
50
+ # which makes the journal the only copy even while the turn is still running.
51
+ compacted: bool = False
52
+ blocks: list[str] = field(default_factory=list)
53
+
54
+ @property
55
+ def branch(self) -> str:
56
+ return "unknown" if self.active is None else "active" if self.active else "inactive"
57
+
58
+ @property
59
+ def text(self) -> str:
60
+ return redact("\n\n".join(self.blocks))
61
+
62
+ @property
63
+ def conclusion(self) -> str:
64
+ """The last assistant text: what the turn decided, not how it got there."""
65
+ for block in reversed(self.blocks):
66
+ if block.startswith("Assistant: "):
67
+ return redact(block.removeprefix("Assistant: ")).strip()
68
+ return ""
69
+
70
+
71
+ @dataclass
72
+ class Chunk:
73
+ session: SessionInfo
74
+ turn: HistoryTurn
75
+ offset: int
76
+ text: str
77
+
78
+ def excerpt_start(self, query: str) -> int:
79
+ """Anchor on the densest match in prose: tool lines rarely hold the answer."""
80
+ words = list(dict.fromkeys(query.casefold().split()))
81
+ folded = self.text.casefold()
82
+ positions = sorted(
83
+ {
84
+ match.start()
85
+ for word in words
86
+ for match in list(re.finditer(re.escape(word), folded))[:MAX_MATCH_POSITIONS]
87
+ }
88
+ )
89
+ if not positions:
90
+ return 0
91
+ candidates = [
92
+ position
93
+ for position in positions
94
+ if not self.text.startswith("Tool: ", self.text.rfind("\n", 0, position) + 1)
95
+ ]
96
+
97
+ def density(position: int) -> int:
98
+ window = folded[position : position + EXCERPT_CHARS]
99
+ return sum(word in window for word in words)
100
+
101
+ # Most distinct terms in the window the excerpt would show; ties go earlier.
102
+ best = max(candidates or positions, key=lambda position: (density(position), -position))
103
+ return max(0, best - 150)
104
+
105
+ def result(self, query: str) -> dict:
106
+ start = self.excerpt_start(query)
107
+ excerpt = self.text[start : start + EXCERPT_CHARS]
108
+ result = {
109
+ "turn_id": self.turn.id,
110
+ "time": self.turn.time,
111
+ "status": self.turn.status,
112
+ "branch": self.turn.branch,
113
+ "offset": self.offset + start,
114
+ "excerpt": excerpt,
115
+ }
116
+ # "Did we already do X" is answered by the turn's last word on it, which
117
+ # a match in the middle of a long turn would otherwise bury.
118
+ conclusion = self.turn.conclusion[:CONCLUSION_CHARS]
119
+ if conclusion and conclusion not in excerpt:
120
+ result["conclusion"] = conclusion
121
+ return result
122
+
123
+
124
+ @dataclass
125
+ class Scan:
126
+ """Searchable chunks plus the coverage of the scan that produced them."""
127
+
128
+ chunks: list[Chunk]
129
+ warnings: list[str]
130
+ sessions_in_scope: int = 0
131
+ sessions_searched: int = 0
132
+ sessions_unreadable: int = 0
133
+ sessions_partial: int = 0
134
+ next_cursor: str | None = None
135
+ scan_complete: bool = False
136
+
137
+
138
+ def _turn_of(record: dict, turns: dict[str, "HistoryTurn"], recording: str | None):
139
+ """The turn a record belongs to: the one it names, else the last one started.
140
+
141
+ The fallback is for journals written before records carried a run ID, and
142
+ for a scan window that began after the turn's own start record.
143
+ """
144
+ identity = record.get("run_id")
145
+ if not isinstance(identity, str) or identity not in turns:
146
+ identity = recording
147
+ return turns.get(identity) if identity is not None else None
148
+
149
+
150
+ class TurnReader:
151
+ """Keep attribution and ancestry intact across journal byte pages."""
152
+
153
+ def __init__(self, info: SessionInfo, root: Path):
154
+ self.journal = JournalReader(info, root, KINDS)
155
+ self.turns: dict[str, HistoryTurn] = {}
156
+ self.parents: dict[str, str | None] = {}
157
+ self.active: str | None = None
158
+ self.recording: str | None = None
159
+
160
+ def read(self, budget: SessionReadBudget):
161
+ for record in self.journal.records(budget):
162
+ self.accept(record)
163
+ if self.journal.done:
164
+ self.finish()
165
+
166
+ def accept(self, record: dict):
167
+ kind = record["kind"]
168
+ if kind == "turn_started" and isinstance(record.get("prompt"), str):
169
+ identity = record.get("run_id") or f"turn-{len(self.turns) + 1}"
170
+ parent = record.get("parent_id", self.active)
171
+ if not isinstance(identity, str) or not (parent is None or isinstance(parent, str)):
172
+ return
173
+ self.parents[identity] = parent
174
+ self.turns[identity] = HistoryTurn(
175
+ identity,
176
+ parent,
177
+ str(record.get("time", self.journal.info.created)),
178
+ active=None,
179
+ blocks=["User: " + record["prompt"]],
180
+ )
181
+ self.active = self.recording = identity
182
+ elif kind == "compaction_checkpoint":
183
+ identity, parent = record.get("node_id"), record.get("parent_id")
184
+ if isinstance(identity, str) and (parent is None or isinstance(parent, str)):
185
+ self.parents[identity] = parent
186
+ self.active, self.recording = identity, None
187
+ elif kind == "tree_selected":
188
+ identity = record.get("node_id")
189
+ if identity is None or isinstance(identity, str):
190
+ self.active = identity
191
+ elif (turn := _turn_of(record, self.turns, self.recording)) is not None:
192
+ if kind == "steering" and isinstance(record.get("prompt"), str):
193
+ turn.blocks.append("User (steering): " + record["prompt"])
194
+ elif kind == "Message" and isinstance(record.get("markdown"), str):
195
+ turn.blocks.append("Assistant: " + record["markdown"])
196
+ elif kind == "ToolSummary":
197
+ # No full results: they are noisy, often huge, and can hold private data.
198
+ turn.blocks.append(
199
+ "Tool: "
200
+ + " ".join(str(record.get(k) or "") for k in ("name", "detail", "command"))
201
+ + (" [failed]" if record.get("failed") else "")
202
+ )
203
+ elif kind == "auto_compacted":
204
+ turn.compacted = True
205
+ elif kind in {"turn_completed", "turn_failed", "turn_cancelled"}:
206
+ turn.status = kind.removeprefix("turn_")
207
+
208
+ def finish(self):
209
+ for turn in self.turns.values():
210
+ turn.active = None if self.journal.incomplete_tail else False
211
+ active, seen = self.active, set()
212
+ while not self.journal.incomplete_tail and active in self.parents and active not in seen:
213
+ seen.add(active)
214
+ if active in self.turns:
215
+ self.turns[active].active = True
216
+ active = self.parents[active]
217
+ # Resolve compaction nodes to the nearest actual turn for context retrieval.
218
+ for turn in self.turns.values():
219
+ seen = set()
220
+ while (
221
+ turn.parent in self.parents
222
+ and turn.parent not in self.turns
223
+ and turn.parent not in seen
224
+ ):
225
+ seen.add(turn.parent)
226
+ turn.parent = self.parents[turn.parent]
227
+
228
+
229
+ def read_turns(
230
+ info: SessionInfo, root: Path, *, budget: SessionReadBudget | None = None
231
+ ) -> dict[str, HistoryTurn]:
232
+ """Read a bounded prefix; unfinished readers leave branch labels unknown."""
233
+ reader = TurnReader(info, root)
234
+ reader.read(budget or SessionReadBudget(MAX_SCAN_BYTES))
235
+ return reader.turns
236
+
237
+
238
+ def project_path(workspace: Path) -> Path:
239
+ workspace = workspace.resolve()
240
+ if workspace.exists():
241
+ project = project_checkout(workspace)
242
+ if project:
243
+ return project
244
+ # Older metadata did not save project identity. Recover pcode's conventional
245
+ # worktree layout even after deletion; arbitrary deleted worktrees stay separate.
246
+ for parent in workspace.parents:
247
+ if parent.name == WORKTREES_DIR:
248
+ candidate = parent.parent
249
+ if (candidate / ".git").exists():
250
+ return candidate
251
+ return workspace
252
+
253
+
254
+ class History:
255
+ def __init__(self, workspace: Path, root: Path, session_id: str | None = None):
256
+ self.workspace = workspace.resolve()
257
+ self.root = root
258
+ self.session_id = session_id
259
+
260
+ def sessions(self, scope: Scope) -> list[SessionInfo]:
261
+ if scope not in ("session", "project", "workspace", "all"):
262
+ raise ValueError("scope must be session, project, workspace, or all")
263
+ records = list_sessions(self.root)
264
+ if scope == "session":
265
+ return [s for s in records if s.id == self.session_id]
266
+ if scope == "all":
267
+ return records
268
+ if scope == "workspace":
269
+ return [s for s in records if Path(s.workspace).resolve() == self.workspace]
270
+ project = project_path(self.workspace)
271
+ projects: dict[tuple[str | None, str], Path] = {}
272
+ result = []
273
+ for info in records:
274
+ key = (info.project, info.workspace)
275
+ if key not in projects:
276
+ projects[key] = (
277
+ Path(info.project).resolve()
278
+ if info.project
279
+ else project_path(Path(info.workspace))
280
+ )
281
+ if projects[key] == project:
282
+ result.append(info)
283
+ return result
284
+
285
+ def chunks(
286
+ self, scope: Scope, *, exclude_turn: str | None = None, after: str | None = None
287
+ ) -> "Scan":
288
+ """Return a bounded page; cursors retain both journal and chunk positions."""
289
+ binding = self._binding(scope, "search", exclude_turn)
290
+ scan, cursor = advance(binding, after, lambda: self._chunk_pages(scope, exclude_turn))
291
+ scan.next_cursor = cursor
292
+ return scan
293
+
294
+ def _binding(self, scope: Scope, *request) -> tuple:
295
+ # Validate even when resuming, before looking up a process-local cursor.
296
+ if scope not in ("session", "project", "workspace", "all"):
297
+ raise ValueError("scope must be session, project, workspace, or all")
298
+ return (str(self.root.resolve()), str(self.workspace), self.session_id, scope, *request)
299
+
300
+ def _chunk_pages(self, scope: Scope, exclude_turn: str | None):
301
+ # Freeze traversal order: live metadata updates must not reorder later pages.
302
+ sessions = self.sessions(scope)
303
+ eligible = {info.id for info in sessions}
304
+ searched = unreadable = 0
305
+ warnings = []
306
+
307
+ def page():
308
+ return Scan(
309
+ [],
310
+ warnings.copy(),
311
+ sessions_in_scope=len(sessions),
312
+ sessions_searched=searched,
313
+ sessions_unreadable=unreadable,
314
+ )
315
+
316
+ scan = page()
317
+ budget = SessionReadBudget(MAX_SCAN_BYTES)
318
+ for info in sessions:
319
+ try:
320
+ if budget.remaining == 0:
321
+ scan.warnings.append("Journal byte budget reached; continue with next_cursor.")
322
+ yield scan, True
323
+ eligible = {current.id for current in self.sessions(scope)}
324
+ scan, budget = page(), SessionReadBudget(MAX_SCAN_BYTES)
325
+ if info.id not in eligible:
326
+ raise ValueError("Session is no longer in scope; restart without after.")
327
+ reader = TurnReader(info, self.root)
328
+ while True:
329
+ reader.read(budget)
330
+ if reader.journal.done:
331
+ break
332
+ scan.sessions_partial = 1
333
+ scan.warnings.append(
334
+ f"Journal byte budget reached inside session {info.id}; continue with "
335
+ "next_cursor. Its hits are deferred until its snapshot is fully read."
336
+ )
337
+ yield scan, True
338
+ eligible = self._validate_resume(info, scope, reader)
339
+ scan, budget = page(), SessionReadBudget(MAX_SCAN_BYTES)
340
+ if reader.journal.incomplete_tail:
341
+ warnings.append(
342
+ f"Unfinished final record in session {info.id}; search again later."
343
+ )
344
+ scan.warnings = warnings.copy()
345
+ for turn in reversed(list(reader.turns.values())):
346
+ if (
347
+ turn.id == exclude_turn
348
+ and info.id == self.session_id
349
+ and not turn.compacted
350
+ ):
351
+ continue
352
+ # Redact once, before splitting; never redact arbitrary byte pages.
353
+ text = turn.text
354
+ for offset in range(0, len(text), CHUNK_CHARS - 200):
355
+ if len(scan.chunks) == MAX_CHUNKS:
356
+ scan.sessions_partial = 1
357
+ scan.warnings.append(
358
+ f"Search limited to {MAX_CHUNKS} chunks; continue with next_cursor."
359
+ )
360
+ yield scan, True
361
+ eligible = self._validate_resume(info, scope, reader)
362
+ scan, budget = page(), SessionReadBudget(MAX_SCAN_BYTES)
363
+ scan.chunks.append(
364
+ Chunk(info, turn, offset, text[offset : offset + CHUNK_CHARS])
365
+ )
366
+ searched += 1
367
+ scan.sessions_searched = searched
368
+ scan.sessions_partial = 0
369
+ except OSError:
370
+ unreadable += 1
371
+ warnings.append(f"Skipped unreadable session {info.id}.")
372
+ scan.sessions_unreadable = unreadable
373
+ scan.warnings = warnings.copy()
374
+ scan.scan_complete = not warnings
375
+ yield scan, False
376
+
377
+ def _validate_resume(self, info: SessionInfo, scope: Scope, reader: TurnReader):
378
+ eligible = {current.id for current in self.sessions(scope)}
379
+ if info.id not in eligible:
380
+ raise ValueError("Session is no longer in scope; restart without after.")
381
+ reader.journal.validate()
382
+ return eligible
383
+
384
+ def read(
385
+ self,
386
+ session_id: str,
387
+ turn_id: str,
388
+ scope: Scope = "project",
389
+ context_turns: int = 1,
390
+ offset: int = 0,
391
+ max_chars: int = 8000,
392
+ after: str | None = None,
393
+ ) -> dict:
394
+ if not 0 <= context_turns <= 3 or offset < 0 or not 1 <= max_chars <= 16_000:
395
+ raise ValueError("Use context_turns 0..3, offset >= 0, and max_chars 1..16000")
396
+ binding = self._binding(
397
+ scope, "read", session_id, turn_id, context_turns, offset, max_chars
398
+ )
399
+ result, cursor = advance(
400
+ binding,
401
+ after,
402
+ lambda: self._read_pages(session_id, turn_id, scope, context_turns, offset, max_chars),
403
+ )
404
+ result["next_cursor"] = cursor
405
+ return result
406
+
407
+ def _read_pages(self, session_id, turn_id, scope, context_turns, offset, max_chars):
408
+ info = next((s for s in self.sessions(scope) if s.id == session_id), None)
409
+ if info is None:
410
+ raise ValueError(
411
+ "Session not found in scope. Use an exact session_id from search_sessions."
412
+ )
413
+ try:
414
+ reader = TurnReader(info, self.root)
415
+ while True:
416
+ reader.read(SessionReadBudget(MAX_SCAN_BYTES))
417
+ if reader.journal.done:
418
+ break
419
+ yield (
420
+ {
421
+ "session_id": info.id,
422
+ "turn_id": turn_id,
423
+ "scan_complete": False,
424
+ "next_cursor": None,
425
+ "text": "",
426
+ "context": [],
427
+ "offset": offset,
428
+ "next_offset": None,
429
+ "warnings": [
430
+ "Journal byte budget reached; the requested turn is not yet resolved. "
431
+ "Continue with next_cursor as after, keeping the other "
432
+ "arguments unchanged."
433
+ ],
434
+ },
435
+ True,
436
+ )
437
+ self._validate_resume(info, scope, reader)
438
+ except OSError:
439
+ raise ValueError("Session transcript is unreadable.") from None
440
+ turns = reader.turns
441
+ if turn_id not in turns:
442
+ raise ValueError("Turn not found. Use a turn_id from search_sessions.")
443
+ turn = turns[turn_id]
444
+ text = turn.text
445
+ if offset > len(text):
446
+ raise ValueError("offset exceeds the turn's text length")
447
+ context = []
448
+ parent, seen = turn.parent, {turn.id}
449
+ while parent in turns and parent not in seen and len(context) < context_turns:
450
+ previous = turns[parent]
451
+ seen.add(parent)
452
+ context.append(
453
+ {"turn_id": parent, "status": previous.status, "text": previous.text[:1000]}
454
+ )
455
+ parent = previous.parent
456
+ end = min(offset + max_chars, len(text))
457
+ yield (
458
+ {
459
+ "session_id": info.id,
460
+ "turn_id": turn.id,
461
+ "time": turn.time,
462
+ "workspace": redact(info.workspace),
463
+ "status": turn.status,
464
+ "branch": turn.branch,
465
+ "warnings": ["Unfinished final journal record; read again later."]
466
+ if reader.journal.incomplete_tail
467
+ else [],
468
+ "scan_complete": not reader.journal.incomplete_tail,
469
+ "next_cursor": None,
470
+ "context": context[::-1],
471
+ "text": text[offset:end],
472
+ "offset": offset,
473
+ "next_offset": end if end < len(text) else None,
474
+ },
475
+ False,
476
+ )
477
+
478
+
479
+ _TOKEN = re.compile(r"\w+")
480
+ # Harness ConversationSearch's defaults: k1 above Lucene's 1.2 favors repeated terms.
481
+ BM25_K1 = 1.5
482
+ BM25_B = 0.75
483
+ PHRASE_BONUS = 1.0
484
+
485
+
486
+ def _tokenize(text: str) -> list[str]:
487
+ return [match.group().casefold() for match in _TOKEN.finditer(text)]
488
+
489
+
490
+ def keyword_ranking(chunks: list[Chunk], query: str) -> list[int]:
491
+ """BM25 over chunks, ported from Harness ConversationSearch, plus an exact-phrase bonus.
492
+
493
+ Rare terms outrank common ones and long chunks are length-normalized, so
494
+ a query like "editor flicker" finds the turn about it rather than the
495
+ turn that merely mentions "editor" the most times.
496
+ """
497
+ query_tokens = list(dict.fromkeys(_tokenize(query)))
498
+ if not query_tokens or not chunks:
499
+ return []
500
+ documents = [_tokenize(chunk.text) for chunk in chunks]
501
+ avgdl = sum(len(tokens) for tokens in documents) / len(documents)
502
+ if not avgdl:
503
+ return []
504
+ frequencies = [Counter(tokens) for tokens in documents]
505
+ total = len(documents)
506
+ idf = {}
507
+ for term in query_tokens:
508
+ df = sum(1 for counts in frequencies if term in counts)
509
+ idf[term] = math.log((total - df + 0.5) / (df + 0.5) + 1.0) if df else 0.0
510
+ phrase = " ".join(query.casefold().split())
511
+ scored = []
512
+ for index, (counts, chunk) in enumerate(zip(frequencies, chunks)):
513
+ dl = sum(counts.values())
514
+ score = 0.0
515
+ for term in query_tokens:
516
+ tf = counts.get(term, 0)
517
+ if tf:
518
+ score += (
519
+ idf[term]
520
+ * tf
521
+ * (BM25_K1 + 1.0)
522
+ / (tf + BM25_K1 * (1.0 - BM25_B + BM25_B * dl / avgdl))
523
+ )
524
+ if score > 0 and phrase in " ".join(chunk.text.casefold().split()):
525
+ score += PHRASE_BONUS
526
+ if score > 0:
527
+ scored.append((score, index))
528
+ return [index for _, index in sorted(scored, key=lambda item: (-item[0], item[1]))]
529
+
530
+
531
+ def group_results(
532
+ chunks: list[Chunk],
533
+ ranking: list[int],
534
+ query: str,
535
+ limit: int,
536
+ current_session: str | None = None,
537
+ current_turn: str | None = None,
538
+ ) -> list[dict]:
539
+ """Group hits by session, spreading a small limit across sessions first.
540
+
541
+ A long session produces many chunks and can otherwise take every slot, so
542
+ the first pass caps each session and a second pass spends whatever is left.
543
+ """
544
+ ordered: list[Chunk] = []
545
+ seen: set[tuple[str, str]] = set()
546
+ for index in ranking:
547
+ chunk = chunks[index]
548
+ key = (chunk.session.id, chunk.turn.id)
549
+ if key not in seen:
550
+ seen.add(key)
551
+ ordered.append(chunk)
552
+ picked: set[int] = set()
553
+ counts: Counter[str] = Counter()
554
+ for cap in (PER_SESSION_LIMIT, len(ordered)):
555
+ for position, chunk in enumerate(ordered):
556
+ if len(picked) == limit:
557
+ break
558
+ if position in picked or counts[chunk.session.id] >= cap:
559
+ continue
560
+ picked.add(position)
561
+ counts[chunk.session.id] += 1
562
+ groups: dict[str, dict] = {}
563
+ for position in sorted(picked):
564
+ chunk = ordered[position]
565
+ group = groups.setdefault(
566
+ chunk.session.id,
567
+ {
568
+ "session_id": chunk.session.id,
569
+ "workspace": redact(chunk.session.workspace),
570
+ "current": chunk.session.id == current_session,
571
+ "turns": [],
572
+ },
573
+ )
574
+ result = chunk.result(query)
575
+ if chunk.turn.id == current_turn and chunk.session.id == current_session:
576
+ # Only survives the scan when auto-compaction took it out of context:
577
+ # this is the turn asking, recovering its own dropped history.
578
+ result["current_turn"] = True
579
+ group["turns"].append(result)
580
+ return list(groups.values())
581
+
582
+
583
+ def merge_rankings(keyword: list[int], semantic: list[int]) -> list[int]:
584
+ """Reciprocal-rank fusion keeps exact matches useful alongside semantic recall."""
585
+ scores: dict[int, float] = {}
586
+ for ranking in (keyword, semantic):
587
+ for rank, index in enumerate(ranking):
588
+ scores[index] = scores.get(index, 0) + 1 / (60 + rank)
589
+ return sorted(scores, key=lambda index: (-scores[index], index))