sidekick-shared 0.27.0 → 0.27.1
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/CHANGELOG.md +16 -0
- package/README.md +9 -6
- package/dist/index.d.ts +1 -1
- package/dist/node.d.ts +1 -1
- package/dist/promptHistory.d.ts +44 -3
- package/dist/promptHistory.js +159 -56
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to sidekick-shared will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.27.1] - 2026-09-22
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- `collectPromptHistory()` returns a prompt again when its first answering call is written after the prompt was collected. Previously the cursor moved past the prompt and a resumed scan could never attach the answer's `model` / `usage`. Now, while a file's last emitted prompt is not final, the cursor keeps `pending: { ordinal, offset, emitted, state? }`: the byte offset of the prompt's line, a 16-hex digest of the model, usage, and status last returned, and (Codex) the parse state from just before that line. The next call replays from that offset, so the prompt's `provider`, `sessionId`, `ordinal`, `timestamp`, and `text` come from the log again and are never synthesized or renumbered, and returns it only if its metadata digest changed. No prompt or assistant text is stored in the cursor. A replay that would exceed `maxFileBytes` is dropped in favour of the committed offset, and that prompt is not revisited.
|
|
13
|
+
- First-answer attribution: Claude split records of the first call replace usage rather than adding it, a later assistant message id, a `tool_result`, or a non-null `stop_reason` closes the first call, later tool-loop calls never replace it, and `<synthetic>` records (API errors, interrupts) are ignored instead of contributing zero usage. Codex closes on the first `token_count` with `last_token_usage`; a `token_count` without usage (rate-limit only) does not. Missing usage stays `undefined`.
|
|
14
|
+
- Documentation: cursor keys are path-free, but values are not (Codex `state.cwd` / `gitBranch`), so the cursor is sensitive, local-only state; a file over `maxFileBytes` is skipped and calling again with the same bounds skips it again.
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- `PromptHistoryEntry.metadataStatus`: `pending` (no answering call yet), `provisional` (Claude call seen with `stop_reason: null`), or `final` (complete, or closed without an answer). A prompt closed by the next prompt without an answer is returned once more as `final`. New exported types `PromptHistoryMetadataStatus` and `PromptHistoryPendingPrompt`.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- `PromptHistoryCursor.version` is now `2` and sessions may carry `pending`. Version 1 cursors are accepted as input and upgraded; prompts they already returned without metadata cannot be recovered. Unknown versions are ignored (full rescan, same identities).
|
|
23
|
+
|
|
8
24
|
## [0.27.0] - 2026-09-22
|
|
9
25
|
|
|
10
26
|
### Added
|
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Types, parsers, providers, readers, formatters, aggregation, search, reporting,
|
|
|
9
9
|
|
|
10
10
|
## Recent additions
|
|
11
11
|
|
|
12
|
+
- **0.27.1: late prompt metadata** — `collectPromptHistory()` returns a prompt again, with the same provider, sessionId, ordinal, timestamp, and text, when its first answering call is written after an earlier collection, and entries gain `metadataStatus` (`pending` / `provisional` / `final`). The cursor becomes `version: 2`, keeps a replay offset and metadata digest for the one open prompt per file (no prompt text), and still accepts 0.27.0 cursors.
|
|
12
13
|
- **0.27.0: human prompt history** — `collectPromptHistory()` (Node-only) returns the prompts a person typed in Claude Code and Codex sessions under a set of workspace roots (plus git worktree siblings), with fail-closed cwd scoping, untruncated text, stable per-session ordinals, the answering model and usage, a JSON-serializable resume cursor, and bounds that stop cleanly and report themselves in `boundsHit`. `isHumanPrompt()` / `humanPromptText()` expose the same classification for any canonical transcript message, and transcript provenance now keeps Claude Code's `isCompactSummary`, `originKind`, and `promptSource`. Claude Opus 5.5 gets its own pricing and context entries.
|
|
13
14
|
- **0.26.6: verified account switching** — `syncLiveAccountState()` reconciles the live Claude Code and Codex logins with saved profiles (registers unknown logins under their email, folds rotated tokens, merges duplicate Codex profiles, re-points the active pointer) and runs at startup, on account-change events, and as phase one of every switch; `getAccountHealth()` / `listAccountsWithHealth()` report secret-free `fresh | expiring | expired | unknown | missing` health; `switchAccount[Async]()` returns a `SwitchAccountResult` with verification, `runningConsumers`, `needsLogin`, and an `undoToken` for `undoLastSwitch()`; `detectRunningAccountConsumers()`, `getAccountLaunchEnv()`, `refreshInactiveAccounts()`, and `getCodexCredentialStoreMode()` are new. Claude logins run `claude auth login`, the Keychain suffix follows Claude Code 2.1.x, and the unused shell-hook helpers were removed.
|
|
14
15
|
- **0.26.5: event-scoped collection** — `ObservedSessionCollector.subscribe()` reconciles each watch event with one `statWatchedSessionFile()` stat and runs a full `discover()` only for unknown paths, catch-up polls, and the initial pass, gated by `minReconcileGapMs`; `discover({ limit, since })` / `collect({ limit, since })` bound a pass, `maxCacheEntries` / `maxCacheBytes` bound the parse cache, `DirectoryListingCache` makes a full pass one stat per unchanged directory, and the info-severity `provider-discovery-completed` diagnostic reports what each pass cost.
|
|
@@ -294,7 +295,7 @@ const humanPrompts = transcript.messages
|
|
|
294
295
|
.map((message) => humanPromptText(message, 'claude-code')); // `/review foo` for slash commands
|
|
295
296
|
```
|
|
296
297
|
|
|
297
|
-
A Claude Code message is a human prompt when it has the user role, an entrypoint in `HUMAN_CLAUDE_ENTRYPOINTS` (`cli`, `claude-desktop`, `sdk-cli` — Agent SDK programs report `sdk-ts` / `sdk-py` and are excluded), is not meta, sidechain, or a compaction summary, has no non-human `origin.kind` (task notifications, coordinator messages, auto-continuations) or `promptSource: 'system'`, and is not tool-result-only, local-command output or caveat, a system reminder, or an interrupt marker. A Codex message is one when it has the user role and some text is left after injected `AGENTS.md`, `<environment_context>`, `<user_instructions>`, turn-aborted, skill, subagent-notification, and internal-context blocks and `<image>` wrappers are dropped. Codex session source is a session-level rule: `collectPromptHistory()` reads only sessions whose `session_meta.source` is in `HUMAN_CODEX_SOURCES` (`cli`, `vscode`, `exec`) and skips subagent and forked sessions.
|
|
298
|
+
A Claude Code message is a human prompt when it has the user role, an entrypoint in `HUMAN_CLAUDE_ENTRYPOINTS` (`cli`, `claude-desktop`, `sdk-cli` — Agent SDK programs report `sdk-ts` / `sdk-py` and are excluded), is not meta, sidechain, or a compaction summary, has no non-human `origin.kind` (task notifications, coordinator messages, auto-continuations) or `promptSource: 'system'`, and is not tool-result-only, local-command output or caveat, bash-mode input or output, a hook or task notification, a system reminder, a continued-session summary, or an interrupt marker. A Codex message is one when it has the user role and some text is left after injected `AGENTS.md`, `<environment_context>`, `<user_instructions>`, permissions and collaboration-mode, recommended-plugin, user-shell-command, turn-aborted, skill, subagent-notification, and internal-context blocks and `<image>` wrappers are dropped. Codex session source is a session-level rule: `collectPromptHistory()` reads only sessions whose `session_meta.source` is in `HUMAN_CODEX_SOURCES` (`cli`, `vscode`, `exec`) and skips subagent and forked sessions.
|
|
298
299
|
|
|
299
300
|
### Collect human prompt history for workspace roots
|
|
300
301
|
|
|
@@ -317,17 +318,19 @@ const result = await collectPromptHistory({
|
|
|
317
318
|
});
|
|
318
319
|
|
|
319
320
|
for (const entry of result.entries) {
|
|
320
|
-
// { provider, sessionId, ordinal, timestamp, text, cwd, gitBranch?, model?, usage
|
|
321
|
+
// { provider, sessionId, ordinal, timestamp, text, cwd, gitBranch?, model?, usage?, metadataStatus }
|
|
322
|
+
// Upsert by (provider, sessionId, ordinal): a prompt comes back when its answer metadata changes.
|
|
321
323
|
}
|
|
322
|
-
saveCursor(result.cursor); // unchanged files are skipped next time
|
|
324
|
+
saveCursor(result.cursor); // sensitive local state; unchanged files are skipped next time
|
|
323
325
|
if (result.boundsHit.length > 0) console.warn('partial pass', result.boundsHit, result.stats);
|
|
324
326
|
```
|
|
325
327
|
|
|
326
|
-
- **Scope fails closed.** A prompt is returned only when its recorded working directory (the Claude line's `cwd`; the Codex `session_meta` / `turn_context` cwd) realpath-resolves to a root or somewhere inside one. Missing or unresolvable cwds are excluded and counted in `stats.promptsOutOfScope`; Codex scope never comes from the rollout's location. `cwd` is the only path in
|
|
328
|
+
- **Scope fails closed.** A prompt is returned only when its recorded working directory (the Claude line's `cwd`; the Codex `session_meta` / `turn_context` cwd) realpath-resolves to a root or somewhere inside one. Missing or unresolvable cwds are excluded and counted in `stats.promptsOutOfScope`; Codex scope never comes from the rollout's location. `cwd` is the only path in entries. Cursor keys hold no paths, but cursor values can (Codex resume state keeps the session's cwd and branch), so store the cursor as sensitive, local-only state.
|
|
327
329
|
- **Parse-only.** No redaction and no network I/O; text is untruncated.
|
|
328
330
|
- **Ordinals are stable.** Every human prompt in a file consumes one ordinal, even when it is out of scope, older than `since`, or lacks a timestamp (such lines are skipped and counted, never given a synthesized time). Entries are ordered by provider, session, then ordinal.
|
|
329
|
-
- **Model and usage** come from the first model call that answered the prompt (Claude: the first assistant message; Codex: the first `token_count` with the model from `turn_context`). `inputTokens` is uncached input
|
|
330
|
-
- **
|
|
331
|
+
- **Model and usage** come from the first model call that answered the prompt (Claude: the first non-synthetic assistant message, with split records of that call replacing rather than adding usage; Codex: the first `token_count` with the model from `turn_context`). Later tool-loop calls never replace it. `inputTokens` is uncached input, and missing usage stays `undefined`, never zero.
|
|
332
|
+
- **Late answers enrich the same prompt.** A prompt can be collected before its answer is written. `metadataStatus` is `pending` (no answering call yet), `provisional` (Claude call still being written, `stop_reason: null`), or `final`. While a file's last prompt is not final, the cursor keeps the byte offset of its line and a digest of what was returned (never prompt or answer text); the next call replays from that line and returns the prompt again, with the same provider, sessionId, ordinal, timestamp, and text, only when its model, usage, or status changed. A prompt closed by the next prompt without an answer comes back once as `final` with no usage. Consumers should upsert by identity. Cursors from 0.27.0 (`version: 1`) are accepted but cannot recover prompts they already returned without metadata.
|
|
333
|
+
- **Bounds never throw.** Hitting `maxSessions`, `maxTotalBytes`, the deadline, or an abort stops the pass. An interrupted file contributes no entries, and the cursor covers only fully processed files, so the next call picks up where this one stopped. A file over `maxFileBytes` is different: it is skipped and counted in `stats.filesOverSizeLimit`, and calling again with the same bounds skips it again, so raise the bound to make progress on it. When only the replay of a pending prompt would exceed `maxFileBytes`, the replay is dropped and the file continues from its committed offset; that prompt is not revisited.
|
|
331
334
|
|
|
332
335
|
For an already-open incremental Codex rollout, keep one `CodexRolloutParser` per stream and pass each parsed `SessionEvent[]` batch to `projectSessionTranscript()` (or append the events to an existing projection). This preserves parser state for paired tool calls, model context, and cumulative token deltas; consumers should not convert raw `response_item` rows themselves.
|
|
333
336
|
|
package/dist/index.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export { projectSessionTranscript } from './transcript';
|
|
|
11
11
|
export type { TranscriptFidelity, TranscriptSourceProvenance, CanonicalTranscriptBlock, CanonicalTranscriptMessage, CanonicalToolCall, CanonicalTranscriptUsageTotals, CanonicalSessionTranscript, ProjectSessionTranscriptOptions, } from './transcript';
|
|
12
12
|
export { listRecentSessions, readSessionTranscript } from './sessionTranscripts';
|
|
13
13
|
export { collectPromptHistory } from './promptHistory';
|
|
14
|
-
export type { CollectPromptHistoryOptions, PromptHistoryBound, PromptHistoryBounds, PromptHistoryCursor, PromptHistoryCursorSession, PromptHistoryEntry, PromptHistoryProvider, PromptHistoryResult, PromptHistoryResumeState, PromptHistoryStats, PromptHistoryUsage, } from './promptHistory';
|
|
14
|
+
export type { CollectPromptHistoryOptions, PromptHistoryBound, PromptHistoryBounds, PromptHistoryCursor, PromptHistoryCursorSession, PromptHistoryEntry, PromptHistoryMetadataStatus, PromptHistoryPendingPrompt, PromptHistoryProvider, PromptHistoryResult, PromptHistoryResumeState, PromptHistoryStats, PromptHistoryUsage, } from './promptHistory';
|
|
15
15
|
export { HUMAN_CLAUDE_ENTRYPOINTS, HUMAN_CODEX_SOURCES, humanPromptText, isHumanPrompt, } from './humanPrompt';
|
|
16
16
|
export type { HumanPromptProvider } from './humanPrompt';
|
|
17
17
|
export { computeSessionFileStats, firstUserPrompt, providerContextSizeFn, readSessionFileStats, } from './sessionStats';
|
package/dist/node.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export { loadObservedContextWindows, recordObservedContextWindow, getObservedCon
|
|
|
11
11
|
export type { ObservedContextWindowOptions, ObservedContextWindowStore, } from './observedContextWindows';
|
|
12
12
|
export { listRecentSessions, readSessionTranscript } from './sessionTranscripts';
|
|
13
13
|
export { collectPromptHistory } from './promptHistory';
|
|
14
|
-
export type { CollectPromptHistoryOptions, PromptHistoryBound, PromptHistoryBounds, PromptHistoryCursor, PromptHistoryCursorSession, PromptHistoryEntry, PromptHistoryProvider, PromptHistoryResult, PromptHistoryResumeState, PromptHistoryStats, PromptHistoryUsage, } from './promptHistory';
|
|
14
|
+
export type { CollectPromptHistoryOptions, PromptHistoryBound, PromptHistoryBounds, PromptHistoryCursor, PromptHistoryCursorSession, PromptHistoryEntry, PromptHistoryMetadataStatus, PromptHistoryPendingPrompt, PromptHistoryProvider, PromptHistoryResult, PromptHistoryResumeState, PromptHistoryStats, PromptHistoryUsage, } from './promptHistory';
|
|
15
15
|
export { HUMAN_CLAUDE_ENTRYPOINTS, HUMAN_CODEX_SOURCES, humanPromptText, isHumanPrompt, } from './humanPrompt';
|
|
16
16
|
export type { HumanPromptProvider } from './humanPrompt';
|
|
17
17
|
export { computeSessionFileStats, firstUserPrompt, providerContextSizeFn, readSessionFileStats, } from './sessionStats';
|
package/dist/promptHistory.d.ts
CHANGED
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* for Codex, its session/turn context) realpath-resolves to an allowed root
|
|
8
8
|
* or somewhere inside one. Every bound stops cleanly and is reported in
|
|
9
9
|
* `boundsHit`; nothing is silently truncated and no bound throws.
|
|
10
|
+
*
|
|
11
|
+
* A prompt can be written before its answer. The cursor then keeps the byte
|
|
12
|
+
* offset of that prompt's line (not its text), the next call replays from it,
|
|
13
|
+
* and the prompt is returned again under the same identity once its first
|
|
14
|
+
* answering call brings model or usage (`metadataStatus` says how settled).
|
|
10
15
|
*/
|
|
11
16
|
import { type HumanPromptProvider } from './humanPrompt';
|
|
12
17
|
export type PromptHistoryProvider = HumanPromptProvider;
|
|
@@ -14,7 +19,10 @@ export type PromptHistoryBound = 'maxSessions' | 'maxFileBytes' | 'maxTotalBytes
|
|
|
14
19
|
export interface PromptHistoryBounds {
|
|
15
20
|
/** Session files read in one call (unchanged files do not count). Default 1000. */
|
|
16
21
|
maxSessions?: number;
|
|
17
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Bytes a single file may need read in this pass. Default 16 MiB. A file over
|
|
24
|
+
* it is skipped, and calling again with the same bounds skips it again.
|
|
25
|
+
*/
|
|
18
26
|
maxFileBytes?: number;
|
|
19
27
|
/** Bytes read across all files in this call. Default 256 MiB. */
|
|
20
28
|
maxTotalBytes?: number;
|
|
@@ -31,6 +39,19 @@ export interface PromptHistoryResumeState {
|
|
|
31
39
|
/** `session_meta` has been seen. */
|
|
32
40
|
meta?: boolean;
|
|
33
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The last emitted prompt of a file whose answering call is not final yet.
|
|
44
|
+
* Holds a replay position and a metadata digest, never prompt or answer text.
|
|
45
|
+
*/
|
|
46
|
+
export interface PromptHistoryPendingPrompt {
|
|
47
|
+
ordinal: number;
|
|
48
|
+
/** Byte offset where the prompt's log line starts; the next pass replays from here. */
|
|
49
|
+
offset: number;
|
|
50
|
+
/** Digest of the model, usage, and status last returned for this prompt. */
|
|
51
|
+
emitted: string;
|
|
52
|
+
/** Codex parse state as it was just before the prompt line. */
|
|
53
|
+
state?: PromptHistoryResumeState;
|
|
54
|
+
}
|
|
34
55
|
export interface PromptHistoryCursorSession {
|
|
35
56
|
provider: PromptHistoryProvider;
|
|
36
57
|
sessionId: string;
|
|
@@ -41,12 +62,26 @@ export interface PromptHistoryCursorSession {
|
|
|
41
62
|
/** Byte offset just past the last complete line processed. */
|
|
42
63
|
offset: number;
|
|
43
64
|
state?: PromptHistoryResumeState;
|
|
65
|
+
pending?: PromptHistoryPendingPrompt;
|
|
44
66
|
}
|
|
45
|
-
/**
|
|
67
|
+
/**
|
|
68
|
+
* JSON-serializable resume point. Keys are opaque and never contain paths, but
|
|
69
|
+
* values can (Codex `state.cwd`), so treat the cursor as sensitive local state.
|
|
70
|
+
* Version 1 cursors from 0.27.0 are accepted as input; output is always version 2.
|
|
71
|
+
*/
|
|
46
72
|
export interface PromptHistoryCursor {
|
|
47
|
-
version:
|
|
73
|
+
version: 2;
|
|
48
74
|
sessions: Record<string, PromptHistoryCursorSession>;
|
|
49
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* How settled an entry's `model` / `usage` are:
|
|
78
|
+
* - `pending`: no answering call seen yet.
|
|
79
|
+
* - `provisional`: the first answering call is still being written (Claude
|
|
80
|
+
* `stop_reason: null`); values may change.
|
|
81
|
+
* - `final`: the first answering call is complete, or the prompt was closed
|
|
82
|
+
* without one. Missing usage is then unknown, not zero.
|
|
83
|
+
*/
|
|
84
|
+
export type PromptHistoryMetadataStatus = 'pending' | 'provisional' | 'final';
|
|
50
85
|
export interface PromptHistoryUsage {
|
|
51
86
|
/** Uncached input tokens. */
|
|
52
87
|
inputTokens?: number;
|
|
@@ -70,6 +105,12 @@ export interface PromptHistoryEntry {
|
|
|
70
105
|
model?: string;
|
|
71
106
|
/** Usage of that first answering model call, when known. */
|
|
72
107
|
usage?: PromptHistoryUsage;
|
|
108
|
+
/**
|
|
109
|
+
* Whether `model` / `usage` can still change. A prompt that is not `final`
|
|
110
|
+
* is returned again, with the same provider, sessionId, ordinal, timestamp,
|
|
111
|
+
* and text, by a later call once its metadata or status changes.
|
|
112
|
+
*/
|
|
113
|
+
metadataStatus: PromptHistoryMetadataStatus;
|
|
73
114
|
}
|
|
74
115
|
export interface PromptHistoryStats {
|
|
75
116
|
sessionsScanned: number;
|
package/dist/promptHistory.js
CHANGED
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
* for Codex, its session/turn context) realpath-resolves to an allowed root
|
|
9
9
|
* or somewhere inside one. Every bound stops cleanly and is reported in
|
|
10
10
|
* `boundsHit`; nothing is silently truncated and no bound throws.
|
|
11
|
+
*
|
|
12
|
+
* A prompt can be written before its answer. The cursor then keeps the byte
|
|
13
|
+
* offset of that prompt's line (not its text), the next call replays from it,
|
|
14
|
+
* and the prompt is returned again under the same identity once its first
|
|
15
|
+
* answering call brings model or usage (`metadataStatus` says how settled).
|
|
11
16
|
*/
|
|
12
17
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
13
18
|
if (k2 === undefined) k2 = k;
|
|
@@ -73,8 +78,8 @@ async function collectPromptHistory(options) {
|
|
|
73
78
|
promptsMissingTimestamp: 0,
|
|
74
79
|
promptsOutOfScope: 0,
|
|
75
80
|
};
|
|
76
|
-
const previous = options.cursor
|
|
77
|
-
const cursor = { version:
|
|
81
|
+
const previous = acceptedCursorSessions(options.cursor);
|
|
82
|
+
const cursor = { version: 2, sessions: { ...previous } };
|
|
78
83
|
const entries = [];
|
|
79
84
|
const shouldStop = () => {
|
|
80
85
|
if (options.signal?.aborted)
|
|
@@ -118,8 +123,9 @@ async function collectPromptHistory(options) {
|
|
|
118
123
|
}
|
|
119
124
|
// A file that shrank was rewritten; its old offsets and ordinals are meaningless.
|
|
120
125
|
const resume = prior && prior.offset <= stat.size ? prior : undefined;
|
|
121
|
-
|
|
122
|
-
|
|
126
|
+
// Replay from the open prompt's line so late answer metadata can reach it.
|
|
127
|
+
let replay = validPending(resume);
|
|
128
|
+
let offset = replay?.offset ?? resume?.offset ?? 0;
|
|
123
129
|
if (resume?.state?.excluded) {
|
|
124
130
|
cursor.sessions[candidate.key] = {
|
|
125
131
|
...resume,
|
|
@@ -130,6 +136,12 @@ async function collectPromptHistory(options) {
|
|
|
130
136
|
stats.sessionsNotInteractive++;
|
|
131
137
|
continue;
|
|
132
138
|
}
|
|
139
|
+
if (replay && resume && stat.size - offset > bounds.maxFileBytes) {
|
|
140
|
+
// Too far to replay: stop waiting on that prompt rather than stall the file.
|
|
141
|
+
replay = undefined;
|
|
142
|
+
offset = resume.offset;
|
|
143
|
+
}
|
|
144
|
+
const bytesToRead = stat.size - offset;
|
|
133
145
|
if (bytesToRead > bounds.maxFileBytes) {
|
|
134
146
|
stats.filesOverSizeLimit++;
|
|
135
147
|
boundsHit.add('maxFileBytes');
|
|
@@ -152,7 +164,7 @@ async function collectPromptHistory(options) {
|
|
|
152
164
|
}
|
|
153
165
|
totalBytes += buffer.length;
|
|
154
166
|
stats.sessionsScanned++;
|
|
155
|
-
const outcome = await scanBuffer(candidate, buffer, offset, resume, context);
|
|
167
|
+
const outcome = await scanBuffer(candidate, buffer, offset, { resume, replay }, context);
|
|
156
168
|
if (outcome.stoppedBy) {
|
|
157
169
|
// Partially processed file: drop its entries and leave its cursor untouched.
|
|
158
170
|
boundsHit.add(outcome.stoppedBy);
|
|
@@ -162,7 +174,13 @@ async function collectPromptHistory(options) {
|
|
|
162
174
|
stats.sessionsNotInteractive++;
|
|
163
175
|
else if (outcome.inScope === 0 && outcome.outOfScope > 0)
|
|
164
176
|
stats.sessionsOutOfScope++;
|
|
165
|
-
|
|
177
|
+
for (const entry of outcome.entries) {
|
|
178
|
+
// A replayed prompt is returned again only when its metadata changed.
|
|
179
|
+
if (replay && entry.ordinal === replay.ordinal && metadataDigest(entry) === replay.emitted) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
entries.push(entry);
|
|
183
|
+
}
|
|
166
184
|
cursor.sessions[candidate.key] = {
|
|
167
185
|
provider: candidate.provider,
|
|
168
186
|
sessionId: candidate.sessionId,
|
|
@@ -171,6 +189,7 @@ async function collectPromptHistory(options) {
|
|
|
171
189
|
lastOrdinal: outcome.lastOrdinal,
|
|
172
190
|
offset: outcome.offset,
|
|
173
191
|
...(outcome.state ? { state: outcome.state } : {}),
|
|
192
|
+
...(outcome.pending ? { pending: outcome.pending } : {}),
|
|
174
193
|
};
|
|
175
194
|
await yieldToEventLoop();
|
|
176
195
|
}
|
|
@@ -187,6 +206,41 @@ function definedBounds(bounds) {
|
|
|
187
206
|
}
|
|
188
207
|
return result;
|
|
189
208
|
}
|
|
209
|
+
function acceptedCursorSessions(cursor) {
|
|
210
|
+
if (!cursor || (cursor.version !== 1 && cursor.version !== 2))
|
|
211
|
+
return {};
|
|
212
|
+
const sessions = cursor.sessions;
|
|
213
|
+
return typeof sessions === 'object' && sessions !== null
|
|
214
|
+
? sessions
|
|
215
|
+
: {};
|
|
216
|
+
}
|
|
217
|
+
function validPending(resume) {
|
|
218
|
+
const pending = resume?.pending;
|
|
219
|
+
if (!resume || !pending)
|
|
220
|
+
return undefined;
|
|
221
|
+
const valid = Number.isInteger(pending.ordinal) &&
|
|
222
|
+
pending.ordinal === resume.lastOrdinal &&
|
|
223
|
+
Number.isInteger(pending.offset) &&
|
|
224
|
+
pending.offset >= 0 &&
|
|
225
|
+
pending.offset <= resume.offset &&
|
|
226
|
+
typeof pending.emitted === 'string';
|
|
227
|
+
return valid ? pending : undefined;
|
|
228
|
+
}
|
|
229
|
+
/** Digest of what a consumer would store for an entry besides its identity and text. */
|
|
230
|
+
function metadataDigest(entry) {
|
|
231
|
+
const usage = entry.usage
|
|
232
|
+
? [
|
|
233
|
+
entry.usage.inputTokens ?? null,
|
|
234
|
+
entry.usage.outputTokens ?? null,
|
|
235
|
+
entry.usage.cacheReadTokens ?? null,
|
|
236
|
+
entry.usage.cacheWriteTokens ?? null,
|
|
237
|
+
]
|
|
238
|
+
: null;
|
|
239
|
+
return (0, crypto_1.createHash)('sha256')
|
|
240
|
+
.update(JSON.stringify([entry.model ?? null, usage, entry.metadataStatus]))
|
|
241
|
+
.digest('hex')
|
|
242
|
+
.slice(0, 16);
|
|
243
|
+
}
|
|
190
244
|
function compareEntries(left, right) {
|
|
191
245
|
return (compareStrings(left.provider, right.provider) ||
|
|
192
246
|
compareStrings(left.sessionId, right.sessionId) ||
|
|
@@ -311,21 +365,24 @@ async function readRange(filePath, offset, length) {
|
|
|
311
365
|
function yieldToEventLoop() {
|
|
312
366
|
return new Promise((resolve) => setImmediate(resolve));
|
|
313
367
|
}
|
|
314
|
-
async function scanBuffer(candidate, buffer, startOffset,
|
|
368
|
+
async function scanBuffer(candidate, buffer, startOffset, start, context) {
|
|
315
369
|
const lastNewline = buffer.lastIndexOf(0x0a);
|
|
316
|
-
const
|
|
317
|
-
const lines = complete.toString('utf8').split('\n');
|
|
370
|
+
const completeLength = lastNewline + 1;
|
|
318
371
|
const scanner = candidate.provider === 'claude-code'
|
|
319
|
-
? new ClaudeLineScanner(candidate,
|
|
320
|
-
: new CodexLineScanner(candidate,
|
|
321
|
-
|
|
372
|
+
? new ClaudeLineScanner(candidate, start, context)
|
|
373
|
+
: new CodexLineScanner(candidate, start, context);
|
|
374
|
+
let lineStart = 0;
|
|
375
|
+
for (let index = 0; lineStart < completeLength; index++) {
|
|
322
376
|
if (index > 0 && index % CHECK_EVERY_LINES === 0) {
|
|
323
377
|
await yieldToEventLoop();
|
|
324
378
|
const stop = context.shouldStop();
|
|
325
379
|
if (stop)
|
|
326
380
|
return { ...scanner.outcome(startOffset), stoppedBy: stop };
|
|
327
381
|
}
|
|
328
|
-
const
|
|
382
|
+
const lineEnd = buffer.indexOf(0x0a, lineStart);
|
|
383
|
+
const line = buffer.toString('utf8', lineStart, lineEnd);
|
|
384
|
+
const lineOffset = startOffset + lineStart;
|
|
385
|
+
lineStart = lineEnd + 1;
|
|
329
386
|
if (!line.trim())
|
|
330
387
|
continue;
|
|
331
388
|
let record;
|
|
@@ -337,17 +394,18 @@ async function scanBuffer(candidate, buffer, startOffset, resume, context) {
|
|
|
337
394
|
}
|
|
338
395
|
if (typeof record !== 'object' || record === null)
|
|
339
396
|
continue;
|
|
340
|
-
if (scanner.accept(record) === 'excluded') {
|
|
397
|
+
if (scanner.accept(record, lineOffset) === 'excluded') {
|
|
341
398
|
// Non-interactive Codex session: nothing in it will ever count.
|
|
342
399
|
return {
|
|
343
400
|
...scanner.outcome(startOffset + buffer.length),
|
|
344
401
|
entries: [],
|
|
402
|
+
pending: undefined,
|
|
345
403
|
notInteractive: true,
|
|
346
404
|
stoppedBy: null,
|
|
347
405
|
};
|
|
348
406
|
}
|
|
349
407
|
}
|
|
350
|
-
return { ...scanner.outcome(startOffset +
|
|
408
|
+
return { ...scanner.outcome(startOffset + completeLength), stoppedBy: null };
|
|
351
409
|
}
|
|
352
410
|
/** Shared per-file prompt bookkeeping for both providers. */
|
|
353
411
|
class LineScanner {
|
|
@@ -357,31 +415,49 @@ class LineScanner {
|
|
|
357
415
|
nextOrdinal;
|
|
358
416
|
inScope = 0;
|
|
359
417
|
outOfScope = 0;
|
|
360
|
-
|
|
361
|
-
|
|
418
|
+
/** Latest emitted prompt still collecting answer metadata. */
|
|
419
|
+
open = null;
|
|
420
|
+
constructor(candidate, start, context) {
|
|
362
421
|
this.candidate = candidate;
|
|
363
422
|
this.context = context;
|
|
364
|
-
this.nextOrdinal = (resume?.lastOrdinal ?? -1) + 1;
|
|
423
|
+
this.nextOrdinal = start.replay ? start.replay.ordinal : (start.resume?.lastOrdinal ?? -1) + 1;
|
|
365
424
|
}
|
|
366
425
|
state() {
|
|
367
426
|
return undefined;
|
|
368
427
|
}
|
|
369
428
|
outcome(offset) {
|
|
370
429
|
const state = this.state();
|
|
430
|
+
const open = this.open;
|
|
371
431
|
return {
|
|
372
432
|
entries: this.entries,
|
|
373
433
|
lastOrdinal: this.nextOrdinal - 1,
|
|
374
434
|
offset,
|
|
375
435
|
...(state ? { state } : {}),
|
|
436
|
+
...(open
|
|
437
|
+
? {
|
|
438
|
+
pending: {
|
|
439
|
+
ordinal: open.entry.ordinal,
|
|
440
|
+
offset: open.lineOffset,
|
|
441
|
+
emitted: metadataDigest(open.entry),
|
|
442
|
+
...(open.stateBefore ? { state: open.stateBefore } : {}),
|
|
443
|
+
},
|
|
444
|
+
}
|
|
445
|
+
: {}),
|
|
376
446
|
inScope: this.inScope,
|
|
377
447
|
outOfScope: this.outOfScope,
|
|
378
448
|
notInteractive: false,
|
|
379
449
|
};
|
|
380
450
|
}
|
|
451
|
+
/** The open prompt's answer is complete, or will never arrive. */
|
|
452
|
+
closeOpen() {
|
|
453
|
+
if (this.open)
|
|
454
|
+
this.open.entry.metadataStatus = 'final';
|
|
455
|
+
this.open = null;
|
|
456
|
+
}
|
|
381
457
|
/** Assign an ordinal to a human prompt and emit it when in scope. */
|
|
382
|
-
recordPrompt(text, timestamp, cwd, gitBranch) {
|
|
458
|
+
recordPrompt(text, timestamp, cwd, gitBranch, lineOffset, stateBefore) {
|
|
383
459
|
const ordinal = this.nextOrdinal++;
|
|
384
|
-
this.
|
|
460
|
+
this.closeOpen();
|
|
385
461
|
if (typeof timestamp !== 'string' || Number.isNaN(Date.parse(timestamp))) {
|
|
386
462
|
this.context.stats.promptsMissingTimestamp++;
|
|
387
463
|
return;
|
|
@@ -403,9 +479,10 @@ class LineScanner {
|
|
|
403
479
|
text,
|
|
404
480
|
cwd: scoped,
|
|
405
481
|
...(gitBranch ? { gitBranch } : {}),
|
|
482
|
+
metadataStatus: 'pending',
|
|
406
483
|
};
|
|
407
484
|
this.entries.push(entry);
|
|
408
|
-
this.
|
|
485
|
+
this.open = { entry, lineOffset, ...(stateBefore ? { stateBefore } : {}) };
|
|
409
486
|
}
|
|
410
487
|
}
|
|
411
488
|
function transcriptMessage(candidate, record, role, content, extra, eventIndex) {
|
|
@@ -446,14 +523,15 @@ function compactUsage(usage) {
|
|
|
446
523
|
class ClaudeLineScanner extends LineScanner {
|
|
447
524
|
lineIndex = 0;
|
|
448
525
|
answerId = null;
|
|
449
|
-
accept(record) {
|
|
526
|
+
accept(record, lineOffset) {
|
|
450
527
|
const index = this.lineIndex++;
|
|
451
528
|
const message = record.message;
|
|
452
529
|
if (typeof message !== 'object' || message === null)
|
|
453
530
|
return 'ok';
|
|
454
531
|
if (record.type === 'user') {
|
|
455
532
|
const origin = record.origin;
|
|
456
|
-
const
|
|
533
|
+
const blocks = claudeBlocks(message.content);
|
|
534
|
+
const canonical = transcriptMessage(this.candidate, record, typeof message.role === 'string' ? message.role : 'user', blocks, {
|
|
457
535
|
entrypoint: stringField(record.entrypoint),
|
|
458
536
|
isMeta: record.isMeta === true ? true : undefined,
|
|
459
537
|
isSidechain: record.isSidechain === true ? true : undefined,
|
|
@@ -462,35 +540,54 @@ class ClaudeLineScanner extends LineScanner {
|
|
|
462
540
|
promptSource: stringField(record.promptSource),
|
|
463
541
|
}, index);
|
|
464
542
|
const text = (0, humanPrompt_1.humanPromptText)(canonical, 'claude-code');
|
|
465
|
-
if (text === null)
|
|
543
|
+
if (text === null) {
|
|
544
|
+
// A tool result means the first answering call already finished.
|
|
545
|
+
if (this.answerId !== null &&
|
|
546
|
+
record.isSidechain !== true &&
|
|
547
|
+
blocks.some((block) => block.type === 'tool_result')) {
|
|
548
|
+
this.closeOpen();
|
|
549
|
+
}
|
|
466
550
|
return 'ok';
|
|
551
|
+
}
|
|
467
552
|
this.answerId = null;
|
|
468
|
-
this.recordPrompt(text, record.timestamp, record.cwd, stringField(record.gitBranch));
|
|
553
|
+
this.recordPrompt(text, record.timestamp, record.cwd, stringField(record.gitBranch), lineOffset);
|
|
469
554
|
return 'ok';
|
|
470
555
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
556
|
+
const open = this.open;
|
|
557
|
+
if (record.type !== 'assistant' || !open || record.isSidechain === true)
|
|
558
|
+
return 'ok';
|
|
559
|
+
// Synthetic records (API errors, interrupts) are not model calls.
|
|
560
|
+
if (message.model === '<synthetic>')
|
|
561
|
+
return 'ok';
|
|
562
|
+
const id = stringField(message.id) ?? `line:${index}`;
|
|
563
|
+
if (this.answerId === null)
|
|
564
|
+
this.answerId = id;
|
|
565
|
+
if (id !== this.answerId) {
|
|
566
|
+
// A later tool-loop call: the first answering call is over and keeps its attribution.
|
|
567
|
+
this.closeOpen();
|
|
568
|
+
return 'ok';
|
|
569
|
+
}
|
|
570
|
+
const entry = open.entry;
|
|
571
|
+
const model = stringField(message.model);
|
|
572
|
+
if (model)
|
|
573
|
+
entry.model = model;
|
|
574
|
+
const usage = message.usage;
|
|
575
|
+
if (usage && typeof usage === 'object') {
|
|
576
|
+
// Split records of one call repeat its usage; the latest replaces, never adds.
|
|
577
|
+
const mapped = compactUsage({
|
|
578
|
+
inputTokens: tokenCount(usage.input_tokens),
|
|
579
|
+
outputTokens: tokenCount(usage.output_tokens),
|
|
580
|
+
cacheReadTokens: tokenCount(usage.cache_read_input_tokens),
|
|
581
|
+
cacheWriteTokens: tokenCount(usage.cache_creation_input_tokens),
|
|
582
|
+
});
|
|
583
|
+
if (mapped)
|
|
584
|
+
entry.usage = mapped;
|
|
493
585
|
}
|
|
586
|
+
// Claude Code writes the call's final stop_reason and usage on every split record.
|
|
587
|
+
if (stringField(message.stop_reason))
|
|
588
|
+
this.closeOpen();
|
|
589
|
+
else
|
|
590
|
+
entry.metadataStatus = 'provisional';
|
|
494
591
|
return 'ok';
|
|
495
592
|
}
|
|
496
593
|
}
|
|
@@ -522,14 +619,16 @@ function claudeBlocks(content) {
|
|
|
522
619
|
class CodexLineScanner extends LineScanner {
|
|
523
620
|
lineIndex = 0;
|
|
524
621
|
resumeState;
|
|
525
|
-
constructor(candidate,
|
|
526
|
-
super(candidate,
|
|
527
|
-
|
|
622
|
+
constructor(candidate, start, context) {
|
|
623
|
+
super(candidate, start, context);
|
|
624
|
+
// A replay restarts at the open prompt's line, so it needs the state from just before it.
|
|
625
|
+
const initial = start.replay ? start.replay.state : start.resume?.state;
|
|
626
|
+
this.resumeState = { ...(initial ?? {}) };
|
|
528
627
|
}
|
|
529
628
|
state() {
|
|
530
629
|
return { ...this.resumeState };
|
|
531
630
|
}
|
|
532
|
-
accept(record) {
|
|
631
|
+
accept(record, lineOffset) {
|
|
533
632
|
const index = this.lineIndex++;
|
|
534
633
|
const payload = record.payload;
|
|
535
634
|
if (typeof payload !== 'object' || payload === null)
|
|
@@ -569,10 +668,14 @@ class CodexLineScanner extends LineScanner {
|
|
|
569
668
|
const text = (0, humanPrompt_1.humanPromptText)(canonical, 'codex');
|
|
570
669
|
if (text === null)
|
|
571
670
|
return 'ok';
|
|
572
|
-
this.recordPrompt(text, record.timestamp, state.cwd, state.gitBranch
|
|
671
|
+
this.recordPrompt(text, record.timestamp, state.cwd, state.gitBranch, lineOffset, {
|
|
672
|
+
...state,
|
|
673
|
+
});
|
|
573
674
|
return 'ok';
|
|
574
675
|
}
|
|
575
|
-
|
|
676
|
+
const open = this.open;
|
|
677
|
+
if (record.type === 'event_msg' && payload.type === 'token_count' && open) {
|
|
678
|
+
// The first token_count after the prompt is the first answering call, complete.
|
|
576
679
|
const info = payload.info;
|
|
577
680
|
const last = info && typeof info === 'object' ? info.last_token_usage : undefined;
|
|
578
681
|
if (!last || typeof last !== 'object')
|
|
@@ -586,10 +689,10 @@ class CodexLineScanner extends LineScanner {
|
|
|
586
689
|
cacheWriteTokens: tokenCount(last.cache_write_input_tokens),
|
|
587
690
|
});
|
|
588
691
|
if (mapped)
|
|
589
|
-
|
|
692
|
+
open.entry.usage = mapped;
|
|
590
693
|
if (state.model)
|
|
591
|
-
|
|
592
|
-
this.
|
|
694
|
+
open.entry.model = state.model;
|
|
695
|
+
this.closeOpen();
|
|
593
696
|
}
|
|
594
697
|
return 'ok';
|
|
595
698
|
}
|