sidekick-shared 0.24.5 → 0.26.0
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 +92 -0
- package/README.md +205 -52
- package/dist/accountChangeSignal.d.ts +3 -0
- package/dist/accountChangeSignal.js +19 -0
- package/dist/accountChanges.d.ts +21 -0
- package/dist/accountChanges.js +170 -0
- package/dist/accountRegistry.d.ts +25 -2
- package/dist/accountRegistry.js +41 -13
- package/dist/accountStatus.d.ts +9 -1
- package/dist/accountStatus.js +3 -3
- package/dist/accounts.d.ts +3 -1
- package/dist/accounts.js +84 -64
- package/dist/aggregation/EventAggregator.d.ts +15 -2
- package/dist/aggregation/EventAggregator.js +54 -9
- package/dist/aggregation/costProvenance.d.ts +14 -0
- package/dist/aggregation/costProvenance.js +35 -0
- package/dist/aggregation/snapshot.d.ts +13 -2
- package/dist/aggregation/snapshot.js +57 -6
- package/dist/aggregation/types.d.ts +30 -1
- package/dist/analytics/compactionLedger.js +15 -5
- package/dist/analytics/qualityScore.js +2 -1
- package/dist/browser.d.ts +19 -4
- package/dist/browser.js +43 -1
- package/dist/codexProfiles.d.ts +3 -1
- package/dist/codexProfiles.js +4 -2
- package/dist/codexQuota.d.ts +2 -0
- package/dist/codexQuota.js +11 -66
- package/dist/codexQuotaWatcher.d.ts +6 -0
- package/dist/codexQuotaWatcher.js +101 -47
- package/dist/failingTools.d.ts +37 -0
- package/dist/failingTools.js +70 -0
- package/dist/formatters/sessionDump.js +13 -7
- package/dist/formatting.d.ts +13 -0
- package/dist/formatting.js +35 -0
- package/dist/historicalStore.d.ts +47 -0
- package/dist/historicalStore.js +312 -0
- package/dist/index.d.ts +67 -23
- package/dist/index.js +138 -26
- package/dist/modelAliases.d.ts +13 -0
- package/dist/modelAliases.js +58 -0
- package/dist/modelCatalog.d.ts +32 -0
- package/dist/modelCatalog.js +145 -0
- package/dist/modelContext.d.ts +36 -3
- package/dist/modelContext.js +109 -34
- package/dist/modelInfo.d.ts +30 -1
- package/dist/modelInfo.js +116 -26
- package/dist/multiProviderQuotaService.d.ts +8 -0
- package/dist/multiProviderQuotaService.js +64 -3
- package/dist/node.d.ts +23 -4
- package/dist/node.js +50 -1
- package/dist/observedContextWindows.d.ts +7 -0
- package/dist/observedContextWindows.js +17 -2
- package/dist/observedSessionCollector.d.ts +72 -6
- package/dist/observedSessionCollector.js +440 -47
- package/dist/parsers/codexHistory.d.ts +4 -2
- package/dist/parsers/codexHistory.js +9 -2
- package/dist/parsers/sessionActivityDetector.d.ts +28 -0
- package/dist/parsers/sessionActivityDetector.js +110 -15
- package/dist/parsers/sessionPathResolver.d.ts +11 -0
- package/dist/parsers/sessionPathResolver.js +13 -10
- package/dist/peakHours.d.ts +34 -3
- package/dist/peakHours.js +92 -7
- package/dist/pricingCatalog.d.ts +7 -0
- package/dist/pricingCatalog.js +8 -5
- package/dist/providerIds.d.ts +11 -0
- package/dist/providerIds.js +9 -0
- package/dist/providerQuota.d.ts +3 -1
- package/dist/providerQuota.js +3 -0
- package/dist/providers/claudeCode.d.ts +10 -1
- package/dist/providers/claudeCode.js +211 -131
- package/dist/providers/codex.d.ts +20 -1
- package/dist/providers/codex.js +308 -244
- package/dist/providers/codexDatabase.d.ts +9 -0
- package/dist/providers/codexDatabase.js +107 -2
- package/dist/providers/detect.d.ts +2 -0
- package/dist/providers/detect.js +29 -24
- package/dist/providers/diagnostics.d.ts +13 -0
- package/dist/providers/diagnostics.js +88 -0
- package/dist/providers/factory.d.ts +20 -0
- package/dist/providers/factory.js +57 -0
- package/dist/providers/openCode.d.ts +22 -1
- package/dist/providers/openCode.js +460 -126
- package/dist/providers/openCodeDatabase.d.ts +10 -0
- package/dist/providers/openCodeDatabase.js +69 -0
- package/dist/providers/rolloutWalker.d.ts +53 -0
- package/dist/providers/rolloutWalker.js +209 -0
- package/dist/providers/types.d.ts +82 -2
- package/dist/providers/types.js +4 -0
- package/dist/quota.d.ts +34 -4
- package/dist/quota.js +37 -9
- package/dist/quotaHistory.d.ts +3 -2
- package/dist/quotaHistory.js +36 -35
- package/dist/quotaPoller.d.ts +13 -1
- package/dist/quotaPoller.js +65 -3
- package/dist/quotaResolver.d.ts +81 -0
- package/dist/quotaResolver.js +213 -0
- package/dist/quotaSnapshots.d.ts +15 -2
- package/dist/quotaSnapshots.js +25 -3
- package/dist/quotaThresholds.d.ts +60 -0
- package/dist/quotaThresholds.js +90 -0
- package/dist/report/htmlReportGenerator.js +10 -4
- package/dist/report/sessionReportInputs.d.ts +23 -0
- package/dist/report/sessionReportInputs.js +34 -0
- package/dist/schemas/accountManager.d.ts +2 -2
- package/dist/schemas/accountManager.js +2 -4
- package/dist/schemas/accountStatus.d.ts +3 -0
- package/dist/schemas/accountStatus.js +1 -0
- package/dist/schemas/index.d.ts +3 -0
- package/dist/schemas/index.js +16 -2
- package/dist/schemas/observedSessionV1.d.ts +27 -26
- package/dist/schemas/observedSessionV1.js +6 -4
- package/dist/schemas/quota.d.ts +141 -2
- package/dist/schemas/quota.js +20 -8
- package/dist/schemas/quotaHistory.d.ts +3 -2
- package/dist/schemas/quotaHistory.js +1 -1
- package/dist/schemas/sessionEvent.d.ts +6 -0
- package/dist/schemas/sessionEvent.js +1 -0
- package/dist/schemas/stateFile.d.ts +198 -0
- package/dist/schemas/stateFile.js +77 -0
- package/dist/schemas/tokenEstimation.d.ts +1 -1
- package/dist/schemas/transcript.js +2 -0
- package/dist/sessionFingerprint.d.ts +25 -0
- package/dist/sessionFingerprint.js +46 -0
- package/dist/sessionMonitor.d.ts +15 -0
- package/dist/sessionMonitor.js +142 -0
- package/dist/sessionPreviews.d.ts +33 -2
- package/dist/sessionPreviews.js +333 -6
- package/dist/sessionStats.d.ts +51 -0
- package/dist/sessionStats.js +166 -0
- package/dist/stateFile.d.ts +113 -0
- package/dist/stateFile.js +160 -0
- package/dist/statusline/claudeStatuslinePayload.d.ts +89 -0
- package/dist/statusline/claudeStatuslinePayload.js +152 -0
- package/dist/statusline/formatter.d.ts +13 -0
- package/dist/statusline/formatter.js +48 -6
- package/dist/statusline/index.d.ts +10 -2
- package/dist/statusline/index.js +19 -1
- package/dist/tokenSummary.d.ts +62 -0
- package/dist/tokenSummary.js +72 -0
- package/dist/transcript.d.ts +5 -0
- package/dist/transcript.js +2 -0
- package/dist/types/observedSessionV1.d.ts +8 -2
- package/dist/types/observedSessionV1.js +88 -25
- package/dist/types/sessionEvent.d.ts +2 -0
- package/dist/usage/billingBlocks.d.ts +85 -0
- package/dist/usage/billingBlocks.js +153 -0
- package/dist/usage/importSessionHistory.d.ts +53 -0
- package/dist/usage/importSessionHistory.js +88 -0
- package/dist/usage/usageEvents.d.ts +92 -0
- package/dist/usage/usageEvents.js +322 -0
- package/dist/usage/usageReports.d.ts +76 -0
- package/dist/usage/usageReports.js +208 -0
- package/dist/watchers/factory.d.ts +6 -0
- package/dist/watchers/factory.js +30 -25
- package/dist/zaiQuotaApi.d.ts +2 -0
- package/dist/zaiQuotaApi.js +2 -0
- package/package.json +22 -1
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*
|
|
18
18
|
* @module sessionPreviews
|
|
19
19
|
*/
|
|
20
|
-
import type { ProviderId, SessionProviderBase } from './providers/types';
|
|
20
|
+
import type { ProviderId, SessionProviderBase, SessionProviderDiagnostic } from './providers/types';
|
|
21
21
|
export interface SessionPreview {
|
|
22
22
|
provider: ProviderId;
|
|
23
23
|
sessionId: string;
|
|
@@ -36,7 +36,11 @@ export interface SessionPreview {
|
|
|
36
36
|
workspacePath: string | null;
|
|
37
37
|
}
|
|
38
38
|
export interface ReadSessionPreviewOptions {
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Byte budget for the generic prefix scan (default 16 KiB). `0` skips the
|
|
41
|
+
* scan entirely for callers that ignore `firstTimestamp` and
|
|
42
|
+
* `workspacePath`; both come back `null` and the file is not opened.
|
|
43
|
+
*/
|
|
40
44
|
maxPrefixBytes?: number;
|
|
41
45
|
}
|
|
42
46
|
export interface ListSessionPreviewsOptions extends ReadSessionPreviewOptions {
|
|
@@ -47,14 +51,41 @@ export interface ListSessionPreviewsOptions extends ReadSessionPreviewOptions {
|
|
|
47
51
|
/** Skip files whose mtime is not strictly newer than this — cheap incremental refresh. */
|
|
48
52
|
since?: Date | string;
|
|
49
53
|
}
|
|
54
|
+
export interface AsyncSessionPreviewOptions {
|
|
55
|
+
/** Maximum concurrent stat/read units (default 4, hard-capped at 16). */
|
|
56
|
+
concurrency?: number;
|
|
57
|
+
/** Cooperative scheduler hook; defaults to setImmediate. */
|
|
58
|
+
yieldBetweenReads?: () => Promise<void> | void;
|
|
59
|
+
}
|
|
60
|
+
export interface ReadSessionPreviewAsyncOptions extends ReadSessionPreviewOptions, AsyncSessionPreviewOptions {
|
|
61
|
+
}
|
|
62
|
+
export interface ListSessionPreviewsAsyncOptions extends ListSessionPreviewsOptions, AsyncSessionPreviewOptions {
|
|
63
|
+
}
|
|
64
|
+
export interface SessionPreviewReadResult {
|
|
65
|
+
preview: SessionPreview | null;
|
|
66
|
+
diagnostics: SessionProviderDiagnostic[];
|
|
67
|
+
}
|
|
68
|
+
export interface SessionPreviewListResult {
|
|
69
|
+
previews: SessionPreview[];
|
|
70
|
+
diagnostics: SessionProviderDiagnostic[];
|
|
71
|
+
/** True when more candidates existed than `limit` allowed; the caller may offer a larger limit. */
|
|
72
|
+
hasMore: boolean;
|
|
73
|
+
}
|
|
50
74
|
/**
|
|
51
75
|
* Reads a bounded preview of one session file. Returns `null` when the file is
|
|
52
76
|
* missing or unreadable; malformed content degrades to `null` fields instead
|
|
53
77
|
* of throwing.
|
|
54
78
|
*/
|
|
55
79
|
export declare function readSessionPreview(provider: SessionProviderBase, sessionPath: string, options?: ReadSessionPreviewOptions): SessionPreview | null;
|
|
80
|
+
/** Event-loop-safe counterpart to readSessionPreview with per-call degradation status. */
|
|
81
|
+
export declare function readSessionPreviewAsync(provider: SessionProviderBase, sessionPath: string, options?: ReadSessionPreviewAsyncOptions): Promise<SessionPreviewReadResult>;
|
|
56
82
|
/**
|
|
57
83
|
* Lists previews across providers, newest first. Enumeration is stat-only;
|
|
58
84
|
* content is read only for the post-`limit` survivors.
|
|
59
85
|
*/
|
|
60
86
|
export declare function listSessionPreviews(providers: SessionProviderBase[], options?: ListSessionPreviewsOptions): SessionPreview[];
|
|
87
|
+
/**
|
|
88
|
+
* Lists previews without blocking the caller loop on corpus-wide synchronous
|
|
89
|
+
* work. SQLite-backed providers perform at most one subprocess per call.
|
|
90
|
+
*/
|
|
91
|
+
export declare function listSessionPreviewsAsync(providers: SessionProviderBase[], options?: ListSessionPreviewsAsyncOptions): Promise<SessionPreviewListResult>;
|
package/dist/sessionPreviews.js
CHANGED
|
@@ -53,26 +53,49 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
53
53
|
})();
|
|
54
54
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
55
|
exports.readSessionPreview = readSessionPreview;
|
|
56
|
+
exports.readSessionPreviewAsync = readSessionPreviewAsync;
|
|
56
57
|
exports.listSessionPreviews = listSessionPreviews;
|
|
58
|
+
exports.listSessionPreviewsAsync = listSessionPreviewsAsync;
|
|
57
59
|
const fs = __importStar(require("fs"));
|
|
60
|
+
function emptyPrefixScan() {
|
|
61
|
+
return { firstTimestamp: null, workspacePath: null };
|
|
62
|
+
}
|
|
58
63
|
const DEFAULT_PREFIX_BYTES = 16 * 1024;
|
|
59
64
|
const DEFAULT_LIMIT = 50;
|
|
65
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
66
|
+
const MAX_CONCURRENCY = 16;
|
|
60
67
|
/**
|
|
61
68
|
* Reads a bounded preview of one session file. Returns `null` when the file is
|
|
62
69
|
* missing or unreadable; malformed content degrades to `null` fields instead
|
|
63
70
|
* of throwing.
|
|
64
71
|
*/
|
|
65
72
|
function readSessionPreview(provider, sessionPath, options = {}) {
|
|
66
|
-
let
|
|
73
|
+
let sizeBytes;
|
|
67
74
|
let modifiedAt;
|
|
68
75
|
try {
|
|
69
|
-
stats = fs.statSync(sessionPath);
|
|
76
|
+
const stats = fs.statSync(sessionPath);
|
|
77
|
+
sizeBytes = stats.size;
|
|
70
78
|
// Inside the try: an out-of-range mtime RangeErrors on serialization,
|
|
71
79
|
// and this function's contract is to degrade, not throw.
|
|
72
80
|
modifiedAt = stats.mtime.toISOString();
|
|
73
81
|
}
|
|
74
82
|
catch {
|
|
75
|
-
|
|
83
|
+
let metadata;
|
|
84
|
+
try {
|
|
85
|
+
metadata = provider.getSessionMetadata?.(sessionPath);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
if (!metadata)
|
|
91
|
+
return null;
|
|
92
|
+
sizeBytes = 0;
|
|
93
|
+
try {
|
|
94
|
+
modifiedAt = metadata.mtime.toISOString();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
76
99
|
}
|
|
77
100
|
let firstUserPrompt = null;
|
|
78
101
|
try {
|
|
@@ -88,18 +111,49 @@ function readSessionPreview(provider, sessionPath, options = {}) {
|
|
|
88
111
|
catch {
|
|
89
112
|
// A provider that cannot identify the file still yields a usable preview.
|
|
90
113
|
}
|
|
91
|
-
const
|
|
114
|
+
const prefixBudget = options.maxPrefixBytes ?? DEFAULT_PREFIX_BYTES;
|
|
115
|
+
const prefix = prefixBudget > 0 ? scanPrefix(sessionPath, prefixBudget) : emptyPrefixScan();
|
|
92
116
|
return {
|
|
93
117
|
provider: provider.id,
|
|
94
118
|
sessionId,
|
|
95
119
|
filePath: sessionPath,
|
|
96
120
|
modifiedAt,
|
|
97
|
-
sizeBytes
|
|
121
|
+
sizeBytes,
|
|
98
122
|
firstUserPrompt,
|
|
99
123
|
firstTimestamp: prefix.firstTimestamp,
|
|
100
124
|
workspacePath: prefix.workspacePath,
|
|
101
125
|
};
|
|
102
126
|
}
|
|
127
|
+
/** Event-loop-safe counterpart to readSessionPreview with per-call degradation status. */
|
|
128
|
+
async function readSessionPreviewAsync(provider, sessionPath, options = {}) {
|
|
129
|
+
let labels;
|
|
130
|
+
const diagnostics = [];
|
|
131
|
+
try {
|
|
132
|
+
labels = await extractLabelsAsync(provider, [sessionPath], options);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
labels = new Map([[sessionPath, null]]);
|
|
136
|
+
diagnostics.push({
|
|
137
|
+
providerId: provider.id,
|
|
138
|
+
kind: 'read_failed',
|
|
139
|
+
severity: 'warning',
|
|
140
|
+
phase: 'read',
|
|
141
|
+
message: `${provider.id} session label was unavailable.`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
const info = await statSessionFileAsync(provider, sessionPath);
|
|
145
|
+
if (!info) {
|
|
146
|
+
return {
|
|
147
|
+
preview: null,
|
|
148
|
+
diagnostics: dedupeDiagnostics([...diagnostics, ...providerDiagnostics(provider)]),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
const preview = await readSessionPreviewFromInfoAsync(provider, info, labels.get(sessionPath) ?? null, options);
|
|
152
|
+
return {
|
|
153
|
+
preview,
|
|
154
|
+
diagnostics: dedupeDiagnostics([...diagnostics, ...providerDiagnostics(provider)]),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
103
157
|
/**
|
|
104
158
|
* Lists previews across providers, newest first. Enumeration is stat-only;
|
|
105
159
|
* content is read only for the post-`limit` survivors.
|
|
@@ -125,11 +179,87 @@ function listSessionPreviews(providers, options = {}) {
|
|
|
125
179
|
}
|
|
126
180
|
return previews;
|
|
127
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Lists previews without blocking the caller loop on corpus-wide synchronous
|
|
184
|
+
* work. SQLite-backed providers perform at most one subprocess per call.
|
|
185
|
+
*/
|
|
186
|
+
async function listSessionPreviewsAsync(providers, options = {}) {
|
|
187
|
+
const limit = Math.max(0, options.limit ?? DEFAULT_LIMIT);
|
|
188
|
+
const sinceMs = resolveSinceMs(options.since);
|
|
189
|
+
const candidates = [];
|
|
190
|
+
const operationDiagnostics = [];
|
|
191
|
+
await Promise.all(providers.map(async (provider) => {
|
|
192
|
+
let files = [];
|
|
193
|
+
try {
|
|
194
|
+
files = provider.listSessionFilesAsync
|
|
195
|
+
? await provider.listSessionFilesAsync(options.workspacePath)
|
|
196
|
+
: await enumerateSessionFilesAsyncFallback(provider, options.workspacePath, options);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
files = [];
|
|
200
|
+
operationDiagnostics.push({
|
|
201
|
+
providerId: provider.id,
|
|
202
|
+
kind: 'read_failed',
|
|
203
|
+
severity: 'error',
|
|
204
|
+
phase: 'enumerate',
|
|
205
|
+
message: `${provider.id} session enumeration failed.`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
for (const info of files) {
|
|
209
|
+
const mtimeMs = info.mtime.getTime();
|
|
210
|
+
if (sinceMs !== null && mtimeMs <= sinceMs)
|
|
211
|
+
continue;
|
|
212
|
+
candidates.push({ provider, info });
|
|
213
|
+
}
|
|
214
|
+
}));
|
|
215
|
+
candidates.sort((left, right) => right.info.mtime.getTime() - left.info.mtime.getTime());
|
|
216
|
+
const selected = candidates.slice(0, limit);
|
|
217
|
+
const labels = new Map();
|
|
218
|
+
await Promise.all(providers.map(async (provider) => {
|
|
219
|
+
const paths = selected
|
|
220
|
+
.filter((item) => item.provider === provider)
|
|
221
|
+
.map((item) => item.info.path);
|
|
222
|
+
if (paths.length === 0)
|
|
223
|
+
return;
|
|
224
|
+
try {
|
|
225
|
+
labels.set(provider, await extractLabelsAsync(provider, paths, options));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
labels.set(provider, new Map(paths.map((sessionPath) => [sessionPath, null])));
|
|
229
|
+
operationDiagnostics.push({
|
|
230
|
+
providerId: provider.id,
|
|
231
|
+
kind: 'read_failed',
|
|
232
|
+
severity: 'warning',
|
|
233
|
+
phase: 'read',
|
|
234
|
+
message: `${provider.id} session labels were unavailable.`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}));
|
|
238
|
+
const previews = await mapWithConcurrency(selected, async ({ provider, info }) => {
|
|
239
|
+
const preview = await readSessionPreviewFromInfoAsync(provider, info, labels.get(provider)?.get(info.path) ?? info.label ?? null, options);
|
|
240
|
+
await yieldControl(options);
|
|
241
|
+
return preview;
|
|
242
|
+
}, concurrency(options));
|
|
243
|
+
return {
|
|
244
|
+
previews: previews.filter((preview) => preview !== null),
|
|
245
|
+
hasMore: candidates.length > limit,
|
|
246
|
+
diagnostics: dedupeDiagnostics([
|
|
247
|
+
...operationDiagnostics,
|
|
248
|
+
...providers.flatMap(providerDiagnostics),
|
|
249
|
+
]),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
128
252
|
function resolveSinceMs(since) {
|
|
129
253
|
if (since === undefined)
|
|
130
254
|
return null;
|
|
131
255
|
const ms = since instanceof Date ? since.getTime() : Date.parse(since);
|
|
132
|
-
|
|
256
|
+
if (!Number.isFinite(ms)) {
|
|
257
|
+
// An unparseable cutoff used to mean "no cutoff", silently returning
|
|
258
|
+
// every session. Fail loudly instead so a typo cannot masquerade as a
|
|
259
|
+
// full listing.
|
|
260
|
+
throw new RangeError(`Invalid \`since\` value: ${String(since)}`);
|
|
261
|
+
}
|
|
262
|
+
return ms;
|
|
133
263
|
}
|
|
134
264
|
function enumerateSessionFiles(provider, workspacePath) {
|
|
135
265
|
try {
|
|
@@ -169,6 +299,151 @@ function statSessionFile(sessionPath) {
|
|
|
169
299
|
return null;
|
|
170
300
|
}
|
|
171
301
|
}
|
|
302
|
+
async function statSessionFileAsync(provider, sessionPath) {
|
|
303
|
+
try {
|
|
304
|
+
const stat = await fs.promises.stat(sessionPath);
|
|
305
|
+
return {
|
|
306
|
+
path: sessionPath,
|
|
307
|
+
mtime: stat.mtime,
|
|
308
|
+
sizeBytes: stat.size,
|
|
309
|
+
sessionId: safeSessionId(provider, sessionPath),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
let metadata = null;
|
|
314
|
+
try {
|
|
315
|
+
// Async first: for DB-backed synthetic paths the sync variant can spawn
|
|
316
|
+
// a blocking sqlite3 subprocess, defeating this API's event-loop safety.
|
|
317
|
+
if (provider.getSessionMetadataAsync) {
|
|
318
|
+
metadata = await provider.getSessionMetadataAsync(sessionPath);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
metadata = provider.getSessionMetadata?.(sessionPath) ?? null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
metadata = null;
|
|
326
|
+
}
|
|
327
|
+
if (!metadata)
|
|
328
|
+
return null;
|
|
329
|
+
return {
|
|
330
|
+
path: sessionPath,
|
|
331
|
+
mtime: metadata.mtime,
|
|
332
|
+
sizeBytes: 0,
|
|
333
|
+
sessionId: safeSessionId(provider, sessionPath),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async function extractLabelsAsync(provider, sessionPaths, options) {
|
|
338
|
+
if (provider.extractSessionLabelsAsync) {
|
|
339
|
+
// Callers deliberately do not retry through the synchronous provider path
|
|
340
|
+
// if this batch fails: DB-backed providers could otherwise spawn once per
|
|
341
|
+
// session after their single async batch failed.
|
|
342
|
+
return provider.extractSessionLabelsAsync(sessionPaths);
|
|
343
|
+
}
|
|
344
|
+
const values = new Map();
|
|
345
|
+
for (const sessionPath of sessionPaths) {
|
|
346
|
+
values.set(sessionPath, await extractLabelWithYield(provider, sessionPath, options));
|
|
347
|
+
}
|
|
348
|
+
return values;
|
|
349
|
+
}
|
|
350
|
+
async function enumerateSessionFilesAsyncFallback(provider, workspacePath, options) {
|
|
351
|
+
await yieldControl(options);
|
|
352
|
+
const paths = workspacePath
|
|
353
|
+
? provider.findAllSessions(workspacePath)
|
|
354
|
+
: provider.listAllSessionFiles
|
|
355
|
+
? provider.listAllSessionFiles().map((item) => item.path)
|
|
356
|
+
: provider
|
|
357
|
+
.getAllProjectFolders()
|
|
358
|
+
.flatMap((folder) => provider.findSessionsInDirectory(folder.dir));
|
|
359
|
+
return (await mapWithConcurrency([...new Set(paths)], async (sessionPath) => {
|
|
360
|
+
const info = await statSessionFileAsync(provider, sessionPath);
|
|
361
|
+
await yieldControl(options);
|
|
362
|
+
return info;
|
|
363
|
+
}, concurrency(options))).filter((item) => item !== null);
|
|
364
|
+
}
|
|
365
|
+
async function readSessionPreviewFromInfoAsync(provider, info, firstUserPrompt, options) {
|
|
366
|
+
let modifiedAt;
|
|
367
|
+
try {
|
|
368
|
+
modifiedAt = info.mtime.toISOString();
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
const prefixBudget = options.maxPrefixBytes ?? DEFAULT_PREFIX_BYTES;
|
|
374
|
+
const prefix = prefixBudget > 0 ? await scanPrefixAsync(info.path, prefixBudget) : emptyPrefixScan();
|
|
375
|
+
return {
|
|
376
|
+
provider: provider.id,
|
|
377
|
+
sessionId: info.sessionId ?? safeSessionId(provider, info.path),
|
|
378
|
+
filePath: info.path,
|
|
379
|
+
modifiedAt,
|
|
380
|
+
sizeBytes: info.sizeBytes ?? 0,
|
|
381
|
+
firstUserPrompt,
|
|
382
|
+
firstTimestamp: prefix.firstTimestamp ?? info.createdAt?.toISOString() ?? null,
|
|
383
|
+
workspacePath: prefix.workspacePath ?? info.workspacePath ?? null,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
async function extractLabelWithYield(provider, sessionPath, options) {
|
|
387
|
+
await yieldControl(options);
|
|
388
|
+
try {
|
|
389
|
+
return provider.extractSessionLabel(sessionPath);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function safeSessionId(provider, sessionPath) {
|
|
396
|
+
try {
|
|
397
|
+
return provider.getSessionId(sessionPath);
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return '';
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function concurrency(options) {
|
|
404
|
+
const requested = options.concurrency ?? DEFAULT_CONCURRENCY;
|
|
405
|
+
if (!Number.isFinite(requested))
|
|
406
|
+
return DEFAULT_CONCURRENCY;
|
|
407
|
+
return Math.min(MAX_CONCURRENCY, Math.max(1, Math.floor(requested)));
|
|
408
|
+
}
|
|
409
|
+
async function yieldControl(options) {
|
|
410
|
+
if (options.yieldBetweenReads) {
|
|
411
|
+
await options.yieldBetweenReads();
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
415
|
+
}
|
|
416
|
+
async function mapWithConcurrency(values, mapper, maxConcurrency) {
|
|
417
|
+
const results = new Array(values.length);
|
|
418
|
+
let cursor = 0;
|
|
419
|
+
await Promise.all(Array.from({ length: Math.min(maxConcurrency, values.length) }, async () => {
|
|
420
|
+
while (true) {
|
|
421
|
+
const index = cursor++;
|
|
422
|
+
if (index >= values.length)
|
|
423
|
+
return;
|
|
424
|
+
results[index] = await mapper(values[index]);
|
|
425
|
+
}
|
|
426
|
+
}));
|
|
427
|
+
return results;
|
|
428
|
+
}
|
|
429
|
+
function providerDiagnostics(provider) {
|
|
430
|
+
try {
|
|
431
|
+
return [...(provider.getLastOperationStatus?.().diagnostics ?? [])];
|
|
432
|
+
}
|
|
433
|
+
catch {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function dedupeDiagnostics(diagnostics) {
|
|
438
|
+
const seen = new Set();
|
|
439
|
+
return diagnostics.filter((diagnostic) => {
|
|
440
|
+
const key = `${diagnostic.providerId}\0${diagnostic.kind}\0${diagnostic.phase}`;
|
|
441
|
+
if (seen.has(key))
|
|
442
|
+
return false;
|
|
443
|
+
seen.add(key);
|
|
444
|
+
return true;
|
|
445
|
+
});
|
|
446
|
+
}
|
|
172
447
|
/**
|
|
173
448
|
* Parses complete JSONL lines out of the first `maxPrefixBytes` of a file,
|
|
174
449
|
* taking the first top-level `timestamp` string and the first workspace path
|
|
@@ -232,3 +507,55 @@ function scanPrefix(sessionPath, maxPrefixBytes) {
|
|
|
232
507
|
}
|
|
233
508
|
return result;
|
|
234
509
|
}
|
|
510
|
+
async function scanPrefixAsync(sessionPath, maxPrefixBytes) {
|
|
511
|
+
const result = { firstTimestamp: null, workspacePath: null };
|
|
512
|
+
let handle = null;
|
|
513
|
+
try {
|
|
514
|
+
handle = await fs.promises.open(sessionPath, 'r');
|
|
515
|
+
const stats = await handle.stat();
|
|
516
|
+
const readBytes = Math.min(stats.size, Math.max(1, maxPrefixBytes));
|
|
517
|
+
const buffer = Buffer.alloc(readBytes);
|
|
518
|
+
const { bytesRead } = await handle.read(buffer, 0, readBytes, 0);
|
|
519
|
+
parsePrefixInto(result, buffer.toString('utf8', 0, bytesRead), bytesRead >= stats.size);
|
|
520
|
+
}
|
|
521
|
+
catch {
|
|
522
|
+
return result;
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
await handle?.close().catch(() => undefined);
|
|
526
|
+
}
|
|
527
|
+
return result;
|
|
528
|
+
}
|
|
529
|
+
function parsePrefixInto(result, content, sawWholeFile) {
|
|
530
|
+
const lines = content.split('\n');
|
|
531
|
+
const completeLines = sawWholeFile ? lines : lines.slice(0, -1);
|
|
532
|
+
for (const line of completeLines) {
|
|
533
|
+
const trimmed = line.trim();
|
|
534
|
+
if (!trimmed)
|
|
535
|
+
continue;
|
|
536
|
+
let parsed;
|
|
537
|
+
try {
|
|
538
|
+
parsed = JSON.parse(trimmed);
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
if (parsed === null || typeof parsed !== 'object')
|
|
544
|
+
continue;
|
|
545
|
+
if (result.firstTimestamp === null && typeof parsed.timestamp === 'string') {
|
|
546
|
+
result.firstTimestamp = parsed.timestamp;
|
|
547
|
+
}
|
|
548
|
+
if (result.workspacePath === null) {
|
|
549
|
+
if (typeof parsed.cwd === 'string' && parsed.cwd)
|
|
550
|
+
result.workspacePath = parsed.cwd;
|
|
551
|
+
else if (parsed.type === 'session_meta') {
|
|
552
|
+
const payload = parsed.payload;
|
|
553
|
+
if (payload && typeof payload.cwd === 'string' && payload.cwd) {
|
|
554
|
+
result.workspacePath = payload.cwd;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (result.firstTimestamp !== null && result.workspacePath !== null)
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One `readSessionStats()` implementation for every session provider.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code, Codex, and OpenCode used to compute session stats three
|
|
5
|
+
* different ways (a hand-rolled JSONL scan, an aggregator drain, and a direct
|
|
6
|
+
* SQLite read with hardcoded zeros). Every provider now drains its reader once
|
|
7
|
+
* into the shared `EventAggregator`, so per-model totals use the
|
|
8
|
+
* cache-inclusive vocabulary, cost carries provenance, tool failures are
|
|
9
|
+
* split out, and compaction and truncation counts are real for all three.
|
|
10
|
+
*/
|
|
11
|
+
import type { EventAggregatorOptions } from './aggregation/types';
|
|
12
|
+
import type { ProviderId, SessionFileStats, SessionFileStatsAvailability, SessionProviderBase } from './providers/types';
|
|
13
|
+
import type { SessionEvent } from './types/sessionEvent';
|
|
14
|
+
export interface ComputeSessionFileStatsOptions {
|
|
15
|
+
providerId: ProviderId;
|
|
16
|
+
sessionId: string;
|
|
17
|
+
filePath: string;
|
|
18
|
+
/** Label to use; derived from the first user prompt when omitted or null. */
|
|
19
|
+
label?: string | null;
|
|
20
|
+
/** Provider-specific context formula, forwarded to the aggregator. */
|
|
21
|
+
computeContextSize?: EventAggregatorOptions['computeContextSize'];
|
|
22
|
+
/** Defaults to `full`. */
|
|
23
|
+
availability?: SessionFileStatsAvailability;
|
|
24
|
+
unavailableReason?: string;
|
|
25
|
+
}
|
|
26
|
+
export interface ReadSessionFileStatsOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Cheap label source consulted before the events (for example a SQLite
|
|
29
|
+
* thread title). Return null to fall back to the first user prompt.
|
|
30
|
+
*/
|
|
31
|
+
resolveLabel?: () => string | null;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* First user prompt in a session, compacted to one line and truncated the
|
|
35
|
+
* way every provider's `extractSessionLabel()` truncates (60 characters).
|
|
36
|
+
*/
|
|
37
|
+
export declare function firstUserPrompt(events: readonly SessionEvent[], maxLength?: number): string | null;
|
|
38
|
+
/** Compute stats for an already-read event array through the shared aggregator. */
|
|
39
|
+
export declare function computeSessionFileStats(events: readonly SessionEvent[], options: ComputeSessionFileStatsOptions): SessionFileStats;
|
|
40
|
+
/** The aggregator's context-size hook for a provider, or undefined for the default formula. */
|
|
41
|
+
export declare function providerContextSizeFn(provider: SessionProviderBase): EventAggregatorOptions['computeContextSize'];
|
|
42
|
+
/**
|
|
43
|
+
* Read a session once through the provider's reader and compute its stats.
|
|
44
|
+
*
|
|
45
|
+
* The reader is drained with `readAll()` and then `flush()`ed so a final line
|
|
46
|
+
* without a trailing newline is included, and it is never opened a second
|
|
47
|
+
* time for the label. A source that cannot be read yields
|
|
48
|
+
* `availability: 'unavailable'` with a reason instead of silent zeros; a
|
|
49
|
+
* reader that reports truncation yields `partial`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function readSessionFileStats(provider: SessionProviderBase, sessionPath: string, options?: ReadSessionFileStatsOptions): SessionFileStats;
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* One `readSessionStats()` implementation for every session provider.
|
|
4
|
+
*
|
|
5
|
+
* Claude Code, Codex, and OpenCode used to compute session stats three
|
|
6
|
+
* different ways (a hand-rolled JSONL scan, an aggregator drain, and a direct
|
|
7
|
+
* SQLite read with hardcoded zeros). Every provider now drains its reader once
|
|
8
|
+
* into the shared `EventAggregator`, so per-model totals use the
|
|
9
|
+
* cache-inclusive vocabulary, cost carries provenance, tool failures are
|
|
10
|
+
* split out, and compaction and truncation counts are real for all three.
|
|
11
|
+
*/
|
|
12
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
13
|
+
exports.firstUserPrompt = firstUserPrompt;
|
|
14
|
+
exports.computeSessionFileStats = computeSessionFileStats;
|
|
15
|
+
exports.providerContextSizeFn = providerContextSizeFn;
|
|
16
|
+
exports.readSessionFileStats = readSessionFileStats;
|
|
17
|
+
const EventAggregator_1 = require("./aggregation/EventAggregator");
|
|
18
|
+
const LABEL_MAX_LENGTH = 60;
|
|
19
|
+
function isTextBlock(block) {
|
|
20
|
+
return (typeof block === 'object' &&
|
|
21
|
+
block !== null &&
|
|
22
|
+
typeof block.text === 'string' &&
|
|
23
|
+
typeof block.type === 'string' &&
|
|
24
|
+
['text', 'input_text', 'output_text'].includes(block.type));
|
|
25
|
+
}
|
|
26
|
+
function messageText(content) {
|
|
27
|
+
if (typeof content === 'string')
|
|
28
|
+
return content;
|
|
29
|
+
if (!Array.isArray(content))
|
|
30
|
+
return null;
|
|
31
|
+
const block = content.find((item) => isTextBlock(item) && item.text.trim().length > 0);
|
|
32
|
+
return block && isTextBlock(block) ? block.text : null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* First user prompt in a session, compacted to one line and truncated the
|
|
36
|
+
* way every provider's `extractSessionLabel()` truncates (60 characters).
|
|
37
|
+
*/
|
|
38
|
+
function firstUserPrompt(events, maxLength = LABEL_MAX_LENGTH) {
|
|
39
|
+
for (const event of events) {
|
|
40
|
+
if (event.type !== 'user')
|
|
41
|
+
continue;
|
|
42
|
+
const text = messageText(event.message?.content);
|
|
43
|
+
if (!text)
|
|
44
|
+
continue;
|
|
45
|
+
const compact = text.replace(/\s+/g, ' ').trim();
|
|
46
|
+
if (!compact)
|
|
47
|
+
continue;
|
|
48
|
+
return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/** Compute stats for an already-read event array through the shared aggregator. */
|
|
53
|
+
function computeSessionFileStats(events, options) {
|
|
54
|
+
const aggregator = new EventAggregator_1.EventAggregator({
|
|
55
|
+
providerId: options.providerId,
|
|
56
|
+
computeContextSize: options.computeContextSize,
|
|
57
|
+
});
|
|
58
|
+
for (const event of events)
|
|
59
|
+
aggregator.processEvent(event);
|
|
60
|
+
const metrics = aggregator.getMetrics();
|
|
61
|
+
const modelUsage = {};
|
|
62
|
+
for (const model of metrics.modelStats) {
|
|
63
|
+
// `tokens` is the aggregator's cache-inclusive total for the model, the
|
|
64
|
+
// same figure `summarizeTokens().total` reports.
|
|
65
|
+
modelUsage[model.model] = {
|
|
66
|
+
calls: model.calls,
|
|
67
|
+
tokens: model.tokens,
|
|
68
|
+
costUsd: model.cost,
|
|
69
|
+
priced: model.priced !== false,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const toolUsage = {};
|
|
73
|
+
const toolFailures = {};
|
|
74
|
+
for (const tool of metrics.toolStats) {
|
|
75
|
+
toolUsage[tool.name] = tool.successCount + tool.failureCount + tool.pendingCount;
|
|
76
|
+
if (tool.failureCount > 0)
|
|
77
|
+
toolFailures[tool.name] = tool.failureCount;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
providerId: options.providerId,
|
|
81
|
+
sessionId: options.sessionId,
|
|
82
|
+
filePath: options.filePath,
|
|
83
|
+
label: options.label ?? firstUserPrompt(events),
|
|
84
|
+
startTime: metrics.sessionStartTime ?? '',
|
|
85
|
+
endTime: metrics.lastEventTime ?? '',
|
|
86
|
+
messageCount: metrics.messageCount,
|
|
87
|
+
tokens: {
|
|
88
|
+
input: metrics.tokens.inputTokens,
|
|
89
|
+
output: metrics.tokens.outputTokens,
|
|
90
|
+
cacheWrite: metrics.tokens.cacheWriteTokens,
|
|
91
|
+
cacheRead: metrics.tokens.cacheReadTokens,
|
|
92
|
+
},
|
|
93
|
+
modelUsage,
|
|
94
|
+
toolUsage,
|
|
95
|
+
toolFailures,
|
|
96
|
+
compactionEstimate: metrics.compactionCount,
|
|
97
|
+
truncationCount: metrics.truncationCount,
|
|
98
|
+
costUsd: metrics.tokens.costUsd,
|
|
99
|
+
costProvenance: metrics.tokens.costProvenance,
|
|
100
|
+
unpricedCalls: metrics.tokens.unpricedCalls,
|
|
101
|
+
availability: options.availability ?? 'full',
|
|
102
|
+
...(options.unavailableReason ? { unavailableReason: options.unavailableReason } : {}),
|
|
103
|
+
reportedCost: metrics.tokens.costUsd,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** The aggregator's context-size hook for a provider, or undefined for the default formula. */
|
|
107
|
+
function providerContextSizeFn(provider) {
|
|
108
|
+
if (!provider.computeContextSize)
|
|
109
|
+
return undefined;
|
|
110
|
+
return (usage) => provider.computeContextSize({ ...usage, model: '', timestamp: new Date() });
|
|
111
|
+
}
|
|
112
|
+
function errorMessage(error) {
|
|
113
|
+
return error instanceof Error ? error.message : String(error);
|
|
114
|
+
}
|
|
115
|
+
function describeMissingSource(provider) {
|
|
116
|
+
const status = provider.getRuntimeStatus?.();
|
|
117
|
+
if (status && !status.available) {
|
|
118
|
+
return status.message ?? `Provider runtime unavailable (${status.kind}).`;
|
|
119
|
+
}
|
|
120
|
+
return 'Session source not found.';
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Read a session once through the provider's reader and compute its stats.
|
|
124
|
+
*
|
|
125
|
+
* The reader is drained with `readAll()` and then `flush()`ed so a final line
|
|
126
|
+
* without a trailing newline is included, and it is never opened a second
|
|
127
|
+
* time for the label. A source that cannot be read yields
|
|
128
|
+
* `availability: 'unavailable'` with a reason instead of silent zeros; a
|
|
129
|
+
* reader that reports truncation yields `partial`.
|
|
130
|
+
*/
|
|
131
|
+
function readSessionFileStats(provider, sessionPath, options = {}) {
|
|
132
|
+
const base = {
|
|
133
|
+
providerId: provider.id,
|
|
134
|
+
sessionId: provider.getSessionId(sessionPath),
|
|
135
|
+
filePath: sessionPath,
|
|
136
|
+
};
|
|
137
|
+
let reader;
|
|
138
|
+
let events;
|
|
139
|
+
try {
|
|
140
|
+
reader = provider.createReader(sessionPath);
|
|
141
|
+
events = reader.readAll();
|
|
142
|
+
// Readers buffer line-oriented input; flushing emits a trailing partial
|
|
143
|
+
// line into the same array `readAll()` returned.
|
|
144
|
+
reader.flush();
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
return computeSessionFileStats([], {
|
|
148
|
+
...base,
|
|
149
|
+
availability: 'unavailable',
|
|
150
|
+
unavailableReason: errorMessage(error),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (events.length === 0 && !reader.exists()) {
|
|
154
|
+
return computeSessionFileStats([], {
|
|
155
|
+
...base,
|
|
156
|
+
availability: 'unavailable',
|
|
157
|
+
unavailableReason: describeMissingSource(provider),
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return computeSessionFileStats(events, {
|
|
161
|
+
...base,
|
|
162
|
+
label: options.resolveLabel?.() ?? undefined,
|
|
163
|
+
computeContextSize: providerContextSizeFn(provider),
|
|
164
|
+
availability: reader.wasTruncated() ? 'partial' : 'full',
|
|
165
|
+
});
|
|
166
|
+
}
|