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.
Files changed (182) hide show
  1. package/README.md +88 -0
  2. package/bin/codexa-local-harness-bridge.js +3 -0
  3. package/bin/codexa.js +28 -0
  4. package/bin/ubume-local-harness-bridge.js +325 -0
  5. package/bin/ubume.js +398 -0
  6. package/package.json +66 -0
  7. package/src/app.tsx +5759 -0
  8. package/src/commands/handler.ts +889 -0
  9. package/src/config/appVersion.ts +69 -0
  10. package/src/config/buildInfo.ts +3 -0
  11. package/src/config/launchArgs.ts +196 -0
  12. package/src/config/layeredConfig.ts +853 -0
  13. package/src/config/legacyEnv.ts +16 -0
  14. package/src/config/persistence.ts +377 -0
  15. package/src/config/runtimeConfig.ts +558 -0
  16. package/src/config/settings.ts +405 -0
  17. package/src/config/toml-serialize.ts +109 -0
  18. package/src/config/trustStore.ts +84 -0
  19. package/src/config/updateCheckCache.ts +85 -0
  20. package/src/core/README.md +55 -0
  21. package/src/core/agent/loop.ts +464 -0
  22. package/src/core/agent/protocol.ts +345 -0
  23. package/src/core/agent/tools.ts +423 -0
  24. package/src/core/auth/codexAuth.ts +359 -0
  25. package/src/core/codex/codexExecArgs.ts +166 -0
  26. package/src/core/codex/codexLaunch.ts +163 -0
  27. package/src/core/codex/codexPrompt.ts +429 -0
  28. package/src/core/debug/inputDebug.ts +51 -0
  29. package/src/core/debug/localStreamDebug.ts +50 -0
  30. package/src/core/debug/modelStateDebug.ts +35 -0
  31. package/src/core/executables/antigravityExecutable.ts +48 -0
  32. package/src/core/executables/claudeExecutable.ts +63 -0
  33. package/src/core/executables/codexExecutable.ts +160 -0
  34. package/src/core/executables/executableResolver.ts +164 -0
  35. package/src/core/executables/geminiExecutable.ts +78 -0
  36. package/src/core/models/codexCapabilities.ts +97 -0
  37. package/src/core/models/codexModelCapabilities.ts +624 -0
  38. package/src/core/models/codexModelsCacheSeed.ts +153 -0
  39. package/src/core/models/modelSpecs.ts +303 -0
  40. package/src/core/models/providerModelCache.ts +94 -0
  41. package/src/core/perf/profiler.ts +125 -0
  42. package/src/core/perf/renderDebug.ts +398 -0
  43. package/src/core/process/CommandRunner.ts +280 -0
  44. package/src/core/process/processValidation.ts +111 -0
  45. package/src/core/providerLauncher/launcher.ts +220 -0
  46. package/src/core/providerLauncher/registry.ts +354 -0
  47. package/src/core/providerLauncher/types.ts +95 -0
  48. package/src/core/providerLauncher/workspaceConfig.ts +487 -0
  49. package/src/core/providerRuntime/anthropic.ts +580 -0
  50. package/src/core/providerRuntime/antigravity.ts +500 -0
  51. package/src/core/providerRuntime/capabilityProfile.ts +383 -0
  52. package/src/core/providerRuntime/claudeCodeDiscovery.ts +724 -0
  53. package/src/core/providerRuntime/claudeCodeDiscoveryDebug.ts +55 -0
  54. package/src/core/providerRuntime/codexaCupy.ts +97 -0
  55. package/src/core/providerRuntime/codexaNative.ts +425 -0
  56. package/src/core/providerRuntime/contextMetadata.ts +397 -0
  57. package/src/core/providerRuntime/gemini.ts +789 -0
  58. package/src/core/providerRuntime/lmstudio.ts +118 -0
  59. package/src/core/providerRuntime/local.ts +770 -0
  60. package/src/core/providerRuntime/localHarness/runtime.ts +1090 -0
  61. package/src/core/providerRuntime/localOutputBudget.ts +17 -0
  62. package/src/core/providerRuntime/mistralVibe.ts +667 -0
  63. package/src/core/providerRuntime/models.ts +175 -0
  64. package/src/core/providerRuntime/reasoning.ts +20 -0
  65. package/src/core/providerRuntime/registry.ts +286 -0
  66. package/src/core/providerRuntime/types.ts +142 -0
  67. package/src/core/providerRuntime/unsloth.ts +216 -0
  68. package/src/core/providers/codexJsonStream.ts +305 -0
  69. package/src/core/providers/codexSubprocess.ts +378 -0
  70. package/src/core/providers/codexTranscript.ts +695 -0
  71. package/src/core/providers/openaiNative.ts +13 -0
  72. package/src/core/providers/registry.ts +21 -0
  73. package/src/core/providers/types.ts +91 -0
  74. package/src/core/shared/attachments.ts +101 -0
  75. package/src/core/shared/cleanupFastFail.ts +67 -0
  76. package/src/core/shared/clipboard.ts +24 -0
  77. package/src/core/shared/clipboardImage.ts +111 -0
  78. package/src/core/shared/githubDiagnostics.ts +222 -0
  79. package/src/core/shared/hollowResponseFormat.ts +39 -0
  80. package/src/core/terminal/clearFrameBoundary.ts +852 -0
  81. package/src/core/terminal/frameLock.ts +110 -0
  82. package/src/core/terminal/inkRenderReset.ts +123 -0
  83. package/src/core/terminal/startupClear.ts +20 -0
  84. package/src/core/terminal/terminalCapabilities.ts +100 -0
  85. package/src/core/terminal/terminalControl.ts +169 -0
  86. package/src/core/terminal/terminalSanitize.ts +147 -0
  87. package/src/core/terminal/terminalTitle.ts +400 -0
  88. package/src/core/version/channel.ts +27 -0
  89. package/src/core/version/packageManager.ts +119 -0
  90. package/src/core/version/updateCheck.ts +203 -0
  91. package/src/core/workspace/appData.ts +107 -0
  92. package/src/core/workspace/conversationStore.ts +335 -0
  93. package/src/core/workspace/launchContext.ts +259 -0
  94. package/src/core/workspace/planStorage.ts +135 -0
  95. package/src/core/workspace/projectInstructions.ts +54 -0
  96. package/src/core/workspace/scratchDir.ts +64 -0
  97. package/src/core/workspace/workspaceActivity.ts +384 -0
  98. package/src/core/workspace/workspaceGuard.ts +377 -0
  99. package/src/core/workspace/workspaceRoot.ts +47 -0
  100. package/src/exec.ts +73 -0
  101. package/src/headless/execArgs.ts +296 -0
  102. package/src/headless/execRunner.ts +304 -0
  103. package/src/index.tsx +270 -0
  104. package/src/legacyEnvBootstrap.ts +5 -0
  105. package/src/session/appSession.ts +771 -0
  106. package/src/session/chatLifecycle.ts +994 -0
  107. package/src/session/conversation.ts +107 -0
  108. package/src/session/liveRenderScheduler.ts +214 -0
  109. package/src/session/persistedResponse.ts +93 -0
  110. package/src/session/planFlow.ts +159 -0
  111. package/src/session/planTranscript.ts +19 -0
  112. package/src/session/promptRunSchedule.ts +26 -0
  113. package/src/session/types.ts +234 -0
  114. package/src/test/runtimeTestUtils.ts +14 -0
  115. package/src/types/react-dom.d.ts +3 -0
  116. package/src/ui/chrome/ActivityBars.tsx +68 -0
  117. package/src/ui/chrome/ActivityIndicator.tsx +58 -0
  118. package/src/ui/chrome/AnimatedStatusText.tsx +69 -0
  119. package/src/ui/chrome/AppShell.tsx +474 -0
  120. package/src/ui/chrome/BottomComposer.tsx +1135 -0
  121. package/src/ui/chrome/DashCard.tsx +82 -0
  122. package/src/ui/chrome/RunFooter.tsx +65 -0
  123. package/src/ui/chrome/RuntimeStatusBar.tsx +108 -0
  124. package/src/ui/chrome/Spinner.tsx +25 -0
  125. package/src/ui/chrome/TopHeader.tsx +439 -0
  126. package/src/ui/chrome/UpdateAvailableCard.tsx +42 -0
  127. package/src/ui/chrome/busyStatusAnimation.ts +11 -0
  128. package/src/ui/input/commandNormalize.ts +66 -0
  129. package/src/ui/input/focus.ts +73 -0
  130. package/src/ui/input/imageAttachments.ts +18 -0
  131. package/src/ui/input/inputBuffer.ts +203 -0
  132. package/src/ui/input/pastedContent.ts +75 -0
  133. package/src/ui/input/rawArrowKeys.ts +28 -0
  134. package/src/ui/input/slashCommands.ts +43 -0
  135. package/src/ui/input/useStdinRawModeLease.ts +23 -0
  136. package/src/ui/layout.ts +560 -0
  137. package/src/ui/panels/AttachmentImportPanel.tsx +131 -0
  138. package/src/ui/panels/AuthPanel.tsx +149 -0
  139. package/src/ui/panels/BackendPicker.tsx +28 -0
  140. package/src/ui/panels/ModePicker.tsx +31 -0
  141. package/src/ui/panels/ModelPicker.tsx +31 -0
  142. package/src/ui/panels/ModelPickerScreen.tsx +761 -0
  143. package/src/ui/panels/ModelReasoningPicker.tsx +458 -0
  144. package/src/ui/panels/Panel.tsx +51 -0
  145. package/src/ui/panels/PermissionsPanel.tsx +78 -0
  146. package/src/ui/panels/PlanActionPicker.tsx +187 -0
  147. package/src/ui/panels/ProviderPicker.tsx +753 -0
  148. package/src/ui/panels/ProviderSetupPrompt.tsx +52 -0
  149. package/src/ui/panels/ReasoningPicker.tsx +46 -0
  150. package/src/ui/panels/ResumePicker.tsx +90 -0
  151. package/src/ui/panels/SelectionPanel.tsx +138 -0
  152. package/src/ui/panels/SettingsPanel.tsx +156 -0
  153. package/src/ui/panels/TextEntryPanel.tsx +139 -0
  154. package/src/ui/panels/ThemePicker.tsx +32 -0
  155. package/src/ui/panels/ToolApprovalPanel.tsx +46 -0
  156. package/src/ui/panels/UpdatePromptPanel.tsx +236 -0
  157. package/src/ui/panels/responsivePickerViewport.ts +64 -0
  158. package/src/ui/render/Markdown.tsx +331 -0
  159. package/src/ui/render/diffRenderer.ts +116 -0
  160. package/src/ui/render/logoVariants.ts +113 -0
  161. package/src/ui/render/modeDisplay.ts +52 -0
  162. package/src/ui/render/outputPipeline.ts +64 -0
  163. package/src/ui/render/runtimeDisplay.ts +128 -0
  164. package/src/ui/render/terminalAnswerFormat.ts +128 -0
  165. package/src/ui/render/textLayout.ts +392 -0
  166. package/src/ui/theme.tsx +274 -0
  167. package/src/ui/themeFlow.ts +41 -0
  168. package/src/ui/timeline/ActionRequiredBlock.tsx +38 -0
  169. package/src/ui/timeline/AgentBlock.tsx +130 -0
  170. package/src/ui/timeline/StaticIntroItem.tsx +54 -0
  171. package/src/ui/timeline/ThinkingBlock.tsx +100 -0
  172. package/src/ui/timeline/Timeline.tsx +1410 -0
  173. package/src/ui/timeline/TranscriptShell.tsx +302 -0
  174. package/src/ui/timeline/TurnGroup.tsx +673 -0
  175. package/src/ui/timeline/layoutListWindow.ts +145 -0
  176. package/src/ui/timeline/liveViewportWindow.ts +68 -0
  177. package/src/ui/timeline/progressEntries.ts +156 -0
  178. package/src/ui/timeline/runActivityView.ts +37 -0
  179. package/src/ui/timeline/staticTranscriptCache.ts +174 -0
  180. package/src/ui/timeline/streamCoalesce.ts +53 -0
  181. package/src/ui/timeline/timelineMeasure.ts +3273 -0
  182. package/src/ui/useThrottledValue.ts +31 -0
