@wukazis/euphony 0.1.45 → 0.1.46

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/dist/index.html CHANGED
@@ -13,7 +13,7 @@
13
13
  <title>Euphony: Visualize Chat Data in Your Browser</title>
14
14
 
15
15
  <link rel="stylesheet" href="/global.css" />
16
- <script type="module" crossorigin src="/assets/main-CmldcHcT.js"></script>
16
+ <script type="module" crossorigin src="/assets/main-DS3CAMrw.js"></script>
17
17
  </head>
18
18
 
19
19
  <body>
@@ -1,5 +1,5 @@
1
1
  import { LitElement, PropertyValues } from 'lit';
2
- import { CodexSessionSummary } from '../../types/common-types';
2
+ import { CodexSessionSummary, CodexUsageDay } from '../../types/common-types';
3
3
  import { Conversation } from '../../types/harmony-types';
4
4
  import { APIManager, BrowserAPIManager } from '../../utils/api-manager';
5
5
  import { EuphonyCodex } from '../codex/codex';
@@ -21,10 +21,13 @@ declare enum DataType {
21
21
  CONVERSATION = "conversation",
22
22
  CODEX = "codex",
23
23
  CODEX_SESSION_INDEX = "codex-session-index",
24
+ CODEX_USAGE = "codex-usage",
24
25
  JSON = "json"
25
26
  }
26
- type MenuItems = 'Load without cache' | 'Load Codex sessions' | 'Load from clipboard' | 'Load local file' | 'Editor mode' | 'Leave editor mode' | 'Filter data' | 'Preferences' | 'Code';
27
+ type MenuItems = 'Load without cache' | 'Load Codex sessions' | 'Usage' | 'Load from clipboard' | 'Load local file' | 'Editor mode' | 'Leave editor mode' | 'Filter data' | 'Preferences' | 'Code';
28
+ type CodexUsageRange = '7d' | '30d' | '1y';
27
29
  type ConversationViewerElement = EuphonyConversation | EuphonyCodex;
