codex-work-receipt 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/core/args.mjs CHANGED
@@ -1,15 +1,22 @@
1
+ import { normalizeScope } from "./range.mjs";
2
+
1
3
  export function printHelp(locale = "zh-CN") {
2
4
  console.log(locale === "en" ? `
3
5
  Codex AI Work Receipt
4
6
 
5
7
  Usage:
8
+ npx codex-work-receipt@latest
6
9
  npx codex-work-receipt@latest --latest --lang en
7
10
  npx codex-work-receipt@latest --today --lang en
11
+ npx codex-work-receipt@latest --range last-7-days --lang en
12
+ npx codex-work-receipt@latest --range this-week --lang en
8
13
  npx codex-work-receipt@latest --install-skill --lang en
9
14
 
10
15
  Options:
16
+ --range <name> Range: latest, today, last-7-days, this-week
11
17
  --latest Summarize the latest active Codex session (default)
12
18
  --today Summarize all Codex activity from today
19
+ --session <id> Summarize one specific Codex session
13
20
  --timezone <name> Use an IANA timezone, for example Asia/Shanghai
14
21
  --lang <name> Receipt language: zh-CN, en
15
22
  --theme <name> Default theme: classic, diner, payroll
@@ -22,13 +29,18 @@ Options:
22
29
  Codex AI 打工小票
23
30
 
24
31
  用法:
32
+ npx codex-work-receipt@latest
25
33
  npx codex-work-receipt@latest --latest
26
34
  npx codex-work-receipt@latest --today
35
+ npx codex-work-receipt@latest --range last-7-days
36
+ npx codex-work-receipt@latest --range this-week
27
37
  npx codex-work-receipt@latest --install-skill
28
38
 
29
39
  选项:
40
+ --range <name> 统计范围:latest、today、last-7-days、this-week
30
41
  --latest 统计最近活跃的 Codex 会话(默认)
31
42
  --today 统计本地时区今天发生的全部 Codex 活动
43
+ --session <id> 统计指定的 Codex 会话
32
44
  --timezone <name> 指定 IANA 时区,例如 Asia/Shanghai
33
45
  --lang <name> 小票语言:zh-CN、en
34
46
  --theme <name> 默认主题:classic、diner、payroll
@@ -43,6 +55,8 @@ Codex AI 打工小票
43
55
  export function parseArgs(argv) {
44
56
  const result = {
45
57
  mode: "latest",
58
+ modeExplicit: false,
59
+ sessionId: null,
46
60
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai",
47
61
  locale: "zh-CN",
48
62
  theme: "classic",
@@ -58,19 +72,36 @@ export function parseArgs(argv) {
58
72
  ["--theme", "theme"],
59
73
  ["--output", "output"],
60
74
  ["--data-dir", "dataDir"],
75
+ ["--session", "sessionId"],
61
76
  ]);
62
77
 
63
78
  for (let index = 0; index < argv.length; index += 1) {
64
79
  const argument = argv[index];
65
- if (argument === "--latest") result.mode = "latest";
66
- else if (argument === "--today") result.mode = "today";
67
- else if (argument === "--install-skill") result.installSkill = true;
80
+ if (argument === "--latest") {
81
+ result.mode = "latest";
82
+ result.modeExplicit = true;
83
+ } else if (argument === "--today") {
84
+ result.mode = "today";
85
+ result.modeExplicit = true;
86
+ } else if (argument === "--range") {
87
+ const value = argv[++index];
88
+ if (!value) throw new Error("--range 需要提供值");
89
+ const scope = normalizeScope(value);
90
+ if (!scope || scope === "session") throw new Error(`不支持的统计范围:${value}`);
91
+ result.mode = scope;
92
+ result.modeExplicit = true;
93
+ } else if (argument === "--install-skill") result.installSkill = true;
68
94
  else if (argument === "--no-open") result.open = false;
69
95
  else if (argument === "--help" || argument === "-h") result.help = true;
70
96
  else if (optionsWithValues.has(argument)) {
71
97
  const value = argv[++index];
72
98
  if (!value) throw new Error(`${argument} 需要提供值`);
73
- result[optionsWithValues.get(argument)] = value;
99
+ const key = optionsWithValues.get(argument);
100
+ result[key] = value;
101
+ if (key === "sessionId") {
102
+ result.mode = "session";
103
+ result.modeExplicit = true;
104
+ }
74
105
  } else throw new Error(`不认识的参数:${argument}`);
75
106
  }
76
107
 
@@ -1,4 +1,5 @@
1
1
  import { dateKey, rowDate } from "../lib/time.mjs";
2
+ import { calendarDayCount, isCalendarScope, isDateInRange } from "./range.mjs";
2
3
  import { selectWorkProfileId } from "./presentation.mjs";
3
4
 
4
5
  function zeroUsage() {
@@ -23,26 +24,26 @@ function addUsage(target, source) {
23
24
  for (const key of Object.keys(target)) target[key] += Number(source[key] || 0);
24
25
  }
25
26
 
26
- function sessionTokenUsage(rows, mode, targetDate, timezone) {
27
+ function sessionTokenUsage(rows, range) {
27
28
  const events = rows
28
29
  .filter((row) => row.type === "event_msg" && row.payload?.type === "token_count")
29
30
  .filter((row) => row.payload?.info?.total_token_usage)
30
31
  .sort((left, right) => (rowDate(left)?.getTime() || 0) - (rowDate(right)?.getTime() || 0));
31
32
 
32
33
  if (!events.length) return zeroUsage();
33
- if (mode === "latest") return { ...zeroUsage(), ...events.at(-1).payload.info.total_token_usage };
34
+ if (!isCalendarScope(range.scope)) {
35
+ return { ...zeroUsage(), ...events.at(-1).payload.info.total_token_usage };
36
+ }
34
37
 
35
- const lastToday = events.filter((row) => {
36
- const date = rowDate(row);
37
- return date && dateKey(date, timezone) === targetDate;
38
- }).at(-1);
39
- if (!lastToday) return zeroUsage();
38
+ const lastInRange = events.filter((row) => isDateInRange(rowDate(row), range)).at(-1);
39
+ if (!lastInRange) return zeroUsage();
40
40
 
41
41
  const baseline = events.filter((row) => {
42
42
  const date = rowDate(row);
43
- return date && dateKey(date, timezone) < targetDate;
43
+ return date && dateKey(date, range.timezone) < range.startDate;
44
44
  }).at(-1)?.payload.info.total_token_usage || zeroUsage();
45
- return subtractUsage(lastToday.payload.info.total_token_usage, baseline);
45
+
46
+ return subtractUsage(lastInRange.payload.info.total_token_usage, baseline);
46
47
  }
47
48
 
48
49
  function calculateWorkPoints(metrics) {
@@ -60,8 +61,14 @@ function calculateWorkPoints(metrics) {
60
61
  return Math.max(0, Math.round(points));
61
62
  }
62
63
 
63
- export function collectMetrics(sessions, mode, timezone) {
64
- const targetDate = dateKey(new Date(), timezone);
64
+ function emptyRangeMessage(range) {
65
+ if (range.scope === "today") return `没有找到 ${range.targetDate} Codex 活动`;
66
+ if (range.scope === "last-7-days") return `没有找到 ${range.startDate} 至 ${range.endDate} 的 Codex 活动`;
67
+ if (range.scope === "this-week") return `本周暂时没有找到 Codex 活动`;
68
+ return "没有找到可统计的 Codex 会话";
69
+ }
70
+
71
+ export function collectMetrics(sessions, range) {
65
72
  const scopedSessions = [];
66
73
  const sessionIds = [];
67
74
  const tokens = zeroUsage();
@@ -73,24 +80,25 @@ export function collectMetrics(sessions, mode, timezone) {
73
80
  let totalFirstTokenMs = 0;
74
81
  let firstTokenSamples = 0;
75
82
  const timestamps = [];
83
+ const activeDateKeys = new Set();
76
84
  const models = new Set();
77
85
 
78
86
  for (const session of sessions) {
79
- const scopedRows = mode === "latest"
80
- ? session.rows
81
- : session.rows.filter((row) => {
82
- const date = rowDate(row);
83
- return date && dateKey(date, timezone) === targetDate;
84
- });
87
+ const scopedRows = isCalendarScope(range.scope)
88
+ ? session.rows.filter((row) => isDateInRange(rowDate(row), range))
89
+ : session.rows;
85
90
 
86
91
  if (!scopedRows.length) continue;
87
92
  scopedSessions.push(session);
88
93
  sessionIds.push(session.sessionId);
89
- addUsage(tokens, sessionTokenUsage(session.rows, mode, targetDate, timezone));
94
+ addUsage(tokens, sessionTokenUsage(session.rows, range));
90
95
 
91
96
  for (const row of scopedRows) {
92
97
  const date = rowDate(row);
93
- if (date) timestamps.push(date);
98
+ if (date) {
99
+ timestamps.push(date);
100
+ activeDateKeys.add(dateKey(date, range.timezone));
101
+ }
94
102
 
95
103
  if (row.type === "turn_context" && row.payload?.model) models.add(row.payload.model);
96
104
  if (row.type === "event_msg") {
@@ -121,15 +129,19 @@ export function collectMetrics(sessions, mode, timezone) {
121
129
  if (fallbackModel) models.add(fallbackModel);
122
130
  }
123
131
 
124
- if (!scopedSessions.length) {
125
- throw new Error(mode === "today" ? `没有找到 ${targetDate} 的 Codex 活动` : "没有找到可统计的 Codex 会话");
126
- }
132
+ if (!scopedSessions.length || !timestamps.length) throw new Error(emptyRangeMessage(range));
127
133
 
128
134
  timestamps.sort((left, right) => left - right);
135
+ const rangeStartDate = range.startDate || dateKey(timestamps[0], range.timezone);
136
+ const rangeEndDate = range.endDate || dateKey(timestamps.at(-1), range.timezone);
129
137
  const metrics = {
130
- mode,
131
- timezone,
132
- targetDate,
138
+ mode: range.scope,
139
+ timezone: range.timezone,
140
+ targetDate: range.targetDate,
141
+ rangeStartDate,
142
+ rangeEndDate,
143
+ calendarDayCount: isCalendarScope(range.scope) ? calendarDayCount(range) : 1,
144
+ activeDayCount: Math.max(1, activeDateKeys.size),
133
145
  sessionIds,
134
146
  sessionCount: scopedSessions.length,
135
147
  startAt: timestamps[0],
@@ -88,12 +88,16 @@ const RECEIPT_COPY = {
88
88
  },
89
89
  scope: {
90
90
  latest: "最近一次会话",
91
+ session: "指定会话",
91
92
  today: "今日全部会话",
93
+ "last-7-days": "最近 7 个自然日",
94
+ "this-week": "本周全部会话",
92
95
  },
93
96
  modelMissing: "未记录",
94
97
  meta: {
95
98
  date: "日期",
96
99
  hours: "营业时段",
100
+ period: "统计周期",
97
101
  number: "小票编号",
98
102
  timezone: "时区",
99
103
  },
@@ -146,12 +150,16 @@ const RECEIPT_COPY = {
146
150
  },
147
151
  scope: {
148
152
  latest: "Latest session",
153
+ session: "Selected session",
149
154
  today: "All sessions today",
155
+ "last-7-days": "Last 7 calendar days",
156
+ "this-week": "All sessions this week",
150
157
  },
151
158
  modelMissing: "Not recorded",
152
159
  meta: {
153
160
  date: "Date",
154
161
  hours: "Work hours",
162
+ period: "Period",
155
163
  number: "Receipt No.",
156
164
  timezone: "Timezone",
157
165
  },
@@ -196,25 +204,32 @@ const RECEIPT_COPY = {
196
204
  const COMPENSATION_COPY = {
197
205
  "zh-CN": {
198
206
  latest: "本单工资",
207
+ session: "本单工资",
199
208
  today: "本日工资",
209
+ "last-7-days": "近七日工资",
210
+ "this-week": "本周工资",
200
211
  unit: "AI 工分",
201
212
  note: "按轮次、工具调用、Token 和改需求次数娱乐折算,不代表真实费用。",
202
213
  },
203
214
  en: {
204
215
  latest: "SHIFT PAY",
216
+ session: "SHIFT PAY",
205
217
  today: "TODAY'S PAY",
218
+ "last-7-days": "7-DAY PAY",
219
+ "this-week": "THIS WEEK'S PAY",
206
220
  unit: "AI work pts",
207
221
  note: "A playful score based on turns, tool calls, Tokens, and interruptions. Not a real charge.",
208
222
  },
209
223
  };
210
224
 
211
225
  export function selectWorkProfileId(metrics) {
212
- if (metrics.interruptions >= 3) return "change-request-survivor";
213
- if (metrics.toolCalls >= 40) return "toolchain-commander";
214
- if (metrics.tokens.total_tokens >= 500_000) return "context-devouring-beast";
215
- if (metrics.completedTurns >= 12) return "continuous-delivery-machine";
216
- if (metrics.workDurationMs >= 60 * 60 * 1000) return "night-shift-companion";
217
- if (metrics.completedTurns >= 5) return "steady-progress-partner";
226
+ const scale = Math.max(1, Number(metrics.activeDayCount || 1));
227
+ if (metrics.interruptions >= 3 * scale) return "change-request-survivor";
228
+ if (metrics.toolCalls >= 40 * scale) return "toolchain-commander";
229
+ if (metrics.tokens.total_tokens >= 500_000 * scale) return "context-devouring-beast";
230
+ if (metrics.completedTurns >= 12 * scale) return "continuous-delivery-machine";
231
+ if (metrics.workDurationMs >= 60 * 60 * 1000 * scale) return "night-shift-companion";
232
+ if (metrics.completedTurns >= 5 * scale) return "steady-progress-partner";
218
233
  return "temporary-hire";
219
234
  }
220
235
 
@@ -230,7 +245,7 @@ export function getReceiptCopy(locale = DEFAULT_LOCALE) {
230
245
  export function buildCompensation(scope, amount, locale = DEFAULT_LOCALE) {
231
246
  const copy = COMPENSATION_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
232
247
  return {
233
- label: scope === "today" ? copy.today : copy.latest,
248
+ label: copy[scope] || copy.latest,
234
249
  amount: Number(amount || 0),
235
250
  unit: copy.unit,
236
251
  note: copy.note,
@@ -5,18 +5,26 @@ import { buildCompensation, getWorkProfileCopy } from "./presentation.mjs";
5
5
 
6
6
  export function compactReceipt(record) {
7
7
  const profileId = record.presentation.work_profile;
8
+ const scope = record.source?.scope || (record.presentation.compensation?.label === "本日工资" ? "today" : "latest");
8
9
  const mobileProfile = profileId
9
10
  ? getWorkProfileCopy(profileId, "zh-CN")
10
11
  : { title: record.presentation.work_title, review: record.presentation.review };
11
12
  const mobileCompensation = profileId
12
- ? buildCompensation(record.source.scope, record.presentation.compensation?.amount, "zh-CN")
13
+ ? buildCompensation(scope, record.presentation.compensation?.amount, "zh-CN")
13
14
  : record.presentation.compensation;
14
15
 
15
16
  return {
16
17
  v: record.schema_version,
17
18
  i: record.id,
18
19
  g: record.generated_at,
19
- d: [record.period.start_at, record.period.end_at, record.period.timezone],
20
+ o: scope,
21
+ d: [
22
+ record.period.start_at,
23
+ record.period.end_at,
24
+ record.period.timezone,
25
+ record.period.range_start_date || null,
26
+ record.period.range_end_date || null,
27
+ ],
20
28
  s: [
21
29
  record.stats.session_count,
22
30
  record.stats.completed_turns,
@@ -0,0 +1,99 @@
1
+ import { dateKey } from "../lib/time.mjs";
2
+
3
+ export const RECEIPT_SCOPES = new Set([
4
+ "latest",
5
+ "session",
6
+ "today",
7
+ "last-7-days",
8
+ "this-week",
9
+ ]);
10
+
11
+ const SCOPE_ALIASES = new Map([
12
+ ["latest", "latest"],
13
+ ["session", "session"],
14
+ ["today", "today"],
15
+ ["7d", "last-7-days"],
16
+ ["last-7-days", "last-7-days"],
17
+ ["last7days", "last-7-days"],
18
+ ["week", "this-week"],
19
+ ["this-week", "this-week"],
20
+ ]);
21
+
22
+ export function normalizeScope(value) {
23
+ return SCOPE_ALIASES.get(String(value || "").trim().toLowerCase()) || null;
24
+ }
25
+
26
+ export function shiftDateKey(value, days) {
27
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value));
28
+ if (!match) throw new Error(`无效日期:${value}`);
29
+ const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
30
+ date.setUTCDate(date.getUTCDate() + Number(days || 0));
31
+ return date.toISOString().slice(0, 10);
32
+ }
33
+
34
+ function mondayDateKey(value) {
35
+ const [year, month, day] = value.split("-").map(Number);
36
+ const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
37
+ const daysSinceMonday = weekday === 0 ? 6 : weekday - 1;
38
+ return shiftDateKey(value, -daysSinceMonday);
39
+ }
40
+
41
+ export function resolveRange(scope, timezone, now = new Date(), sessionId = null) {
42
+ const normalizedScope = normalizeScope(scope);
43
+ if (!normalizedScope || !RECEIPT_SCOPES.has(normalizedScope)) {
44
+ throw new Error(`不支持的统计范围:${scope}`);
45
+ }
46
+
47
+ const targetDate = dateKey(now, timezone);
48
+ let startDate = null;
49
+ let endDate = null;
50
+
51
+ if (normalizedScope === "today") {
52
+ startDate = targetDate;
53
+ endDate = targetDate;
54
+ } else if (normalizedScope === "last-7-days") {
55
+ startDate = shiftDateKey(targetDate, -6);
56
+ endDate = targetDate;
57
+ } else if (normalizedScope === "this-week") {
58
+ startDate = mondayDateKey(targetDate);
59
+ endDate = targetDate;
60
+ }
61
+
62
+ return {
63
+ scope: normalizedScope,
64
+ timezone,
65
+ targetDate,
66
+ startDate,
67
+ endDate,
68
+ sessionId: sessionId || null,
69
+ };
70
+ }
71
+
72
+ export function isCalendarScope(scope) {
73
+ return scope === "today" || scope === "last-7-days" || scope === "this-week";
74
+ }
75
+
76
+ export function isDateInRange(date, range) {
77
+ if (!date) return false;
78
+ if (!isCalendarScope(range.scope)) return true;
79
+ const key = dateKey(date, range.timezone);
80
+ return key >= range.startDate && key <= range.endDate;
81
+ }
82
+
83
+ export function calendarDayCount(range) {
84
+ if (!range.startDate || !range.endDate) return 1;
85
+ let count = 1;
86
+ let current = range.startDate;
87
+ while (current < range.endDate) {
88
+ current = shiftDateKey(current, 1);
89
+ count += 1;
90
+ }
91
+ return count;
92
+ }
93
+
94
+ export function outputSlugForScope(scope) {
95
+ if (scope === "last-7-days") return "last-7-days";
96
+ if (scope === "this-week") return "this-week";
97
+ if (scope === "session") return "session";
98
+ return scope;
99
+ }
@@ -10,7 +10,7 @@ import {
10
10
  } from "./presentation.mjs";
11
11
 
12
12
  const SCHEMA_VERSION = 1;
13
- const COLLECTOR_VERSION = "0.3.0";
13
+ const COLLECTOR_VERSION = "0.4.0";
14
14
 
15
15
  function fingerprintSessionIds(sessionIds) {
16
16
  return crypto.createHash("sha256").update([...sessionIds].sort().join("|")).digest("hex").slice(0, 16);
@@ -18,9 +18,11 @@ function fingerprintSessionIds(sessionIds) {
18
18
 
19
19
  export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE) {
20
20
  const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
21
- const logicalKey = metrics.mode === "latest"
21
+ const logicalKey = metrics.mode === "latest" || metrics.mode === "session"
22
22
  ? `latest:${sessionFingerprint}`
23
- : `today:${metrics.targetDate}:${metrics.timezone}`;
23
+ : metrics.mode === "this-week"
24
+ ? `this-week:${metrics.rangeStartDate}:${metrics.timezone}`
25
+ : `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}`;
24
26
  const id = `cwr_${crypto.createHash("sha256").update(logicalKey).digest("hex").slice(0, 16)}`;
25
27
  const snapshotHash = crypto.createHash("sha256").update(JSON.stringify({
26
28
  start: metrics.startAt.toISOString(),
@@ -29,6 +31,9 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
29
31
  tools: metrics.toolCalls,
30
32
  turns: metrics.completedTurns,
31
33
  interruptions: metrics.interruptions,
34
+ scope: metrics.mode,
35
+ rangeStartDate: metrics.rangeStartDate,
36
+ rangeEndDate: metrics.rangeEndDate,
32
37
  })).digest("hex").slice(0, 16);
33
38
  const workProfileId = metrics.workProfileId || "temporary-hire";
34
39
  const workProfile = getWorkProfileCopy(workProfileId, locale);
@@ -50,6 +55,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
50
55
  start_at: metrics.startAt.toISOString(),
51
56
  end_at: metrics.endAt.toISOString(),
52
57
  timezone: metrics.timezone,
58
+ range_start_date: metrics.rangeStartDate,
59
+ range_end_date: metrics.rangeEndDate,
53
60
  },
54
61
  stats: {
55
62
  session_count: metrics.sessionCount,
@@ -0,0 +1,71 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ function formatSessionDate(value, timezone, locale) {
4
+ return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "zh-CN", {
5
+ timeZone: timezone,
6
+ month: "2-digit",
7
+ day: "2-digit",
8
+ hour: "2-digit",
9
+ minute: "2-digit",
10
+ hour12: false,
11
+ }).format(value);
12
+ }
13
+
14
+ function sessionLine(session, index, timezone, locale) {
15
+ const start = formatSessionDate(session.startAt, timezone, locale);
16
+ const end = formatSessionDate(session.endAt, timezone, locale);
17
+ const model = session.model || (locale === "en" ? "model unknown" : "模型未记录");
18
+ if (locale === "en") {
19
+ return `${index + 1}. ${start}–${end} · ${session.completedTurns} turns · ${session.toolCalls} tool calls · ${model}`;
20
+ }
21
+ return `${index + 1}. ${start}–${end} · ${session.completedTurns} 轮 · ${session.toolCalls} 次工具调用 · ${model}`;
22
+ }
23
+
24
+ async function askForNumber(readline, prompt, minimum, maximum, fallback = null) {
25
+ while (true) {
26
+ const answer = (await readline.question(prompt)).trim();
27
+ if (!answer && fallback !== null) return fallback;
28
+ const value = Number(answer);
29
+ if (Number.isInteger(value) && value >= minimum && value <= maximum) return value;
30
+ }
31
+ }
32
+
33
+ export async function promptForRange({ locale, timezone, loadRecentSessions }) {
34
+ const isEnglish = locale === "en";
35
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
36
+
37
+ try {
38
+ console.log(isEnglish ? "\nChoose a receipt range:\n" : "\n请选择小票统计范围:\n");
39
+ console.log(isEnglish ? "1. All activity today (recommended)" : "1. 今天全部活动(推荐)");
40
+ console.log(isEnglish ? "2. Last 7 calendar days" : "2. 最近 7 个自然日");
41
+ console.log(isEnglish ? "3. This week (Monday to now)" : "3. 本周(周一至今)");
42
+ console.log(isEnglish ? "4. Choose a specific session" : "4. 选择一个具体会话");
43
+
44
+ const choice = await askForNumber(
45
+ readline,
46
+ isEnglish ? "\nEnter 1–4 (default 1): " : "\n请输入 1–4(默认 1):",
47
+ 1,
48
+ 4,
49
+ 1,
50
+ );
51
+
52
+ if (choice === 1) return { mode: "today", sessionId: null };
53
+ if (choice === 2) return { mode: "last-7-days", sessionId: null };
54
+ if (choice === 3) return { mode: "this-week", sessionId: null };
55
+
56
+ const sessions = await loadRecentSessions();
57
+ if (!sessions.length) throw new Error(isEnglish ? "No Codex sessions found" : "没有找到可选择的 Codex 会话");
58
+
59
+ console.log(isEnglish ? "\nRecent sessions:\n" : "\n最近的会话:\n");
60
+ sessions.forEach((session, index) => console.log(sessionLine(session, index, timezone, locale)));
61
+ const sessionChoice = await askForNumber(
62
+ readline,
63
+ isEnglish ? `\nEnter 1–${sessions.length}: ` : `\n请输入 1–${sessions.length}:`,
64
+ 1,
65
+ sessions.length,
66
+ );
67
+ return { mode: "session", sessionId: sessions[sessionChoice - 1].sessionId };
68
+ } finally {
69
+ readline.close();
70
+ }
71
+ }
@@ -2,6 +2,8 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
+ import { rowDate } from "../lib/time.mjs";
6
+
5
7
  function walkJsonlFiles(directory, accumulator = []) {
6
8
  if (!fs.existsSync(directory)) return accumulator;
7
9
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
@@ -26,26 +28,90 @@ function readJsonl(filePath) {
26
28
  return rows;
27
29
  }
28
30
 
29
- export function loadCodexSessions(mode) {
31
+ function sessionFromFile(file) {
32
+ const rows = readJsonl(file.filePath);
33
+ const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
34
+ const timestamps = rows.map(rowDate).filter(Boolean).sort((left, right) => left - right);
35
+ const fallbackDate = new Date(file.modifiedAt);
36
+ return {
37
+ rows,
38
+ filePath: file.filePath,
39
+ modifiedAt: file.modifiedAt,
40
+ sessionId: meta.session_id || meta.id || path.basename(file.filePath, ".jsonl"),
41
+ startAt: timestamps[0] || fallbackDate,
42
+ endAt: timestamps.at(-1) || fallbackDate,
43
+ };
44
+ }
45
+
46
+ function codexSessionFiles() {
30
47
  const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
31
48
  const sessionsDirectory = path.join(codexHome, "sessions");
32
49
  const files = walkJsonlFiles(sessionsDirectory)
33
50
  .map((filePath) => ({ filePath, modifiedAt: fs.statSync(filePath).mtimeMs }))
34
51
  .sort((left, right) => right.modifiedAt - left.modifiedAt);
35
-
36
52
  if (!files.length) throw new Error(`没有在 ${sessionsDirectory} 找到 Codex 会话记录`);
53
+ return files;
54
+ }
55
+
56
+ function calendarCandidates(files, startDate) {
57
+ if (!startDate) return files;
58
+ const approximateStart = Date.parse(`${startDate}T00:00:00.000Z`) - 48 * 60 * 60 * 1000;
59
+ return files.filter((file) => file.modifiedAt >= approximateStart);
60
+ }
61
+
62
+ export function loadCodexSessions(range) {
63
+ const files = codexSessionFiles();
64
+ let candidates = files;
37
65
 
38
- const selected = mode === "latest"
39
- ? files.slice(0, 1)
40
- : files.filter((file) => file.modifiedAt >= Date.now() - 72 * 60 * 60 * 1000);
66
+ if (range.scope === "latest") {
67
+ candidates = files.slice(0, 40);
68
+ } else if (range.scope === "session" && range.sessionId) {
69
+ const filenameMatches = files.filter((file) => path.basename(file.filePath).includes(range.sessionId));
70
+ candidates = filenameMatches.length ? filenameMatches : files;
71
+ } else {
72
+ candidates = calendarCandidates(files, range.startDate);
73
+ }
74
+
75
+ const sessions = candidates
76
+ .map(sessionFromFile)
77
+ .sort((left, right) => right.endAt - left.endAt);
78
+
79
+ if (range.scope === "latest") return sessions.slice(0, 1);
80
+ if (range.scope === "session") {
81
+ const selected = sessions.find((session) => session.sessionId === range.sessionId);
82
+ if (!selected) throw new Error(`没有找到指定的 Codex 会话:${range.sessionId}`);
83
+ return [selected];
84
+ }
85
+ return sessions;
86
+ }
87
+
88
+ export function listRecentCodexSessions(limit = 10) {
89
+ const sessions = codexSessionFiles()
90
+ .slice(0, Math.max(40, limit * 3))
91
+ .map(sessionFromFile)
92
+ .filter((session) => session.rows.length)
93
+ .sort((left, right) => right.endAt - left.endAt)
94
+ .slice(0, limit);
41
95
 
42
- return selected.map((file) => {
43
- const rows = readJsonl(file.filePath);
44
- const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
96
+ return sessions.map((session) => {
97
+ let completedTurns = 0;
98
+ let toolCalls = 0;
99
+ let model = null;
100
+ for (const row of session.rows) {
101
+ if (row.type === "turn_context" && row.payload?.model) model = row.payload.model;
102
+ if (row.type === "event_msg" && row.payload?.type === "task_complete") completedTurns += 1;
103
+ if (
104
+ row.type === "response_item" &&
105
+ (row.payload?.type === "custom_tool_call" || row.payload?.type === "function_call")
106
+ ) toolCalls += 1;
107
+ }
45
108
  return {
46
- rows,
47
- sessionId: meta.session_id || meta.id || path.basename(file.filePath, ".jsonl"),
109
+ sessionId: session.sessionId,
110
+ startAt: session.startAt,
111
+ endAt: session.endAt,
112
+ completedTurns,
113
+ toolCalls,
114
+ model,
48
115
  };
49
116
  });
50
117
  }
51
-