@@ -0,0 +1,203 @@
1
+ import { APP_VERSION } from "../../config/settings.js";
2
+ import { isLocalDevChannel } from "./channel.js";
3
+
4
+ export const UBUME_NPM_PACKAGE = "ubume";
5
+ export const UBUME_NPM_REGISTRY_URL = "https://registry.npmjs.org/ubume";
6
+ export const UBUME_UPDATE_COMMAND = `npm install -g ${UBUME_NPM_PACKAGE}@latest`;
7
+
8
+ export const CODEXA_NPM_PACKAGE = UBUME_NPM_PACKAGE;
9
+ export const CODEXA_NPM_REGISTRY_URL = UBUME_NPM_REGISTRY_URL;
10
+ export const CODEXA_UPDATE_COMMAND = UBUME_UPDATE_COMMAND;
11
+
12
+ export type UpdateStatus = "up-to-date" | "update-available" | "unknown" | "error";
13
+
14
+ export interface NpmRegistryMetadata {
15
+ "dist-tags"?: { latest?: unknown };
16
+ }
17
+
18
+ export interface UpdateCheckResult {
19
+ status: UpdateStatus;
20
+ currentVersion: string;
21
+ latestVersion: string | null;
22
+ errorMessage?: string;
23
+ checkedAt: number;
24
+ source?: "npm" | "cache";
25
+ }
26
+
27
+ const FETCH_TIMEOUT_MS = 5000;
28
+
29
+ /** Strip a leading "v" so "v1.0.2" and "1.0.2" are treated as equal. */
30
+ export function normalizeVersion(v: string): string {
31
+ return v.startsWith("v") ? v.slice(1) : v;
32
+ }
33
+
34
+ export function formatVersionLabel(version: string): string {
35
+ const normalized = normalizeVersion(version.trim());
36
+ return normalized ? `v${normalized}` : version;
37
+ }
38
+
39
+ const SEMVER_RE = /^\d+\.\d+\.\d+(-[\w.]+)?$/;
40
+
41
+ /** Returns true for valid semver strings with or without a leading "v". */
42
+ export function isValidSemver(v: string): boolean {
43
+ return SEMVER_RE.test(normalizeVersion(v));
44
+ }
45
+
46
+ export function shouldRunStartupUpdateCheck(
47
+ env: NodeJS.ProcessEnv = process.env,
48
+ enabled = true,
49
+ ): boolean {
50
+ return enabled && !isLocalDevChannel(env);
51
+ }
52
+
53
+ // Compares two semver strings numerically. Returns negative if a < b, 0 if equal, positive if a > b.
54
+ // Pre-release versions (e.g. 1.0.2-beta.1) sort below their release counterpart (1.0.2 > 1.0.2-beta.1).
55
+ // Leading "v" is stripped before comparison.
56
+ export function compareSemver(a: string, b: string): number {
57
+ const parseParts = (v: string): { numeric: number[]; prerelease: string | null } => {
58
+ const norm = normalizeVersion(v);
59
+ const dashIdx = norm.indexOf("-");
60
+ const base = dashIdx === -1 ? norm : norm.slice(0, dashIdx);
61
+ const prerelease = dashIdx === -1 ? null : norm.slice(dashIdx + 1);
62
+ const numeric = base.split(".").map((p) => parseInt(p, 10) || 0);
63
+ return { numeric, prerelease };
64
+ };
65
+
66
+ const pa = parseParts(a);
67
+ const pb = parseParts(b);
68
+ const len = Math.max(pa.numeric.length, pb.numeric.length);
69
+
70
+ for (let i = 0; i < len; i++) {
71
+ const diff = (pa.numeric[i] ?? 0) - (pb.numeric[i] ?? 0);
72
+ if (diff !== 0) return diff;
73
+ }
74
+
75
+ // Same numeric version: no pre-release > has pre-release
76
+ if (pa.prerelease === null && pb.prerelease !== null) return 1;
77
+ if (pa.prerelease !== null && pb.prerelease === null) return -1;
78
+ if (pa.prerelease !== null && pb.prerelease !== null) {
79
+ return pa.prerelease < pb.prerelease ? -1 : pa.prerelease > pb.prerelease ? 1 : 0;
80
+ }
81
+ return 0;
82
+ }
83
+
84
+ export function isNewerVersion(candidate: string, current: string): boolean {
85
+ return compareSemver(candidate, current) > 0;
86
+ }
87
+
88
+ export interface UpdateCheckOverrides {
89
+ currentVersion?: string;
90
+ fetchNpmMetadataFn?: (url: string) => Promise<NpmRegistryMetadata>;
91
+ }
92
+
93
+ async function defaultFetchNpmMetadata(url: string): Promise<NpmRegistryMetadata> {
94
+ const controller = new AbortController();
95
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
96
+ try {
97
+ const res = await fetch(url, {
98
+ signal: controller.signal,
99
+ headers: { "User-Agent": `${UBUME_NPM_PACKAGE}-update-checker/1.0` },
100
+ });
101
+ if (!res.ok) throw new Error(`npm registry returned HTTP ${res.status}`);
102
+ return await res.json() as NpmRegistryMetadata;
103
+ } finally {
104
+ clearTimeout(timer);
105
+ }
106
+ }
107
+
108
+ export async function checkForUpdates(
109
+ opts?: { enabled?: boolean },
110
+ overrides?: UpdateCheckOverrides,
111
+ ): Promise<UpdateCheckResult> {
112
+ const currentVersion = normalizeVersion(overrides?.currentVersion ?? APP_VERSION);
113
+
114
+ if (opts?.enabled === false) {
115
+ return { status: "unknown", currentVersion, latestVersion: null, checkedAt: Date.now(), source: "npm" };
116
+ }
117
+
118
+ try {
119
+ const fetchFn = overrides?.fetchNpmMetadataFn ?? defaultFetchNpmMetadata;
120
+ const metadata = await fetchFn(UBUME_NPM_REGISTRY_URL);
121
+ const rawLatest = metadata["dist-tags"]?.latest;
122
+
123
+ if (typeof rawLatest !== "string" || !rawLatest.trim()) {
124
+ return {
125
+ status: "error",
126
+ currentVersion,
127
+ latestVersion: null,
128
+ errorMessage: "npm registry response did not include dist-tags.latest",
129
+ checkedAt: Date.now(),
130
+ source: "npm",
131
+ };
132
+ }
133
+
134
+ const latestVersion = normalizeVersion(rawLatest.trim());
135
+
136
+ if (!isValidSemver(latestVersion) || !isValidSemver(currentVersion)) {
137
+ return {
138
+ status: "unknown",
139
+ currentVersion,
140
+ latestVersion: rawLatest,
141
+ errorMessage: `Invalid semver — current: "${currentVersion}", latest: "${rawLatest}"`,
142
+ checkedAt: Date.now(),
143
+ source: "npm",
144
+ };
145
+ }
146
+
147
+ const status = isNewerVersion(latestVersion, currentVersion) ? "update-available" : "up-to-date";
148
+ return { status, currentVersion, latestVersion, checkedAt: Date.now(), source: "npm" };
149
+ } catch (err) {
150
+ return {
151
+ status: "error",
152
+ currentVersion,
153
+ latestVersion: null,
154
+ errorMessage: err instanceof Error ? err.message : String(err),
155
+ checkedAt: Date.now(),
156
+ source: "npm",
157
+ };
158
+ }
159
+ }
160
+
161
+ export function formatUpdateInstructions(
162
+ result: UpdateCheckResult | null,
163
+ updateCommand: string = UBUME_UPDATE_COMMAND,
164
+ ): string {
165
+ const current = result?.currentVersion ?? APP_VERSION;
166
+ const latest = result?.latestVersion ?? "unknown";
167
+
168
+ if (result?.status === "error") {
169
+ return [
170
+ `Current installed version: ${current}`,
171
+ `npm latest version: ${latest}`,
172
+ `Error checking npm update status: ${result.errorMessage ?? "unknown error"}`,
173
+ ].join("\n");
174
+ }
175
+
176
+ if (result?.status === "up-to-date") {
177
+ return [
178
+ "Ubume is up to date.",
179
+ `Current installed version: ${current}`,
180
+ `npm latest version: ${latest}`,
181
+ ].join("\n");
182
+ }
183
+
184
+ const statusLine = result?.status === "update-available" && result.latestVersion
185
+ ? `Update available: Ubume ${formatVersionLabel(result.latestVersion)}`
186
+ : "Status unknown — could not reach npm registry.";
187
+
188
+ return [
189
+ `Current installed version: ${current}`,
190
+ `npm latest version: ${latest}`,
191
+ `Status: ${statusLine}`,
192
+ "",
193
+ `Run: ${updateCommand}`,
194
+ ].join("\n");
195
+ }
196
+
197
+ export function formatLocalDevUpdateStatus(): string {
198
+ return [
199
+ "Running local-dev Ubume.",
200
+ "Automatic published npm update prompts are disabled for this channel.",
201
+ "Run /update check to explicitly check the published npm package.",
202
+ ].join("\n");
203
+ }
@@ -0,0 +1,107 @@
1
+ import { createHash } from "node:crypto";
2
+ import { cpSync, existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join, normalize } from "node:path";
5
+
6
+ type Platform = "win32" | "darwin" | "linux" | string;
7
+ type Environment = Record<string, string | undefined>;
8
+
9
+ export function resolveLegacyCodexaDataDir(
10
+ platformOverride?: Platform,
11
+ env: Environment = process.env,
12
+ home = homedir(),
13
+ ): string {
14
+ const configuredDir = env["CODEXA_DATA_DIR"]?.trim();
15
+ if (configuredDir) return configuredDir;
16
+
17
+ const platform = platformOverride ?? process.platform;
18
+ if (platform === "win32") {
19
+ return join(env["LOCALAPPDATA"]?.trim() || env["APPDATA"]?.trim() || join(home, "AppData", "Local"), "Codexa");
20
+ }
21
+ if (platform === "darwin") {
22
+ return join(home, "Library", "Application Support", "Codexa");
23
+ }
24
+
25
+ return join(env["XDG_DATA_HOME"]?.trim() || join(home, ".local", "share"), "codexa");
26
+ }
27
+
28
+ export function resolveUbumeDataDir(
29
+ platformOverride?: Platform,
30
+ env: Environment = process.env,
31
+ home = homedir(),
32
+ ): string {
33
+ const configuredDir = env["UBUME_DATA_DIR"]?.trim() || env["CODEXA_DATA_DIR"]?.trim();
34
+ if (configuredDir) return configuredDir;
35
+
36
+ const platform = platformOverride ?? process.platform;
37
+ if (platform === "win32") {
38
+ return join(env["LOCALAPPDATA"]?.trim() || env["APPDATA"]?.trim() || join(home, "AppData", "Local"), "Ubume");
39
+ }
40
+ if (platform === "darwin") {
41
+ return join(home, "Library", "Application Support", "Ubume");
42
+ }
43
+
44
+ return join(env["XDG_DATA_HOME"]?.trim() || join(home, ".local", "share"), "ubume");
45
+ }
46
+
47
+ let dataMigrated = false;
48
+
49
+ export function maybeMigrateLegacyData(
50
+ platformOverride?: Platform,
51
+ env: Environment = process.env,
52
+ home = homedir(),
53
+ ): void {
54
+ if (dataMigrated) return;
55
+ dataMigrated = true;
56
+
57
+ try {
58
+ const ubumeDir = resolveUbumeDataDir(platformOverride, env, home);
59
+ const legacyDir = resolveLegacyCodexaDataDir(platformOverride, env, home);
60
+
61
+ if (ubumeDir !== legacyDir && !existsSync(ubumeDir) && existsSync(legacyDir)) {
62
+ // Safe non-destructive one-time copy
63
+ cpSync(legacyDir, ubumeDir, { recursive: true });
64
+ }
65
+ } catch {
66
+ // Non-destructive best-effort migration
67
+ }
68
+ }
69
+
70
+ export function resetDataMigrationForTests(): void {
71
+ dataMigrated = false;
72
+ }
73
+
74
+ export function workspaceStorageKey(workspaceRoot: string): string {
75
+ return createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 16);
76
+ }
77
+
78
+ export function resolveUbumeWorkspaceDataDir(workspaceRoot: string): string {
79
+ maybeMigrateLegacyData();
80
+ return join(resolveUbumeDataDir(), "workspaces", workspaceStorageKey(workspaceRoot));
81
+ }
82
+
83
+ export function resolveUbumeConversationDir(workspaceRoot: string): string {
84
+ return join(resolveUbumeWorkspaceDataDir(workspaceRoot), "conversations");
85
+ }
86
+
87
+ export function resolveUbumeAttachmentDir(workspaceRoot: string, configuredDir: string): string {
88
+ if (isAbsolute(configuredDir)) return configuredDir;
89
+
90
+ const normalized = configuredDir
91
+ .trim()
92
+ .replace(/\\/g, "/")
93
+ .replace(/^\.(?:ubume|codexa)\/?/, "") || "attachments";
94
+ const safeRelativeDir = normalize(normalized).replace(/^(\.\.([/\\]|$))+/, "") || "attachments";
95
+ return join(resolveUbumeWorkspaceDataDir(workspaceRoot), safeRelativeDir);
96
+ }
97
+
98
+ export function resolveUbumeDebugLogPath(env: Environment = process.env): string {
99
+ return join(resolveUbumeDataDir(undefined, env), "debug", "render-status.log");
100
+ }
101
+
102
+ // Backwards-compatible aliases
103
+ export const resolveCodexaDataDir = resolveUbumeDataDir;
104
+ export const resolveCodexaWorkspaceDataDir = resolveUbumeWorkspaceDataDir;
105
+ export const resolveCodexaConversationDir = resolveUbumeConversationDir;
106
+ export const resolveCodexaAttachmentDir = resolveUbumeAttachmentDir;
107
+ export const resolveCodexaDebugLogPath = resolveUbumeDebugLogPath;
@@ -0,0 +1,335 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ renameSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { randomUUID } from "node:crypto";
11
+ import type { ProviderBackendKind } from "../providerRuntime/types.js";
12
+ import type { ProviderId } from "../providerLauncher/types.js";
13
+ import type { LocalBackendId } from "../providerLauncher/types.js";
14
+ import { resolveUbumeConversationDir } from "./appData.js";
15
+
16
+ export type ConversationMessageRole = "user" | "assistant";
17
+
18
+ export interface ConversationMessage {
19
+ role: ConversationMessageRole;
20
+ content: string;
21
+ /** Files changed / commands run during the run that produced this reply. */
22
+ activitySummary?: string;
23
+ }
24
+
25
+ export interface ConversationContextCheckpoint {
26
+ version: 1;
27
+ modelId: string;
28
+ contextLength: number | null;
29
+ throughMessageCount: number;
30
+ transcriptHash: string;
31
+ summary: string;
32
+ activeWindowChars?: number;
33
+ responseCharsCovered?: number;
34
+ updatedAt: string;
35
+ }
36
+
37
+ export interface LocalHarnessSessionMetadata {
38
+ version: 1;
39
+ sessionId: string;
40
+ harnessVersion: string;
41
+ routeFingerprint: string;
42
+ throughMessageCount: number;
43
+ transcriptHash: string;
44
+ updatedAt: string;
45
+ }
46
+
47
+ export interface ConversationMetadata {
48
+ version: 1;
49
+ id: string;
50
+ title: string;
51
+ createdAt: string;
52
+ updatedAt: string;
53
+ providerId: ProviderId | string | null;
54
+ modelId: string;
55
+ backendKind: ProviderBackendKind | string | null;
56
+ reasoning?: string;
57
+ localBackend?: LocalBackendId;
58
+ localContextCheckpoint?: ConversationContextCheckpoint;
59
+ localHarnessSession?: LocalHarnessSessionMetadata;
60
+ messageCount: number;
61
+ }
62
+
63
+ export interface ConversationRecord {
64
+ metadata: ConversationMetadata;
65
+ messages: ConversationMessage[];
66
+ }
67
+
68
+ export interface ConversationListEntry extends ConversationMetadata {}
69
+
70
+ interface ConversationStoreOptions {
71
+ rootDir?: string;
72
+ now?: () => Date;
73
+ idFactory?: () => string;
74
+ onDiagnostic?: (message: string) => void;
75
+ }
76
+
77
+ function isRecord(value: unknown): value is Record<string, unknown> {
78
+ return typeof value === "object" && value !== null && !Array.isArray(value);
79
+ }
80
+
81
+ function safeString(value: unknown): string | null {
82
+ return typeof value === "string" && value.trim() ? value : null;
83
+ }
84
+
85
+ function isNonNegativeInteger(value: unknown): value is number {
86
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
87
+ }
88
+
89
+ function parseContextCheckpoint(value: unknown): ConversationContextCheckpoint | null {
90
+ if (!isRecord(value) || value.version !== 1) return null;
91
+ const modelId = safeString(value.modelId);
92
+ const transcriptHash = safeString(value.transcriptHash);
93
+ const summary = safeString(value.summary);
94
+ const updatedAt = safeString(value.updatedAt);
95
+ const contextLength = value.contextLength === null
96
+ ? null
97
+ : isNonNegativeInteger(value.contextLength) && value.contextLength > 0
98
+ ? value.contextLength
99
+ : undefined;
100
+ if (
101
+ !modelId
102
+ || contextLength === undefined
103
+ || !isNonNegativeInteger(value.throughMessageCount)
104
+ || !transcriptHash
105
+ || !summary
106
+ || !updatedAt
107
+ || (value.activeWindowChars !== undefined && !isNonNegativeInteger(value.activeWindowChars))
108
+ || (value.responseCharsCovered !== undefined && !isNonNegativeInteger(value.responseCharsCovered))
109
+ ) return null;
110
+
111
+ return {
112
+ version: 1,
113
+ modelId,
114
+ contextLength,
115
+ throughMessageCount: value.throughMessageCount,
116
+ transcriptHash,
117
+ summary,
118
+ ...(value.activeWindowChars === undefined ? {} : { activeWindowChars: value.activeWindowChars }),
119
+ ...(value.responseCharsCovered === undefined ? {} : { responseCharsCovered: value.responseCharsCovered }),
120
+ updatedAt,
121
+ };
122
+ }
123
+
124
+ function parseLocalHarnessSession(value: unknown): LocalHarnessSessionMetadata | null {
125
+ if (!isRecord(value) || value.version !== 1) return null;
126
+ const sessionId = safeString(value.sessionId);
127
+ const harnessVersion = safeString(value.harnessVersion);
128
+ const routeFingerprint = safeString(value.routeFingerprint);
129
+ const transcriptHash = safeString(value.transcriptHash);
130
+ const updatedAt = safeString(value.updatedAt);
131
+ if (!sessionId || !harnessVersion || !routeFingerprint || !transcriptHash || !updatedAt) return null;
132
+ if (!isNonNegativeInteger(value.throughMessageCount)) return null;
133
+ return {
134
+ version: 1,
135
+ sessionId,
136
+ harnessVersion,
137
+ routeFingerprint,
138
+ throughMessageCount: value.throughMessageCount,
139
+ transcriptHash,
140
+ updatedAt,
141
+ };
142
+ }
143
+
144
+ function parseMessages(value: unknown): ConversationMessage[] | null {
145
+ if (!Array.isArray(value)) return null;
146
+ const messages: ConversationMessage[] = [];
147
+ for (const item of value) {
148
+ if (!isRecord(item)) return null;
149
+ const role = item.role;
150
+ const content = item.content;
151
+ if ((role !== "user" && role !== "assistant") || typeof content !== "string") return null;
152
+ const activitySummary = typeof item.activitySummary === "string" && item.activitySummary.trim()
153
+ ? item.activitySummary
154
+ : null;
155
+ messages.push({ role, content, ...(activitySummary ? { activitySummary } : {}) });
156
+ }
157
+ return messages;
158
+ }
159
+
160
+ function parseMetadata(value: unknown, fallbackId: string): ConversationMetadata | null {
161
+ if (!isRecord(value)) return null;
162
+ const id = safeString(value.id) ?? fallbackId;
163
+ const title = safeString(value.title) ?? "Untitled conversation";
164
+ const createdAt = safeString(value.createdAt) ?? new Date(0).toISOString();
165
+ const updatedAt = safeString(value.updatedAt) ?? createdAt;
166
+ const modelId = safeString(value.modelId) ?? "unknown";
167
+ const messageCount = typeof value.messageCount === "number" && Number.isInteger(value.messageCount)
168
+ ? Math.max(0, value.messageCount)
169
+ : 0;
170
+ const localContextCheckpoint = parseContextCheckpoint(value.localContextCheckpoint);
171
+ const localHarnessSession = parseLocalHarnessSession(value.localHarnessSession);
172
+ return {
173
+ version: 1,
174
+ id,
175
+ title,
176
+ createdAt,
177
+ updatedAt,
178
+ providerId: typeof value.providerId === "string" ? value.providerId : null,
179
+ modelId,
180
+ backendKind: typeof value.backendKind === "string" ? value.backendKind : null,
181
+ ...(typeof value.reasoning === "string" && value.reasoning.trim() ? { reasoning: value.reasoning } : {}),
182
+ ...(value.localBackend === "lm-studio" || value.localBackend === "unsloth" ? { localBackend: value.localBackend } : {}),
183
+ ...(localContextCheckpoint ? { localContextCheckpoint } : {}),
184
+ ...(localHarnessSession ? { localHarnessSession } : {}),
185
+ messageCount,
186
+ };
187
+ }
188
+
189
+ function titleFromMessages(messages: ConversationMessage[]): string {
190
+ const firstUser = messages.find((message) => message.role === "user" && message.content.trim());
191
+ if (!firstUser) return "Untitled conversation";
192
+ const title = firstUser.content.replace(/\s+/g, " ").trim();
193
+ return title.length > 72 ? `${title.slice(0, 69).trimEnd()}...` : title;
194
+ }
195
+
196
+ function atomicWriteJson(filePath: string, value: unknown): void {
197
+ const temporaryPath = `${filePath}.tmp`;
198
+ writeFileSync(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
199
+ renameSync(temporaryPath, filePath);
200
+ }
201
+
202
+ function isSafeConversationId(id: string): boolean {
203
+ return /^chat_[A-Za-z0-9-]+$/.test(id);
204
+ }
205
+
206
+ export class ConversationStore {
207
+ private readonly rootDir: string;
208
+ private readonly now: () => Date;
209
+ private readonly idFactory: () => string;
210
+ private readonly onDiagnostic: (message: string) => void;
211
+
212
+ constructor(workspaceRoot: string, options: ConversationStoreOptions = {}) {
213
+ this.rootDir = options.rootDir ?? resolveUbumeConversationDir(workspaceRoot);
214
+ this.now = options.now ?? (() => new Date());
215
+ this.idFactory = options.idFactory ?? (() => randomUUID());
216
+ this.onDiagnostic = options.onDiagnostic ?? (() => undefined);
217
+ }
218
+
219
+ private conversationDir(id: string): string {
220
+ if (!isSafeConversationId(id)) throw new Error("Invalid conversation id.");
221
+ return join(this.rootDir, id);
222
+ }
223
+
224
+ private ensureRoot(): void {
225
+ mkdirSync(this.rootDir, { recursive: true });
226
+ }
227
+
228
+ createConversation(route: {
229
+ providerId: ProviderId | string | null;
230
+ modelId: string;
231
+ backendKind: ProviderBackendKind | string | null;
232
+ reasoning?: string;
233
+ localBackend?: LocalBackendId;
234
+ }): ConversationRecord {
235
+ this.ensureRoot();
236
+ const id = `chat_${this.idFactory()}`;
237
+ const timestamp = this.now().toISOString();
238
+ const metadata: ConversationMetadata = {
239
+ version: 1,
240
+ id,
241
+ title: "Untitled conversation",
242
+ createdAt: timestamp,
243
+ updatedAt: timestamp,
244
+ providerId: route.providerId,
245
+ modelId: route.modelId,
246
+ backendKind: route.backendKind,
247
+ ...(route.reasoning ? { reasoning: route.reasoning } : {}),
248
+ ...(route.localBackend ? { localBackend: route.localBackend } : {}),
249
+ messageCount: 0,
250
+ };
251
+ return { metadata, messages: [] };
252
+ }
253
+
254
+ save(record: ConversationRecord): void {
255
+ const dir = this.conversationDir(record.metadata.id);
256
+ mkdirSync(dir, { recursive: true });
257
+ const messages = record.messages.map((message) => ({
258
+ role: message.role,
259
+ content: message.content,
260
+ ...(message.activitySummary ? { activitySummary: message.activitySummary } : {}),
261
+ }));
262
+ const metadata: ConversationMetadata = {
263
+ ...record.metadata,
264
+ title: record.metadata.title === "Untitled conversation" ? titleFromMessages(record.messages) : record.metadata.title,
265
+ updatedAt: this.now().toISOString(),
266
+ messageCount: messages.length,
267
+ };
268
+ atomicWriteJson(join(dir, "messages.json"), messages);
269
+ atomicWriteJson(join(dir, "metadata.json"), metadata);
270
+ }
271
+
272
+ load(id: string): ConversationRecord | null {
273
+ try {
274
+ const dir = this.conversationDir(id);
275
+ const messages = parseMessages(JSON.parse(readFileSync(join(dir, "messages.json"), "utf8")));
276
+ if (!messages) throw new Error("messages.json is not a valid conversation message array");
277
+ let metadata: ConversationMetadata | null = null;
278
+ const metadataPath = join(dir, "metadata.json");
279
+ if (existsSync(metadataPath)) {
280
+ metadata = parseMetadata(JSON.parse(readFileSync(metadataPath, "utf8")), id);
281
+ }
282
+ const timestamp = this.now().toISOString();
283
+ metadata ??= {
284
+ version: 1,
285
+ id,
286
+ title: titleFromMessages(messages),
287
+ createdAt: timestamp,
288
+ updatedAt: timestamp,
289
+ providerId: null,
290
+ modelId: "unknown",
291
+ backendKind: null,
292
+ messageCount: messages.length,
293
+ };
294
+ metadata = { ...metadata, messageCount: messages.length };
295
+ return { metadata, messages };
296
+ } catch (error) {
297
+ this.onDiagnostic(`Skipped conversation ${id}: ${error instanceof Error ? error.message : "invalid data"}`);
298
+ return null;
299
+ }
300
+ }
301
+
302
+ list(): ConversationListEntry[] {
303
+ if (!existsSync(this.rootDir)) return [];
304
+ const entries: ConversationListEntry[] = [];
305
+ let directoryEntries;
306
+ try {
307
+ directoryEntries = readdirSync(this.rootDir, { withFileTypes: true });
308
+ } catch (error) {
309
+ this.onDiagnostic(`Unable to list conversations: ${error instanceof Error ? error.message : "filesystem error"}`);
310
+ return [];
311
+ }
312
+ for (const entry of directoryEntries) {
313
+ if (!entry.isDirectory() || !isSafeConversationId(entry.name)) continue;
314
+ const dir = join(this.rootDir, entry.name);
315
+ try {
316
+ const metadataPath = join(dir, "metadata.json");
317
+ if (existsSync(metadataPath)) {
318
+ const metadata = parseMetadata(JSON.parse(readFileSync(metadataPath, "utf8")), entry.name);
319
+ if (metadata) {
320
+ entries.push(metadata);
321
+ continue;
322
+ }
323
+ }
324
+ const record = this.load(entry.name);
325
+ if (record) entries.push(record.metadata);
326
+ } catch (error) {
327
+ this.onDiagnostic(`Skipped conversation ${entry.name}: ${error instanceof Error ? error.message : "invalid metadata"}`);
328
+ }
329
+ }
330
+ return entries.sort((left, right) => {
331
+ const updated = right.updatedAt.localeCompare(left.updatedAt);
332
+ return updated !== 0 ? updated : right.id.localeCompare(left.id);
333
+ });
334
+ }
335
+ }