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.
@@ -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) {
@@ -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
  }
@@ -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
+ }
@@ -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 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);
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.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") {
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
+ }