codex-work-receipt 0.8.1 → 0.10.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/CHANGELOG.md +19 -0
- package/README.en.md +14 -4
- package/README.md +14 -4
- 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 +20 -4
- package/docs/data-schema.md +20 -4
- package/docs/privacy.en.md +8 -3
- package/docs/privacy.md +8 -3
- 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 +143 -15
- package/src/core/presentation.mjs +190 -3
- package/src/core/project-identity.mjs +132 -0
- package/src/core/range.mjs +155 -8
- package/src/core/receipt-record.mjs +36 -1
- package/src/core/selector.mjs +146 -21
- package/src/lib/tool-category.mjs +36 -0
- package/src/parsers/codex.mjs +58 -7
- package/src/renderers/html.mjs +661 -2
|
@@ -21,6 +21,30 @@ const SCHEMA_VERSION = 2;
|
|
|
21
21
|
const SOURCE_VERSION = "cwr2";
|
|
22
22
|
const COLLECTOR_VERSION = "0.6.0";
|
|
23
23
|
|
|
24
|
+
function fallbackInsights(metrics) {
|
|
25
|
+
const completedTurns = Math.max(0, Number(metrics.completedTurns || 0));
|
|
26
|
+
const ratio = (value) => completedTurns ? Number(value || 0) / completedTurns : 0;
|
|
27
|
+
const inputTokens = Math.max(0, Number(metrics.tokens?.input_tokens || 0));
|
|
28
|
+
return {
|
|
29
|
+
cache_hit_rate: inputTokens
|
|
30
|
+
? Math.min(1, Math.max(0, Number(metrics.tokens?.cached_input_tokens || 0) / inputTokens))
|
|
31
|
+
: 0,
|
|
32
|
+
per_turn: {
|
|
33
|
+
total_tokens: ratio(metrics.tokens?.total_tokens),
|
|
34
|
+
output_tokens: ratio(metrics.tokens?.output_tokens),
|
|
35
|
+
tool_calls: ratio(metrics.toolCalls),
|
|
36
|
+
work_duration_ms: Math.round(ratio(metrics.workDurationMs)),
|
|
37
|
+
},
|
|
38
|
+
latency_ms: {
|
|
39
|
+
first_token: { sample_count: 0, p50: 0, p90: 0 },
|
|
40
|
+
turn: { sample_count: 0, p50: 0, p90: 0 },
|
|
41
|
+
},
|
|
42
|
+
activity_by_hour: Array(24).fill(0),
|
|
43
|
+
model_usage: (metrics.models || []).map((model) => ({ model, count: 0 })),
|
|
44
|
+
tool_usage: metrics.toolCalls ? [{ category: "other", count: metrics.toolCalls }] : [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
function fingerprintSessionIds(sessionIds) {
|
|
25
49
|
return crypto.createHash("sha256").update([...sessionIds].sort().join("|")).digest("hex").slice(0, 16);
|
|
26
50
|
}
|
|
@@ -28,7 +52,9 @@ function fingerprintSessionIds(sessionIds) {
|
|
|
28
52
|
export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE, canonical = {}) {
|
|
29
53
|
const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
|
|
30
54
|
const logicalKey = buildLogicalReceiptKey(metrics);
|
|
31
|
-
const summaryOnly = metrics.mode === "last-hours"
|
|
55
|
+
const summaryOnly = metrics.mode === "last-hours"
|
|
56
|
+
|| (metrics.mode === "custom-range" && metrics.boundaryKind === "exact-time");
|
|
57
|
+
const insights = metrics.insights || fallbackInsights(metrics);
|
|
32
58
|
const schemaVersion = summaryOnly ? 1 : SCHEMA_VERSION;
|
|
33
59
|
const sourceVersion = summaryOnly ? "cwr1" : SOURCE_VERSION;
|
|
34
60
|
const id = summaryOnly
|
|
@@ -41,11 +67,14 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
|
|
|
41
67
|
tools: metrics.toolCalls,
|
|
42
68
|
turns: metrics.completedTurns,
|
|
43
69
|
interruptions: metrics.interruptions,
|
|
70
|
+
insights,
|
|
44
71
|
scope: metrics.mode,
|
|
45
72
|
rangeStartDate: metrics.rangeStartDate,
|
|
46
73
|
rangeEndDate: metrics.rangeEndDate,
|
|
47
74
|
windowStartAt: metrics.windowStartAt?.toISOString() || null,
|
|
48
75
|
windowEndAt: metrics.windowEndAt?.toISOString() || null,
|
|
76
|
+
boundaryKind: metrics.boundaryKind || null,
|
|
77
|
+
projectFiltered: Boolean(metrics.projectId),
|
|
49
78
|
});
|
|
50
79
|
const workProfileId = metrics.workProfileId || "temporary-hire";
|
|
51
80
|
const workProfile = getWorkProfileCopy(workProfileId, locale);
|
|
@@ -82,6 +111,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
|
|
|
82
111
|
version: sourceVersion,
|
|
83
112
|
scope: metrics.mode,
|
|
84
113
|
hours: metrics.windowHours || null,
|
|
114
|
+
range_kind: metrics.boundaryKind || null,
|
|
115
|
+
filter_kind: metrics.projectId ? "project" : null,
|
|
85
116
|
collector_version: COLLECTOR_VERSION,
|
|
86
117
|
logical_key: logicalKey,
|
|
87
118
|
session_fingerprint: sessionFingerprint,
|
|
@@ -104,6 +135,7 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
|
|
|
104
135
|
average_first_token_ms: Math.round(metrics.averageFirstTokenMs),
|
|
105
136
|
tokens: { ...metrics.tokens },
|
|
106
137
|
models: [...metrics.models],
|
|
138
|
+
insights,
|
|
107
139
|
receipt_work_points: metrics.workPoints,
|
|
108
140
|
receipt_formula_version: "receipt_work_points_v1",
|
|
109
141
|
},
|
|
@@ -120,6 +152,9 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
|
|
|
120
152
|
contains_code: false,
|
|
121
153
|
contains_paths: false,
|
|
122
154
|
contains_filenames: false,
|
|
155
|
+
contains_tool_names: false,
|
|
156
|
+
contains_tool_arguments: false,
|
|
157
|
+
contains_tool_output: false,
|
|
123
158
|
},
|
|
124
159
|
};
|
|
125
160
|
if (!summaryOnly) {
|
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
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const TOOL_CATEGORIES = new Set([
|
|
2
|
+
"terminal",
|
|
3
|
+
"file-edit",
|
|
4
|
+
"browser",
|
|
5
|
+
"research",
|
|
6
|
+
"media",
|
|
7
|
+
"agents",
|
|
8
|
+
"planning",
|
|
9
|
+
"integrations",
|
|
10
|
+
"other",
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
export function normalizeToolCategory(value) {
|
|
14
|
+
return TOOL_CATEGORIES.has(value) ? value : "other";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function classifyToolName(value) {
|
|
18
|
+
const name = String(value || "").trim().toLowerCase();
|
|
19
|
+
if (!name) return "other";
|
|
20
|
+
if (/(^|[_.-])(apply[_-]?patch|edit(?:[_-]?file)?|write(?:[_-]?file)?)$/.test(name)) return "file-edit";
|
|
21
|
+
if (/browser|playwright|computer|chrome|navigate|screenshot/.test(name)) return "browser";
|
|
22
|
+
if (/web[_-]?search|search[_-]?docs|fetch[_-]?doc|openai.*docs/.test(name)) return "research";
|
|
23
|
+
if (/image|audio|video|canvas|view[_-]?image/.test(name)) return "media";
|
|
24
|
+
if (/spawn[_-]?agent|send[_-]?message|followup[_-]?task|wait[_-]?agent|collaboration/.test(name)) return "agents";
|
|
25
|
+
if (/update[_-]?plan|request[_-]?user[_-]?input|create[_-]?goal|update[_-]?goal/.test(name)) return "planning";
|
|
26
|
+
if (/exec|shell|bash|terminal|write[_-]?stdin|command/.test(name)) return "terminal";
|
|
27
|
+
if (/^mcp__|connector|plugin|slack|github|notion|linear|figma/.test(name)) return "integrations";
|
|
28
|
+
return "other";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function toolCategoryForRow(row) {
|
|
32
|
+
if (row?.type !== "response_item") return null;
|
|
33
|
+
const payloadType = row.payload?.type;
|
|
34
|
+
if (payloadType !== "custom_tool_call" && payloadType !== "function_call") return null;
|
|
35
|
+
return normalizeToolCategory(row.payload?.tool_category);
|
|
36
|
+
}
|
package/src/parsers/codex.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
|
|
6
6
|
import { rowDate } from "../lib/time.mjs";
|
|
7
|
+
import { classifyToolName } from "../lib/tool-category.mjs";
|
|
8
|
+
import { projectDescriptorFromSessionMeta } from "../core/project-identity.mjs";
|
|
7
9
|
|
|
8
10
|
const READ_CHUNK_BYTES = 256 * 1024;
|
|
9
11
|
const MAX_JSONL_ROW_BYTES = 64 * 1024 * 1024;
|
|
@@ -70,6 +72,11 @@ function compactPayload(rowType, value) {
|
|
|
70
72
|
if (firstTokenMs !== null) payload.time_to_first_token_ms = firstTokenMs;
|
|
71
73
|
const totalTokenUsage = compactTokenUsage(value.info?.total_token_usage);
|
|
72
74
|
if (totalTokenUsage) payload.info = { total_token_usage: totalTokenUsage };
|
|
75
|
+
} else if (
|
|
76
|
+
rowType === "response_item" &&
|
|
77
|
+
(value.type === "custom_tool_call" || value.type === "function_call")
|
|
78
|
+
) {
|
|
79
|
+
payload.tool_category = classifyToolName(value.name);
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
return Object.keys(payload).length ? payload : null;
|
|
@@ -109,7 +116,7 @@ function sessionReadError(file, error) {
|
|
|
109
116
|
);
|
|
110
117
|
}
|
|
111
118
|
|
|
112
|
-
function readJsonl(file) {
|
|
119
|
+
function readJsonl(file, projectSecret = null) {
|
|
113
120
|
const rows = [];
|
|
114
121
|
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
115
122
|
const lineParts = [];
|
|
@@ -119,6 +126,7 @@ function readJsonl(file) {
|
|
|
119
126
|
let tail = Buffer.alloc(0);
|
|
120
127
|
let skippingOversizedLine = false;
|
|
121
128
|
let fileDescriptor = null;
|
|
129
|
+
let project = null;
|
|
122
130
|
|
|
123
131
|
const resetLine = () => {
|
|
124
132
|
lineParts.length = 0;
|
|
@@ -154,7 +162,11 @@ function readJsonl(file) {
|
|
|
154
162
|
const line = lineBuffer.toString("utf8").replace(/\r$/, "");
|
|
155
163
|
if (line.trim()) {
|
|
156
164
|
try {
|
|
157
|
-
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);
|
|
158
170
|
if (compact) rows.push(compact);
|
|
159
171
|
} catch {
|
|
160
172
|
console.warn(`跳过无法解析的记录:${file.filePath}:${lineIndex}`);
|
|
@@ -194,11 +206,12 @@ function readJsonl(file) {
|
|
|
194
206
|
rows,
|
|
195
207
|
byteLength: offset,
|
|
196
208
|
tailHash: crypto.createHash("sha256").update(tail).digest("hex"),
|
|
209
|
+
project,
|
|
197
210
|
};
|
|
198
211
|
}
|
|
199
212
|
|
|
200
|
-
function sessionFromFile(file) {
|
|
201
|
-
const { rows, byteLength, tailHash } = readJsonl(file);
|
|
213
|
+
function sessionFromFile(file, projectSecret = null) {
|
|
214
|
+
const { rows, byteLength, tailHash, project } = readJsonl(file, projectSecret);
|
|
202
215
|
const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
|
|
203
216
|
const metadataSessionId = meta.session_id || meta.id || null;
|
|
204
217
|
const timestamps = rows.map(rowDate).filter(Boolean).sort((left, right) => left - right);
|
|
@@ -209,6 +222,8 @@ function sessionFromFile(file) {
|
|
|
209
222
|
modifiedAt: file.modifiedAt,
|
|
210
223
|
sessionId: metadataSessionId || path.basename(file.filePath, ".jsonl"),
|
|
211
224
|
identityQuality: metadataSessionId ? "metadata" : "filename_fallback",
|
|
225
|
+
projectId: project?.projectId || null,
|
|
226
|
+
projectLabel: project?.projectLabel || null,
|
|
212
227
|
sourceRevision: {
|
|
213
228
|
kind: "append-only-jsonl-v1",
|
|
214
229
|
row_count: rows.length,
|
|
@@ -275,12 +290,16 @@ export function deduplicateCodexSessions(sessions) {
|
|
|
275
290
|
return [...selected.values()];
|
|
276
291
|
}
|
|
277
292
|
|
|
278
|
-
export function loadCodexSessions(range, { codexHome = null } = {}) {
|
|
293
|
+
export function loadCodexSessions(range, { codexHome = null, projectSecret = null } = {}) {
|
|
279
294
|
const files = codexSessionFiles(codexHome);
|
|
280
295
|
let candidates = files;
|
|
281
296
|
let scanMode = "none";
|
|
282
297
|
|
|
283
|
-
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") {
|
|
284
303
|
candidates = files.slice(0, 40);
|
|
285
304
|
} else if (range.scope === "session" && range.sessionId) {
|
|
286
305
|
const filenameMatches = files.filter((file) => path.basename(file.filePath).includes(range.sessionId));
|
|
@@ -291,7 +310,8 @@ export function loadCodexSessions(range, { codexHome = null } = {}) {
|
|
|
291
310
|
scanMode = fullScan ? "full" : "best_effort";
|
|
292
311
|
}
|
|
293
312
|
|
|
294
|
-
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)
|
|
295
315
|
.sort((left, right) => right.endAt - left.endAt);
|
|
296
316
|
|
|
297
317
|
if (range.scope === "latest") {
|
|
@@ -340,3 +360,34 @@ export function listRecentCodexSessions(limit = 10, { codexHome = null } = {}) {
|
|
|
340
360
|
};
|
|
341
361
|
});
|
|
342
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
|
+
}
|