@chrysb/alphaclaw 0.5.0 → 0.5.1-beta.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.
@@ -2,6 +2,7 @@ import { h } from "https://esm.sh/preact";
2
2
  import htm from "https://esm.sh/htm";
3
3
  import { ActionButton } from "../action-button.js";
4
4
  import { PageHeader } from "../page-header.js";
5
+ import { showToast } from "../toast.js";
5
6
  import { OverviewSection } from "./overview-section.js";
6
7
  import { SessionsSection } from "./sessions-section.js";
7
8
  import { useUsageTab } from "./use-usage-tab.js";
@@ -26,6 +27,20 @@ export const UsageTab = ({ sessionId = "" }) => {
26
27
  );
27
28
  };
28
29
 
30
+ const handleRunBackfill = async () => {
31
+ try {
32
+ const result = await actions.triggerBackfill();
33
+ const backfilledEvents = Number(result?.backfilledEvents || 0);
34
+ const filesScanned = Number(result?.filesScanned || 0);
35
+ showToast(
36
+ `Imported ${backfilledEvents.toLocaleString()} usage events from ${filesScanned.toLocaleString()} session files`,
37
+ "success",
38
+ );
39
+ } catch (error) {
40
+ showToast(error.message || "Could not import historical usage data", "error");
41
+ }
42
+ };
43
+
29
44
  return html`
30
45
  <div class="space-y-4">
31
46
  <${PageHeader}
@@ -46,6 +61,34 @@ export const UsageTab = ({ sessionId = "" }) => {
46
61
  ${state.error}
47
62
  </div>`
48
63
  : null}
