ubume 0.1.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/README.md +88 -0
- package/bin/codexa-local-harness-bridge.js +3 -0
- package/bin/codexa.js +28 -0
- package/bin/ubume-local-harness-bridge.js +325 -0
- package/bin/ubume.js +398 -0
- package/package.json +66 -0
- package/src/app.tsx +5759 -0
- package/src/commands/handler.ts +889 -0
- package/src/config/appVersion.ts +69 -0
- package/src/config/buildInfo.ts +3 -0
- package/src/config/launchArgs.ts +196 -0
- package/src/config/layeredConfig.ts +853 -0
- package/src/config/legacyEnv.ts +16 -0
- package/src/config/persistence.ts +377 -0
- package/src/config/runtimeConfig.ts +558 -0
- package/src/config/settings.ts +405 -0
- package/src/config/toml-serialize.ts +109 -0
- package/src/config/trustStore.ts +84 -0
- package/src/config/updateCheckCache.ts +85 -0
- package/src/core/README.md +55 -0
- package/src/core/agent/loop.ts +464 -0
- package/src/core/agent/protocol.ts +345 -0
- package/src/core/agent/tools.ts +423 -0
- package/src/core/auth/codexAuth.ts +359 -0
- package/src/core/codex/codexExecArgs.ts +166 -0
- package/src/core/codex/codexLaunch.ts +163 -0
- package/src/core/codex/codexPrompt.ts +429 -0
- package/src/core/debug/inputDebug.ts +51 -0
- package/src/core/debug/localStreamDebug.ts +50 -0
- package/src/core/debug/modelStateDebug.ts +35 -0
- package/src/core/executables/antigravityExecutable.ts +48 -0
- package/src/core/executables/claudeExecutable.ts +63 -0
- package/src/core/executables/codexExecutable.ts +160 -0
- package/src/core/executables/executableResolver.ts +164 -0
- package/src/core/executables/geminiExecutable.ts +78 -0
- package/src/core/models/codexCapabilities.ts +97 -0
- package/src/core/models/codexModelCapabilities.ts +624 -0
- package/src/core/models/codexModelsCacheSeed.ts +153 -0
- package/src/core/models/modelSpecs.ts +303 -0
- package/src/core/models/providerModelCache.ts +94 -0
- package/src/core/perf/profiler.ts +125 -0
- package/src/core/perf/renderDebug.ts +398 -0
- package/src/core/process/CommandRunner.ts +280 -0
- package/src/core/process/processValidation.ts +111 -0
- package/src/core/providerLauncher/launcher.ts +220 -0
- package/src/core/providerLauncher/registry.ts +354 -0
- package/src/core/providerLauncher/types.ts +95 -0
- package/src/core/providerLauncher/workspaceConfig.ts +487 -0
- package/src/core/providerRuntime/anthropic.ts +580 -0
- package/src/core/providerRuntime/antigravity.ts +500 -0
- package/src/core/providerRuntime/capabilityProfile.ts +383 -0
- package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
- package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
- package/src/core/providerRuntime/codexaCupy.ts +97 -0
- package/src/core/providerRuntime/codexaNative.ts +425 -0
- package/src/core/providerRuntime/contextMetadata.ts +397 -0
- package/src/core/providerRuntime/gemini.ts +789 -0
- package/src/core/providerRuntime/lmstudio.ts +118 -0
- package/src/core/providerRuntime/local.ts +770 -0
- package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
- package/src/core/providerRuntime/localOutputBudget.ts +17 -0
- package/src/core/providerRuntime/mistralVibe.ts +667 -0
- package/src/core/providerRuntime/models.ts +175 -0
- package/src/core/providerRuntime/reasoning.ts +20 -0
- package/src/core/providerRuntime/registry.ts +286 -0
- package/src/core/providerRuntime/types.ts +142 -0
- package/src/core/providerRuntime/unsloth.ts +216 -0
- package/src/core/providers/codexJsonStream.ts +305 -0
- package/src/core/providers/codexSubprocess.ts +378 -0
- package/src/core/providers/codexTranscript.ts +695 -0
- package/src/core/providers/openaiNative.ts +13 -0
- package/src/core/providers/registry.ts +21 -0
- package/src/core/providers/types.ts +91 -0
- package/src/core/shared/attachments.ts +101 -0
- package/src/core/shared/cleanupFastFail.ts +67 -0
- package/src/core/shared/clipboard.ts +24 -0
- package/src/core/shared/clipboardImage.ts +111 -0
- package/src/core/shared/githubDiagnostics.ts +222 -0
- package/src/core/shared/hollowResponseFormat.ts +39 -0
- package/src/core/terminal/clearFrameBoundary.ts +852 -0
- package/src/core/terminal/frameLock.ts +110 -0
- package/src/core/terminal/inkRenderReset.ts +123 -0
- package/src/core/terminal/startupClear.ts +20 -0
- package/src/core/terminal/terminalCapabilities.ts +100 -0
- package/src/core/terminal/terminalControl.ts +169 -0
- package/src/core/terminal/terminalSanitize.ts +147 -0
- package/src/core/terminal/terminalTitle.ts +400 -0
- package/src/core/version/channel.ts +27 -0
- package/src/core/version/packageManager.ts +119 -0
- package/src/core/version/updateCheck.ts +203 -0
- package/src/core/workspace/appData.ts +107 -0
- package/src/core/workspace/conversationStore.ts +335 -0
- package/src/core/workspace/launchContext.ts +259 -0
- package/src/core/workspace/planStorage.ts +135 -0
- package/src/core/workspace/projectInstructions.ts +54 -0
- package/src/core/workspace/scratchDir.ts +64 -0
- package/src/core/workspace/workspaceActivity.ts +384 -0
- package/src/core/workspace/workspaceGuard.ts +377 -0
- package/src/core/workspace/workspaceRoot.ts +47 -0
- package/src/exec.ts +73 -0
- package/src/headless/execArgs.ts +296 -0
- package/src/headless/execRunner.ts +304 -0
- package/src/index.tsx +270 -0
- package/src/legacyEnvBootstrap.ts +5 -0
- package/src/session/appSession.ts +771 -0
- package/src/session/chatLifecycle.ts +994 -0
- package/src/session/conversation.ts +107 -0
- package/src/session/liveRenderScheduler.ts +214 -0
- package/src/session/persistedResponse.ts +93 -0
- package/src/session/planFlow.ts +159 -0
- package/src/session/planTranscript.ts +19 -0
- package/src/session/promptRunSchedule.ts +26 -0
- package/src/session/types.ts +234 -0
- package/src/test/runtimeTestUtils.ts +14 -0
- package/src/types/react-dom.d.ts +3 -0
- package/src/ui/chrome/ActivityBars.tsx +68 -0
- package/src/ui/chrome/ActivityIndicator.tsx +58 -0
- package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
- package/src/ui/chrome/AppShell.tsx +474 -0
- package/src/ui/chrome/BottomComposer.tsx +1135 -0
- package/src/ui/chrome/DashCard.tsx +82 -0
- package/src/ui/chrome/RunFooter.tsx +65 -0
- package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
- package/src/ui/chrome/Spinner.tsx +25 -0
- package/src/ui/chrome/TopHeader.tsx +439 -0
- package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
- package/src/ui/chrome/busyStatusAnimation.ts +11 -0
- package/src/ui/input/commandNormalize.ts +66 -0
- package/src/ui/input/focus.ts +73 -0
- package/src/ui/input/imageAttachments.ts +18 -0
- package/src/ui/input/inputBuffer.ts +203 -0
- package/src/ui/input/pastedContent.ts +75 -0
- package/src/ui/input/rawArrowKeys.ts +28 -0
- package/src/ui/input/slashCommands.ts +43 -0
- package/src/ui/input/useStdinRawModeLease.ts +23 -0
- package/src/ui/layout.ts +560 -0
- package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
- package/src/ui/panels/AuthPanel.tsx +149 -0
- package/src/ui/panels/BackendPicker.tsx +28 -0
- package/src/ui/panels/ModePicker.tsx +31 -0
- package/src/ui/panels/ModelPicker.tsx +31 -0
- package/src/ui/panels/ModelPickerScreen.tsx +761 -0
- package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
- package/src/ui/panels/Panel.tsx +51 -0
- package/src/ui/panels/PermissionsPanel.tsx +78 -0
- package/src/ui/panels/PlanActionPicker.tsx +187 -0
- package/src/ui/panels/ProviderPicker.tsx +753 -0
- package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
- package/src/ui/panels/ReasoningPicker.tsx +46 -0
- package/src/ui/panels/ResumePicker.tsx +90 -0
- package/src/ui/panels/SelectionPanel.tsx +138 -0
- package/src/ui/panels/SettingsPanel.tsx +156 -0
- package/src/ui/panels/TextEntryPanel.tsx +139 -0
- package/src/ui/panels/ThemePicker.tsx +32 -0
- package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
- package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
- package/src/ui/panels/responsivePickerViewport.ts +64 -0
- package/src/ui/render/Markdown.tsx +331 -0
- package/src/ui/render/diffRenderer.ts +116 -0
- package/src/ui/render/logoVariants.ts +113 -0
- package/src/ui/render/modeDisplay.ts +52 -0
- package/src/ui/render/outputPipeline.ts +64 -0
- package/src/ui/render/runtimeDisplay.ts +128 -0
- package/src/ui/render/terminalAnswerFormat.ts +128 -0
- package/src/ui/render/textLayout.ts +392 -0
- package/src/ui/theme.tsx +274 -0
- package/src/ui/themeFlow.ts +41 -0
- package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
- package/src/ui/timeline/AgentBlock.tsx +130 -0
- package/src/ui/timeline/StaticIntroItem.tsx +54 -0
- package/src/ui/timeline/ThinkingBlock.tsx +100 -0
- package/src/ui/timeline/Timeline.tsx +1410 -0
- package/src/ui/timeline/TranscriptShell.tsx +302 -0
- package/src/ui/timeline/TurnGroup.tsx +673 -0
- package/src/ui/timeline/layoutListWindow.ts +145 -0
- package/src/ui/timeline/liveViewportWindow.ts +68 -0
- package/src/ui/timeline/progressEntries.ts +156 -0
- package/src/ui/timeline/runActivityView.ts +37 -0
- package/src/ui/timeline/staticTranscriptCache.ts +174 -0
- package/src/ui/timeline/streamCoalesce.ts +53 -0
- package/src/ui/timeline/timelineMeasure.ts +3273 -0
- package/src/ui/useThrottledValue.ts +31 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { formatReasoningLabel } from "../../config/settings.js";
|
|
5
|
+
import type { ProviderModel } from "../providerRuntime/types.js";
|
|
6
|
+
import type {
|
|
7
|
+
CodexModelCapabilities,
|
|
8
|
+
CodexModelCapability,
|
|
9
|
+
ReasoningEffortCapability,
|
|
10
|
+
} from "./codexModelCapabilities.js";
|
|
11
|
+
import { loadCachedProviderModels, type CachedProviderModels } from "./providerModelCache.js";
|
|
12
|
+
|
|
13
|
+
// The codex CLI maintains its own model catalog cache with slugs, labels and
|
|
14
|
+
// reasoning levels. Reading it seeds Ubume's OpenAI model list instantly —
|
|
15
|
+
// no subprocess — and stays current because the codex CLI refreshes the file
|
|
16
|
+
// on its own runs.
|
|
17
|
+
// Resolved per call from env (matching claudeCodeDiscovery) so HOME
|
|
18
|
+
// redirection in tests holds — Bun's homedir() ignores runtime HOME changes.
|
|
19
|
+
export function getCodexModelsCacheFile(): string {
|
|
20
|
+
const home = process.env.USERPROFILE ?? process.env.HOME ?? homedir();
|
|
21
|
+
return join(home, ".codex", "models_cache.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface CodexSeed {
|
|
25
|
+
fetchedAt: number;
|
|
26
|
+
models: readonly ProviderModel[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
30
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function asString(value: unknown): string | null {
|
|
34
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseReasoningLevels(raw: unknown): readonly ReasoningEffortCapability[] | null {
|
|
38
|
+
if (!Array.isArray(raw)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const levels = raw
|
|
42
|
+
.map((entry): ReasoningEffortCapability | null => {
|
|
43
|
+
if (!isRecord(entry)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const id = asString(entry.effort);
|
|
47
|
+
if (!id) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
return { id, label: formatReasoningLabel(id), description: asString(entry.description) };
|
|
51
|
+
})
|
|
52
|
+
.filter((entry): entry is ReasoningEffortCapability => entry !== null);
|
|
53
|
+
return levels.length > 0 ? levels : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function parseSeedModel(raw: unknown): ProviderModel | null {
|
|
57
|
+
if (!isRecord(raw)) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
// Models with visibility "hide" are internal codex routes (e.g. auto-review).
|
|
61
|
+
if (asString(raw.visibility) === "hide") {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const modelId = asString(raw.slug);
|
|
65
|
+
if (!modelId) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
id: modelId,
|
|
70
|
+
modelId,
|
|
71
|
+
label: asString(raw.display_name) ?? modelId,
|
|
72
|
+
description: asString(raw.description),
|
|
73
|
+
defaultReasoningLevel: asString(raw.default_reasoning_level),
|
|
74
|
+
supportedReasoningLevels: parseReasoningLevels(raw.supported_reasoning_levels),
|
|
75
|
+
source: "discovered",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function loadCodexSeedModels(cacheFile = getCodexModelsCacheFile()): CodexSeed | null {
|
|
80
|
+
try {
|
|
81
|
+
if (!existsSync(cacheFile)) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const parsed: unknown = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
85
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.models)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const models = parsed.models
|
|
89
|
+
.map(parseSeedModel)
|
|
90
|
+
.filter((model): model is ProviderModel => model !== null);
|
|
91
|
+
if (models.length === 0) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const fetchedAtRaw = asString(parsed.fetched_at);
|
|
95
|
+
const fetchedAt = fetchedAtRaw ? Date.parse(fetchedAtRaw) : Number.NaN;
|
|
96
|
+
return { fetchedAt: Number.isFinite(fetchedAt) ? fetchedAt : 0, models };
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Freshest locally known OpenAI models without spawning a subprocess:
|
|
103
|
+
// codex's own cache file vs Ubume's persisted last-good discovery.
|
|
104
|
+
export function loadSeededOpenAiModels(options: {
|
|
105
|
+
codexCacheFile?: string;
|
|
106
|
+
providerCacheFile?: string;
|
|
107
|
+
} = {}): CachedProviderModels | null {
|
|
108
|
+
const seed = loadCodexSeedModels(options.codexCacheFile);
|
|
109
|
+
const persisted = options.providerCacheFile === undefined
|
|
110
|
+
? loadCachedProviderModels("openai")
|
|
111
|
+
: loadCachedProviderModels("openai", options.providerCacheFile);
|
|
112
|
+
if (seed && (!persisted || seed.fetchedAt >= persisted.discoveredAt)) {
|
|
113
|
+
return { discoveredAt: seed.fetchedAt, models: seed.models };
|
|
114
|
+
}
|
|
115
|
+
return persisted;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function toCapability(model: ProviderModel, index: number): CodexModelCapability {
|
|
119
|
+
return {
|
|
120
|
+
id: model.id,
|
|
121
|
+
model: model.modelId,
|
|
122
|
+
label: model.label,
|
|
123
|
+
description: model.description,
|
|
124
|
+
available: true,
|
|
125
|
+
hidden: false,
|
|
126
|
+
isDefault: index === 0,
|
|
127
|
+
defaultReasoningLevel: model.defaultReasoningLevel,
|
|
128
|
+
supportedReasoningLevels: model.supportedReasoningLevels,
|
|
129
|
+
reasoningLevelCount: model.supportedReasoningLevels ? model.supportedReasoningLevels.length : null,
|
|
130
|
+
source: "runtime",
|
|
131
|
+
raw: model,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Capabilities for the OpenAI picker sourced purely from local caches.
|
|
136
|
+
// Returns null when nothing is cached yet (first ever launch).
|
|
137
|
+
export function loadSeededCodexCapabilities(options: {
|
|
138
|
+
codexCacheFile?: string;
|
|
139
|
+
providerCacheFile?: string;
|
|
140
|
+
} = {}): CodexModelCapabilities | null {
|
|
141
|
+
const seeded = loadSeededOpenAiModels(options);
|
|
142
|
+
if (!seeded) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
status: "ready",
|
|
147
|
+
source: "runtime",
|
|
148
|
+
models: seeded.models.map(toCapability),
|
|
149
|
+
discoveredAt: seeded.discoveredAt,
|
|
150
|
+
executable: null,
|
|
151
|
+
error: null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
2
|
+
import { MODEL_SPECS_FILE, LEGACY_MODEL_SPECS_FILE } from "../../config/settings.js";
|
|
3
|
+
|
|
4
|
+
export type ModelSpecStatus = "verified" | "loading" | "unknown";
|
|
5
|
+
|
|
6
|
+
export interface VerifiedModelSpec {
|
|
7
|
+
status: "verified";
|
|
8
|
+
contextWindow: number;
|
|
9
|
+
maxOutputTokens: number;
|
|
10
|
+
sourceUrl: string;
|
|
11
|
+
verifiedAt: number;
|
|
12
|
+
isEstimated?: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PendingModelSpec {
|
|
16
|
+
status: "loading" | "unknown";
|
|
17
|
+
contextWindow: null;
|
|
18
|
+
maxOutputTokens: null;
|
|
19
|
+
sourceUrl: string;
|
|
20
|
+
verifiedAt: null;
|
|
21
|
+
error: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ModelSpec = VerifiedModelSpec | PendingModelSpec;
|
|
25
|
+
|
|
26
|
+
export const MODEL_SPEC_DOC_URLS: Record<string, string> = {
|
|
27
|
+
"gpt-5.4": "https://developers.openai.com/api/docs/models/gpt-5.4",
|
|
28
|
+
"gpt-5.4-mini": "https://developers.openai.com/api/docs/models/gpt-5.4-mini",
|
|
29
|
+
"gpt-5.3-codex": "https://developers.openai.com/api/docs/models/gpt-5.3-codex",
|
|
30
|
+
"gpt-5.2": "https://developers.openai.com/api/docs/models/gpt-5.2",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Legacy model spec registry. Context-window display now goes through
|
|
34
|
+
// providerRuntime/contextMetadata.ts so values are source-aware and provider
|
|
35
|
+
// scoped. Keep this empty to avoid showing broad or guessed model limits.
|
|
36
|
+
export interface KnownModelSpec {
|
|
37
|
+
contextWindow: number;
|
|
38
|
+
maxOutputTokens: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const KNOWN_MODEL_SPECS: Record<string, KnownModelSpec> = {};
|
|
42
|
+
|
|
43
|
+
type ModelSpecCache = Partial<Record<string, VerifiedModelSpec>>;
|
|
44
|
+
|
|
45
|
+
interface ModelSpecServiceOptions {
|
|
46
|
+
cacheFile?: string;
|
|
47
|
+
fetchImpl?: typeof fetch;
|
|
48
|
+
now?: () => number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function createPendingModelSpec(
|
|
52
|
+
model: string,
|
|
53
|
+
status: PendingModelSpec["status"],
|
|
54
|
+
error: string | null = null,
|
|
55
|
+
): PendingModelSpec {
|
|
56
|
+
return {
|
|
57
|
+
status,
|
|
58
|
+
contextWindow: null,
|
|
59
|
+
maxOutputTokens: null,
|
|
60
|
+
sourceUrl: getModelSpecDocUrl(model),
|
|
61
|
+
verifiedAt: null,
|
|
62
|
+
error,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function getModelSpecDocUrl(model: string): string {
|
|
67
|
+
return MODEL_SPEC_DOC_URLS[model] ?? `https://developers.openai.com/api/docs/models/${encodeURIComponent(model)}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function createLoadingModelSpec(model: string): ModelSpec {
|
|
71
|
+
return createPendingModelSpec(model, "loading");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createUnknownModelSpec(model: string, error: string | null = null): ModelSpec {
|
|
75
|
+
return createPendingModelSpec(model, "unknown", error);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseTokenCount(rawValue: string): number | null {
|
|
79
|
+
const normalized = rawValue.trim().replace(/\s+/g, "").toLowerCase();
|
|
80
|
+
if (!normalized) return null;
|
|
81
|
+
|
|
82
|
+
const suffix = normalized.endsWith("m")
|
|
83
|
+
? 1_000_000
|
|
84
|
+
: normalized.endsWith("k")
|
|
85
|
+
? 1_000
|
|
86
|
+
: 1;
|
|
87
|
+
const numericText = suffix === 1 ? normalized : normalized.slice(0, -1);
|
|
88
|
+
const numeric = Number.parseFloat(numericText.replace(/,/g, ""));
|
|
89
|
+
if (!Number.isFinite(numeric)) return null;
|
|
90
|
+
|
|
91
|
+
return Math.round(numeric * suffix);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function stripHtmlToText(html: string): string {
|
|
95
|
+
return html
|
|
96
|
+
.replace(/<script\b[\s\S]*?<\/script[^>]*>/gi, " ")
|
|
97
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
98
|
+
.replace(/<[^>]+>/g, " ")
|
|
99
|
+
.replace(/ | /gi, " ")
|
|
100
|
+
.replace(/"/gi, "\"")
|
|
101
|
+
.replace(/'|'/gi, "'")
|
|
102
|
+
.replace(/&/gi, "&")
|
|
103
|
+
.replace(/\s+/g, " ")
|
|
104
|
+
.trim();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function extractModelSpecFromDocText(
|
|
108
|
+
model: string,
|
|
109
|
+
text: string,
|
|
110
|
+
verifiedAt = Date.now(),
|
|
111
|
+
): VerifiedModelSpec | null {
|
|
112
|
+
const normalized = text.replace(/\u00a0/g, " ").replace(/\s+/g, " ");
|
|
113
|
+
const contextMatch = normalized.match(/(\d[\d,.]*(?:\s*[mk])?)\s+context\s+window/i);
|
|
114
|
+
const maxOutputMatch = normalized.match(/(\d[\d,.]*(?:\s*[mk])?)\s+max\s+output\s+tokens/i);
|
|
115
|
+
|
|
116
|
+
const contextWindow = contextMatch ? parseTokenCount(contextMatch[1] ?? "") : null;
|
|
117
|
+
const maxOutputTokens = maxOutputMatch ? parseTokenCount(maxOutputMatch[1] ?? "") : null;
|
|
118
|
+
if (!contextWindow || !maxOutputTokens) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
status: "verified",
|
|
124
|
+
contextWindow,
|
|
125
|
+
maxOutputTokens,
|
|
126
|
+
sourceUrl: getModelSpecDocUrl(model),
|
|
127
|
+
verifiedAt,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function isVerifiedModelSpec(value: unknown): value is VerifiedModelSpec {
|
|
132
|
+
if (!value || typeof value !== "object") return false;
|
|
133
|
+
|
|
134
|
+
const candidate = value as Partial<VerifiedModelSpec>;
|
|
135
|
+
return candidate.status === "verified"
|
|
136
|
+
&& typeof candidate.contextWindow === "number"
|
|
137
|
+
&& Number.isFinite(candidate.contextWindow)
|
|
138
|
+
&& typeof candidate.maxOutputTokens === "number"
|
|
139
|
+
&& Number.isFinite(candidate.maxOutputTokens)
|
|
140
|
+
&& typeof candidate.sourceUrl === "string"
|
|
141
|
+
&& typeof candidate.verifiedAt === "number"
|
|
142
|
+
&& Number.isFinite(candidate.verifiedAt);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function loadModelSpecCache(cacheFile = MODEL_SPECS_FILE): ModelSpecCache {
|
|
146
|
+
if (cacheFile === MODEL_SPECS_FILE && !existsSync(MODEL_SPECS_FILE) && existsSync(LEGACY_MODEL_SPECS_FILE)) {
|
|
147
|
+
cacheFile = LEGACY_MODEL_SPECS_FILE;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const raw = JSON.parse(readFileSync(cacheFile, "utf-8")) as Record<string, unknown>;
|
|
151
|
+
const cache: ModelSpecCache = {};
|
|
152
|
+
|
|
153
|
+
for (const [model, value] of Object.entries(raw)) {
|
|
154
|
+
if (isVerifiedModelSpec(value)) {
|
|
155
|
+
cache[model] = value;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return cache;
|
|
160
|
+
} catch {
|
|
161
|
+
return {};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function saveModelSpecCache(cache: ModelSpecCache, cacheFile = MODEL_SPECS_FILE): void {
|
|
166
|
+
try {
|
|
167
|
+
const tmpFile = `${cacheFile}.tmp`;
|
|
168
|
+
writeFileSync(tmpFile, JSON.stringify(cache, null, 2), "utf-8");
|
|
169
|
+
renameSync(tmpFile, cacheFile);
|
|
170
|
+
} catch {
|
|
171
|
+
// Best-effort cache only.
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function fetchModelSpecFromDocs(
|
|
176
|
+
model: string,
|
|
177
|
+
fetchImpl: typeof fetch = fetch,
|
|
178
|
+
verifiedAt = Date.now(),
|
|
179
|
+
): Promise<VerifiedModelSpec> {
|
|
180
|
+
const response = await fetchImpl(getModelSpecDocUrl(model), {
|
|
181
|
+
headers: {
|
|
182
|
+
Accept: "text/html,application/xhtml+xml",
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
if (!response.ok) {
|
|
187
|
+
throw new Error(`Spec request failed with ${response.status}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const html = await response.text();
|
|
191
|
+
const parsed = extractModelSpecFromDocText(model, stripHtmlToText(html), verifiedAt);
|
|
192
|
+
if (!parsed) {
|
|
193
|
+
throw new Error(`Unable to parse model spec for ${model}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return parsed;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface ResolveModelSpecOptions {
|
|
200
|
+
// Persisted verified cache (loaded synchronously at startup).
|
|
201
|
+
cache?: Partial<Record<string, VerifiedModelSpec>>;
|
|
202
|
+
// Runtime discovery contextWindow if/when Codex starts providing it.
|
|
203
|
+
runtimeContextWindow?: number | null;
|
|
204
|
+
runtimeMaxOutputTokens?: number | null;
|
|
205
|
+
// Set true when a background refresh is in progress and no other source
|
|
206
|
+
// has produced verified numbers — lets callers render a "loading" state
|
|
207
|
+
// instead of "unknown".
|
|
208
|
+
refreshInFlight?: boolean;
|
|
209
|
+
now?: () => number;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Single authoritative resolver. Precedence:
|
|
213
|
+
// 1. Runtime discovery metadata (if contextWindow is provided)
|
|
214
|
+
// 2. Persistent spec cache (~/.ubume-model-specs.json)
|
|
215
|
+
// 3. Trusted static registry (KNOWN_MODEL_SPECS)
|
|
216
|
+
// 4. Loading (if a background refresh is in-flight)
|
|
217
|
+
// 5. Unknown
|
|
218
|
+
export function resolveModelSpec(model: string, options: ResolveModelSpecOptions = {}): ModelSpec {
|
|
219
|
+
const now = options.now?.() ?? Date.now();
|
|
220
|
+
|
|
221
|
+
const runtimeCtx = options.runtimeContextWindow;
|
|
222
|
+
const runtimeMax = options.runtimeMaxOutputTokens;
|
|
223
|
+
if (
|
|
224
|
+
typeof runtimeCtx === "number" && Number.isFinite(runtimeCtx) && runtimeCtx > 0
|
|
225
|
+
&& typeof runtimeMax === "number" && Number.isFinite(runtimeMax) && runtimeMax > 0
|
|
226
|
+
) {
|
|
227
|
+
return {
|
|
228
|
+
status: "verified",
|
|
229
|
+
contextWindow: runtimeCtx,
|
|
230
|
+
maxOutputTokens: runtimeMax,
|
|
231
|
+
sourceUrl: getModelSpecDocUrl(model),
|
|
232
|
+
verifiedAt: now,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const cached = options.cache?.[model];
|
|
237
|
+
if (cached && cached.status === "verified") {
|
|
238
|
+
return cached;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const known = KNOWN_MODEL_SPECS[model];
|
|
242
|
+
if (known) {
|
|
243
|
+
return {
|
|
244
|
+
status: "verified",
|
|
245
|
+
contextWindow: known.contextWindow,
|
|
246
|
+
maxOutputTokens: known.maxOutputTokens,
|
|
247
|
+
sourceUrl: getModelSpecDocUrl(model),
|
|
248
|
+
verifiedAt: now,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (options.refreshInFlight) {
|
|
253
|
+
return createLoadingModelSpec(model);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return createUnknownModelSpec(model);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function areModelSpecsEqual(left: ModelSpec | undefined, right: ModelSpec): boolean {
|
|
260
|
+
if (!left) return false;
|
|
261
|
+
if (left.status !== right.status) return false;
|
|
262
|
+
|
|
263
|
+
return left.contextWindow === right.contextWindow
|
|
264
|
+
&& left.maxOutputTokens === right.maxOutputTokens
|
|
265
|
+
&& left.sourceUrl === right.sourceUrl
|
|
266
|
+
&& left.verifiedAt === right.verifiedAt
|
|
267
|
+
&& ("error" in left ? left.error : null) === ("error" in right ? right.error : null);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function createModelSpecService(options: ModelSpecServiceOptions = {}) {
|
|
271
|
+
const cacheFile = options.cacheFile ?? MODEL_SPECS_FILE;
|
|
272
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
273
|
+
const now = options.now ?? Date.now;
|
|
274
|
+
// Persisted specs are kept for saving, not for live UI trust.
|
|
275
|
+
const cache = loadModelSpecCache(cacheFile);
|
|
276
|
+
const inflight = new Map<string, Promise<ModelSpec>>();
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
async refreshSpec(model: string): Promise<ModelSpec> {
|
|
280
|
+
const current = inflight.get(model);
|
|
281
|
+
if (current) return current;
|
|
282
|
+
|
|
283
|
+
const refreshPromise = (async () => {
|
|
284
|
+
try {
|
|
285
|
+
const verified = await fetchModelSpecFromDocs(model, fetchImpl, now());
|
|
286
|
+
cache[model] = verified;
|
|
287
|
+
saveModelSpecCache(cache, cacheFile);
|
|
288
|
+
return verified;
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return createUnknownModelSpec(
|
|
291
|
+
model,
|
|
292
|
+
error instanceof Error ? error.message : "Unknown model spec failure",
|
|
293
|
+
);
|
|
294
|
+
} finally {
|
|
295
|
+
inflight.delete(model);
|
|
296
|
+
}
|
|
297
|
+
})();
|
|
298
|
+
|
|
299
|
+
inflight.set(model, refreshPromise);
|
|
300
|
+
return refreshPromise;
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ProviderId } from "../providerLauncher/types.js";
|
|
5
|
+
import type { ProviderModel } from "../providerRuntime/types.js";
|
|
6
|
+
|
|
7
|
+
// Persistent last-good model discovery results, one entry per provider.
|
|
8
|
+
// Lets pickers open instantly with the previous session's discovered models
|
|
9
|
+
// while a background refresh runs. Corrupt or missing cache degrades to null.
|
|
10
|
+
// Resolved per call from env (matching claudeCodeDiscovery) so HOME
|
|
11
|
+
// redirection in tests holds — Bun's homedir() ignores runtime HOME changes.
|
|
12
|
+
export function getProviderModelCacheFile(): string {
|
|
13
|
+
const home = process.env.USERPROFILE ?? process.env.HOME ?? homedir();
|
|
14
|
+
const ubumePath = join(home, ".ubume-model-cache.json");
|
|
15
|
+
if (existsSync(ubumePath)) return ubumePath;
|
|
16
|
+
const legacyPath = join(home, ".codexa-model-cache.json");
|
|
17
|
+
if (existsSync(legacyPath)) return legacyPath;
|
|
18
|
+
return ubumePath;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const CACHE_VERSION = 1;
|
|
22
|
+
|
|
23
|
+
export interface CachedProviderModels {
|
|
24
|
+
discoveredAt: number;
|
|
25
|
+
models: readonly ProviderModel[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ProviderModelCacheFile {
|
|
29
|
+
version: number;
|
|
30
|
+
providers: Partial<Record<ProviderId, CachedProviderModels>>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function readCacheFile(cacheFile: string): ProviderModelCacheFile | null {
|
|
34
|
+
try {
|
|
35
|
+
if (!existsSync(cacheFile)) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const parsed: unknown = JSON.parse(readFileSync(cacheFile, "utf8"));
|
|
39
|
+
if (
|
|
40
|
+
typeof parsed !== "object" || parsed === null || Array.isArray(parsed)
|
|
41
|
+
|| (parsed as ProviderModelCacheFile).version !== CACHE_VERSION
|
|
42
|
+
|| typeof (parsed as ProviderModelCacheFile).providers !== "object"
|
|
43
|
+
|| (parsed as ProviderModelCacheFile).providers === null
|
|
44
|
+
) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
return parsed as ProviderModelCacheFile;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isValidEntry(entry: unknown): entry is CachedProviderModels {
|
|
54
|
+
if (typeof entry !== "object" || entry === null) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
const candidate = entry as CachedProviderModels;
|
|
58
|
+
return typeof candidate.discoveredAt === "number"
|
|
59
|
+
&& Array.isArray(candidate.models)
|
|
60
|
+
&& candidate.models.every((model) =>
|
|
61
|
+
typeof model === "object" && model !== null
|
|
62
|
+
&& typeof model.id === "string"
|
|
63
|
+
&& typeof model.modelId === "string"
|
|
64
|
+
&& typeof model.label === "string");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function loadCachedProviderModels(
|
|
68
|
+
providerId: ProviderId,
|
|
69
|
+
cacheFile = getProviderModelCacheFile(),
|
|
70
|
+
): CachedProviderModels | null {
|
|
71
|
+
const cache = readCacheFile(cacheFile);
|
|
72
|
+
const entry = cache?.providers?.[providerId];
|
|
73
|
+
if (!entry || !isValidEntry(entry) || entry.models.length === 0) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return entry;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function saveCachedProviderModels(
|
|
80
|
+
providerId: ProviderId,
|
|
81
|
+
entry: CachedProviderModels,
|
|
82
|
+
cacheFile = getProviderModelCacheFile(),
|
|
83
|
+
): void {
|
|
84
|
+
if (entry.models.length === 0) {
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const cache = readCacheFile(cacheFile) ?? { version: CACHE_VERSION, providers: {} };
|
|
89
|
+
cache.providers[providerId] = entry;
|
|
90
|
+
writeFileSync(cacheFile, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
|
91
|
+
} catch {
|
|
92
|
+
// Persistence is best-effort; discovery still works without it.
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { appendFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
export interface PerfSession {
|
|
6
|
+
runId: string;
|
|
7
|
+
marks: Record<string, number>;
|
|
8
|
+
counters: Record<string, number>;
|
|
9
|
+
accumulations: Record<string, number>;
|
|
10
|
+
metadata: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let _enabled: boolean | null = null;
|
|
14
|
+
let _session: PerfSession | null = null;
|
|
15
|
+
|
|
16
|
+
export function isEnabled(): boolean {
|
|
17
|
+
if (_enabled === null) {
|
|
18
|
+
_enabled = process.env["UBUME_PERF"] === "1";
|
|
19
|
+
}
|
|
20
|
+
return _enabled;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function startSession(runId: string): void {
|
|
24
|
+
if (!isEnabled()) return;
|
|
25
|
+
_session = { runId, marks: {}, counters: {}, accumulations: {}, metadata: {} };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function mark(label: string): void {
|
|
29
|
+
if (_session) _session.marks[label] = performance.now();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function inc(counter: string, by = 1): void {
|
|
33
|
+
if (_session) _session.counters[counter] = (_session.counters[counter] ?? 0) + by;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function accumulate(key: string, ms: number): void {
|
|
37
|
+
if (_session) _session.accumulations[key] = (_session.accumulations[key] ?? 0) + ms;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function setMeta(key: string, value: unknown): void {
|
|
41
|
+
if (_session) _session.metadata[key] = value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function getSession(): PerfSession | null {
|
|
45
|
+
return _session;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── Formatting ───────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function dur(session: PerfSession, from: string, to: string): string {
|
|
51
|
+
const a = session.marks[from];
|
|
52
|
+
const b = session.marks[to];
|
|
53
|
+
if (a === undefined || b === undefined) return " ?";
|
|
54
|
+
return String(Math.round(b - a)).padStart(4);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const STAGE_ROWS: Array<[from: string, to: string, label: string, note?: string]> = [
|
|
58
|
+
["submit", "dispatch_start", "submit → dispatch_start", "pre-dispatch overhead"],
|
|
59
|
+
["dispatch_start", "provider_run_start", "dispatch_start → provider_run_start", "orchestration setup"],
|
|
60
|
+
["exec_resolve_start", "exec_resolve_end", "exec_resolve", "cached after first run"],
|
|
61
|
+
["caps_probe_start", "caps_probe_end", "caps_probe", "cached after first run"],
|
|
62
|
+
["provider_run_start", "spawn_done", "provider_run_start → spawn_done", "spawn overhead"],
|
|
63
|
+
["spawn_done", "first_chunk", "spawn_done → first_chunk (TTFT)", "← backend latency"],
|
|
64
|
+
["first_chunk", "first_render", "first_chunk → first_render", "render dispatch"],
|
|
65
|
+
["first_chunk", "last_chunk", "streaming duration", "total stream time"],
|
|
66
|
+
["last_chunk", "response_cb_start", "last_chunk → response_cb", ""],
|
|
67
|
+
["snapshot_start", "snapshot_end", "workspace_snapshot (blocking)", "← post-run overhead"],
|
|
68
|
+
["finalize_start", "finalize_done", "finalize_start → finalize_done", ""],
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
export function buildSummary(session: PerfSession): string {
|
|
72
|
+
const lines: string[] = [
|
|
73
|
+
"┌── UBUME PERF REPORT ─────────────────────────────────────────",
|
|
74
|
+
"│ Stage ms",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
const durations: Array<{ label: string; ms: number }> = [];
|
|
78
|
+
|
|
79
|
+
for (const [from, to, label, note] of STAGE_ROWS) {
|
|
80
|
+
const a = session.marks[from];
|
|
81
|
+
const b = session.marks[to];
|
|
82
|
+
const ms = a !== undefined && b !== undefined ? Math.round(b - a) : null;
|
|
83
|
+
const msStr = ms !== null ? String(ms).padStart(4) : " ?";
|
|
84
|
+
const noteStr = note ? ` ${note}` : "";
|
|
85
|
+
lines.push(`│ ${label.padEnd(44)} ${msStr}ms${noteStr}`);
|
|
86
|
+
if (ms !== null) durations.push({ label, ms });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const c = session.counters;
|
|
90
|
+
const a = session.accumulations;
|
|
91
|
+
lines.push("│");
|
|
92
|
+
lines.push(
|
|
93
|
+
`│ Counters chunks=${c["chunks"] ?? 0} flushes=${c["flushes"] ?? 0} progress_updates=${c["progress_updates"] ?? 0}`,
|
|
94
|
+
);
|
|
95
|
+
lines.push(`│ Sanitise accumulated ${Math.round(a["sanitize_ms"] ?? 0)}ms across ${c["chunks"] ?? 0} chunks`);
|
|
96
|
+
|
|
97
|
+
const metaEntries = Object.entries(session.metadata);
|
|
98
|
+
if (metaEntries.length > 0) {
|
|
99
|
+
lines.push("│");
|
|
100
|
+
for (const [k, v] of metaEntries) {
|
|
101
|
+
lines.push(`│ ${k}: ${String(v)}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Bottleneck: largest duration stage
|
|
106
|
+
if (durations.length > 0) {
|
|
107
|
+
const top = durations.reduce((a, b) => (b.ms > a.ms ? b : a));
|
|
108
|
+
lines.push("│");
|
|
109
|
+
lines.push(`│ ► BOTTLENECK: ${top.label} = ${top.ms}ms`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
lines.push("└───────────────────────────────────────────────────────────────");
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Sessions are appended as JSONL to ~/.ubume-perf.jsonl for offline analysis.
|
|
117
|
+
export function persistSession(session: PerfSession): void {
|
|
118
|
+
try {
|
|
119
|
+
const logPath = join(homedir(), ".ubume-perf.jsonl");
|
|
120
|
+
const line = JSON.stringify({ ...session, ts: Date.now() }) + "\n";
|
|
121
|
+
appendFileSync(logPath, line, "utf8");
|
|
122
|
+
} catch {
|
|
123
|
+
// Profiling must never crash the app.
|
|
124
|
+
}
|
|
125
|
+
}
|