codex-work-receipt 0.9.0 → 0.10.1

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.
@@ -4,6 +4,7 @@ export const RECEIPT_SCOPES = new Set([
4
4
  "latest",
5
5
  "session",
6
6
  "last-hours",
7
+ "custom-range",
7
8
  "today",
8
9
  "last-7-days",
9
10
  "this-week",
@@ -14,6 +15,8 @@ const SCOPE_ALIASES = new Map([
14
15
  ["session", "session"],
15
16
  ["hours", "last-hours"],
16
17
  ["last-hours", "last-hours"],
18
+ ["custom", "custom-range"],
19
+ ["custom-range", "custom-range"],
17
20
  ["today", "today"],
18
21
  ["7d", "last-7-days"],
19
22
  ["last-7-days", "last-7-days"],
@@ -47,7 +50,125 @@ function floorToMinute(value) {
47
50
  return date;
48
51
  }
49
52
 
50
- export function resolveRange(scope, timezone, now = new Date(), sessionId = null, hours = null) {
53
+ function parseDateParts(value) {
54
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value || "").trim());
55
+ if (!match) return null;
56
+ return { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) };
57
+ }
58
+
59
+ function parseDateTimeParts(value) {
60
+ const match = /^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2})$/.exec(String(value || "").trim());
61
+ if (!match) return null;
62
+ return {
63
+ year: Number(match[1]),
64
+ month: Number(match[2]),
65
+ day: Number(match[3]),
66
+ hour: Number(match[4]),
67
+ minute: Number(match[5]),
68
+ };
69
+ }
70
+
71
+ function validCalendarParts(parts, includeTime = false) {
72
+ if (!parts) return false;
73
+ const probe = new Date(Date.UTC(parts.year, parts.month - 1, parts.day));
74
+ if (
75
+ probe.getUTCFullYear() !== parts.year ||
76
+ probe.getUTCMonth() + 1 !== parts.month ||
77
+ probe.getUTCDate() !== parts.day
78
+ ) return false;
79
+ if (!includeTime) return true;
80
+ return parts.hour >= 0 && parts.hour <= 23 && parts.minute >= 0 && parts.minute <= 59;
81
+ }
82
+
83
+ function zonedParts(date, timezone) {
84
+ const parts = new Intl.DateTimeFormat("en-CA", {
85
+ timeZone: timezone,
86
+ year: "numeric",
87
+ month: "2-digit",
88
+ day: "2-digit",
89
+ hour: "2-digit",
90
+ minute: "2-digit",
91
+ second: "2-digit",
92
+ hourCycle: "h23",
93
+ }).formatToParts(date);
94
+ return Object.fromEntries(parts.filter((part) => part.type !== "literal").map((part) => [part.type, Number(part.value)]));
95
+ }
96
+
97
+ function zonedDateTime(parts, timezone) {
98
+ const target = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour || 0, parts.minute || 0, 0, 0);
99
+ let result = new Date(target);
100
+ for (let index = 0; index < 3; index += 1) {
101
+ const displayed = zonedParts(result, timezone);
102
+ const displayedUtc = Date.UTC(
103
+ displayed.year,
104
+ displayed.month - 1,
105
+ displayed.day,
106
+ displayed.hour,
107
+ displayed.minute,
108
+ displayed.second,
109
+ );
110
+ result = new Date(result.getTime() + target - displayedUtc);
111
+ }
112
+ const displayed = zonedParts(result, timezone);
113
+ if (
114
+ displayed.year !== parts.year ||
115
+ displayed.month !== parts.month ||
116
+ displayed.day !== parts.day ||
117
+ displayed.hour !== (parts.hour || 0) ||
118
+ displayed.minute !== (parts.minute || 0)
119
+ ) throw new Error("自定义时间在所选时区中不存在,请检查夏令时切换或输入格式");
120
+ return result;
121
+ }
122
+
123
+ export function parseCustomRange(from, to, timezone) {
124
+ const fromDate = parseDateParts(from);
125
+ const toDate = parseDateParts(to);
126
+ const fromDateTime = parseDateTimeParts(from);
127
+ const toDateTime = parseDateTimeParts(to);
128
+
129
+ if (fromDate && toDate) {
130
+ if (!validCalendarParts(fromDate) || !validCalendarParts(toDate)) throw new Error("自定义日期无效");
131
+ const startDate = String(from).trim();
132
+ const endDate = String(to).trim();
133
+ if (startDate > endDate) throw new Error("自定义区间的开始日期不能晚于结束日期");
134
+ const nextDate = shiftDateKey(endDate, 1);
135
+ return {
136
+ boundaryKind: "calendar-days",
137
+ startDate,
138
+ endDate,
139
+ startAt: zonedDateTime({ ...fromDate, hour: 0, minute: 0 }, timezone),
140
+ endAt: zonedDateTime({ ...parseDateParts(nextDate), hour: 0, minute: 0 }, timezone),
141
+ };
142
+ }
143
+
144
+ if (fromDateTime && toDateTime) {
145
+ if (!validCalendarParts(fromDateTime, true) || !validCalendarParts(toDateTime, true)) {
146
+ throw new Error("自定义时间无效");
147
+ }
148
+ const startAt = zonedDateTime(fromDateTime, timezone);
149
+ const endAt = zonedDateTime(toDateTime, timezone);
150
+ if (startAt >= endAt) throw new Error("自定义区间的开始时间必须早于结束时间");
151
+ return {
152
+ boundaryKind: "exact-time",
153
+ startDate: dateKey(startAt, timezone),
154
+ endDate: dateKey(endAt, timezone),
155
+ startAt,
156
+ endAt,
157
+ };
158
+ }
159
+
160
+ throw new Error("自定义区间请统一使用 YYYY-MM-DD,或 YYYY-MM-DDTHH:mm 格式");
161
+ }
162
+
163
+ export function resolveRange(
164
+ scope,
165
+ timezone,
166
+ now = new Date(),
167
+ sessionId = null,
168
+ hours = null,
169
+ customRange = null,
170
+ projectId = null,
171
+ ) {
51
172
  const normalizedScope = normalizeScope(scope);
52
173
  if (!normalizedScope || !RECEIPT_SCOPES.has(normalizedScope)) {
53
174
  throw new Error(`不支持的统计范围:${scope}`);
@@ -59,6 +180,7 @@ export function resolveRange(scope, timezone, now = new Date(), sessionId = null
59
180
  let startAt = null;
60
181
  let endAt = null;
61
182
  let windowHours = null;
183
+ let boundaryKind = null;
62
184
 
63
185
  if (normalizedScope === "last-hours") {
64
186
  windowHours = Number(hours ?? 3);
@@ -69,6 +191,15 @@ export function resolveRange(scope, timezone, now = new Date(), sessionId = null
69
191
  startAt = new Date(endAt.getTime() - windowHours * 60 * 60 * 1000);
70
192
  startDate = dateKey(startAt, timezone);
71
193
  endDate = dateKey(endAt, timezone);
194
+ boundaryKind = "exact-time";
195
+ } else if (normalizedScope === "custom-range") {
196
+ if (!customRange?.from || !customRange?.to) throw new Error("自定义区间需要开始和结束时间");
197
+ const parsed = parseCustomRange(customRange.from, customRange.to, timezone);
198
+ startDate = parsed.startDate;
199
+ endDate = parsed.endDate;
200
+ startAt = parsed.startAt;
201
+ endAt = parsed.endAt;
202
+ boundaryKind = parsed.boundaryKind;
72
203
  } else if (normalizedScope === "today") {
73
204
  startDate = targetDate;
74
205
  endDate = targetDate;
@@ -90,23 +221,27 @@ export function resolveRange(scope, timezone, now = new Date(), sessionId = null
90
221
  endAt,
91
222
  hours: windowHours,
92
223
  sessionId: sessionId || null,
224
+ boundaryKind,
225
+ projectId: projectId || null,
93
226
  };
94
227
  }
95
228
 
96
- export function isCalendarScope(scope) {
97
- return scope === "today" || scope === "last-7-days" || scope === "this-week";
229
+ export function isCalendarRange(range) {
230
+ return range?.scope === "today" || range?.scope === "last-7-days" || range?.scope === "this-week"
231
+ || (range?.scope === "custom-range" && range?.boundaryKind === "calendar-days");
98
232
  }
99
233
 
100
- export function isTimeWindowScope(scope) {
101
- return scope === "last-hours";
234
+ export function isTimeWindowRange(range) {
235
+ return range?.scope === "last-hours"
236
+ || (range?.scope === "custom-range" && range?.boundaryKind === "exact-time");
102
237
  }
103
238
 
104
239
  export function isDateInRange(date, range) {
105
240
  if (!date) return false;
106
- if (isTimeWindowScope(range.scope)) {
107
- return date >= range.startAt && date <= range.endAt;
241
+ if (isTimeWindowRange(range)) {
242
+ return date >= range.startAt && (range.scope === "custom-range" ? date < range.endAt : date <= range.endAt);
108
243
  }
109
- if (!isCalendarScope(range.scope)) return true;
244
+ if (!isCalendarRange(range)) return true;
110
245
  const key = dateKey(date, range.timezone);
111
246
  return key >= range.startDate && key <= range.endDate;
112
247
  }
@@ -145,6 +280,18 @@ export function outputSlugForRange(range, receiptId = "") {
145
280
  : endAt.toISOString().replace(/[-:]/g, "").slice(0, 13);
146
281
  return `last-${Number(range?.hours || 3)}-hours-${safeSlugSegment(endStamp, "window")}`;
147
282
  }
283
+ if (scope === "custom-range") {
284
+ if (range?.boundaryKind === "calendar-days") {
285
+ const dateSpan = startDate === endDate ? endDate : `${startDate}-to-${endDate}`;
286
+ return `custom-${safeSlugSegment(dateSpan, "range")}`;
287
+ }
288
+ const startAt = range?.startAt instanceof Date ? range.startAt : new Date(range?.startAt || 0);
289
+ const endAt = range?.endAt instanceof Date ? range.endAt : new Date(range?.endAt || 0);
290
+ const stamp = (value) => Number.isNaN(value.getTime())
291
+ ? "time"
292
+ : value.toISOString().replace(/[-:]/g, "").slice(0, 13);
293
+ return `custom-${safeSlugSegment(`${stamp(startAt)}-to-${stamp(endAt)}`, "range")}`;
294
+ }
148
295
  if (scope === "last-7-days" || scope === "this-week") {
149
296
  const dateSpan = startDate === endDate ? endDate : `${startDate}-to-${endDate}`;
150
297
  return `${scope}-${safeSlugSegment(dateSpan, scope)}`;
@@ -52,7 +52,8 @@ function fingerprintSessionIds(sessionIds) {
52
52
  export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE, canonical = {}) {
53
53
  const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
54
54
  const logicalKey = buildLogicalReceiptKey(metrics);
55
- const summaryOnly = metrics.mode === "last-hours";
55
+ const summaryOnly = metrics.mode === "last-hours"
56
+ || (metrics.mode === "custom-range" && metrics.boundaryKind === "exact-time");
56
57
  const insights = metrics.insights || fallbackInsights(metrics);
57
58
  const schemaVersion = summaryOnly ? 1 : SCHEMA_VERSION;
58
59
  const sourceVersion = summaryOnly ? "cwr1" : SOURCE_VERSION;
@@ -72,6 +73,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
72
73
  rangeEndDate: metrics.rangeEndDate,
73
74
  windowStartAt: metrics.windowStartAt?.toISOString() || null,
74
75
  windowEndAt: metrics.windowEndAt?.toISOString() || null,
76
+ boundaryKind: metrics.boundaryKind || null,
77
+ projectFiltered: Boolean(metrics.projectId),
75
78
  });
76
79
  const workProfileId = metrics.workProfileId || "temporary-hire";
77
80
  const workProfile = getWorkProfileCopy(workProfileId, locale);
@@ -108,6 +111,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
108
111
  version: sourceVersion,
109
112
  scope: metrics.mode,
110
113
  hours: metrics.windowHours || null,
114
+ range_kind: metrics.boundaryKind || null,
115
+ filter_kind: metrics.projectId ? "project" : null,
111
116
  collector_version: COLLECTOR_VERSION,
112
117
  logical_key: logicalKey,
113
118
  session_fingerprint: sessionFingerprint,
@@ -1,5 +1,7 @@
1
1
  import { createInterface } from "node:readline/promises";
2
2
 
3
+ import { parseCustomRange } from "./range.mjs";
4
+
3
5
  function formatSessionDate(value, timezone, locale) {
4
6
  return new Intl.DateTimeFormat(locale === "en" ? "en-US" : "zh-CN", {
5
7
  timeZone: timezone,
@@ -21,6 +23,15 @@ function sessionLine(session, index, timezone, locale) {
21
23
  return `${index + 1}. ${start}–${end} · ${session.completedTurns} 轮 · ${session.toolCalls} 次工具调用 · ${model}`;
22
24
  }
23
25
 
26
+ function projectLine(project, index, timezone, locale) {
27
+ const latest = formatSessionDate(project.endAt, timezone, locale);
28
+ const suffix = project.projectId.slice(-6).toUpperCase();
29
+ if (locale === "en") {
30
+ return `${index + 1}. ${project.projectLabel} · ${project.sessionCount} sessions · latest ${latest} · ${suffix}`;
31
+ }
32
+ return `${index + 1}. ${project.projectLabel} · ${project.sessionCount} 个会话 · 最近 ${latest} · ${suffix}`;
33
+ }
34
+
24
35
  async function askForNumber(readline, prompt, minimum, maximum, fallback = null) {
25
36
  while (true) {
26
37
  const answer = (await readline.question(prompt)).trim();
@@ -30,11 +41,135 @@ async function askForNumber(readline, prompt, minimum, maximum, fallback = null)
30
41
  }
31
42
  }
32
43
 
33
- export async function promptForRange({ locale, timezone, loadRecentSessions }) {
44
+ async function askForValue(readline, prompt) {
45
+ while (true) {
46
+ const answer = (await readline.question(prompt)).trim();
47
+ if (answer) return answer;
48
+ }
49
+ }
50
+
51
+ async function chooseSession(readline, { locale, timezone, loadRecentSessions }) {
34
52
  const isEnglish = locale === "en";
35
- const readline = createInterface({ input: process.stdin, output: process.stdout });
53
+ const sessions = await loadRecentSessions();
54
+ if (!sessions.length) throw new Error(isEnglish ? "No Codex sessions found" : "没有找到可选择的 Codex 会话");
55
+
56
+ console.log(isEnglish ? "\nRecent sessions:\n" : "\n最近的会话:\n");
57
+ sessions.forEach((session, index) => console.log(sessionLine(session, index, timezone, locale)));
58
+ const choice = await askForNumber(
59
+ readline,
60
+ isEnglish ? `\nEnter 1–${sessions.length}: ` : `\n请输入 1–${sessions.length}:`,
61
+ 1,
62
+ sessions.length,
63
+ );
64
+ return { mode: "session", sessionId: sessions[choice - 1].sessionId };
65
+ }
66
+
67
+ async function chooseCustomRange(readline, { locale, timezone }) {
68
+ const isEnglish = locale === "en";
69
+ console.log(isEnglish ? "\nChoose custom range precision:\n" : "\n请选择自定义区间精度:\n");
70
+ console.log(isEnglish
71
+ ? "1. Calendar dates (accountable cwr2 facts)"
72
+ : "1. 按自然日(生成可计入供销社的 cwr2 事实)");
73
+ console.log(isEnglish
74
+ ? "2. Exact date and time (private cwr1 summary)"
75
+ : "2. 精确到时间(生成不计入供销社的私人 cwr1 摘要)");
76
+ const precision = await askForNumber(
77
+ readline,
78
+ isEnglish ? "\nEnter 1–2 (default 1): " : "\n请输入 1–2(默认 1):",
79
+ 1,
80
+ 2,
81
+ 1,
82
+ );
83
+ const dateOnly = precision === 1;
84
+ const format = dateOnly ? "YYYY-MM-DD" : "YYYY-MM-DDTHH:mm";
85
+ while (true) {
86
+ const from = await askForValue(
87
+ readline,
88
+ isEnglish ? `Start (${format}, ${timezone}): ` : `开始(${format},${timezone}):`,
89
+ );
90
+ const to = await askForValue(
91
+ readline,
92
+ isEnglish ? `End (${format}, ${timezone}): ` : `结束(${format},${timezone}):`,
93
+ );
94
+ try {
95
+ parseCustomRange(from, to, timezone);
96
+ } catch (error) {
97
+ console.log(isEnglish ? `Invalid range: ${error.message}` : `区间无效:${error.message}`);
98
+ continue;
99
+ }
100
+ const confirmation = (await readline.question(isEnglish
101
+ ? `Use ${from} to ${to} in ${timezone}? [Y/n]: `
102
+ : `确认统计 ${timezone} 的 ${from} 至 ${to}?[Y/n]:`)).trim().toLowerCase();
103
+ if (!confirmation || confirmation === "y" || confirmation === "yes") {
104
+ return { mode: "custom-range", sessionId: null, from, to };
105
+ }
106
+ }
107
+ }
108
+
109
+ async function chooseProjectRange(readline, { locale, timezone, loadRecentProjects }) {
110
+ const isEnglish = locale === "en";
111
+ const projects = await loadRecentProjects();
112
+ if (!projects.length) throw new Error(isEnglish ? "No projects found in recent Codex sessions" : "最近的 Codex 会话中没有找到项目");
36
113
 
114
+ console.log(isEnglish ? "\nRecent projects:\n" : "\n最近的项目:\n");
115
+ projects.forEach((project, index) => console.log(projectLine(project, index, timezone, locale)));
116
+ const projectChoice = await askForNumber(
117
+ readline,
118
+ isEnglish ? `\nEnter 1–${projects.length}: ` : `\n请输入 1–${projects.length}:`,
119
+ 1,
120
+ projects.length,
121
+ );
122
+
123
+ console.log(isEnglish ? "\nChoose a range for this project:\n" : "\n请选择该项目的统计范围:\n");
124
+ console.log(isEnglish ? "1. Today (recommended)" : "1. 今天(推荐)");
125
+ console.log(isEnglish ? "2. Last 3 hours" : "2. 最近 3 小时");
126
+ console.log(isEnglish ? "3. Last 7 calendar days" : "3. 最近 7 个自然日");
127
+ console.log(isEnglish ? "4. This week" : "4. 本周");
128
+ console.log(isEnglish ? "5. Custom range" : "5. 自定义时间区间");
129
+ const rangeChoice = await askForNumber(
130
+ readline,
131
+ isEnglish ? "\nEnter 1–5 (default 1): " : "\n请输入 1–5(默认 1):",
132
+ 1,
133
+ 5,
134
+ 1,
135
+ );
136
+ const projectId = projects[projectChoice - 1].projectId;
137
+ if (rangeChoice === 1) return { mode: "today", projectId };
138
+ if (rangeChoice === 2) return { mode: "last-hours", hours: 3, projectId };
139
+ if (rangeChoice === 3) return { mode: "last-7-days", projectId };
140
+ if (rangeChoice === 4) return { mode: "this-week", projectId };
141
+ return { ...(await chooseCustomRange(readline, { locale, timezone })), projectId };
142
+ }
143
+
144
+ async function withReadline(callback) {
145
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
37
146
  try {
147
+ return await callback(readline);
148
+ } finally {
149
+ readline.close();
150
+ }
151
+ }
152
+
153
+ export function promptForSpecificSession(options) {
154
+ return withReadline((readline) => chooseSession(readline, options));
155
+ }
156
+
157
+ export function promptForCustomRange(options) {
158
+ return withReadline((readline) => chooseCustomRange(readline, options));
159
+ }
160
+
161
+ export function promptForProjectRange(options) {
162
+ return withReadline((readline) => chooseProjectRange(readline, options));
163
+ }
164
+
165
+ export async function promptForRange({
166
+ locale,
167
+ timezone,
168
+ loadRecentSessions,
169
+ loadRecentProjects,
170
+ }) {
171
+ const isEnglish = locale === "en";
172
+ return withReadline(async (readline) => {
38
173
  console.log(isEnglish ? "\nChoose a receipt range:\n" : "\n请选择小票统计范围:\n");
39
174
  console.log(isEnglish ? "1. All activity today (recommended)" : "1. 今天全部活动(推荐)");
40
175
  console.log(isEnglish
@@ -42,13 +177,15 @@ export async function promptForRange({ locale, timezone, loadRecentSessions }) {
42
177
  : "2. 最近 3 小时(私人滚动摘要,不计入供销社)");
43
178
  console.log(isEnglish ? "3. Last 7 calendar days" : "3. 最近 7 个自然日");
44
179
  console.log(isEnglish ? "4. This week (Monday to now)" : "4. 本周(周一至今)");
45
- console.log(isEnglish ? "5. Choose a specific session" : "5. 选择一个具体会话");
180
+ console.log(isEnglish ? "5. Custom date or time range" : "5. 自定义日期或时间区间");
181
+ console.log(isEnglish ? "6. Choose a specific session" : "6. 选择一个具体会话");
182
+ console.log(isEnglish ? "7. Choose a project" : "7. 选择一个项目");
46
183
 
47
184
  const choice = await askForNumber(
48
185
  readline,
49
- isEnglish ? "\nEnter 1–5 (default 1): " : "\n请输入 1–5(默认 1):",
186
+ isEnglish ? "\nEnter 1–7 (default 1): " : "\n请输入 1–7(默认 1):",
50
187
  1,
51
- 5,
188
+ 7,
52
189
  1,
53
190
  );
54
191
 
@@ -56,20 +193,8 @@ export async function promptForRange({ locale, timezone, loadRecentSessions }) {
56
193
  if (choice === 2) return { mode: "last-hours", sessionId: null, hours: 3 };
57
194
  if (choice === 3) return { mode: "last-7-days", sessionId: null };
58
195
  if (choice === 4) return { mode: "this-week", sessionId: null };
59
-
60
- const sessions = await loadRecentSessions();
61
- if (!sessions.length) throw new Error(isEnglish ? "No Codex sessions found" : "没有找到可选择的 Codex 会话");
62
-
63
- console.log(isEnglish ? "\nRecent sessions:\n" : "\n最近的会话:\n");
64
- sessions.forEach((session, index) => console.log(sessionLine(session, index, timezone, locale)));
65
- const sessionChoice = await askForNumber(
66
- readline,
67
- isEnglish ? `\nEnter 1–${sessions.length}: ` : `\n请输入 1–${sessions.length}:`,
68
- 1,
69
- sessions.length,
70
- );
71
- return { mode: "session", sessionId: sessions[sessionChoice - 1].sessionId };
72
- } finally {
73
- readline.close();
74
- }
196
+ if (choice === 5) return chooseCustomRange(readline, { locale, timezone });
197
+ if (choice === 6) return chooseSession(readline, { locale, timezone, loadRecentSessions });
198
+ return chooseProjectRange(readline, { locale, timezone, loadRecentProjects });
199
+ });
75
200
  }
@@ -5,6 +5,7 @@ import path from "node:path";
5
5
 
6
6
  import { rowDate } from "../lib/time.mjs";
7
7
  import { classifyToolName } from "../lib/tool-category.mjs";
8
+ import { projectDescriptorFromSessionMeta } from "../core/project-identity.mjs";
8
9
 
9
10
  const READ_CHUNK_BYTES = 256 * 1024;
10
11
  const MAX_JSONL_ROW_BYTES = 64 * 1024 * 1024;
@@ -115,7 +116,7 @@ function sessionReadError(file, error) {
115
116
  );
116
117
  }
117
118
 
118
- function readJsonl(file) {
119
+ function readJsonl(file, projectSecret = null) {
119
120
  const rows = [];
120
121
  const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
121
122
  const lineParts = [];
@@ -125,6 +126,7 @@ function readJsonl(file) {
125
126
  let tail = Buffer.alloc(0);
126
127
  let skippingOversizedLine = false;
127
128
  let fileDescriptor = null;
129
+ let project = null;
128
130
 
129
131
  const resetLine = () => {
130
132
  lineParts.length = 0;
@@ -160,7 +162,11 @@ function readJsonl(file) {
160
162
  const line = lineBuffer.toString("utf8").replace(/\r$/, "");
161
163
  if (line.trim()) {
162
164
  try {
163
- const compact = compactRow(JSON.parse(line), lineIndex);
165
+ const parsed = JSON.parse(line);
166
+ if (!project && parsed?.type === "session_meta" && projectSecret) {
167
+ project = projectDescriptorFromSessionMeta(parsed.payload, projectSecret);
168
+ }
169
+ const compact = compactRow(parsed, lineIndex);
164
170
  if (compact) rows.push(compact);
165
171
  } catch {
166
172
  console.warn(`跳过无法解析的记录:${file.filePath}:${lineIndex}`);
@@ -200,11 +206,12 @@ function readJsonl(file) {
200
206
  rows,
201
207
  byteLength: offset,
202
208
  tailHash: crypto.createHash("sha256").update(tail).digest("hex"),
209
+ project,
203
210
  };
204
211
  }
205
212
 
206
- function sessionFromFile(file) {
207
- const { rows, byteLength, tailHash } = readJsonl(file);
213
+ function sessionFromFile(file, projectSecret = null) {
214
+ const { rows, byteLength, tailHash, project } = readJsonl(file, projectSecret);
208
215
  const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
209
216
  const metadataSessionId = meta.session_id || meta.id || null;
210
217
  const timestamps = rows.map(rowDate).filter(Boolean).sort((left, right) => left - right);
@@ -215,6 +222,8 @@ function sessionFromFile(file) {
215
222
  modifiedAt: file.modifiedAt,
216
223
  sessionId: metadataSessionId || path.basename(file.filePath, ".jsonl"),
217
224
  identityQuality: metadataSessionId ? "metadata" : "filename_fallback",
225
+ projectId: project?.projectId || null,
226
+ projectLabel: project?.projectLabel || null,
218
227
  sourceRevision: {
219
228
  kind: "append-only-jsonl-v1",
220
229
  row_count: rows.length,
@@ -281,12 +290,16 @@ export function deduplicateCodexSessions(sessions) {
281
290
  return [...selected.values()];
282
291
  }
283
292
 
284
- export function loadCodexSessions(range, { codexHome = null } = {}) {
293
+ export function loadCodexSessions(range, { codexHome = null, projectSecret = null } = {}) {
285
294
  const files = codexSessionFiles(codexHome);
286
295
  let candidates = files;
287
296
  let scanMode = "none";
288
297
 
289
- if (range.scope === "latest") {
298
+ if (range.projectId) {
299
+ const fullScan = process.env.CODEX_WORK_RECEIPT_FULL_SCAN === "1";
300
+ candidates = range.startDate && !fullScan ? calendarCandidates(files, range.startDate) : files;
301
+ scanMode = fullScan ? "full" : "best_effort";
302
+ } else if (range.scope === "latest") {
290
303
  candidates = files.slice(0, 40);
291
304
  } else if (range.scope === "session" && range.sessionId) {
292
305
  const filenameMatches = files.filter((file) => path.basename(file.filePath).includes(range.sessionId));
@@ -297,7 +310,8 @@ export function loadCodexSessions(range, { codexHome = null } = {}) {
297
310
  scanMode = fullScan ? "full" : "best_effort";
298
311
  }
299
312
 
300
- const sessions = deduplicateCodexSessions(candidates.map(sessionFromFile))
313
+ const sessions = deduplicateCodexSessions(candidates.map((file) => sessionFromFile(file, projectSecret)))
314
+ .filter((session) => !range.projectId || session.projectId === range.projectId)
301
315
  .sort((left, right) => right.endAt - left.endAt);
302
316
 
303
317
  if (range.scope === "latest") {
@@ -346,3 +360,34 @@ export function listRecentCodexSessions(limit = 10, { codexHome = null } = {}) {
346
360
  };
347
361
  });
348
362
  }
363
+
364
+ export function listRecentCodexProjects(
365
+ limit = 10,
366
+ { codexHome = null, projectSecret = null } = {},
367
+ ) {
368
+ if (!projectSecret) throw new Error("列出项目时缺少本地项目身份密钥");
369
+ const sessions = deduplicateCodexSessions(codexSessionFiles(codexHome)
370
+ .slice(0, Math.max(120, limit * 20))
371
+ .map((file) => sessionFromFile(file, projectSecret))
372
+ .filter((session) => session.rows.length && session.projectId));
373
+ const projects = new Map();
374
+ for (const session of sessions) {
375
+ const current = projects.get(session.projectId);
376
+ if (!current) {
377
+ projects.set(session.projectId, {
378
+ projectId: session.projectId,
379
+ projectLabel: session.projectLabel,
380
+ sessionCount: 1,
381
+ startAt: session.startAt,
382
+ endAt: session.endAt,
383
+ });
384
+ continue;
385
+ }
386
+ current.sessionCount += 1;
387
+ if (session.startAt < current.startAt) current.startAt = session.startAt;
388
+ if (session.endAt > current.endAt) current.endAt = session.endAt;
389
+ }
390
+ return [...projects.values()]
391
+ .sort((left, right) => right.endAt - left.endAt)
392
+ .slice(0, limit);
393
+ }