cdx-manager 0.26.1 → 0.26.2
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.
- package/README.md +65 -12
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_26_2.md +20 -0
- package/changelogs/CHANGELOGS_UNRELEASED.md +12 -0
- package/checksums/release-archives.json +11 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +1 -1
- package/src/commands/conversation.py +8 -2
- package/src/conversation.py +289 -18
- package/src/interactive_usage.py +24 -4
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
<br clear="left"/>
|
|
6
6
|
|
|
7
|
-
[](LICENSE) ](LICENSE)  
|
|
8
8
|
|
|
9
9
|
**The right AI coding account, when you need it.** CDX Manager keeps your Codex and Claude accounts in separate profiles, shows which ones have quota, and helps you pick the next usable session from your terminal.
|
|
10
10
|
|
|
@@ -246,41 +246,94 @@ for a fresh credential, and that cleanup works after disconnect and SIGTERM.
|
|
|
246
246
|
|
|
247
247
|
### Read an interactive conversation
|
|
248
248
|
|
|
249
|
-
`cdx conversation <name> [--conversation ID] [--cursor CURSOR] [--limit N] --json`
|
|
249
|
+
`cdx conversation <name> [--conversation ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json`
|
|
250
250
|
returns a read-only JSON page for the profile's current conversation. An explicit
|
|
251
251
|
conversation ID uses an exact transcript lookup and never falls back. When the
|
|
252
252
|
profile's recorded ID has no transcript on this host (for example after a remote
|
|
253
|
-
run on another host), or there is no recorded ID, the command
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
run on another host), or there is no recorded ID, the command selects the latest
|
|
254
|
+
local transcript for `--cwd PATH`, defaulting to the process working directory.
|
|
255
|
+
Claude-compatible providers use `.claude/projects/<path-slug>`; Codex requires
|
|
256
|
+
a matching `cwd` in the rollout's initial `session_meta` record. Missing or
|
|
257
|
+
unreadable Codex metadata is excluded. `--since ISO` further restricts fallback
|
|
258
|
+
to transcripts modified at or after the session start timestamp (for example
|
|
259
|
+
`--since 2026-11-04T00:39:00Z`). Callers should pass a timestamp with a timezone.
|
|
260
|
+
Without a candidate, JSON reports `status: "unavailable"` and
|
|
261
|
+
`reason: "no_conversation_yet"`, including while a new session awaits folder trust.
|
|
262
|
+
This fallback is best effort and does not rewrite the recorded ID. Exact ID
|
|
263
|
+
lookups remain independent of these fallback constraints. JSON `resolution_method` is
|
|
256
264
|
`conversation_id` for an exact match, `recency` for a local fallback, or `null`
|
|
257
|
-
when no transcript is resolved.
|
|
265
|
+
when no transcript is resolved. JSON `resolution_scope` records the canonical
|
|
266
|
+
absolute `cwd` and supplied `since` timestamp for fallback attempts, including
|
|
267
|
+
unavailable results; `since` is `null` when omitted. Exact lookups and unsupported
|
|
268
|
+
providers report a `null` scope. A missing explicit ID still reports
|
|
269
|
+
`transcript_not_found`.
|
|
258
270
|
|
|
259
271
|
The normal cdx JSON envelope includes `status`, `reason`, `provider`, `session`,
|
|
260
272
|
`entries`, `next_cursor`, and `has_more`. Each entry has exactly `role`
|
|
261
273
|
(`user`, `assistant`, or `tool`), `text`, `tool_name`, `tool_summary`, `at`, and
|
|
262
|
-
`id`. Missing values are `null`; tool text is empty. Entries follow file and
|
|
274
|
+
`id`. Missing values are `null`; normal tool text is empty. Entries follow file and
|
|
263
275
|
content-block order, with stable IDs for the same file position. Timestamps are
|
|
264
276
|
provider timestamps when valid. Tool calls/results have a name and a short
|
|
265
277
|
summary; arguments, results, attachments, and reasoning blocks are omitted.
|
|
266
|
-
Result names are recovered from
|
|
278
|
+
Result names are recovered from recent calls, otherwise `tool`. The cache holds
|
|
279
|
+
up to 100 names and is trimmed oldest first to 2 KiB of compact JSON when saved
|
|
280
|
+
in a cursor. Existing version-1 cursors with larger caches remain readable.
|
|
267
281
|
Common credential patterns and secret assignments in text are redacted without
|
|
268
282
|
opening credential stores. Review conversation text before sharing: arbitrary
|
|
269
283
|
unlabelled sensitive prose cannot be identified automatically.
|
|
270
284
|
|
|
271
285
|
Pass `next_cursor` unchanged as `--cursor` to fetch the next page or poll for new
|
|
272
286
|
entries. `--limit` bounds entries (default 100, range 1–1000). Reading seeks to
|
|
273
|
-
the saved byte position rather than replaying history, and
|
|
274
|
-
per call
|
|
287
|
+
the saved byte position rather than replaying history, and stops scanning after
|
|
288
|
+
8 MiB per call, finishing the current record when it crosses that budget.
|
|
289
|
+
A page may be empty while still returning a usable cursor. Poll on a
|
|
275
290
|
timer: `has_more` means unread bytes, which can include an incomplete final line
|
|
276
291
|
that will only become readable after the provider appends its newline.
|
|
277
292
|
|
|
293
|
+
Start near the present with `cdx conversation work --tail 100 --json`.
|
|
294
|
+
`--tail N` returns the last N normalized entries (1–1000) in chronological order.
|
|
295
|
+
It reads backwards in 64 KiB blocks, retaining at most a 1 MiB record prefix;
|
|
296
|
+
it does not replay the transcript from the beginning. Records that produce
|
|
297
|
+
multiple entries can split across pages. The same redaction, tool summaries,
|
|
298
|
+
oversized-record placeholders and file-position IDs apply. Recent tool names are
|
|
299
|
+
recovered from a 64 KiB lookback (extended to 8 MiB when resolving a result in the selected window), with the usual `tool`
|
|
300
|
+
fallback. Finding N entries can require scanning more bytes when records
|
|
301
|
+
are oversized or contain no visible entries. Incomplete trailing records are
|
|
302
|
+
excluded until their newline arrives.
|
|
303
|
+
|
|
304
|
+
Tail and backward pages add `before_cursor`: pass it unchanged as
|
|
305
|
+
`--before CURSOR --limit N` to load the preceding entries, also in chronological
|
|
306
|
+
order. `before_cursor: null` means the beginning has been reached. You can also
|
|
307
|
+
use `--before CURSOR --tail N`; `--tail` sets the count when both count options
|
|
308
|
+
are present. `next_cursor` still works with `--cursor` to follow entries after
|
|
309
|
+
the returned page, including messages appended after an initial tail read.
|
|
310
|
+
`has_more` continues to describe unread bytes **after** that page; use
|
|
311
|
+
`before_cursor` to decide whether older pages remain. `--cursor` cannot be
|
|
312
|
+
combined with `--tail` or `--before`. Without either new option, the existing
|
|
313
|
+
forward page contract is unchanged.
|
|
314
|
+
|
|
315
|
+
Backward cursors retain the original file size, modification time and boundary
|
|
316
|
+
anchors. Replacement, truncation or a detected in-place rewrite returns
|
|
317
|
+
`reset_required`; appending new messages preserves backward pagination. As with
|
|
318
|
+
forward cursors, transcripts must be append-only: bounded boundary checks cannot
|
|
319
|
+
detect every arbitrary edit elsewhere in a file that also grows.
|
|
320
|
+
|
|
278
321
|
Codex response items and Claude-compatible JSONL transcripts (Claude and
|
|
279
|
-
Antigravity) are supported through the existing transcript resolver.
|
|
322
|
+
Antigravity) are supported through the existing transcript resolver. Only
|
|
323
|
+
`response_item` records from Codex and `user`/`assistant` records with `message`
|
|
324
|
+
from Claude-compatible transcripts are read, including their tool blocks. All
|
|
325
|
+
other record types are ignored, even when they repeat conversation content. Missing or
|
|
280
326
|
unreadable files return `status: "unavailable"` with a reason. Ollama's terminal
|
|
281
327
|
capture does not reliably identify message roles and returns
|
|
282
328
|
`status: "unsupported"`, including when no capture exists. A transcript record
|
|
283
|
-
larger than 1 MiB
|
|
329
|
+
larger than 1 MiB is skipped with bounded reads through its newline. For an allowed conversation record with readable type and message metadata, it produces
|
|
330
|
+
one entry containing `[élément trop volumineux ignoré]`, with a stable ID and a
|
|
331
|
+
role recovered from structural metadata in the readable prefix, or `tool` when
|
|
332
|
+
unknown. Records whose type or message cannot be established from the bounded
|
|
333
|
+
prefix are ignored. Its body, images and attachments are never returned. The cursor advances
|
|
334
|
+
past that record so later messages remain accessible. An oversized final record
|
|
335
|
+
without a newline produces no entry yet and is retried on the next call.
|
|
336
|
+
A replaced/truncated file or invalid
|
|
284
337
|
cursor returns `reset_required`; discard the cursor and reload the conversation.
|
|
285
338
|
Cursors are opaque, versioned, and bound to a provider and file. Treat transcripts
|
|
286
339
|
as append-only; reset the client if a provider rewrites earlier history.
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.26.
|
|
1
|
+
0.26.2
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# CDX Manager 0.26.2
|
|
2
|
+
|
|
3
|
+
## Fixes
|
|
4
|
+
|
|
5
|
+
- Scope conversation recency fallback to `--cwd PATH` (the process working
|
|
6
|
+
directory by default), using Claude project folders or Codex `session_meta.cwd`.
|
|
7
|
+
Support `--since ISO` to exclude transcripts modified before session start.
|
|
8
|
+
Return `unavailable` / `no_conversation_yet` when no candidate matches instead
|
|
9
|
+
of showing another project. Add `resolution_scope` without changing
|
|
10
|
+
`resolution_method` or exact conversation ID lookup semantics.
|
|
11
|
+
|
|
12
|
+
- Read only Claude-compatible `user` and `assistant` records with `message`,
|
|
13
|
+
preserving their tool calls and results. Ignore all other record types,
|
|
14
|
+
including Claude Code request blobs that repeat user prompts without dates.
|
|
15
|
+
- Apply the same allowlist to oversized records; retain only `response_item`
|
|
16
|
+
records for Codex so event copies cannot appear as conversation entries.
|
|
17
|
+
Ignore oversized records when the bounded prefix cannot establish their type
|
|
18
|
+
and message metadata.
|
|
19
|
+
- Cover the reported Claude Code 2.1.317 request-blob shape, dated messages,
|
|
20
|
+
tools, cursor pagination and ignored metadata for all structured providers.
|
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Unreleased
|
|
2
2
|
|
|
3
|
+
## Conversation tail pagination (planned for 0.26.2)
|
|
4
|
+
|
|
5
|
+
- Add `cdx conversation --tail N` (1–1000), chronological backward pages via `before_cursor` and `--before`, and a compatible `next_cursor` for live polling. Read JSONL backwards in bounded 64 KiB blocks, preserve normalization, redaction, stable IDs and oversized-record skipping, and reject stale backward cursors.
|
|
6
|
+
- Measure tail I/O on 6,000-entry Codex and Claude-compatible transcripts and cover backward traversal, content-block splits, append polling and incomplete/oversized records.
|
|
7
|
+
|
|
8
|
+
## Read past oversized conversation records
|
|
9
|
+
|
|
10
|
+
- Skip JSONL records larger than 1 MiB using bounded reads instead of returning `record_too_large`. Emit a short placeholder with a stable ID and the role from readable structural metadata, falling back to `tool`, then advance the cursor so subsequent messages remain accessible. Never return image or attachment bodies.
|
|
11
|
+
- Retry oversized trailing records without a newline on the next poll. The 8 MiB page scan budget finishes the current record before returning.
|
|
12
|
+
- Bound the cursor's recent tool-name cache to 2 KiB of compact JSON, evicting oldest entries while keeping version-1 cursors with up to 100 names readable.
|
|
13
|
+
- Cover 20 MiB records in the middle, cursors immediately before them, incomplete oversized tails, bounded reads, stable IDs, conservative role detection and legacy cursor compatibility.
|
|
14
|
+
|
|
3
15
|
## Preserve interactive conversations across headless runs
|
|
4
16
|
|
|
5
17
|
- Keep fresh Claude run identities in run records and history without changing the profile's interactive conversation. Skip interactive Codex identity observation when recording headless run history, including detached, remote and continued runs.
|
|
@@ -804,6 +804,17 @@
|
|
|
804
804
|
"x86_64-pc-windows-msvc": "24ba2a8ed13de1bdc0401661a51c105c2c7ed186f3b17a871cd721ac8adf4916",
|
|
805
805
|
"x86_64-unknown-linux-musl": "200c8ab9465625784a32ae326d923bca97b5f9616809c43cb5dc73e0e5d9ca17"
|
|
806
806
|
}
|
|
807
|
+
},
|
|
808
|
+
"v0.26.1": {
|
|
809
|
+
"github_tarball_sha256": "b1ccc629c8697354dc5c8e1b892a94a9668061ef6667711af2bd62816e3bdff7",
|
|
810
|
+
"github_zip_sha256": "5ba11ac492690967f7c076b32c6e3e13dd183b7c097c706ff16e3df61364bee9",
|
|
811
|
+
"tray_assets": {
|
|
812
|
+
"aarch64-apple-darwin": "d570430988a271c87403e176f5689115ca6c214990a13831c7d1ed69b7943353",
|
|
813
|
+
"aarch64-unknown-linux-musl": "1b8acda432cff9d58fc3aaaac5d03f684ae4887f4976821ccecc383c79d6bc98",
|
|
814
|
+
"x86_64-apple-darwin": "b51111f3c6f747671f9416f8f7ed544a5a98318121694c33440b6b54782c1080",
|
|
815
|
+
"x86_64-pc-windows-msvc": "07cf6f66d58320494833107cf369b66cdf171649c8128d6463028a9aa7058494",
|
|
816
|
+
"x86_64-unknown-linux-musl": "e18869d50747b1eb7924005554e11154f04a27363b8b3148f7ae0a1042d6ca7a"
|
|
817
|
+
}
|
|
807
818
|
}
|
|
808
819
|
}
|
|
809
820
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
|
@@ -272,7 +272,7 @@ def _print_help(use_color=False):
|
|
|
272
272
|
f" {_style('cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--by session|project|model] [--json]', '36', use_color)}",
|
|
273
273
|
f" {_style('cdx last [--json]', '36', use_color)}",
|
|
274
274
|
f" {_style('cdx resume <name> [--project PATH] [--conversation <id>] [--json]', '36', use_color)}",
|
|
275
|
-
f" {_style('cdx conversation <name> [--conversation ID] [--cursor CURSOR] [--limit N] --json', '36', use_color)}",
|
|
275
|
+
f" {_style('cdx conversation <name> [--conversation ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json', '36', use_color)}",
|
|
276
276
|
f" {_style('cdx can-resume <name> [--conversation <id>] [--json]', '36', use_color)}",
|
|
277
277
|
f" {_style('cdx rescue [<session>] [--to <target>] [--yes] [--dry-run] [--json]', '36', use_color)}",
|
|
278
278
|
f" {_style('cdx retro [--limit N] [--json]', '36', use_color)}",
|
|
@@ -6,13 +6,17 @@ from ..conversation import read_conversation
|
|
|
6
6
|
from ..errors import CdxError
|
|
7
7
|
from ..provider_runtime import _get_auth_home
|
|
8
8
|
|
|
9
|
-
CONVERSATION_USAGE = "Usage: cdx conversation <name> [--conversation ID] [--cursor CURSOR] [--limit N] --json"
|
|
9
|
+
CONVERSATION_USAGE = "Usage: cdx conversation <name> [--conversation ID] [--cwd PATH] [--since ISO] [--cursor CURSOR | --tail N | --before CURSOR] [--limit N] --json"
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def handle_conversation(rest, ctx):
|
|
13
13
|
parsed = _parse_flag_args(rest, {
|
|
14
14
|
"--json": {"key": "json", "type": "bool", "default": False},
|
|
15
15
|
"--conversation": {"key": "conversation", "type": "str"},
|
|
16
|
+
"--cwd": {"key": "cwd", "type": "str"},
|
|
17
|
+
"--since": {"key": "since", "type": "str"},
|
|
18
|
+
"--tail": {"key": "tail", "type": "str"},
|
|
19
|
+
"--before": {"key": "before", "type": "str"},
|
|
16
20
|
"--cursor": {"key": "cursor", "type": "str"},
|
|
17
21
|
"--limit": {"key": "limit", "type": "str", "default": "100"},
|
|
18
22
|
}, CONVERSATION_USAGE, positionals_key="names", max_positionals=1)
|
|
@@ -23,12 +27,14 @@ def handle_conversation(rest, ctx):
|
|
|
23
27
|
raise CdxError(f"Unknown session: {parsed['names'][0]}")
|
|
24
28
|
try:
|
|
25
29
|
limit = int(parsed["limit"])
|
|
30
|
+
tail = int(parsed["tail"]) if parsed["tail"] is not None else None
|
|
26
31
|
except ValueError:
|
|
27
32
|
raise CdxError(CONVERSATION_USAGE) from None
|
|
28
33
|
page = read_conversation(
|
|
29
34
|
session["provider"], _get_auth_home(session),
|
|
30
35
|
conversation_id=parsed["conversation"] or (session.get("conversation") or {}).get("id"),
|
|
31
|
-
|
|
36
|
+
cwd=parsed["cwd"], started_at=parsed["since"],
|
|
37
|
+
cursor=parsed["cursor"], limit=limit, tail=tail, before=parsed["before"], fallback_to_recency=not parsed["conversation"],
|
|
32
38
|
)
|
|
33
39
|
_write_json(ctx, _json_success("conversation", "Conversation page loaded.", session=session["name"], **page))
|
|
34
40
|
return 0
|
package/src/conversation.py
CHANGED
|
@@ -13,6 +13,7 @@ from .interactive_usage import conversation_transcript, resolve_transcript
|
|
|
13
13
|
|
|
14
14
|
MAX_LINE_BYTES = 1024 * 1024
|
|
15
15
|
MAX_PAGE_BYTES = 8 * 1024 * 1024
|
|
16
|
+
MAX_CURSOR_NAMES_BYTES = 2048
|
|
16
17
|
REDACTED = "[REDACTED]"
|
|
17
18
|
|
|
18
19
|
|
|
@@ -47,18 +48,54 @@ def _tool_name(message, names, result=False):
|
|
|
47
48
|
return name
|
|
48
49
|
|
|
49
50
|
|
|
51
|
+
def _conversation_record(provider, kind):
|
|
52
|
+
# Provider bookkeeping can carry the same text as real conversation records.
|
|
53
|
+
return kind == "response_item" if provider == "codex" else kind in ("user", "assistant")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
_CLAUDE_HARNESS = re.compile(
|
|
57
|
+
r"\s*(?:<(?P<tag>task-notification|local-command-stdout|local-command-stderr|command-name|command-message|command-args|system-reminder)>"
|
|
58
|
+
r"(?:(?!</(?P=tag)>).)*</(?P=tag)>\s*)+", re.S,
|
|
59
|
+
)
|
|
60
|
+
_CLAUDE_MID_TURN_START = "The user sent a new message while you were working:\n"
|
|
61
|
+
_CLAUDE_MID_TURN_END = re.compile(
|
|
62
|
+
r"\n+This is how Claude Code surfaces messages the user sends mid-turn[^\n]*"
|
|
63
|
+
r"Address the message above as you continue this turn\.\s*\Z"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _claude_user_content(content):
|
|
68
|
+
"""Remove complete harness wrappers, preserving ordinary prose and images."""
|
|
69
|
+
blocks = [{"type": "text", "text": content}] if isinstance(content, str) else content
|
|
70
|
+
if not isinstance(blocks, list):
|
|
71
|
+
return content
|
|
72
|
+
blocks = [dict(block) if isinstance(block, dict) else block for block in blocks]
|
|
73
|
+
texts = [block for block in blocks if isinstance(block, dict)
|
|
74
|
+
and block.get("type") == "text" and isinstance(block.get("text"), str)]
|
|
75
|
+
# Require both anchored boundaries; a quoted sentence alone is user content.
|
|
76
|
+
if texts and texts[0]["text"].startswith(_CLAUDE_MID_TURN_START):
|
|
77
|
+
end = _CLAUDE_MID_TURN_END.search(texts[-1]["text"])
|
|
78
|
+
if end:
|
|
79
|
+
texts[-1]["text"] = texts[-1]["text"][:end.start()]
|
|
80
|
+
texts[0]["text"] = texts[0]["text"][len(_CLAUDE_MID_TURN_START):].strip("\n")
|
|
81
|
+
return [block for block in blocks if not (
|
|
82
|
+
isinstance(block, dict) and block.get("type") == "text"
|
|
83
|
+
and isinstance(block.get("text"), str)
|
|
84
|
+
and (not block["text"].strip() or _CLAUDE_HARNESS.fullmatch(block["text"]))
|
|
85
|
+
)]
|
|
86
|
+
|
|
87
|
+
|
|
50
88
|
def _entries(provider, record, names):
|
|
51
89
|
if not isinstance(record, dict):
|
|
52
90
|
return []
|
|
91
|
+
if not _conversation_record(provider, record.get("type")):
|
|
92
|
+
return []
|
|
53
93
|
stamp = record.get("timestamp")
|
|
54
94
|
try:
|
|
55
95
|
datetime.fromisoformat(stamp.replace("Z", "+00:00"))
|
|
56
96
|
except (AttributeError, TypeError, ValueError):
|
|
57
97
|
stamp = None
|
|
58
98
|
if provider == "codex":
|
|
59
|
-
# event_msg repeats response_item messages; consume only canonical items.
|
|
60
|
-
if record.get("type") != "response_item":
|
|
61
|
-
return []
|
|
62
99
|
message = record.get("payload")
|
|
63
100
|
else:
|
|
64
101
|
message = record.get("message")
|
|
@@ -78,6 +115,8 @@ def _entries(provider, record, names):
|
|
|
78
115
|
if role == "tool":
|
|
79
116
|
return [_entry("tool", name=_tool_name(message, names, True), summary="Result recorded", at=stamp)]
|
|
80
117
|
content = message.get("content")
|
|
118
|
+
if provider == "claude" and role == "user":
|
|
119
|
+
content = _claude_user_content(content)
|
|
81
120
|
if isinstance(content, str):
|
|
82
121
|
return [_entry(role, content, at=stamp)]
|
|
83
122
|
if not isinstance(content, list):
|
|
@@ -92,6 +131,9 @@ def _entries(provider, record, names):
|
|
|
92
131
|
elif kind in ("tool_use", "tool_result"):
|
|
93
132
|
name = _tool_name(block, names, kind == "tool_result")
|
|
94
133
|
entries.append(_entry("tool", name=name, summary="Call recorded" if kind == "tool_use" else "Result recorded", at=stamp))
|
|
134
|
+
if provider == "claude" and role == "user" and not any(e["role"] == "user" for e in entries):
|
|
135
|
+
if any(isinstance(block, dict) and block.get("type") == "image" for block in content):
|
|
136
|
+
entries.append(_entry("user", at=stamp))
|
|
95
137
|
return entries
|
|
96
138
|
|
|
97
139
|
|
|
@@ -100,21 +142,238 @@ def _anchor(handle, offset):
|
|
|
100
142
|
return hashlib.sha256(handle.read(min(offset, 128))).hexdigest()
|
|
101
143
|
|
|
102
144
|
|
|
145
|
+
def _field_start(prefix, field, start=0):
|
|
146
|
+
"""Find an object field before any incomplete value in a bounded prefix."""
|
|
147
|
+
decoder = json.JSONDecoder()
|
|
148
|
+
whitespace = re.compile(r"\s*")
|
|
149
|
+
try:
|
|
150
|
+
start = whitespace.match(prefix, start).end()
|
|
151
|
+
if prefix[start] != "{":
|
|
152
|
+
return None
|
|
153
|
+
start += 1
|
|
154
|
+
while True:
|
|
155
|
+
start = whitespace.match(prefix, start).end()
|
|
156
|
+
key, end = decoder.raw_decode(prefix, start)
|
|
157
|
+
if not isinstance(key, str):
|
|
158
|
+
return None
|
|
159
|
+
end = whitespace.match(prefix, end).end()
|
|
160
|
+
if prefix[end] != ":":
|
|
161
|
+
return None
|
|
162
|
+
start = whitespace.match(prefix, end + 1).end()
|
|
163
|
+
if key == field:
|
|
164
|
+
return start
|
|
165
|
+
_, end = decoder.raw_decode(prefix, start)
|
|
166
|
+
end = whitespace.match(prefix, end).end()
|
|
167
|
+
if prefix[end] != ",":
|
|
168
|
+
return None
|
|
169
|
+
start = end + 1
|
|
170
|
+
except (ValueError, IndexError, RecursionError):
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _oversized_entry(provider, prefix):
|
|
175
|
+
prefix = prefix.decode("utf-8", errors="replace")
|
|
176
|
+
kind = _field_start(prefix, "type")
|
|
177
|
+
try:
|
|
178
|
+
value, _ = json.JSONDecoder().raw_decode(prefix, kind) if kind is not None else (None, 0)
|
|
179
|
+
except (ValueError, RecursionError):
|
|
180
|
+
value = None
|
|
181
|
+
if not _conversation_record(provider, value):
|
|
182
|
+
return None
|
|
183
|
+
message = _field_start(prefix, "payload" if provider == "codex" else "message")
|
|
184
|
+
if message is None or prefix[message:message + 1] != "{":
|
|
185
|
+
return None
|
|
186
|
+
role = _field_start(prefix, "role", message)
|
|
187
|
+
if role is None and provider != "codex":
|
|
188
|
+
role = _field_start(prefix, "type")
|
|
189
|
+
try:
|
|
190
|
+
value, _ = json.JSONDecoder().raw_decode(prefix, role) if role is not None else (None, 0)
|
|
191
|
+
except (ValueError, RecursionError):
|
|
192
|
+
value = None
|
|
193
|
+
return _entry(value if value in ("user", "assistant", "tool") else "tool",
|
|
194
|
+
"[élément trop volumineux ignoré]")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
REVERSE_BLOCK_BYTES = 64 * 1024
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _encode_cursor(handle, identity, offset, index, names):
|
|
201
|
+
names = dict(names)
|
|
202
|
+
while len(json.dumps(names, separators=(",", ":")).encode()) > MAX_CURSOR_NAMES_BYTES:
|
|
203
|
+
names.pop(next(iter(names)))
|
|
204
|
+
state = {"v": 1, "file": identity, "offset": offset, "index": index,
|
|
205
|
+
"anchor": _anchor(handle, offset), "names": names}
|
|
206
|
+
return base64.urlsafe_b64encode(json.dumps(state, separators=(",", ":")).encode()).decode()
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _reverse_records(handle, end):
|
|
210
|
+
"""Yield line positions and bounded prefixes, including an incomplete tail."""
|
|
211
|
+
position, line_end, length, prefix = end, end, 0, b""
|
|
212
|
+
while position:
|
|
213
|
+
start = max(0, position - REVERSE_BLOCK_BYTES)
|
|
214
|
+
handle.seek(start)
|
|
215
|
+
block = handle.read(position - start)
|
|
216
|
+
right = len(block)
|
|
217
|
+
while right:
|
|
218
|
+
newline = block.rfind(b"\n", 0, right)
|
|
219
|
+
left = newline + 1
|
|
220
|
+
part = block[left:right]
|
|
221
|
+
length += len(part)
|
|
222
|
+
prefix = (part + prefix)[:MAX_LINE_BYTES + 1]
|
|
223
|
+
if newline < 0:
|
|
224
|
+
break
|
|
225
|
+
if length:
|
|
226
|
+
yield start + left, line_end, prefix, length
|
|
227
|
+
line_end = start + left
|
|
228
|
+
length, prefix, right = 1, b"\n", newline
|
|
229
|
+
position = start
|
|
230
|
+
if length:
|
|
231
|
+
yield 0, line_end, prefix, length
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _record_entries(provider, prefix, length, names):
|
|
235
|
+
if length > MAX_LINE_BYTES:
|
|
236
|
+
entry = _oversized_entry(provider, prefix)
|
|
237
|
+
return [entry] if entry is not None else []
|
|
238
|
+
try:
|
|
239
|
+
return _entries(provider, json.loads(prefix), names)
|
|
240
|
+
except (ValueError, UnicodeError, RecursionError):
|
|
241
|
+
return []
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
class _NameLookups(dict):
|
|
245
|
+
def __init__(self):
|
|
246
|
+
super().__init__()
|
|
247
|
+
self.missing = set()
|
|
248
|
+
|
|
249
|
+
def get(self, key, default=None):
|
|
250
|
+
if key not in self:
|
|
251
|
+
self.missing.add(key)
|
|
252
|
+
return super().get(key, default)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _read_tail(handle, stat, identity, provider, page, count, before):
|
|
256
|
+
end, stop_index = stat.st_size, None
|
|
257
|
+
snapshot = {"size": stat.st_size, "mtime": stat.st_mtime_ns,
|
|
258
|
+
"anchor": _anchor(handle, stat.st_size)}
|
|
259
|
+
if before is not None:
|
|
260
|
+
try:
|
|
261
|
+
if not isinstance(before, str) or len(before) > 4096:
|
|
262
|
+
raise ValueError
|
|
263
|
+
state = json.loads(base64.urlsafe_b64decode(before.encode("ascii")))
|
|
264
|
+
end, stop_index = state["offset"], state["index"]
|
|
265
|
+
snapshot = state["snapshot"]
|
|
266
|
+
size = snapshot["size"]
|
|
267
|
+
if (state["v"] != 2 or state["file"] != identity or state["direction"] != "before"
|
|
268
|
+
or type(end) is not int or type(stop_index) is not int
|
|
269
|
+
or type(size) is not int or not 0 <= end <= size <= stat.st_size
|
|
270
|
+
or not 0 <= stop_index <= MAX_LINE_BYTES
|
|
271
|
+
or state["anchor"] != _anchor(handle, end)
|
|
272
|
+
or snapshot["anchor"] != _anchor(handle, size)
|
|
273
|
+
or (size == stat.st_size and snapshot["mtime"] != stat.st_mtime_ns)):
|
|
274
|
+
raise ValueError
|
|
275
|
+
except (ValueError, TypeError, KeyError, AttributeError, UnicodeError):
|
|
276
|
+
return {**page, "status": "reset_required", "reason": "invalid_or_stale_cursor"}
|
|
277
|
+
# Include the boundary record when a page split its content blocks.
|
|
278
|
+
boundary_end = end
|
|
279
|
+
if stop_index:
|
|
280
|
+
handle.seek(end)
|
|
281
|
+
line = handle.readline(MAX_LINE_BYTES + 1)
|
|
282
|
+
if not line.endswith(b"\n") or len(line) > MAX_LINE_BYTES:
|
|
283
|
+
return {**page, "status": "reset_required", "reason": "invalid_or_stale_cursor"}
|
|
284
|
+
boundary_end = end + len(line)
|
|
285
|
+
selected, remaining, names = [], count, {}
|
|
286
|
+
earliest = (end, stop_index or 0)
|
|
287
|
+
forward_end = (end, stop_index or 0)
|
|
288
|
+
lookback_start = None
|
|
289
|
+
missing_names = set()
|
|
290
|
+
for start, record_end, prefix, length in _reverse_records(handle, boundary_end):
|
|
291
|
+
if not prefix.endswith(b"\n") and length <= MAX_LINE_BYTES:
|
|
292
|
+
# The first reverse record may be an unfinished provider write.
|
|
293
|
+
forward_end = (start, 0)
|
|
294
|
+
continue
|
|
295
|
+
if length > MAX_LINE_BYTES:
|
|
296
|
+
handle.seek(record_end - 1)
|
|
297
|
+
if handle.read(1) != b"\n":
|
|
298
|
+
forward_end = (start, 0)
|
|
299
|
+
continue
|
|
300
|
+
local_names = _NameLookups()
|
|
301
|
+
entries = _record_entries(provider, prefix, length, local_names)
|
|
302
|
+
if start == end and stop_index and stop_index >= len(entries):
|
|
303
|
+
return {**page, "status": "reset_required", "reason": "invalid_or_stale_cursor"}
|
|
304
|
+
if remaining:
|
|
305
|
+
upper = stop_index if start == end and stop_index is not None else len(entries)
|
|
306
|
+
lower = max(0, upper - remaining)
|
|
307
|
+
if upper > lower:
|
|
308
|
+
selected.append((start, prefix, length, lower, upper))
|
|
309
|
+
missing_names.update(local_names.missing)
|
|
310
|
+
missing_names.difference_update(local_names)
|
|
311
|
+
remaining -= upper - lower
|
|
312
|
+
earliest = (start, lower)
|
|
313
|
+
if len(selected) == 1:
|
|
314
|
+
forward_end = (start, upper) if upper < len(entries) else (record_end, 0)
|
|
315
|
+
if not remaining:
|
|
316
|
+
lookback_start = start
|
|
317
|
+
else:
|
|
318
|
+
# Recover recent call names before normalizing the selected window.
|
|
319
|
+
for key, name in local_names.items():
|
|
320
|
+
names.setdefault(key, name)
|
|
321
|
+
missing_names.difference_update(local_names)
|
|
322
|
+
budget = MAX_PAGE_BYTES if missing_names else REVERSE_BLOCK_BYTES
|
|
323
|
+
if len(names) >= 100 or lookback_start - start >= budget:
|
|
324
|
+
break
|
|
325
|
+
if remaining:
|
|
326
|
+
earliest = (0, 0)
|
|
327
|
+
names = dict(reversed(list(names.items())))
|
|
328
|
+
for start, prefix, length, lower, upper in reversed(selected):
|
|
329
|
+
entries = _record_entries(provider, prefix, length, names)
|
|
330
|
+
for index in range(lower, upper):
|
|
331
|
+
entry = entries[index]
|
|
332
|
+
entry["id"] = f"{identity[:16]}:{start}:{index}"
|
|
333
|
+
page["entries"].append(entry)
|
|
334
|
+
offset, index = forward_end
|
|
335
|
+
page["next_cursor"] = _encode_cursor(handle, identity, offset, index, names)
|
|
336
|
+
page["has_more"] = offset < stat.st_size
|
|
337
|
+
offset, index = earliest
|
|
338
|
+
page["before_cursor"] = None
|
|
339
|
+
if offset or index:
|
|
340
|
+
state = {"v": 2, "direction": "before", "file": identity, "offset": offset,
|
|
341
|
+
"index": index, "anchor": _anchor(handle, offset), "snapshot": snapshot}
|
|
342
|
+
page["before_cursor"] = base64.urlsafe_b64encode(json.dumps(state, separators=(",", ":")).encode()).decode()
|
|
343
|
+
return page
|
|
344
|
+
|
|
345
|
+
|
|
103
346
|
def read_conversation(provider, auth_home=None, *, conversation_id=None, started_at=None,
|
|
104
|
-
terminal_transcript=None, cursor=None, limit=100, fallback_to_recency=False):
|
|
347
|
+
terminal_transcript=None, cursor=None, limit=100, fallback_to_recency=False, tail=None, before=None, cwd=None):
|
|
105
348
|
"""Return a JSON-serializable page; pass next_cursor back unchanged to poll.
|
|
106
349
|
|
|
107
350
|
Entries follow file/block order. Cursors bind a byte offset and block index
|
|
108
351
|
to the resolved file; reset_required means discard the old cursor and page
|
|
109
352
|
again from the beginning. Incomplete trailing lines are retried next time.
|
|
353
|
+
tail selects the latest entries; before selects entries preceding an opaque
|
|
354
|
+
backward cursor. Both return before_cursor as well as a forward next_cursor.
|
|
110
355
|
Only implicit session identities may opt into fallback_to_recency.
|
|
356
|
+
Recency is restricted to cwd (the process directory by default), and to
|
|
357
|
+
transcripts modified at or after started_at when supplied.
|
|
111
358
|
Tool bodies and reasoning blocks are never returned. Ollama PTY captures
|
|
112
359
|
cannot reliably distinguish roles and are explicitly unsupported.
|
|
113
360
|
"""
|
|
114
361
|
if not isinstance(limit, int) or isinstance(limit, bool) or not 1 <= limit <= 1000:
|
|
115
362
|
raise CdxError("Conversation limit must be an integer between 1 and 1000.")
|
|
363
|
+
if tail is not None and (type(tail) is not int or not 1 <= tail <= 1000):
|
|
364
|
+
raise CdxError("Conversation tail must be an integer between 1 and 1000.")
|
|
365
|
+
if cursor is not None and (tail is not None or before is not None):
|
|
366
|
+
raise CdxError("Conversation --cursor cannot be combined with --tail or --before.")
|
|
367
|
+
if started_at is not None:
|
|
368
|
+
try:
|
|
369
|
+
datetime.fromisoformat(started_at.replace("Z", "+00:00"))
|
|
370
|
+
except (ValueError, AttributeError, TypeError):
|
|
371
|
+
raise CdxError("Conversation --since must be an ISO timestamp.") from None
|
|
372
|
+
scope = {"cwd": os.path.realpath(os.path.expanduser(cwd or os.getcwd())), "since": started_at}
|
|
116
373
|
page = {"status": "ok", "reason": None, "provider": provider, "entries": [], "next_cursor": None, "has_more": False,
|
|
117
|
-
"resolution_method": None}
|
|
374
|
+
"resolution_method": None, "resolution_scope": None}
|
|
375
|
+
if tail is not None or before is not None:
|
|
376
|
+
page["before_cursor"] = None
|
|
118
377
|
if provider not in PROVIDERS or provider == "ollama":
|
|
119
378
|
return {**page, "status": "unsupported", "reason": "no_structured_transcript_reader"}
|
|
120
379
|
# An explicit conversation must never fall back to an unrelated recent file.
|
|
@@ -123,15 +382,20 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
|
|
|
123
382
|
if path:
|
|
124
383
|
page["resolution_method"] = "conversation_id"
|
|
125
384
|
elif fallback_to_recency:
|
|
126
|
-
|
|
385
|
+
page["resolution_scope"] = scope
|
|
386
|
+
path, page["resolution_method"] = resolve_transcript(provider, auth_home, started_at, cwd=scope["cwd"])
|
|
127
387
|
else:
|
|
128
|
-
|
|
388
|
+
page["resolution_scope"] = scope
|
|
389
|
+
path, page["resolution_method"] = resolve_transcript(provider, auth_home, started_at, terminal_transcript=terminal_transcript, cwd=scope["cwd"])
|
|
129
390
|
if not path:
|
|
130
|
-
|
|
391
|
+
reason = "no_conversation_yet" if page["resolution_scope"] is not None else "transcript_not_found"
|
|
392
|
+
return {**page, "status": "unavailable", "reason": reason}
|
|
131
393
|
try:
|
|
132
394
|
with open(path, "rb") as handle:
|
|
133
395
|
stat = os.fstat(handle.fileno())
|
|
134
396
|
identity = hashlib.sha256(f"{provider}:{os.path.realpath(path)}:{stat.st_dev}:{stat.st_ino}".encode()).hexdigest()
|
|
397
|
+
if tail is not None or before is not None:
|
|
398
|
+
return _read_tail(handle, stat, identity, provider, page, tail if tail is not None else limit, before)
|
|
135
399
|
offset, index, names = 0, 0, {}
|
|
136
400
|
if cursor is not None:
|
|
137
401
|
try:
|
|
@@ -158,14 +422,22 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
|
|
|
158
422
|
start = handle.tell()
|
|
159
423
|
line = handle.readline(MAX_LINE_BYTES + 1)
|
|
160
424
|
if len(line) > MAX_LINE_BYTES:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
entries = []
|
|
425
|
+
entry = _oversized_entry(provider, line)
|
|
426
|
+
# Finish this record even beyond the page scan budget. Never
|
|
427
|
+
# retain its body, and retry from start if its newline is pending.
|
|
428
|
+
while line and not line.endswith(b"\n"):
|
|
429
|
+
line = handle.readline(MAX_LINE_BYTES + 1)
|
|
430
|
+
if not line:
|
|
431
|
+
break
|
|
432
|
+
entries = [entry] if entry is not None else []
|
|
433
|
+
else:
|
|
434
|
+
if not line or not line.endswith(b"\n"):
|
|
435
|
+
break
|
|
436
|
+
try:
|
|
437
|
+
entries = _entries(provider, json.loads(line), names)
|
|
438
|
+
except (ValueError, UnicodeError):
|
|
439
|
+
entries = []
|
|
440
|
+
scanned += handle.tell() - start
|
|
169
441
|
for block_index in range(index, len(entries)):
|
|
170
442
|
entry = entries[block_index]
|
|
171
443
|
entry["id"] = f"{identity[:16]}:{start}:{block_index}"
|
|
@@ -180,8 +452,7 @@ def read_conversation(provider, auth_home=None, *, conversation_id=None, started
|
|
|
180
452
|
break
|
|
181
453
|
offset, index = handle.tell(), 0
|
|
182
454
|
page["has_more"] = offset < stat.st_size
|
|
183
|
-
|
|
184
|
-
page["next_cursor"] = base64.urlsafe_b64encode(json.dumps(state, separators=(",", ":")).encode()).decode()
|
|
455
|
+
page["next_cursor"] = _encode_cursor(handle, identity, offset, index, names)
|
|
185
456
|
return page
|
|
186
457
|
except OSError:
|
|
187
458
|
return {**page, "status": "unavailable", "reason": "transcript_unreadable", "entries": []}
|
package/src/interactive_usage.py
CHANGED
|
@@ -14,6 +14,7 @@ import re
|
|
|
14
14
|
import time
|
|
15
15
|
|
|
16
16
|
from .run_usage import claude_usage_dedup_key, normalize_usage
|
|
17
|
+
from .shared_memory import claude_project_name
|
|
17
18
|
from .status_source import _normalize_terminal_transcript
|
|
18
19
|
|
|
19
20
|
# Readers stream JSONL line-by-line. A 16 MiB cap excluded ordinary long
|
|
@@ -84,7 +85,7 @@ def extract_interactive_usage(provider, auth_home, started_at=None, conversation
|
|
|
84
85
|
|
|
85
86
|
|
|
86
87
|
def resolve_transcript(provider, auth_home, started_at=None, conversation_id=None,
|
|
87
|
-
terminal_transcript=None):
|
|
88
|
+
terminal_transcript=None, cwd=None):
|
|
88
89
|
"""The transcript belonging to this session, and how confidently."""
|
|
89
90
|
if provider == "ollama":
|
|
90
91
|
# No provider-native transcript exists to resolve. The run's own PTY
|
|
@@ -108,7 +109,7 @@ def resolve_transcript(provider, auth_home, started_at=None, conversation_id=Non
|
|
|
108
109
|
# a Codex rollout lives under the session's own auth home, so the
|
|
109
110
|
# newest one touched during this run is this session's current
|
|
110
111
|
# conversation, not somebody else's file.
|
|
111
|
-
path = _latest_transcript(provider, auth_home, started_at)
|
|
112
|
+
path = _latest_transcript(provider, auth_home, started_at, cwd=cwd)
|
|
112
113
|
return path, (MATCH_RECENCY if path else None)
|
|
113
114
|
|
|
114
115
|
|
|
@@ -166,10 +167,25 @@ def _codex_rollout_for(auth_home, conversation_id):
|
|
|
166
167
|
_READERS = {}
|
|
167
168
|
|
|
168
169
|
|
|
169
|
-
def
|
|
170
|
+
def _codex_matches_cwd(path, cwd):
|
|
171
|
+
"""Fail closed when the bounded session metadata cannot identify a project."""
|
|
172
|
+
try:
|
|
173
|
+
with open(path, encoding="utf-8") as handle:
|
|
174
|
+
record = json.loads(handle.readline(64 * 1024))
|
|
175
|
+
recorded = record["payload"]["cwd"] if record["type"] == "session_meta" else None
|
|
176
|
+
return isinstance(recorded, str) and os.path.isabs(recorded) and os.path.realpath(recorded) == cwd
|
|
177
|
+
except (OSError, ValueError, KeyError, TypeError, RecursionError):
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _latest_transcript(provider, auth_home, started_at, cwd=None):
|
|
170
182
|
if not auth_home:
|
|
171
183
|
return None
|
|
172
184
|
root = os.path.join(auth_home, "sessions") if provider == "codex" else os.path.join(auth_home, ".claude", "projects")
|
|
185
|
+
if cwd is not None:
|
|
186
|
+
cwd = os.path.realpath(cwd)
|
|
187
|
+
if provider != "codex":
|
|
188
|
+
root = os.path.join(root, claude_project_name(cwd))
|
|
173
189
|
if not os.path.isdir(root):
|
|
174
190
|
return None
|
|
175
191
|
cutoff = _timestamp(started_at)
|
|
@@ -181,9 +197,11 @@ def _latest_transcript(provider, auth_home, started_at):
|
|
|
181
197
|
# reached". Returning a partial answer here is what made a wrong
|
|
182
198
|
# attribution indistinguishable from a real measurement.
|
|
183
199
|
return None
|
|
184
|
-
if provider == "claude":
|
|
200
|
+
if provider == "claude" or cwd is not None:
|
|
185
201
|
subdirs[:] = [name for name in subdirs if name != "subagents"]
|
|
186
202
|
for name in names:
|
|
203
|
+
if time.monotonic() >= deadline:
|
|
204
|
+
return None
|
|
187
205
|
if not name.endswith(".jsonl"):
|
|
188
206
|
continue
|
|
189
207
|
path = os.path.join(directory, name)
|
|
@@ -192,6 +210,8 @@ def _latest_transcript(provider, auth_home, started_at):
|
|
|
192
210
|
except OSError:
|
|
193
211
|
continue
|
|
194
212
|
if cutoff is None or modified >= cutoff:
|
|
213
|
+
if cwd is not None and provider == "codex" and not _codex_matches_cwd(path, cwd):
|
|
214
|
+
continue
|
|
195
215
|
candidates.append((modified, path))
|
|
196
216
|
if len(candidates) > MAX_TRANSCRIPT_CANDIDATES:
|
|
197
217
|
return None
|