sidekick-shared 0.27.6 → 0.28.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 +34 -0
- package/README.md +59 -18
- package/dist/browser.d.ts +13 -2
- package/dist/browser.js +30 -2
- package/dist/claudePaths.d.ts +11 -0
- package/dist/claudePaths.js +61 -0
- package/dist/claudeProfiles.d.ts +2 -9
- package/dist/claudeProfiles.js +10 -25
- package/dist/codexPaths.d.ts +22 -0
- package/dist/codexPaths.js +67 -0
- package/dist/codexProfiles.d.ts +1 -4
- package/dist/codexProfiles.js +5 -43
- package/dist/doctor.js +2 -2
- package/dist/esm/accountChangeSignal.js +15 -0
- package/dist/esm/accountChanges.js +152 -0
- package/dist/esm/accountHealth.js +288 -0
- package/dist/esm/accountLaunch.js +145 -0
- package/dist/esm/accountLease.js +60 -0
- package/dist/esm/accountManager.js +606 -0
- package/dist/esm/accountRegistry.js +214 -0
- package/dist/esm/accountStatus.js +51 -0
- package/dist/esm/accountSwitch.js +74 -0
- package/dist/esm/accountSync.js +156 -0
- package/dist/esm/accountSyncTypes.js +4 -0
- package/dist/esm/accounts.js +1291 -0
- package/dist/esm/aggregation/EventAggregator.js +2034 -0
- package/dist/esm/aggregation/FrequencyTracker.js +69 -0
- package/dist/esm/aggregation/HeatmapTracker.js +89 -0
- package/dist/esm/aggregation/PatternExtractor.js +168 -0
- package/dist/esm/aggregation/costProvenance.js +31 -0
- package/dist/esm/aggregation/snapshot.js +161 -0
- package/dist/esm/aggregation/types.js +5 -0
- package/dist/esm/analytics/codeImpact.js +18 -0
- package/dist/esm/analytics/compactionLedger.js +37 -0
- package/dist/esm/analytics/qualityScore.js +112 -0
- package/dist/esm/autoSwitch.js +185 -0
- package/dist/esm/browser.js +34 -0
- package/dist/esm/claudeCredentialDoctor.js +239 -0
- package/dist/esm/claudeCredentialLedger.js +91 -0
- package/dist/esm/claudeCredentials.js +111 -0
- package/dist/esm/claudeLiveGuard.js +43 -0
- package/dist/esm/claudePaths.js +22 -0
- package/dist/esm/claudeProfiles.js +142 -0
- package/dist/esm/claudeRefreshLock.js +106 -0
- package/dist/esm/codexAuth.js +101 -0
- package/dist/esm/codexCredentialDoctor.js +265 -0
- package/dist/esm/codexCredentialStore.js +164 -0
- package/dist/esm/codexKeepAlive.js +112 -0
- package/dist/esm/codexPaths.js +172 -0
- package/dist/esm/codexProfiles.js +1251 -0
- package/dist/esm/codexQuota.js +564 -0
- package/dist/esm/codexQuotaWatcher.js +363 -0
- package/dist/esm/context/composer.js +71 -0
- package/dist/esm/context/sessionContext.js +546 -0
- package/dist/esm/credentialIO.js +283 -0
- package/dist/esm/credentials.js +49 -0
- package/dist/esm/doctor.js +305 -0
- package/dist/esm/ensureDefaultAccounts.js +62 -0
- package/dist/esm/errorHistory.js +56 -0
- package/dist/esm/extractors/errorTaxonomy.js +43 -0
- package/dist/esm/extractors/gatherAssets.js +32 -0
- package/dist/esm/extractors/sessionAssets.js +176 -0
- package/dist/esm/extractors/sources/claudeAssets.js +158 -0
- package/dist/esm/extractors/sources/codexAssets.js +234 -0
- package/dist/esm/extractors/tokenUsage.js +41 -0
- package/dist/esm/extractors/toolCall.js +142 -0
- package/dist/esm/failingTools.js +65 -0
- package/dist/esm/fileHead.js +27 -0
- package/dist/esm/formatters/eventHighlighter.js +332 -0
- package/dist/esm/formatters/noiseClassifier.js +209 -0
- package/dist/esm/formatters/sessionDump.js +328 -0
- package/dist/esm/formatters/toolSummary.js +226 -0
- package/dist/esm/formatting.js +82 -0
- package/dist/esm/handoffUrl.js +23 -0
- package/dist/esm/historicalStore.js +305 -0
- package/dist/esm/humanPrompt.js +88 -0
- package/dist/esm/index.js +213 -0
- package/dist/esm/keepAlive.js +189 -0
- package/dist/esm/liveCredentialGuard.js +33 -0
- package/dist/esm/modelAliases.js +51 -0
- package/dist/esm/modelCatalog.js +139 -0
- package/dist/esm/modelContext.js +280 -0
- package/dist/esm/modelInfo.js +832 -0
- package/dist/esm/multiProviderQuotaService.js +450 -0
- package/dist/esm/node.js +46 -0
- package/dist/esm/observedContextWindows.js +165 -0
- package/dist/esm/observedSessionCollector.js +939 -0
- package/dist/esm/parsers/changelogParser.js +71 -0
- package/dist/esm/parsers/codexHistory.js +89 -0
- package/dist/esm/parsers/codexParser.js +906 -0
- package/dist/esm/parsers/debugLogParser.js +126 -0
- package/dist/esm/parsers/jsonl.js +66 -0
- package/dist/esm/parsers/mcpToolName.js +28 -0
- package/dist/esm/parsers/openCodeParser.js +625 -0
- package/dist/esm/parsers/planExtractor.js +335 -0
- package/dist/esm/parsers/sessionActivityDetector.js +246 -0
- package/dist/esm/parsers/sessionPathResolver.js +713 -0
- package/dist/esm/parsers/subagentScanner.js +360 -0
- package/dist/esm/parsers/subagentStatsCache.js +45 -0
- package/dist/esm/parsers/subagentTraceParser.js +325 -0
- package/dist/esm/paths.js +134 -0
- package/dist/esm/peakHours.js +194 -0
- package/dist/esm/phrases.js +1330 -0
- package/dist/esm/pricingCatalog.js +247 -0
- package/dist/esm/processDetection.js +200 -0
- package/dist/esm/projectMigration.js +59 -0
- package/dist/esm/promptHistory.js +1221 -0
- package/dist/esm/providerFailure.js +362 -0
- package/dist/esm/providerIds.js +6 -0
- package/dist/esm/providerQuota.js +1 -0
- package/dist/esm/providerServiceStatus.js +145 -0
- package/dist/esm/providerServiceStatusTypes.js +1 -0
- package/dist/esm/providerStatus.js +117 -0
- package/dist/esm/providers/claudeCode.js +575 -0
- package/dist/esm/providers/claudeListing.js +121 -0
- package/dist/esm/providers/codex.js +1034 -0
- package/dist/esm/providers/codexDatabase.js +297 -0
- package/dist/esm/providers/codexRollouts.js +215 -0
- package/dist/esm/providers/detect.js +185 -0
- package/dist/esm/providers/diagnostics.js +83 -0
- package/dist/esm/providers/directoryListingCache.js +206 -0
- package/dist/esm/providers/factory.js +54 -0
- package/dist/esm/providers/openCode.js +2008 -0
- package/dist/esm/providers/openCodeDatabase.js +419 -0
- package/dist/esm/providers/rolloutWalker.js +252 -0
- package/dist/esm/providers/types.js +12 -0
- package/dist/esm/quota.js +182 -0
- package/dist/esm/quotaHistory.js +364 -0
- package/dist/esm/quotaPoller.js +230 -0
- package/dist/esm/quotaPresentation.js +97 -0
- package/dist/esm/quotaResolver.js +231 -0
- package/dist/esm/quotaSnapshots.js +130 -0
- package/dist/esm/quotaThresholds.js +85 -0
- package/dist/esm/readers/decisions.js +24 -0
- package/dist/esm/readers/handoff.js +20 -0
- package/dist/esm/readers/helpers.js +27 -0
- package/dist/esm/readers/history.js +9 -0
- package/dist/esm/readers/notes.js +48 -0
- package/dist/esm/readers/plans.js +225 -0
- package/dist/esm/readers/tasks.js +19 -0
- package/dist/esm/report/htmlHelpers.js +280 -0
- package/dist/esm/report/htmlReportGenerator.js +704 -0
- package/dist/esm/report/index.js +7 -0
- package/dist/esm/report/logo.js +2 -0
- package/dist/esm/report/openBrowser.js +19 -0
- package/dist/esm/report/sessionReportInputs.js +33 -0
- package/dist/esm/report/transcriptParser.js +83 -0
- package/dist/esm/report/types.js +4 -0
- package/dist/esm/schemas/accountManager.js +204 -0
- package/dist/esm/schemas/accountStatus.js +25 -0
- package/dist/esm/schemas/assistantTurn.js +74 -0
- package/dist/esm/schemas/index.js +25 -0
- package/dist/esm/schemas/observedSessionV1.js +77 -0
- package/dist/esm/schemas/providerFailure.js +37 -0
- package/dist/esm/schemas/quota.js +132 -0
- package/dist/esm/schemas/quotaHistory.js +40 -0
- package/dist/esm/schemas/sessionEvent.js +216 -0
- package/dist/esm/schemas/stateFile.js +75 -0
- package/dist/esm/schemas/tokenEstimation.js +7 -0
- package/dist/esm/schemas/transcript.js +86 -0
- package/dist/esm/schemas/usageNormalization.js +38 -0
- package/dist/esm/search/advancedFilter.js +197 -0
- package/dist/esm/search/sessionSearch.js +66 -0
- package/dist/esm/sessionFingerprint.js +40 -0
- package/dist/esm/sessionMonitor.js +198 -0
- package/dist/esm/sessionPreviews.js +522 -0
- package/dist/esm/sessionPromptHistory.js +254 -0
- package/dist/esm/sessionPromptSignals.js +364 -0
- package/dist/esm/sessionStats.js +160 -0
- package/dist/esm/sessionTokenTotals.js +98 -0
- package/dist/esm/sessionTranscripts.js +49 -0
- package/dist/esm/stateFile.js +119 -0
- package/dist/esm/statusline/BurnRateCalculator.js +40 -0
- package/dist/esm/statusline/claudeStatuslinePayload.js +148 -0
- package/dist/esm/statusline/formatter.js +122 -0
- package/dist/esm/statusline/index.js +8 -0
- package/dist/esm/terminalSync.js +95 -0
- package/dist/esm/testing/credentialGuard.js +222 -0
- package/dist/esm/testing/index.js +12 -0
- package/dist/esm/testing/sessionLogFixtures.js +128 -0
- package/dist/esm/tokenEstimation.js +67 -0
- package/dist/esm/tokenSummary.js +113 -0
- package/dist/esm/toolCategory.js +82 -0
- package/dist/esm/transcript.js +257 -0
- package/dist/esm/turns/assistantTurn.js +373 -0
- package/dist/esm/types/codex.js +23 -0
- package/dist/esm/types/decisionLog.js +5 -0
- package/dist/esm/types/historicalData.js +47 -0
- package/dist/esm/types/knowledgeNote.js +15 -0
- package/dist/esm/types/observedSessionV1.js +257 -0
- package/dist/esm/types/opencode.js +12 -0
- package/dist/esm/types/plan.js +7 -0
- package/dist/esm/types/sessionEvent.js +10 -0
- package/dist/esm/types/taskPersistence.js +12 -0
- package/dist/esm/usage/billingBlocks.js +148 -0
- package/dist/esm/usage/claudeUsageDedupe.js +189 -0
- package/dist/esm/usage/importSessionHistory.js +84 -0
- package/dist/esm/usage/subagentUsage.js +18 -0
- package/dist/esm/usage/usageEvents.js +402 -0
- package/dist/esm/usage/usageReports.js +202 -0
- package/dist/esm/usageNormalization.js +95 -0
- package/dist/esm/watchers/eventBridge.js +219 -0
- package/dist/esm/watchers/factory.js +63 -0
- package/dist/esm/watchers/jsonlTail.js +289 -0
- package/dist/esm/watchers/jsonlWatcher.js +435 -0
- package/dist/esm/watchers/providerReaderWatcher.js +118 -0
- package/dist/esm/watchers/sqliteWatcher.js +285 -0
- package/dist/esm/watchers/types.js +4 -0
- package/dist/esm/workspaceRoots.js +56 -0
- package/dist/esm/writers/atomic.js +375 -0
- package/dist/esm/writers/decisions.js +51 -0
- package/dist/esm/writers/notes.js +46 -0
- package/dist/esm/writers/tasks.js +63 -0
- package/dist/esm/zaiQuota.js +323 -0
- package/dist/esm/zaiQuotaApi.js +271 -0
- package/dist/esm/zaiQuotaWatcher.js +265 -0
- package/dist/fileHead.d.ts +22 -0
- package/dist/fileHead.js +33 -0
- package/dist/index.d.ts +16 -7
- package/dist/index.js +49 -15
- package/dist/node.d.ts +13 -1
- package/dist/node.js +43 -2
- package/dist/parsers/codexHistory.js +2 -2
- package/dist/parsers/codexParser.d.ts +12 -0
- package/dist/parsers/codexParser.js +34 -12
- package/dist/parsers/debugLogParser.js +2 -2
- package/dist/parsers/sessionPathResolver.js +5 -4
- package/dist/promptHistory.d.ts +32 -0
- package/dist/promptHistory.js +115 -59
- package/dist/providerFailure.d.ts +12 -6
- package/dist/providerFailure.js +45 -0
- package/dist/providers/claudeCode.js +7 -99
- package/dist/providers/claudeListing.d.ts +22 -0
- package/dist/providers/claudeListing.js +158 -0
- package/dist/providers/codex.d.ts +3 -1
- package/dist/providers/codex.js +26 -227
- package/dist/providers/codexRollouts.d.ts +44 -0
- package/dist/providers/codexRollouts.js +261 -0
- package/dist/providers/detect.js +4 -4
- package/dist/readers/plans.js +3 -3
- package/dist/schemas/index.d.ts +2 -0
- package/dist/schemas/index.js +7 -2
- package/dist/schemas/providerFailure.d.ts +154 -0
- package/dist/schemas/providerFailure.js +40 -0
- package/dist/sessionPromptHistory.d.ts +12 -7
- package/dist/sessionPromptHistory.js +15 -3
- package/dist/sessionPromptSignals.d.ts +9 -0
- package/dist/sessionPromptSignals.js +13 -0
- package/dist/testing/credentialGuard.d.ts +23 -0
- package/dist/testing/credentialGuard.js +258 -0
- package/dist/testing/index.d.ts +14 -0
- package/dist/testing/index.js +22 -0
- package/dist/testing/sessionLogFixtures.d.ts +28 -0
- package/dist/testing/sessionLogFixtures.js +169 -0
- package/dist/tokenEstimation.d.ts +2 -0
- package/dist/tokenEstimation.js +21 -11
- package/dist/toolCategory.d.ts +18 -0
- package/dist/toolCategory.js +86 -0
- package/dist/types/codex.d.ts +4 -0
- package/dist/usage/usageEvents.d.ts +16 -0
- package/dist/usage/usageEvents.js +53 -9
- package/dist/watchers/jsonlTail.d.ts +26 -1
- package/dist/watchers/jsonlTail.js +136 -28
- package/dist/watchers/jsonlWatcher.js +7 -0
- package/dist/watchers/providerReaderWatcher.js +1 -0
- package/dist/watchers/types.d.ts +6 -0
- package/dist/workspaceRoots.d.ts +23 -0
- package/dist/workspaceRoots.js +93 -0
- package/package.json +22 -2
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only collection of human-typed prompts from Claude Code and Codex
|
|
3
|
+
* session logs, scoped to a set of workspace roots.
|
|
4
|
+
*
|
|
5
|
+
* Parse-only: no redaction and no network I/O. Scope fails closed — a prompt
|
|
6
|
+
* is returned only when the working directory recorded on its log line (or,
|
|
7
|
+
* for Codex, its session/turn context) realpath-resolves to an allowed root
|
|
8
|
+
* or somewhere inside one. Every bound stops cleanly and is reported in
|
|
9
|
+
* `boundsHit`; nothing is silently truncated and no bound throws.
|
|
10
|
+
*
|
|
11
|
+
* Files are streamed in fixed-size chunks and every pass stops at a complete
|
|
12
|
+
* line, so a large or busy log is read across several calls: the cursor holds
|
|
13
|
+
* where each file stopped and the next call continues from there. A single
|
|
14
|
+
* record over `maxRecordBytes` is skipped without being buffered and reported
|
|
15
|
+
* in `exclusions`, the only way a record is permanently left out.
|
|
16
|
+
*
|
|
17
|
+
* A prompt can be written before its answer. The cursor then keeps the byte
|
|
18
|
+
* offset of that prompt's line and its answer-tracking state (never text), and
|
|
19
|
+
* the prompt is returned again under the same identity once its first
|
|
20
|
+
* answering call brings model or usage (`metadataStatus` says how settled).
|
|
21
|
+
*/
|
|
22
|
+
import { createHash } from 'crypto';
|
|
23
|
+
import * as fs from 'fs';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import { HUMAN_CODEX_SOURCES, humanPromptText } from './humanPrompt';
|
|
26
|
+
import { listClaudeSessionFilesAsync } from './providers/claudeListing';
|
|
27
|
+
import { listCodexSessionFilesAsync } from './providers/codexRollouts';
|
|
28
|
+
import { DirectoryListingCache } from './providers/directoryListingCache';
|
|
29
|
+
import { realpathOrNull, resolveWorkspaceRoots } from './workspaceRoots';
|
|
30
|
+
import { fileHeadOf, HEAD_BYTES, headMatches } from './fileHead';
|
|
31
|
+
/** @internal */
|
|
32
|
+
export const DEFAULT_BOUNDS = {
|
|
33
|
+
maxSessions: 1000,
|
|
34
|
+
maxFileBytes: 16 * 1024 * 1024,
|
|
35
|
+
maxTotalBytes: 256 * 1024 * 1024,
|
|
36
|
+
maxRecordBytes: 16 * 1024 * 1024,
|
|
37
|
+
maxEntries: 10_000,
|
|
38
|
+
deadlineMs: 30_000,
|
|
39
|
+
};
|
|
40
|
+
const DEFAULT_READ_CHUNK_BYTES = 1024 * 1024;
|
|
41
|
+
const MAX_EXCLUSIONS = 1000;
|
|
42
|
+
const CHECK_EVERY_LINES = 500;
|
|
43
|
+
const ALL_PROVIDERS = ['claude-code', 'codex'];
|
|
44
|
+
let readChunkBytes = DEFAULT_READ_CHUNK_BYTES;
|
|
45
|
+
/** Test hooks; not part of the public entry points. */
|
|
46
|
+
export const __promptHistoryTesting = {
|
|
47
|
+
setReadChunkBytes(bytes) {
|
|
48
|
+
readChunkBytes = bytes === null ? DEFAULT_READ_CHUNK_BYTES : Math.max(1, Math.floor(bytes));
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
/** Collect human-typed prompts from Claude Code and Codex logs under the given roots. */
|
|
52
|
+
export async function collectPromptHistory(options) {
|
|
53
|
+
const bounds = { ...DEFAULT_BOUNDS, ...definedBounds(options.bounds) };
|
|
54
|
+
const startedAt = Date.now();
|
|
55
|
+
const boundsHit = new Set();
|
|
56
|
+
const stats = emptyStats();
|
|
57
|
+
const { sessions: previous, next: previousNext } = acceptedCursor(options.cursor);
|
|
58
|
+
const cursor = { version: 3, sessions: { ...previous } };
|
|
59
|
+
const entries = [];
|
|
60
|
+
const exclusions = [];
|
|
61
|
+
const changed = new Map();
|
|
62
|
+
let hasMore = false;
|
|
63
|
+
let progressed = false;
|
|
64
|
+
let scannedRoots = [];
|
|
65
|
+
const markChanged = (candidate) => {
|
|
66
|
+
changed.set(candidate.key, {
|
|
67
|
+
provider: candidate.provider,
|
|
68
|
+
sessionId: candidate.sessionId,
|
|
69
|
+
sessionKey: candidate.key,
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
const shouldStop = () => {
|
|
73
|
+
if (options.signal?.aborted)
|
|
74
|
+
return 'aborted';
|
|
75
|
+
if (Date.now() - startedAt >= bounds.deadlineMs)
|
|
76
|
+
return 'deadline';
|
|
77
|
+
return null;
|
|
78
|
+
};
|
|
79
|
+
const finish = () => ({
|
|
80
|
+
entries: entries.sort(compareEntries),
|
|
81
|
+
cursor,
|
|
82
|
+
stats,
|
|
83
|
+
boundsHit: [...boundsHit],
|
|
84
|
+
exclusions,
|
|
85
|
+
hasMore,
|
|
86
|
+
changed: [...changed.values()],
|
|
87
|
+
progressed,
|
|
88
|
+
roots: scannedRoots,
|
|
89
|
+
});
|
|
90
|
+
const realpaths = new Map();
|
|
91
|
+
const { roots, listingRoots } = resolveRoots(options.workspacePaths, options.includeWorktrees !== false, realpaths);
|
|
92
|
+
scannedRoots = roots;
|
|
93
|
+
if (roots.length === 0)
|
|
94
|
+
return finish();
|
|
95
|
+
const sinceMs = options.since ? options.since.getTime() : null;
|
|
96
|
+
const providers = options.providers ?? ALL_PROVIDERS;
|
|
97
|
+
const candidates = rotate(await listCandidates(providers, listingRoots, sinceMs), previousNext);
|
|
98
|
+
const moved = movedSessionKeys(previous, candidates);
|
|
99
|
+
const context = {
|
|
100
|
+
roots,
|
|
101
|
+
sinceMs,
|
|
102
|
+
stats,
|
|
103
|
+
realpaths,
|
|
104
|
+
bounds,
|
|
105
|
+
shouldStop,
|
|
106
|
+
entriesSoFar: () => entries.length,
|
|
107
|
+
exclude: (exclusion) => {
|
|
108
|
+
if (exclusions.length < MAX_EXCLUSIONS)
|
|
109
|
+
exclusions.push(exclusion);
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
let totalBytes = 0;
|
|
113
|
+
let scannedAny = false;
|
|
114
|
+
// A call-wide stop remembers where it stopped so the next call starts there.
|
|
115
|
+
const stopAt = (candidate, bound) => {
|
|
116
|
+
boundsHit.add(bound);
|
|
117
|
+
cursor.next = candidate.key;
|
|
118
|
+
hasMore = true;
|
|
119
|
+
};
|
|
120
|
+
for (const candidate of candidates) {
|
|
121
|
+
const stop = shouldStop();
|
|
122
|
+
if (stop) {
|
|
123
|
+
stopAt(candidate, stop);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
if (scannedAny && entries.length >= bounds.maxEntries) {
|
|
127
|
+
stopAt(candidate, 'maxEntries');
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
let stat;
|
|
131
|
+
try {
|
|
132
|
+
stat = await fs.promises.stat(candidate.filePath);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
let prior = validSession(previous[candidate.key]);
|
|
138
|
+
const sessionRef = `${candidate.provider}:${candidate.sessionId}`;
|
|
139
|
+
const movedFrom = prior ? undefined : moved.get(sessionRef);
|
|
140
|
+
const carried = movedFrom ? validSession(previous[movedFrom]) : undefined;
|
|
141
|
+
// The same session under a new path (Codex archives a thread by moving its
|
|
142
|
+
// rollout): carry the entry over, but only when the new file starts with
|
|
143
|
+
// the recorded head. Another file that merely shares the id (an unlisted
|
|
144
|
+
// file is not necessarily gone) keeps its own entry and is read as new.
|
|
145
|
+
if (movedFrom && carried?.head && (await fileStartsWith(candidate.filePath, carried.head))) {
|
|
146
|
+
moved.delete(sessionRef);
|
|
147
|
+
prior = carried;
|
|
148
|
+
delete cursor.sessions[movedFrom];
|
|
149
|
+
cursor.sessions[candidate.key] = carried;
|
|
150
|
+
markChanged(candidate);
|
|
151
|
+
}
|
|
152
|
+
if (prior && isSettled(prior) && prior.size === stat.size && prior.mtimeMs === stat.mtimeMs) {
|
|
153
|
+
stats.sessionsSkippedUnchanged++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (stats.sessionsScanned >= bounds.maxSessions) {
|
|
157
|
+
stopAt(candidate, 'maxSessions');
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
const remainingTotal = bounds.maxTotalBytes - totalBytes;
|
|
161
|
+
if (scannedAny && remainingTotal <= 0) {
|
|
162
|
+
stopAt(candidate, 'maxTotalBytes');
|
|
163
|
+
break;
|
|
164
|
+
}
|
|
165
|
+
const budget = bounds.maxFileBytes <= remainingTotal
|
|
166
|
+
? { bytes: bounds.maxFileBytes, bound: 'maxFileBytes', forceProgress: !scannedAny }
|
|
167
|
+
: {
|
|
168
|
+
bytes: Math.max(0, remainingTotal),
|
|
169
|
+
bound: 'maxTotalBytes',
|
|
170
|
+
forceProgress: !scannedAny,
|
|
171
|
+
};
|
|
172
|
+
stats.sessionsScanned++;
|
|
173
|
+
scannedAny = true;
|
|
174
|
+
let outcome;
|
|
175
|
+
try {
|
|
176
|
+
outcome = await scanFile(candidate, stat, prior, budget, context);
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
totalBytes += outcome.bytesRead;
|
|
182
|
+
for (const entry of outcome.entries)
|
|
183
|
+
entry.sessionKey = candidate.key;
|
|
184
|
+
entries.push(...outcome.entries);
|
|
185
|
+
if (outcome.progressed)
|
|
186
|
+
progressed = true;
|
|
187
|
+
if (!outcome.session ||
|
|
188
|
+
JSON.stringify(outcome.session) !== JSON.stringify(previous[candidate.key])) {
|
|
189
|
+
markChanged(candidate);
|
|
190
|
+
}
|
|
191
|
+
if (outcome.notInteractive)
|
|
192
|
+
stats.sessionsNotInteractive++;
|
|
193
|
+
else if (outcome.inScope === 0 && outcome.outOfScope > 0)
|
|
194
|
+
stats.sessionsOutOfScope++;
|
|
195
|
+
if (outcome.session) {
|
|
196
|
+
cursor.sessions[candidate.key] = outcome.session;
|
|
197
|
+
if (outcome.session.backlog)
|
|
198
|
+
stats.sessionsWithBacklog++;
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// Rewritten under us: forget the file so the next call reads it from the start.
|
|
202
|
+
delete cursor.sessions[candidate.key];
|
|
203
|
+
stats.sessionsRewritten++;
|
|
204
|
+
hasMore = true;
|
|
205
|
+
}
|
|
206
|
+
if (outcome.stoppedBy === 'maxFileBytes' && outcome.progressed) {
|
|
207
|
+
// Per-file budget: move on so one large file cannot starve the others.
|
|
208
|
+
boundsHit.add('maxFileBytes');
|
|
209
|
+
stats.filesOverSizeLimit++;
|
|
210
|
+
hasMore = true;
|
|
211
|
+
}
|
|
212
|
+
else if (outcome.stoppedBy) {
|
|
213
|
+
// A file that could not move within its budget starts the next call, where
|
|
214
|
+
// the first file may always finish its first record.
|
|
215
|
+
if (outcome.stoppedBy === 'maxFileBytes')
|
|
216
|
+
stats.filesOverSizeLimit++;
|
|
217
|
+
stopAt(candidate, outcome.stoppedBy);
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
await yieldToEventLoop();
|
|
221
|
+
}
|
|
222
|
+
return finish();
|
|
223
|
+
}
|
|
224
|
+
/** @internal */
|
|
225
|
+
export function emptyStats() {
|
|
226
|
+
return {
|
|
227
|
+
sessionsScanned: 0,
|
|
228
|
+
sessionsSkippedUnchanged: 0,
|
|
229
|
+
sessionsOutOfScope: 0,
|
|
230
|
+
filesOverSizeLimit: 0,
|
|
231
|
+
sessionsWithBacklog: 0,
|
|
232
|
+
sessionsRewritten: 0,
|
|
233
|
+
sessionsNotInteractive: 0,
|
|
234
|
+
recordsOverSizeLimit: 0,
|
|
235
|
+
recordsMalformed: 0,
|
|
236
|
+
promptsMissingTimestamp: 0,
|
|
237
|
+
promptsOutOfScope: 0,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function definedBounds(bounds) {
|
|
241
|
+
const result = {};
|
|
242
|
+
if (!bounds)
|
|
243
|
+
return result;
|
|
244
|
+
for (const key of Object.keys(DEFAULT_BOUNDS)) {
|
|
245
|
+
const value = bounds[key];
|
|
246
|
+
if (typeof value === 'number' && !Number.isNaN(value))
|
|
247
|
+
result[key] = Math.max(0, value);
|
|
248
|
+
}
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
function acceptedCursor(cursor) {
|
|
252
|
+
if (!cursor || ![1, 2, 3].includes(cursor.version))
|
|
253
|
+
return { sessions: {} };
|
|
254
|
+
const sessions = cursor.sessions;
|
|
255
|
+
return {
|
|
256
|
+
sessions: typeof sessions === 'object' && sessions !== null
|
|
257
|
+
? sessions
|
|
258
|
+
: {},
|
|
259
|
+
...(typeof cursor.next === 'string' ? { next: cursor.next } : {}),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function isOffset(value) {
|
|
263
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
264
|
+
}
|
|
265
|
+
function validSession(session) {
|
|
266
|
+
if (!session || typeof session !== 'object')
|
|
267
|
+
return undefined;
|
|
268
|
+
const valid = isOffset(session.offset) &&
|
|
269
|
+
Number.isInteger(session.lastOrdinal) &&
|
|
270
|
+
session.lastOrdinal >= -1 &&
|
|
271
|
+
typeof session.size === 'number' &&
|
|
272
|
+
typeof session.mtimeMs === 'number';
|
|
273
|
+
return valid ? session : undefined;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* True when a cursor entry's last pass reached the end of its file with no
|
|
277
|
+
* prompt still waiting for its answer: an unchanged file needs no read.
|
|
278
|
+
*/
|
|
279
|
+
export function isPromptHistorySessionSettled(session) {
|
|
280
|
+
return validSession(session) !== undefined && isSettled(session);
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Validates a stored cursor (versions 1 to 3). Returns it as a version-3
|
|
284
|
+
* cursor with malformed session entries dropped, or undefined when it is
|
|
285
|
+
* not a cursor at all.
|
|
286
|
+
*/
|
|
287
|
+
export function parsePromptHistoryCursor(value) {
|
|
288
|
+
if (!value || typeof value !== 'object')
|
|
289
|
+
return undefined;
|
|
290
|
+
const candidate = value;
|
|
291
|
+
if (typeof candidate.version !== 'number' || ![1, 2, 3].includes(candidate.version)) {
|
|
292
|
+
return undefined;
|
|
293
|
+
}
|
|
294
|
+
const { sessions, next } = acceptedCursor(value);
|
|
295
|
+
const valid = {};
|
|
296
|
+
for (const [key, session] of Object.entries(sessions)) {
|
|
297
|
+
if (validSession(session))
|
|
298
|
+
valid[key] = session;
|
|
299
|
+
}
|
|
300
|
+
return { version: 3, sessions: valid, ...(next !== undefined ? { next } : {}) };
|
|
301
|
+
}
|
|
302
|
+
/** A session whose last pass reached the end and needs no migration. */
|
|
303
|
+
function isSettled(session) {
|
|
304
|
+
if (session.backlog || session.skipping)
|
|
305
|
+
return false;
|
|
306
|
+
if (session.state?.excluded)
|
|
307
|
+
return true;
|
|
308
|
+
if (!session.head)
|
|
309
|
+
return false;
|
|
310
|
+
return !session.pending || session.pending.answer !== undefined;
|
|
311
|
+
}
|
|
312
|
+
function resumePlan(session) {
|
|
313
|
+
if (!session)
|
|
314
|
+
return { kind: 'fresh' };
|
|
315
|
+
const pending = session.pending;
|
|
316
|
+
if (!pending)
|
|
317
|
+
return { kind: 'continue', session };
|
|
318
|
+
const basic = Number.isInteger(pending.ordinal) &&
|
|
319
|
+
pending.ordinal === session.lastOrdinal &&
|
|
320
|
+
isOffset(pending.offset) &&
|
|
321
|
+
pending.offset <= session.offset &&
|
|
322
|
+
typeof pending.emitted === 'string';
|
|
323
|
+
if (!basic)
|
|
324
|
+
return { kind: 'continue', session };
|
|
325
|
+
const answer = pending.answer;
|
|
326
|
+
if (answer === undefined) {
|
|
327
|
+
return session.skipping ? { kind: 'continue', session } : { kind: 'replay', session, pending };
|
|
328
|
+
}
|
|
329
|
+
const carried = typeof answer === 'object' &&
|
|
330
|
+
answer !== null &&
|
|
331
|
+
(answer.status === 'pending' || answer.status === 'provisional') &&
|
|
332
|
+
isOffset(pending.lineBytes) &&
|
|
333
|
+
pending.offset + pending.lineBytes < session.offset &&
|
|
334
|
+
typeof pending.timestamp === 'string';
|
|
335
|
+
return carried ? { kind: 'continue', session, carried: pending } : { kind: 'continue', session };
|
|
336
|
+
}
|
|
337
|
+
/** Digest of what a consumer would store for an entry besides its identity and text. */
|
|
338
|
+
function metadataDigest(entry) {
|
|
339
|
+
const usage = entry.usage
|
|
340
|
+
? [
|
|
341
|
+
entry.usage.inputTokens ?? null,
|
|
342
|
+
entry.usage.outputTokens ?? null,
|
|
343
|
+
entry.usage.cacheReadTokens ?? null,
|
|
344
|
+
entry.usage.cacheWriteTokens ?? null,
|
|
345
|
+
]
|
|
346
|
+
: null;
|
|
347
|
+
return createHash('sha256')
|
|
348
|
+
.update(JSON.stringify([entry.model ?? null, usage, entry.metadataStatus]))
|
|
349
|
+
.digest('hex')
|
|
350
|
+
.slice(0, 16);
|
|
351
|
+
}
|
|
352
|
+
function compareEntries(left, right) {
|
|
353
|
+
return (compareStrings(left.provider, right.provider) ||
|
|
354
|
+
compareStrings(left.sessionId, right.sessionId) ||
|
|
355
|
+
left.ordinal - right.ordinal);
|
|
356
|
+
}
|
|
357
|
+
/** @internal */
|
|
358
|
+
export function compareStrings(left, right) {
|
|
359
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
360
|
+
}
|
|
361
|
+
function rotate(candidates, nextKey) {
|
|
362
|
+
const index = nextKey ? candidates.findIndex((candidate) => candidate.key === nextKey) : -1;
|
|
363
|
+
return index <= 0 ? candidates : [...candidates.slice(index), ...candidates.slice(0, index)];
|
|
364
|
+
}
|
|
365
|
+
/** @internal */
|
|
366
|
+
export function resolveRoots(workspacePaths, includeWorktrees, realpaths) {
|
|
367
|
+
return resolveWorkspaceRoots(workspacePaths, { includeWorktrees }, realpaths);
|
|
368
|
+
}
|
|
369
|
+
/** Fail-closed scope check on a recorded working directory. */
|
|
370
|
+
function scopedCwd(cwd, roots, realpaths) {
|
|
371
|
+
if (typeof cwd !== 'string' || cwd.length === 0)
|
|
372
|
+
return null;
|
|
373
|
+
const real = realpathOrNull(cwd, realpaths);
|
|
374
|
+
if (!real)
|
|
375
|
+
return null;
|
|
376
|
+
for (const root of roots) {
|
|
377
|
+
if (real === root)
|
|
378
|
+
return real;
|
|
379
|
+
const prefix = root.endsWith(path.sep) ? root : root + path.sep;
|
|
380
|
+
if (real.startsWith(prefix))
|
|
381
|
+
return real;
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
/** @internal */
|
|
386
|
+
export async function listCandidates(providers, listingRoots, sinceMs) {
|
|
387
|
+
const byPath = new Map();
|
|
388
|
+
const listOptions = sinceMs !== null ? { since: sinceMs } : {};
|
|
389
|
+
for (const provider of [...new Set(providers)]) {
|
|
390
|
+
if (!ALL_PROVIDERS.includes(provider))
|
|
391
|
+
continue;
|
|
392
|
+
// Listings are only candidate supersets; scope is re-checked per prompt.
|
|
393
|
+
const list = provider === 'claude-code' ? listClaudeSessionFilesAsync : listCodexSessionFilesAsync;
|
|
394
|
+
const cache = new DirectoryListingCache();
|
|
395
|
+
for (const root of listingRoots) {
|
|
396
|
+
let files;
|
|
397
|
+
try {
|
|
398
|
+
files = await list(root, listOptions, cache);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
files = [];
|
|
402
|
+
}
|
|
403
|
+
for (const file of files) {
|
|
404
|
+
if (byPath.has(file.path))
|
|
405
|
+
continue;
|
|
406
|
+
const sessionId = file.sessionId ?? path.basename(file.path, '.jsonl');
|
|
407
|
+
byPath.set(file.path, {
|
|
408
|
+
provider,
|
|
409
|
+
sessionId,
|
|
410
|
+
filePath: file.path,
|
|
411
|
+
key: `${provider}:${sessionId}:${hashPath(file.path)}`,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return [...byPath.values()].sort((left, right) => compareStrings(left.provider, right.provider) ||
|
|
417
|
+
compareStrings(left.sessionId, right.sessionId) ||
|
|
418
|
+
compareStrings(left.key, right.key));
|
|
419
|
+
}
|
|
420
|
+
/** True when the file's first bytes still match a recorded head. */
|
|
421
|
+
async function fileStartsWith(filePath, head) {
|
|
422
|
+
let handle;
|
|
423
|
+
try {
|
|
424
|
+
handle = await fs.promises.open(filePath, 'r');
|
|
425
|
+
return headMatches(head, await readAt(handle, 0, Math.min(HEAD_BYTES, head.bytes)));
|
|
426
|
+
}
|
|
427
|
+
catch {
|
|
428
|
+
return false;
|
|
429
|
+
}
|
|
430
|
+
finally {
|
|
431
|
+
await handle?.close();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Cursor entries whose file is no longer listed, by `provider:sessionId`, for
|
|
436
|
+
* sessions that are listed again under another path.
|
|
437
|
+
*/
|
|
438
|
+
function movedSessionKeys(previous, candidates) {
|
|
439
|
+
const listedKeys = new Set(candidates.map((candidate) => candidate.key));
|
|
440
|
+
const listedIds = new Set(candidates.map((candidate) => `${candidate.provider}:${candidate.sessionId}`));
|
|
441
|
+
const moved = new Map();
|
|
442
|
+
for (const [key, value] of Object.entries(previous)) {
|
|
443
|
+
if (listedKeys.has(key))
|
|
444
|
+
continue;
|
|
445
|
+
const session = validSession(value);
|
|
446
|
+
if (!session)
|
|
447
|
+
continue;
|
|
448
|
+
const id = `${session.provider}:${session.sessionId}`;
|
|
449
|
+
if (listedIds.has(id) && !moved.has(id))
|
|
450
|
+
moved.set(id, key);
|
|
451
|
+
}
|
|
452
|
+
return moved;
|
|
453
|
+
}
|
|
454
|
+
function hashPath(filePath) {
|
|
455
|
+
return createHash('sha256').update(filePath).digest('hex').slice(0, 12);
|
|
456
|
+
}
|
|
457
|
+
async function readAt(handle, offset, length) {
|
|
458
|
+
const buffer = Buffer.alloc(length);
|
|
459
|
+
let read = 0;
|
|
460
|
+
while (read < length) {
|
|
461
|
+
const { bytesRead } = await handle.read(buffer, read, length - read, offset + read);
|
|
462
|
+
if (bytesRead === 0)
|
|
463
|
+
break;
|
|
464
|
+
read += bytesRead;
|
|
465
|
+
}
|
|
466
|
+
return read === length ? buffer : buffer.subarray(0, read);
|
|
467
|
+
}
|
|
468
|
+
function yieldToEventLoop() {
|
|
469
|
+
return new Promise((resolve) => setImmediate(resolve));
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Checks that the prefix and resume point recorded for a file still hold.
|
|
473
|
+
* Returns the head to store, or null when the file was truncated or rewritten.
|
|
474
|
+
*/
|
|
475
|
+
async function verifyFile(handle, size, session) {
|
|
476
|
+
const headBytes = Math.min(HEAD_BYTES, size);
|
|
477
|
+
const head = await readAt(handle, 0, headBytes);
|
|
478
|
+
let bytesRead = head.length;
|
|
479
|
+
if (head.length !== headBytes)
|
|
480
|
+
return null;
|
|
481
|
+
if (session) {
|
|
482
|
+
if (session.offset > size)
|
|
483
|
+
return null;
|
|
484
|
+
const known = session.head;
|
|
485
|
+
if (known && !headMatches(known, head))
|
|
486
|
+
return null;
|
|
487
|
+
if (session.offset > 0 && !session.skipping) {
|
|
488
|
+
const before = await readAt(handle, session.offset - 1, 1);
|
|
489
|
+
bytesRead += before.length;
|
|
490
|
+
if (before.length !== 1 || before[0] !== 0x0a)
|
|
491
|
+
return null;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return { head: fileHeadOf(head), bytesRead };
|
|
495
|
+
}
|
|
496
|
+
/** @internal */
|
|
497
|
+
export async function scanFile(candidate, stat, prior, budget, context) {
|
|
498
|
+
const handle = await fs.promises.open(candidate.filePath, 'r');
|
|
499
|
+
try {
|
|
500
|
+
let bytesRead = 0;
|
|
501
|
+
const verified = await verifyFile(handle, stat.size, prior);
|
|
502
|
+
let plan = resumePlan(prior);
|
|
503
|
+
let head = verified?.head;
|
|
504
|
+
if (verified) {
|
|
505
|
+
bytesRead += verified.bytesRead;
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
// Offsets and ordinals of a rewritten file are meaningless: start over.
|
|
509
|
+
if (prior)
|
|
510
|
+
context.stats.sessionsRewritten++;
|
|
511
|
+
plan = { kind: 'fresh' };
|
|
512
|
+
const fresh = await verifyFile(handle, stat.size, undefined);
|
|
513
|
+
if (!fresh)
|
|
514
|
+
throw new Error('file changed while reading');
|
|
515
|
+
bytesRead += fresh.bytesRead;
|
|
516
|
+
head = fresh.head;
|
|
517
|
+
}
|
|
518
|
+
const session = plan.kind === 'fresh' ? undefined : plan.session;
|
|
519
|
+
if (session?.state?.excluded) {
|
|
520
|
+
return {
|
|
521
|
+
entries: [],
|
|
522
|
+
progressed: true,
|
|
523
|
+
session: {
|
|
524
|
+
provider: candidate.provider,
|
|
525
|
+
sessionId: candidate.sessionId,
|
|
526
|
+
size: stat.size,
|
|
527
|
+
mtimeMs: stat.mtimeMs,
|
|
528
|
+
lastOrdinal: session.lastOrdinal,
|
|
529
|
+
offset: stat.size,
|
|
530
|
+
state: session.state,
|
|
531
|
+
...(head ? { head } : {}),
|
|
532
|
+
},
|
|
533
|
+
bytesRead,
|
|
534
|
+
stoppedBy: null,
|
|
535
|
+
notInteractive: true,
|
|
536
|
+
inScope: 0,
|
|
537
|
+
outOfScope: 0,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
const scanner = candidate.provider === 'claude-code'
|
|
541
|
+
? new ClaudeLineScanner(candidate, plan, context)
|
|
542
|
+
: new CodexLineScanner(candidate, plan, context);
|
|
543
|
+
const startOffset = plan.kind === 'fresh'
|
|
544
|
+
? 0
|
|
545
|
+
: plan.kind === 'replay'
|
|
546
|
+
? plan.pending.offset
|
|
547
|
+
: plan.session.offset;
|
|
548
|
+
const stream = await streamLines(handle, startOffset, stat.size, plan.kind === 'continue' && plan.session.skipping === true, budget, context, candidate, scanner);
|
|
549
|
+
bytesRead += stream.bytesRead;
|
|
550
|
+
if (stream.excluded) {
|
|
551
|
+
return {
|
|
552
|
+
entries: [],
|
|
553
|
+
progressed: true,
|
|
554
|
+
session: {
|
|
555
|
+
provider: candidate.provider,
|
|
556
|
+
sessionId: candidate.sessionId,
|
|
557
|
+
size: stat.size,
|
|
558
|
+
mtimeMs: stat.mtimeMs,
|
|
559
|
+
lastOrdinal: scanner.lastOrdinal(),
|
|
560
|
+
offset: stat.size,
|
|
561
|
+
state: scanner.resumeState() ?? { excluded: true },
|
|
562
|
+
...(head ? { head } : {}),
|
|
563
|
+
},
|
|
564
|
+
bytesRead,
|
|
565
|
+
stoppedBy: null,
|
|
566
|
+
notInteractive: true,
|
|
567
|
+
inScope: 0,
|
|
568
|
+
outOfScope: 0,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
const update = await scanner.carriedUpdate(handle);
|
|
572
|
+
bytesRead += update.bytesRead;
|
|
573
|
+
if (update.rewritten) {
|
|
574
|
+
return {
|
|
575
|
+
entries: [],
|
|
576
|
+
progressed: false,
|
|
577
|
+
session: undefined,
|
|
578
|
+
bytesRead,
|
|
579
|
+
stoppedBy: null,
|
|
580
|
+
notInteractive: false,
|
|
581
|
+
inScope: 0,
|
|
582
|
+
outOfScope: 0,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
const entries = scanner.returnedEntries();
|
|
586
|
+
if (update.entry)
|
|
587
|
+
entries.push(update.entry);
|
|
588
|
+
const state = scanner.resumeState();
|
|
589
|
+
const pending = scanner.pendingPrompt();
|
|
590
|
+
// A truncated read also keeps a backlog, so the next call re-verifies the file.
|
|
591
|
+
const backlog = stream.offset < stat.size && (stream.stoppedBy !== null || stream.truncated);
|
|
592
|
+
return {
|
|
593
|
+
entries,
|
|
594
|
+
progressed: stream.offset > startOffset || stream.skipping,
|
|
595
|
+
session: {
|
|
596
|
+
provider: candidate.provider,
|
|
597
|
+
sessionId: candidate.sessionId,
|
|
598
|
+
size: stat.size,
|
|
599
|
+
mtimeMs: stat.mtimeMs,
|
|
600
|
+
lastOrdinal: scanner.lastOrdinal(),
|
|
601
|
+
offset: stream.offset,
|
|
602
|
+
...(state ? { state } : {}),
|
|
603
|
+
...(pending ? { pending } : {}),
|
|
604
|
+
...(backlog ? { backlog: true } : {}),
|
|
605
|
+
...(stream.skipping ? { skipping: true } : {}),
|
|
606
|
+
...(head ? { head } : {}),
|
|
607
|
+
},
|
|
608
|
+
bytesRead,
|
|
609
|
+
stoppedBy: backlog ? stream.stoppedBy : null,
|
|
610
|
+
notInteractive: false,
|
|
611
|
+
inScope: scanner.inScopeCount(),
|
|
612
|
+
outOfScope: scanner.outOfScopeCount(),
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
finally {
|
|
616
|
+
await handle.close();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Feeds complete JSONL lines from `[start, end)` to the scanner in fixed-size
|
|
621
|
+
* chunks. Lines split only at `\n`, which never occurs inside a multi-byte
|
|
622
|
+
* UTF-8 sequence, so decoding whole lines is exact whatever the chunk edges.
|
|
623
|
+
* A line longer than `maxRecordBytes` is dropped as it streams past.
|
|
624
|
+
*/
|
|
625
|
+
async function streamLines(handle, start, end, resumeSkipping, budget, context, candidate, scanner) {
|
|
626
|
+
const { maxRecordBytes, maxEntries } = context.bounds;
|
|
627
|
+
const chunk = Buffer.allocUnsafe(Math.max(1, Math.min(readChunkBytes, end - start || 1)));
|
|
628
|
+
let position = start;
|
|
629
|
+
let committed = start;
|
|
630
|
+
let lineStart = start;
|
|
631
|
+
let skipping = resumeSkipping;
|
|
632
|
+
let pieces = [];
|
|
633
|
+
let pieceBytes = 0;
|
|
634
|
+
let bytesRead = 0;
|
|
635
|
+
let lines = 0;
|
|
636
|
+
let stoppedBy = null;
|
|
637
|
+
let truncated = false;
|
|
638
|
+
// The first file of a call always finishes its first record, so no budget can stall it.
|
|
639
|
+
const mayStop = () => !(budget.forceProgress && committed === start && !skipping);
|
|
640
|
+
const result = (excluded = false) => ({
|
|
641
|
+
offset: skipping ? position : committed,
|
|
642
|
+
skipping,
|
|
643
|
+
bytesRead,
|
|
644
|
+
stoppedBy,
|
|
645
|
+
truncated,
|
|
646
|
+
excluded,
|
|
647
|
+
});
|
|
648
|
+
while (position < end) {
|
|
649
|
+
const stop = context.shouldStop();
|
|
650
|
+
if (stop) {
|
|
651
|
+
stoppedBy = stop;
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
const allowance = budget.bytes - bytesRead;
|
|
655
|
+
if (allowance <= 0 && mayStop()) {
|
|
656
|
+
stoppedBy = budget.bound;
|
|
657
|
+
break;
|
|
658
|
+
}
|
|
659
|
+
const want = Math.min(chunk.length, end - position, allowance > 0 ? allowance : chunk.length);
|
|
660
|
+
const { bytesRead: got } = await handle.read(chunk, 0, want, position);
|
|
661
|
+
if (got === 0) {
|
|
662
|
+
// Truncated while reading; the next call's verification sees the rewrite.
|
|
663
|
+
truncated = true;
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
bytesRead += got;
|
|
667
|
+
const view = chunk.subarray(0, got);
|
|
668
|
+
let index = 0;
|
|
669
|
+
while (index < got) {
|
|
670
|
+
const newline = view.indexOf(0x0a, index);
|
|
671
|
+
const segmentEnd = newline === -1 ? got : newline;
|
|
672
|
+
if (!skipping) {
|
|
673
|
+
const segmentBytes = segmentEnd - index;
|
|
674
|
+
if (pieceBytes + segmentBytes > maxRecordBytes) {
|
|
675
|
+
skipping = true;
|
|
676
|
+
pieces = [];
|
|
677
|
+
pieceBytes = 0;
|
|
678
|
+
context.stats.recordsOverSizeLimit++;
|
|
679
|
+
context.exclude({
|
|
680
|
+
provider: candidate.provider,
|
|
681
|
+
sessionId: candidate.sessionId,
|
|
682
|
+
reason: 'oversizedRecord',
|
|
683
|
+
offset: lineStart,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
else if (newline === -1 && segmentBytes > 0) {
|
|
687
|
+
// The chunk buffer is reused, so a partial line keeps a copy.
|
|
688
|
+
pieces.push(Buffer.from(view.subarray(index, segmentEnd)));
|
|
689
|
+
pieceBytes += segmentBytes;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (newline === -1)
|
|
693
|
+
break;
|
|
694
|
+
if (skipping) {
|
|
695
|
+
skipping = false;
|
|
696
|
+
}
|
|
697
|
+
else {
|
|
698
|
+
let line;
|
|
699
|
+
if (pieces.length === 0) {
|
|
700
|
+
line = view.toString('utf8', index, newline);
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
pieces.push(view.subarray(index, newline));
|
|
704
|
+
line = Buffer.concat(pieces).toString('utf8');
|
|
705
|
+
}
|
|
706
|
+
const lineOffset = lineStart;
|
|
707
|
+
const lineBytes = position + newline - lineStart;
|
|
708
|
+
if (acceptLine(line, lineOffset, lineBytes, scanner, context, candidate) === 'excluded') {
|
|
709
|
+
committed = position + newline + 1;
|
|
710
|
+
return result(true);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
pieces = [];
|
|
714
|
+
pieceBytes = 0;
|
|
715
|
+
index = newline + 1;
|
|
716
|
+
committed = position + index;
|
|
717
|
+
lineStart = committed;
|
|
718
|
+
lines++;
|
|
719
|
+
if (mayStop() && context.entriesSoFar() + scanner.entryCount() >= maxEntries) {
|
|
720
|
+
stoppedBy = 'maxEntries';
|
|
721
|
+
position += index;
|
|
722
|
+
return result();
|
|
723
|
+
}
|
|
724
|
+
if (lines % CHECK_EVERY_LINES === 0) {
|
|
725
|
+
await yieldToEventLoop();
|
|
726
|
+
const stop = context.shouldStop();
|
|
727
|
+
if (stop) {
|
|
728
|
+
stoppedBy = stop;
|
|
729
|
+
position += index;
|
|
730
|
+
return result();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
position += got;
|
|
735
|
+
}
|
|
736
|
+
return result();
|
|
737
|
+
}
|
|
738
|
+
function acceptLine(line, lineOffset, lineBytes, scanner, context, candidate) {
|
|
739
|
+
if (!line.trim())
|
|
740
|
+
return 'ok';
|
|
741
|
+
let record;
|
|
742
|
+
try {
|
|
743
|
+
record = JSON.parse(line);
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
context.stats.recordsMalformed++;
|
|
747
|
+
context.exclude({
|
|
748
|
+
provider: candidate.provider,
|
|
749
|
+
sessionId: candidate.sessionId,
|
|
750
|
+
reason: 'malformedRecord',
|
|
751
|
+
offset: lineOffset,
|
|
752
|
+
});
|
|
753
|
+
return 'ok';
|
|
754
|
+
}
|
|
755
|
+
if (typeof record !== 'object' || record === null)
|
|
756
|
+
return 'ok';
|
|
757
|
+
const row = record;
|
|
758
|
+
const accepted = scanner.accept(row, lineOffset, lineBytes);
|
|
759
|
+
if (accepted === 'ok' && context.observe) {
|
|
760
|
+
context.observe(row, { ordinal: scanner.lastOrdinal(), lineOffset });
|
|
761
|
+
}
|
|
762
|
+
return accepted;
|
|
763
|
+
}
|
|
764
|
+
/** Shared per-file prompt bookkeeping for both providers. */
|
|
765
|
+
class LineScanner {
|
|
766
|
+
candidate;
|
|
767
|
+
context;
|
|
768
|
+
entries = [];
|
|
769
|
+
nextOrdinal;
|
|
770
|
+
inScope = 0;
|
|
771
|
+
outOfScope = 0;
|
|
772
|
+
/** Latest emitted prompt still collecting answer metadata. */
|
|
773
|
+
open = null;
|
|
774
|
+
/** The carried prompt once it has been closed during this pass. */
|
|
775
|
+
closedCarried = null;
|
|
776
|
+
/** Version 2 replay: the prompt already returned with this digest is not returned again. */
|
|
777
|
+
suppress = null;
|
|
778
|
+
constructor(candidate, plan, context) {
|
|
779
|
+
this.candidate = candidate;
|
|
780
|
+
this.context = context;
|
|
781
|
+
if (plan.kind === 'fresh') {
|
|
782
|
+
this.nextOrdinal = 0;
|
|
783
|
+
}
|
|
784
|
+
else if (plan.kind === 'replay') {
|
|
785
|
+
this.nextOrdinal = plan.pending.ordinal;
|
|
786
|
+
this.suppress = { ordinal: plan.pending.ordinal, emitted: plan.pending.emitted };
|
|
787
|
+
}
|
|
788
|
+
else {
|
|
789
|
+
this.nextOrdinal = plan.session.lastOrdinal + 1;
|
|
790
|
+
const carried = plan.carried;
|
|
791
|
+
if (carried?.answer &&
|
|
792
|
+
carried.lineBytes !== undefined &&
|
|
793
|
+
carried.lineBytes <= context.bounds.maxRecordBytes &&
|
|
794
|
+
carried.timestamp !== undefined) {
|
|
795
|
+
const answer = carried.answer;
|
|
796
|
+
const usage = answer.usage ? compactUsage(answer.usage) : undefined;
|
|
797
|
+
this.open = {
|
|
798
|
+
entry: {
|
|
799
|
+
provider: candidate.provider,
|
|
800
|
+
sessionId: candidate.sessionId,
|
|
801
|
+
ordinal: carried.ordinal,
|
|
802
|
+
timestamp: carried.timestamp,
|
|
803
|
+
text: '',
|
|
804
|
+
cwd: '',
|
|
805
|
+
...(typeof answer.model === 'string' ? { model: answer.model } : {}),
|
|
806
|
+
...(usage ? { usage } : {}),
|
|
807
|
+
metadataStatus: answer.status,
|
|
808
|
+
},
|
|
809
|
+
lineOffset: carried.offset,
|
|
810
|
+
lineBytes: carried.lineBytes,
|
|
811
|
+
...(carried.state ? { stateBefore: { ...carried.state } } : {}),
|
|
812
|
+
answerId: typeof answer.id === 'string' ? answer.id : null,
|
|
813
|
+
carried: { emitted: carried.emitted },
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
resumeState() {
|
|
819
|
+
return undefined;
|
|
820
|
+
}
|
|
821
|
+
lastOrdinal() {
|
|
822
|
+
return this.nextOrdinal - 1;
|
|
823
|
+
}
|
|
824
|
+
entryCount() {
|
|
825
|
+
return this.entries.length;
|
|
826
|
+
}
|
|
827
|
+
inScopeCount() {
|
|
828
|
+
return this.inScope;
|
|
829
|
+
}
|
|
830
|
+
outOfScopeCount() {
|
|
831
|
+
return this.outOfScope;
|
|
832
|
+
}
|
|
833
|
+
returnedEntries() {
|
|
834
|
+
const suppress = this.suppress;
|
|
835
|
+
if (!suppress)
|
|
836
|
+
return [...this.entries];
|
|
837
|
+
// A replayed prompt is returned again only when its metadata changed.
|
|
838
|
+
return this.entries.filter((entry) => entry.ordinal !== suppress.ordinal || metadataDigest(entry) !== suppress.emitted);
|
|
839
|
+
}
|
|
840
|
+
pendingPrompt() {
|
|
841
|
+
const open = this.open;
|
|
842
|
+
if (!open)
|
|
843
|
+
return undefined;
|
|
844
|
+
const entry = open.entry;
|
|
845
|
+
const status = entry.metadataStatus === 'provisional' ? 'provisional' : 'pending';
|
|
846
|
+
const answer = {
|
|
847
|
+
status,
|
|
848
|
+
...(open.answerId !== null ? { id: open.answerId } : {}),
|
|
849
|
+
...(entry.model ? { model: entry.model } : {}),
|
|
850
|
+
...(entry.usage ? { usage: { ...entry.usage } } : {}),
|
|
851
|
+
};
|
|
852
|
+
return {
|
|
853
|
+
ordinal: entry.ordinal,
|
|
854
|
+
offset: open.lineOffset,
|
|
855
|
+
lineBytes: open.lineBytes,
|
|
856
|
+
timestamp: entry.timestamp,
|
|
857
|
+
emitted: metadataDigest(entry),
|
|
858
|
+
...(open.stateBefore ? { state: { ...open.stateBefore } } : {}),
|
|
859
|
+
answer,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* The carried prompt returned again when its metadata changed in this pass.
|
|
864
|
+
* Its text is re-read from its own line, which must still match exactly.
|
|
865
|
+
*/
|
|
866
|
+
async carriedUpdate(handle) {
|
|
867
|
+
const carried = this.closedCarried ?? (this.open?.carried ? this.open : null);
|
|
868
|
+
if (!carried?.carried)
|
|
869
|
+
return { bytesRead: 0, rewritten: false };
|
|
870
|
+
const entry = carried.entry;
|
|
871
|
+
if (metadataDigest(entry) === carried.carried.emitted)
|
|
872
|
+
return { bytesRead: 0, rewritten: false };
|
|
873
|
+
const raw = await readAt(handle, carried.lineOffset, carried.lineBytes + 1);
|
|
874
|
+
const rewritten = { bytesRead: raw.length, rewritten: true };
|
|
875
|
+
if (raw.length !== carried.lineBytes + 1 || raw[carried.lineBytes] !== 0x0a)
|
|
876
|
+
return rewritten;
|
|
877
|
+
let record;
|
|
878
|
+
try {
|
|
879
|
+
record = JSON.parse(raw.toString('utf8', 0, carried.lineBytes));
|
|
880
|
+
}
|
|
881
|
+
catch {
|
|
882
|
+
return rewritten;
|
|
883
|
+
}
|
|
884
|
+
if (typeof record !== 'object' || record === null)
|
|
885
|
+
return rewritten;
|
|
886
|
+
const row = record;
|
|
887
|
+
const text = this.promptTextOf(row);
|
|
888
|
+
if (text === null || row.timestamp !== entry.timestamp)
|
|
889
|
+
return rewritten;
|
|
890
|
+
const place = this.promptPlaceOf(row, carried.stateBefore);
|
|
891
|
+
const scoped = scopedCwd(place.cwd, this.context.roots, this.context.realpaths);
|
|
892
|
+
if (!scoped) {
|
|
893
|
+
// The directory no longer resolves inside the roots: fail closed.
|
|
894
|
+
this.context.stats.promptsOutOfScope++;
|
|
895
|
+
return { bytesRead: raw.length, rewritten: false };
|
|
896
|
+
}
|
|
897
|
+
if (carried === this.open)
|
|
898
|
+
carried.carried = { emitted: metadataDigest(entry) };
|
|
899
|
+
return {
|
|
900
|
+
entry: {
|
|
901
|
+
...entry,
|
|
902
|
+
text,
|
|
903
|
+
cwd: scoped,
|
|
904
|
+
...(place.gitBranch ? { gitBranch: place.gitBranch } : {}),
|
|
905
|
+
...(entry.usage ? { usage: { ...entry.usage } } : {}),
|
|
906
|
+
},
|
|
907
|
+
bytesRead: raw.length,
|
|
908
|
+
rewritten: false,
|
|
909
|
+
};
|
|
910
|
+
}
|
|
911
|
+
/** The open prompt's answer is complete, or will never arrive. */
|
|
912
|
+
closeOpen() {
|
|
913
|
+
const open = this.open;
|
|
914
|
+
if (!open)
|
|
915
|
+
return;
|
|
916
|
+
open.entry.metadataStatus = 'final';
|
|
917
|
+
if (open.carried)
|
|
918
|
+
this.closedCarried = open;
|
|
919
|
+
this.open = null;
|
|
920
|
+
}
|
|
921
|
+
/** Assign an ordinal to a human prompt and emit it when in scope. */
|
|
922
|
+
recordPrompt(text, timestamp, cwd, gitBranch, lineOffset, lineBytes, stateBefore) {
|
|
923
|
+
const ordinal = this.nextOrdinal++;
|
|
924
|
+
this.closeOpen();
|
|
925
|
+
if (typeof timestamp !== 'string' || Number.isNaN(Date.parse(timestamp))) {
|
|
926
|
+
this.context.stats.promptsMissingTimestamp++;
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const scoped = scopedCwd(cwd, this.context.roots, this.context.realpaths);
|
|
930
|
+
if (!scoped) {
|
|
931
|
+
this.outOfScope++;
|
|
932
|
+
this.context.stats.promptsOutOfScope++;
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
this.inScope++;
|
|
936
|
+
if (this.context.sinceMs !== null && Date.parse(timestamp) < this.context.sinceMs)
|
|
937
|
+
return;
|
|
938
|
+
const entry = {
|
|
939
|
+
provider: this.candidate.provider,
|
|
940
|
+
sessionId: this.candidate.sessionId,
|
|
941
|
+
ordinal,
|
|
942
|
+
timestamp,
|
|
943
|
+
text,
|
|
944
|
+
cwd: scoped,
|
|
945
|
+
...(gitBranch ? { gitBranch } : {}),
|
|
946
|
+
metadataStatus: 'pending',
|
|
947
|
+
};
|
|
948
|
+
this.entries.push(entry);
|
|
949
|
+
this.open = {
|
|
950
|
+
entry,
|
|
951
|
+
lineOffset,
|
|
952
|
+
lineBytes,
|
|
953
|
+
answerId: null,
|
|
954
|
+
...(stateBefore ? { stateBefore } : {}),
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
function transcriptMessage(candidate, record, role, content, extra, eventIndex) {
|
|
959
|
+
return {
|
|
960
|
+
role: 'user',
|
|
961
|
+
timestamp: typeof record.timestamp === 'string' ? record.timestamp : '',
|
|
962
|
+
content,
|
|
963
|
+
text: content
|
|
964
|
+
.filter((block) => block.type === 'text')
|
|
965
|
+
.map((block) => block.text ?? '')
|
|
966
|
+
.join('\n'),
|
|
967
|
+
tools: [],
|
|
968
|
+
commands: [],
|
|
969
|
+
source: {
|
|
970
|
+
provider: candidate.provider,
|
|
971
|
+
sessionId: candidate.sessionId,
|
|
972
|
+
originalRole: role,
|
|
973
|
+
eventIndex,
|
|
974
|
+
eventType: 'user',
|
|
975
|
+
...extra,
|
|
976
|
+
},
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
function stringField(value) {
|
|
980
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
981
|
+
}
|
|
982
|
+
function tokenCount(value) {
|
|
983
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
984
|
+
}
|
|
985
|
+
function compactUsage(usage) {
|
|
986
|
+
const result = {};
|
|
987
|
+
for (const [key, value] of Object.entries(usage)) {
|
|
988
|
+
if (typeof value === 'number')
|
|
989
|
+
result[key] = value;
|
|
990
|
+
}
|
|
991
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
992
|
+
}
|
|
993
|
+
function claudeUserText(candidate, record, message, blocks, eventIndex) {
|
|
994
|
+
const origin = record.origin;
|
|
995
|
+
const canonical = transcriptMessage(candidate, record, typeof message.role === 'string' ? message.role : 'user', blocks, {
|
|
996
|
+
entrypoint: stringField(record.entrypoint),
|
|
997
|
+
isMeta: record.isMeta === true ? true : undefined,
|
|
998
|
+
isSidechain: record.isSidechain === true ? true : undefined,
|
|
999
|
+
isCompactSummary: record.isCompactSummary === true ? true : undefined,
|
|
1000
|
+
originKind: origin && typeof origin.kind === 'string' ? origin.kind : undefined,
|
|
1001
|
+
promptSource: stringField(record.promptSource),
|
|
1002
|
+
}, eventIndex);
|
|
1003
|
+
return humanPromptText(canonical, 'claude-code');
|
|
1004
|
+
}
|
|
1005
|
+
class ClaudeLineScanner extends LineScanner {
|
|
1006
|
+
lineIndex = 0;
|
|
1007
|
+
promptTextOf(record) {
|
|
1008
|
+
const message = record.message;
|
|
1009
|
+
if (record.type !== 'user' || typeof message !== 'object' || message === null)
|
|
1010
|
+
return null;
|
|
1011
|
+
return claudeUserText(this.candidate, record, message, claudeBlocks(message.content), 0);
|
|
1012
|
+
}
|
|
1013
|
+
promptPlaceOf(record) {
|
|
1014
|
+
return { cwd: record.cwd, gitBranch: stringField(record.gitBranch) };
|
|
1015
|
+
}
|
|
1016
|
+
accept(record, lineOffset, lineBytes) {
|
|
1017
|
+
const index = this.lineIndex++;
|
|
1018
|
+
const message = record.message;
|
|
1019
|
+
if (typeof message !== 'object' || message === null)
|
|
1020
|
+
return 'ok';
|
|
1021
|
+
if (record.type === 'user') {
|
|
1022
|
+
const blocks = claudeBlocks(message.content);
|
|
1023
|
+
const text = claudeUserText(this.candidate, record, message, blocks, index);
|
|
1024
|
+
if (text === null) {
|
|
1025
|
+
// A tool result means the first answering call already finished.
|
|
1026
|
+
if (this.open !== null &&
|
|
1027
|
+
this.open.answerId !== null &&
|
|
1028
|
+
record.isSidechain !== true &&
|
|
1029
|
+
blocks.some((block) => block.type === 'tool_result')) {
|
|
1030
|
+
this.closeOpen();
|
|
1031
|
+
}
|
|
1032
|
+
return 'ok';
|
|
1033
|
+
}
|
|
1034
|
+
this.recordPrompt(text, record.timestamp, record.cwd, stringField(record.gitBranch), lineOffset, lineBytes);
|
|
1035
|
+
return 'ok';
|
|
1036
|
+
}
|
|
1037
|
+
const open = this.open;
|
|
1038
|
+
if (record.type !== 'assistant' || !open || record.isSidechain === true)
|
|
1039
|
+
return 'ok';
|
|
1040
|
+
// Synthetic records (API errors, interrupts) are not model calls.
|
|
1041
|
+
if (message.model === '<synthetic>')
|
|
1042
|
+
return 'ok';
|
|
1043
|
+
const id = stringField(message.id) ?? `line:${lineOffset}`;
|
|
1044
|
+
if (open.answerId === null)
|
|
1045
|
+
open.answerId = id;
|
|
1046
|
+
if (id !== open.answerId) {
|
|
1047
|
+
// A later tool-loop call: the first answering call is over and keeps its attribution.
|
|
1048
|
+
this.closeOpen();
|
|
1049
|
+
return 'ok';
|
|
1050
|
+
}
|
|
1051
|
+
const entry = open.entry;
|
|
1052
|
+
const model = stringField(message.model);
|
|
1053
|
+
if (model)
|
|
1054
|
+
entry.model = model;
|
|
1055
|
+
const usage = message.usage;
|
|
1056
|
+
if (usage && typeof usage === 'object') {
|
|
1057
|
+
// Split records of one call repeat its usage; the latest replaces, never adds.
|
|
1058
|
+
const mapped = compactUsage({
|
|
1059
|
+
inputTokens: tokenCount(usage.input_tokens),
|
|
1060
|
+
outputTokens: tokenCount(usage.output_tokens),
|
|
1061
|
+
cacheReadTokens: tokenCount(usage.cache_read_input_tokens),
|
|
1062
|
+
cacheWriteTokens: tokenCount(usage.cache_creation_input_tokens),
|
|
1063
|
+
});
|
|
1064
|
+
if (mapped)
|
|
1065
|
+
entry.usage = mapped;
|
|
1066
|
+
}
|
|
1067
|
+
// Claude Code writes the call's final stop_reason and usage on every split record.
|
|
1068
|
+
if (stringField(message.stop_reason))
|
|
1069
|
+
this.closeOpen();
|
|
1070
|
+
else
|
|
1071
|
+
entry.metadataStatus = 'provisional';
|
|
1072
|
+
return 'ok';
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
function claudeBlocks(content) {
|
|
1076
|
+
if (typeof content === 'string')
|
|
1077
|
+
return [{ type: 'text', text: content }];
|
|
1078
|
+
if (!Array.isArray(content))
|
|
1079
|
+
return [];
|
|
1080
|
+
const blocks = [];
|
|
1081
|
+
for (const item of content) {
|
|
1082
|
+
if (typeof item !== 'object' || item === null)
|
|
1083
|
+
continue;
|
|
1084
|
+
const block = item;
|
|
1085
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
1086
|
+
blocks.push({ type: 'text', text: block.text });
|
|
1087
|
+
}
|
|
1088
|
+
else if (block.type === 'tool_result') {
|
|
1089
|
+
blocks.push({ type: 'tool_result' });
|
|
1090
|
+
}
|
|
1091
|
+
else if (block.type === 'image') {
|
|
1092
|
+
blocks.push({ type: 'image' });
|
|
1093
|
+
}
|
|
1094
|
+
else {
|
|
1095
|
+
blocks.push({ type: 'unknown' });
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return blocks;
|
|
1099
|
+
}
|
|
1100
|
+
class CodexLineScanner extends LineScanner {
|
|
1101
|
+
lineIndex = 0;
|
|
1102
|
+
state;
|
|
1103
|
+
constructor(candidate, plan, context) {
|
|
1104
|
+
super(candidate, plan, context);
|
|
1105
|
+
// A replay restarts at the open prompt's line, so it needs the state from just before it.
|
|
1106
|
+
const initial = plan.kind === 'replay'
|
|
1107
|
+
? plan.pending.state
|
|
1108
|
+
: plan.kind === 'continue'
|
|
1109
|
+
? plan.session.state
|
|
1110
|
+
: undefined;
|
|
1111
|
+
this.state = { ...(initial ?? {}) };
|
|
1112
|
+
}
|
|
1113
|
+
resumeState() {
|
|
1114
|
+
return { ...this.state };
|
|
1115
|
+
}
|
|
1116
|
+
promptTextOf(record) {
|
|
1117
|
+
const payload = record.payload;
|
|
1118
|
+
if (!isCodexUserMessage(record, payload))
|
|
1119
|
+
return null;
|
|
1120
|
+
const canonical = transcriptMessage(this.candidate, record, 'user', codexBlocks(payload.content), {}, 0);
|
|
1121
|
+
return humanPromptText(canonical, 'codex');
|
|
1122
|
+
}
|
|
1123
|
+
promptPlaceOf(_record, stateBefore) {
|
|
1124
|
+
return { cwd: stateBefore?.cwd, gitBranch: stateBefore?.gitBranch };
|
|
1125
|
+
}
|
|
1126
|
+
accept(record, lineOffset, lineBytes) {
|
|
1127
|
+
const index = this.lineIndex++;
|
|
1128
|
+
const payload = record.payload;
|
|
1129
|
+
if (typeof payload !== 'object' || payload === null)
|
|
1130
|
+
return 'ok';
|
|
1131
|
+
const state = this.state;
|
|
1132
|
+
if (record.type === 'session_meta') {
|
|
1133
|
+
// Forked sessions embed their parent's session_meta after their own; only the first counts.
|
|
1134
|
+
if (state.meta)
|
|
1135
|
+
return 'ok';
|
|
1136
|
+
state.meta = true;
|
|
1137
|
+
const source = typeof payload.source === 'string' ? payload.source : null;
|
|
1138
|
+
if (!source || !HUMAN_CODEX_SOURCES.includes(source) || stringField(payload.forked_from_id)) {
|
|
1139
|
+
state.excluded = true;
|
|
1140
|
+
return 'excluded';
|
|
1141
|
+
}
|
|
1142
|
+
state.cwd = stringField(payload.cwd);
|
|
1143
|
+
const git = payload.git;
|
|
1144
|
+
state.gitBranch = git && typeof git === 'object' ? stringField(git.branch) : undefined;
|
|
1145
|
+
return 'ok';
|
|
1146
|
+
}
|
|
1147
|
+
if (record.type === 'turn_context') {
|
|
1148
|
+
const cwd = stringField(payload.cwd);
|
|
1149
|
+
if (cwd)
|
|
1150
|
+
state.cwd = cwd;
|
|
1151
|
+
const model = stringField(payload.model);
|
|
1152
|
+
if (model)
|
|
1153
|
+
state.model = model;
|
|
1154
|
+
return 'ok';
|
|
1155
|
+
}
|
|
1156
|
+
if (isCodexUserMessage(record, payload)) {
|
|
1157
|
+
// Without session metadata the source (and thus interactivity) is unknown: fail closed.
|
|
1158
|
+
if (!state.meta) {
|
|
1159
|
+
state.excluded = true;
|
|
1160
|
+
return 'excluded';
|
|
1161
|
+
}
|
|
1162
|
+
const canonical = transcriptMessage(this.candidate, record, 'user', codexBlocks(payload.content), {}, index);
|
|
1163
|
+
const text = humanPromptText(canonical, 'codex');
|
|
1164
|
+
if (text === null)
|
|
1165
|
+
return 'ok';
|
|
1166
|
+
this.recordPrompt(text, record.timestamp, state.cwd, state.gitBranch, lineOffset, lineBytes, {
|
|
1167
|
+
...state,
|
|
1168
|
+
});
|
|
1169
|
+
return 'ok';
|
|
1170
|
+
}
|
|
1171
|
+
const open = this.open;
|
|
1172
|
+
if (record.type === 'event_msg' && payload.type === 'token_count' && open) {
|
|
1173
|
+
// The first token_count after the prompt is the first answering call, complete.
|
|
1174
|
+
const info = payload.info;
|
|
1175
|
+
const last = info && typeof info === 'object' ? info.last_token_usage : undefined;
|
|
1176
|
+
if (!last || typeof last !== 'object')
|
|
1177
|
+
return 'ok';
|
|
1178
|
+
const input = tokenCount(last.input_tokens);
|
|
1179
|
+
const cached = tokenCount(last.cached_input_tokens);
|
|
1180
|
+
const mapped = compactUsage({
|
|
1181
|
+
inputTokens: input !== undefined ? Math.max(0, input - (cached ?? 0)) : undefined,
|
|
1182
|
+
outputTokens: tokenCount(last.output_tokens),
|
|
1183
|
+
cacheReadTokens: cached,
|
|
1184
|
+
cacheWriteTokens: tokenCount(last.cache_write_input_tokens),
|
|
1185
|
+
});
|
|
1186
|
+
if (mapped)
|
|
1187
|
+
open.entry.usage = mapped;
|
|
1188
|
+
if (state.model)
|
|
1189
|
+
open.entry.model = state.model;
|
|
1190
|
+
this.closeOpen();
|
|
1191
|
+
}
|
|
1192
|
+
return 'ok';
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
function isCodexUserMessage(record, payload) {
|
|
1196
|
+
return (record.type === 'response_item' &&
|
|
1197
|
+
typeof payload === 'object' &&
|
|
1198
|
+
payload !== null &&
|
|
1199
|
+
payload.type === 'message' &&
|
|
1200
|
+
payload.role === 'user');
|
|
1201
|
+
}
|
|
1202
|
+
function codexBlocks(content) {
|
|
1203
|
+
if (!Array.isArray(content))
|
|
1204
|
+
return [];
|
|
1205
|
+
const blocks = [];
|
|
1206
|
+
for (const item of content) {
|
|
1207
|
+
if (typeof item !== 'object' || item === null)
|
|
1208
|
+
continue;
|
|
1209
|
+
const block = item;
|
|
1210
|
+
if ((block.type === 'input_text' || block.type === 'text') && typeof block.text === 'string') {
|
|
1211
|
+
blocks.push({ type: 'text', text: block.text });
|
|
1212
|
+
}
|
|
1213
|
+
else if (block.type === 'input_image') {
|
|
1214
|
+
blocks.push({ type: 'image' });
|
|
1215
|
+
}
|
|
1216
|
+
else {
|
|
1217
|
+
blocks.push({ type: 'unknown' });
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
return blocks;
|
|
1221
|
+
}
|