codex-work-receipt 0.6.0 → 0.7.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/cli.mjs CHANGED
@@ -9,12 +9,13 @@ import QRCode from "qrcode";
9
9
  import { parseArgs, printHelp } from "./core/args.mjs";
10
10
  import { buildCanonicalFacts } from "./core/fact-buckets.mjs";
11
11
  import { collectMetrics } from "./core/metrics.mjs";
12
- import { getReceiptCopy } from "./core/presentation.mjs";
12
+ import { getScopeLabel } from "./core/presentation.mjs";
13
13
  import { encodeReceiptPayloads } from "./core/qr-payload.mjs";
14
14
  import { outputSlugForRange, resolveRange } from "./core/range.mjs";
15
15
  import { buildReceiptRecord, persistReceiptRecord } from "./core/receipt-record.mjs";
16
16
  import { promptForRange } from "./core/selector.mjs";
17
17
  import { installCodexSkill } from "./core/skill-installer.mjs";
18
+ import { installCodexPet, uninstallCodexPet } from "./core/pet-installer.mjs";
18
19
  import { formatNumber } from "./lib/time.mjs";
19
20
  import { listRecentCodexSessions, loadCodexSessions } from "./parsers/codex.mjs";
20
21
  import { renderHtml } from "./renderers/html.mjs";
@@ -64,6 +65,33 @@ async function main() {
64
65
  }
65
66
  return;
66
67
  }
68
+ if (options.installPet || options.installCompanion) {
69
+ const installedPet = installCodexPet({ projectDir: PROJECT_DIR });
70
+ const installedSkill = options.installCompanion
71
+ ? installCodexSkill({ projectDir: PROJECT_DIR })
72
+ : null;
73
+ if (options.locale === "en") {
74
+ if (installedSkill) console.log(`AI Work Receipt skill installed: ${installedSkill.targetDir}`);
75
+ console.log(`Codex pet installed: ${installedPet.targetDir}`);
76
+ console.log("Restart Codex, open Settings > Pets, select Refresh, then choose 票仔 · AI 小票工 (Ticket Buddy).");
77
+ console.log("Use /pet to wake it. You can then ask: Ticket Buddy, create a receipt for today.");
78
+ } else {
79
+ if (installedSkill) console.log(`AI 打工小票 Skill 已安装:${installedSkill.targetDir}`);
80
+ console.log(`Codex 桌宠已安装:${installedPet.targetDir}`);
81
+ console.log("请重启 Codex,打开 Settings > Pets,点击 Refresh 后选择“票仔 · AI 小票工”。");
82
+ console.log("输入 /pet 唤醒票仔;以后可以说:票仔,开今天的票。");
83
+ }
84
+ return;
85
+ }
86
+ if (options.uninstallPet) {
87
+ const removed = uninstallCodexPet();
88
+ if (options.locale === "en") {
89
+ console.log(removed.existed ? `Codex pet removed: ${removed.targetDir}` : "Codex pet was not installed.");
90
+ } else {
91
+ console.log(removed.existed ? `Codex 桌宠已卸载:${removed.targetDir}` : "尚未安装 AI 打工小票桌宠。");
92
+ }
93
+ return;
94
+ }
67
95
 