30
+ export declare const isCodexUsageRange: (value: string) => value is CodexUsageRange;
28
31
  /**
29
32
  * App element.
30
33
  *
@@ -35,12 +38,17 @@ export declare class EuphonyApp extends LitElement {
35
38
  JSONData: Record<string, unknown>[];
36
39
  codexSessionData: unknown[][];
37
40
  codexSessionSummaries: CodexSessionSummary[];
41
+ codexUsageDays: CodexUsageDay[];
42
+ codexUsageChartDays: CodexUsageDay[];
43
+ codexUsageRange: CodexUsageRange;
38
44
  dataType: DataType;
39
45
  isLoadingData: boolean;
40
46
  curPage: number;
41
47
  globalIsShowingMetadata: boolean;
42
48
  globalShouldRenderMarkdown: boolean;
43
49
  jmespathQuery: string;
50
+ codexSearchQuery: string;
51
+ codexSearchInputValue: string;
44
52
  focusModeAuthor: string[];
45
53
  focusModeRecipient: string[];
46
54
  focusModeContentType: string[];
@@ -114,6 +122,7 @@ export declare class EuphonyApp extends LitElement {
114
122
  selectAllButtonClicked(): void;
115
123
  updatePageNumber(newPageNumber: number, scrollToTop: boolean): Promise<void>;
116
124
  loadCodexSessionsClicked(): Promise<void>;
125
+ loadCodexUsageClicked(): Promise<void>;
117
126
  openCodexSessionSummary(summary: CodexSessionSummary): Promise<void>;
118
127
  pageClicked(e: CustomEvent<number>): void;
119
128
  itemsPerPageChanged(e: CustomEvent<number>): void;
@@ -133,6 +142,10 @@ export declare class EuphonyApp extends LitElement {
133
142
  preferenceWindowTranslateAllClicked(): void;
134
143
  preferenceWindowFocusModeSettingsChanged(e: CustomEvent<FocusModeSettings>): void;
135
144
  searchWindowQuerySubmitted(e: CustomEvent<string>): Promise<void>;
145
+ codexSessionSearchInputChanged(e: Event): void;
146
+ codexSessionSearchSubmitted(e: Event): Promise<void>;
147
+ clearCodexSessionSearch(): Promise<void>;
148
+ codexUsageRangeChanged(e: Event): Promise<void>;
136
149
  /**
137
150
  * Show the token window when user clicks on the harmony render button
138
151
  * @param e CustomEvent<string> - The custom event containing the conversation string
@@ -165,21 +178,27 @@ export declare class EuphonyApp extends LitElement {
165
178
  loadDataFromFile: (sourceFile: File) => Promise<void>;
166
179
  localDataWorkerMessageHandler(e: MessageEvent<LocalDataWorkerMessage>): void;
167
180
  localFileInputChanged(e: Event): void;
168
- loadData: ({ blobURL, offset, limit, showSuccessToast, noCache, jmespathQuery }: {
181
+ loadData: ({ blobURL, offset, limit, showSuccessToast, noCache, jmespathQuery, codexSearchQuery, codexUsageRange }: {
169
182
  blobURL: string;
170
183
  offset: number;
171
184
  limit: number;
172
185
  showSuccessToast?: boolean;
173
186
  noCache?: boolean;
174
187
  jmespathQuery?: string;
188
+ codexSearchQuery?: string;
189
+ codexUsageRange?: CodexUsageRange;
175
190
  }) => Promise<{
176
191
  isLoadDataSuccessful: boolean;
177
192
  loadDataMessage: string;
178
193
  loadedURL: string;
179
194
  }>;
180
- resetFilter: (filter: "jmespath" | "concept") => Promise<void>;
195
+ resetFilter: (filter: "jmespath" | "codexSearch" | "concept") => Promise<void>;
181
196
  resetHash: () => void;
182
197
  buildEuphonyStyle(styleConfig: Record<string, string>): string;
198
+ getCodexUsageBucketKey(date: Date, range: CodexUsageRange): string;
199
+ getCodexUsageBucketLabel(bucketKey: string, range?: CodexUsageRange): string;
200
+ buildCodexUsageChartDays(): CodexUsageDay[];
201
+ renderCodexUsageChart(): import('lit').TemplateResult<1>;
183
202
  getConversationViewerElements(): ConversationViewerElement[];
184
203
  render(): import('lit').TemplateResult<1>;
185
204
  static styles: import('lit').CSSResult[];
@@ -49,10 +49,23 @@ export interface CodexSessionSummary {
49
49
  timestamp: string | null;
50
50
  age_seconds: number | null;
51
51
  event_count: number;
52
+ token_usage: CodexTokenUsage | null;
53
+ usage_timestamp: string | null;
54
+ }
55
+ export interface CodexTokenUsage {
56
+ input_tokens: number;
57
+ cached_input_tokens: number;
58
+ output_tokens: number;
59
+ reasoning_output_tokens: number;
60
+ total_tokens: number;
61
+ }
62
+ export interface CodexUsageDay extends CodexTokenUsage {
63
+ date: string;
64
+ session_count: number;
52
65
  }
53
66
  export interface BlobJSONLPayload {
54
67
  total: number;
55
- data: string[] | Conversation[] | Record<string, unknown>[] | unknown[][] | CodexSessionSummary[];
68
+ data: string[] | Conversation[] | Record<string, unknown>[] | unknown[][] | CodexSessionSummary[] | CodexUsageDay[];
56
69
  isFiltered: boolean;
57
70
  matchedCount: number;
58
71
  resolvedURL: string;
@@ -61,7 +74,7 @@ export interface BlobJSONLResponse {
61
74
  offset: number;
62
75
  limit: number;
63
76
  total: number;
64
- data: string[] | Conversation[] | Record<string, Conversation | string>[] | unknown[][] | CodexSessionSummary[];
77
+ data: string[] | Conversation[] | Record<string, Conversation | string>[] | unknown[][] | CodexSessionSummary[] | CodexUsageDay[];
65
78
  isFiltered: boolean;
66
79
  matchedCount: number;
67
80
  resolvedURL: string;
@@ -5,23 +5,27 @@ export declare const extractConversationFromJSONL: (data: unknown[]) => Conversa
5
5
  export declare class APIManager {
6
6
  apiBaseURL: string;
7
7
  constructor(apiBaseURL: string);
8
- getJSONL: ({ blobURL, offset, limit, noCache, jmespathQuery }: {
8
+ getJSONL: ({ blobURL, offset, limit, noCache, jmespathQuery, codexSearchQuery, codexUsageRange }: {
9
9
  blobURL: string;
10
10
  offset: number;
11
11
  limit: number;
12
12
  noCache: boolean;
13
13
  jmespathQuery: string;
14
+ codexSearchQuery?: string;
15
+ codexUsageRange?: string;
14
16
  }) => Promise<BlobJSONLPayload>;
15
17
  refreshRendererList: () => Promise<string[]>;
16
18
  harmonyRender: (conversation: string, renderer: string) => Promise<HarmonyRenderResponse>;
17
19
  }
18
20
  export declare class BrowserAPIManager {
19
- getJSONL: ({ blobURL, offset, limit, noCache, jmespathQuery }: {
21
+ getJSONL: ({ blobURL, offset, limit, noCache, jmespathQuery, codexSearchQuery, codexUsageRange }: {
20
22
  blobURL: string;
21
23
  offset: number;
22
24
  limit: number;
23
25
  noCache: boolean;
24
26
  jmespathQuery: string;
27
+ codexSearchQuery?: string;
28
+ codexUsageRange?: string;
25
29
  }) => Promise<BlobJSONLPayload>;
26
30
  validateOpenAIAPIKey(apiKey: string): Promise<boolean>;
27
31
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wukazis/euphony",
3
3
  "private": false,
4
- "version": "0.1.45",
4
+ "version": "0.1.46",
5
5
  "type": "module",
6
6
  "types": "./lib/euphony.d.ts",
7
7
  "exports": {
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
- import { stat, readFile, readdir } from "node:fs/promises";
3
+ import { stat, readdir, open, readFile } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import { dirname, resolve, join, extname, basename, sep } from "node:path";
6
6
  import { fileURLToPath, pathToFileURL } from "node:url";
@@ -434,12 +434,17 @@ const SERVER_DIR = dirname(fileURLToPath(import.meta.url));
434
434
  const PACKAGE_ROOT = resolve(SERVER_DIR, "..");
435
435
  const DIST_DIR = resolve(PACKAGE_ROOT, "dist");
436
436
  const CODEX_SESSIONS_URL = "codex:sessions";
437
+ const CODEX_USAGE_URL = "codex:usage";
437
438
  const CODEX_SESSION_URL_PREFIX = "codex:session:";
438
439
  const CODEX_SESSIONS_DIR = resolve(
439
440
  process.env.CODEX_SESSIONS_DIR ?? join(process.env.HOME ?? process.cwd(), ".codex", "sessions")
440
441
  );
441
442
  const MAX_PUBLIC_JSON_BYTES = 25 * 1024 * 1024;
442
443
  const MAX_CODEX_SESSION_BYTES = 25 * 1024 * 1024;
444
+ const MAX_CODEX_SEARCH_PREVIEW_BYTES = 256 * 1024;
445
+ const CODEX_USAGE_TAIL_CHUNK_BYTES = 64 * 1024;
446
+ const MAX_CODEX_USAGE_TAIL_BYTES = 2 * 1024 * 1024;
447
+ const CODEX_USAGE_CACHE_TTL_MS = 60 * 1e3;
443
448
  const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024;
444
449
  const TRANSLATION_CACHE_TTL_MS = 5 * 60 * 60 * 1e3;
445
450
  const TRANSLATION_MAX_CONCURRENCY = 1024;
@@ -453,6 +458,8 @@ class HttpError extends Error {
453
458
  }
454
459
  const translationCache = /* @__PURE__ */ new Map();