64
+ ${state.showBackfillBanner
65
+ ? html`
66
+ <div class="bg-surface border border-border rounded-xl p-4 space-y-3">
67
+ <p class="text-xs text-gray-300 leading-5">
68
+ We found historical usage data from
69
+ <span class="font-medium text-gray-200"
70
+ >${Number(state.estimatedBackfillFiles || 0).toLocaleString()}</span
71
+ >
72
+ session files. Import it?
73
+ </p>
74
+ <div class="flex flex-wrap items-center gap-2">
75
+ <${ActionButton}
76
+ onClick=${handleRunBackfill}
77
+ loading=${state.runningBackfill}
78
+ disabled=${state.loadingBackfillStatus}
79
+ idleLabel="Import historical data"
80
+ loadingLabel="Importing..."
81
+ />
82
+ <${ActionButton}
83
+ onClick=${actions.dismissBackfillBanner}
84
+ tone="secondary"
85
+ disabled=${state.runningBackfill}
86
+ idleLabel="Dismiss"
87
+ />
88
+ </div>
89
+ </div>
90
+ `
91
+ : null}
49
92
  ${state.loadingSummary && !state.summary
50
93
  ? html`<div class="text-sm text-[var(--text-muted)]">Loading usage summary...</div>`
51
94
  : html`
@@ -31,8 +31,9 @@ const getCacheHitRateValueClass = (ratio) => {
31
31
  const getOverviewMetrics = (summary) => {
32
32
  const totals = summary?.totals || {};
33
33
  const cacheReadTokens = Number(totals.cacheReadTokens || 0);
34
+ const cacheWriteTokens = Number(totals.cacheWriteTokens || 0);
34
35
  const inputTokens = Number(totals.inputTokens || 0);
35
- const promptTokens = inputTokens + cacheReadTokens;
36
+ const promptTokens = inputTokens + cacheReadTokens + cacheWriteTokens;
36
37
  const turnCount = Number(totals.turnCount || 0);
37
38
  const totalTokens = Number(totals.totalTokens || 0);
38
39
  const totalCost = Number(totals.totalCost || 0);
@@ -6,9 +6,11 @@ import {
6
6
  useState,
7
7
  } from "https://esm.sh/preact/hooks";
8
8
  import {
9
+ fetchUsageBackfillStatus,
9
10
  fetchUsageSessionDetail,
10
11
  fetchUsageSessions,
11
12
  fetchUsageSummary,
13
+ runUsageBackfill,
12
14
  } from "../../lib/api.js";
13
15
  import { formatInteger, formatUsd } from "../../lib/format.js";
14
16
  import { readUiSettings, writeUiSettings } from "../../lib/ui-settings.js";
@@ -41,6 +43,10 @@ export const useUsageTab = ({ sessionId = "" }) => {
41
43
  const [loadingSummary, setLoadingSummary] = useState(false);
42
44
  const [loadingSessions, setLoadingSessions] = useState(false);
43
45
  const [loadingDetailById, setLoadingDetailById] = useState({});
46
+ const [loadingBackfillStatus, setLoadingBackfillStatus] = useState(false);
47
+ const [runningBackfill, setRunningBackfill] = useState(false);
48
+ const [showBackfillBanner, setShowBackfillBanner] = useState(false);
49
+ const [estimatedBackfillFiles, setEstimatedBackfillFiles] = useState(0);
44
50
  const [expandedSessionIds, setExpandedSessionIds] = useState(() =>
45
51
  sessionId ? [String(sessionId)] : [],
46
52
  );
@@ -96,6 +102,37 @@ export const useUsageTab = ({ sessionId = "" }) => {
96
102
  }
97
103
  }, []);
98
104
 
105
+ const loadBackfillStatus = useCallback(async () => {
106
+ setLoadingBackfillStatus(true);
107
+ try {
108
+ const data = await fetchUsageBackfillStatus();
109
+ const available = !!data?.available;
110
+ setShowBackfillBanner(available);
111
+ setEstimatedBackfillFiles(Number(data?.estimatedFiles || 0));
112
+ } catch {
113
+ setShowBackfillBanner(false);
114
+ setEstimatedBackfillFiles(0);
115
+ } finally {
116
+ setLoadingBackfillStatus(false);
117
+ }
118
+ }, []);
119
+
120
+ const triggerBackfill = useCallback(async () => {
121
+ setRunningBackfill(true);
122
+ try {
123
+ const result = await runUsageBackfill();
124
+ setShowBackfillBanner(false);
125
+ await loadSummary();
126
+ await loadSessions();
127
+ return result;
128
+ } catch (err) {
129
+ setError(err.message || "Could not backfill usage data");
130
+ throw err;
131
+ } finally {
132
+ setRunningBackfill(false);
133
+ }
134
+ }, [loadSessions, loadSummary]);
135
+
99
136
  useEffect(() => {
100
137
  loadSummary();
101
138
  }, [loadSummary]);
@@ -111,6 +148,10 @@ export const useUsageTab = ({ sessionId = "" }) => {
111
148
  loadSessions();
112
149
  }, [loadSessions]);
113
150
 
151
+ useEffect(() => {
152
+ loadBackfillStatus();
153
+ }, [loadBackfillStatus]);
154
+
114
155
  useEffect(() => {
115
156
  const safeSessionId = String(sessionId || "").trim();
116
157
  if (!safeSessionId) return;
@@ -256,6 +297,10 @@ export const useUsageTab = ({ sessionId = "" }) => {
256
297
  loadingSummary,
257
298
  loadingSessions,
258
299
  loadingDetailById,
300
+ loadingBackfillStatus,
301
+ runningBackfill,
302
+ showBackfillBanner,
303
+ estimatedBackfillFiles,
259
304
  expandedSessionIds,
260
305
  error,
261
306
  periodSummary,
@@ -265,6 +310,9 @@ export const useUsageTab = ({ sessionId = "" }) => {
265
310
  setDays,
266
311
  setMetric,
267
312
  loadSummary,
313
+ loadBackfillStatus,
314
+ triggerBackfill,
315
+ dismissBackfillBanner: () => setShowBackfillBanner(false),
268
316
  loadSessionDetail,
269
317
  setExpandedSessionIds,
270
318
  },
@@ -345,6 +345,20 @@ export async function fetchUsageSessionTimeSeries(sessionId, maxPoints = 100) {
345
345
  return parseJsonOrThrow(res, "Could not load usage time series");
346
346
  }
347
347
 
348
+ export async function fetchUsageBackfillStatus() {
349
+ const res = await authFetch("/api/usage/backfill/status");
350
+ return parseJsonOrThrow(res, "Could not load usage backfill status");
351
+ }
352
+
353
+ export async function runUsageBackfill() {
354
+ const res = await authFetch("/api/usage/backfill", {
355
+ method: "POST",
356
+ headers: { "Content-Type": "application/json" },
357
+ body: JSON.stringify({}),
358
+ });
359
+ return parseJsonOrThrow(res, "Could not backfill usage data");
360
+ }
361
+
348
362
  export async function fetchWatchdogEvents(limit = 20) {
349
363
  const res = await authFetch(
350
364
  `/api/watchdog/events?limit=${encodeURIComponent(String(limit))}`,
@@ -0,0 +1,416 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const readline = require("readline");
4
+
5
+ const kSessionsStoreFileName = "sessions.json";
6
+ const kSessionFileSuffix = ".jsonl";
7
+
8
+ const toFiniteNumber = (value) => {
9
+ if (typeof value === "number" && Number.isFinite(value)) return value;
10
+ if (typeof value === "string" && value.trim()) {
11
+ const parsed = Number(value);
12
+ if (Number.isFinite(parsed)) return parsed;
13
+ }
14
+ return undefined;
15
+ };
16
+
17
+ const toNonNegativeInt = (value) => {
18
+ const parsed = toFiniteNumber(value);
19
+ if (parsed === undefined) return undefined;
20
+ if (parsed <= 0) return 0;
21
+ return Math.floor(parsed);
22
+ };
23
+
24
+ const normalizeUsage = (rawUsage) => {
25
+ if (!rawUsage || typeof rawUsage !== "object") return null;
26
+ const inputTokens = toNonNegativeInt(
27
+ rawUsage.input ??
28
+ rawUsage.inputTokens ??
29
+ rawUsage.input_tokens ??
30
+ rawUsage.promptTokens ??
31
+ rawUsage.prompt_tokens,
32
+ );
33
+ const outputTokens = toNonNegativeInt(
34
+ rawUsage.output ??
35
+ rawUsage.outputTokens ??
36
+ rawUsage.output_tokens ??
37
+ rawUsage.completionTokens ??
38
+ rawUsage.completion_tokens,
39
+ );
40
+ const cacheReadTokens = toNonNegativeInt(
41
+ rawUsage.cacheRead ??
42
+ rawUsage.cache_read ??
43
+ rawUsage.cache_read_input_tokens ??
44
+ rawUsage.cached_tokens ??
45
+ rawUsage.prompt_tokens_details?.cached_tokens,
46
+ );
47
+ const cacheWriteTokens = toNonNegativeInt(
48
+ rawUsage.cacheWrite ??
49
+ rawUsage.cache_write ??
50
+ rawUsage.cache_creation_input_tokens,
51
+ );
52
+ const totalTokens = toNonNegativeInt(
53
+ rawUsage.total ?? rawUsage.totalTokens ?? rawUsage.total_tokens,
54
+ );
55
+ if (
56
+ inputTokens === undefined &&
57
+ outputTokens === undefined &&
58
+ cacheReadTokens === undefined &&
59
+ cacheWriteTokens === undefined &&
60
+ totalTokens === undefined
61
+ ) {
62
+ return null;
63
+ }
64
+
65
+ const normalized = {
66
+ inputTokens: inputTokens ?? 0,
67
+ outputTokens: outputTokens ?? 0,
68
+ cacheReadTokens: cacheReadTokens ?? 0,
69
+ cacheWriteTokens: cacheWriteTokens ?? 0,
70
+ totalTokens:
71
+ totalTokens ??
72
+ (inputTokens ?? 0) +
73
+ (outputTokens ?? 0) +
74
+ (cacheReadTokens ?? 0) +
75
+ (cacheWriteTokens ?? 0),
76
+ };
77
+ if (normalized.totalTokens <= 0) return null;
78
+ return normalized;
79
+ };
80
+
81
+ const parseTimestampMs = (entry) => {
82
+ const rawTimestamp = toFiniteNumber(entry?.timestamp);
83
+ if (rawTimestamp !== undefined && rawTimestamp > 0) {
84
+ return Math.floor(rawTimestamp);
85
+ }
86
+
87
+ const rawTimestampIso = String(entry?.timestamp || "").trim();
88
+ if (rawTimestampIso) {
89
+ const parsedDate = new Date(rawTimestampIso);
90
+ const parsedMs = parsedDate.valueOf();
91
+ if (Number.isFinite(parsedMs) && parsedMs > 0) return parsedMs;
92
+ }
93
+
94
+ const messageTimestamp = toFiniteNumber(entry?.message?.timestamp);
95
+ if (messageTimestamp !== undefined && messageTimestamp > 0) {
96
+ return Math.floor(messageTimestamp);
97
+ }
98
+ return null;
99
+ };
100
+
101
+ const toDayKey = (timestampMs) =>
102
+ new Date(Number(timestampMs || 0)).toISOString().slice(0, 10);
103
+
104
+ const resolveSessionsDir = (openclawDir) =>
105
+ path.join(String(openclawDir || ""), "agents", "main", "sessions");
106
+
107
+ const listBackfillCandidateFiles = ({
108
+ openclawDir,
109
+ earliestExistingTimestampMs = null,
110
+ fsModule = fs,
111
+ }) => {
112
+ const sessionsDir = resolveSessionsDir(openclawDir);
113
+ if (!sessionsDir || !fsModule.existsSync(sessionsDir)) return [];
114
+ const entries = fsModule.readdirSync(sessionsDir, { withFileTypes: true });
115
+ const candidateFiles = [];
116
+ for (const entry of entries) {
117
+ if (!entry?.isFile?.()) continue;
118
+ const fileName = String(entry.name || "");
119
+ if (!fileName.endsWith(kSessionFileSuffix)) continue;
120
+ if (!earliestExistingTimestampMs || earliestExistingTimestampMs <= 0) {
121
+ candidateFiles.push(fileName);
122
+ continue;
123
+ }
124
+ const absolutePath = path.join(sessionsDir, fileName);
125
+ try {
126
+ const stats = fsModule.statSync(absolutePath);
127
+ if (Number(stats?.mtimeMs || 0) < earliestExistingTimestampMs) {
128
+ candidateFiles.push(fileName);
129
+ }
130
+ } catch {}
131
+ }
132
+ return candidateFiles.sort((leftValue, rightValue) =>
133
+ leftValue.localeCompare(rightValue),
134
+ );
135
+ };
136
+
137
+ const getEarliestUsageTimestampMs = (database) => {
138
+ const row = database
139
+ .prepare("SELECT MIN(timestamp) AS earliest_timestamp FROM usage_events")
140
+ .get();
141
+ const parsed = toNonNegativeInt(row?.earliest_timestamp);
142
+ return parsed && parsed > 0 ? parsed : null;
143
+ };
144
+
145
+ const readSessionsStore = ({ sessionsDir, fsModule = fs }) => {
146
+ const sessionsStorePath = path.join(sessionsDir, kSessionsStoreFileName);
147
+ if (!fsModule.existsSync(sessionsStorePath)) return {};
148
+ try {
149
+ const raw = fsModule.readFileSync(sessionsStorePath, "utf8");
150
+ const parsed = JSON.parse(raw);
151
+ return parsed && typeof parsed === "object" ? parsed : {};
152
+ } catch {
153
+ return {};
154
+ }
155
+ };
156
+
157
+ const buildSessionContextByFileName = ({ sessionsDir, fsModule = fs }) => {
158
+ const sessionStore = readSessionsStore({ sessionsDir, fsModule });
159
+ const byFileName = new Map();
160
+ for (const [sessionKey, entry] of Object.entries(sessionStore)) {
161
+ const safeSessionKey = String(sessionKey || "").trim();
162
+ if (!safeSessionKey) continue;
163
+ const rawSessionId = String(entry?.sessionId || "").trim();
164
+ const rawSessionFile = String(entry?.sessionFile || "").trim();
165
+ const baseContext = {
166
+ sessionKey: safeSessionKey,
167
+ sessionId: rawSessionId,
168
+ };
169
+ if (rawSessionFile) {
170
+ byFileName.set(path.basename(rawSessionFile), baseContext);
171
+ }
172
+ if (rawSessionId) {
173
+ byFileName.set(`${rawSessionId}${kSessionFileSuffix}`, baseContext);
174
+ }
175
+ }
176
+ return byFileName;
177
+ };
178
+
179
+ const resolveSessionContext = ({ fileName, sessionContextByFileName }) => {
180
+ const mappedContext = sessionContextByFileName.get(fileName) || null;
181
+ if (mappedContext) return mappedContext;
182
+ return {
183
+ sessionKey: "",
184
+ sessionId: String(fileName || "").replace(/\.jsonl$/i, ""),
185
+ };
186
+ };
187
+
188
+ const scanUsageRecordsFromFile = async ({
189
+ filePath,
190
+ onRecord = () => {},
191
+ onSkip = () => {},
192
+ earliestExistingTimestampMs = null,
193
+ }) => {
194
+ const stream = fs.createReadStream(filePath, { encoding: "utf8" });
195
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
196
+ try {
197
+ for await (const rawLine of lines) {
198
+ const trimmedLine = String(rawLine || "").trim();
199
+ if (!trimmedLine) {
200
+ onSkip();
201
+ continue;
202
+ }
203
+ let parsed = null;
204
+ try {
205
+ parsed = JSON.parse(trimmedLine);
206
+ } catch {
207
+ onSkip();
208
+ continue;
209
+ }
210
+ const message = parsed?.message;
211
+ if (!message || typeof message !== "object") {
212
+ onSkip();
213
+ continue;
214
+ }
215
+ if (String(message.role || "").trim() !== "assistant") {
216
+ onSkip();
217
+ continue;
218
+ }
219
+ const usage = normalizeUsage(message.usage ?? parsed?.usage);
220
+ if (!usage) {
221
+ onSkip();
222
+ continue;
223
+ }
224
+ const timestampMs = parseTimestampMs(parsed);
225
+ if (!timestampMs) {
226
+ onSkip();
227
+ continue;
228
+ }
229
+ if (
230
+ earliestExistingTimestampMs &&
231
+ timestampMs >= earliestExistingTimestampMs
232
+ ) {
233
+ onSkip();
234
+ continue;
235
+ }
236
+ const provider =
237
+ String(message.provider || parsed?.provider || "").trim() || "unknown";
238
+ const model = String(message.model || parsed?.model || "").trim() || "unknown";
239
+ const runId =
240
+ String(
241
+ parsed?.runId ||
242
+ parsed?.run_id ||
243
+ message?.runId ||
244
+ message?.run_id ||
245
+ "",
246
+ ).trim() || "";
247
+ onRecord({
248
+ timestampMs,
249
+ provider,
250
+ model,
251
+ runId,
252
+ ...usage,
253
+ });
254
+ }
255
+ } finally {
256
+ lines.close();
257
+ stream.destroy();
258
+ }
259
+ };
260
+
261
+ const getBackfillStatus = ({ database, openclawDir, fsModule = fs }) => {
262
+ if (!database) throw new Error("Usage DB is not initialized");
263
+ const earliestExistingTimestampMs = getEarliestUsageTimestampMs(database);
264
+ const files = listBackfillCandidateFiles({
265
+ openclawDir,
266
+ earliestExistingTimestampMs,
267
+ fsModule,
268
+ });
269
+ return {
270
+ available: files.length > 0,
271
+ estimatedFiles: files.length,
272
+ earliestExistingTimestampMs,
273
+ };
274
+ };
275
+
276
+ const backfillFromTranscripts = async ({
277
+ database,
278
+ openclawDir,
279
+ fsModule = fs,
280
+ }) => {
281
+ if (!database) throw new Error("Usage DB is not initialized");
282
+ const earliestExistingTimestampMs = getEarliestUsageTimestampMs(database);
283
+ const sessionsDir = resolveSessionsDir(openclawDir);
284
+ const files = listBackfillCandidateFiles({
285
+ openclawDir,
286
+ earliestExistingTimestampMs,
287
+ fsModule,
288
+ });
289
+ const sessionContextByFileName = buildSessionContextByFileName({
290
+ sessionsDir,
291
+ fsModule,
292
+ });
293
+
294
+ const insertUsageEventStmt = database.prepare(`
295
+ INSERT INTO usage_events (
296
+ timestamp,
297
+ session_id,
298
+ session_key,
299
+ run_id,
300
+ provider,
301
+ model,
302
+ input_tokens,
303
+ output_tokens,
304
+ cache_read_tokens,
305
+ cache_write_tokens,
306
+ total_tokens
307
+ ) VALUES (
308
+ $timestamp,
309
+ $session_id,
310
+ $session_key,
311
+ $run_id,
312
+ $provider,
313
+ $model,
314
+ $input_tokens,
315
+ $output_tokens,
316
+ $cache_read_tokens,
317
+ $cache_write_tokens,
318
+ $total_tokens
319
+ )
320
+ `);
321
+ const upsertUsageDailyStmt = database.prepare(`
322
+ INSERT INTO usage_daily (
323
+ date,
324
+ model,
325
+ provider,
326
+ input_tokens,
327
+ output_tokens,
328
+ cache_read_tokens,
329
+ cache_write_tokens,
330
+ total_tokens,
331
+ turn_count
332
+ ) VALUES (
333
+ $date,
334
+ $model,
335
+ $provider,
336
+ $input_tokens,
337
+ $output_tokens,
338
+ $cache_read_tokens,
339
+ $cache_write_tokens,
340
+ $total_tokens,
341
+ 1
342
+ )
343
+ ON CONFLICT(date, model) DO UPDATE SET
344
+ provider = COALESCE(excluded.provider, usage_daily.provider),
345
+ input_tokens = usage_daily.input_tokens + excluded.input_tokens,
346
+ output_tokens = usage_daily.output_tokens + excluded.output_tokens,
347
+ cache_read_tokens = usage_daily.cache_read_tokens + excluded.cache_read_tokens,
348
+ cache_write_tokens = usage_daily.cache_write_tokens + excluded.cache_write_tokens,
349
+ total_tokens = usage_daily.total_tokens + excluded.total_tokens,
350
+ turn_count = usage_daily.turn_count + 1
351
+ `);
352
+
353
+ let backfilledEvents = 0;
354
+ let skippedEvents = 0;
355
+ let filesScanned = 0;
356
+ database.exec("BEGIN");
357
+ try {
358
+ for (const fileName of files) {
359
+ filesScanned += 1;
360
+ const filePath = path.join(sessionsDir, fileName);
361
+ const sessionContext = resolveSessionContext({
362
+ fileName,
363
+ sessionContextByFileName,
364
+ });
365
+ await scanUsageRecordsFromFile({
366
+ filePath,
367
+ earliestExistingTimestampMs,
368
+ onSkip: () => {
369
+ skippedEvents += 1;
370
+ },
371
+ onRecord: (record) => {
372
+ insertUsageEventStmt.run({
373
+ $timestamp: record.timestampMs,
374
+ $session_id: String(sessionContext.sessionId || ""),
375
+ $session_key: String(sessionContext.sessionKey || ""),
376
+ $run_id: String(record.runId || ""),
377
+ $provider: record.provider,
378
+ $model: record.model,
379
+ $input_tokens: record.inputTokens,
380
+ $output_tokens: record.outputTokens,
381
+ $cache_read_tokens: record.cacheReadTokens,
382
+ $cache_write_tokens: record.cacheWriteTokens,
383
+ $total_tokens: record.totalTokens,
384
+ });
385
+ upsertUsageDailyStmt.run({
386
+ $date: toDayKey(record.timestampMs),
387
+ $model: record.model,
388
+ $provider: record.provider,
389
+ $input_tokens: record.inputTokens,
390
+ $output_tokens: record.outputTokens,
391
+ $cache_read_tokens: record.cacheReadTokens,
392
+ $cache_write_tokens: record.cacheWriteTokens,
393
+ $total_tokens: record.totalTokens,
394
+ });
395
+ backfilledEvents += 1;
396
+ },
397
+ });
398
+ }
399
+ database.exec("COMMIT");
400
+ } catch (error) {
401
+ database.exec("ROLLBACK");
402
+ throw error;
403
+ }
404
+
405
+ return {
406
+ backfilledEvents,
407
+ skippedEvents,
408
+ filesScanned,
409
+ cutoffMs: earliestExistingTimestampMs,
410
+ };
411
+ };
412
+
413
+ module.exports = {
414
+ getBackfillStatus,
415
+ backfillFromTranscripts,
416
+ };
@@ -6,6 +6,7 @@ const { getDailySummary } = require("./summary");
6
6
  const { getSessionsList, getSessionDetail } = require("./sessions");
7
7
  const { getSessionTimeSeries } = require("./timeseries");
8
8
  const { kGlobalModelPricing } = require("./pricing");
9
+ const { getBackfillStatus, backfillFromTranscripts } = require("./backfill");
9
10
 
10
11
  let db = null;
11
12
  let usageDbPath = "";
@@ -31,5 +32,8 @@ module.exports = {
31
32
  getSessionDetail: (options = {}) => getSessionDetail({ database: ensureDb(), ...options }),
32
33
  getSessionTimeSeries: (options = {}) =>
33
34
  getSessionTimeSeries({ database: ensureDb(), ...options }),
35
+ getBackfillStatus: (options = {}) => getBackfillStatus({ database: ensureDb(), ...options }),
36
+ backfillFromTranscripts: (options = {}) =>
37
+ backfillFromTranscripts({ database: ensureDb(), ...options }),
34
38
  kGlobalModelPricing,
35
39
  };
@@ -1,10 +1,33 @@
1
1
  const topicRegistry = require("../topic-registry");
2
2
  const { parsePositiveInt } = require("../utils/number");
3
+ const fs = require("fs");
4
+ const path = require("path");
3
5
 
4
6
  const kSummaryCacheTtlMs = 60 * 1000;
5
7
  const kClientTimeZoneHeader = "x-client-timezone";
6
8
 
7
9
  const createSummaryCache = () => new Map();
10
+ const readOnboardingMarker = ({ onboardingMarkerPath, fsModule = fs }) => {
11
+ const safePath = String(onboardingMarkerPath || "").trim();
12
+ if (!safePath || !fsModule.existsSync(safePath)) return {};
13
+ try {
14
+ const parsed = JSON.parse(fsModule.readFileSync(safePath, "utf8"));
15
+ return parsed && typeof parsed === "object" ? parsed : {};
16
+ } catch {
17
+ return {};
18
+ }
19
+ };
20
+
21
+ const writeOnboardingMarker = ({
22
+ onboardingMarkerPath,
23
+ marker,
24
+ fsModule = fs,
25
+ }) => {
26
+ const safePath = String(onboardingMarkerPath || "").trim();
27
+ if (!safePath) return;
28
+ fsModule.mkdirSync(path.dirname(safePath), { recursive: true });
29
+ fsModule.writeFileSync(safePath, JSON.stringify(marker || {}, null, 2));
30
+ };
8
31
  const toTitleLabel = (value) => {
9
32
  const raw = String(value || "").trim();
10
33
  if (!raw) return "";
@@ -92,6 +115,11 @@ const registerUsageRoutes = ({
92
115
  getSessionsList,
93
116
  getSessionDetail,
94
117
  getSessionTimeSeries,
118
+ getBackfillStatus,
119
+ backfillFromTranscripts,
120
+ openclawDir = "",
121
+ onboardingMarkerPath = "",
122
+ fsModule = fs,
95
123
  }) => {
96
124
  const summaryCache = createSummaryCache();
97
125
 
@@ -151,6 +179,58 @@ const registerUsageRoutes = ({
151
179
  res.status(500).json({ ok: false, error: err.message });
152
180
  }
153
181
  });
182
+
183
+ app.get("/api/usage/backfill/status", requireAuth, (req, res) => {
184
+ try {
185
+ const marker = readOnboardingMarker({ onboardingMarkerPath, fsModule });
186
+ if (String(marker?.usageBackfilledAt || "").trim()) {
187
+ res.json({ ok: true, available: false, estimatedFiles: 0 });
188
+ return;
189
+ }
190
+ const status = getBackfillStatus({ openclawDir, fsModule });
191
+ res.json({
192
+ ok: true,
193
+ available: !!status?.available,
194
+ estimatedFiles: Number(status?.estimatedFiles || 0),
195
+ });
196
+ } catch (err) {
197
+ res.status(500).json({ ok: false, error: err.message });
198
+ }
199
+ });
200
+
201
+ app.post("/api/usage/backfill", requireAuth, async (req, res) => {
202
+ try {
203
+ const marker = readOnboardingMarker({ onboardingMarkerPath, fsModule });
204
+ if (String(marker?.usageBackfilledAt || "").trim()) {
205
+ res.json({
206
+ ok: true,
207
+ alreadyBackfilled: true,
208
+ backfilledEvents: 0,
209
+ skippedEvents: 0,
210
+ filesScanned: 0,
211
+ });
212
+ return;
213
+ }
214
+ const result = await backfillFromTranscripts({ openclawDir, fsModule });
215
+ writeOnboardingMarker({
216
+ onboardingMarkerPath,
217
+ fsModule,
218
+ marker: {
219
+ ...marker,
220
+ onboarded:
221
+ typeof marker?.onboarded === "boolean" ? marker.onboarded : true,
222
+ reason: String(marker?.reason || "onboarding_complete"),
223
+ markedAt: String(marker?.markedAt || new Date().toISOString()),
224
+ usageBackfilledAt: new Date().toISOString(),
225
+ usageBackfilledEvents: Number(result?.backfilledEvents || 0),
226
+ },
227
+ });
228
+ summaryCache.clear();
229
+ res.json({ ok: true, ...result });
230
+ } catch (err) {
231
+ res.status(500).json({ ok: false, error: err.message });
232
+ }
233
+ });
154
234
  };
155
235
 
156
236
  module.exports = { registerUsageRoutes };
package/lib/server.js CHANGED
@@ -42,6 +42,8 @@ const {
42
42
  getSessionsList,
43
43
  getSessionDetail,
44
44
  getSessionTimeSeries,
45
+ getBackfillStatus,
46
+ backfillFromTranscripts,
45
47
  } = require("./server/db/usage");
46
48
  const topicRegistry = require("./server/topic-registry");
47
49
  const {
@@ -373,6 +375,11 @@ registerUsageRoutes({
373
375
  getSessionsList,
374
376
  getSessionDetail,
375
377
  getSessionTimeSeries,
378
+ getBackfillStatus,
379
+ backfillFromTranscripts,
380
+ openclawDir: constants.OPENCLAW_DIR,
381
+ onboardingMarkerPath: constants.kOnboardingMarkerPath,
382
+ fsModule: fs,
376
383
  });
377
384
  registerDoctorRoutes({
378
385
  app,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrysb/alphaclaw",
3
- "version": "0.5.0",
3
+ "version": "0.5.1-beta.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },