@wukazis/euphony 0.1.45 → 0.1.47

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.
@@ -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,22 @@ 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:";
439
+ const CLAUDE_SESSIONS_URL = "claude:sessions";
440
+ const CLAUDE_SESSION_URL_PREFIX = "claude:session:";
438
441
  const CODEX_SESSIONS_DIR = resolve(
439
442
  process.env.CODEX_SESSIONS_DIR ?? join(process.env.HOME ?? process.cwd(), ".codex", "sessions")
440
443
  );
444
+ const CLAUDE_PROJECTS_DIR = resolve(
445
+ process.env.CLAUDE_PROJECTS_DIR ?? join(process.env.HOME ?? process.cwd(), ".claude", "projects")
446
+ );
441
447
  const MAX_PUBLIC_JSON_BYTES = 25 * 1024 * 1024;
442
448
  const MAX_CODEX_SESSION_BYTES = 25 * 1024 * 1024;
449
+ const MAX_CODEX_SEARCH_PREVIEW_BYTES = 256 * 1024;
450
+ const CODEX_USAGE_TAIL_CHUNK_BYTES = 64 * 1024;
451
+ const MAX_CODEX_USAGE_TAIL_BYTES = 2 * 1024 * 1024;
452
+ const CODEX_USAGE_CACHE_TTL_MS = 60 * 1e3;
443
453
  const MAX_JSON_BODY_BYTES = 2 * 1024 * 1024;
444
454
  const TRANSLATION_CACHE_TTL_MS = 5 * 60 * 60 * 1e3;
445
455
  const TRANSLATION_MAX_CONCURRENCY = 1024;
@@ -453,6 +463,8 @@ class HttpError extends Error {
453
463
  }
454
464
  const translationCache = /* @__PURE__ */ new Map();
455
465
  const inflightTranslations = /* @__PURE__ */ new Map();
466
+ const codexUsageCache = /* @__PURE__ */ new Map();
467
+ const inflightCodexUsage = /* @__PURE__ */ new Map();
456
468
  let activeTranslations = 0;
457
469
  const translationWaiters = [];
