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.
- package/CHANGELOG.md +15 -0
- package/README.en.md +13 -3
- package/README.md +13 -3
- package/docs/cli.en.md +40 -2
- package/docs/cli.md +40 -2
- package/docs/codex-skill.en.md +1 -1
- package/docs/codex-skill.md +1 -1
- package/docs/data-schema.en.md +3 -3
- package/docs/data-schema.md +3 -3
- package/docs/privacy.en.md +5 -0
- package/docs/privacy.md +5 -0
- package/package.json +2 -2
- package/skills/ai-work-receipt/SKILL.md +10 -5
- package/src/auto-runner.mjs +4 -4
- package/src/cli.mjs +64 -5
- package/src/core/args.mjs +59 -3
- package/src/core/fact-buckets.mjs +3 -3
- package/src/core/fact-identity.mjs +8 -4
- package/src/core/generator.mjs +15 -3
- package/src/core/metrics.mjs +9 -6
- package/src/core/presentation.mjs +54 -5
- package/src/core/project-identity.mjs +132 -0
- package/src/core/range.mjs +155 -8
- package/src/core/receipt-record.mjs +6 -1
- package/src/core/selector.mjs +146 -21
- package/src/parsers/codex.mjs +52 -7
- package/src/renderers/html.mjs +158 -24
package/src/core/range.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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 (!
|
|
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,
|
package/src/core/selector.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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.
|
|
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–
|
|
186
|
+
isEnglish ? "\nEnter 1–7 (default 1): " : "\n请输入 1–7(默认 1):",
|
|
50
187
|
1,
|
|
51
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
}
|
package/src/parsers/codex.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
+
}
|