455
460
  const inflightTranslations = /* @__PURE__ */ new Map();
461
+ const codexUsageCache = /* @__PURE__ */ new Map();
462
+ const inflightCodexUsage = /* @__PURE__ */ new Map();
456
463
  let activeTranslations = 0;
457
464
  const translationWaiters = [];
458
465
  const normalizeLimit = (value, fallback) => {
@@ -463,6 +470,24 @@ const normalizeOffset = (value) => {
463
470
  const parsed = Number.parseInt(value ?? "", 10);
464
471
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
465
472
  };
473
+ const normalizeCodexUsageRange = (value) => {
474
+ if (value === "7d" || value === "30d" || value === "1y") {
475
+ return value;
476
+ }
477
+ return "30d";
478
+ };
479
+ const formatNumberPart = (value, length = 2) => value.toString().padStart(length, "0");
480
+ const formatLocalDateBucket = (timestamp) => `${timestamp.getFullYear()}-${formatNumberPart(timestamp.getMonth() + 1)}-${formatNumberPart(timestamp.getDate())}`;
481
+ const codexUsageRangeStart = (range) => {
482
+ const dayMs = 24 * 60 * 60 * 1e3;
483
+ const start = /* @__PURE__ */ new Date();
484
+ const daysBack = range === "7d" ? 6 : range === "30d" ? 29 : 364;
485
+ start.setHours(0, 0, 0, 0);
486
+ return new Date(start.getTime() - daysBack * dayMs);
487
+ };
488
+ const codexUsageBucketKey = (timestamp, range) => {
489
+ return formatLocalDateBucket(timestamp);
490
+ };
466
491
  const isInsideDirectory = (root, candidate) => {
467
492
  const relative = resolve(candidate).slice(resolve(root).length);
468
493
  return candidate === root || relative.startsWith(sep) && !relative.slice(1).startsWith(`..${sep}`);
@@ -498,13 +523,15 @@ const parseJsonOrJsonl = (text) => {
498
523
  return parseJsonl(text);
499
524
  }
500
525
  };
501
- const loadJsonlEvents = async (path) => {
526
+ const loadJsonlText = async (path) => {
502
527
  const fileStat = await stat(path);
503
528
  if (fileStat.size > MAX_CODEX_SESSION_BYTES) {
504
529
  throw new Error(`Codex session file is too large: ${path}`);
505
530
  }
506
- const text = stripBom(await readFile(path, "utf8"));
507
- return parseJsonl(text);
531
+ return stripBom(await readFile(path, "utf8"));
532
+ };
533
+ const loadJsonlEvents = async (path) => {
534
+ return parseJsonl(await loadJsonlText(path));
508
535
  };
509
536
  const resolveCodexSessionPath = (sessionRef) => {
510
537
  const relativePath = decodeURIComponent(sessionRef);
@@ -522,6 +549,77 @@ const parseTimestamp = (timestamp) => {
522
549
  const parsed = new Date(normalized);
523
550
  return Number.isNaN(parsed.getTime()) ? null : parsed;
524
551
  };
552
+ const numberFromUsageField = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
553
+ const emptyCodexTokenUsage = () => ({
554
+ input_tokens: 0,
555
+ cached_input_tokens: 0,
556
+ output_tokens: 0,
557
+ reasoning_output_tokens: 0,
558
+ total_tokens: 0
559
+ });
560
+ const addCodexTokenUsage = (target, source) => {
561
+ target.input_tokens += source.input_tokens;
562
+ target.cached_input_tokens += source.cached_input_tokens;
563
+ target.output_tokens += source.output_tokens;
564
+ target.reasoning_output_tokens += source.reasoning_output_tokens;
565
+ target.total_tokens += source.total_tokens;
566
+ };
567
+ const extractCodexTokenUsage = (value) => {
568
+ if (!value || typeof value !== "object") {
569
+ return null;
570
+ }
571
+ const record = value;
572
+ const usage = {
573
+ input_tokens: numberFromUsageField(record.input_tokens),
574
+ cached_input_tokens: numberFromUsageField(
575
+ record.cached_input_tokens ?? record.cached_tokens
576
+ ),
577
+ output_tokens: numberFromUsageField(record.output_tokens),
578
+ reasoning_output_tokens: numberFromUsageField(
579
+ record.reasoning_output_tokens
580
+ ),
581
+ total_tokens: numberFromUsageField(record.total_tokens)
582
+ };
583
+ if (usage.total_tokens === 0 && (usage.input_tokens > 0 || usage.output_tokens > 0)) {
584
+ usage.total_tokens = usage.input_tokens + usage.output_tokens;
585
+ }
586
+ return usage.total_tokens > 0 ? usage : null;
587
+ };
588
+ const extractCodexUsageSnapshotFromEvent = (event) => {
589
+ if (!event || typeof event !== "object") {
590
+ return null;
591
+ }
592
+ const record = event;
593
+ const payload = record.payload;
594
+ if (record.type !== "event_msg" || !payload || typeof payload !== "object") {
595
+ return null;
596
+ }
597
+ const payloadRecord = payload;
598
+ if (payloadRecord.type !== "token_count") {
599
+ return null;
600
+ }
601
+ const info = payloadRecord.info;
602
+ if (!info || typeof info !== "object") {
603
+ return null;
604
+ }
605
+ const usage = extractCodexTokenUsage(
606
+ info.total_token_usage
607
+ );
608
+ if (!usage) {
609
+ return null;
610
+ }
611
+ return {
612
+ usage,
613
+ timestamp: parseTimestamp(record.timestamp)
614
+ };
615
+ };
616
+ const extractCodexUsageSnapshotFromEvents = (events) => {
617
+ let snapshot = null;
618
+ for (const event of events) {
619
+ snapshot = extractCodexUsageSnapshotFromEvent(event) ?? snapshot;
620
+ }
621
+ return snapshot;
622
+ };
525
623
  const extractTextFromContent = (content) => {
526
624
  if (typeof content === "string") {
527
625
  const stripped = content.trim();
@@ -567,12 +665,140 @@ const cleanSummaryText = (text, maxLength = 240) => {
567
665
  return cleaned.length <= maxLength ? cleaned : `${cleaned.slice(0, maxLength - 3).trimEnd()}...`;
568
666
  };
569
667
  const relativeCodexPath = (path) => resolve(path).slice(resolve(CODEX_SESSIONS_DIR).length + 1).split(sep).join("/");
668
+ const normalizeCodexSearchQuery = (query) => query.trim().toLowerCase().split(/\s+/).filter(Boolean);
669
+ const extractDisplayableUserMessage = (record) => {
670
+ const payload = record.payload;
671
+ if (!payload || typeof payload !== "object") {
672
+ return "";
673
+ }
674
+ const payloadRecord = payload;
675
+ let candidate = "";
676
+ if (record.type === "response_item" && payloadRecord.role === "user") {
677
+ candidate = extractTextFromContent(payloadRecord.content) ?? "";
678
+ } else if (record.type === "event_msg" && payloadRecord.type === "user_message") {
679
+ candidate = extractTextFromContent(payloadRecord) ?? "";
680
+ }
681
+ return candidate.length > 0 && isDisplayableFirstMessage(candidate) ? candidate : "";
682
+ };
683
+ const readCodexSessionFirstMessage = async (path) => {
684
+ const fileStat = await stat(path);
685
+ const previewByteLength = Math.min(
686
+ fileStat.size,
687
+ MAX_CODEX_SEARCH_PREVIEW_BYTES
688
+ );
689
+ const fileHandle = await open(path, "r");
690
+ let text = "";
691
+ try {
692
+ const buffer = Buffer.alloc(previewByteLength);
693
+ const { bytesRead } = await fileHandle.read(
694
+ buffer,
695
+ 0,
696
+ previewByteLength,
697
+ 0
698
+ );
699
+ text = stripBom(buffer.subarray(0, bytesRead).toString("utf8"));
700
+ } finally {
701
+ await fileHandle.close();
702
+ }
703
+ const lines = text.split(/\r?\n/);
704
+ if (fileStat.size > previewByteLength) {
705
+ lines.pop();
706
+ }
707
+ for (const line of lines) {
708
+ const stripped = line.trim();
709
+ if (stripped.length === 0) {
710
+ continue;
711
+ }
712
+ let event;
713
+ try {
714
+ event = JSON.parse(stripped);
715
+ } catch {
716
+ continue;
717
+ }
718
+ if (!event || typeof event !== "object") {
719
+ continue;
720
+ }
721
+ const candidate = extractDisplayableUserMessage(
722
+ event
723
+ );
724
+ if (candidate.length > 0) {
725
+ return cleanSummaryText(candidate, 1e3);
726
+ }
727
+ }
728
+ return "";
729
+ };
730
+ const readCodexUsageSnapshot = async (path) => {
731
+ const fileStat = await stat(path);
732
+ if (fileStat.size === 0) {
733
+ return null;
734
+ }
735
+ const fileHandle = await open(path, "r");
736
+ let cursor = fileStat.size;
737
+ let scannedBytes = 0;
738
+ let tailText = "";
739
+ try {
740
+ while (cursor > 0 && scannedBytes < MAX_CODEX_USAGE_TAIL_BYTES) {
741
+ const chunkSize = Math.min(
742
+ CODEX_USAGE_TAIL_CHUNK_BYTES,
743
+ cursor,
744
+ MAX_CODEX_USAGE_TAIL_BYTES - scannedBytes
745
+ );
746
+ cursor -= chunkSize;
747
+ const buffer = Buffer.alloc(chunkSize);
748
+ const { bytesRead } = await fileHandle.read(
749
+ buffer,
750
+ 0,
751
+ chunkSize,
752
+ cursor
753
+ );
754
+ if (bytesRead === 0) {
755
+ break;
756
+ }
757
+ scannedBytes += bytesRead;
758
+ tailText = buffer.subarray(0, bytesRead).toString("utf8") + tailText;
759
+ const lines = tailText.split(/\r?\n/);
760
+ const firstCompleteLineIndex = cursor > 0 ? 1 : 0;
761
+ for (let i = lines.length - 1; i >= firstCompleteLineIndex; i--) {
762
+ const stripped = stripBom(lines[i]).trim();
763
+ if (stripped.length === 0 || !stripped.includes('"token_count"')) {
764
+ continue;
765
+ }
766
+ let event;
767
+ try {
768
+ event = JSON.parse(stripped);
769
+ } catch {
770
+ continue;
771
+ }
772
+ const snapshot = extractCodexUsageSnapshotFromEvent(event);
773
+ if (snapshot) {
774
+ return snapshot;
775
+ }
776
+ }
777
+ }
778
+ } finally {
779
+ await fileHandle.close();
780
+ }
781
+ return null;
782
+ };
783
+ const codexSessionMatchesSearch = async (path, searchTerms) => {
784
+ if (searchTerms.length === 0) {
785
+ return true;
786
+ }
787
+ const relativePath = relativeCodexPath(path).toLowerCase();
788
+ if (searchTerms.every((term) => relativePath.includes(term))) {
789
+ return true;
790
+ }
791
+ const searchableText = `${relativePath}
792
+ ${await readCodexSessionFirstMessage(path)}`.toLowerCase();
793
+ return searchTerms.every((term) => searchableText.includes(term));
794
+ };
570
795
  const summarizeCodexSession = async (path) => {
571
796
  const events = await loadJsonlEvents(path);
572
797
  const relativePath = relativeCodexPath(path);
573
798
  const encodedPath = encodeURIComponent(relativePath);
574
799
  let sessionTimestamp = null;
575
800
  let firstMessage = "";
801
+ const usageSnapshot = extractCodexUsageSnapshotFromEvents(events);
576
802
  for (const event of events) {
577
803
  if (!event || typeof event !== "object") {
578
804
  continue;
@@ -596,12 +822,7 @@ const summarizeCodexSession = async (path) => {
596
822
  if (firstMessage.length > 0) {
597
823
  continue;
598
824
  }
599
- let candidate = "";
600
- if (record.type === "response_item" && payloadRecord.role === "user") {
601
- candidate = extractTextFromContent(payloadRecord.content) ?? "";
602
- } else if (record.type === "event_msg" && payloadRecord.type === "user_message") {
603
- candidate = extractTextFromContent(payloadRecord) ?? "";
604
- }
825
+ const candidate = extractDisplayableUserMessage(record);
605
826
  if (candidate.length > 0 && isDisplayableFirstMessage(candidate)) {
606
827
  firstMessage = cleanSummaryText(candidate);
607
828
  }
@@ -623,7 +844,9 @@ const summarizeCodexSession = async (path) => {
623
844
  first_message: firstMessage,
624
845
  timestamp: sessionTimestamp.toISOString(),
625
846
  age_seconds: ageSeconds,
626
- event_count: events.length
847
+ event_count: events.length,
848
+ token_usage: usageSnapshot?.usage ?? null,
849
+ usage_timestamp: usageSnapshot?.timestamp?.toISOString() ?? null
627
850
  };
628
851
  };
629
852
  const findJsonlFiles = async (root) => {
@@ -658,7 +881,8 @@ const findJsonlFiles = async (root) => {
658
881
  withStats.sort((a, b) => b.mtime - a.mtime || b.path.localeCompare(a.path));
659
882
  return withStats.map((item) => item.path);
660
883
  };
661
- const getCodexSessions = async (offset, limit) => {
884
+ const getCodexSessions = async (offset, limit, searchQuery = "") => {
885
+ const searchTerms = normalizeCodexSearchQuery(searchQuery);
662
886
  try {
663
887
  const rootStat = await stat(CODEX_SESSIONS_DIR);
664
888
  if (!rootStat.isDirectory()) {
@@ -670,13 +894,27 @@ const getCodexSessions = async (offset, limit) => {
670
894
  offset,
671
895
  limit,
672
896
  total: 0,
673
- isFiltered: false,
897
+ isFiltered: searchTerms.length > 0,
674
898
  matchedCount: 0,
675
899
  resolvedURL: CODEX_SESSIONS_URL
676
900
  };
677
901
  }
678
902
  const files = await findJsonlFiles(CODEX_SESSIONS_DIR);
679
- const pageFiles = files.slice(offset, offset + limit);
903
+ const matchedFiles = [];
904
+ if (searchTerms.length === 0) {
905
+ matchedFiles.push(...files);
906
+ } else {
907
+ for (const path of files) {
908
+ try {
909
+ if (await codexSessionMatchesSearch(path, searchTerms)) {
910
+ matchedFiles.push(path);
911
+ }
912
+ } catch (error) {
913
+ console.error(`Failed to search Codex session file: ${path}`, error);
914
+ }
915
+ }
916
+ }
917
+ const pageFiles = matchedFiles.slice(offset, offset + limit);
680
918
  const summaries = [];
681
919
  for (const path of pageFiles) {
682
920
  try {
@@ -690,11 +928,89 @@ const getCodexSessions = async (offset, limit) => {
690
928
  offset,
691
929
  limit,
692
930
  total: files.length,
693
- isFiltered: false,
694
- matchedCount: files.length,
931
+ isFiltered: searchTerms.length > 0,
932
+ matchedCount: matchedFiles.length,
695
933
  resolvedURL: CODEX_SESSIONS_URL
696
934
  };
697
935
  };
936
+ const buildCodexUsageDays = async (range) => {
937
+ try {
938
+ const rootStat = await stat(CODEX_SESSIONS_DIR);
939
+ if (!rootStat.isDirectory()) {
940
+ throw new Error("Not a directory");
941
+ }
942
+ } catch {
943
+ return [];
944
+ }
945
+ const files = await findJsonlFiles(CODEX_SESSIONS_DIR);
946
+ const usageByDay = /* @__PURE__ */ new Map();
947
+ const rangeStart = codexUsageRangeStart(range);
948
+ const now = /* @__PURE__ */ new Date();
949
+ for (const path of files) {
950
+ try {
951
+ const snapshot = await readCodexUsageSnapshot(path);
952
+ if (!snapshot) {
953
+ continue;
954
+ }
955
+ const fileStat = await stat(path);
956
+ const timestamp = snapshot.timestamp ?? new Date(fileStat.mtimeMs);
957
+ if (timestamp < rangeStart || timestamp > now) {
958
+ continue;
959
+ }
960
+ const date = codexUsageBucketKey(timestamp, range);
961
+ const dayUsage = usageByDay.get(date) ?? {
962
+ date,
963
+ session_count: 0,
964
+ ...emptyCodexTokenUsage()
965
+ };
966
+ dayUsage.session_count += 1;
967
+ addCodexTokenUsage(dayUsage, snapshot.usage);
968
+ usageByDay.set(date, dayUsage);
969
+ } catch (error) {
970
+ console.error(`Failed to read Codex usage from file: ${path}`, error);
971
+ }
972
+ }
973
+ const days = [...usageByDay.values()].sort(
974
+ (a, b) => b.date.localeCompare(a.date)
975
+ );
976
+ return days;
977
+ };
978
+ const getCodexUsageDays = async (range, noCache) => {
979
+ if (noCache) {
980
+ codexUsageCache.delete(range);
981
+ }
982
+ const cached = codexUsageCache.get(range);
983
+ if (cached && cached.expiresAt > Date.now()) {
984
+ return cached.days;
985
+ }
986
+ const inflight = inflightCodexUsage.get(range);
987
+ if (inflight) {
988
+ return inflight;
989
+ }
990
+ const promise = buildCodexUsageDays(range).then((days) => {
991
+ codexUsageCache.set(range, {
992
+ expiresAt: Date.now() + CODEX_USAGE_CACHE_TTL_MS,
993
+ days
994
+ });
995
+ return days;
996
+ }).finally(() => {
997
+ inflightCodexUsage.delete(range);
998
+ });
999
+ inflightCodexUsage.set(range, promise);
1000
+ return promise;
1001
+ };
1002
+ const getCodexUsage = async (offset, limit, range, noCache) => {
1003
+ const days = await getCodexUsageDays(range, noCache);
1004
+ return {
1005
+ data: days.slice(offset, offset + limit),
1006
+ offset,
1007
+ limit,
1008
+ total: days.length,
1009
+ isFiltered: false,
1010
+ matchedCount: days.length,
1011
+ resolvedURL: CODEX_USAGE_URL
1012
+ };
1013
+ };
698
1014
  const getCodexSession = async (sessionRef, offset, limit) => {
699
1015
  const path = resolveCodexSessionPath(sessionRef);
700
1016
  try {
@@ -1146,8 +1462,26 @@ const handleBlobJsonl = async (req, res, url) => {
1146
1462
  const limit = normalizeLimit(url.searchParams.get("limit"), 10);
1147
1463
  const noCache = url.searchParams.get("noCache") === "true";
1148
1464
  const jmespathQuery = url.searchParams.get("jmespathQuery") ?? "";
1465
+ const codexSearchQuery = url.searchParams.get("codexSearchQuery") ?? "";
1466
+ const codexUsageRange = normalizeCodexUsageRange(
1467
+ url.searchParams.get("codexUsageRange")
1468
+ );
1149
1469
  if (blobURL === CODEX_SESSIONS_URL) {
1150
- await sendJson(req, res, 200, await getCodexSessions(offset, limit));
1470
+ await sendJson(
1471
+ req,
1472
+ res,
1473
+ 200,
1474
+ await getCodexSessions(offset, limit, codexSearchQuery)
1475
+ );
1476
+ return;
1477
+ }
1478
+ if (blobURL === CODEX_USAGE_URL) {
1479
+ await sendJson(
1480
+ req,
1481
+ res,
1482
+ 200,
1483
+ await getCodexUsage(offset, limit, codexUsageRange, noCache)
1484
+ );
1151
1485
  return;
1152
1486
  }
1153
1487
  if (blobURL.startsWith(CODEX_SESSION_URL_PREFIX)) {
@@ -1262,6 +1596,7 @@ const startServer = (options = {}) => {
1262
1596
  console.log(
1263
1597
  `Local Codex sessions: http://${host}:${port}/?path=codex%3Asessions`
1264
1598
  );
1599
+ console.log(`Codex usage: http://${host}:${port}/?path=codex%3Ausage`);
1265
1600
  });
1266
1601
  return server;
1267
1602
  };