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.
- pcode/__init__.py +7 -0
- pcode/_job_supervisor.py +65 -0
- pcode/agent.py +592 -0
- pcode/anthropic_oauth.py +469 -0
- pcode/app.py +3165 -0
- pcode/appearance.py +181 -0
- pcode/aside.py +541 -0
- pcode/aside_guard.py +39 -0
- pcode/aside_ui.py +683 -0
- pcode/auth.py +113 -0
- pcode/block.py +39 -0
- pcode/browser.py +393 -0
- pcode/cache_diagnostics.py +336 -0
- pcode/cache_settings.py +58 -0
- pcode/cache_warnings.py +139 -0
- pcode/claude_sdk/__init__.py +96 -0
- pcode/claude_sdk/cli.py +52 -0
- pcode/claude_sdk/errors.py +61 -0
- pcode/claude_sdk/messages.py +154 -0
- pcode/claude_sdk/model.py +179 -0
- pcode/claude_sdk/resume.py +114 -0
- pcode/claude_sdk/session.py +547 -0
- pcode/claude_sdk/session_pool.py +251 -0
- pcode/claude_sdk/workspace.py +34 -0
- pcode/cli.py +73 -0
- pcode/clipboard.py +76 -0
- pcode/code_mode.py +43 -0
- pcode/codex_login.py +189 -0
- pcode/command_transcript.py +71 -0
- pcode/commands.py +145 -0
- pcode/compaction.py +457 -0
- pcode/completion.py +263 -0
- pcode/config.py +223 -0
- pcode/context_breakdown.py +178 -0
- pcode/context_usage.py +52 -0
- pcode/controller.py +3496 -0
- pcode/conversation_ids.py +27 -0
- pcode/conversation_tree.py +160 -0
- pcode/copy_ui.py +172 -0
- pcode/delegation.py +236 -0
- pcode/delta.py +295 -0
- pcode/diagnostics.py +226 -0
- pcode/edit_preview.py +158 -0
- pcode/edit_transcript.py +287 -0
- pcode/edit_ui.py +850 -0
- pcode/edits.py +162 -0
- pcode/email_remote/__init__.py +14 -0
- pcode/email_remote/cli.py +133 -0
- pcode/email_remote/listener.py +695 -0
- pcode/email_remote/mailbox.py +336 -0
- pcode/email_remote/outbound.py +75 -0
- pcode/email_remote/parsing.py +226 -0
- pcode/email_remote/render.py +175 -0
- pcode/email_remote/state.py +79 -0
- pcode/error_report.py +38 -0
- pcode/ext.py +602 -0
- pcode/extension_guide.md +311 -0
- pcode/extensions/browser.py +284 -0
- pcode/extensions/ponytail.py +217 -0
- pcode/extensions/sandbox.py +138 -0
- pcode/extensions/session_history.py +170 -0
- pcode/extensions/web_research.py +89 -0
- pcode/file_refs.py +265 -0
- pcode/filesystem.py +138 -0
- pcode/frame.py +149 -0
- pcode/gateway.py +465 -0
- pcode/git_diff.py +497 -0
- pcode/history.py +589 -0
- pcode/history_cursor.py +147 -0
- pcode/history_embeddings.py +102 -0
- pcode/host.py +941 -0
- pcode/host_protocol.py +291 -0
- pcode/host_ui.py +225 -0
- pcode/input_keys.py +35 -0
- pcode/inspection.py +289 -0
- pcode/inspector_ui.py +448 -0
- pcode/isolated_delegation.py +425 -0
- pcode/job_notices.py +54 -0
- pcode/jobs.py +584 -0
- pcode/jobs_ui.py +265 -0
- pcode/keymap.py +215 -0
- pcode/layout_speed.py +103 -0
- pcode/links.py +90 -0
- pcode/links_ui.py +200 -0
- pcode/live.py +1301 -0
- pcode/llm_proxy.py +44 -0
- pcode/logins.py +233 -0
- pcode/mcp.py +683 -0
- pcode/mcp_notice.py +91 -0
- pcode/mcp_oauth.py +381 -0
- pcode/meridian.py +233 -0
- pcode/meridian_process.py +337 -0
- pcode/meridian_reminders.py +107 -0
- pcode/meridian_setup.py +356 -0
- pcode/model_metadata.py +451 -0
- pcode/model_ui.py +168 -0
- pcode/models.py +209 -0
- pcode/native_results.py +119 -0
- pcode/oauth_pages.py +66 -0
- pcode/output_limits.py +41 -0
- pcode/paste.py +45 -0
- pcode/path_completion.py +66 -0
- pcode/plan_preview.py +131 -0
- pcode/plan_sizes.py +35 -0
- pcode/planning.py +161 -0
- pcode/popup_ui.py +873 -0
- pcode/preferences.py +1176 -0
- pcode/prefix_keys.py +490 -0
- pcode/profile_benchmark.py +332 -0
- pcode/profiling.py +566 -0
- pcode/project_trust.py +94 -0
- pcode/prompt_keys.py +286 -0
- pcode/remote.py +717 -0
- pcode/remote_print.py +125 -0
- pcode/remote_profile.py +237 -0
- pcode/replay_benchmark.py +319 -0
- pcode/repo_context.py +158 -0
- pcode/retries.py +28 -0
- pcode/rpc.py +280 -0
- pcode/runtime.py +244 -0
- pcode/sandbox.py +454 -0
- pcode/session_naming.py +82 -0
- pcode/session_ui.py +550 -0
- pcode/sessions.py +892 -0
- pcode/shell.py +113 -0
- pcode/shell_mode.py +172 -0
- pcode/shell_tools.py +459 -0
- pcode/skills.py +161 -0
- pcode/stall_log.py +187 -0
- pcode/startup_input.py +96 -0
- pcode/steering.py +27 -0
- pcode/stream_display.py +188 -0
- pcode/stream_events.py +437 -0
- pcode/strict_tools.py +130 -0
- pcode/syntax.py +43 -0
- pcode/syntax_colors.py +206 -0
- pcode/task_prompt.py +28 -0
- pcode/task_worktrees.py +482 -0
- pcode/terminal_notify.py +383 -0
- pcode/theme.py +135 -0
- pcode/theme_gallery.py +88 -0
- pcode/thinking_markdown.py +41 -0
- pcode/token_accounting.py +67 -0
- pcode/tool_display.py +784 -0
- pcode/tool_output_limits.py +108 -0
- pcode/tool_panel.py +377 -0
- pcode/transcript_log.py +127 -0
- pcode/transcript_notice.py +103 -0
- pcode/tree_ui.py +288 -0
- pcode/turn.py +44 -0
- pcode/ui.py +3745 -0
- pcode/usage.py +293 -0
- pcode/word_wrap.py +107 -0
- pcode/worker_ui.py +235 -0
- pcode/workers.py +136 -0
- pcode/workspace.py +57 -0
- pcode/workspace_filesystem.py +72 -0
- pcode/worktree.py +650 -0
- pcode-0.1.0.dist-info/METADATA +145 -0
- pcode-0.1.0.dist-info/RECORD +163 -0
- pcode-0.1.0.dist-info/WHEEL +4 -0
- pcode-0.1.0.dist-info/entry_points.txt +3 -0
- 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))
|