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,2008 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview OpenCode session provider for the shared package.
|
|
3
|
+
*
|
|
4
|
+
* Implements SessionProviderBase for monitoring OpenCode CLI sessions.
|
|
5
|
+
* Uses SQLite database as primary data source (opencode.db), with
|
|
6
|
+
* file-based scanning as fallback for older OpenCode installations.
|
|
7
|
+
*
|
|
8
|
+
* Ported from sidekick-vscode/src/services/providers/OpenCodeSessionProvider.ts
|
|
9
|
+
*
|
|
10
|
+
* OpenCode file storage layout (legacy):
|
|
11
|
+
* - Base: $XDG_DATA_HOME/opencode/ or ~/.local/share/opencode/
|
|
12
|
+
* - Sessions: storage/session/{projectID}/{sessionID}.json
|
|
13
|
+
* - Messages: storage/message/{sessionID}/{messageID}.json
|
|
14
|
+
* - Parts: storage/part/{messageID}/{partID}.json
|
|
15
|
+
*
|
|
16
|
+
* @module providers/openCode
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
import * as os from 'os';
|
|
20
|
+
import * as path from 'path';
|
|
21
|
+
import { execSync } from 'child_process';
|
|
22
|
+
import { readSessionContextSnapshot } from '../context/sessionContext';
|
|
23
|
+
import { readSessionFileStats } from '../sessionStats';
|
|
24
|
+
import { convertOpenCodeMessage, parseDbMessageData, parseDbPartData, normalizeToolName, normalizeToolInput, } from '../parsers/openCodeParser';
|
|
25
|
+
import { OpenCodeDatabase } from './openCodeDatabase';
|
|
26
|
+
import { normalizeProviderUsage } from '../usageNormalization';
|
|
27
|
+
import { addUsageToSubagent } from '../usage/subagentUsage';
|
|
28
|
+
import { accumulateZaiUsage, inferZaiQuotaState, parseZaiQuotaError, ZAI_PROVIDER_IDS, } from '../zaiQuota';
|
|
29
|
+
import { ProviderDiagnosticTracker, diagnosticFromRuntimeStatus } from './diagnostics';
|
|
30
|
+
import { getModelContextWindowSize } from '../modelContext';
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Helper functions
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/**
|
|
35
|
+
* Gets the OpenCode data directory.
|
|
36
|
+
*
|
|
37
|
+
* Resolves in priority order: `XDG_DATA_HOME` (if set), then whichever of
|
|
38
|
+
* the platform-default candidates actually contains an `opencode.db` file.
|
|
39
|
+
* OpenCode's CLI has historically used the Linux-style `~/.local/share/opencode`
|
|
40
|
+
* path even on macOS, so we probe both and pick whichever exists — falling
|
|
41
|
+
* back to the platform default when neither is present yet.
|
|
42
|
+
*/
|
|
43
|
+
export function getOpenCodeDataDir() {
|
|
44
|
+
const xdg = process.env.XDG_DATA_HOME;
|
|
45
|
+
if (xdg)
|
|
46
|
+
return path.join(xdg, 'opencode');
|
|
47
|
+
const candidates = [path.join(os.homedir(), '.local', 'share', 'opencode')];
|
|
48
|
+
if (process.platform === 'darwin') {
|
|
49
|
+
candidates.push(path.join(os.homedir(), 'Library', 'Application Support', 'opencode'));
|
|
50
|
+
}
|
|
51
|
+
else if (process.platform === 'win32') {
|
|
52
|
+
candidates.push(path.join(process.env.LOCALAPPDATA ||
|
|
53
|
+
process.env.APPDATA ||
|
|
54
|
+
path.join(os.homedir(), 'AppData', 'Local'), 'opencode'));
|
|
55
|
+
}
|
|
56
|
+
for (const candidate of candidates) {
|
|
57
|
+
try {
|
|
58
|
+
if (fs.existsSync(path.join(candidate, 'opencode.db')))
|
|
59
|
+
return candidate;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// ignore — try the next candidate.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// Nothing found yet — return the platform default so a fresh OpenCode
|
|
66
|
+
// install gets detected at the conventional location for this OS.
|
|
67
|
+
if (process.platform === 'darwin') {
|
|
68
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'opencode');
|
|
69
|
+
}
|
|
70
|
+
if (process.platform === 'win32') {
|
|
71
|
+
return path.join(process.env.LOCALAPPDATA ||
|
|
72
|
+
process.env.APPDATA ||
|
|
73
|
+
path.join(os.homedir(), 'AppData', 'Local'), 'opencode');
|
|
74
|
+
}
|
|
75
|
+
return path.join(os.homedir(), '.local', 'share', 'opencode');
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Gets the storage base directory within OpenCode's data dir.
|
|
79
|
+
*/
|
|
80
|
+
function getStorageDir() {
|
|
81
|
+
return path.join(getOpenCodeDataDir(), 'storage');
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Pattern matching model IDs and generic agent names that don't convey type info.
|
|
85
|
+
*/
|
|
86
|
+
const GENERIC_AGENT_RE = /^(gpt-|claude-|o[1-9]|gemini|default|agent|worker)/i;
|
|
87
|
+
/**
|
|
88
|
+
* Returns true if the raw agent type is a model ID or generic name
|
|
89
|
+
* that doesn't convey meaningful type information.
|
|
90
|
+
*/
|
|
91
|
+
function isGenericAgentType(type) {
|
|
92
|
+
return GENERIC_AGENT_RE.test(type);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Detects a meaningful agent type from the description using keyword matching.
|
|
96
|
+
* Returns undefined if no keywords match.
|
|
97
|
+
*/
|
|
98
|
+
function detectAgentTypeFromDescription(desc) {
|
|
99
|
+
const lower = desc.toLowerCase();
|
|
100
|
+
if (lower.includes('explore') || lower.includes('research') || lower.includes('investigate')) {
|
|
101
|
+
return 'Explore';
|
|
102
|
+
}
|
|
103
|
+
if (lower.includes('plan') || lower.includes('architect') || lower.includes('design')) {
|
|
104
|
+
return 'Plan';
|
|
105
|
+
}
|
|
106
|
+
if (lower.includes('task') ||
|
|
107
|
+
lower.includes('execute') ||
|
|
108
|
+
lower.includes('implement') ||
|
|
109
|
+
lower.includes('build')) {
|
|
110
|
+
return 'Task';
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Normalizes a raw agent type. If the raw type is a model ID or generic name,
|
|
116
|
+
* falls back to keyword detection from the description.
|
|
117
|
+
*/
|
|
118
|
+
function normalizeAgentType(rawType, description) {
|
|
119
|
+
if (!rawType)
|
|
120
|
+
return rawType;
|
|
121
|
+
if (!isGenericAgentType(rawType))
|
|
122
|
+
return rawType;
|
|
123
|
+
// Raw type is a model ID or generic name -- try to detect from description
|
|
124
|
+
if (description) {
|
|
125
|
+
return detectAgentTypeFromDescription(description) || rawType;
|
|
126
|
+
}
|
|
127
|
+
return rawType;
|
|
128
|
+
}
|
|
129
|
+
/** Prefix for synthetic DB session paths */
|
|
130
|
+
const DB_SESSION_PREFIX = 'db-sessions';
|
|
131
|
+
/** Build a synthetic session path for a DB-backed session. */
|
|
132
|
+
function makeDbSessionPath(dataDir, projectId, sessionId) {
|
|
133
|
+
return path.join(dataDir, DB_SESSION_PREFIX, projectId, `${sessionId}.json`);
|
|
134
|
+
}
|
|
135
|
+
/** Check if a path is a synthetic DB session path. */
|
|
136
|
+
function isDbSessionPath(sessionPath) {
|
|
137
|
+
return sessionPath.includes(path.sep + DB_SESSION_PREFIX + path.sep);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Whether a session recorded in `sessionDirectory` belongs to `workspacePath`.
|
|
141
|
+
*
|
|
142
|
+
* A session started inside the workspace (the workspace itself or any
|
|
143
|
+
* subdirectory, e.g. a monorepo package) matches. A session started in a
|
|
144
|
+
* *parent* of the workspace does not: a sub-package workspace must not list
|
|
145
|
+
* every session of the enclosing repo as its own.
|
|
146
|
+
*/
|
|
147
|
+
function workspaceMatches(sessionDirectory, workspacePath) {
|
|
148
|
+
const session = path
|
|
149
|
+
.resolve(sessionDirectory)
|
|
150
|
+
.replace(/[\\/]+$/, '')
|
|
151
|
+
.toLowerCase();
|
|
152
|
+
const workspace = path
|
|
153
|
+
.resolve(workspacePath)
|
|
154
|
+
.replace(/[\\/]+$/, '')
|
|
155
|
+
.toLowerCase();
|
|
156
|
+
return session === workspace || session.startsWith(workspace + path.sep);
|
|
157
|
+
}
|
|
158
|
+
/** Extract project ID from a synthetic DB session path. */
|
|
159
|
+
function extractProjectIdFromDbPath(sessionPath) {
|
|
160
|
+
const prefix = path.sep + DB_SESSION_PREFIX + path.sep;
|
|
161
|
+
const idx = sessionPath.indexOf(prefix);
|
|
162
|
+
if (idx < 0)
|
|
163
|
+
return null;
|
|
164
|
+
const rest = sessionPath.substring(idx + prefix.length);
|
|
165
|
+
const slashIdx = rest.indexOf(path.sep);
|
|
166
|
+
return slashIdx > 0 ? rest.substring(0, slashIdx) : null;
|
|
167
|
+
}
|
|
168
|
+
/** Extract role from a DB message row payload. */
|
|
169
|
+
function extractRoleFromDbMessage(row) {
|
|
170
|
+
try {
|
|
171
|
+
const data = JSON.parse(row.data);
|
|
172
|
+
if (data.role === 'user' || data.role === 'assistant' || data.role === 'system') {
|
|
173
|
+
return data.role;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Ignore malformed payloads
|
|
178
|
+
}
|
|
179
|
+
return 'unknown';
|
|
180
|
+
}
|
|
181
|
+
/** Extract parent message ID from a DB message row payload. */
|
|
182
|
+
function extractParentIdFromDbMessage(row) {
|
|
183
|
+
try {
|
|
184
|
+
const data = JSON.parse(row.data);
|
|
185
|
+
return typeof data.parentID === 'string' && data.parentID.length > 0 ? data.parentID : null;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Resolves the OpenCode project ID for a workspace path.
|
|
193
|
+
*
|
|
194
|
+
* Tries DB first, then file-based project metadata, then git root commit hash.
|
|
195
|
+
*/
|
|
196
|
+
function resolveProjectId(workspacePath, db, runtimeStatus) {
|
|
197
|
+
// Strategy 1: DB lookup
|
|
198
|
+
if (db) {
|
|
199
|
+
const dbProject = db.findProjectByWorktree(workspacePath);
|
|
200
|
+
if (dbProject)
|
|
201
|
+
return dbProject.id;
|
|
202
|
+
const byDirectory = db.findProjectBySessionDirectory(workspacePath);
|
|
203
|
+
if (byDirectory)
|
|
204
|
+
return byDirectory.id;
|
|
205
|
+
}
|
|
206
|
+
if (runtimeStatus.kind !== 'db_missing') {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
// Strategy 2: scan project files to find matching path
|
|
210
|
+
return resolveProjectIdFromFiles(workspacePath);
|
|
211
|
+
}
|
|
212
|
+
function resolveProjectIdFromFiles(workspacePath) {
|
|
213
|
+
const workspaceResolved = normalizePath(workspacePath);
|
|
214
|
+
try {
|
|
215
|
+
const projectDir = path.join(getStorageDir(), 'project');
|
|
216
|
+
if (!fs.existsSync(projectDir))
|
|
217
|
+
return resolveProjectIdFromGit(workspacePath);
|
|
218
|
+
const files = fs.readdirSync(projectDir).filter((f) => f.endsWith('.json'));
|
|
219
|
+
const matches = [];
|
|
220
|
+
for (const file of files) {
|
|
221
|
+
try {
|
|
222
|
+
const project = JSON.parse(fs.readFileSync(path.join(projectDir, file), 'utf-8'));
|
|
223
|
+
if (project.path) {
|
|
224
|
+
const projectPath = normalizePath(project.path);
|
|
225
|
+
if (projectPath === workspaceResolved) {
|
|
226
|
+
matches.push({ id: project.id, path: projectPath });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (workspaceResolved.startsWith(projectPath + path.sep)) {
|
|
230
|
+
matches.push({ id: project.id, path: projectPath });
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (projectPath.startsWith(workspaceResolved + path.sep)) {
|
|
234
|
+
matches.push({ id: project.id, path: projectPath });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
// Skip malformed project files
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (matches.length > 0) {
|
|
243
|
+
matches.sort((a, b) => b.path.length - a.path.length);
|
|
244
|
+
return matches[0].id;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Can't read project directory
|
|
249
|
+
}
|
|
250
|
+
return resolveProjectIdFromGit(workspacePath);
|
|
251
|
+
}
|
|
252
|
+
function resolveProjectIdFromGit(workspacePath) {
|
|
253
|
+
try {
|
|
254
|
+
const hash = execSync('git rev-list --max-parents=0 HEAD', {
|
|
255
|
+
cwd: workspacePath,
|
|
256
|
+
encoding: 'utf-8',
|
|
257
|
+
timeout: 4000,
|
|
258
|
+
killSignal: 'SIGKILL',
|
|
259
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
260
|
+
})
|
|
261
|
+
.trim()
|
|
262
|
+
.split('\n')[0];
|
|
263
|
+
if (hash && /^[a-f0-9]+$/i.test(hash)) {
|
|
264
|
+
return hash;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// Git not available or not a git repo
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Safely reads and parses a JSON file, returning null on failure.
|
|
274
|
+
*/
|
|
275
|
+
function readJsonSafe(filePath) {
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/** Truncate a title to 60 chars with ellipsis. */
|
|
284
|
+
function truncateTitle(title) {
|
|
285
|
+
const trimmed = title.trim();
|
|
286
|
+
if (trimmed.length > 60) {
|
|
287
|
+
return trimmed.substring(0, 57) + '...';
|
|
288
|
+
}
|
|
289
|
+
return trimmed;
|
|
290
|
+
}
|
|
291
|
+
function normalizePath(input) {
|
|
292
|
+
try {
|
|
293
|
+
return fs.realpathSync(input);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return path.resolve(input);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function messageCreatedMs(message) {
|
|
300
|
+
const created = message.time?.created;
|
|
301
|
+
const ms = typeof created === 'number' ? created : Date.parse(String(created ?? ''));
|
|
302
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
303
|
+
}
|
|
304
|
+
class OpenCodeFileReader {
|
|
305
|
+
sessionId;
|
|
306
|
+
seenMessages = new Map();
|
|
307
|
+
pendingSkip = 0;
|
|
308
|
+
storageBase;
|
|
309
|
+
constructor(sessionId) {
|
|
310
|
+
this.sessionId = sessionId;
|
|
311
|
+
this.storageBase = getStorageDir();
|
|
312
|
+
}
|
|
313
|
+
readNew() {
|
|
314
|
+
const messageDir = path.join(this.storageBase, 'message', this.sessionId);
|
|
315
|
+
if (!fs.existsSync(messageDir))
|
|
316
|
+
return [];
|
|
317
|
+
let messageFiles;
|
|
318
|
+
try {
|
|
319
|
+
messageFiles = fs.readdirSync(messageDir).filter((f) => f.endsWith('.json'));
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
const entries = [];
|
|
325
|
+
for (const file of messageFiles) {
|
|
326
|
+
const msgId = path.basename(file, '.json');
|
|
327
|
+
const messagePath = path.join(messageDir, file);
|
|
328
|
+
const message = readJsonSafe(messagePath);
|
|
329
|
+
if (!message)
|
|
330
|
+
continue;
|
|
331
|
+
let mtimeMs = 0;
|
|
332
|
+
try {
|
|
333
|
+
mtimeMs = fs.statSync(messagePath).mtimeMs;
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
mtimeMs = 0;
|
|
337
|
+
}
|
|
338
|
+
const parts = this.readParts(msgId);
|
|
339
|
+
entries.push({ msgId, message, mtimeMs, parts, partIds: new Set(parts.map((p) => p.id)) });
|
|
340
|
+
}
|
|
341
|
+
if (this.pendingSkip > 0) {
|
|
342
|
+
// A position for this reader is a message count (see getPosition()), so a
|
|
343
|
+
// seek marks the oldest N messages as already seen without emitting them.
|
|
344
|
+
const ordered = [...entries].sort((a, b) => messageCreatedMs(a.message) - messageCreatedMs(b.message) ||
|
|
345
|
+
a.msgId.localeCompare(b.msgId));
|
|
346
|
+
for (const entry of ordered.slice(0, this.pendingSkip)) {
|
|
347
|
+
this.seenMessages.set(entry.msgId, { partIds: entry.partIds, mtimeMs: entry.mtimeMs });
|
|
348
|
+
}
|
|
349
|
+
this.pendingSkip = 0;
|
|
350
|
+
}
|
|
351
|
+
const newEvents = [];
|
|
352
|
+
for (const entry of entries) {
|
|
353
|
+
const previous = this.seenMessages.get(entry.msgId);
|
|
354
|
+
const isNewMessage = !previous;
|
|
355
|
+
let hasNewParts = false;
|
|
356
|
+
if (previous) {
|
|
357
|
+
if (previous.mtimeMs !== entry.mtimeMs) {
|
|
358
|
+
hasNewParts = true;
|
|
359
|
+
}
|
|
360
|
+
else if (entry.partIds.size !== previous.partIds.size) {
|
|
361
|
+
hasNewParts = true;
|
|
362
|
+
}
|
|
363
|
+
else {
|
|
364
|
+
for (const partId of entry.partIds) {
|
|
365
|
+
if (!previous.partIds.has(partId)) {
|
|
366
|
+
hasNewParts = true;
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
if (isNewMessage || hasNewParts) {
|
|
373
|
+
newEvents.push(...convertOpenCodeMessage(entry.message, entry.parts));
|
|
374
|
+
this.seenMessages.set(entry.msgId, { partIds: entry.partIds, mtimeMs: entry.mtimeMs });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
// Sort by timestamp
|
|
378
|
+
return newEvents.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
379
|
+
}
|
|
380
|
+
readParts(msgId) {
|
|
381
|
+
const partDir = path.join(this.storageBase, 'part', msgId);
|
|
382
|
+
if (!fs.existsSync(partDir))
|
|
383
|
+
return [];
|
|
384
|
+
try {
|
|
385
|
+
return fs
|
|
386
|
+
.readdirSync(partDir)
|
|
387
|
+
.filter((f) => f.endsWith('.json'))
|
|
388
|
+
.map((f) => readJsonSafe(path.join(partDir, f)))
|
|
389
|
+
.filter((p) => p !== null);
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
// Skip unreadable part directories
|
|
393
|
+
return [];
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
readAll() {
|
|
397
|
+
this.reset();
|
|
398
|
+
return this.readNew();
|
|
399
|
+
}
|
|
400
|
+
reset() {
|
|
401
|
+
this.seenMessages.clear();
|
|
402
|
+
this.pendingSkip = 0;
|
|
403
|
+
}
|
|
404
|
+
exists() {
|
|
405
|
+
return fs.existsSync(path.join(this.storageBase, 'message', this.sessionId));
|
|
406
|
+
}
|
|
407
|
+
flush() {
|
|
408
|
+
// No-op for JSON file reading
|
|
409
|
+
}
|
|
410
|
+
getPosition() {
|
|
411
|
+
return this.seenMessages.size;
|
|
412
|
+
}
|
|
413
|
+
seekTo(position) {
|
|
414
|
+
// Positions are message counts (getPosition()), so the next readNew() marks
|
|
415
|
+
// the oldest `position` messages as seen and emits only what follows.
|
|
416
|
+
this.pendingSkip = Number.isFinite(position) && position > 0 ? Math.floor(position) : 0;
|
|
417
|
+
}
|
|
418
|
+
wasTruncated() {
|
|
419
|
+
return false; // JSON files don't truncate
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// DB-backed reader
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
/**
|
|
426
|
+
* Incremental reader for DB-backed OpenCode sessions.
|
|
427
|
+
*
|
|
428
|
+
* Uses time_updated timestamps to track which data has been seen,
|
|
429
|
+
* querying only for newer messages/parts on each readNew() call.
|
|
430
|
+
*/
|
|
431
|
+
/** How long a database-backed reader trusts its last session-row check. */
|
|
432
|
+
const DB_EXISTS_MEMO_MS = 30_000;
|
|
433
|
+
class OpenCodeDbReader {
|
|
434
|
+
sessionId;
|
|
435
|
+
db;
|
|
436
|
+
lastTimeUpdated = 0;
|
|
437
|
+
hasReadOnce = false;
|
|
438
|
+
existsCheckedAtMs = 0;
|
|
439
|
+
existsResult = true;
|
|
440
|
+
constructor(sessionId, db) {
|
|
441
|
+
this.sessionId = sessionId;
|
|
442
|
+
this.db = db;
|
|
443
|
+
}
|
|
444
|
+
readNew() {
|
|
445
|
+
// On first call, load the full session history so that attaching to an
|
|
446
|
+
// existing session populates the dashboard with all prior events.
|
|
447
|
+
// This matches the behavior of OpenCodeFileReader and the Claude Code
|
|
448
|
+
// JSONL reader where the first readNew() returns all existing data.
|
|
449
|
+
if (!this.hasReadOnce) {
|
|
450
|
+
this.hasReadOnce = true;
|
|
451
|
+
return this.readAllInternal();
|
|
452
|
+
}
|
|
453
|
+
return this.readIncremental();
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Reads all messages and parts for the session, converting them to events.
|
|
457
|
+
* Used for initial history load and readAll().
|
|
458
|
+
*/
|
|
459
|
+
readAllInternal() {
|
|
460
|
+
const events = [];
|
|
461
|
+
const messages = this.db.getMessagesForSession(this.sessionId);
|
|
462
|
+
const parts = this.db.getPartsForSession(this.sessionId);
|
|
463
|
+
if (messages.length === 0) {
|
|
464
|
+
return [];
|
|
465
|
+
}
|
|
466
|
+
// Group parts by message_id
|
|
467
|
+
const partsByMessage = new Map();
|
|
468
|
+
for (const part of parts) {
|
|
469
|
+
const existing = partsByMessage.get(part.message_id);
|
|
470
|
+
if (existing) {
|
|
471
|
+
existing.push(part);
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
partsByMessage.set(part.message_id, [part]);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// Sort messages by creation time
|
|
478
|
+
messages.sort((a, b) => a.time_created - b.time_created);
|
|
479
|
+
// Filter user messages that haven't been processed yet
|
|
480
|
+
const userMessageIds = messages
|
|
481
|
+
.filter((m) => extractRoleFromDbMessage(m) === 'user')
|
|
482
|
+
.map((m) => m.id);
|
|
483
|
+
const processedUserMessageIds = new Set(this.db.getProcessedUserMessageIds(this.sessionId, userMessageIds));
|
|
484
|
+
for (const msgRow of messages) {
|
|
485
|
+
try {
|
|
486
|
+
if (extractRoleFromDbMessage(msgRow) === 'user' &&
|
|
487
|
+
!processedUserMessageIds.has(msgRow.id)) {
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
const message = parseDbMessageData(msgRow);
|
|
491
|
+
const msgParts = (partsByMessage.get(msgRow.id) || [])
|
|
492
|
+
.map((row) => {
|
|
493
|
+
try {
|
|
494
|
+
return parseDbPartData(row);
|
|
495
|
+
}
|
|
496
|
+
catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
})
|
|
500
|
+
.filter((p) => p !== null);
|
|
501
|
+
events.push(...convertOpenCodeMessage(message, msgParts));
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// Skip malformed messages
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// Set cursor to max time_updated across all results
|
|
508
|
+
let maxTimeUpdated = this.lastTimeUpdated;
|
|
509
|
+
for (const m of messages) {
|
|
510
|
+
if (m.time_updated > maxTimeUpdated)
|
|
511
|
+
maxTimeUpdated = m.time_updated;
|
|
512
|
+
}
|
|
513
|
+
for (const p of parts) {
|
|
514
|
+
if (p.time_updated > maxTimeUpdated)
|
|
515
|
+
maxTimeUpdated = p.time_updated;
|
|
516
|
+
}
|
|
517
|
+
this.lastTimeUpdated = maxTimeUpdated;
|
|
518
|
+
return events.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Reads only messages/parts newer than the last cursor position.
|
|
522
|
+
* Used for subsequent polling calls after the initial history load.
|
|
523
|
+
*/
|
|
524
|
+
readIncremental() {
|
|
525
|
+
const events = [];
|
|
526
|
+
// Get messages and parts that are newer than what we've seen
|
|
527
|
+
const messages = this.db.getMessagesNewerThan(this.sessionId, this.lastTimeUpdated);
|
|
528
|
+
const parts = this.db.getPartsNewerThan(this.sessionId, this.lastTimeUpdated);
|
|
529
|
+
if (messages.length === 0 && parts.length === 0) {
|
|
530
|
+
return [];
|
|
531
|
+
}
|
|
532
|
+
// Build set of affected message IDs from both message and part changes
|
|
533
|
+
const affectedMessageIds = new Set(messages.map((m) => m.id));
|
|
534
|
+
for (const part of parts) {
|
|
535
|
+
affectedMessageIds.add(part.message_id);
|
|
536
|
+
}
|
|
537
|
+
// If an assistant reply arrives, include its parent user message so
|
|
538
|
+
// queued user prompts are surfaced only once they are actually processed.
|
|
539
|
+
for (const msg of messages) {
|
|
540
|
+
const parentId = extractParentIdFromDbMessage(msg);
|
|
541
|
+
if (parentId) {
|
|
542
|
+
affectedMessageIds.add(parentId);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
// Fetch missing message rows for part-only updates
|
|
546
|
+
const messageMap = new Map(messages.map((m) => [m.id, m]));
|
|
547
|
+
const missingMessageIds = [...affectedMessageIds].filter((id) => !messageMap.has(id));
|
|
548
|
+
if (missingMessageIds.length > 0) {
|
|
549
|
+
const missingRows = this.db.getMessagesByIds(this.sessionId, missingMessageIds);
|
|
550
|
+
for (const row of missingRows) {
|
|
551
|
+
messageMap.set(row.id, row);
|
|
552
|
+
}
|
|
553
|
+
const unresolved = missingMessageIds.filter((id) => !messageMap.has(id));
|
|
554
|
+
if (unresolved.length > 0) {
|
|
555
|
+
// Keep cursor unchanged so we can retry on next polling cycle.
|
|
556
|
+
return [];
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
const targetMessages = [...messageMap.values()];
|
|
560
|
+
const targetMessageIds = targetMessages.map((m) => m.id);
|
|
561
|
+
// Fetch complete part sets for all affected messages in one batched query
|
|
562
|
+
const allParts = this.db.getPartsForMessages(this.sessionId, targetMessageIds);
|
|
563
|
+
const partsByMessage = new Map();
|
|
564
|
+
for (const part of allParts) {
|
|
565
|
+
const existing = partsByMessage.get(part.message_id);
|
|
566
|
+
if (existing) {
|
|
567
|
+
existing.push(part);
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
partsByMessage.set(part.message_id, [part]);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
// Track max time_updated across all results
|
|
574
|
+
let maxTimeUpdated = this.lastTimeUpdated;
|
|
575
|
+
for (const m of messages) {
|
|
576
|
+
if (m.time_updated > maxTimeUpdated)
|
|
577
|
+
maxTimeUpdated = m.time_updated;
|
|
578
|
+
}
|
|
579
|
+
for (const p of parts) {
|
|
580
|
+
if (p.time_updated > maxTimeUpdated)
|
|
581
|
+
maxTimeUpdated = p.time_updated;
|
|
582
|
+
}
|
|
583
|
+
// Convert each message + its parts to events
|
|
584
|
+
// Sort messages by creation time
|
|
585
|
+
targetMessages.sort((a, b) => a.time_created - b.time_created);
|
|
586
|
+
const userMessageIds = targetMessages
|
|
587
|
+
.filter((m) => extractRoleFromDbMessage(m) === 'user')
|
|
588
|
+
.map((m) => m.id);
|
|
589
|
+
const processedUserMessageIds = new Set(this.db.getProcessedUserMessageIds(this.sessionId, userMessageIds));
|
|
590
|
+
for (const msgRow of targetMessages) {
|
|
591
|
+
try {
|
|
592
|
+
if (extractRoleFromDbMessage(msgRow) === 'user' &&
|
|
593
|
+
!processedUserMessageIds.has(msgRow.id)) {
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const message = parseDbMessageData(msgRow);
|
|
597
|
+
const msgParts = (partsByMessage.get(msgRow.id) || [])
|
|
598
|
+
.map((row) => {
|
|
599
|
+
try {
|
|
600
|
+
return parseDbPartData(row);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
})
|
|
606
|
+
.filter((p) => p !== null);
|
|
607
|
+
events.push(...convertOpenCodeMessage(message, msgParts));
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
// Skip malformed messages
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
this.lastTimeUpdated = maxTimeUpdated;
|
|
614
|
+
return events;
|
|
615
|
+
}
|
|
616
|
+
readAll() {
|
|
617
|
+
this.reset();
|
|
618
|
+
return this.readAllInternal();
|
|
619
|
+
}
|
|
620
|
+
reset() {
|
|
621
|
+
this.lastTimeUpdated = 0;
|
|
622
|
+
this.hasReadOnce = false;
|
|
623
|
+
}
|
|
624
|
+
exists() {
|
|
625
|
+
// DB-backed sessions are durable rows rather than ephemeral files. The row
|
|
626
|
+
// check costs a sqlite3 subprocess, so it is memoised, and a transient
|
|
627
|
+
// query failure still reports "exists" so SessionMonitor does not flap
|
|
628
|
+
// into discovery mode.
|
|
629
|
+
const now = Date.now();
|
|
630
|
+
if (now - this.existsCheckedAtMs < DB_EXISTS_MEMO_MS)
|
|
631
|
+
return this.existsResult;
|
|
632
|
+
this.existsCheckedAtMs = now;
|
|
633
|
+
try {
|
|
634
|
+
const row = this.db.getSession(this.sessionId);
|
|
635
|
+
this.existsResult = row !== null || this.db.getRuntimeStatus().kind === 'query_failed';
|
|
636
|
+
}
|
|
637
|
+
catch {
|
|
638
|
+
this.existsResult = true;
|
|
639
|
+
}
|
|
640
|
+
return this.existsResult;
|
|
641
|
+
}
|
|
642
|
+
flush() {
|
|
643
|
+
// No-op for DB reading
|
|
644
|
+
}
|
|
645
|
+
getPosition() {
|
|
646
|
+
return this.lastTimeUpdated;
|
|
647
|
+
}
|
|
648
|
+
seekTo(position) {
|
|
649
|
+
this.lastTimeUpdated = position;
|
|
650
|
+
this.hasReadOnce = true; // Skip full history load on next readNew()
|
|
651
|
+
}
|
|
652
|
+
wasTruncated() {
|
|
653
|
+
return false;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
// ---------------------------------------------------------------------------
|
|
657
|
+
// Main provider
|
|
658
|
+
// ---------------------------------------------------------------------------
|
|
659
|
+
/**
|
|
660
|
+
* Session provider for OpenCode CLI.
|
|
661
|
+
*
|
|
662
|
+
* Uses SQLite database as primary data source, with file-based
|
|
663
|
+
* scanning as fallback for older OpenCode installations.
|
|
664
|
+
*/
|
|
665
|
+
export class OpenCodeProvider {
|
|
666
|
+
id = 'opencode';
|
|
667
|
+
displayName = 'OpenCode';
|
|
668
|
+
db = null;
|
|
669
|
+
dbInitialized = false;
|
|
670
|
+
dbStatus = { available: false, kind: 'db_missing' };
|
|
671
|
+
diagnostics;
|
|
672
|
+
/** Cache of session metadata populated during listing */
|
|
673
|
+
sessionMetaCache = new Map();
|
|
674
|
+
/** Runtime-reported context window limit (overrides static map when set). */
|
|
675
|
+
dynamicContextWindowLimit = null;
|
|
676
|
+
constructor(options = {}) {
|
|
677
|
+
this.diagnostics = new ProviderDiagnosticTracker(this.id, options);
|
|
678
|
+
}
|
|
679
|
+
/** Lazy-initialize the database connection. */
|
|
680
|
+
ensureDb() {
|
|
681
|
+
if (this.dbInitialized)
|
|
682
|
+
return this.db;
|
|
683
|
+
this.dbInitialized = true;
|
|
684
|
+
const dataDir = getOpenCodeDataDir();
|
|
685
|
+
const db = new OpenCodeDatabase(dataDir);
|
|
686
|
+
if (db.open()) {
|
|
687
|
+
this.db = db;
|
|
688
|
+
}
|
|
689
|
+
this.dbStatus = db.getRuntimeStatus();
|
|
690
|
+
return this.db;
|
|
691
|
+
}
|
|
692
|
+
async ensureDbAsync() {
|
|
693
|
+
if (this.db)
|
|
694
|
+
return this.db;
|
|
695
|
+
return new OpenCodeDatabase(getOpenCodeDataDir());
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Cache an async-created database only after an operation proved it usable.
|
|
699
|
+
* Sync paths treat a cached `db` as "open() succeeded", so adopting an
|
|
700
|
+
* unproven instance would cut them off from the file-storage fallback.
|
|
701
|
+
*/
|
|
702
|
+
adoptDbIfAvailable(db) {
|
|
703
|
+
if (this.db || !db.getRuntimeStatus().available)
|
|
704
|
+
return;
|
|
705
|
+
this.db = db;
|
|
706
|
+
this.dbInitialized = true;
|
|
707
|
+
}
|
|
708
|
+
getRuntimeStatus() {
|
|
709
|
+
const db = this.ensureDb();
|
|
710
|
+
if (db)
|
|
711
|
+
return db.getRuntimeStatus();
|
|
712
|
+
return this.dbStatus;
|
|
713
|
+
}
|
|
714
|
+
getLastOperationStatus() {
|
|
715
|
+
return this.diagnostics.getLastOperationStatus();
|
|
716
|
+
}
|
|
717
|
+
canMonitorDirectory(dir) {
|
|
718
|
+
if (fs.existsSync(dir))
|
|
719
|
+
return true;
|
|
720
|
+
const db = this.ensureDb();
|
|
721
|
+
if (!db)
|
|
722
|
+
return false;
|
|
723
|
+
const projectId = extractProjectIdFromDbPath(dir + path.sep + 'dummy.json') || path.basename(dir);
|
|
724
|
+
return projectId.length > 0 && db.hasProject(projectId);
|
|
725
|
+
}
|
|
726
|
+
mapDbSessions(projectId) {
|
|
727
|
+
const db = this.ensureDb();
|
|
728
|
+
if (!db)
|
|
729
|
+
return [];
|
|
730
|
+
const sessions = db.getSessionsForProject(projectId);
|
|
731
|
+
const dataDir = getOpenCodeDataDir();
|
|
732
|
+
return sessions.map((session) => {
|
|
733
|
+
const syntheticPath = makeDbSessionPath(dataDir, projectId, session.id);
|
|
734
|
+
this.sessionMetaCache.set(syntheticPath, {
|
|
735
|
+
title: session.title,
|
|
736
|
+
timeUpdated: session.time_updated,
|
|
737
|
+
});
|
|
738
|
+
return syntheticPath;
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
// --- Path resolution ---
|
|
742
|
+
getSessionDirectory(workspacePath) {
|
|
743
|
+
const db = this.ensureDb();
|
|
744
|
+
const dbStatus = this.getRuntimeStatus();
|
|
745
|
+
const projectId = resolveProjectId(workspacePath, db, dbStatus);
|
|
746
|
+
if (projectId) {
|
|
747
|
+
// For DB sessions, return a synthetic directory path
|
|
748
|
+
if (db) {
|
|
749
|
+
return path.join(getOpenCodeDataDir(), DB_SESSION_PREFIX, projectId);
|
|
750
|
+
}
|
|
751
|
+
return path.join(getStorageDir(), 'session', projectId);
|
|
752
|
+
}
|
|
753
|
+
if (dbStatus.kind !== 'db_missing') {
|
|
754
|
+
return path.join(getOpenCodeDataDir(), DB_SESSION_PREFIX);
|
|
755
|
+
}
|
|
756
|
+
return path.join(getStorageDir(), 'session');
|
|
757
|
+
}
|
|
758
|
+
discoverSessionDirectory(workspacePath) {
|
|
759
|
+
const db = this.ensureDb();
|
|
760
|
+
const dbStatus = this.getRuntimeStatus();
|
|
761
|
+
const projectId = resolveProjectId(workspacePath, db, dbStatus);
|
|
762
|
+
if (!projectId)
|
|
763
|
+
return null;
|
|
764
|
+
// DB: check if project has sessions
|
|
765
|
+
if (db) {
|
|
766
|
+
if (db.hasProject(projectId)) {
|
|
767
|
+
return path.join(getOpenCodeDataDir(), DB_SESSION_PREFIX, projectId);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (dbStatus.kind !== 'db_missing') {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
// File fallback
|
|
774
|
+
const dir = path.join(getStorageDir(), 'session', projectId);
|
|
775
|
+
return fs.existsSync(dir) ? dir : null;
|
|
776
|
+
}
|
|
777
|
+
// --- Session discovery ---
|
|
778
|
+
findActiveSession(workspacePath) {
|
|
779
|
+
const db = this.ensureDb();
|
|
780
|
+
const dbStatus = this.getRuntimeStatus();
|
|
781
|
+
const projectId = resolveProjectId(workspacePath, db, dbStatus);
|
|
782
|
+
if (!projectId) {
|
|
783
|
+
this.recordDatabaseStatus('findActiveSession', dbStatus.kind === 'db_missing');
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
// DB primary
|
|
787
|
+
if (db) {
|
|
788
|
+
const session = db.getMostRecentSession(projectId);
|
|
789
|
+
if (session) {
|
|
790
|
+
const syntheticPath = makeDbSessionPath(getOpenCodeDataDir(), projectId, session.id);
|
|
791
|
+
this.sessionMetaCache.set(syntheticPath, {
|
|
792
|
+
title: session.title,
|
|
793
|
+
timeUpdated: session.time_updated,
|
|
794
|
+
});
|
|
795
|
+
this.recordDatabaseStatus('findActiveSession', true);
|
|
796
|
+
return syntheticPath;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (dbStatus.kind !== 'db_missing') {
|
|
800
|
+
this.recordDatabaseStatus('findActiveSession', dbStatus.available);
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
// File fallback
|
|
804
|
+
const result = this.findActiveSessionFromFiles(projectId);
|
|
805
|
+
this.recordDatabaseStatus('findActiveSession', true);
|
|
806
|
+
return result;
|
|
807
|
+
}
|
|
808
|
+
findActiveSessionFromFiles(projectId) {
|
|
809
|
+
const sessionDir = path.join(getStorageDir(), 'session', projectId);
|
|
810
|
+
if (!fs.existsSync(sessionDir))
|
|
811
|
+
return null;
|
|
812
|
+
let bestPath = null;
|
|
813
|
+
let bestMtime = 0;
|
|
814
|
+
try {
|
|
815
|
+
const files = fs.readdirSync(sessionDir).filter((f) => f.endsWith('.json'));
|
|
816
|
+
for (const file of files) {
|
|
817
|
+
const fullPath = path.join(sessionDir, file);
|
|
818
|
+
try {
|
|
819
|
+
const stats = fs.statSync(fullPath);
|
|
820
|
+
if (stats.size > 0 && stats.mtime.getTime() > bestMtime) {
|
|
821
|
+
bestMtime = stats.mtime.getTime();
|
|
822
|
+
bestPath = fullPath;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
// Skip
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
catch {
|
|
831
|
+
return null;
|
|
832
|
+
}
|
|
833
|
+
if (bestPath) {
|
|
834
|
+
const sessionId = path.basename(bestPath, '.json');
|
|
835
|
+
const messageDir = path.join(getStorageDir(), 'message', sessionId);
|
|
836
|
+
if (fs.existsSync(messageDir)) {
|
|
837
|
+
try {
|
|
838
|
+
const messageFiles = fs.readdirSync(messageDir).filter((f) => f.endsWith('.json'));
|
|
839
|
+
for (const mf of messageFiles) {
|
|
840
|
+
try {
|
|
841
|
+
const mstat = fs.statSync(path.join(messageDir, mf));
|
|
842
|
+
if (mstat.mtime.getTime() > bestMtime) {
|
|
843
|
+
bestMtime = mstat.mtime.getTime();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
catch {
|
|
847
|
+
// Skip
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
catch {
|
|
852
|
+
// Skip
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
return bestPath;
|
|
856
|
+
}
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
/** Backward-compatible alias for findAllSessions. */
|
|
860
|
+
findSessionFiles(workspacePath) {
|
|
861
|
+
return this.findAllSessions(workspacePath);
|
|
862
|
+
}
|
|
863
|
+
findAllSessions(workspacePath) {
|
|
864
|
+
const db = this.ensureDb();
|
|
865
|
+
const dbStatus = this.getRuntimeStatus();
|
|
866
|
+
const projectId = resolveProjectId(workspacePath, db, dbStatus);
|
|
867
|
+
if (!projectId) {
|
|
868
|
+
this.recordDatabaseStatus('findAllSessions', dbStatus.kind === 'db_missing');
|
|
869
|
+
return [];
|
|
870
|
+
}
|
|
871
|
+
// DB primary
|
|
872
|
+
if (db) {
|
|
873
|
+
const sessions = this.mapDbSessions(projectId);
|
|
874
|
+
this.recordDatabaseStatus('findAllSessions', true);
|
|
875
|
+
return sessions;
|
|
876
|
+
}
|
|
877
|
+
if (dbStatus.kind !== 'db_missing') {
|
|
878
|
+
this.recordDatabaseStatus('findAllSessions', dbStatus.available);
|
|
879
|
+
return [];
|
|
880
|
+
}
|
|
881
|
+
// File fallback
|
|
882
|
+
const sessionDir = path.join(getStorageDir(), 'session', projectId);
|
|
883
|
+
const sessions = this.findSessionsInDirectoryFromFiles(sessionDir);
|
|
884
|
+
this.recordDatabaseStatus('findAllSessions', true);
|
|
885
|
+
return sessions;
|
|
886
|
+
}
|
|
887
|
+
findSessionById(workspacePath, sessionId) {
|
|
888
|
+
const normalizedId = sessionId.trim();
|
|
889
|
+
if (!normalizedId || path.basename(normalizedId) !== normalizedId)
|
|
890
|
+
return null;
|
|
891
|
+
try {
|
|
892
|
+
const db = this.ensureDb();
|
|
893
|
+
if (db) {
|
|
894
|
+
const session = db.getSession(normalizedId);
|
|
895
|
+
if (session && workspaceMatches(session.directory, workspacePath)) {
|
|
896
|
+
const syntheticPath = makeDbSessionPath(getOpenCodeDataDir(), session.project_id, session.id);
|
|
897
|
+
this.sessionMetaCache.set(syntheticPath, {
|
|
898
|
+
title: session.title,
|
|
899
|
+
timeUpdated: session.time_updated,
|
|
900
|
+
});
|
|
901
|
+
this.recordDatabaseStatus('findSessionById', true);
|
|
902
|
+
return syntheticPath;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
const projectId = resolveProjectId(workspacePath, db, this.getRuntimeStatus());
|
|
906
|
+
if (projectId) {
|
|
907
|
+
const candidate = path.join(getStorageDir(), 'session', projectId, `${normalizedId}.json`);
|
|
908
|
+
try {
|
|
909
|
+
if (fs.statSync(candidate).isFile()) {
|
|
910
|
+
this.recordDatabaseStatus('findSessionById', true);
|
|
911
|
+
return candidate;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
catch {
|
|
915
|
+
// Missing legacy session is a normal miss.
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
this.recordDatabaseStatus('findSessionById', true);
|
|
919
|
+
return null;
|
|
920
|
+
}
|
|
921
|
+
catch {
|
|
922
|
+
this.recordDatabaseStatus('findSessionById', false);
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
findSessionsInDirectory(dir) {
|
|
927
|
+
const db = this.ensureDb();
|
|
928
|
+
const dbStatus = this.getRuntimeStatus();
|
|
929
|
+
// Check if this is a synthetic DB session directory
|
|
930
|
+
if (db && dir.includes(path.sep + DB_SESSION_PREFIX + path.sep)) {
|
|
931
|
+
const projectId = extractProjectIdFromDbPath(dir + path.sep + 'dummy.json');
|
|
932
|
+
if (projectId) {
|
|
933
|
+
const sessions = this.mapDbSessions(projectId);
|
|
934
|
+
this.recordDatabaseStatus('findSessionsInDirectory', true);
|
|
935
|
+
return sessions;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
// For a DB-backed directory path ending with the project ID,
|
|
939
|
+
// try to extract project ID from dir name
|
|
940
|
+
if (db) {
|
|
941
|
+
const dirName = path.basename(dir);
|
|
942
|
+
if (db.hasProject(dirName)) {
|
|
943
|
+
const sessions = this.mapDbSessions(dirName);
|
|
944
|
+
this.recordDatabaseStatus('findSessionsInDirectory', true);
|
|
945
|
+
return sessions;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (dbStatus.kind !== 'db_missing') {
|
|
949
|
+
this.recordDatabaseStatus('findSessionsInDirectory', dbStatus.available);
|
|
950
|
+
return [];
|
|
951
|
+
}
|
|
952
|
+
// File fallback
|
|
953
|
+
const sessions = this.findSessionsInDirectoryFromFiles(dir);
|
|
954
|
+
this.recordDatabaseStatus('findSessionsInDirectory', true);
|
|
955
|
+
return sessions;
|
|
956
|
+
}
|
|
957
|
+
findSessionsInDirectoryFromFiles(dir) {
|
|
958
|
+
try {
|
|
959
|
+
if (!fs.existsSync(dir))
|
|
960
|
+
return [];
|
|
961
|
+
return fs
|
|
962
|
+
.readdirSync(dir)
|
|
963
|
+
.filter((f) => f.endsWith('.json'))
|
|
964
|
+
.map((f) => {
|
|
965
|
+
const fullPath = path.join(dir, f);
|
|
966
|
+
try {
|
|
967
|
+
const stats = fs.statSync(fullPath);
|
|
968
|
+
return { path: fullPath, mtime: stats.mtime.getTime(), size: stats.size };
|
|
969
|
+
}
|
|
970
|
+
catch {
|
|
971
|
+
return null;
|
|
972
|
+
}
|
|
973
|
+
})
|
|
974
|
+
.filter((f) => f !== null && f.size > 0)
|
|
975
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
976
|
+
.map((f) => f.path);
|
|
977
|
+
}
|
|
978
|
+
catch {
|
|
979
|
+
return [];
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
listAllSessionFiles() {
|
|
983
|
+
try {
|
|
984
|
+
const db = this.ensureDb();
|
|
985
|
+
if (db) {
|
|
986
|
+
const dataDir = getOpenCodeDataDir();
|
|
987
|
+
const files = db.getAllSessions().map((session) => {
|
|
988
|
+
const syntheticPath = makeDbSessionPath(dataDir, session.project_id, session.id);
|
|
989
|
+
this.sessionMetaCache.set(syntheticPath, {
|
|
990
|
+
title: session.title,
|
|
991
|
+
timeUpdated: session.time_updated,
|
|
992
|
+
});
|
|
993
|
+
return {
|
|
994
|
+
path: syntheticPath,
|
|
995
|
+
mtime: new Date(session.time_updated),
|
|
996
|
+
label: session.title ?? undefined,
|
|
997
|
+
sizeBytes: 0,
|
|
998
|
+
sessionId: session.id,
|
|
999
|
+
workspacePath: session.directory,
|
|
1000
|
+
createdAt: new Date(session.time_created),
|
|
1001
|
+
};
|
|
1002
|
+
});
|
|
1003
|
+
this.recordDatabaseStatus('listAllSessionFiles', true);
|
|
1004
|
+
return files;
|
|
1005
|
+
}
|
|
1006
|
+
if (this.getRuntimeStatus().kind !== 'db_missing') {
|
|
1007
|
+
this.recordDatabaseStatus('listAllSessionFiles', true);
|
|
1008
|
+
return [];
|
|
1009
|
+
}
|
|
1010
|
+
const root = path.join(getStorageDir(), 'session');
|
|
1011
|
+
const results = [];
|
|
1012
|
+
let projectDirs = [];
|
|
1013
|
+
try {
|
|
1014
|
+
projectDirs = fs.readdirSync(root, { withFileTypes: true });
|
|
1015
|
+
}
|
|
1016
|
+
catch {
|
|
1017
|
+
this.recordDatabaseStatus('listAllSessionFiles', true);
|
|
1018
|
+
return [];
|
|
1019
|
+
}
|
|
1020
|
+
for (const entry of projectDirs) {
|
|
1021
|
+
if (!entry.isDirectory())
|
|
1022
|
+
continue;
|
|
1023
|
+
for (const sessionPath of this.findSessionsInDirectoryFromFiles(path.join(root, entry.name))) {
|
|
1024
|
+
try {
|
|
1025
|
+
const stats = fs.statSync(sessionPath);
|
|
1026
|
+
results.push({
|
|
1027
|
+
path: sessionPath,
|
|
1028
|
+
mtime: stats.mtime,
|
|
1029
|
+
sizeBytes: stats.size,
|
|
1030
|
+
sessionId: path.basename(sessionPath, '.json'),
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
// Session vanished between listing and stat.
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
this.recordDatabaseStatus('listAllSessionFiles', true);
|
|
1039
|
+
return results.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
1040
|
+
}
|
|
1041
|
+
catch {
|
|
1042
|
+
this.recordDatabaseStatus('listAllSessionFiles', false);
|
|
1043
|
+
return [];
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
async listSessionFilesAsync(workspacePath) {
|
|
1047
|
+
const db = await this.ensureDbAsync();
|
|
1048
|
+
const sessions = await db.getAllSessionsAsync();
|
|
1049
|
+
this.dbStatus = db.getRuntimeStatus();
|
|
1050
|
+
this.adoptDbIfAvailable(db);
|
|
1051
|
+
if (this.dbStatus.available) {
|
|
1052
|
+
const dataDir = getOpenCodeDataDir();
|
|
1053
|
+
const files = [];
|
|
1054
|
+
for (const session of sessions) {
|
|
1055
|
+
if (workspacePath && !workspaceMatches(session.directory, workspacePath))
|
|
1056
|
+
continue;
|
|
1057
|
+
const syntheticPath = makeDbSessionPath(dataDir, session.project_id, session.id);
|
|
1058
|
+
this.sessionMetaCache.set(syntheticPath, {
|
|
1059
|
+
title: session.title,
|
|
1060
|
+
timeUpdated: session.time_updated,
|
|
1061
|
+
});
|
|
1062
|
+
files.push({
|
|
1063
|
+
path: syntheticPath,
|
|
1064
|
+
mtime: new Date(session.time_updated),
|
|
1065
|
+
label: session.title ?? undefined,
|
|
1066
|
+
sizeBytes: 0,
|
|
1067
|
+
sessionId: session.id,
|
|
1068
|
+
workspacePath: session.directory,
|
|
1069
|
+
createdAt: new Date(session.time_created),
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
this.recordDatabaseStatus('listSessionFilesAsync', true);
|
|
1073
|
+
return files;
|
|
1074
|
+
}
|
|
1075
|
+
const root = path.join(getStorageDir(), 'session');
|
|
1076
|
+
const results = [];
|
|
1077
|
+
let projectDirs = [];
|
|
1078
|
+
try {
|
|
1079
|
+
projectDirs = await fs.promises.readdir(root, { withFileTypes: true });
|
|
1080
|
+
}
|
|
1081
|
+
catch {
|
|
1082
|
+
this.recordDatabaseStatus('listSessionFilesAsync', true);
|
|
1083
|
+
return [];
|
|
1084
|
+
}
|
|
1085
|
+
for (const project of projectDirs) {
|
|
1086
|
+
if (!project.isDirectory())
|
|
1087
|
+
continue;
|
|
1088
|
+
let entries = [];
|
|
1089
|
+
try {
|
|
1090
|
+
entries = await fs.promises.readdir(path.join(root, project.name), {
|
|
1091
|
+
withFileTypes: true,
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
1094
|
+
catch {
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
for (const entry of entries) {
|
|
1098
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
1099
|
+
continue;
|
|
1100
|
+
const sessionPath = path.join(root, project.name, entry.name);
|
|
1101
|
+
try {
|
|
1102
|
+
const [stat, raw] = await Promise.all([
|
|
1103
|
+
fs.promises.stat(sessionPath),
|
|
1104
|
+
workspacePath ? fs.promises.readFile(sessionPath, 'utf8') : Promise.resolve(null),
|
|
1105
|
+
]);
|
|
1106
|
+
let session = null;
|
|
1107
|
+
if (raw !== null) {
|
|
1108
|
+
try {
|
|
1109
|
+
session = JSON.parse(raw);
|
|
1110
|
+
}
|
|
1111
|
+
catch {
|
|
1112
|
+
continue;
|
|
1113
|
+
}
|
|
1114
|
+
const directory = session.directory;
|
|
1115
|
+
if (directory && !workspaceMatches(directory, workspacePath))
|
|
1116
|
+
continue;
|
|
1117
|
+
}
|
|
1118
|
+
results.push({
|
|
1119
|
+
path: sessionPath,
|
|
1120
|
+
mtime: stat.mtime,
|
|
1121
|
+
sizeBytes: stat.size,
|
|
1122
|
+
sessionId: path.basename(entry.name, '.json'),
|
|
1123
|
+
workspacePath: session?.directory,
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
catch {
|
|
1127
|
+
// Skip inaccessible sessions.
|
|
1128
|
+
}
|
|
1129
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
this.recordDatabaseStatus('listSessionFilesAsync', true);
|
|
1133
|
+
return results.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
1134
|
+
}
|
|
1135
|
+
getAllProjectFolders(workspacePath) {
|
|
1136
|
+
const db = this.ensureDb();
|
|
1137
|
+
const folders = [];
|
|
1138
|
+
const dbStatus = this.getRuntimeStatus();
|
|
1139
|
+
// DB primary
|
|
1140
|
+
if (db) {
|
|
1141
|
+
const projects = db.getAllProjects();
|
|
1142
|
+
const stats = db.getProjectSessionStats();
|
|
1143
|
+
const statsMap = new Map(stats.map((s) => [s.projectId, s]));
|
|
1144
|
+
let currentProjectId = null;
|
|
1145
|
+
if (workspacePath) {
|
|
1146
|
+
currentProjectId = resolveProjectId(workspacePath, db, dbStatus);
|
|
1147
|
+
}
|
|
1148
|
+
const dataDir = getOpenCodeDataDir();
|
|
1149
|
+
for (const project of projects) {
|
|
1150
|
+
const projStats = statsMap.get(project.id);
|
|
1151
|
+
if (!projStats || projStats.sessionCount === 0)
|
|
1152
|
+
continue;
|
|
1153
|
+
folders.push({
|
|
1154
|
+
dir: path.join(dataDir, DB_SESSION_PREFIX, project.id),
|
|
1155
|
+
name: project.worktree || project.name || project.id,
|
|
1156
|
+
encodedName: project.id,
|
|
1157
|
+
sessionCount: projStats.sessionCount,
|
|
1158
|
+
lastModified: new Date(projStats.maxTimeUpdated),
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
// Sort: current project first, then by recency
|
|
1162
|
+
folders.sort((a, b) => {
|
|
1163
|
+
if (currentProjectId) {
|
|
1164
|
+
const aIsCurrent = a.encodedName === currentProjectId;
|
|
1165
|
+
const bIsCurrent = b.encodedName === currentProjectId;
|
|
1166
|
+
if (aIsCurrent && !bIsCurrent)
|
|
1167
|
+
return -1;
|
|
1168
|
+
if (!aIsCurrent && bIsCurrent)
|
|
1169
|
+
return 1;
|
|
1170
|
+
}
|
|
1171
|
+
return b.lastModified.getTime() - a.lastModified.getTime();
|
|
1172
|
+
});
|
|
1173
|
+
if (folders.length > 0) {
|
|
1174
|
+
this.recordDatabaseStatus('getAllProjectFolders', true);
|
|
1175
|
+
return folders;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
if (dbStatus.kind !== 'db_missing') {
|
|
1179
|
+
this.recordDatabaseStatus('getAllProjectFolders', dbStatus.available);
|
|
1180
|
+
return [];
|
|
1181
|
+
}
|
|
1182
|
+
// File fallback
|
|
1183
|
+
const fallback = this.getAllProjectFoldersFromFiles(workspacePath);
|
|
1184
|
+
this.recordDatabaseStatus('getAllProjectFolders', true);
|
|
1185
|
+
return fallback;
|
|
1186
|
+
}
|
|
1187
|
+
getAllProjectFoldersFromFiles(workspacePath) {
|
|
1188
|
+
const folders = [];
|
|
1189
|
+
const sessionBase = path.join(getStorageDir(), 'session');
|
|
1190
|
+
try {
|
|
1191
|
+
if (!fs.existsSync(sessionBase))
|
|
1192
|
+
return [];
|
|
1193
|
+
const projectIds = fs.readdirSync(sessionBase).filter((name) => {
|
|
1194
|
+
try {
|
|
1195
|
+
return fs.statSync(path.join(sessionBase, name)).isDirectory();
|
|
1196
|
+
}
|
|
1197
|
+
catch {
|
|
1198
|
+
return false;
|
|
1199
|
+
}
|
|
1200
|
+
});
|
|
1201
|
+
const projectDir = path.join(getStorageDir(), 'project');
|
|
1202
|
+
const projectNames = new Map();
|
|
1203
|
+
if (fs.existsSync(projectDir)) {
|
|
1204
|
+
try {
|
|
1205
|
+
for (const file of fs.readdirSync(projectDir).filter((f) => f.endsWith('.json'))) {
|
|
1206
|
+
const proj = readJsonSafe(path.join(projectDir, file));
|
|
1207
|
+
if (proj) {
|
|
1208
|
+
projectNames.set(proj.id, proj.path || proj.name || proj.id);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
catch {
|
|
1213
|
+
// Skip
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
let currentProjectId = null;
|
|
1217
|
+
if (workspacePath) {
|
|
1218
|
+
currentProjectId = resolveProjectIdFromFiles(workspacePath);
|
|
1219
|
+
}
|
|
1220
|
+
for (const projectId of projectIds) {
|
|
1221
|
+
const projSessionDir = path.join(sessionBase, projectId);
|
|
1222
|
+
let sessionCount = 0;
|
|
1223
|
+
let lastModified = new Date(0);
|
|
1224
|
+
try {
|
|
1225
|
+
const sessions = fs.readdirSync(projSessionDir).filter((f) => f.endsWith('.json'));
|
|
1226
|
+
for (const session of sessions) {
|
|
1227
|
+
try {
|
|
1228
|
+
const fstats = fs.statSync(path.join(projSessionDir, session));
|
|
1229
|
+
if (fstats.size > 0) {
|
|
1230
|
+
sessionCount++;
|
|
1231
|
+
if (fstats.mtime > lastModified) {
|
|
1232
|
+
lastModified = fstats.mtime;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
catch {
|
|
1237
|
+
// Skip
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
catch {
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
folders.push({
|
|
1245
|
+
dir: projSessionDir,
|
|
1246
|
+
name: projectNames.get(projectId) || projectId,
|
|
1247
|
+
encodedName: projectId,
|
|
1248
|
+
sessionCount,
|
|
1249
|
+
lastModified,
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
folders.sort((a, b) => {
|
|
1253
|
+
if (currentProjectId) {
|
|
1254
|
+
const aIsCurrent = a.encodedName === currentProjectId;
|
|
1255
|
+
const bIsCurrent = b.encodedName === currentProjectId;
|
|
1256
|
+
if (aIsCurrent && !bIsCurrent)
|
|
1257
|
+
return -1;
|
|
1258
|
+
if (!aIsCurrent && bIsCurrent)
|
|
1259
|
+
return 1;
|
|
1260
|
+
}
|
|
1261
|
+
return b.lastModified.getTime() - a.lastModified.getTime();
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
catch {
|
|
1265
|
+
// Skip
|
|
1266
|
+
}
|
|
1267
|
+
return folders;
|
|
1268
|
+
}
|
|
1269
|
+
// --- File identification ---
|
|
1270
|
+
isSessionFile(filename) {
|
|
1271
|
+
return filename.endsWith('.json');
|
|
1272
|
+
}
|
|
1273
|
+
getSessionId(sessionPath) {
|
|
1274
|
+
return path.basename(sessionPath, '.json');
|
|
1275
|
+
}
|
|
1276
|
+
encodeWorkspacePath(workspacePath) {
|
|
1277
|
+
const db = this.ensureDb();
|
|
1278
|
+
return resolveProjectId(workspacePath, db, this.getRuntimeStatus()) || workspacePath;
|
|
1279
|
+
}
|
|
1280
|
+
/** Session title from the metadata cache or the database; null without either. */
|
|
1281
|
+
extractSessionLabelFromMetadata(sessionPath) {
|
|
1282
|
+
// Check metadata cache first
|
|
1283
|
+
const cached = this.sessionMetaCache.get(sessionPath);
|
|
1284
|
+
if (cached?.title) {
|
|
1285
|
+
return truncateTitle(cached.title);
|
|
1286
|
+
}
|
|
1287
|
+
// DB lookup
|
|
1288
|
+
const db = this.ensureDb();
|
|
1289
|
+
if (db) {
|
|
1290
|
+
const session = db.getSession(this.getSessionId(sessionPath));
|
|
1291
|
+
if (session?.title) {
|
|
1292
|
+
this.recordDatabaseStatus('extractSessionLabel', true);
|
|
1293
|
+
return truncateTitle(session.title);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
return null;
|
|
1297
|
+
}
|
|
1298
|
+
extractSessionLabel(sessionPath) {
|
|
1299
|
+
const fromMetadata = this.extractSessionLabelFromMetadata(sessionPath);
|
|
1300
|
+
if (fromMetadata)
|
|
1301
|
+
return fromMetadata;
|
|
1302
|
+
// File fallback
|
|
1303
|
+
const sessionId = this.getSessionId(sessionPath);
|
|
1304
|
+
const label = this.extractSessionLabelFromFiles(sessionPath, sessionId);
|
|
1305
|
+
this.recordDatabaseStatus('extractSessionLabel', label !== null || !isDbSessionPath(sessionPath));
|
|
1306
|
+
return label;
|
|
1307
|
+
}
|
|
1308
|
+
async extractSessionLabelsAsync(sessionPaths) {
|
|
1309
|
+
const results = new Map();
|
|
1310
|
+
const unresolved = [];
|
|
1311
|
+
for (const sessionPath of sessionPaths) {
|
|
1312
|
+
const cached = this.sessionMetaCache.get(sessionPath);
|
|
1313
|
+
if (cached)
|
|
1314
|
+
results.set(sessionPath, cached.title ? truncateTitle(cached.title) : null);
|
|
1315
|
+
else
|
|
1316
|
+
unresolved.push(sessionPath);
|
|
1317
|
+
}
|
|
1318
|
+
if (unresolved.length > 0 && (!this.dbInitialized || this.dbStatus.available)) {
|
|
1319
|
+
const db = await this.ensureDbAsync();
|
|
1320
|
+
const byId = new Map(unresolved.map((item) => [this.getSessionId(item), item]));
|
|
1321
|
+
const sessions = await db.getSessionsByIdsAsync([...byId.keys()]);
|
|
1322
|
+
this.dbStatus = db.getRuntimeStatus();
|
|
1323
|
+
this.adoptDbIfAvailable(db);
|
|
1324
|
+
for (const session of sessions) {
|
|
1325
|
+
const sessionPath = byId.get(session.id);
|
|
1326
|
+
if (!sessionPath)
|
|
1327
|
+
continue;
|
|
1328
|
+
this.sessionMetaCache.set(sessionPath, {
|
|
1329
|
+
title: session.title,
|
|
1330
|
+
timeUpdated: session.time_updated,
|
|
1331
|
+
});
|
|
1332
|
+
results.set(sessionPath, session.title ? truncateTitle(session.title) : null);
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
for (const sessionPath of unresolved) {
|
|
1336
|
+
if (results.has(sessionPath))
|
|
1337
|
+
continue;
|
|
1338
|
+
if (isDbSessionPath(sessionPath)) {
|
|
1339
|
+
results.set(sessionPath, null);
|
|
1340
|
+
continue;
|
|
1341
|
+
}
|
|
1342
|
+
try {
|
|
1343
|
+
const session = JSON.parse(await fs.promises.readFile(sessionPath, 'utf8'));
|
|
1344
|
+
results.set(sessionPath, session.title ? truncateTitle(session.title) : null);
|
|
1345
|
+
}
|
|
1346
|
+
catch {
|
|
1347
|
+
results.set(sessionPath, null);
|
|
1348
|
+
}
|
|
1349
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
1350
|
+
}
|
|
1351
|
+
this.recordDatabaseStatus('extractSessionLabelsAsync', true);
|
|
1352
|
+
return results;
|
|
1353
|
+
}
|
|
1354
|
+
extractSessionLabelFromFiles(sessionPath, sessionId) {
|
|
1355
|
+
if (isDbSessionPath(sessionPath))
|
|
1356
|
+
return null;
|
|
1357
|
+
const session = readJsonSafe(sessionPath);
|
|
1358
|
+
if (session?.title) {
|
|
1359
|
+
return truncateTitle(session.title);
|
|
1360
|
+
}
|
|
1361
|
+
const messageDir = path.join(getStorageDir(), 'message', sessionId);
|
|
1362
|
+
if (!fs.existsSync(messageDir))
|
|
1363
|
+
return null;
|
|
1364
|
+
try {
|
|
1365
|
+
const files = fs
|
|
1366
|
+
.readdirSync(messageDir)
|
|
1367
|
+
.filter((f) => f.endsWith('.json'))
|
|
1368
|
+
.slice(0, 5);
|
|
1369
|
+
for (const file of files) {
|
|
1370
|
+
const msg = readJsonSafe(path.join(messageDir, file));
|
|
1371
|
+
if (msg?.role === 'user') {
|
|
1372
|
+
const partDir = path.join(getStorageDir(), 'part', msg.id);
|
|
1373
|
+
if (fs.existsSync(partDir)) {
|
|
1374
|
+
const partFiles = fs.readdirSync(partDir).filter((f) => f.endsWith('.json'));
|
|
1375
|
+
for (const pf of partFiles) {
|
|
1376
|
+
const part = readJsonSafe(path.join(partDir, pf));
|
|
1377
|
+
if (part?.type === 'text' && part.text.trim().length > 0) {
|
|
1378
|
+
let text = part.text.trim().replace(/\s+/g, ' ');
|
|
1379
|
+
if (text.length > 60) {
|
|
1380
|
+
text = text.substring(0, 57) + '...';
|
|
1381
|
+
}
|
|
1382
|
+
return text;
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
catch {
|
|
1390
|
+
// Skip
|
|
1391
|
+
}
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
// --- Data reading ---
|
|
1395
|
+
createReader(sessionPath) {
|
|
1396
|
+
const db = this.ensureDb();
|
|
1397
|
+
const sessionId = this.getSessionId(sessionPath);
|
|
1398
|
+
// Use DB reader if available and session exists in DB
|
|
1399
|
+
if (db) {
|
|
1400
|
+
const session = db.getSession(sessionId);
|
|
1401
|
+
if (session) {
|
|
1402
|
+
return new OpenCodeDbReader(sessionId, db);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
// File fallback
|
|
1406
|
+
return new OpenCodeFileReader(sessionId);
|
|
1407
|
+
}
|
|
1408
|
+
// --- Subagent support ---
|
|
1409
|
+
scanSubagents(_sessionDir, sessionId) {
|
|
1410
|
+
const db = this.ensureDb();
|
|
1411
|
+
if (!db)
|
|
1412
|
+
return [];
|
|
1413
|
+
try {
|
|
1414
|
+
// Get subtask parts from the parent session for metadata
|
|
1415
|
+
const parts = db.getPartsForSession(sessionId);
|
|
1416
|
+
const subtaskParts = [];
|
|
1417
|
+
for (const partRow of parts) {
|
|
1418
|
+
try {
|
|
1419
|
+
const data = JSON.parse(partRow.data);
|
|
1420
|
+
if (data.type !== 'subtask')
|
|
1421
|
+
continue;
|
|
1422
|
+
subtaskParts.push({
|
|
1423
|
+
id: partRow.id,
|
|
1424
|
+
agent: data.agent || undefined,
|
|
1425
|
+
description: data.description || undefined,
|
|
1426
|
+
timeCreated: partRow.time_created,
|
|
1427
|
+
});
|
|
1428
|
+
}
|
|
1429
|
+
catch {
|
|
1430
|
+
// Skip malformed part data
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
// Query child sessions for real metrics
|
|
1434
|
+
const childSessions = db.getChildSessions(sessionId);
|
|
1435
|
+
// Build a lookup from child session index to subtask part metadata.
|
|
1436
|
+
// Child sessions are ordered by time_created; subtask parts likewise.
|
|
1437
|
+
// Match them positionally when counts align, otherwise fall back to
|
|
1438
|
+
// child session data only.
|
|
1439
|
+
const results = [];
|
|
1440
|
+
if (childSessions.length > 0) {
|
|
1441
|
+
for (let i = 0; i < childSessions.length; i++) {
|
|
1442
|
+
const child = childSessions[i];
|
|
1443
|
+
const subtask = i < subtaskParts.length ? subtaskParts[i] : undefined;
|
|
1444
|
+
// Aggregate tokens from child session messages
|
|
1445
|
+
const childMessages = db.getMessagesForSession(child.id);
|
|
1446
|
+
const tokenTotals = {
|
|
1447
|
+
agentId: '',
|
|
1448
|
+
toolCalls: [],
|
|
1449
|
+
inputTokens: 0,
|
|
1450
|
+
outputTokens: 0,
|
|
1451
|
+
};
|
|
1452
|
+
for (const msgRow of childMessages) {
|
|
1453
|
+
try {
|
|
1454
|
+
const msgData = JSON.parse(msgRow.data);
|
|
1455
|
+
const tokens = msgData.tokens;
|
|
1456
|
+
if (tokens) {
|
|
1457
|
+
// Same semantics as the main-session parser (openCodeParser).
|
|
1458
|
+
const cache = tokens.cache;
|
|
1459
|
+
const providerID = msgData.providerID;
|
|
1460
|
+
addUsageToSubagent(tokenTotals, normalizeProviderUsage({
|
|
1461
|
+
semantics: providerID === 'openai'
|
|
1462
|
+
? 'openai'
|
|
1463
|
+
: providerID === 'anthropic'
|
|
1464
|
+
? 'anthropic'
|
|
1465
|
+
: 'sidekick',
|
|
1466
|
+
provider: providerID ?? 'opencode',
|
|
1467
|
+
inputTokens: tokens.input,
|
|
1468
|
+
outputTokens: tokens.output,
|
|
1469
|
+
cacheReadTokens: cache?.read,
|
|
1470
|
+
cacheWriteTokens: cache?.write,
|
|
1471
|
+
reasoningTokens: tokens.reasoning,
|
|
1472
|
+
reasoningIncludedInOutput: providerID === 'openai',
|
|
1473
|
+
}));
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
catch {
|
|
1477
|
+
// Skip malformed messages
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
const { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, totalTokens, } = tokenTotals;
|
|
1481
|
+
// Extract tool calls from child session parts
|
|
1482
|
+
const childParts = db.getPartsForSession(child.id);
|
|
1483
|
+
const toolCalls = [];
|
|
1484
|
+
for (const childPart of childParts) {
|
|
1485
|
+
try {
|
|
1486
|
+
const partData = JSON.parse(childPart.data);
|
|
1487
|
+
if (partData.type !== 'tool' && partData.type !== 'tool-invocation')
|
|
1488
|
+
continue;
|
|
1489
|
+
const state = partData.state;
|
|
1490
|
+
let duration;
|
|
1491
|
+
const timeInfo = state?.time;
|
|
1492
|
+
if (timeInfo?.start && timeInfo?.end) {
|
|
1493
|
+
const startMs = typeof timeInfo.start === 'number'
|
|
1494
|
+
? timeInfo.start
|
|
1495
|
+
: new Date(timeInfo.start).getTime();
|
|
1496
|
+
const endMs = typeof timeInfo.end === 'number'
|
|
1497
|
+
? timeInfo.end
|
|
1498
|
+
: new Date(timeInfo.end).getTime();
|
|
1499
|
+
if (endMs > startMs)
|
|
1500
|
+
duration = endMs - startMs;
|
|
1501
|
+
}
|
|
1502
|
+
toolCalls.push({
|
|
1503
|
+
name: normalizeToolName(partData.tool || ''),
|
|
1504
|
+
input: normalizeToolInput(state?.input || {}),
|
|
1505
|
+
timestamp: new Date(childPart.time_created),
|
|
1506
|
+
duration,
|
|
1507
|
+
isError: state?.status === 'error',
|
|
1508
|
+
});
|
|
1509
|
+
}
|
|
1510
|
+
catch {
|
|
1511
|
+
// Skip malformed parts
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
// Calculate duration from first/last message timestamps
|
|
1515
|
+
let startTime;
|
|
1516
|
+
let endTime;
|
|
1517
|
+
let durationMs;
|
|
1518
|
+
if (childMessages.length > 0) {
|
|
1519
|
+
startTime = new Date(childMessages[0].time_created);
|
|
1520
|
+
endTime = new Date(childMessages[childMessages.length - 1].time_created);
|
|
1521
|
+
durationMs = endTime.getTime() - startTime.getTime();
|
|
1522
|
+
}
|
|
1523
|
+
else {
|
|
1524
|
+
startTime = new Date(child.time_created);
|
|
1525
|
+
}
|
|
1526
|
+
const rawAgentType = subtask?.agent || child.title || undefined;
|
|
1527
|
+
const description = subtask?.description || child.title || undefined;
|
|
1528
|
+
results.push({
|
|
1529
|
+
agentId: subtask?.id || child.id,
|
|
1530
|
+
agentType: normalizeAgentType(rawAgentType, description),
|
|
1531
|
+
description,
|
|
1532
|
+
toolCalls,
|
|
1533
|
+
inputTokens,
|
|
1534
|
+
outputTokens,
|
|
1535
|
+
cacheReadTokens,
|
|
1536
|
+
cacheWriteTokens,
|
|
1537
|
+
reasoningTokens,
|
|
1538
|
+
totalTokens,
|
|
1539
|
+
startTime,
|
|
1540
|
+
endTime,
|
|
1541
|
+
durationMs,
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
else {
|
|
1546
|
+
// Fallback: no child sessions found, return stubs from subtask parts
|
|
1547
|
+
for (const sp of subtaskParts) {
|
|
1548
|
+
results.push({
|
|
1549
|
+
agentId: sp.id,
|
|
1550
|
+
agentType: normalizeAgentType(sp.agent, sp.description),
|
|
1551
|
+
description: sp.description,
|
|
1552
|
+
toolCalls: [],
|
|
1553
|
+
inputTokens: 0,
|
|
1554
|
+
outputTokens: 0,
|
|
1555
|
+
startTime: new Date(sp.timeCreated),
|
|
1556
|
+
});
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
return results;
|
|
1560
|
+
}
|
|
1561
|
+
catch {
|
|
1562
|
+
return [];
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
// --- Cross-session search ---
|
|
1566
|
+
searchInSession(sessionPath, query, maxResults) {
|
|
1567
|
+
const db = this.ensureDb();
|
|
1568
|
+
const sessionId = this.getSessionId(sessionPath);
|
|
1569
|
+
const queryLower = query.toLowerCase();
|
|
1570
|
+
// DB primary
|
|
1571
|
+
if (db) {
|
|
1572
|
+
const dbSession = db.getSession(sessionId);
|
|
1573
|
+
if (dbSession) {
|
|
1574
|
+
return this.searchInSessionFromDb(db, sessionId, sessionPath, dbSession.project_id, queryLower, query, maxResults);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
// File fallback
|
|
1578
|
+
return this.searchInSessionFromFiles(sessionPath, sessionId, queryLower, query, maxResults);
|
|
1579
|
+
}
|
|
1580
|
+
searchInSessionFromDb(db, sessionId, sessionPath, projectId, queryLower, query, maxResults) {
|
|
1581
|
+
const results = [];
|
|
1582
|
+
try {
|
|
1583
|
+
const parts = db.getPartsForSession(sessionId);
|
|
1584
|
+
const messages = db.getMessagesForSession(sessionId);
|
|
1585
|
+
const messageMap = new Map(messages.map((m) => [m.id, m]));
|
|
1586
|
+
for (const partRow of parts) {
|
|
1587
|
+
if (results.length >= maxResults)
|
|
1588
|
+
break;
|
|
1589
|
+
const dataStr = partRow.data;
|
|
1590
|
+
const dataLower = dataStr.toLowerCase();
|
|
1591
|
+
const matchIdx = dataLower.indexOf(queryLower);
|
|
1592
|
+
if (matchIdx < 0)
|
|
1593
|
+
continue;
|
|
1594
|
+
// Extract a snippet from the raw data
|
|
1595
|
+
const start = Math.max(0, matchIdx - 40);
|
|
1596
|
+
const end = Math.min(dataStr.length, matchIdx + query.length + 40);
|
|
1597
|
+
const snippet = (start > 0 ? '...' : '') +
|
|
1598
|
+
dataStr.substring(start, end) +
|
|
1599
|
+
(end < dataStr.length ? '...' : '');
|
|
1600
|
+
const msgRow = messageMap.get(partRow.message_id);
|
|
1601
|
+
const msgData = msgRow ? JSON.parse(msgRow.data) : {};
|
|
1602
|
+
results.push({
|
|
1603
|
+
sessionPath,
|
|
1604
|
+
line: snippet.replace(/\n/g, ' '),
|
|
1605
|
+
eventType: msgData.role || 'unknown',
|
|
1606
|
+
timestamp: String(partRow.time_created),
|
|
1607
|
+
projectPath: projectId,
|
|
1608
|
+
});
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
catch {
|
|
1612
|
+
// Skip
|
|
1613
|
+
}
|
|
1614
|
+
return results;
|
|
1615
|
+
}
|
|
1616
|
+
searchInSessionFromFiles(sessionPath, sessionId, queryLower, query, maxResults) {
|
|
1617
|
+
const results = [];
|
|
1618
|
+
const messageDir = path.join(getStorageDir(), 'message', sessionId);
|
|
1619
|
+
if (!fs.existsSync(messageDir))
|
|
1620
|
+
return results;
|
|
1621
|
+
try {
|
|
1622
|
+
const session = readJsonSafe(sessionPath);
|
|
1623
|
+
const projectPath = session?.projectID || sessionId;
|
|
1624
|
+
const messageFiles = fs.readdirSync(messageDir).filter((f) => f.endsWith('.json'));
|
|
1625
|
+
for (const file of messageFiles) {
|
|
1626
|
+
if (results.length >= maxResults)
|
|
1627
|
+
break;
|
|
1628
|
+
const msg = readJsonSafe(path.join(messageDir, file));
|
|
1629
|
+
if (!msg)
|
|
1630
|
+
continue;
|
|
1631
|
+
const partDir = path.join(getStorageDir(), 'part', msg.id);
|
|
1632
|
+
if (!fs.existsSync(partDir))
|
|
1633
|
+
continue;
|
|
1634
|
+
try {
|
|
1635
|
+
const partFiles = fs.readdirSync(partDir).filter((f) => f.endsWith('.json'));
|
|
1636
|
+
for (const pf of partFiles) {
|
|
1637
|
+
if (results.length >= maxResults)
|
|
1638
|
+
break;
|
|
1639
|
+
const part = readJsonSafe(path.join(partDir, pf));
|
|
1640
|
+
if (!part)
|
|
1641
|
+
continue;
|
|
1642
|
+
let text = '';
|
|
1643
|
+
if (part.type === 'text')
|
|
1644
|
+
text = part.text;
|
|
1645
|
+
else if (part.type === 'reasoning')
|
|
1646
|
+
text = part.text;
|
|
1647
|
+
else if (part.type === 'tool-invocation' && part.state.output)
|
|
1648
|
+
text = part.state.output;
|
|
1649
|
+
else if (part.type === 'tool' && part.state.output)
|
|
1650
|
+
text = part.state.output;
|
|
1651
|
+
if (!text)
|
|
1652
|
+
continue;
|
|
1653
|
+
const textLower = text.toLowerCase();
|
|
1654
|
+
const matchIdx = textLower.indexOf(queryLower);
|
|
1655
|
+
if (matchIdx < 0)
|
|
1656
|
+
continue;
|
|
1657
|
+
const start = Math.max(0, matchIdx - 40);
|
|
1658
|
+
const end = Math.min(text.length, matchIdx + query.length + 40);
|
|
1659
|
+
const snippet = (start > 0 ? '...' : '') +
|
|
1660
|
+
text.substring(start, end) +
|
|
1661
|
+
(end < text.length ? '...' : '');
|
|
1662
|
+
results.push({
|
|
1663
|
+
sessionPath,
|
|
1664
|
+
line: snippet.replace(/\n/g, ' '),
|
|
1665
|
+
eventType: msg.role,
|
|
1666
|
+
timestamp: String(msg.time.created),
|
|
1667
|
+
projectPath,
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
catch {
|
|
1672
|
+
// Skip
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
catch {
|
|
1677
|
+
// Skip
|
|
1678
|
+
}
|
|
1679
|
+
return results;
|
|
1680
|
+
}
|
|
1681
|
+
getProjectsBaseDir() {
|
|
1682
|
+
return path.join(getStorageDir(), 'session');
|
|
1683
|
+
}
|
|
1684
|
+
getWatchRoots() {
|
|
1685
|
+
return [getOpenCodeDataDir(), this.getProjectsBaseDir()];
|
|
1686
|
+
}
|
|
1687
|
+
// --- Stats ---
|
|
1688
|
+
readSessionStats(sessionPath) {
|
|
1689
|
+
// One reader pass (database-backed or file-backed) feeds the shared
|
|
1690
|
+
// aggregator, so compaction and truncation counts are real instead of the
|
|
1691
|
+
// hardcoded zeros the direct SQLite read returned, and a session that
|
|
1692
|
+
// cannot be read reports `availability: 'unavailable'` instead of zeros.
|
|
1693
|
+
return readSessionFileStats(this, sessionPath, {
|
|
1694
|
+
resolveLabel: () => this.extractSessionLabelFromMetadata(sessionPath),
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
readSessionContextSnapshot(sessionPath, options = {}) {
|
|
1698
|
+
return readSessionContextSnapshot(this, sessionPath, options);
|
|
1699
|
+
}
|
|
1700
|
+
// --- Optional methods ---
|
|
1701
|
+
getSessionMetadata(sessionPath) {
|
|
1702
|
+
// Check cache first
|
|
1703
|
+
const cached = this.sessionMetaCache.get(sessionPath);
|
|
1704
|
+
if (cached) {
|
|
1705
|
+
return { mtime: new Date(cached.timeUpdated) };
|
|
1706
|
+
}
|
|
1707
|
+
// DB lookup
|
|
1708
|
+
const db = this.ensureDb();
|
|
1709
|
+
if (db) {
|
|
1710
|
+
const sessionId = this.getSessionId(sessionPath);
|
|
1711
|
+
const session = db.getSession(sessionId);
|
|
1712
|
+
if (session) {
|
|
1713
|
+
this.sessionMetaCache.set(sessionPath, {
|
|
1714
|
+
title: session.title,
|
|
1715
|
+
timeUpdated: session.time_updated,
|
|
1716
|
+
});
|
|
1717
|
+
return { mtime: new Date(session.time_updated) };
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
// Try filesystem for non-DB paths
|
|
1721
|
+
if (!isDbSessionPath(sessionPath)) {
|
|
1722
|
+
try {
|
|
1723
|
+
const stats = fs.statSync(sessionPath);
|
|
1724
|
+
return { mtime: stats.mtime };
|
|
1725
|
+
}
|
|
1726
|
+
catch {
|
|
1727
|
+
// File doesn't exist
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
return null;
|
|
1731
|
+
}
|
|
1732
|
+
async getSessionMetadataAsync(sessionPath) {
|
|
1733
|
+
if (!isDbSessionPath(sessionPath)) {
|
|
1734
|
+
try {
|
|
1735
|
+
return { mtime: (await fs.promises.stat(sessionPath)).mtime };
|
|
1736
|
+
}
|
|
1737
|
+
catch {
|
|
1738
|
+
return null;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
const cached = this.sessionMetaCache.get(sessionPath);
|
|
1742
|
+
try {
|
|
1743
|
+
const db = await this.ensureDbAsync();
|
|
1744
|
+
const sessionId = this.getSessionId(sessionPath);
|
|
1745
|
+
const session = (await db.getSessionsByIdsAsync([sessionId]))[0];
|
|
1746
|
+
this.dbStatus = db.getRuntimeStatus();
|
|
1747
|
+
this.adoptDbIfAvailable(db);
|
|
1748
|
+
if (session) {
|
|
1749
|
+
this.sessionMetaCache.set(sessionPath, {
|
|
1750
|
+
title: session.title,
|
|
1751
|
+
timeUpdated: session.time_updated,
|
|
1752
|
+
});
|
|
1753
|
+
this.recordDatabaseStatus('getSessionMetadataAsync', true);
|
|
1754
|
+
return { mtime: new Date(session.time_updated) };
|
|
1755
|
+
}
|
|
1756
|
+
this.recordDatabaseStatus('getSessionMetadataAsync', cached !== undefined);
|
|
1757
|
+
}
|
|
1758
|
+
catch {
|
|
1759
|
+
this.recordDatabaseStatus('getSessionMetadataAsync', cached !== undefined);
|
|
1760
|
+
}
|
|
1761
|
+
return cached ? { mtime: new Date(cached.timeUpdated) } : null;
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Returns the latest assistant message's token snapshot for context window sizing.
|
|
1765
|
+
* Cumulative session totals are derived from readNew() events processed by SessionMonitor.
|
|
1766
|
+
*/
|
|
1767
|
+
getCurrentUsageSnapshot(sessionPath) {
|
|
1768
|
+
const db = this.ensureDb();
|
|
1769
|
+
if (!db)
|
|
1770
|
+
return null;
|
|
1771
|
+
const sessionId = this.getSessionId(sessionPath);
|
|
1772
|
+
const snapshot = db.getLatestAssistantContextUsage(sessionId);
|
|
1773
|
+
if (!snapshot)
|
|
1774
|
+
return null;
|
|
1775
|
+
return {
|
|
1776
|
+
inputTokens: Number(snapshot.inputTokens) || 0,
|
|
1777
|
+
outputTokens: Number(snapshot.outputTokens) || 0,
|
|
1778
|
+
cacheWriteTokens: Number(snapshot.cacheWriteTokens) || 0,
|
|
1779
|
+
cacheReadTokens: Number(snapshot.cacheReadTokens) || 0,
|
|
1780
|
+
reasoningTokens: Number(snapshot.reasoningTokens) || 0,
|
|
1781
|
+
model: snapshot.modelId || 'unknown',
|
|
1782
|
+
timestamp: new Date(Number(snapshot.timeCreated) || Date.now()),
|
|
1783
|
+
};
|
|
1784
|
+
}
|
|
1785
|
+
computeContextSize(usage) {
|
|
1786
|
+
// Context = input tokens + cache tokens. Output and reasoning tokens
|
|
1787
|
+
// are the model's response, not part of the context window.
|
|
1788
|
+
return usage.inputTokens + usage.cacheWriteTokens + usage.cacheReadTokens;
|
|
1789
|
+
}
|
|
1790
|
+
getContextAttribution(sessionPath) {
|
|
1791
|
+
const db = this.ensureDb();
|
|
1792
|
+
if (!db)
|
|
1793
|
+
return null;
|
|
1794
|
+
const sessionId = this.getSessionId(sessionPath);
|
|
1795
|
+
try {
|
|
1796
|
+
const messages = db.getMessagesForSession(sessionId);
|
|
1797
|
+
if (messages.length === 0)
|
|
1798
|
+
return null;
|
|
1799
|
+
const attribution = {
|
|
1800
|
+
systemPrompt: 0,
|
|
1801
|
+
userMessages: 0,
|
|
1802
|
+
assistantResponses: 0,
|
|
1803
|
+
toolInputs: 0,
|
|
1804
|
+
toolOutputs: 0,
|
|
1805
|
+
thinking: 0,
|
|
1806
|
+
other: 0,
|
|
1807
|
+
};
|
|
1808
|
+
// Accumulate input tokens by role. OpenCode messages store actual
|
|
1809
|
+
// token counts from the provider, so this is more accurate than
|
|
1810
|
+
// heuristic content estimation.
|
|
1811
|
+
for (const row of messages) {
|
|
1812
|
+
try {
|
|
1813
|
+
const data = JSON.parse(row.data);
|
|
1814
|
+
const role = data.role;
|
|
1815
|
+
const msgTokens = data.tokens;
|
|
1816
|
+
const cache = msgTokens?.cache;
|
|
1817
|
+
// Context contribution = input + cacheWrite + cacheRead
|
|
1818
|
+
const input = msgTokens?.input || 0;
|
|
1819
|
+
const cacheRead = cache?.read || 0;
|
|
1820
|
+
const cacheWrite = cache?.write || 0;
|
|
1821
|
+
const reasoning = msgTokens?.reasoning || 0;
|
|
1822
|
+
const contextTokens = input + cacheRead + cacheWrite;
|
|
1823
|
+
if (contextTokens === 0)
|
|
1824
|
+
continue;
|
|
1825
|
+
if (role === 'user') {
|
|
1826
|
+
// User messages include prompts and tool results;
|
|
1827
|
+
// we can't distinguish without parsing parts, so attribute
|
|
1828
|
+
// to userMessages (tool outputs arrive as separate assistant
|
|
1829
|
+
// messages in OpenCode's model)
|
|
1830
|
+
attribution.userMessages += contextTokens;
|
|
1831
|
+
}
|
|
1832
|
+
else if (role === 'assistant') {
|
|
1833
|
+
// Attribute reasoning separately when available
|
|
1834
|
+
if (reasoning > 0) {
|
|
1835
|
+
attribution.thinking += reasoning;
|
|
1836
|
+
attribution.assistantResponses += contextTokens - reasoning;
|
|
1837
|
+
}
|
|
1838
|
+
else {
|
|
1839
|
+
attribution.assistantResponses += contextTokens;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
else if (role === 'system') {
|
|
1843
|
+
attribution.systemPrompt += contextTokens;
|
|
1844
|
+
}
|
|
1845
|
+
else {
|
|
1846
|
+
attribution.other += contextTokens;
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
catch {
|
|
1850
|
+
// Skip malformed message data
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
// Refine user attribution: scan parts to separate tool outputs
|
|
1854
|
+
// from user prompts for more accurate breakdown
|
|
1855
|
+
const parts = db.getPartsForSession(sessionId);
|
|
1856
|
+
let toolPartCount = 0;
|
|
1857
|
+
let userTextPartCount = 0;
|
|
1858
|
+
for (const partRow of parts) {
|
|
1859
|
+
try {
|
|
1860
|
+
const partData = JSON.parse(partRow.data);
|
|
1861
|
+
const partType = partData.type;
|
|
1862
|
+
if (partType === 'tool' || partType === 'tool-invocation') {
|
|
1863
|
+
toolPartCount++;
|
|
1864
|
+
}
|
|
1865
|
+
else if (partType === 'text') {
|
|
1866
|
+
userTextPartCount++;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
catch {
|
|
1870
|
+
// Skip
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
// If there are tool parts, redistribute user token attribution
|
|
1874
|
+
// between userMessages and toolOutputs proportionally
|
|
1875
|
+
const totalParts = toolPartCount + userTextPartCount;
|
|
1876
|
+
if (totalParts > 0 && toolPartCount > 0 && attribution.userMessages > 0) {
|
|
1877
|
+
const toolProportion = toolPartCount / totalParts;
|
|
1878
|
+
const toolTokens = Math.round(attribution.userMessages * toolProportion);
|
|
1879
|
+
attribution.toolOutputs += toolTokens;
|
|
1880
|
+
attribution.userMessages -= toolTokens;
|
|
1881
|
+
}
|
|
1882
|
+
const total = attribution.systemPrompt +
|
|
1883
|
+
attribution.userMessages +
|
|
1884
|
+
attribution.assistantResponses +
|
|
1885
|
+
attribution.toolInputs +
|
|
1886
|
+
attribution.toolOutputs +
|
|
1887
|
+
attribution.thinking +
|
|
1888
|
+
attribution.other;
|
|
1889
|
+
return total > 0 ? attribution : null;
|
|
1890
|
+
}
|
|
1891
|
+
catch {
|
|
1892
|
+
return null;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
getContextWindowLimit(modelId) {
|
|
1896
|
+
if (this.dynamicContextWindowLimit)
|
|
1897
|
+
return this.dynamicContextWindowLimit;
|
|
1898
|
+
return getModelContextWindowSize(modelId);
|
|
1899
|
+
}
|
|
1900
|
+
/** Set a runtime-reported context window limit (overrides static map). */
|
|
1901
|
+
setDynamicContextWindowLimit(limit) {
|
|
1902
|
+
this.dynamicContextWindowLimit = limit;
|
|
1903
|
+
}
|
|
1904
|
+
// --- Lifecycle ---
|
|
1905
|
+
dispose() {
|
|
1906
|
+
this.db?.close();
|
|
1907
|
+
this.db = null;
|
|
1908
|
+
this.dbInitialized = false;
|
|
1909
|
+
this.dbStatus = { available: false, kind: 'db_missing' };
|
|
1910
|
+
this.sessionMetaCache.clear();
|
|
1911
|
+
this.dynamicContextWindowLimit = null;
|
|
1912
|
+
}
|
|
1913
|
+
recordDatabaseStatus(operation, usable) {
|
|
1914
|
+
const status = this.db?.getRuntimeStatus() ?? this.dbStatus;
|
|
1915
|
+
const diagnostic = diagnosticFromRuntimeStatus(this.id, 'query', status);
|
|
1916
|
+
if (!diagnostic || status.kind === 'db_missing') {
|
|
1917
|
+
// A genuinely absent OpenCode DB is expected for legacy file storage.
|
|
1918
|
+
this.diagnostics.available(operation);
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
this.diagnostics.degraded(operation, status, {
|
|
1922
|
+
kind: diagnostic.kind,
|
|
1923
|
+
severity: diagnostic.severity,
|
|
1924
|
+
phase: diagnostic.phase,
|
|
1925
|
+
message: diagnostic.message,
|
|
1926
|
+
}, usable);
|
|
1927
|
+
}
|
|
1928
|
+
// --- z.ai (GLM Coding Plan) compatibility quota derivation ---
|
|
1929
|
+
/**
|
|
1930
|
+
* Derives a z.ai coding-plan `QuotaState` from OpenCode assistant turns
|
|
1931
|
+
* tagged with `providerID ∈ {zai, zai-coding-plan}`.
|
|
1932
|
+
*
|
|
1933
|
+
* This compatibility estimator predates the authoritative z.ai quota API.
|
|
1934
|
+
* New product code should use `zaiQuotaApi.ts`; this method remains for
|
|
1935
|
+
* consumers that still want a local OpenCode DB-derived estimate. Returns
|
|
1936
|
+
* null when no z.ai routing is detected.
|
|
1937
|
+
*
|
|
1938
|
+
* @param tier Plan tier; `'auto'` infers from observed weekly volume.
|
|
1939
|
+
* When the caller has no opinion, the heuristic falls back to
|
|
1940
|
+
* `'max'` (conservative — small overcount rather than large).
|
|
1941
|
+
*/
|
|
1942
|
+
getZaiQuotaState(tier = 'auto') {
|
|
1943
|
+
const db = this.ensureDb();
|
|
1944
|
+
if (!db)
|
|
1945
|
+
return null;
|
|
1946
|
+
const sinceMs = Date.now() - 7 * 86_400_000;
|
|
1947
|
+
let rawRows = [];
|
|
1948
|
+
try {
|
|
1949
|
+
rawRows = db.getAssistantMessagesByProviderId([...ZAI_PROVIDER_IDS], sinceMs);
|
|
1950
|
+
}
|
|
1951
|
+
catch {
|
|
1952
|
+
return null;
|
|
1953
|
+
}
|
|
1954
|
+
if (rawRows.length === 0)
|
|
1955
|
+
return null;
|
|
1956
|
+
const turns = rawRows.map((row) => ({
|
|
1957
|
+
timestampMs: row.timeCreated,
|
|
1958
|
+
model: row.modelId,
|
|
1959
|
+
inputTokens: row.inputTokens || 0,
|
|
1960
|
+
outputTokens: row.outputTokens || 0,
|
|
1961
|
+
cacheReadTokens: row.cacheReadTokens || 0,
|
|
1962
|
+
cacheWriteTokens: row.cacheWriteTokens || 0,
|
|
1963
|
+
reasoningTokens: row.reasoningTokens || 0,
|
|
1964
|
+
}));
|
|
1965
|
+
const accumulated = accumulateZaiUsage(turns, Date.now());
|
|
1966
|
+
// Default to 'max' on auto when usage is non-zero — overestimating the
|
|
1967
|
+
// tier gives a lower (safer) utilization % until calibration runs.
|
|
1968
|
+
const resolvedTier = tier === 'auto' ? (accumulated.weeklyTurns > 0 ? 'max' : 'lite') : tier;
|
|
1969
|
+
// Walk rows again to trap any captured z.ai business errors.
|
|
1970
|
+
let authoritativeFiveHourResetAt;
|
|
1971
|
+
let authoritativeWeeklyResetAt;
|
|
1972
|
+
for (const row of rawRows) {
|
|
1973
|
+
if (row.errorCode == null && !row.errorMessage)
|
|
1974
|
+
continue;
|
|
1975
|
+
const parsed = parseZaiQuotaError({
|
|
1976
|
+
code: row.errorCode ?? undefined,
|
|
1977
|
+
message: row.errorMessage ?? undefined,
|
|
1978
|
+
});
|
|
1979
|
+
if (!parsed?.resetsAt)
|
|
1980
|
+
continue;
|
|
1981
|
+
if (parsed.kind === 'exhausted') {
|
|
1982
|
+
authoritativeFiveHourResetAt = parsed.resetsAt;
|
|
1983
|
+
if (String(parsed.code) === '1310')
|
|
1984
|
+
authoritativeWeeklyResetAt = parsed.resetsAt;
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
return inferZaiQuotaState(accumulated, resolvedTier, {
|
|
1988
|
+
authoritativeFiveHourResetAt,
|
|
1989
|
+
authoritativeWeeklyResetAt,
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
/**
|
|
1993
|
+
* Cheap boolean: does the local OpenCode install currently route at z.ai?
|
|
1994
|
+
* Used by callers to decide whether to render a z.ai quota surface.
|
|
1995
|
+
*/
|
|
1996
|
+
isZaiRoutingActive() {
|
|
1997
|
+
const db = this.ensureDb();
|
|
1998
|
+
if (!db)
|
|
1999
|
+
return false;
|
|
2000
|
+
try {
|
|
2001
|
+
const rows = db.getAssistantMessagesByProviderId([...ZAI_PROVIDER_IDS], Date.now() - 7 * 86_400_000);
|
|
2002
|
+
return rows.length > 0;
|
|
2003
|
+
}
|
|
2004
|
+
catch {
|
|
2005
|
+
return false;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|