68
96
  if (!options.modeExplicit && process.stdin.isTTY && process.stdout.isTTY) {
69
97
  const selected = await promptForRange({
@@ -73,13 +101,16 @@ async function main() {
73
101
  });
74
102
  options.mode = selected.mode;
75
103
  options.sessionId = selected.sessionId;
104
+ options.hours = selected.hours || options.hours;
76
105
  }
77
106
 
78
- const range = resolveRange(options.mode, options.timezone, new Date(), options.sessionId);
107
+ const range = resolveRange(options.mode, options.timezone, new Date(), options.sessionId, options.hours);
79
108
  const sessions = loadCodexSessions(range);
80
109
  const metrics = collectMetrics(sessions, range);
81
110
  const observedAt = new Date().toISOString();
82
- const canonical = buildCanonicalFacts(sessions, range, { observedAt });
111
+ const canonical = range.scope === "last-hours"
112
+ ? {}
113
+ : buildCanonicalFacts(sessions, range, { observedAt });
83
114
  const record = buildReceiptRecord(metrics, options.theme, options.locale, canonical);
84
115
  const qrPayloads = encodeReceiptPayloads(record);
85
116
  const dataQrDataUrls = await Promise.all(qrPayloads.map((payload) => QRCode.toDataURL(payload, {
@@ -109,17 +140,21 @@ async function main() {
109
140
  console.log(`Generated HTML: ${outputFile}`);
110
141
  console.log(`Structured data: ${persisted.companionPath}`);
111
142
  console.log(`Local history: ${persisted.receiptPath}`);
112
- console.log(`Range: ${getReceiptCopy(options.locale).scope[record.source.scope]} · ${record.stats.session_count} session(s)`);
143
+ console.log(`Range: ${getScopeLabel(record.source.scope, options.locale, record.source.hours)} · ${record.stats.session_count} session(s)`);
113
144
  console.log(`Stats: ${record.stats.completed_turns} turns · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Tokens · ${record.stats.tool_calls} tool calls`);
114
- console.log(`Data QR: ${qrPayloads.length} code(s) · ${record.manifest.fact_count} canonical fact(s) · schema v${record.schema_version}`);
145
+ console.log(record.manifest
146
+ ? `Data QR: ${qrPayloads.length} code(s) · ${record.manifest.fact_count} canonical fact(s) · schema v${record.schema_version}`
147
+ : `Data QR: ${qrPayloads.length} code(s) · rolling summary · schema v${record.schema_version}`);
115
148
  if (!miniProgramCodeDataUrl) console.log("Mini-program code: not configured; using the explicit placeholder");
116
149
  } else {
117
150
  console.log(`已生成网页:${outputFile}`);
118
151
  console.log(`结构数据:${persisted.companionPath}`);
119
152
  console.log(`本地历史:${persisted.receiptPath}`);
120
- console.log(`统计范围:${getReceiptCopy(options.locale).scope[record.source.scope]} · ${record.stats.session_count} 个会话`);
153
+ console.log(`统计范围:${getScopeLabel(record.source.scope, options.locale, record.source.hours)} · ${record.stats.session_count} 个会话`);
121
154
  console.log(`统计:${record.stats.completed_turns} 轮 · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Token · ${record.stats.tool_calls} 次工具调用`);
122
- console.log(`数据二维码:${qrPayloads.length} 个 · ${record.manifest.fact_count} 条规范事实 · schema v${record.schema_version}`);
155
+ console.log(record.manifest
156
+ ? `数据二维码:${qrPayloads.length} 个 · ${record.manifest.fact_count} 条规范事实 · schema v${record.schema_version}`
157
+ : `数据二维码:${qrPayloads.length} 个 · 滚动摘要 · schema v${record.schema_version}`);
123
158
  if (!miniProgramCodeDataUrl) console.log("小程序码:尚未配置,页面使用明确占位符");
124
159
  }
125
160
  if (options.open) openFile(outputFile);
package/src/core/args.mjs CHANGED
@@ -7,13 +7,16 @@ Codex AI Work Receipt
7
7
  Usage:
8
8
  npx codex-work-receipt@latest
9
9
  npx codex-work-receipt@latest --latest --lang en
10
+ npx codex-work-receipt@latest --hours 3 --lang en
10
11
  npx codex-work-receipt@latest --today --lang en
11
12
  npx codex-work-receipt@latest --range last-7-days --lang en
12
13
  npx codex-work-receipt@latest --range this-week --lang en
13
14
  npx codex-work-receipt@latest --install-skill --lang en
15
+ npx codex-work-receipt@latest --install-companion --lang en
14
16
 
15
17
  Options:
16
- --range <name> Range: latest, today, last-7-days, this-week
18
+ --range <name> Range: latest, last-hours, today, last-7-days, this-week
19
+ --hours <number> Summarize the last 1-168 hours
17
20
  --latest Summarize the latest active Codex session (default)
18
21
  --today Summarize all Codex activity from today
19
22
  --session <id> Summarize one specific Codex session
@@ -23,6 +26,9 @@ Options:
23
26
  --output <file> Set the generated HTML path
24
27
  --data-dir <directory> Set the local structured-history directory
25
28
  --install-skill Install the natural-language Codex skill
29
+ --install-pet Install the Codex pet only
30
+ --uninstall-pet Remove the installed Codex pet
31
+ --install-companion Install both the skill and Codex pet
26
32
  --no-open Do not open the browser after generation
27
33
  --help Show help
28
34
  ` : `
@@ -31,13 +37,16 @@ Codex AI 打工小票
31
37
  用法:
32
38
  npx codex-work-receipt@latest
33
39
  npx codex-work-receipt@latest --latest
40
+ npx codex-work-receipt@latest --hours 3
34
41
  npx codex-work-receipt@latest --today
35
42
  npx codex-work-receipt@latest --range last-7-days
36
43
  npx codex-work-receipt@latest --range this-week
37
44
  npx codex-work-receipt@latest --install-skill
45
+ npx codex-work-receipt@latest --install-companion
38
46
 
39
47
  选项:
40
- --range <name> 统计范围:latest、today、last-7-days、this-week
48
+ --range <name> 统计范围:latest、last-hours、today、last-7-days、this-week
49
+ --hours <number> 统计最近 1~168 小时
41
50
  --latest 统计最近活跃的 Codex 会话(默认)
42
51
  --today 统计本地时区今天发生的全部 Codex 活动
43
52
  --session <id> 统计指定的 Codex 会话
@@ -47,6 +56,9 @@ Codex AI 打工小票
47
56
  --output <file> 指定生成的 HTML 文件,默认写入 ./codex-work-receipt-output/
48
57
  --data-dir <directory> 指定本地结构数据目录
49
58
  --install-skill 安装可通过自然语言调用的 Codex Skill
59
+ --install-pet 只安装 Codex 桌宠
60
+ --uninstall-pet 卸载 AI 打工小票 Codex 桌宠
61
+ --install-companion 同时安装 Skill 和 Codex 桌宠
50
62
  --no-open 生成后不自动打开浏览器
51
63
  --help 显示帮助
52
64
  `);
@@ -57,12 +69,16 @@ export function parseArgs(argv) {
57
69
  mode: "latest",
58
70
  modeExplicit: false,
59
71
  sessionId: null,
72
+ hours: null,
60
73
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai",
61
74
  locale: "zh-CN",
62
75
  theme: "classic",
63
76
  output: null,
64
77
  dataDir: null,
65
78
  installSkill: false,
79
+ installPet: false,
80
+ uninstallPet: false,
81
+ installCompanion: false,
66
82
  open: true,
67
83
  };
68
84
 
@@ -73,6 +89,7 @@ export function parseArgs(argv) {
73
89
  ["--output", "output"],
74
90
  ["--data-dir", "dataDir"],
75
91
  ["--session", "sessionId"],
92
+ ["--hours", "hours"],
76
93
  ]);
77
94
 
78
95
  for (let index = 0; index < argv.length; index += 1) {
@@ -83,7 +100,10 @@ export function parseArgs(argv) {
83
100
  } else if (argument === "--today") {
84
101
  result.mode = "today";
85
102
  result.modeExplicit = true;
86
- } else if (argument === "--range") {
103
+ } else if (argument === "--install-pet") result.installPet = true;
104
+ else if (argument === "--uninstall-pet") result.uninstallPet = true;
105
+ else if (argument === "--install-companion") result.installCompanion = true;
106
+ else if (argument === "--range") {
87
107
  const value = argv[++index];
88
108
  if (!value) throw new Error("--range 需要提供值");
89
109
  const scope = normalizeScope(value);
@@ -101,6 +121,9 @@ export function parseArgs(argv) {
101
121
  if (key === "sessionId") {
102
122
  result.mode = "session";
103
123
  result.modeExplicit = true;
124
+ } else if (key === "hours") {
125
+ result.mode = "last-hours";
126
+ result.modeExplicit = true;
104
127
  }
105
128
  } else throw new Error(`不认识的参数:${argument}`);
106
129
  }
@@ -111,5 +134,12 @@ export function parseArgs(argv) {
111
134
  if (!new Set(["zh-CN", "en"]).has(result.locale)) {
112
135
  throw new Error(`不支持的语言:${result.locale}`);
113
136
  }
137
+ if (result.hours !== null) {
138
+ result.hours = Number(result.hours);
139
+ if (!Number.isInteger(result.hours) || result.hours < 1 || result.hours > 168) {
140
+ throw new Error("--hours 需要 1 至 168 之间的整数");
141
+ }
142
+ }
143
+ if (result.mode === "last-hours" && result.hours === null) result.hours = 3;
114
144
  return result;
115
145
  }
@@ -0,0 +1,52 @@
1
+ const CODE128_PATTERNS = [
2
+ "212222", "222122", "222221", "121223", "121322", "131222", "122213", "122312", "132212", "221213", "221312", "231212",
3
+ "112232", "122132", "122231", "113222", "123122", "123221", "223211", "221132", "221231", "213212", "223112", "312131",
4
+ "311222", "321122", "321221", "312212", "322112", "322211", "212123", "212321", "232121", "111323", "131123", "131321",
5
+ "112313", "132113", "132311", "211313", "231113", "231311", "112133", "112331", "132131", "113123", "113321", "133121",
6
+ "313121", "211331", "231131", "213113", "213311", "213131", "311123", "311321", "331121", "312113", "312311", "332111",
7
+ "314111", "221411", "431111", "111224", "111422", "121124", "121421", "141122", "141221", "112214", "112412", "122114",
8
+ "122411", "142112", "142211", "241211", "221114", "413111", "241112", "134111", "111242", "121142", "121241", "114212",
9
+ "124112", "124211", "411212", "421112", "421211", "212141", "214121", "412121", "111143", "111341", "131141", "114113",
10
+ "114311", "411113", "411311", "113141", "114131", "311141", "411131", "211412", "211214", "211232", "2331112",
11
+ ];
12
+
13
+ const START_CODE_B = 104;
14
+ const STOP_CODE = 106;
15
+
16
+ function normalizeCode128Value(value) {
17
+ return [...String(value)]
18
+ .map((character) => {
19
+ const code = character.charCodeAt(0);
20
+ return code >= 32 && code <= 126 ? character : "?";
21
+ })
22
+ .join("")
23
+ .slice(0, 48) || "CWR";
24
+ }
25
+
26
+ export function buildCode128B(value) {
27
+ const normalized = normalizeCode128Value(value);
28
+ const dataCodes = [...normalized].map((character) => character.charCodeAt(0) - 32);
29
+ const checksum = dataCodes.reduce(
30
+ (total, code, index) => total + code * (index + 1),
31
+ START_CODE_B,
32
+ ) % 103;
33
+ const codes = [START_CODE_B, ...dataCodes, checksum, STOP_CODE];
34
+ const segments = [];
35
+
36
+ for (const code of codes) {
37
+ const pattern = CODE128_PATTERNS[code];
38
+ for (const [index, width] of [...pattern].entries()) {
39
+ segments.push({
40
+ isBar: index % 2 === 0,
41
+ width: Number(width),
42
+ });
43
+ }
44
+ }
45
+
46
+ return {
47
+ value: normalized,
48
+ checksum,
49
+ segments,
50
+ totalModules: segments.reduce((total, segment) => total + segment.width, 0),
51
+ };
52
+ }
@@ -28,12 +28,19 @@ export function buildLogicalReceiptKey(metrics) {
28
28
  if (metrics.mode === "latest" || metrics.mode === "session") {
29
29
  return `${metrics.mode}:${sessionPart}:${metrics.timezone}`;
30
30
  }
31
+ if (metrics.mode === "last-hours") {
32
+ return `last-hours:${metrics.windowHours}:${metrics.windowStartAt.toISOString()}:${metrics.windowEndAt.toISOString()}:${metrics.timezone}`;
33
+ }
31
34
  if (metrics.mode === "this-week") {
32
35
  return `this-week:${metrics.rangeStartDate}:${metrics.timezone}`;
33
36
  }
34
37
  return `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}`;
35
38
  }
36
39
 
40
+ export function buildSummaryReceiptId(logicalReceiptKey) {
41
+ return stableId("cwr", "codex-work-receipt/receipt/v1", [logicalReceiptKey]);
42
+ }
43
+
37
44
  export function buildProtocolReceiptId(sourceVersion, logicalReceiptKey) {
38
45
  return stableId("cwr2", "codex-work-receipt/receipt/v2", [sourceVersion, logicalReceiptKey]);
39
46
  }
@@ -1,5 +1,10 @@
1
1
  import { dateKey, rowDate } from "../lib/time.mjs";
2
- import { calendarDayCount, isCalendarScope, isDateInRange } from "./range.mjs";
2
+ import {
3
+ calendarDayCount,
4
+ isCalendarScope,
5
+ isDateInRange,
6
+ isTimeWindowScope,
7
+ } from "./range.mjs";
3
8
  import { selectWorkProfileId } from "./presentation.mjs";
4
9
 
5
10
  function zeroUsage() {
@@ -28,7 +33,8 @@ function sessionTokenUsage(rows, range) {
28
33
  for (const row of events) {
29
34
  const currentUsage = row.payload.info.total_token_usage;
30
35
  const date = rowDate(row);
31
- const selected = !isCalendarScope(range.scope) || isDateInRange(date, range);
36
+ const selected = (!isCalendarScope(range.scope) && !isTimeWindowScope(range.scope))
37
+ || isDateInRange(date, range);
32
38
 
33
39
  for (const key of Object.keys(totals)) {
34
40
  const current = Math.max(0, Number(currentUsage[key] || 0));
@@ -56,6 +62,7 @@ function calculateWorkPoints(metrics) {
56
62
  }
57
63
 
58
64
  function emptyRangeMessage(range) {
65
+ if (range.scope === "last-hours") return `最近 ${range.hours} 小时没有找到 Codex 活动`;
59
66
  if (range.scope === "today") return `没有找到 ${range.targetDate} 的 Codex 活动`;
60
67
  if (range.scope === "last-7-days") return `没有找到 ${range.startDate} 至 ${range.endDate} 的 Codex 活动`;
61
68
  if (range.scope === "this-week") return `本周暂时没有找到 Codex 活动`;
@@ -78,7 +85,7 @@ export function collectMetrics(sessions, range) {
78
85
  const models = new Set();
79
86
 
80
87
  for (const session of sessions) {
81
- const scopedRows = isCalendarScope(range.scope)
88
+ const scopedRows = isCalendarScope(range.scope) || isTimeWindowScope(range.scope)
82
89
  ? session.rows.filter((row) => isDateInRange(rowDate(row), range))
83
90
  : session.rows;
84
91
 
@@ -134,6 +141,9 @@ export function collectMetrics(sessions, range) {
134
141
  targetDate: range.targetDate,
135
142
  rangeStartDate,
136
143
  rangeEndDate,
144
+ windowStartAt: range.startAt,
145
+ windowEndAt: range.endAt,
146
+ windowHours: range.hours,
137
147
  calendarDayCount: isCalendarScope(range.scope) ? calendarDayCount(range) : 1,
138
148
  activeDayCount: Math.max(1, activeDateKeys.size),
139
149
  sessionIds,
@@ -0,0 +1,50 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const CODEX_PET_NAME = "ai-work-receipt";
6
+
7
+ export function getCodexPetHome({ homeDir = os.homedir(), codexHome = null } = {}) {
8
+ return path.resolve(codexHome || process.env.CODEX_HOME || path.join(homeDir, ".codex"));
9
+ }
10
+
11
+ export function getCodexPetInstallPath(options = {}) {
12
+ return path.join(getCodexPetHome(options), "pets", CODEX_PET_NAME);
13
+ }
14
+
15
+ export function installCodexPet({ projectDir, homeDir = os.homedir(), codexHome = null } = {}) {
16
+ if (!projectDir) throw new Error("安装 Pet 时缺少项目目录");
17
+
18
+ const sourceDir = path.join(projectDir, "assets", "codex-pet", CODEX_PET_NAME);
19
+ const sourceManifest = path.join(sourceDir, "pet.json");
20
+ const sourceSpritesheet = path.join(sourceDir, "spritesheet.webp");
21
+ if (!fs.existsSync(sourceManifest) || !fs.existsSync(sourceSpritesheet)) {
22
+ throw new Error(`发布包中缺少 Codex Pet:${sourceDir}`);
23
+ }
24
+
25
+ const targetDir = getCodexPetInstallPath({ homeDir, codexHome });
26
+ const targetParent = path.dirname(targetDir);
27
+ const stagingDir = path.join(
28
+ targetParent,
29
+ `.${CODEX_PET_NAME}.install-${process.pid}-${Date.now()}`,
30
+ );
31
+
32
+ fs.mkdirSync(targetParent, { recursive: true });
33
+ try {
34
+ fs.cpSync(sourceDir, stagingDir, { recursive: true });
35
+ fs.rmSync(targetDir, { recursive: true, force: true });
36
+ fs.renameSync(stagingDir, targetDir);
37
+ } catch (error) {
38
+ fs.rmSync(stagingDir, { recursive: true, force: true });
39
+ throw error;
40
+ }
41
+
42
+ return { sourceDir, targetDir };
43
+ }
44
+
45
+ export function uninstallCodexPet({ homeDir = os.homedir(), codexHome = null } = {}) {
46
+ const targetDir = getCodexPetInstallPath({ homeDir, codexHome });
47
+ const existed = fs.existsSync(targetDir);
48
+ fs.rmSync(targetDir, { recursive: true, force: true });
49
+ return { targetDir, existed };
50
+ }
@@ -93,6 +93,7 @@ const RECEIPT_COPY = {
93
93
  scope: {
94
94
  latest: "最近一次会话",
95
95
  session: "指定会话",
96
+ "last-hours": "最近 {hours} 小时",
96
97
  today: "今日全部会话",
97
98
  "last-7-days": "最近 7 个自然日",
98
99
  "this-week": "本周全部会话",
@@ -133,6 +134,12 @@ const RECEIPT_COPY = {
133
134
  openMiniProgramHint: "微信扫码进入 AI 打工图鉴",
134
135
  importData: "2 · 扫描导入数据",
135
136
  importDataHint: "在小程序里点击“从电脑导入”后扫描",
137
+ multipartOpenTitle: "先打开小程序",
138
+ multipartOpenHint: "数据码将在 {seconds} 秒后自动开始轮播,无需点击电脑",
139
+ multipartTransferTitle: "保持手机对准屏幕",
140
+ multipartTransferHint: "数据码会自动逐张轮播;漏扫或重复都没关系",
141
+ multipartPartLabel: "数据分片 {current}/{total}",
142
+ multipartRestart: "重新显示小程序码",
136
143
  dataQrAlt: "当前小票数据二维码",
137
144
  miniProgramAlt: "微信小程序码",
138
145
  placeholderLabel: "小程序码",
@@ -159,6 +166,7 @@ const RECEIPT_COPY = {
159
166
  scope: {
160
167
  latest: "Latest session",
161
168
  session: "Selected session",
169
+ "last-hours": "Last {hours} hours",
162
170
  today: "All sessions today",
163
171
  "last-7-days": "Last 7 calendar days",
164
172
  "this-week": "All sessions this week",
@@ -199,6 +207,12 @@ const RECEIPT_COPY = {
199
207
  openMiniProgramHint: "Scan with WeChat to open AI Work Archive",
200
208
  importData: "2 · Import receipt data",
201
209
  importDataHint: "Tap “从电脑导入小票” in the mini program, then scan this code",
210
+ multipartOpenTitle: "Open the mini program first",
211
+ multipartOpenHint: "The data codes will start rotating automatically in {seconds}s. No desktop click is required.",
212
+ multipartTransferTitle: "Keep your phone pointed at the screen",
213
+ multipartTransferHint: "Data codes rotate automatically. Missed or duplicate scans are safe.",
214
+ multipartPartLabel: "Data part {current}/{total}",
215
+ multipartRestart: "Show mini-program code again",
202
216
  dataQrAlt: "Current receipt data QR code",
203
217
  miniProgramAlt: "WeChat mini-program code",
204
218
  placeholderLabel: "Mini program",
@@ -213,6 +227,7 @@ const COMPENSATION_COPY = {
213
227
  "zh-CN": {
214
228
  latest: "本单工资",
215
229
  session: "本单工资",
230
+ "last-hours": "本段工资",
216
231
  today: "本日工资",
217
232
  "last-7-days": "近七日工资",
218
233
  "this-week": "本周工资",
@@ -222,6 +237,7 @@ const COMPENSATION_COPY = {
222
237
  en: {
223
238
  latest: "SHIFT PAY",
224
239
  session: "SHIFT PAY",
240
+ "last-hours": "WINDOW PAY",
225
241
  today: "TODAY'S PAY",
226
242
  "last-7-days": "7-DAY PAY",
227
243
  "this-week": "THIS WEEK'S PAY",
@@ -250,6 +266,11 @@ export function getReceiptCopy(locale = DEFAULT_LOCALE) {
250
266
  return RECEIPT_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
251
267
  }
252
268
 
269
+ export function getScopeLabel(scope, locale = DEFAULT_LOCALE, hours = null) {
270
+ const template = getReceiptCopy(locale).scope[scope] || getReceiptCopy(locale).scope.latest;
271
+ return String(template).replaceAll("{hours}", String(hours || 3));
272
+ }
273
+
253
274
  export function buildCompensation(scope, amount, locale = DEFAULT_LOCALE) {
254
275
  const copy = COMPENSATION_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
255
276
  return {
@@ -3,6 +3,7 @@ import { dateKey } from "../lib/time.mjs";
3
3
  export const RECEIPT_SCOPES = new Set([
4
4
  "latest",
5
5
  "session",
6
+ "last-hours",
6
7
  "today",
7
8
  "last-7-days",
8
9
  "this-week",
@@ -11,6 +12,8 @@ export const RECEIPT_SCOPES = new Set([
11
12
  const SCOPE_ALIASES = new Map([
12
13
  ["latest", "latest"],
13
14
  ["session", "session"],
15
+ ["hours", "last-hours"],
16
+ ["last-hours", "last-hours"],
14
17
  ["today", "today"],
15
18
  ["7d", "last-7-days"],
16
19
  ["last-7-days", "last-7-days"],
@@ -38,7 +41,13 @@ function mondayDateKey(value) {
38
41
  return shiftDateKey(value, -daysSinceMonday);
39
42
  }
40
43
 
41
- export function resolveRange(scope, timezone, now = new Date(), sessionId = null) {
44
+ function floorToMinute(value) {
45
+ const date = new Date(value);
46
+ date.setUTCSeconds(0, 0);
47
+ return date;
48
+ }
49
+
50
+ export function resolveRange(scope, timezone, now = new Date(), sessionId = null, hours = null) {
42
51
  const normalizedScope = normalizeScope(scope);
43
52
  if (!normalizedScope || !RECEIPT_SCOPES.has(normalizedScope)) {
44
53
  throw new Error(`不支持的统计范围:${scope}`);
@@ -47,8 +56,20 @@ export function resolveRange(scope, timezone, now = new Date(), sessionId = null
47
56
  const targetDate = dateKey(now, timezone);
48
57
  let startDate = null;
49
58
  let endDate = null;
59
+ let startAt = null;
60
+ let endAt = null;
61
+ let windowHours = null;
50
62
 
51
- if (normalizedScope === "today") {
63
+ if (normalizedScope === "last-hours") {
64
+ windowHours = Number(hours ?? 3);
65
+ if (!Number.isInteger(windowHours) || windowHours < 1 || windowHours > 168) {
66
+ throw new Error("最近小时数必须是 1 至 168 之间的整数");
67
+ }
68
+ endAt = floorToMinute(now);
69
+ startAt = new Date(endAt.getTime() - windowHours * 60 * 60 * 1000);
70
+ startDate = dateKey(startAt, timezone);
71
+ endDate = dateKey(endAt, timezone);
72
+ } else if (normalizedScope === "today") {
52
73
  startDate = targetDate;
53
74
  endDate = targetDate;
54
75
  } else if (normalizedScope === "last-7-days") {
@@ -65,6 +86,9 @@ export function resolveRange(scope, timezone, now = new Date(), sessionId = null
65
86
  targetDate,
66
87
  startDate,
67
88
  endDate,
89
+ startAt,
90
+ endAt,
91
+ hours: windowHours,
68
92
  sessionId: sessionId || null,
69
93
  };
70
94
  }
@@ -73,8 +97,15 @@ export function isCalendarScope(scope) {
73
97
  return scope === "today" || scope === "last-7-days" || scope === "this-week";
74
98
  }
75
99
 
100
+ export function isTimeWindowScope(scope) {
101
+ return scope === "last-hours";
102
+ }
103
+
76
104
  export function isDateInRange(date, range) {
77
105
  if (!date) return false;
106
+ if (isTimeWindowScope(range.scope)) {
107
+ return date >= range.startAt && date <= range.endAt;
108
+ }
78
109
  if (!isCalendarScope(range.scope)) return true;
79
110
  const key = dateKey(date, range.timezone);
80
111
  return key >= range.startDate && key <= range.endDate;
@@ -107,6 +138,13 @@ export function outputSlugForRange(range, receiptId = "") {
107
138
  const endDate = range?.endDate || range?.targetDate || startDate;
108
139
 
109
140
  if (scope === "today") return `today-${safeSlugSegment(endDate, "today")}`;
141
+ if (scope === "last-hours") {
142
+ const endAt = range?.endAt instanceof Date ? range.endAt : new Date(range?.endAt || 0);
143
+ const endStamp = Number.isNaN(endAt.getTime())
144
+ ? safeSlugSegment(endDate, "window")
145
+ : endAt.toISOString().replace(/[-:]/g, "").slice(0, 13);
146
+ return `last-${Number(range?.hours || 3)}-hours-${safeSlugSegment(endStamp, "window")}`;
147
+ }
110
148
  if (scope === "last-7-days" || scope === "this-week") {
111
149
  const dateSpan = startDate === endDate ? endDate : `${startDate}-to-${endDate}`;
112
150
  return `${scope}-${safeSlugSegment(dateSpan, scope)}`;
@@ -4,7 +4,11 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  import { canonicalStringify, sha256Hex } from "./canonical.mjs";
7
- import { buildLogicalReceiptKey, buildProtocolReceiptId } from "./fact-identity.mjs";
7
+ import {
8
+ buildLogicalReceiptKey,
9
+ buildProtocolReceiptId,
10
+ buildSummaryReceiptId,
11
+ } from "./fact-identity.mjs";
8
12
  import {
9
13
  buildCompensation,
10
14
  DEFAULT_LOCALE,
@@ -22,7 +26,12 @@ function fingerprintSessionIds(sessionIds) {
22
26
  export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE, canonical = {}) {
23
27
  const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
24
28
  const logicalKey = buildLogicalReceiptKey(metrics);
25
- const id = buildProtocolReceiptId(SOURCE_VERSION, logicalKey);
29
+ const summaryOnly = metrics.mode === "last-hours";
30
+ const schemaVersion = summaryOnly ? 1 : SCHEMA_VERSION;
31
+ const sourceVersion = summaryOnly ? "cwr1" : SOURCE_VERSION;
32
+ const id = summaryOnly
33
+ ? buildSummaryReceiptId(logicalKey)
34
+ : buildProtocolReceiptId(SOURCE_VERSION, logicalKey);
26
35
  const snapshotHash = sha256Hex({
27
36
  start: metrics.startAt.toISOString(),
28
37
  end: metrics.endAt.toISOString(),
@@ -33,6 +42,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
33
42
  scope: metrics.mode,
34
43
  rangeStartDate: metrics.rangeStartDate,
35
44
  rangeEndDate: metrics.rangeEndDate,
45
+ windowStartAt: metrics.windowStartAt?.toISOString() || null,
46
+ windowEndAt: metrics.windowEndAt?.toISOString() || null,
36
47
  });
37
48
  const workProfileId = metrics.workProfileId || "temporary-hire";
38
49
  const workProfile = getWorkProfileCopy(workProfileId, locale);
@@ -59,23 +70,24 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
59
70
  manifest_hash: sha256Hex(canonicalStringify(manifestCore)),
60
71
  };
61
72
 
62
- return {
63
- schema_version: SCHEMA_VERSION,
73
+ const record = {
74
+ schema_version: schemaVersion,
64
75
  locale,
65
76
  id,
66
77
  generated_at: new Date().toISOString(),
67
78
  source: {
68
79
  type: "codex",
69
- version: SOURCE_VERSION,
80
+ version: sourceVersion,
70
81
  scope: metrics.mode,
82
+ hours: metrics.windowHours || null,
71
83
  collector_version: COLLECTOR_VERSION,
72
84
  logical_key: logicalKey,
73
85
  session_fingerprint: sessionFingerprint,
74
86
  snapshot_hash: snapshotHash,
75
87
  },
76
88
  period: {
77
- start_at: metrics.startAt.toISOString(),
78
- end_at: metrics.endAt.toISOString(),
89
+ start_at: (metrics.windowStartAt || metrics.startAt).toISOString(),
90
+ end_at: (metrics.windowEndAt || metrics.endAt).toISOString(),
79
91
  timezone: metrics.timezone,
80
92
  range_start_date: metrics.rangeStartDate,
81
93
  range_end_date: metrics.rangeEndDate,
@@ -107,9 +119,12 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
107
119
  contains_paths: false,
108
120
  contains_filenames: false,
109
121
  },
110
- manifest,
111
- facts,
112
122
  };
123
+ if (!summaryOnly) {
124
+ record.manifest = manifest;
125
+ record.facts = facts;
126
+ }
127
+ return record;
113
128
  }
114
129
 
115
130
  export function persistReceiptRecord(record, outputHtmlPath, requestedDataDir = null) {
@@ -37,21 +37,23 @@ export async function promptForRange({ locale, timezone, loadRecentSessions }) {
37
37
  try {
38
38
  console.log(isEnglish ? "\nChoose a receipt range:\n" : "\n请选择小票统计范围:\n");
39
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. 选择一个具体会话");
40
+ console.log(isEnglish ? "2. Last 3 hours" : "2. 最近 3 小时");
41
+ console.log(isEnglish ? "3. Last 7 calendar days" : "3. 最近 7 个自然日");
42
+ console.log(isEnglish ? "4. This week (Monday to now)" : "4. 本周(周一至今)");
43
+ console.log(isEnglish ? "5. Choose a specific session" : "5. 选择一个具体会话");
43
44
 
44
45
  const choice = await askForNumber(
45
46
  readline,
46
- isEnglish ? "\nEnter 1–4 (default 1): " : "\n请输入 1–4(默认 1):",
47
+ isEnglish ? "\nEnter 1–5 (default 1): " : "\n请输入 1–5(默认 1):",
47
48
  1,
48
- 4,
49
+ 5,
49
50
  1,
50
51
  );
51
52
 
52
53
  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 };
54
+ if (choice === 2) return { mode: "last-hours", sessionId: null, hours: 3 };
55
+ if (choice === 3) return { mode: "last-7-days", sessionId: null };
56
+ if (choice === 4) return { mode: "this-week", sessionId: null };
55
57
 
56
58
  const sessions = await loadRecentSessions();
57
59
  if (!sessions.length) throw new Error(isEnglish ? "No Codex sessions found" : "没有找到可选择的 Codex 会话");