sidekick-shared 0.27.6 → 0.28.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 +28 -0
- package/README.md +58 -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 +197 -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 +233 -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,122 @@
|
|
|
1
|
+
import { FIVE_HOUR_WINDOW_MS, formatQuotaAge } from '../quota';
|
|
2
|
+
import { estimateTimeToQuota } from './BurnRateCalculator';
|
|
3
|
+
export function selectStatuslineAccount(input) {
|
|
4
|
+
if (input.accounts.claude.present) {
|
|
5
|
+
return {
|
|
6
|
+
providerId: 'claude-code',
|
|
7
|
+
accountId: input.accounts.claude.accountId,
|
|
8
|
+
accountLabel: input.accounts.claude.label ?? input.accounts.claude.email ?? 'claude',
|
|
9
|
+
quota: input.claudeQuota ?? null,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
if (input.accounts.codex.present) {
|
|
13
|
+
return {
|
|
14
|
+
providerId: 'codex',
|
|
15
|
+
accountId: input.accounts.codex.accountId,
|
|
16
|
+
accountLabel: input.accounts.codex.label ?? input.accounts.codex.email ?? 'codex',
|
|
17
|
+
quota: input.codexQuota ?? null,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* One-line status. Segments, in order, each omitted when unknown:
|
|
24
|
+
* `acct:<label> · 5h 42% resets 14:00 · ~1h20m left · 7d 61% · (2h ago) · ctx 37% · $0.42 · cache 92%`
|
|
25
|
+
*
|
|
26
|
+
* The age segment appears only for cached quota older than five minutes, so
|
|
27
|
+
* a snapshot from yesterday can no longer read like a live figure.
|
|
28
|
+
*/
|
|
29
|
+
export function formatStatusline(input) {
|
|
30
|
+
const selected = selectStatuslineAccount(input);
|
|
31
|
+
const now = input.now ?? new Date();
|
|
32
|
+
const liveSegments = formatLiveSegments(input.live);
|
|
33
|
+
if (!selected)
|
|
34
|
+
return joinSegments(['acct:none', 'quota unavailable', ...liveSegments]);
|
|
35
|
+
const account = compactLabel(selected.accountLabel);
|
|
36
|
+
const quota = selected.quota;
|
|
37
|
+
if (!quota?.available) {
|
|
38
|
+
return joinSegments([`acct:${account}`, 'quota unavailable', ...liveSegments]);
|
|
39
|
+
}
|
|
40
|
+
const utilization = clampPercent(quota.fiveHour.utilization);
|
|
41
|
+
const reset = formatReset(quota.fiveHour.resetsAt, now);
|
|
42
|
+
const eta = estimateWindowEta(quota, now);
|
|
43
|
+
const segments = [`acct:${account}`, `5h ${Math.round(utilization)}% resets ${reset}`];
|
|
44
|
+
if (eta != null)
|
|
45
|
+
segments.push(`~${formatMinutes(eta)} left`);
|
|
46
|
+
if (quota.sevenDay.resetsAt && quota.sevenDay.utilization > 0) {
|
|
47
|
+
segments.push(`7d ${Math.round(clampPercent(quota.sevenDay.utilization))}%`);
|
|
48
|
+
}
|
|
49
|
+
if (quota.freshness && quota.freshness !== 'fresh') {
|
|
50
|
+
segments.push(`(${formatQuotaAge(quota.ageMs)})`);
|
|
51
|
+
}
|
|
52
|
+
return joinSegments([...segments, ...liveSegments]);
|
|
53
|
+
}
|
|
54
|
+
function formatLiveSegments(live) {
|
|
55
|
+
if (!live)
|
|
56
|
+
return [];
|
|
57
|
+
const segments = [];
|
|
58
|
+
const context = live.contextWindow?.usedPercentage;
|
|
59
|
+
if (typeof context === 'number')
|
|
60
|
+
segments.push(`ctx ${Math.round(clampPercent(context))}%`);
|
|
61
|
+
const cost = live.cost?.totalCostUsd;
|
|
62
|
+
if (typeof cost === 'number' && cost > 0)
|
|
63
|
+
segments.push(`$${formatCost(cost)}`);
|
|
64
|
+
const hitRatio = live.promptCache?.hitRatio;
|
|
65
|
+
if (typeof hitRatio === 'number')
|
|
66
|
+
segments.push(`cache ${Math.round(clampPercent(hitRatio * 100))}%`);
|
|
67
|
+
return segments;
|
|
68
|
+
}
|
|
69
|
+
function joinSegments(segments) {
|
|
70
|
+
return segments.join(' · ');
|
|
71
|
+
}
|
|
72
|
+
function formatCost(value) {
|
|
73
|
+
if (value >= 100)
|
|
74
|
+
return value.toFixed(0);
|
|
75
|
+
if (value >= 10)
|
|
76
|
+
return value.toFixed(1);
|
|
77
|
+
return value.toFixed(2);
|
|
78
|
+
}
|
|
79
|
+
function estimateWindowEta(quota, now) {
|
|
80
|
+
const resetAt = Date.parse(quota.fiveHour.resetsAt);
|
|
81
|
+
if (!Number.isFinite(resetAt))
|
|
82
|
+
return null;
|
|
83
|
+
const nowMs = now.getTime();
|
|
84
|
+
if (resetAt <= nowMs)
|
|
85
|
+
return null;
|
|
86
|
+
const capturedAt = quota.capturedAt ? Date.parse(quota.capturedAt) : now.getTime();
|
|
87
|
+
const sampleAt = Number.isFinite(capturedAt) ? capturedAt : now.getTime();
|
|
88
|
+
if (sampleAt >= resetAt)
|
|
89
|
+
return null;
|
|
90
|
+
const elapsedMinutes = (sampleAt - (resetAt - FIVE_HOUR_WINDOW_MS)) / 60_000;
|
|
91
|
+
if (elapsedMinutes <= 0 || quota.fiveHour.utilization <= 0)
|
|
92
|
+
return null;
|
|
93
|
+
const burnRate = quota.fiveHour.utilization / elapsedMinutes;
|
|
94
|
+
const eta = estimateTimeToQuota(quota.fiveHour.utilization, 100, burnRate);
|
|
95
|
+
const minutesUntilReset = (resetAt - nowMs) / 60_000;
|
|
96
|
+
return eta !== null && eta < minutesUntilReset ? eta : null;
|
|
97
|
+
}
|
|
98
|
+
function compactLabel(value) {
|
|
99
|
+
const trimmed = value.trim();
|
|
100
|
+
const localPart = trimmed.includes('@') ? trimmed.split('@')[0] : trimmed;
|
|
101
|
+
return localPart.replace(/\s+/g, '-').slice(0, 24) || 'unknown';
|
|
102
|
+
}
|
|
103
|
+
function clampPercent(value) {
|
|
104
|
+
return Number.isFinite(value) ? Math.min(100, Math.max(0, value)) : 0;
|
|
105
|
+
}
|
|
106
|
+
function formatReset(value, fallback) {
|
|
107
|
+
const reset = new Date(value);
|
|
108
|
+
const date = Number.isNaN(reset.getTime()) ? fallback : reset;
|
|
109
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
110
|
+
hour: '2-digit',
|
|
111
|
+
minute: '2-digit',
|
|
112
|
+
hour12: false,
|
|
113
|
+
}).format(date);
|
|
114
|
+
}
|
|
115
|
+
function formatMinutes(value) {
|
|
116
|
+
const minutes = Math.max(0, Math.round(value));
|
|
117
|
+
if (minutes < 60)
|
|
118
|
+
return `${minutes}min`;
|
|
119
|
+
const hours = Math.floor(minutes / 60);
|
|
120
|
+
const remainder = minutes % 60;
|
|
121
|
+
return remainder ? `${hours}h${remainder}m` : `${hours}h`;
|
|
122
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { getActiveAccountStatus } from '../accountStatus';
|
|
2
|
+
export { readQuotaSnapshot, writeQuotaSnapshot } from '../quotaSnapshots';
|
|
3
|
+
export { appendQuotaHistorySample, getWorkspaceIdFromPath } from '../quotaHistory';
|
|
4
|
+
export { classifyQuotaFreshness, formatQuotaAge } from '../quota';
|
|
5
|
+
export { BurnRateCalculator, estimateTimeToQuota } from './BurnRateCalculator';
|
|
6
|
+
export { formatStatusline, selectStatuslineAccount } from './formatter';
|
|
7
|
+
export { parseClaudeStatuslinePayload, quotaFromStatuslinePayload, } from './claudeStatuslinePayload';
|
|
8
|
+
export { STATE_FILE_NAME, STATE_FILE_SCHEMA_VERSION, billingBlockToStateFile, getStateFilePath, quotaToStateFile, readStateFile, writeStateFile, } from '../stateFile';
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as os from 'os';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import { atomicWriteFileSync } from './writers/atomic';
|
|
5
|
+
const LAUNCHER_MARKER = '# sidekick-launcher v1';
|
|
6
|
+
const LAUNCHER_NAME_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
7
|
+
function providerEnvVar(provider) {
|
|
8
|
+
return provider === 'claude-code' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME';
|
|
9
|
+
}
|
|
10
|
+
function providerBinary(provider) {
|
|
11
|
+
return provider === 'claude-code' ? 'claude' : 'codex';
|
|
12
|
+
}
|
|
13
|
+
function getLauncherDir() {
|
|
14
|
+
return path.join(os.homedir(), '.local', 'bin');
|
|
15
|
+
}
|
|
16
|
+
function getLauncherPath(name) {
|
|
17
|
+
return path.join(getLauncherDir(), name);
|
|
18
|
+
}
|
|
19
|
+
function assertValidLauncherName(name) {
|
|
20
|
+
if (!LAUNCHER_NAME_PATTERN.test(name)) {
|
|
21
|
+
throw new Error(`Invalid launcher name "${name}". Use letters, numbers, underscores, and hyphens only.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function isSidekickLauncher(filePath) {
|
|
25
|
+
try {
|
|
26
|
+
return fs.readFileSync(filePath, 'utf8').includes(LAUNCHER_MARKER);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function assertNoLauncherCollision(name, targetPath) {
|
|
33
|
+
if (fs.existsSync(targetPath) && !isSidekickLauncher(targetPath)) {
|
|
34
|
+
throw new Error(`Launcher "${name}" already exists and is not managed by sidekick.`);
|
|
35
|
+
}
|
|
36
|
+
for (const dir of (process.env.PATH ?? '').split(path.delimiter).filter(Boolean)) {
|
|
37
|
+
const candidate = path.join(dir, name);
|
|
38
|
+
if (path.resolve(candidate) === path.resolve(targetPath))
|
|
39
|
+
continue;
|
|
40
|
+
if (fs.existsSync(candidate)) {
|
|
41
|
+
throw new Error(`Launcher "${name}" collides with an existing command on PATH.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function shellSingleQuote(value) {
|
|
46
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Write a POSIX launcher script that runs the provider CLI as a saved account.
|
|
50
|
+
* The script defers to `sidekick accounts shell` at run time, which resolves
|
|
51
|
+
* the right home then: the account's profile home while it is inactive, the
|
|
52
|
+
* live home while it is the live login (a session pinned to the profile home
|
|
53
|
+
* would otherwise be a second copy of the live login). Unix only; on Windows
|
|
54
|
+
* use `sidekick accounts env --shell powershell`.
|
|
55
|
+
*/
|
|
56
|
+
export function writeAccountLauncher(name, provider, accountId) {
|
|
57
|
+
assertValidLauncherName(name);
|
|
58
|
+
const launcherPath = getLauncherPath(name);
|
|
59
|
+
assertNoLauncherCollision(name, launcherPath);
|
|
60
|
+
const script = [
|
|
61
|
+
'#!/bin/sh',
|
|
62
|
+
LAUNCHER_MARKER,
|
|
63
|
+
`exec sidekick accounts shell --provider ${provider} ${shellSingleQuote(accountId)} -- ${providerBinary(provider)} "$@"`,
|
|
64
|
+
'',
|
|
65
|
+
].join('\n');
|
|
66
|
+
atomicWriteFileSync(launcherPath, script, 0o755);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* @deprecated Pins a launcher to one profile home; when that account becomes
|
|
70
|
+
* the live login the launcher runs a second copy of it. Use
|
|
71
|
+
* {@link writeAccountLauncher}.
|
|
72
|
+
*/
|
|
73
|
+
export function writeLauncher(name, provider, profileHome) {
|
|
74
|
+
assertValidLauncherName(name);
|
|
75
|
+
const launcherPath = getLauncherPath(name);
|
|
76
|
+
assertNoLauncherCollision(name, launcherPath);
|
|
77
|
+
const envVar = providerEnvVar(provider);
|
|
78
|
+
const binary = providerBinary(provider);
|
|
79
|
+
const script = [
|
|
80
|
+
'#!/bin/sh',
|
|
81
|
+
LAUNCHER_MARKER,
|
|
82
|
+
`export ${envVar}=${JSON.stringify(profileHome)}`,
|
|
83
|
+
...(provider === 'claude-code' ? ['unset CLAUDE_SECURESTORAGE_CONFIG_DIR'] : []),
|
|
84
|
+
`exec ${binary} "$@"`,
|
|
85
|
+
'',
|
|
86
|
+
].join('\n');
|
|
87
|
+
atomicWriteFileSync(launcherPath, script, 0o755);
|
|
88
|
+
}
|
|
89
|
+
export function removeLauncher(name) {
|
|
90
|
+
assertValidLauncherName(name);
|
|
91
|
+
const launcherPath = getLauncherPath(name);
|
|
92
|
+
if (!isSidekickLauncher(launcherPath))
|
|
93
|
+
return;
|
|
94
|
+
fs.rmSync(launcherPath, { force: true });
|
|
95
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential guard for test runners, published as `sidekick-shared/testing`.
|
|
3
|
+
*
|
|
4
|
+
* No test may reach the real macOS `security` binary, the real `claude` /
|
|
5
|
+
* `codex` CLIs, or the developer's real Claude, Codex, or Sidekick homes: a
|
|
6
|
+
* stray keychain write copies a live refresh token, and Claude Code rotates
|
|
7
|
+
* that token on every refresh, so a second copy is a login waiting to die.
|
|
8
|
+
*
|
|
9
|
+
* Three layers:
|
|
10
|
+
* - `HOME` (and the Windows equivalents) point at a per-process temp dir,
|
|
11
|
+
* removed on exit, and the home overrides (`CLAUDE_CONFIG_DIR`,
|
|
12
|
+
* `CLAUDE_SECURESTORAGE_CONFIG_DIR`, `CODEX_HOME`, `SIDEKICK_CONFIG_DIR`)
|
|
13
|
+
* are unset, so every `os.homedir()`-derived path is isolated structurally;
|
|
14
|
+
* - the real `child_process` module object is patched, which reaches both ESM
|
|
15
|
+
* named imports (after `syncBuiltinESMExports`) and CommonJS code loaded
|
|
16
|
+
* through native `require`; per-file `vi.mock('child_process')` factories
|
|
17
|
+
* replace the module and never get here;
|
|
18
|
+
* - the real `fs` module refuses writes under the real homes and records reads.
|
|
19
|
+
*
|
|
20
|
+
* Production code swallows most errors, so violations are also recorded;
|
|
21
|
+
* call `assertNoViolations()` after each test (see `credentialGuard.setup.ts`
|
|
22
|
+
* for a vitest wiring). Framework-free; the real modules come from
|
|
23
|
+
* `process.getBuiltinModule` (Node 20.16+), or `require` in CommonJS.
|
|
24
|
+
*
|
|
25
|
+
* @module testing/credentialGuard
|
|
26
|
+
*/
|
|
27
|
+
import * as os from 'node:os';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
const GUARD_KEY = '__sidekickCredentialGuard';
|
|
30
|
+
const DEFAULT_BLOCKED_BINARIES = ['security', 'claude', 'codex'];
|
|
31
|
+
const CHILD_PROCESS_FUNCTIONS = [
|
|
32
|
+
'spawn',
|
|
33
|
+
'spawnSync',
|
|
34
|
+
'execFile',
|
|
35
|
+
'execFileSync',
|
|
36
|
+
'exec',
|
|
37
|
+
'execSync',
|
|
38
|
+
];
|
|
39
|
+
const FS_WRITE_FUNCTIONS = [
|
|
40
|
+
'writeFileSync',
|
|
41
|
+
'appendFileSync',
|
|
42
|
+
'mkdirSync',
|
|
43
|
+
'rmSync',
|
|
44
|
+
'rmdirSync',
|
|
45
|
+
'unlinkSync',
|
|
46
|
+
'renameSync',
|
|
47
|
+
'copyFileSync',
|
|
48
|
+
'cpSync',
|
|
49
|
+
'chmodSync',
|
|
50
|
+
'symlinkSync',
|
|
51
|
+
];
|
|
52
|
+
const FS_READ_FUNCTIONS = [
|
|
53
|
+
'readFileSync',
|
|
54
|
+
'existsSync',
|
|
55
|
+
'statSync',
|
|
56
|
+
'lstatSync',
|
|
57
|
+
'readdirSync',
|
|
58
|
+
'openSync',
|
|
59
|
+
'watch',
|
|
60
|
+
];
|
|
61
|
+
/**
|
|
62
|
+
* Installs the guard once per process and returns it; later calls return the
|
|
63
|
+
* same guard and ignore their options.
|
|
64
|
+
*/
|
|
65
|
+
export function installCredentialGuard(options = {}) {
|
|
66
|
+
const existing = globalThis[GUARD_KEY];
|
|
67
|
+
if (existing)
|
|
68
|
+
return existing;
|
|
69
|
+
const childProcess = builtin('node:child_process');
|
|
70
|
+
const realFs = builtin('node:fs');
|
|
71
|
+
const fs = realFs;
|
|
72
|
+
const { syncBuiltinESMExports } = builtin('node:module');
|
|
73
|
+
const blockedBinaries = new Set((options.blockedBinaries ?? DEFAULT_BLOCKED_BINARIES).map((name) => name.toLowerCase()));
|
|
74
|
+
// os.userInfo() reads the passwd entry, so it still names the real home
|
|
75
|
+
// after HOME is redirected below.
|
|
76
|
+
const realHome = os.userInfo().homedir;
|
|
77
|
+
const testHome = realFs.mkdtempSync(path.join(os.tmpdir(), 'sidekick-test-home-'));
|
|
78
|
+
const removeTestHome = realFs.rmSync.bind(realFs);
|
|
79
|
+
process.once('exit', () => {
|
|
80
|
+
try {
|
|
81
|
+
removeTestHome(testHome, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* best effort */
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
const state = {
|
|
88
|
+
realHome,
|
|
89
|
+
testHome,
|
|
90
|
+
violations: [],
|
|
91
|
+
take() {
|
|
92
|
+
return state.violations.splice(0, state.violations.length);
|
|
93
|
+
},
|
|
94
|
+
assertNoViolations() {
|
|
95
|
+
const violations = state.take();
|
|
96
|
+
if (violations.length > 0) {
|
|
97
|
+
throw new Error(`Credential guard: this test reached real credentials or homes:\n- ${violations.join('\n- ')}`);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
process.env.HOME = testHome;
|
|
102
|
+
process.env.USERPROFILE = testHome;
|
|
103
|
+
process.env.APPDATA = path.join(testHome, 'AppData', 'Roaming');
|
|
104
|
+
process.env.LOCALAPPDATA = path.join(testHome, 'AppData', 'Local');
|
|
105
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
106
|
+
delete process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR;
|
|
107
|
+
delete process.env.CODEX_HOME;
|
|
108
|
+
delete process.env.SIDEKICK_CONFIG_DIR;
|
|
109
|
+
const protectedRoots = path.resolve(realHome) === path.resolve(testHome)
|
|
110
|
+
? []
|
|
111
|
+
: [
|
|
112
|
+
path.join(realHome, '.claude'),
|
|
113
|
+
path.join(realHome, '.claude.json'),
|
|
114
|
+
path.join(realHome, '.codex'),
|
|
115
|
+
path.join(realHome, '.config', 'sidekick'),
|
|
116
|
+
...(options.protectedPaths ?? []),
|
|
117
|
+
];
|
|
118
|
+
const isProtected = (target) => {
|
|
119
|
+
let candidate = null;
|
|
120
|
+
if (typeof target === 'string')
|
|
121
|
+
candidate = target;
|
|
122
|
+
else if (target instanceof URL && target.protocol === 'file:')
|
|
123
|
+
candidate = target.pathname;
|
|
124
|
+
if (!candidate)
|
|
125
|
+
return null;
|
|
126
|
+
const resolved = path.resolve(candidate);
|
|
127
|
+
return protectedRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep))
|
|
128
|
+
? resolved
|
|
129
|
+
: null;
|
|
130
|
+
};
|
|
131
|
+
const binaryName = (command) => {
|
|
132
|
+
if (typeof command !== 'string')
|
|
133
|
+
return '';
|
|
134
|
+
const first = command.trim().split(/\s+/)[0] ?? '';
|
|
135
|
+
return path
|
|
136
|
+
.basename(first.replace(/^["']|["']$/g, ''))
|
|
137
|
+
.replace(/\.(exe|cmd|bat)$/i, '')
|
|
138
|
+
.toLowerCase();
|
|
139
|
+
};
|
|
140
|
+
const record = (message) => {
|
|
141
|
+
state.violations.push(message);
|
|
142
|
+
throw new Error(`[credential guard] ${message}`);
|
|
143
|
+
};
|
|
144
|
+
for (const name of CHILD_PROCESS_FUNCTIONS) {
|
|
145
|
+
const original = childProcess[name];
|
|
146
|
+
if (typeof original !== 'function')
|
|
147
|
+
continue;
|
|
148
|
+
childProcess[name] = function guarded(...args) {
|
|
149
|
+
const binary = binaryName(args[0]);
|
|
150
|
+
if (blockedBinaries.has(binary)) {
|
|
151
|
+
record(`child_process.${name}('${binary}') reached the real binary; mock child_process or inject a runner`);
|
|
152
|
+
}
|
|
153
|
+
return original.apply(this, args);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
for (const name of FS_WRITE_FUNCTIONS) {
|
|
157
|
+
const original = fs[name];
|
|
158
|
+
if (typeof original !== 'function')
|
|
159
|
+
continue;
|
|
160
|
+
fs[name] = function guarded(...args) {
|
|
161
|
+
for (const arg of args.slice(0, 2)) {
|
|
162
|
+
const hit = isProtected(arg);
|
|
163
|
+
if (hit)
|
|
164
|
+
record(`fs.${name}(${hit}) would write inside the real home`);
|
|
165
|
+
}
|
|
166
|
+
return original.apply(this, args);
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
for (const name of FS_READ_FUNCTIONS) {
|
|
170
|
+
const original = fs[name];
|
|
171
|
+
if (typeof original !== 'function')
|
|
172
|
+
continue;
|
|
173
|
+
fs[name] = function guarded(...args) {
|
|
174
|
+
const hit = isProtected(args[0]);
|
|
175
|
+
// Reads are recorded rather than thrown so existsSync keeps its
|
|
176
|
+
// never-throws contract; the afterEach below still fails the test.
|
|
177
|
+
if (hit)
|
|
178
|
+
state.violations.push(`fs.${name}(${hit}) read the real home`);
|
|
179
|
+
return original.apply(this, args);
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
syncBuiltinESMExports();
|
|
183
|
+
globalThis[GUARD_KEY] = state;
|
|
184
|
+
return state;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The real module object of a Node built-in: `process.getBuiltinModule`
|
|
188
|
+
* (Node 20.16+), or `require` in CommonJS on older Node.
|
|
189
|
+
*/
|
|
190
|
+
function builtin(id) {
|
|
191
|
+
if (typeof process.getBuiltinModule === 'function')
|
|
192
|
+
return process.getBuiltinModule(id);
|
|
193
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- CommonJS fallback for Node < 20.16
|
|
194
|
+
if (typeof require === 'function')
|
|
195
|
+
return require(id);
|
|
196
|
+
throw new Error('installCredentialGuard() needs Node 20.16+ outside CommonJS.');
|
|
197
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Testing entry (`sidekick-shared/testing`): helpers for test suites that
|
|
3
|
+
* exercise sidekick-shared against Claude Code and Codex session data.
|
|
4
|
+
*
|
|
5
|
+
* - `installCredentialGuard()` keeps tests away from the real `security`,
|
|
6
|
+
* `claude`, and `codex` binaries and the real Claude, Codex, and Sidekick
|
|
7
|
+
* homes.
|
|
8
|
+
* - The session-log builders write Claude Code and Codex logs in the shapes
|
|
9
|
+
* the readers parse.
|
|
10
|
+
*/
|
|
11
|
+
export { installCredentialGuard } from './credentialGuard';
|
|
12
|
+
export { appendJsonl, claudeFiller, codexFiller, createSessionLogFixtures, ts, writeJsonl, } from './sessionLogFixtures';
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code and Codex session-log builders shared by the prompt-history
|
|
3
|
+
* tests, published through `sidekick-shared/testing`.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import { encodeWorkspacePath as encodeClaudeWorkspacePath } from '../parsers/sessionPathResolver';
|
|
8
|
+
export function ts(second) {
|
|
9
|
+
return new Date(Date.UTC(2026, 8, 1, 10, 0, second)).toISOString();
|
|
10
|
+
}
|
|
11
|
+
export function writeJsonl(file, rows) {
|
|
12
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
13
|
+
fs.writeFileSync(file, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
|
|
14
|
+
}
|
|
15
|
+
export function appendJsonl(file, rows) {
|
|
16
|
+
fs.appendFileSync(file, rows.map((row) => JSON.stringify(row)).join('\n') + '\n');
|
|
17
|
+
}
|
|
18
|
+
/** A Claude Code line with no message: read and ignored, like progress records. */
|
|
19
|
+
export function claudeFiller(bytes, second) {
|
|
20
|
+
return { type: 'system', subtype: 'filler', timestamp: ts(second), content: 'f'.repeat(bytes) };
|
|
21
|
+
}
|
|
22
|
+
/** A Codex line that is neither a prompt nor usage. */
|
|
23
|
+
export function codexFiller(bytes, second) {
|
|
24
|
+
return {
|
|
25
|
+
timestamp: ts(second),
|
|
26
|
+
type: 'event_msg',
|
|
27
|
+
payload: { type: 'agent_message', message: 'f'.repeat(bytes) },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Builders that read the per-test home and repo lazily from `context`. */
|
|
31
|
+
export function createSessionLogFixtures(context) {
|
|
32
|
+
let codexCounter = 0;
|
|
33
|
+
function claudeFile(workspace, sessionId) {
|
|
34
|
+
return path.join(context().home, '.claude', 'projects', encodeClaudeWorkspacePath(workspace), `${sessionId}.jsonl`);
|
|
35
|
+
}
|
|
36
|
+
function claudeUser(content, second, extra = {}) {
|
|
37
|
+
return {
|
|
38
|
+
type: 'user',
|
|
39
|
+
entrypoint: 'cli',
|
|
40
|
+
cwd: context().repo,
|
|
41
|
+
gitBranch: 'main',
|
|
42
|
+
timestamp: ts(second),
|
|
43
|
+
message: { role: 'user', content },
|
|
44
|
+
...extra,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/** One Claude Code assistant record; `message` fields are merged over the defaults. */
|
|
48
|
+
function claudeAssistant(id, second, extra = {}, message = {}) {
|
|
49
|
+
return {
|
|
50
|
+
type: 'assistant',
|
|
51
|
+
entrypoint: 'cli',
|
|
52
|
+
cwd: context().repo,
|
|
53
|
+
timestamp: ts(second),
|
|
54
|
+
message: {
|
|
55
|
+
id,
|
|
56
|
+
role: 'assistant',
|
|
57
|
+
model: 'claude-opus-5',
|
|
58
|
+
content: [{ type: 'text', text: 'ok' }],
|
|
59
|
+
stop_reason: 'end_turn',
|
|
60
|
+
usage: {
|
|
61
|
+
input_tokens: 10,
|
|
62
|
+
output_tokens: 20,
|
|
63
|
+
cache_read_input_tokens: 30,
|
|
64
|
+
cache_creation_input_tokens: 40,
|
|
65
|
+
},
|
|
66
|
+
...message,
|
|
67
|
+
},
|
|
68
|
+
...extra,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function codexFile() {
|
|
72
|
+
codexCounter++;
|
|
73
|
+
const id = `019d86b0-b20c-7b02-a3b2-${String(codexCounter).padStart(12, '0')}`;
|
|
74
|
+
return path.join(context().home, '.codex', 'sessions', '2026', '09', '01', `rollout-2026-09-01T10-00-${String(codexCounter).padStart(2, '0')}-${id}.jsonl`);
|
|
75
|
+
}
|
|
76
|
+
function codexMeta(cwd, source = 'cli', extra = {}) {
|
|
77
|
+
return {
|
|
78
|
+
timestamp: ts(0),
|
|
79
|
+
type: 'session_meta',
|
|
80
|
+
payload: {
|
|
81
|
+
id: 'meta',
|
|
82
|
+
timestamp: ts(0),
|
|
83
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
84
|
+
originator: 'codex-tui',
|
|
85
|
+
source,
|
|
86
|
+
git: { branch: 'feature/x' },
|
|
87
|
+
...extra,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function codexUser(texts, second) {
|
|
92
|
+
return {
|
|
93
|
+
timestamp: ts(second),
|
|
94
|
+
type: 'response_item',
|
|
95
|
+
payload: {
|
|
96
|
+
type: 'message',
|
|
97
|
+
role: 'user',
|
|
98
|
+
content: texts.map((text) => ({ type: 'input_text', text })),
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
function codexTokenCount(second) {
|
|
103
|
+
return {
|
|
104
|
+
timestamp: ts(second),
|
|
105
|
+
type: 'event_msg',
|
|
106
|
+
payload: {
|
|
107
|
+
type: 'token_count',
|
|
108
|
+
info: {
|
|
109
|
+
last_token_usage: {
|
|
110
|
+
input_tokens: 100,
|
|
111
|
+
cached_input_tokens: 60,
|
|
112
|
+
output_tokens: 7,
|
|
113
|
+
cache_write_input_tokens: 5,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
claudeFile,
|
|
121
|
+
claudeUser,
|
|
122
|
+
claudeAssistant,
|
|
123
|
+
codexFile,
|
|
124
|
+
codexMeta,
|
|
125
|
+
codexUser,
|
|
126
|
+
codexTokenCount,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Latin/source-code characters per token in the fallback estimate. */
|
|
2
|
+
export const ASCII_CHARS_PER_TOKEN = 3.5;
|
|
3
|
+
const CJK = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
|
|
4
|
+
const EMOJI = /\p{Extended_Pictographic}/u;
|
|
5
|
+
/**
|
|
6
|
+
* Estimate tokens without bundling a tokenizer.
|
|
7
|
+
*
|
|
8
|
+
* Fallback v1 counts Latin/source-code runs at 3.5 UTF-16 characters per
|
|
9
|
+
* token, CJK code points at one token, emoji at two, and other non-ASCII code
|
|
10
|
+
* points at one. The result is deliberately conservative for context budgets.
|
|
11
|
+
*/
|
|
12
|
+
export function estimateTextTokens(text, options = {}) {
|
|
13
|
+
if (text.length === 0) {
|
|
14
|
+
return { count: 0, method: 'sidekick-fallback-v1', confidence: 'medium', provenance: 'empty' };
|
|
15
|
+
}
|
|
16
|
+
let exact;
|
|
17
|
+
try {
|
|
18
|
+
exact = options.exactCounter?.(text, { model: options.model });
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
exact = undefined;
|
|
22
|
+
}
|
|
23
|
+
if (typeof exact === 'number' && Number.isFinite(exact) && exact >= 0) {
|
|
24
|
+
return {
|
|
25
|
+
count: Math.ceil(exact),
|
|
26
|
+
method: 'exact',
|
|
27
|
+
confidence: 'exact',
|
|
28
|
+
provenance: options.model ?? 'injected-counter',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// ASCII is never CJK or pictographic, so the leading ASCII run is counted
|
|
32
|
+
// without the per-code-point regex tests; most source text is all ASCII.
|
|
33
|
+
let firstNonAscii = 0;
|
|
34
|
+
while (firstNonAscii < text.length && text.charCodeAt(firstNonAscii) <= 127)
|
|
35
|
+
firstNonAscii++;
|
|
36
|
+
let asciiChars = firstNonAscii;
|
|
37
|
+
let conservativeTokens = 0;
|
|
38
|
+
if (firstNonAscii < text.length) {
|
|
39
|
+
for (const codePoint of text.slice(firstNonAscii)) {
|
|
40
|
+
if (EMOJI.test(codePoint))
|
|
41
|
+
conservativeTokens += 2;
|
|
42
|
+
else if (CJK.test(codePoint))
|
|
43
|
+
conservativeTokens += 1;
|
|
44
|
+
else if ((codePoint.codePointAt(0) ?? 128) <= 127)
|
|
45
|
+
asciiChars += codePoint.length;
|
|
46
|
+
else
|
|
47
|
+
conservativeTokens += 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const count = Math.ceil(asciiChars / ASCII_CHARS_PER_TOKEN) + conservativeTokens;
|
|
51
|
+
return {
|
|
52
|
+
count: Number.isFinite(count) && count >= 0 ? count : 0,
|
|
53
|
+
method: 'sidekick-fallback-v1',
|
|
54
|
+
confidence: conservativeTokens > 0 ? 'low' : 'medium',
|
|
55
|
+
provenance: 'chars-per-token-3.5+cjk1+emoji2',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
export function estimateSerializedTokens(value, options = {}) {
|
|
59
|
+
let serialized;
|
|
60
|
+
try {
|
|
61
|
+
serialized = JSON.stringify(value) ?? '';
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
serialized = String(value);
|
|
65
|
+
}
|
|
66
|
+
return estimateTextTokens(serialized, options);
|
|
67
|
+
}
|