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_cursor.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Process-local continuations and bounded, snapshot journal reads for recall.
|
|
2
|
+
|
|
3
|
+
Continuations retain parser state, not a reusable search index. They expire and
|
|
4
|
+
never write transcript text to disk. Journal readers reopen at their byte offset
|
|
5
|
+
on each page, so no file descriptor is held while a caller considers a result.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from collections.abc import Callable, Generator
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from uuid import uuid4
|
|
18
|
+
|
|
19
|
+
from pcode.sessions import SessionInfo, SessionReadBudget
|
|
20
|
+
|
|
21
|
+
CURSOR_TTL = 30 * 60
|
|
22
|
+
MAX_CURSORS = 16
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Continuation:
|
|
27
|
+
binding: tuple
|
|
28
|
+
pages: Generator[tuple[Any, bool], None, None]
|
|
29
|
+
expires: float
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_continuations: OrderedDict[str, Continuation] = OrderedDict()
|
|
33
|
+
_lock = threading.Lock()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def advance(
|
|
37
|
+
binding: tuple,
|
|
38
|
+
after: str | None,
|
|
39
|
+
pages: Callable[[], Generator[tuple[Any, bool], None, None]],
|
|
40
|
+
):
|
|
41
|
+
"""Advance a single-use cursor, returning the page and its next opaque token."""
|
|
42
|
+
now = time.monotonic()
|
|
43
|
+
with _lock:
|
|
44
|
+
for token, saved in list(_continuations.items()):
|
|
45
|
+
if saved.expires <= now:
|
|
46
|
+
del _continuations[token]
|
|
47
|
+
saved.pages.close()
|
|
48
|
+
if after:
|
|
49
|
+
saved = _continuations.get(after)
|
|
50
|
+
if saved is None:
|
|
51
|
+
raise ValueError("Unknown cursor or expired continuation; restart without after.")
|
|
52
|
+
if saved.binding != binding:
|
|
53
|
+
raise ValueError("Cursor does not match this scope or read request.")
|
|
54
|
+
del _continuations[after]
|
|
55
|
+
iterator = saved.pages
|
|
56
|
+
else:
|
|
57
|
+
iterator = pages()
|
|
58
|
+
try:
|
|
59
|
+
result, more = next(iterator)
|
|
60
|
+
except BaseException:
|
|
61
|
+
iterator.close()
|
|
62
|
+
raise
|
|
63
|
+
token = None
|
|
64
|
+
if more:
|
|
65
|
+
token = uuid4().hex
|
|
66
|
+
with _lock:
|
|
67
|
+
while len(_continuations) >= MAX_CURSORS:
|
|
68
|
+
_, saved = _continuations.popitem(last=False)
|
|
69
|
+
saved.pages.close()
|
|
70
|
+
_continuations[token] = Continuation(binding, iterator, time.monotonic() + CURSOR_TTL)
|
|
71
|
+
else:
|
|
72
|
+
iterator.close()
|
|
73
|
+
return result, token
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class JournalReader:
|
|
78
|
+
"""Read a finite journal snapshot, even when a JSON line spans several pages."""
|
|
79
|
+
|
|
80
|
+
info: SessionInfo
|
|
81
|
+
root: Path
|
|
82
|
+
kinds: tuple[str, ...]
|
|
83
|
+
offset: int = 0
|
|
84
|
+
pending: bytearray = field(default_factory=bytearray)
|
|
85
|
+
incomplete_tail: bool = False
|
|
86
|
+
|
|
87
|
+
def __post_init__(self):
|
|
88
|
+
self.path = self.root / self.info.id / "transcript.jsonl"
|
|
89
|
+
self._check_path()
|
|
90
|
+
stat = self.path.stat()
|
|
91
|
+
self.identity = (stat.st_dev, stat.st_ino)
|
|
92
|
+
self.size = stat.st_size
|
|
93
|
+
self.mtime = stat.st_mtime_ns
|
|
94
|
+
self.markers = tuple(f'"{kind}"'.encode() for kind in self.kinds)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def done(self) -> bool:
|
|
98
|
+
return self.offset == self.size
|
|
99
|
+
|
|
100
|
+
def _check_path(self):
|
|
101
|
+
if self.path.parent.is_symlink() or self.path.is_symlink():
|
|
102
|
+
raise OSError("symlinked session")
|
|
103
|
+
if not (self.path.parent / "session.json").is_file():
|
|
104
|
+
raise OSError("session removed")
|
|
105
|
+
|
|
106
|
+
def validate(self, stat=None):
|
|
107
|
+
# Catches a replaced, shrunk, or same-size-edited journal. Journals are
|
|
108
|
+
# assumed append-only otherwise: a rewrite that grows the file looks
|
|
109
|
+
# exactly like an append and is not detected.
|
|
110
|
+
self._check_path()
|
|
111
|
+
stat = stat or self.path.stat()
|
|
112
|
+
if (
|
|
113
|
+
(stat.st_dev, stat.st_ino) != self.identity
|
|
114
|
+
or stat.st_size < self.size
|
|
115
|
+
or (stat.st_size == self.size and stat.st_mtime_ns != self.mtime)
|
|
116
|
+
):
|
|
117
|
+
raise ValueError("Session journal changed; restart without after.")
|
|
118
|
+
|
|
119
|
+
def records(self, budget: SessionReadBudget):
|
|
120
|
+
self.validate()
|
|
121
|
+
with self.path.open("rb") as stream:
|
|
122
|
+
self.validate(os.fstat(stream.fileno()))
|
|
123
|
+
stream.seek(self.offset)
|
|
124
|
+
while not self.done and budget.remaining > 0:
|
|
125
|
+
part = stream.readline(min(budget.remaining, self.size - self.offset))
|
|
126
|
+
if not part:
|
|
127
|
+
raise ValueError("Session journal was truncated; restart without after.")
|
|
128
|
+
budget.remaining -= len(part)
|
|
129
|
+
self.offset += len(part)
|
|
130
|
+
self.pending.extend(part)
|
|
131
|
+
terminated = part.endswith(b"\n")
|
|
132
|
+
if not terminated and not self.done:
|
|
133
|
+
continue
|
|
134
|
+
line, self.pending = self.pending, bytearray()
|
|
135
|
+
if terminated and not any(marker in line for marker in self.markers):
|
|
136
|
+
continue
|
|
137
|
+
try:
|
|
138
|
+
record = json.loads(line.decode("utf-8", errors="replace"))
|
|
139
|
+
except ValueError:
|
|
140
|
+
# A complete final JSON object needs no newline, but a torn
|
|
141
|
+
# live write must not be mistaken for complete coverage.
|
|
142
|
+
if not terminated:
|
|
143
|
+
self.incomplete_tail = True
|
|
144
|
+
continue
|
|
145
|
+
if isinstance(record, dict) and record.get("kind") in self.kinds:
|
|
146
|
+
yield record
|
|
147
|
+
budget.exhausted = not self.done
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Opt-in semantic ranking; cache vectors locally, never transcript text."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import sqlite3
|
|
8
|
+
from contextlib import closing
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pcode.diagnostics import redact
|
|
12
|
+
from pcode.history import Chunk
|
|
13
|
+
from pcode.sessions import private_file
|
|
14
|
+
|
|
15
|
+
MAX_NEW_CHUNKS = 128
|
|
16
|
+
BATCH_SIZE = 32
|
|
17
|
+
CACHE_NAME = ".history-embeddings.sqlite3"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _cache(path: Path, keys: list[str], updates: dict[str, list[float]] | None = None):
|
|
21
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
22
|
+
private_file(path)
|
|
23
|
+
with closing(sqlite3.connect(path)) as db, db:
|
|
24
|
+
db.execute(
|
|
25
|
+
"CREATE TABLE IF NOT EXISTS vectors (key TEXT PRIMARY KEY, vector TEXT NOT NULL)"
|
|
26
|
+
)
|
|
27
|
+
if updates:
|
|
28
|
+
db.executemany(
|
|
29
|
+
"INSERT OR REPLACE INTO vectors VALUES (?, ?)",
|
|
30
|
+
[(key, json.dumps(vector)) for key, vector in updates.items()],
|
|
31
|
+
)
|
|
32
|
+
result = {}
|
|
33
|
+
for key in keys:
|
|
34
|
+
row = db.execute("SELECT vector FROM vectors WHERE key = ?", (key,)).fetchone()
|
|
35
|
+
if row:
|
|
36
|
+
result[key] = json.loads(row[0])
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _unit(vector) -> list[float]:
|
|
41
|
+
values = [float(value) for value in vector]
|
|
42
|
+
if not values or not all(math.isfinite(value) for value in values):
|
|
43
|
+
raise ValueError("Invalid embedding vector")
|
|
44
|
+
length = math.hypot(*values)
|
|
45
|
+
if not length:
|
|
46
|
+
raise ValueError("Empty embedding vector")
|
|
47
|
+
return [value / length for value in values]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
async def semantic_ranking(
|
|
51
|
+
chunks: list[Chunk], query: str, model: str, root: Path, *, embedder=None
|
|
52
|
+
) -> tuple[list[int], list[str]]:
|
|
53
|
+
"""Incrementally index only this search's scope, bounded to 128 new chunks per call."""
|
|
54
|
+
if not chunks:
|
|
55
|
+
return [], []
|
|
56
|
+
if embedder is None:
|
|
57
|
+
from pydantic_ai import Embedder
|
|
58
|
+
|
|
59
|
+
embedder = Embedder(model)
|
|
60
|
+
# Include format/model in the key: changing either cannot reuse incompatible vectors.
|
|
61
|
+
keys = [hashlib.sha256(f"v1\0{model}\0{c.text}".encode()).hexdigest() for c in chunks]
|
|
62
|
+
path = root / CACHE_NAME
|
|
63
|
+
cached = await asyncio.to_thread(_cache, path, keys)
|
|
64
|
+
missing = list(dict.fromkeys(key for key in keys if key not in cached))
|
|
65
|
+
texts = {key: chunk.text for key, chunk in zip(keys, chunks)}
|
|
66
|
+
for start in range(0, min(len(missing), MAX_NEW_CHUNKS), BATCH_SIZE):
|
|
67
|
+
batch = missing[start : min(start + BATCH_SIZE, MAX_NEW_CHUNKS)]
|
|
68
|
+
result = await embedder.embed_documents([texts[key] for key in batch])
|
|
69
|
+
if len(result.embeddings) != len(batch):
|
|
70
|
+
raise ValueError("Embedding count mismatch")
|
|
71
|
+
updates = {key: _unit(vector) for key, vector in zip(batch, result.embeddings)}
|
|
72
|
+
await asyncio.to_thread(_cache, path, [], updates)
|
|
73
|
+
cached.update(updates)
|
|
74
|
+
result = await embedder.embed_query(redact(query))
|
|
75
|
+
query_vector = _unit(result.embeddings[0])
|
|
76
|
+
scored = []
|
|
77
|
+
for index, key in enumerate(keys):
|
|
78
|
+
if key not in cached:
|
|
79
|
+
continue
|
|
80
|
+
vector = _unit(cached[key])
|
|
81
|
+
if len(vector) != len(query_vector):
|
|
82
|
+
raise ValueError("Embedding dimensions changed; clear the history embedding cache")
|
|
83
|
+
similarity = sum(a * b for a, b in zip(vector, query_vector))
|
|
84
|
+
if similarity > 0:
|
|
85
|
+
scored.append((similarity, index))
|
|
86
|
+
warnings = []
|
|
87
|
+
if len(missing) > MAX_NEW_CHUNKS:
|
|
88
|
+
warnings.append(
|
|
89
|
+
f"Semantic index is partial: {len(missing) - MAX_NEW_CHUNKS} chunks remain. "
|
|
90
|
+
"Later semantic searches index more; keyword search covers the scanned corpus."
|
|
91
|
+
)
|
|
92
|
+
ranking, seen = [], set()
|
|
93
|
+
for _, index in sorted(scored, key=lambda item: (-item[0], item[1])):
|
|
94
|
+
chunk = chunks[index]
|
|
95
|
+
identity = (chunk.session.id, chunk.turn.id)
|
|
96
|
+
if identity in seen:
|
|
97
|
+
continue
|
|
98
|
+
seen.add(identity)
|
|
99
|
+
ranking.append(index)
|
|
100
|
+
if len(ranking) == 50:
|
|
101
|
+
break
|
|
102
|
+
return ranking, warnings
|