codex-work-receipt 0.4.0 → 0.6.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.
@@ -80,11 +80,15 @@ const RECEIPT_COPY = {
80
80
  pageTitle: "Codex AI 打工小票",
81
81
  receiptTitle: "AI 打工小票",
82
82
  themeAria: "小票主题",
83
+ exportImage: "保存完整长图",
84
+ exportingImage: "正在生成…",
85
+ exportSuccess: "图片已保存",
86
+ exportError: "图片生成失败,请重试",
83
87
  receiptAria: "Codex AI 打工小票",
84
88
  themes: {
85
- classic: "经典热敏",
86
- diner: "餐馆结账",
87
- payroll: "办公室工资条",
89
+ classic: "经典白票",
90
+ diner: "复古粉票",
91
+ payroll: "夜班绿票",
88
92
  },
89
93
  scope: {
90
94
  latest: "最近一次会话",
@@ -142,11 +146,15 @@ const RECEIPT_COPY = {
142
146
  pageTitle: "Codex AI Work Receipt",
143
147
  receiptTitle: "AI Work Receipt",
144
148
  themeAria: "Receipt theme",
149
+ exportImage: "Save full PNG",
150
+ exportingImage: "Rendering…",
151
+ exportSuccess: "Image saved",
152
+ exportError: "Could not generate the image. Please try again.",
145
153
  receiptAria: "Codex AI Work Receipt",
146
154
  themes: {
147
- classic: "Classic Thermal",
148
- diner: "Vintage Diner",
149
- payroll: "Office Payroll",
155
+ classic: "Classic White",
156
+ diner: "Vintage Pink",
157
+ payroll: "Night Shift Green",
150
158
  },
151
159
  scope: {
152
160
  latest: "Latest session",
@@ -1,9 +1,17 @@
1
1
  import crypto from "node:crypto";
2
2
  import zlib from "node:zlib";
3
+ import QRCode from "qrcode";
3
4
 
4
5
  import { buildCompensation, getWorkProfileCopy } from "./presentation.mjs";
5
6
 
6
- export function compactReceipt(record) {
7
+ const MAX_QR_VERSION = 25;
8
+ const MAX_MULTIPART_PARTS = 12;
9
+
10
+ function checksum(value, length = 8) {
11
+ return crypto.createHash("sha256").update(value).digest("hex").slice(0, length);
12
+ }
13
+
14
+ function compactPresentation(record) {
7
15
  const profileId = record.presentation.work_profile;
8
16
  const scope = record.source?.scope || (record.presentation.compensation?.label === "本日工资" ? "today" : "latest");
9
17
  const mobileProfile = profileId
@@ -13,11 +21,21 @@ export function compactReceipt(record) {
13
21
  ? buildCompensation(scope, record.presentation.compensation?.amount, "zh-CN")
14
22
  : record.presentation.compensation;
15
23
 
24
+ return {
25
+ profileId,
26
+ scope,
27
+ mobileProfile,
28
+ mobileCompensation,
29
+ };
30
+ }
31
+
32
+ function compactBase(record) {
33
+ const presentation = compactPresentation(record);
16
34
  return {
17
35
  v: record.schema_version,
18
36
  i: record.id,
19
37
  g: record.generated_at,
20
- o: scope,
38
+ o: presentation.scope,
21
39
  d: [
22
40
  record.period.start_at,
23
41
  record.period.end_at,
@@ -43,35 +61,206 @@ export function compactReceipt(record) {
43
61
  ],
44
62
  m: record.stats.models,
45
63
  l: record.locale || "zh-CN",
46
- r: profileId || null,
64
+ r: presentation.profileId || null,
47
65
  p: [
48
66
  record.presentation.default_theme,
49
- mobileProfile.title,
50
- mobileProfile.review,
51
- mobileCompensation
67
+ presentation.mobileProfile.title,
68
+ presentation.mobileProfile.review,
69
+ presentation.mobileCompensation
52
70
  ? [
53
- mobileCompensation.label,
54
- mobileCompensation.amount,
55
- mobileCompensation.unit,
56
- mobileCompensation.note,
57
- mobileCompensation.formula_version,
71
+ presentation.mobileCompensation.label,
72
+ presentation.mobileCompensation.amount,
73
+ presentation.mobileCompensation.unit,
74
+ presentation.mobileCompensation.note,
75
+ presentation.mobileCompensation.formula_version,
58
76
  ]
59
77
  : null,
60
78
  ],
61
79
  };
62
80
  }
63
81
 
82
+ function compactFact(fact) {
83
+ const stats = fact.stats;
84
+ return [
85
+ fact.fact_id,
86
+ fact.session_id,
87
+ fact.identity_quality,
88
+ fact.source_type,
89
+ fact.local_date,
90
+ fact.bucket_start_at,
91
+ fact.bucket_end_at,
92
+ fact.source_watermark_at,
93
+ fact.observed_at,
94
+ [
95
+ fact.source_revision.kind,
96
+ fact.source_revision.row_count,
97
+ fact.source_revision.byte_length,
98
+ fact.source_revision.tail_hash,
99
+ ],
100
+ fact.content_hash,
101
+ [
102
+ stats.completed_turns,
103
+ stats.user_messages,
104
+ stats.tool_calls,
105
+ stats.interruptions,
106
+ stats.work_duration_ms,
107
+ stats.first_token_total_ms,
108
+ stats.first_token_sample_count,
109
+ stats.input_tokens,
110
+ stats.cached_input_tokens,
111
+ stats.output_tokens,
112
+ stats.reasoning_output_tokens,
113
+ stats.total_tokens,
114
+ stats.token_reset_count,
115
+ stats.models,
116
+ ],
117
+ ];
118
+ }
119
+
120
+ export function compactReceipt(record) {
121
+ const compact = compactBase(record);
122
+ if (record.schema_version !== 2) return compact;
123
+
124
+ const coverage = record.manifest.coverage;
125
+ return {
126
+ ...compact,
127
+ k: record.source.logical_key,
128
+ h: record.source.snapshot_hash,
129
+ a: [
130
+ record.manifest.version,
131
+ record.manifest.fact_schema_version,
132
+ record.manifest.metric_schema_version,
133
+ record.manifest.accounting_timezone,
134
+ record.manifest.fact_count,
135
+ record.manifest.fact_ids,
136
+ [
137
+ coverage.kind,
138
+ coverage.scan_mode,
139
+ coverage.start_date || null,
140
+ coverage.end_date || null,
141
+ coverage.complete_through_date || null,
142
+ coverage.observed_through_at,
143
+ ],
144
+ record.manifest.manifest_hash,
145
+ ],
146
+ f: record.facts.map(compactFact),
147
+ };
148
+ }
149
+
150
+ function encodeCompact(prefix, compact) {
151
+ const compressed = zlib.deflateRawSync(Buffer.from(JSON.stringify(compact), "utf8"));
152
+ return `${prefix}.${checksum(compressed)}.${compressed.toString("base64url")}`;
153
+ }
154
+
64
155
  export function encodeReceiptPayload(record) {
65
- const compressed = zlib.deflateRawSync(Buffer.from(JSON.stringify(compactReceipt(record)), "utf8"));
66
- const checksum = crypto.createHash("sha256").update(compressed).digest("hex").slice(0, 8);
67
- return `cwr1.${checksum}.${compressed.toString("base64url")}`;
156
+ return encodeCompact(record.schema_version === 2 ? "cwr2" : "cwr1", compactReceipt(record));
68
157
  }
69
158
 
70
- export function decodeReceiptPayload(payload) {
71
- const [prefix, checksum, encoded] = String(payload).split(".");
72
- if (prefix !== "cwr1" || !checksum || !encoded) throw new Error("无效的打工小票二维码");
159
+ function qrVersion(payload) {
160
+ return QRCode.create(payload, { errorCorrectionLevel: "M" }).version;
161
+ }
162
+
163
+ function fitsVersion(payload, maxVersion) {
164
+ try {
165
+ return qrVersion(payload) <= maxVersion;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ function multipartPayload(transferId, partIndex, partCount, totalChecksum, chunk) {
172
+ return `cwr2p.${transferId}.${partIndex}.${partCount}.${totalChecksum}.${checksum(chunk)}.${chunk}`;
173
+ }
174
+
175
+ function maxChunkSize(singlePayload, maxVersion) {
176
+ const transferId = checksum(singlePayload, 12);
177
+ const totalChecksum = checksum(singlePayload);
178
+ let low = 32;
179
+ let high = singlePayload.length;
180
+
181
+ while (low < high) {
182
+ const middle = Math.ceil((low + high) / 2);
183
+ const candidate = multipartPayload(transferId, 12, 12, totalChecksum, singlePayload.slice(0, middle));
184
+ if (fitsVersion(candidate, maxVersion)) low = middle;
185
+ else high = middle - 1;
186
+ }
187
+ return low;
188
+ }
189
+
190
+ export function encodeReceiptPayloads(record, options = {}) {
191
+ const maxVersion = options.maxVersion || MAX_QR_VERSION;
192
+ const maxParts = options.maxParts || MAX_MULTIPART_PARTS;
193
+ const singlePayload = encodeReceiptPayload(record);
194
+ if (fitsVersion(singlePayload, maxVersion)) return [singlePayload];
195
+ if (!singlePayload.startsWith("cwr2.")) {
196
+ throw new Error("旧版小票数据超过单个二维码容量");
197
+ }
198
+
199
+ const chunkSize = maxChunkSize(singlePayload, maxVersion);
200
+ const chunks = [];
201
+ for (let offset = 0; offset < singlePayload.length; offset += chunkSize) {
202
+ chunks.push(singlePayload.slice(offset, offset + chunkSize));
203
+ }
204
+ if (chunks.length > maxParts) {
205
+ throw new Error(`小票数据需要 ${chunks.length} 个二维码,超过 ${maxParts} 个上限,请缩小统计范围`);
206
+ }
207
+
208
+ const transferId = checksum(singlePayload, 12);
209
+ const totalChecksum = checksum(singlePayload);
210
+ return chunks.map((chunk, index) => (
211
+ multipartPayload(transferId, index + 1, chunks.length, totalChecksum, chunk)
212
+ ));
213
+ }
214
+
215
+ function decodeSinglePayload(payload) {
216
+ const [prefix, expectedChecksum, encoded] = String(payload).split(".");
217
+ if ((prefix !== "cwr1" && prefix !== "cwr2") || !expectedChecksum || !encoded) {
218
+ throw new Error("无效的打工小票二维码");
219
+ }
73
220
  const compressed = Buffer.from(encoded, "base64url");
74
- const actual = crypto.createHash("sha256").update(compressed).digest("hex").slice(0, 8);
75
- if (actual !== checksum) throw new Error("二维码数据校验失败");
221
+ if (checksum(compressed) !== expectedChecksum) throw new Error("二维码数据校验失败");
76
222
  return JSON.parse(zlib.inflateRawSync(compressed).toString("utf8"));
77
223
  }
224
+
225
+ export function decodeReceiptPayload(payload) {
226
+ return decodeSinglePayload(payload);
227
+ }
228
+
229
+ export function decodeMultipartReceiptPayloads(payloads) {
230
+ const parts = payloads.map((payload) => {
231
+ const fields = String(payload).split(".");
232
+ const [prefix, transferId, indexText, countText, totalChecksum, partChecksum] = fields;
233
+ const chunk = fields.slice(6).join(".");
234
+ const index = Number(indexText);
235
+ const count = Number(countText);
236
+ if (
237
+ prefix !== "cwr2p" || !transferId || !totalChecksum || !chunk ||
238
+ !Number.isInteger(index) || !Number.isInteger(count) || index < 1 || index > count || count > MAX_MULTIPART_PARTS ||
239
+ checksum(chunk) !== partChecksum
240
+ ) throw new Error("无效的分片二维码");
241
+ return { transferId, index, count, totalChecksum, chunk };
242
+ });
243
+ if (!parts.length) throw new Error("缺少分片二维码");
244
+ const first = parts[0];
245
+ if (parts.some((part) => (
246
+ part.transferId !== first.transferId || part.count !== first.count || part.totalChecksum !== first.totalChecksum
247
+ ))) throw new Error("分片二维码不属于同一张小票");
248
+ const unique = new Map(parts.map((part) => [part.index, part]));
249
+ if (unique.size !== first.count) throw new Error("分片二维码尚未集齐");
250
+ const singlePayload = [...unique.values()].sort((left, right) => left.index - right.index).map((part) => part.chunk).join("");
251
+ if (checksum(singlePayload) !== first.totalChecksum) throw new Error("分片二维码总校验失败");
252
+ return decodeSinglePayload(singlePayload);
253
+ }
254
+
255
+ export function inspectReceiptPayloadPart(payload) {
256
+ const fields = String(payload).split(".");
257
+ const [prefix, transferId, indexText, countText, totalChecksum, partChecksum] = fields;
258
+ const chunk = fields.slice(6).join(".");
259
+ if (prefix !== "cwr2p") return null;
260
+ const index = Number(indexText);
261
+ const count = Number(countText);
262
+ if (!transferId || !chunk || checksum(chunk) !== partChecksum || !Number.isInteger(index) || !Number.isInteger(count)) {
263
+ throw new Error("无效的分片二维码");
264
+ }
265
+ return { transferId, index, count, totalChecksum };
266
+ }
@@ -91,9 +91,28 @@ export function calendarDayCount(range) {
91
91
  return count;
92
92
  }
93
93
 
94
- export function outputSlugForScope(scope) {
95
- if (scope === "last-7-days") return "last-7-days";
96
- if (scope === "this-week") return "this-week";
97
- if (scope === "session") return "session";
98
- return scope;
94
+ function safeSlugSegment(value, fallback = "receipt") {
95
+ const normalized = String(value || "")
96
+ .trim()
97
+ .replace(/^cwr_/, "")
98
+ .replace(/[^a-zA-Z0-9-]+/g, "-")
99
+ .replace(/^-+|-+$/g, "")
100
+ .slice(0, 24);
101
+ return normalized || fallback;
102
+ }
103
+
104
+ export function outputSlugForRange(range, receiptId = "") {
105
+ const scope = normalizeScope(range?.scope) || "latest";
106
+ const startDate = range?.startDate || range?.targetDate || "";
107
+ const endDate = range?.endDate || range?.targetDate || startDate;
108
+
109
+ if (scope === "today") return `today-${safeSlugSegment(endDate, "today")}`;
110
+ if (scope === "last-7-days" || scope === "this-week") {
111
+ const dateSpan = startDate === endDate ? endDate : `${startDate}-to-${endDate}`;
112
+ return `${scope}-${safeSlugSegment(dateSpan, scope)}`;
113
+ }
114
+ if (scope === "session") {
115
+ return `session-${safeSlugSegment(range?.sessionId || receiptId, "selected")}`;
116
+ }
117
+ return `latest-${safeSlugSegment(receiptId || endDate, "receipt")}`;
99
118
  }
@@ -3,28 +3,27 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
+ import { canonicalStringify, sha256Hex } from "./canonical.mjs";
7
+ import { buildLogicalReceiptKey, buildProtocolReceiptId } from "./fact-identity.mjs";
6
8
  import {
7
9
  buildCompensation,
8
10
  DEFAULT_LOCALE,
9
11
  getWorkProfileCopy,
10
12
  } from "./presentation.mjs";
11
13
 
12
- const SCHEMA_VERSION = 1;
13
- const COLLECTOR_VERSION = "0.4.0";
14
+ const SCHEMA_VERSION = 2;
15
+ const SOURCE_VERSION = "cwr2";
16
+ const COLLECTOR_VERSION = "0.6.0";
14
17
 
15
18
  function fingerprintSessionIds(sessionIds) {
16
19
  return crypto.createHash("sha256").update([...sessionIds].sort().join("|")).digest("hex").slice(0, 16);
17
20
  }
18
21
 
19
- export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE) {
22
+ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE, canonical = {}) {
20
23
  const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
21
- const logicalKey = metrics.mode === "latest" || metrics.mode === "session"
22
- ? `latest:${sessionFingerprint}`
23
- : metrics.mode === "this-week"
24
- ? `this-week:${metrics.rangeStartDate}:${metrics.timezone}`
25
- : `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}`;
26
- const id = `cwr_${crypto.createHash("sha256").update(logicalKey).digest("hex").slice(0, 16)}`;
27
- const snapshotHash = crypto.createHash("sha256").update(JSON.stringify({
24
+ const logicalKey = buildLogicalReceiptKey(metrics);
25
+ const id = buildProtocolReceiptId(SOURCE_VERSION, logicalKey);
26
+ const snapshotHash = sha256Hex({
28
27
  start: metrics.startAt.toISOString(),
29
28
  end: metrics.endAt.toISOString(),
30
29
  tokens: metrics.tokens,
@@ -34,9 +33,31 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
34
33
  scope: metrics.mode,
35
34
  rangeStartDate: metrics.rangeStartDate,
36
35
  rangeEndDate: metrics.rangeEndDate,
37
- })).digest("hex").slice(0, 16);
36
+ });
38
37
  const workProfileId = metrics.workProfileId || "temporary-hire";
39
38
  const workProfile = getWorkProfileCopy(workProfileId, locale);
39
+ const facts = Array.isArray(canonical.facts) ? canonical.facts : [];
40
+ const coverage = canonical.coverage || {
41
+ kind: "selected_sessions",
42
+ scan_mode: "none",
43
+ start_date: metrics.rangeStartDate,
44
+ end_date: metrics.rangeEndDate,
45
+ complete_through_date: null,
46
+ observed_through_at: new Date().toISOString(),
47
+ };
48
+ const manifestCore = {
49
+ version: 1,
50
+ fact_schema_version: 1,
51
+ metric_schema_version: 1,
52
+ accounting_timezone: "Asia/Shanghai",
53
+ fact_count: facts.length,
54
+ fact_ids: facts.map((fact) => fact.fact_id),
55
+ coverage,
56
+ };
57
+ const manifest = {
58
+ ...manifestCore,
59
+ manifest_hash: sha256Hex(canonicalStringify(manifestCore)),
60
+ };
40
61
 
41
62
  return {
42
63
  schema_version: SCHEMA_VERSION,
@@ -45,6 +66,7 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
45
66
  generated_at: new Date().toISOString(),
46
67
  source: {
47
68
  type: "codex",
69
+ version: SOURCE_VERSION,
48
70
  scope: metrics.mode,
49
71
  collector_version: COLLECTOR_VERSION,
50
72
  logical_key: logicalKey,
@@ -68,6 +90,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
68
90
  average_first_token_ms: Math.round(metrics.averageFirstTokenMs),
69
91
  tokens: { ...metrics.tokens },
70
92
  models: [...metrics.models],
93
+ receipt_work_points: metrics.workPoints,
94
+ receipt_formula_version: "receipt_work_points_v1",
71
95
  },
72
96
  presentation: {
73
97
  default_theme: defaultTheme,
@@ -83,6 +107,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
83
107
  contains_paths: false,
84
108
  contains_filenames: false,
85
109
  },
110
+ manifest,
111
+ facts,
86
112
  };
87
113
  }
88
114
 
@@ -0,0 +1,17 @@
1
+ export function compareSourceRevision(candidate, current) {
2
+ if (!current) return "newer";
3
+ if (!candidate) return "conflict";
4
+
5
+ const candidateRows = Number(candidate.row_count ?? candidate.rowCount ?? 0);
6
+ const currentRows = Number(current.row_count ?? current.rowCount ?? 0);
7
+ const candidateBytes = Number(candidate.byte_length ?? candidate.byteLength ?? 0);
8
+ const currentBytes = Number(current.byte_length ?? current.byteLength ?? 0);
9
+ const candidateTail = String(candidate.tail_hash ?? candidate.tailHash ?? "");
10
+ const currentTail = String(current.tail_hash ?? current.tailHash ?? "");
11
+
12
+ if (candidateRows === currentRows && candidateBytes === currentBytes) {
13
+ return candidateTail === currentTail ? "same" : "conflict";
14
+ }
15
+ if (candidateRows >= currentRows && candidateBytes >= currentBytes) return "newer";
16
+ return "stale";
17
+ }
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -20,24 +21,33 @@ function readJsonl(filePath) {
20
21
  for (const [lineIndex, line] of source.split("\n").entries()) {
21
22
  if (!line.trim()) continue;
22
23
  try {
23
- rows.push(JSON.parse(line));
24
+ rows.push({ ...JSON.parse(line), __sourceLine: lineIndex + 1 });
24
25
  } catch {
25
26
  console.warn(`跳过无法解析的记录:${path.basename(filePath)}:${lineIndex + 1}`);
26
27
  }
27
28
  }
28
- return rows;
29
+ return { rows, source };
29
30
  }
30
31
 
31
32
  function sessionFromFile(file) {
32
- const rows = readJsonl(file.filePath);
33
+ const { rows, source } = readJsonl(file.filePath);
33
34
  const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
35
+ const metadataSessionId = meta.session_id || meta.id || null;
34
36
  const timestamps = rows.map(rowDate).filter(Boolean).sort((left, right) => left - right);
35
37
  const fallbackDate = new Date(file.modifiedAt);
38
+ const sourceBuffer = Buffer.from(source, "utf8");
36
39
  return {
37
40
  rows,
38
41
  filePath: file.filePath,
39
42
  modifiedAt: file.modifiedAt,
40
- sessionId: meta.session_id || meta.id || path.basename(file.filePath, ".jsonl"),
43
+ sessionId: metadataSessionId || path.basename(file.filePath, ".jsonl"),
44
+ identityQuality: metadataSessionId ? "metadata" : "filename_fallback",
45
+ sourceRevision: {
46
+ kind: "append-only-jsonl-v1",
47
+ row_count: rows.length,
48
+ byte_length: sourceBuffer.byteLength,
49
+ tail_hash: crypto.createHash("sha256").update(sourceBuffer.subarray(-4096)).digest("hex"),
50
+ },
41
51
  startAt: timestamps[0] || fallbackDate,
42
52
  endAt: timestamps.at(-1) || fallbackDate,
43
53
  };
@@ -62,6 +72,7 @@ function calendarCandidates(files, startDate) {
62
72
  export function loadCodexSessions(range) {
63
73
  const files = codexSessionFiles();
64
74
  let candidates = files;
75
+ let scanMode = "none";
65
76
 
66
77
  if (range.scope === "latest") {
67
78
  candidates = files.slice(0, 40);
@@ -69,19 +80,28 @@ export function loadCodexSessions(range) {
69
80
  const filenameMatches = files.filter((file) => path.basename(file.filePath).includes(range.sessionId));
70
81
  candidates = filenameMatches.length ? filenameMatches : files;
71
82
  } else {
72
- candidates = calendarCandidates(files, range.startDate);
83
+ const fullScan = process.env.CODEX_WORK_RECEIPT_FULL_SCAN === "1";
84
+ candidates = fullScan ? files : calendarCandidates(files, range.startDate);
85
+ scanMode = fullScan ? "full" : "best_effort";
73
86
  }
74
87
 
75
88
  const sessions = candidates
76
89
  .map(sessionFromFile)
77
90
  .sort((left, right) => right.endAt - left.endAt);
78
91
 
79
- if (range.scope === "latest") return sessions.slice(0, 1);
92
+ if (range.scope === "latest") {
93
+ const selected = sessions.slice(0, 1);
94
+ selected.scanMode = scanMode;
95
+ return selected;
96
+ }
80
97
  if (range.scope === "session") {
81
98
  const selected = sessions.find((session) => session.sessionId === range.sessionId);
82
99
  if (!selected) throw new Error(`没有找到指定的 Codex 会话:${range.sessionId}`);
83
- return [selected];
100
+ const result = [selected];
101
+ result.scanMode = scanMode;
102
+ return result;
84
103
  }
104
+ sessions.scanMode = scanMode;
85
105
  return sessions;
86
106
  }
87
107