@dashclaw/cli 0.2.0 → 0.3.2

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.
@@ -0,0 +1,360 @@
1
+ // VENDORED from app/lib/codex/parser.ts — GENERATED, do not edit by hand.
2
+ // The CLI runs as raw Node ESM and is published without app/, so it cannot import
3
+ // the app TypeScript source. Regenerate by compiling app/lib/codex/parser.ts.
4
+ // See cli/lib/code/vendored.js for the same sibling-package rationale.
5
+
6
+ // app/lib/codex/parser.js
7
+ //
8
+ // Codex CLI session JSONL parser.
9
+ //
10
+ // Codex writes session rollouts to `~/.codex/sessions/rollout-<ts>-<uuid>.jsonl`.
11
+ // Each line has the shape:
12
+ //
13
+ // { "timestamp": "...", "type": "session_meta"|"response_item"|...,
14
+ // "payload": { ... } }
15
+ //
16
+ // We normalize the rollout into a session object that's intentionally
17
+ // parallel to the Claude Code parser output (app/lib/claude-code/parser.js)
18
+ // so downstream insights, alerts, and rules engines can be reused with
19
+ // minimal branching. The shape differs only where Codex's data model
20
+ // fundamentally diverges from Claude's (token semantics, reasoning tokens).
21
+ //
22
+ // Token mapping (Codex → normalized):
23
+ // input_tokens → input_tokens
24
+ // output_tokens → output_tokens (excludes reasoning)
25
+ // cached_input_tokens → cache_read_tokens
26
+ // reasoning_output_tokens → reasoning_tokens (Codex-only field)
27
+ // total_tokens → recomputed at the request level for safety
28
+ //
29
+ // There is no Codex equivalent to Claude's `cache_creation_tokens`. We emit
30
+ // 0 for that field on Codex sessions to keep the schema compatible.
31
+ //
32
+ // This parser is deliberately tolerant: malformed lines are counted in
33
+ // `skippedLines` and never throw. It returns a partial session even when
34
+ // the rollout was truncated mid-write.
35
+ import fs from 'node:fs';
36
+ import readline from 'node:readline';
37
+ export const CODEX_PARSER_VERSION = 1;
38
+ function safeParse(line) {
39
+ try {
40
+ return JSON.parse(line);
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ }
46
+ function previewText(value, max = 200) {
47
+ if (typeof value !== 'string')
48
+ return '';
49
+ return value.slice(0, max);
50
+ }
51
+ // Best-effort agent-kind tag for downstream branching. Always 'codex' here.
52
+ export const CODEX_AGENT_KIND = 'codex';
53
+ function _initState({ sourceFile, sourceMtime }) {
54
+ return {
55
+ session: {
56
+ agentKind: CODEX_AGENT_KIND,
57
+ sourceFile: sourceFile || null,
58
+ sourceMtime: sourceMtime || null,
59
+ sessionUuid: null,
60
+ projectSlug: null,
61
+ cwd: null,
62
+ gitBranch: null,
63
+ gitCommit: null,
64
+ gitRepoUrl: null,
65
+ cliVersion: null,
66
+ originator: null,
67
+ modelProvider: null,
68
+ modelPrimary: null,
69
+ startedAt: null,
70
+ endedAt: null,
71
+ parserVersion: CODEX_PARSER_VERSION,
72
+ // counters
73
+ jsonlRecords: 0,
74
+ responseItems: 0,
75
+ eventMessages: 0,
76
+ compactedItems: 0,
77
+ skippedLines: 0,
78
+ turnCount: 0,
79
+ toolCallCount: 0,
80
+ // token totals
81
+ totals: {
82
+ input_tokens: 0,
83
+ output_tokens: 0,
84
+ cache_read_tokens: 0,
85
+ cache_creation_tokens: 0, // always 0 for Codex; kept for schema parity
86
+ reasoning_tokens: 0,
87
+ },
88
+ // per-turn token snapshots, in arrival order
89
+ turns: [], // { turnId, inputTokens, outputTokens, cachedInputTokens, reasoningTokens, total, at }
90
+ // canonical message timeline (user + assistant + reasoning + tool_call + tool_result)
91
+ messages: [],
92
+ // tool uses across the session
93
+ toolUses: [],
94
+ },
95
+ seenTurnIds: new Set(),
96
+ };
97
+ }
98
+ function _handleSessionMeta(state, payload, ts) {
99
+ const s = state.session;
100
+ if (!s.sessionUuid && payload?.id)
101
+ s.sessionUuid = payload.id;
102
+ if (!s.cwd && payload?.cwd)
103
+ s.cwd = payload.cwd;
104
+ if (!s.cliVersion && payload?.cli_version)
105
+ s.cliVersion = payload.cli_version;
106
+ if (!s.originator && payload?.originator)
107
+ s.originator = payload.originator;
108
+ if (!s.modelProvider && payload?.model_provider)
109
+ s.modelProvider = payload.model_provider;
110
+ if (!s.startedAt)
111
+ s.startedAt = ts || payload?.timestamp || null;
112
+ if (payload?.git && typeof payload.git === 'object') {
113
+ const git = payload.git;
114
+ if (!s.gitBranch && git.branch)
115
+ s.gitBranch = git.branch;
116
+ if (!s.gitCommit && git.commit_hash)
117
+ s.gitCommit = git.commit_hash;
118
+ if (!s.gitRepoUrl && git.repository_url)
119
+ s.gitRepoUrl = git.repository_url;
120
+ }
121
+ }
122
+ function _handleResponseItem(state, payload, ts) {
123
+ const s = state.session;
124
+ s.responseItems += 1;
125
+ if (!payload || typeof payload !== 'object')
126
+ return;
127
+ const role = payload.role;
128
+ // Response items can be of several shapes:
129
+ // - { role: 'user'|'assistant'|'system', content: [...] }
130
+ // - { type: 'function_call', name, arguments, call_id }
131
+ // - { type: 'function_call_output', call_id, output }
132
+ // - { type: 'reasoning', summary: [...] }
133
+ if (payload.type === 'function_call' || (payload.name && payload.call_id)) {
134
+ s.toolCallCount += 1;
135
+ s.toolUses.push({
136
+ name: payload.name || 'unknown',
137
+ tool_use_id: payload.call_id || payload.id || null,
138
+ arguments_preview: previewText(payload.arguments, 240),
139
+ target: safeToolTarget(payload),
140
+ at: ts,
141
+ });
142
+ s.messages.push({
143
+ role: 'tool_call',
144
+ name: payload.name,
145
+ tool_use_id: payload.call_id || null,
146
+ preview: previewText(payload.arguments, 240),
147
+ at: ts,
148
+ });
149
+ return;
150
+ }
151
+ if (payload.type === 'function_call_output') {
152
+ s.messages.push({
153
+ role: 'tool_result',
154
+ tool_use_id: payload.call_id || null,
155
+ preview: previewText(typeof payload.output === 'string' ? payload.output : JSON.stringify(payload.output ?? ''), 240),
156
+ at: ts,
157
+ });
158
+ return;
159
+ }
160
+ if (payload.type === 'reasoning') {
161
+ // Reasoning items are first-class in Codex — we keep a stub on the
162
+ // timeline so downstream tools can correlate cost with thinking.
163
+ s.messages.push({ role: 'reasoning', preview: '(reasoning)', at: ts });
164
+ return;
165
+ }
166
+ if (role && Array.isArray(payload.content)) {
167
+ s.messages.push({
168
+ role,
169
+ preview: extractTextPreview(payload.content),
170
+ at: ts,
171
+ });
172
+ }
173
+ }
174
+ function extractTextPreview(content) {
175
+ for (const c of content) {
176
+ if (!c)
177
+ continue;
178
+ if (typeof c === 'string')
179
+ return previewText(c);
180
+ const part = c;
181
+ if (part.type === 'input_text' && typeof part.text === 'string')
182
+ return previewText(part.text);
183
+ if (part.type === 'output_text' && typeof part.text === 'string')
184
+ return previewText(part.text);
185
+ if (part.type === 'text' && typeof part.text === 'string')
186
+ return previewText(part.text);
187
+ }
188
+ return '';
189
+ }
190
+ function safeToolTarget(p) {
191
+ // Codex tools include `local_shell` (command exec) and a generic `apply_patch`
192
+ // among many MCP tools. We try to extract a SHORT non-secret identifier.
193
+ try {
194
+ const args = typeof p.arguments === 'string'
195
+ ? JSON.parse(p.arguments)
196
+ : p.arguments;
197
+ if (!args || typeof args !== 'object')
198
+ return null;
199
+ if (p.name === 'local_shell' || p.name === 'shell') {
200
+ const cmd = typeof args.command === 'string'
201
+ ? args.command
202
+ : Array.isArray(args.command) ? args.command.join(' ') : '';
203
+ const head = cmd.trim().split(/\s+/)[0] || '';
204
+ return head ? head.slice(0, 60) : null;
205
+ }
206
+ if (typeof args.file_path === 'string')
207
+ return args.file_path.slice(0, 160);
208
+ if (typeof args.path === 'string')
209
+ return args.path.slice(0, 160);
210
+ if (typeof args.url === 'string') {
211
+ try {
212
+ return new URL(args.url).host;
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ }
218
+ }
219
+ catch { /* fall through */ }
220
+ return null;
221
+ }
222
+ function _handleEventMsg(state, payload, ts) {
223
+ const s = state.session;
224
+ s.eventMessages += 1;
225
+ if (!payload || typeof payload !== 'object')
226
+ return;
227
+ const type = payload.type;
228
+ if (type === 'token_count') {
229
+ // Payload shape: { type: 'token_count', info: { total_token_usage, last_token_usage, model_context_window } }
230
+ const last = payload.info?.last_token_usage;
231
+ if (last) {
232
+ const input = Number(last.input_tokens) || 0;
233
+ const output = Number(last.output_tokens) || 0;
234
+ const cachedInput = Number(last.cached_input_tokens) || 0;
235
+ const reasoning = Number(last.reasoning_output_tokens) || 0;
236
+ const total = Number(last.total_tokens) || (input + output + cachedInput + reasoning);
237
+ s.totals.input_tokens += input;
238
+ s.totals.output_tokens += output;
239
+ s.totals.cache_read_tokens += cachedInput;
240
+ s.totals.reasoning_tokens += reasoning;
241
+ // Turn snapshot — Codex emits one token_count per model turn. We use
242
+ // the event ordinal as a synthetic turn id since the payload doesn't
243
+ // carry one. The token-count event always corresponds to one turn.
244
+ const turnId = `t${s.turns.length + 1}`;
245
+ s.turns.push({
246
+ turnId,
247
+ inputTokens: input,
248
+ outputTokens: output,
249
+ cachedInputTokens: cachedInput,
250
+ reasoningTokens: reasoning,
251
+ total,
252
+ at: ts,
253
+ });
254
+ s.turnCount = s.turns.length;
255
+ }
256
+ return;
257
+ }
258
+ if (type === 'session_configured') {
259
+ if (!s.modelPrimary && payload.model)
260
+ s.modelPrimary = payload.model;
261
+ return;
262
+ }
263
+ if (type === 'agent_message') {
264
+ if (typeof payload.message === 'string') {
265
+ s.messages.push({ role: 'assistant', preview: previewText(payload.message), at: ts });
266
+ }
267
+ return;
268
+ }
269
+ if (type === 'user_message') {
270
+ if (typeof payload.message === 'string') {
271
+ s.messages.push({ role: 'user', preview: previewText(payload.message), at: ts });
272
+ }
273
+ return;
274
+ }
275
+ }
276
+ function _handleCompacted(state, payload, ts) {
277
+ const s = state.session;
278
+ s.compactedItems += 1;
279
+ s.messages.push({
280
+ role: 'system',
281
+ preview: '(compacted) ' + previewText(payload?.message || '', 180),
282
+ at: ts,
283
+ });
284
+ }
285
+ function _processLine(state, line, lineNo) {
286
+ if (!line || !line.trim())
287
+ return;
288
+ const rec = safeParse(line);
289
+ const s = state.session;
290
+ if (!rec || typeof rec !== 'object') {
291
+ s.skippedLines += 1;
292
+ return;
293
+ }
294
+ s.jsonlRecords += 1;
295
+ const ts = typeof rec.timestamp === 'string' ? rec.timestamp : null;
296
+ if (ts) {
297
+ if (!s.startedAt || ts < s.startedAt)
298
+ s.startedAt = ts;
299
+ if (!s.endedAt || ts > s.endedAt)
300
+ s.endedAt = ts;
301
+ }
302
+ // RolloutLine flattens `type` to the top level (serde flatten).
303
+ const type = rec.type;
304
+ const payload = rec.payload;
305
+ switch (type) {
306
+ case 'session_meta':
307
+ _handleSessionMeta(state, payload, ts);
308
+ break;
309
+ case 'response_item':
310
+ _handleResponseItem(state, payload, ts);
311
+ break;
312
+ case 'event_msg':
313
+ _handleEventMsg(state, payload, ts);
314
+ break;
315
+ case 'compacted':
316
+ _handleCompacted(state, payload, ts);
317
+ break;
318
+ case 'turn_context':
319
+ // No-op for now; turn_context carries the per-turn model + tool list
320
+ // but we don't yet use it for normalization.
321
+ break;
322
+ default:
323
+ // Unknown line type — counted in jsonlRecords but no further action.
324
+ break;
325
+ }
326
+ }
327
+ /**
328
+ * Parse an array of raw JSONL lines (in-memory). Returns the normalized
329
+ * session object. The server uses this so it never touches the filesystem.
330
+ */
331
+ export function parseCodexSessionLines(lines, { sourceFile = null, sourceMtime = null } = {}) {
332
+ if (!Array.isArray(lines)) {
333
+ throw new TypeError('parseCodexSessionLines: lines must be an array of strings');
334
+ }
335
+ const state = _initState({ sourceFile, sourceMtime });
336
+ for (let i = 0; i < lines.length; i++) {
337
+ _processLine(state, lines[i], i + 1);
338
+ }
339
+ return state.session;
340
+ }
341
+ /**
342
+ * Stream-parse a Codex JSONL file from disk. Used by the CLI ingest path.
343
+ */
344
+ export async function parseCodexSessionFile(filePath, { mtime } = {}) {
345
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
346
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
347
+ let actualMtime = mtime;
348
+ if (!actualMtime) {
349
+ try {
350
+ actualMtime = fs.statSync(filePath).mtime.toISOString();
351
+ }
352
+ catch { /* keep null */ }
353
+ }
354
+ const state = _initState({ sourceFile: filePath, sourceMtime: actualMtime });
355
+ let lineNo = 0;
356
+ for await (const line of rl) {
357
+ _processLine(state, line, ++lineNo);
358
+ }
359
+ return state.session;
360
+ }
@@ -0,0 +1,244 @@
1
+ // cli/lib/code/ingest-codex.js
2
+ //
3
+ // Codex JSONL backfill. Walks `codexSessionsDir` (default ~/.codex/sessions),
4
+ // parses each rollout file via app/lib/codex/parser.js, and either:
5
+ //
6
+ // (a) writes the normalized session JSON to an --out directory (default
7
+ // ~/.dashclaw/codex-sessions/) for later server upload, or
8
+ // (b) POSTs it to --endpoint <url> directly.
9
+ //
10
+ // Phase 3 ships option (a) — local-only. Option (b) becomes useful once
11
+ // the server-side ingest route accepts source_host='codex-jsonl' (deferred
12
+ // until the AgentLens absorption lands, to avoid editing files already
13
+ // being modified by another agent).
14
+ //
15
+ // Output JSON shape is the raw parseCodexSessionFile return value. The
16
+ // server, when it grows codex ingestion, will deserialize it and persist.
17
+ //
18
+ // Logs per file: { file, status, reason?, session_uuid?, turns?, tokens? }.
19
+ // NEVER logs raw message text — could leak user transcripts.
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { homedir } from 'node:os';
24
+
25
+ // Vendored: the CLI is published without app/, so it can't import the app's
26
+ // TypeScript parser directly. See codex-parser.vendored.js.
27
+ import { parseCodexSessionFile } from './codex-parser.vendored.js';
28
+
29
+ const MAX_FILE_BYTES = 100 * 1024 * 1024; // codex rollouts can grow large
30
+
31
+ export function defaultCodexSessionsDir(env = process.env) {
32
+ if (env.CODEX_SESSIONS_DIR) return env.CODEX_SESSIONS_DIR;
33
+ const root = process.platform === 'win32'
34
+ ? (env.USERPROFILE || homedir())
35
+ : (env.HOME || homedir());
36
+ // Codex defaults to <codex_home>/sessions; codex_home defaults to ~/.codex.
37
+ return path.join(env.CODEX_HOME || path.join(root, '.codex'), 'sessions');
38
+ }
39
+
40
+ export function defaultCodexOutDir(env = process.env) {
41
+ if (env.DASHCLAW_CODEX_OUT_DIR) return env.DASHCLAW_CODEX_OUT_DIR;
42
+ const root = process.platform === 'win32'
43
+ ? (env.USERPROFILE || homedir())
44
+ : (env.HOME || homedir());
45
+ return path.join(root, '.dashclaw', 'codex-sessions');
46
+ }
47
+
48
+ function listJsonlFiles(rootDir) {
49
+ const out = [];
50
+ let entries;
51
+ try { entries = fs.readdirSync(rootDir, { withFileTypes: true }); }
52
+ catch { return out; }
53
+ for (const e of entries) {
54
+ const p = path.join(rootDir, e.name);
55
+ if (e.isDirectory()) {
56
+ // Recurse one level — codex doesn't currently nest, but archived
57
+ // sessions sometimes land in `<sessions>/archived/`.
58
+ let inner;
59
+ try { inner = fs.readdirSync(p, { withFileTypes: true }); }
60
+ catch { continue; }
61
+ for (const f of inner) {
62
+ if (f.isFile() && f.name.endsWith('.jsonl')) {
63
+ out.push(path.join(p, f.name));
64
+ }
65
+ }
66
+ } else if (e.isFile() && e.name.endsWith('.jsonl')) {
67
+ out.push(p);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ function bytesOf(filePath) {
74
+ try { return fs.statSync(filePath).size; }
75
+ catch { return 0; }
76
+ }
77
+
78
+ /**
79
+ * Build the per-session output JSON. Stable shape that the server can
80
+ * consume once codex ingestion lands.
81
+ */
82
+ function buildOutputPayload(session) {
83
+ return {
84
+ parser_version: session.parserVersion,
85
+ agent_kind: session.agentKind,
86
+ session_uuid: session.sessionUuid,
87
+ cwd: session.cwd,
88
+ cli_version: session.cliVersion,
89
+ originator: session.originator,
90
+ model_provider: session.modelProvider,
91
+ model_primary: session.modelPrimary,
92
+ started_at: session.startedAt,
93
+ ended_at: session.endedAt,
94
+ git_branch: session.gitBranch,
95
+ git_commit: session.gitCommit,
96
+ git_repo_url: session.gitRepoUrl,
97
+ jsonl_records: session.jsonlRecords,
98
+ skipped_lines: session.skippedLines,
99
+ response_items: session.responseItems,
100
+ event_messages: session.eventMessages,
101
+ compacted_items: session.compactedItems,
102
+ tool_call_count: session.toolCallCount,
103
+ turn_count: session.turnCount,
104
+ totals: session.totals,
105
+ turns: session.turns,
106
+ // Messages and tool uses are large — keep them in the payload so the
107
+ // server can persist them when ready. CLI does not log them.
108
+ messages: session.messages,
109
+ tool_uses: session.toolUses,
110
+ source: {
111
+ host: 'codex-jsonl',
112
+ file: session.sourceFile,
113
+ mtime: session.sourceMtime,
114
+ },
115
+ };
116
+ }
117
+
118
+ async function writeLocalOut({ outDir, payload }) {
119
+ fs.mkdirSync(outDir, { recursive: true });
120
+ const id = payload.session_uuid || `unknown-${Date.now()}`;
121
+ const target = path.join(outDir, `${id}.json`);
122
+ fs.writeFileSync(target, JSON.stringify(payload, null, 2) + '\n');
123
+ return target;
124
+ }
125
+
126
+ async function postPayload({ endpoint, apiKey, payload, timeoutMs = 10000 }) {
127
+ const url = new URL(endpoint);
128
+ const body = JSON.stringify(payload);
129
+ const headers = {
130
+ 'content-type': 'application/json',
131
+ 'x-api-key': apiKey,
132
+ };
133
+
134
+ const { request: httpReq } = await import('node:http');
135
+ const { request: httpsReq } = await import('node:https');
136
+ const lib = url.protocol === 'https:' ? httpsReq : httpReq;
137
+
138
+ return new Promise((resolve) => {
139
+ const req = lib({
140
+ method: 'POST',
141
+ host: url.hostname,
142
+ port: url.port || (url.protocol === 'https:' ? 443 : 80),
143
+ path: url.pathname + url.search,
144
+ headers: { ...headers, 'content-length': Buffer.byteLength(body).toString() },
145
+ }, (res) => {
146
+ let chunks = '';
147
+ res.setEncoding('utf8');
148
+ res.on('data', (c) => { chunks += c; });
149
+ res.on('end', () => {
150
+ if (res.statusCode >= 200 && res.statusCode < 300) {
151
+ resolve({ status: 'ingested', http: res.statusCode });
152
+ } else {
153
+ resolve({ status: 'error', reason: `http_${res.statusCode}`, detail: chunks.slice(0, 200) });
154
+ }
155
+ });
156
+ });
157
+ req.on('error', (err) => resolve({ status: 'error', reason: 'network', detail: err.message }));
158
+ req.setTimeout(timeoutMs, () => { req.destroy(); resolve({ status: 'error', reason: 'timeout' }); });
159
+ req.write(body);
160
+ req.end();
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Top-level orchestration. Returns an array of result objects, one per
166
+ * jsonl file found in `sessionsDir`.
167
+ */
168
+ export async function runCodexIngest({
169
+ sessionsDir = defaultCodexSessionsDir(),
170
+ outDir = defaultCodexOutDir(),
171
+ endpoint = null, // when set, POST instead of write-local
172
+ apiKey = null,
173
+ dryRun = false,
174
+ logger = console,
175
+ } = {}) {
176
+ if (!fs.existsSync(sessionsDir)) {
177
+ logger.warn?.(`codex ingest: sessions dir does not exist: ${sessionsDir}`);
178
+ return [];
179
+ }
180
+ const files = listJsonlFiles(sessionsDir);
181
+ if (files.length === 0) {
182
+ logger.info?.(`codex ingest: no .jsonl files in ${sessionsDir}`);
183
+ return [];
184
+ }
185
+
186
+ const results = [];
187
+ for (const file of files) {
188
+ const size = bytesOf(file);
189
+ if (size === 0) {
190
+ results.push({ file, status: 'skipped', reason: 'empty' });
191
+ continue;
192
+ }
193
+ if (size > MAX_FILE_BYTES) {
194
+ results.push({ file, status: 'skipped', reason: 'too_large', bytes: size });
195
+ continue;
196
+ }
197
+
198
+ let session;
199
+ try {
200
+ session = await parseCodexSessionFile(file);
201
+ } catch (err) {
202
+ results.push({ file, status: 'error', reason: 'parse', detail: err.message });
203
+ continue;
204
+ }
205
+ const payload = buildOutputPayload(session);
206
+
207
+ if (dryRun) {
208
+ results.push({
209
+ file,
210
+ status: 'dry_run',
211
+ session_uuid: session.sessionUuid,
212
+ turns: session.turnCount,
213
+ tokens: session.totals,
214
+ });
215
+ continue;
216
+ }
217
+
218
+ if (endpoint) {
219
+ const httpResult = await postPayload({ endpoint, apiKey, payload });
220
+ results.push({
221
+ file,
222
+ ...httpResult,
223
+ session_uuid: session.sessionUuid,
224
+ turns: session.turnCount,
225
+ });
226
+ } else {
227
+ try {
228
+ const target = await writeLocalOut({ outDir, payload });
229
+ results.push({
230
+ file,
231
+ status: 'written_local',
232
+ session_uuid: session.sessionUuid,
233
+ out: target,
234
+ turns: session.turnCount,
235
+ tokens: session.totals,
236
+ });
237
+ } catch (err) {
238
+ results.push({ file, status: 'error', reason: 'write', detail: err.message });
239
+ }
240
+ }
241
+ }
242
+
243
+ return results;
244
+ }