458
470
  const normalizeLimit = (value, fallback) => {
@@ -463,6 +475,24 @@ const normalizeOffset = (value) => {
463
475
  const parsed = Number.parseInt(value ?? "", 10);
464
476
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0;
465
477
  };
478
+ const normalizeCodexUsageRange = (value) => {
479
+ if (value === "7d" || value === "30d" || value === "1y") {
480
+ return value;
481
+ }
482
+ return "30d";
483
+ };
484
+ const formatNumberPart = (value, length = 2) => value.toString().padStart(length, "0");
485
+ const formatLocalDateBucket = (timestamp) => `${timestamp.getFullYear()}-${formatNumberPart(timestamp.getMonth() + 1)}-${formatNumberPart(timestamp.getDate())}`;
486
+ const codexUsageRangeStart = (range) => {
487
+ const dayMs = 24 * 60 * 60 * 1e3;
488
+ const start = /* @__PURE__ */ new Date();
489
+ const daysBack = range === "7d" ? 6 : range === "30d" ? 29 : 364;
490
+ start.setHours(0, 0, 0, 0);
491
+ return new Date(start.getTime() - daysBack * dayMs);
492
+ };
493
+ const codexUsageBucketKey = (timestamp, range) => {
494
+ return formatLocalDateBucket(timestamp);
495
+ };
466
496
  const isInsideDirectory = (root, candidate) => {
467
497
  const relative = resolve(candidate).slice(resolve(root).length);
468
498
  return candidate === root || relative.startsWith(sep) && !relative.slice(1).startsWith(`..${sep}`);
@@ -486,6 +516,21 @@ const parseJsonl = (text) => {
486
516
  }
487
517
  return events;
488
518
  };
519
+ const parseJsonlTolerant = (text) => {
520
+ const events = [];
521
+ for (const line of text.split(/\r?\n/)) {
522
+ const stripped = line.trim();
523
+ if (stripped.length === 0) {
524
+ continue;
525
+ }
526
+ try {
527
+ events.push(JSON.parse(stripped));
528
+ } catch {
529
+ continue;
530
+ }
531
+ }
532
+ return events;
533
+ };
489
534
  const parseJsonOrJsonl = (text) => {
490
535
  const stripped = text.trim();
491
536
  if (stripped.length === 0) {
@@ -498,13 +543,18 @@ const parseJsonOrJsonl = (text) => {
498
543
  return parseJsonl(text);
499
544
  }
500
545
  };
501
- const loadJsonlEvents = async (path) => {
546
+ const loadJsonlText = async (path) => {
502
547
  const fileStat = await stat(path);
503
548
  if (fileStat.size > MAX_CODEX_SESSION_BYTES) {
504
549
  throw new Error(`Codex session file is too large: ${path}`);
505
550
  }
506
- const text = stripBom(await readFile(path, "utf8"));
507
- return parseJsonl(text);
551
+ return stripBom(await readFile(path, "utf8"));
552
+ };
553
+ const loadJsonlEvents = async (path) => {
554
+ return parseJsonl(await loadJsonlText(path));
555
+ };
556
+ const loadJsonlEventsTolerant = async (path) => {
557
+ return parseJsonlTolerant(await loadJsonlText(path));
508
558
  };
509
559
  const resolveCodexSessionPath = (sessionRef) => {
510
560
  const relativePath = decodeURIComponent(sessionRef);
@@ -514,6 +564,14 @@ const resolveCodexSessionPath = (sessionRef) => {
514
564
  }
515
565
  return candidate;
516
566
  };
567
+ const resolveClaudeSessionPath = (sessionRef) => {
568
+ const relativePath = decodeURIComponent(sessionRef);
569
+ const candidate = resolve(CLAUDE_PROJECTS_DIR, relativePath);
570
+ if (!isInsideDirectory(CLAUDE_PROJECTS_DIR, candidate) || extname(candidate) !== ".jsonl") {
571
+ throw new HttpError(404, "Claude session not found");
572
+ }
573
+ return candidate;
574
+ };
517
575
  const parseTimestamp = (timestamp) => {
518
576
  if (typeof timestamp !== "string" || timestamp.length === 0) {
519
577
  return null;
@@ -522,6 +580,122 @@ const parseTimestamp = (timestamp) => {
522
580
  const parsed = new Date(normalized);
523
581
  return Number.isNaN(parsed.getTime()) ? null : parsed;
524
582
  };
583
+ const numberFromUsageField = (value) => typeof value === "number" && Number.isFinite(value) ? value : 0;
584
+ const emptyCodexTokenUsage = () => ({
585
+ input_tokens: 0,
586
+ cached_input_tokens: 0,
587
+ output_tokens: 0,
588
+ reasoning_output_tokens: 0,
589
+ total_tokens: 0
590
+ });
591
+ const addCodexTokenUsage = (target, source) => {
592
+ target.input_tokens += source.input_tokens;
593
+ target.cached_input_tokens += source.cached_input_tokens;
594
+ target.output_tokens += source.output_tokens;
595
+ target.reasoning_output_tokens += source.reasoning_output_tokens;
596
+ target.total_tokens += source.total_tokens;
597
+ };
598
+ const extractCodexTokenUsage = (value) => {
599
+ if (!value || typeof value !== "object") {
600
+ return null;
601
+ }
602
+ const record = value;
603
+ const usage = {
604
+ input_tokens: numberFromUsageField(record.input_tokens),
605
+ cached_input_tokens: numberFromUsageField(
606
+ record.cached_input_tokens ?? record.cached_tokens
607
+ ),
608
+ output_tokens: numberFromUsageField(record.output_tokens),
609
+ reasoning_output_tokens: numberFromUsageField(
610
+ record.reasoning_output_tokens
611
+ ),
612
+ total_tokens: numberFromUsageField(record.total_tokens)
613
+ };
614
+ if (usage.total_tokens === 0 && (usage.input_tokens > 0 || usage.output_tokens > 0)) {
615
+ usage.total_tokens = usage.input_tokens + usage.output_tokens;
616
+ }
617
+ return usage.total_tokens > 0 ? usage : null;
618
+ };
619
+ const extractCodexUsageSnapshotFromEvent = (event) => {
620
+ if (!event || typeof event !== "object") {
621
+ return null;
622
+ }
623
+ const record = event;
624
+ const payload = record.payload;
625
+ if (record.type !== "event_msg" || !payload || typeof payload !== "object") {
626
+ return null;
627
+ }
628
+ const payloadRecord = payload;
629
+ if (payloadRecord.type !== "token_count") {
630
+ return null;
631
+ }
632
+ const info = payloadRecord.info;
633
+ if (!info || typeof info !== "object") {
634
+ return null;
635
+ }
636
+ const usage = extractCodexTokenUsage(
637
+ info.total_token_usage
638
+ );
639
+ if (!usage) {
640
+ return null;
641
+ }
642
+ return {
643
+ usage,
644
+ timestamp: parseTimestamp(record.timestamp)
645
+ };
646
+ };
647
+ const extractCodexUsageSnapshotFromEvents = (events) => {
648
+ let snapshot = null;
649
+ for (const event of events) {
650
+ snapshot = extractCodexUsageSnapshotFromEvent(event) ?? snapshot;
651
+ }
652
+ return snapshot;
653
+ };
654
+ const extractClaudeUsageSnapshotFromEvents = (events) => {
655
+ const usage = emptyCodexTokenUsage();
656
+ const seenUsageIds = /* @__PURE__ */ new Set();
657
+ let timestamp = null;
658
+ for (const event of events) {
659
+ if (!event || typeof event !== "object") {
660
+ continue;
661
+ }
662
+ const record = event;
663
+ if (record.type !== "assistant") {
664
+ continue;
665
+ }
666
+ const message = record.message;
667
+ if (!message || typeof message !== "object") {
668
+ continue;
669
+ }
670
+ const messageRecord = message;
671
+ const rawUsage = messageRecord.usage;
672
+ if (!rawUsage || typeof rawUsage !== "object") {
673
+ continue;
674
+ }
675
+ const usageId = typeof messageRecord.id === "string" ? messageRecord.id : typeof record.requestId === "string" ? record.requestId : typeof record.uuid === "string" ? record.uuid : null;
676
+ if (usageId && seenUsageIds.has(usageId)) {
677
+ continue;
678
+ }
679
+ if (usageId) {
680
+ seenUsageIds.add(usageId);
681
+ }
682
+ const usageRecord = rawUsage;
683
+ const inputTokens = numberFromUsageField(usageRecord.input_tokens);
684
+ const cacheCreationTokens = numberFromUsageField(
685
+ usageRecord.cache_creation_input_tokens
686
+ );
687
+ const cacheReadTokens = numberFromUsageField(
688
+ usageRecord.cache_read_input_tokens
689
+ );
690
+ const outputTokens = numberFromUsageField(usageRecord.output_tokens);
691
+ usage.input_tokens += inputTokens;
692
+ usage.cached_input_tokens += cacheCreationTokens + cacheReadTokens;
693
+ usage.output_tokens += outputTokens;
694
+ usage.total_tokens += inputTokens + cacheCreationTokens + cacheReadTokens + outputTokens;
695
+ timestamp = parseTimestamp(record.timestamp) ?? timestamp;
696
+ }
697
+ return usage.total_tokens > 0 ? { usage, timestamp } : null;
698
+ };
525
699
  const extractTextFromContent = (content) => {
526
700
  if (typeof content === "string") {
527
701
  const stripped = content.trim();
@@ -557,6 +731,12 @@ const isDisplayableFirstMessage = (text) => {
557
731
  "<collaboration_mode>",
558
732
  "<model_switch>",
559
733
  "<environment_context>",
734
+ "<local-command-caveat>",
735
+ "<local-command-stdout>",
736
+ "<local-command-stderr>",
737
+ "<command-name>",
738
+ "<command-message>",
739
+ "<command-args>",
560
740
  "# AGENTS.md instructions"
561
741
  ].some((prefix) => stripped.startsWith(prefix));
562
742
  };
@@ -567,12 +747,215 @@ const cleanSummaryText = (text, maxLength = 240) => {
567
747
  return cleaned.length <= maxLength ? cleaned : `${cleaned.slice(0, maxLength - 3).trimEnd()}...`;
568
748
  };
569
749
  const relativeCodexPath = (path) => resolve(path).slice(resolve(CODEX_SESSIONS_DIR).length + 1).split(sep).join("/");
750
+ const relativeClaudePath = (path) => resolve(path).slice(resolve(CLAUDE_PROJECTS_DIR).length + 1).split(sep).join("/");
751
+ const normalizeCodexSearchQuery = (query) => query.trim().toLowerCase().split(/\s+/).filter(Boolean);
752
+ const extractDisplayableUserMessage = (record) => {
753
+ const payload = record.payload;
754
+ if (!payload || typeof payload !== "object") {
755
+ return "";
756
+ }
757
+ const payloadRecord = payload;
758
+ let candidate = "";
759
+ if (record.type === "response_item" && payloadRecord.role === "user") {
760
+ candidate = extractTextFromContent(payloadRecord.content) ?? "";
761
+ } else if (record.type === "event_msg" && payloadRecord.type === "user_message") {
762
+ candidate = extractTextFromContent(payloadRecord) ?? "";
763
+ }
764
+ return candidate.length > 0 && isDisplayableFirstMessage(candidate) ? candidate : "";
765
+ };
766
+ const extractClaudeDisplayableUserMessage = (record) => {
767
+ if (record.type !== "user" || record.isMeta === true) {
768
+ return "";
769
+ }
770
+ const message = record.message;
771
+ if (!message || typeof message !== "object") {
772
+ return "";
773
+ }
774
+ const messageRecord = message;
775
+ if (messageRecord.role !== "user") {
776
+ return "";
777
+ }
778
+ const candidate = extractTextFromContent(messageRecord.content) ?? "";
779
+ return candidate.length > 0 && isDisplayableFirstMessage(candidate) ? candidate : "";
780
+ };
781
+ const readCodexSessionFirstMessage = async (path) => {
782
+ const fileStat = await stat(path);
783
+ const previewByteLength = Math.min(
784
+ fileStat.size,
785
+ MAX_CODEX_SEARCH_PREVIEW_BYTES
786
+ );
787
+ const fileHandle = await open(path, "r");
788
+ let text = "";
789
+ try {
790
+ const buffer = Buffer.alloc(previewByteLength);
791
+ const { bytesRead } = await fileHandle.read(
792
+ buffer,
793
+ 0,
794
+ previewByteLength,
795
+ 0
796
+ );
797
+ text = stripBom(buffer.subarray(0, bytesRead).toString("utf8"));
798
+ } finally {
799
+ await fileHandle.close();
800
+ }
801
+ const lines = text.split(/\r?\n/);
802
+ if (fileStat.size > previewByteLength) {
803
+ lines.pop();
804
+ }
805
+ for (const line of lines) {
806
+ const stripped = line.trim();
807
+ if (stripped.length === 0) {
808
+ continue;
809
+ }
810
+ let event;
811
+ try {
812
+ event = JSON.parse(stripped);
813
+ } catch {
814
+ continue;
815
+ }
816
+ if (!event || typeof event !== "object") {
817
+ continue;
818
+ }
819
+ const candidate = extractDisplayableUserMessage(
820
+ event
821
+ );
822
+ if (candidate.length > 0) {
823
+ return cleanSummaryText(candidate, 1e3);
824
+ }
825
+ }
826
+ return "";
827
+ };
828
+ const readClaudeSessionFirstMessage = async (path) => {
829
+ const fileStat = await stat(path);
830
+ const previewByteLength = Math.min(
831
+ fileStat.size,
832
+ MAX_CODEX_SEARCH_PREVIEW_BYTES
833
+ );
834
+ const fileHandle = await open(path, "r");
835
+ let text = "";
836
+ try {
837
+ const buffer = Buffer.alloc(previewByteLength);
838
+ const { bytesRead } = await fileHandle.read(
839
+ buffer,
840
+ 0,
841
+ previewByteLength,
842
+ 0
843
+ );
844
+ text = stripBom(buffer.subarray(0, bytesRead).toString("utf8"));
845
+ } finally {
846
+ await fileHandle.close();
847
+ }
848
+ const lines = text.split(/\r?\n/);
849
+ if (fileStat.size > previewByteLength) {
850
+ lines.pop();
851
+ }
852
+ for (const line of lines) {
853
+ const stripped = line.trim();
854
+ if (stripped.length === 0) {
855
+ continue;
856
+ }
857
+ let event;
858
+ try {
859
+ event = JSON.parse(stripped);
860
+ } catch {
861
+ continue;
862
+ }
863
+ if (!event || typeof event !== "object") {
864
+ continue;
865
+ }
866
+ const candidate = extractClaudeDisplayableUserMessage(
867
+ event
868
+ );
869
+ if (candidate.length > 0) {
870
+ return cleanSummaryText(candidate, 1e3);
871
+ }
872
+ }
873
+ return "";
874
+ };
875
+ const readCodexUsageSnapshot = async (path) => {
876
+ const fileStat = await stat(path);
877
+ if (fileStat.size === 0) {
878
+ return null;
879
+ }
880
+ const fileHandle = await open(path, "r");
881
+ let cursor = fileStat.size;
882
+ let scannedBytes = 0;
883
+ let tailText = "";
884
+ try {
885
+ while (cursor > 0 && scannedBytes < MAX_CODEX_USAGE_TAIL_BYTES) {
886
+ const chunkSize = Math.min(
887
+ CODEX_USAGE_TAIL_CHUNK_BYTES,
888
+ cursor,
889
+ MAX_CODEX_USAGE_TAIL_BYTES - scannedBytes
890
+ );
891
+ cursor -= chunkSize;
892
+ const buffer = Buffer.alloc(chunkSize);
893
+ const { bytesRead } = await fileHandle.read(
894
+ buffer,
895
+ 0,
896
+ chunkSize,
897
+ cursor
898
+ );
899
+ if (bytesRead === 0) {
900
+ break;
901
+ }
902
+ scannedBytes += bytesRead;
903
+ tailText = buffer.subarray(0, bytesRead).toString("utf8") + tailText;
904
+ const lines = tailText.split(/\r?\n/);
905
+ const firstCompleteLineIndex = cursor > 0 ? 1 : 0;
906
+ for (let i = lines.length - 1; i >= firstCompleteLineIndex; i--) {
907
+ const stripped = stripBom(lines[i]).trim();
908
+ if (stripped.length === 0 || !stripped.includes('"token_count"')) {
909
+ continue;
910
+ }
911
+ let event;
912
+ try {
913
+ event = JSON.parse(stripped);
914
+ } catch {
915
+ continue;
916
+ }
917
+ const snapshot = extractCodexUsageSnapshotFromEvent(event);
918
+ if (snapshot) {
919
+ return snapshot;
920
+ }
921
+ }
922
+ }
923
+ } finally {
924
+ await fileHandle.close();
925
+ }
926
+ return null;
927
+ };
928
+ const codexSessionMatchesSearch = async (path, searchTerms) => {
929
+ if (searchTerms.length === 0) {
930
+ return true;
931
+ }
932
+ const relativePath = relativeCodexPath(path).toLowerCase();
933
+ if (searchTerms.every((term) => relativePath.includes(term))) {
934
+ return true;
935
+ }
936
+ const searchableText = `${relativePath}
937
+ ${await readCodexSessionFirstMessage(path)}`.toLowerCase();
938
+ return searchTerms.every((term) => searchableText.includes(term));
939
+ };
940
+ const claudeSessionMatchesSearch = async (path, searchTerms) => {
941
+ if (searchTerms.length === 0) {
942
+ return true;
943
+ }
944
+ const relativePath = relativeClaudePath(path).toLowerCase();
945
+ if (searchTerms.every((term) => relativePath.includes(term))) {
946
+ return true;
947
+ }
948
+ const searchableText = `${relativePath}
949
+ ${await readClaudeSessionFirstMessage(path)}`.toLowerCase();
950
+ return searchTerms.every((term) => searchableText.includes(term));
951
+ };
570
952
  const summarizeCodexSession = async (path) => {
571
953
  const events = await loadJsonlEvents(path);
572
954
  const relativePath = relativeCodexPath(path);
573
955
  const encodedPath = encodeURIComponent(relativePath);
574
956
  let sessionTimestamp = null;
575
957
  let firstMessage = "";
958
+ const usageSnapshot = extractCodexUsageSnapshotFromEvents(events);
576
959
  for (const event of events) {
577
960
  if (!event || typeof event !== "object") {
578
961
  continue;
@@ -596,12 +979,7 @@ const summarizeCodexSession = async (path) => {
596
979
  if (firstMessage.length > 0) {
597
980
  continue;
598
981
  }
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
- }
982
+ const candidate = extractDisplayableUserMessage(record);
605
983
  if (candidate.length > 0 && isDisplayableFirstMessage(candidate)) {
606
984
  firstMessage = cleanSummaryText(candidate);
607
985
  }
@@ -623,7 +1001,59 @@ const summarizeCodexSession = async (path) => {
623
1001
  first_message: firstMessage,
624
1002
  timestamp: sessionTimestamp.toISOString(),
625
1003
  age_seconds: ageSeconds,
626
- event_count: events.length
1004
+ event_count: events.length,
1005
+ token_usage: usageSnapshot?.usage ?? null,
1006
+ usage_timestamp: usageSnapshot?.timestamp?.toISOString() ?? null
1007
+ };
1008
+ };
1009
+ const summarizeClaudeSession = async (path) => {
1010
+ const events = await loadJsonlEventsTolerant(path);
1011
+ const relativePath = relativeClaudePath(path);
1012
+ const encodedPath = encodeURIComponent(relativePath);
1013
+ let sessionTimestamp = null;
1014
+ let firstMessage = "";
1015
+ let aiTitle = "";
1016
+ const usageSnapshot = extractClaudeUsageSnapshotFromEvents(events);
1017
+ for (const event of events) {
1018
+ if (!event || typeof event !== "object") {
1019
+ continue;
1020
+ }
1021
+ const record = event;
1022
+ const eventTimestamp = parseTimestamp(record.timestamp) ?? (record.snapshot && typeof record.snapshot === "object" ? parseTimestamp(record.snapshot.timestamp) : null);
1023
+ if (sessionTimestamp === null && eventTimestamp !== null) {
1024
+ sessionTimestamp = eventTimestamp;
1025
+ }
1026
+ if (record.type === "ai-title" && typeof record.aiTitle === "string" && aiTitle.length === 0) {
1027
+ aiTitle = cleanSummaryText(record.aiTitle);
1028
+ }
1029
+ if (firstMessage.length > 0) {
1030
+ continue;
1031
+ }
1032
+ const candidate = extractClaudeDisplayableUserMessage(record);
1033
+ if (candidate.length > 0 && isDisplayableFirstMessage(candidate)) {
1034
+ firstMessage = cleanSummaryText(candidate);
1035
+ }
1036
+ }
1037
+ if (firstMessage.length === 0) {
1038
+ firstMessage = aiTitle || "(no user message)";
1039
+ }
1040
+ if (sessionTimestamp === null) {
1041
+ const fileStat = await stat(path);
1042
+ sessionTimestamp = new Date(fileStat.mtimeMs);
1043
+ }
1044
+ const ageSeconds = Math.max(
1045
+ 0,
1046
+ Math.floor((Date.now() - sessionTimestamp.getTime()) / 1e3)
1047
+ );
1048
+ return {
1049
+ path: relativePath,
1050
+ load_url: `${CLAUDE_SESSION_URL_PREFIX}${encodedPath}`,
1051
+ first_message: firstMessage,
1052
+ timestamp: sessionTimestamp.toISOString(),
1053
+ age_seconds: ageSeconds,
1054
+ event_count: events.length,
1055
+ token_usage: usageSnapshot?.usage ?? null,
1056
+ usage_timestamp: usageSnapshot?.timestamp?.toISOString() ?? null
627
1057
  };
628
1058
  };
629
1059
  const findJsonlFiles = async (root) => {
@@ -658,7 +1088,8 @@ const findJsonlFiles = async (root) => {
658
1088
  withStats.sort((a, b) => b.mtime - a.mtime || b.path.localeCompare(a.path));
659
1089
  return withStats.map((item) => item.path);
660
1090
  };
661
- const getCodexSessions = async (offset, limit) => {
1091
+ const getCodexSessions = async (offset, limit, searchQuery = "") => {
1092
+ const searchTerms = normalizeCodexSearchQuery(searchQuery);
662
1093
  try {
663
1094
  const rootStat = await stat(CODEX_SESSIONS_DIR);
664
1095
  if (!rootStat.isDirectory()) {
@@ -670,13 +1101,27 @@ const getCodexSessions = async (offset, limit) => {
670
1101
  offset,
671
1102
  limit,
672
1103
  total: 0,
673
- isFiltered: false,
1104
+ isFiltered: searchTerms.length > 0,
674
1105
  matchedCount: 0,
675
1106
  resolvedURL: CODEX_SESSIONS_URL
676
1107
  };
677
1108
  }
678
1109
  const files = await findJsonlFiles(CODEX_SESSIONS_DIR);
679
- const pageFiles = files.slice(offset, offset + limit);
1110
+ const matchedFiles = [];
1111
+ if (searchTerms.length === 0) {
1112
+ matchedFiles.push(...files);
1113
+ } else {
1114
+ for (const path of files) {
1115
+ try {
1116
+ if (await codexSessionMatchesSearch(path, searchTerms)) {
1117
+ matchedFiles.push(path);
1118
+ }
1119
+ } catch (error) {
1120
+ console.error(`Failed to search Codex session file: ${path}`, error);
1121
+ }
1122
+ }
1123
+ }
1124
+ const pageFiles = matchedFiles.slice(offset, offset + limit);
680
1125
  const summaries = [];
681
1126
  for (const path of pageFiles) {
682
1127
  try {
@@ -690,11 +1135,141 @@ const getCodexSessions = async (offset, limit) => {
690
1135
  offset,
691
1136
  limit,
692
1137
  total: files.length,
693
- isFiltered: false,
694
- matchedCount: files.length,
1138
+ isFiltered: searchTerms.length > 0,
1139
+ matchedCount: matchedFiles.length,
695
1140
  resolvedURL: CODEX_SESSIONS_URL
696
1141
  };
697
1142
  };
1143
+ const getClaudeSessions = async (offset, limit, searchQuery = "") => {
1144
+ const searchTerms = normalizeCodexSearchQuery(searchQuery);
1145
+ try {
1146
+ const rootStat = await stat(CLAUDE_PROJECTS_DIR);
1147
+ if (!rootStat.isDirectory()) {
1148
+ throw new Error("Not a directory");
1149
+ }
1150
+ } catch {
1151
+ return {
1152
+ data: [],
1153
+ offset,
1154
+ limit,
1155
+ total: 0,
1156
+ isFiltered: searchTerms.length > 0,
1157
+ matchedCount: 0,
1158
+ resolvedURL: CLAUDE_SESSIONS_URL
1159
+ };
1160
+ }
1161
+ const files = await findJsonlFiles(CLAUDE_PROJECTS_DIR);
1162
+ const matchedFiles = [];
1163
+ if (searchTerms.length === 0) {
1164
+ matchedFiles.push(...files);
1165
+ } else {
1166
+ for (const path of files) {
1167
+ try {
1168
+ if (await claudeSessionMatchesSearch(path, searchTerms)) {
1169
+ matchedFiles.push(path);
1170
+ }
1171
+ } catch (error) {
1172
+ console.error(`Failed to search Claude session file: ${path}`, error);
1173
+ }
1174
+ }
1175
+ }
1176
+ const pageFiles = matchedFiles.slice(offset, offset + limit);
1177
+ const summaries = [];
1178
+ for (const path of pageFiles) {
1179
+ try {
1180
+ summaries.push(await summarizeClaudeSession(path));
1181
+ } catch (error) {
1182
+ console.error(`Failed to load Claude session file: ${path}`, error);
1183
+ }
1184
+ }
1185
+ return {
1186
+ data: summaries,
1187
+ offset,
1188
+ limit,
1189
+ total: files.length,
1190
+ isFiltered: searchTerms.length > 0,
1191
+ matchedCount: matchedFiles.length,
1192
+ resolvedURL: CLAUDE_SESSIONS_URL
1193
+ };
1194
+ };
1195
+ const buildCodexUsageDays = async (range) => {
1196
+ try {
1197
+ const rootStat = await stat(CODEX_SESSIONS_DIR);
1198
+ if (!rootStat.isDirectory()) {
1199
+ throw new Error("Not a directory");
1200
+ }
1201
+ } catch {
1202
+ return [];
1203
+ }
1204
+ const files = await findJsonlFiles(CODEX_SESSIONS_DIR);
1205
+ const usageByDay = /* @__PURE__ */ new Map();
1206
+ const rangeStart = codexUsageRangeStart(range);
1207
+ const now = /* @__PURE__ */ new Date();
1208
+ for (const path of files) {
1209
+ try {
1210
+ const snapshot = await readCodexUsageSnapshot(path);
1211
+ if (!snapshot) {
1212
+ continue;
1213
+ }
1214
+ const fileStat = await stat(path);
1215
+ const timestamp = snapshot.timestamp ?? new Date(fileStat.mtimeMs);
1216
+ if (timestamp < rangeStart || timestamp > now) {
1217
+ continue;
1218
+ }
1219
+ const date = codexUsageBucketKey(timestamp, range);
1220
+ const dayUsage = usageByDay.get(date) ?? {
1221
+ date,
1222
+ session_count: 0,
1223
+ ...emptyCodexTokenUsage()
1224
+ };
1225
+ dayUsage.session_count += 1;
1226
+ addCodexTokenUsage(dayUsage, snapshot.usage);
1227
+ usageByDay.set(date, dayUsage);
1228
+ } catch (error) {
1229
+ console.error(`Failed to read Codex usage from file: ${path}`, error);
1230
+ }
1231
+ }
1232
+ const days = [...usageByDay.values()].sort(
1233
+ (a, b) => b.date.localeCompare(a.date)
1234
+ );
1235
+ return days;
1236
+ };
1237
+ const getCodexUsageDays = async (range, noCache) => {
1238
+ if (noCache) {
1239
+ codexUsageCache.delete(range);
1240
+ }
1241
+ const cached = codexUsageCache.get(range);
1242
+ if (cached && cached.expiresAt > Date.now()) {
1243
+ return cached.days;
1244
+ }
1245
+ const inflight = inflightCodexUsage.get(range);
1246
+ if (inflight) {
1247
+ return inflight;
1248
+ }
1249
+ const promise = buildCodexUsageDays(range).then((days) => {
1250
+ codexUsageCache.set(range, {
1251
+ expiresAt: Date.now() + CODEX_USAGE_CACHE_TTL_MS,
1252
+ days
1253
+ });
1254
+ return days;
1255
+ }).finally(() => {
1256
+ inflightCodexUsage.delete(range);
1257
+ });
1258
+ inflightCodexUsage.set(range, promise);
1259
+ return promise;
1260
+ };
1261
+ const getCodexUsage = async (offset, limit, range, noCache) => {
1262
+ const days = await getCodexUsageDays(range, noCache);
1263
+ return {
1264
+ data: days.slice(offset, offset + limit),
1265
+ offset,
1266
+ limit,
1267
+ total: days.length,
1268
+ isFiltered: false,
1269
+ matchedCount: days.length,
1270
+ resolvedURL: CODEX_USAGE_URL
1271
+ };
1272
+ };
698
1273
  const getCodexSession = async (sessionRef, offset, limit) => {
699
1274
  const path = resolveCodexSessionPath(sessionRef);
700
1275
  try {
@@ -718,6 +1293,29 @@ const getCodexSession = async (sessionRef, offset, limit) => {
718
1293
  resolvedURL
719
1294
  };
720
1295
  };
1296
+ const getClaudeSession = async (sessionRef, offset, limit) => {
1297
+ const path = resolveClaudeSessionPath(sessionRef);
1298
+ try {
1299
+ const fileStat = await stat(path);
1300
+ if (!fileStat.isFile()) {
1301
+ throw new Error("Not a file");
1302
+ }
1303
+ } catch (error) {
1304
+ throw new HttpError(404, "Claude session not found");
1305
+ }
1306
+ const events = await loadJsonlEventsTolerant(path);
1307
+ const relativePath = relativeClaudePath(path);
1308
+ const resolvedURL = `${CLAUDE_SESSION_URL_PREFIX}${encodeURIComponent(relativePath)}`;
1309
+ return {
1310
+ data: events.slice(offset, offset + limit),
1311
+ offset,
1312
+ limit,
1313
+ total: events.length,
1314
+ isFiltered: false,
1315
+ matchedCount: events.length,
1316
+ resolvedURL
1317
+ };
1318
+ };
721
1319
  const readRemoteResponseBody = async (response) => {
722
1320
  const chunks = [];
723
1321
  let totalBytes = 0;
@@ -1146,8 +1744,35 @@ const handleBlobJsonl = async (req, res, url) => {
1146
1744
  const limit = normalizeLimit(url.searchParams.get("limit"), 10);
1147
1745
  const noCache = url.searchParams.get("noCache") === "true";
1148
1746
  const jmespathQuery = url.searchParams.get("jmespathQuery") ?? "";
1747
+ const codexSearchQuery = url.searchParams.get("codexSearchQuery") ?? "";
1748
+ const codexUsageRange = normalizeCodexUsageRange(
1749
+ url.searchParams.get("codexUsageRange")
1750
+ );
1149
1751
  if (blobURL === CODEX_SESSIONS_URL) {
1150
- await sendJson(req, res, 200, await getCodexSessions(offset, limit));
1752
+ await sendJson(
1753
+ req,
1754
+ res,
1755
+ 200,
1756
+ await getCodexSessions(offset, limit, codexSearchQuery)
1757
+ );
1758
+ return;
1759
+ }
1760
+ if (blobURL === CLAUDE_SESSIONS_URL) {
1761
+ await sendJson(
1762
+ req,
1763
+ res,
1764
+ 200,
1765
+ await getClaudeSessions(offset, limit, codexSearchQuery)
1766
+ );
1767
+ return;
1768
+ }
1769
+ if (blobURL === CODEX_USAGE_URL) {
1770
+ await sendJson(
1771
+ req,
1772
+ res,
1773
+ 200,
1774
+ await getCodexUsage(offset, limit, codexUsageRange, noCache)
1775
+ );
1151
1776
  return;
1152
1777
  }
1153
1778
  if (blobURL.startsWith(CODEX_SESSION_URL_PREFIX)) {
@@ -1163,6 +1788,19 @@ const handleBlobJsonl = async (req, res, url) => {
1163
1788
  );
1164
1789
  return;
1165
1790
  }
1791
+ if (blobURL.startsWith(CLAUDE_SESSION_URL_PREFIX)) {
1792
+ await sendJson(
1793
+ req,
1794
+ res,
1795
+ 200,
1796
+ await getClaudeSession(
1797
+ blobURL.slice(CLAUDE_SESSION_URL_PREFIX.length),
1798
+ offset,
1799
+ limit
1800
+ )
1801
+ );
1802
+ return;
1803
+ }
1166
1804
  await sendJson(
1167
1805
  req,
1168
1806
  res,
@@ -1262,6 +1900,10 @@ const startServer = (options = {}) => {
1262
1900
  console.log(
1263
1901
  `Local Codex sessions: http://${host}:${port}/?path=codex%3Asessions`
1264
1902
  );
1903
+ console.log(
1904
+ `Local Claude sessions: http://${host}:${port}/?path=claude%3Asessions`
1905
+ );
1906
+ console.log(`Codex usage: http://${host}:${port}/?path=codex%3Ausage`);
1265
1907
  });
1266
1908
  return server;
1267
1909
  };