sidekick-shared 0.27.3 → 0.27.5

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,355 @@
1
+ "use strict";
2
+ /**
3
+ * What the human-prompt filter leaves out of a session, for consumers that
4
+ * classify a whole session: the agent's reply to each prompt and timestamped
5
+ * interaction signals (interrupts, rejected or failed tools, compactions, API
6
+ * errors, rollbacks). Pure: log records in, results out; no file system access.
7
+ *
8
+ * Records reach an extractor after the prompt scanner has seen them, with the
9
+ * ordinal of the latest human prompt, so replies and signals share the prompt
10
+ * ordinals of `collectPromptHistory()`.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.SIGNAL_TEXT_CHARS = void 0;
14
+ exports.createSessionRecordExtractor = createSessionRecordExtractor;
15
+ /** Error text kept on a signal; tool output can be arbitrarily long. */
16
+ exports.SIGNAL_TEXT_CHARS = 1024;
17
+ const CLAUDE_REJECTED_PREFIX = "The user doesn't want to proceed with this tool use";
18
+ const CLAUDE_REJECTION_REASON = /the user said:\s*([\s\S]*)$/;
19
+ const CLAUDE_INTERRUPT_PREFIX = '[Request interrupted by user';
20
+ const CLAUDE_TOOL_USE_INTERRUPT = '[Request interrupted by user for tool use]';
21
+ function createSessionRecordExtractor(provider, include) {
22
+ return provider === 'claude-code'
23
+ ? new ClaudeRecordExtractor(include)
24
+ : new CodexRecordExtractor(include);
25
+ }
26
+ /** Timestamps, signal bookkeeping, and reply slots shared by both providers. */
27
+ class RecordExtractor {
28
+ include;
29
+ firstMs = Infinity;
30
+ lastMs = -Infinity;
31
+ firstTimestamp;
32
+ lastTimestamp;
33
+ signals = [];
34
+ replies = new Map();
35
+ constructor(include) {
36
+ this.include = include;
37
+ }
38
+ observe(record, info) {
39
+ const timestamp = validTimestamp(record.timestamp);
40
+ if (timestamp) {
41
+ const ms = Date.parse(timestamp);
42
+ if (ms < this.firstMs) {
43
+ this.firstMs = ms;
44
+ this.firstTimestamp = timestamp;
45
+ }
46
+ if (ms > this.lastMs) {
47
+ this.lastMs = ms;
48
+ this.lastTimestamp = timestamp;
49
+ }
50
+ }
51
+ this.accept(record, timestamp, info.ordinal, info.lineOffset);
52
+ }
53
+ result() {
54
+ return {
55
+ ...(this.firstTimestamp ? { firstTimestamp: this.firstTimestamp } : {}),
56
+ ...(this.lastTimestamp ? { lastTimestamp: this.lastTimestamp } : {}),
57
+ signals: [...this.signals],
58
+ replies: new Map(this.replies),
59
+ };
60
+ }
61
+ signal(kind, timestamp, ordinal, detail = {}) {
62
+ if (!this.include.signals || !timestamp)
63
+ return;
64
+ const signal = { kind, timestamp, afterOrdinal: ordinal };
65
+ if (detail.tool)
66
+ signal.tool = detail.tool;
67
+ if (detail.fullText) {
68
+ signal.text = detail.fullText;
69
+ }
70
+ else if (detail.text?.trim()) {
71
+ const text = detail.text.trim();
72
+ if (text.length > exports.SIGNAL_TEXT_CHARS) {
73
+ signal.text = detachedPrefix(text, exports.SIGNAL_TEXT_CHARS);
74
+ signal.textTruncated = true;
75
+ }
76
+ else {
77
+ signal.text = text;
78
+ }
79
+ }
80
+ this.signals.push(signal);
81
+ }
82
+ }
83
+ class ClaudeRecordExtractor extends RecordExtractor {
84
+ toolNames = new Map();
85
+ /** The reply being assembled: split records of one message share an id. */
86
+ reply = null;
87
+ /**
88
+ * A rejection without a reason is always followed by a
89
+ * `[Request interrupted by user for tool use]` record; it is one action.
90
+ */
91
+ rejectionAwaitingInterrupt = false;
92
+ lastOrdinal = -1;
93
+ accept(record, timestamp, ordinal, lineOffset) {
94
+ if (record.isSidechain === true)
95
+ return;
96
+ if (ordinal !== this.lastOrdinal) {
97
+ this.lastOrdinal = ordinal;
98
+ this.rejectionAwaitingInterrupt = false;
99
+ }
100
+ if (record.type === 'system') {
101
+ if (record.subtype === 'compact_boundary')
102
+ this.signal('compaction', timestamp, ordinal);
103
+ return;
104
+ }
105
+ const message = record.message;
106
+ if (typeof message !== 'object' || message === null)
107
+ return;
108
+ const blocks = contentBlocks(message.content);
109
+ if (record.type === 'assistant') {
110
+ this.rejectionAwaitingInterrupt = false;
111
+ for (const block of blocks) {
112
+ if (block.type === 'tool_use' && typeof block.id === 'string') {
113
+ const name = stringValue(block.name);
114
+ if (name)
115
+ this.toolNames.set(block.id, name);
116
+ }
117
+ }
118
+ if (record.isApiErrorMessage === true) {
119
+ this.signal('apiError', timestamp, ordinal, { text: blockText(blocks) });
120
+ return;
121
+ }
122
+ if (message.model === '<synthetic>')
123
+ return;
124
+ this.recordReply(blocks, stringValue(message.id) ?? `line:${lineOffset}`, timestamp, ordinal);
125
+ return;
126
+ }
127
+ if (record.type !== 'user')
128
+ return;
129
+ for (const block of blocks) {
130
+ if (block.type === 'text' && typeof block.text === 'string') {
131
+ const text = block.text.trim();
132
+ if (!text.startsWith(CLAUDE_INTERRUPT_PREFIX))
133
+ continue;
134
+ const paired = text.startsWith(CLAUDE_TOOL_USE_INTERRUPT) && this.rejectionAwaitingInterrupt;
135
+ this.rejectionAwaitingInterrupt = false;
136
+ if (!paired)
137
+ this.signal('interrupt', timestamp, ordinal);
138
+ }
139
+ else if (block.type === 'tool_result' && block.is_error === true) {
140
+ const text = toolResultText(block.content);
141
+ const tool = typeof block.tool_use_id === 'string' ? this.toolNames.get(block.tool_use_id) : undefined;
142
+ if (text.startsWith(CLAUDE_REJECTED_PREFIX)) {
143
+ const reason = text.match(CLAUDE_REJECTION_REASON)?.[1].trim();
144
+ this.signal('toolRejected', timestamp, ordinal, { tool, fullText: reason || undefined });
145
+ this.rejectionAwaitingInterrupt = !reason;
146
+ }
147
+ else {
148
+ this.signal('toolError', timestamp, ordinal, { tool, text });
149
+ }
150
+ }
151
+ }
152
+ }
153
+ recordReply(blocks, id, timestamp, ordinal) {
154
+ if (!this.include.replies || ordinal < 0 || !timestamp)
155
+ return;
156
+ const text = blockText(blocks);
157
+ if (!text.trim())
158
+ return;
159
+ const current = this.reply;
160
+ if (current && current.ordinal === ordinal && current.id === id) {
161
+ current.parts.push(text);
162
+ }
163
+ else {
164
+ this.reply = { ordinal, id, parts: [text], timestamp };
165
+ }
166
+ const reply = this.reply;
167
+ this.replies.set(ordinal, { text: reply.parts.join('\n'), timestamp: reply.timestamp });
168
+ }
169
+ }
170
+ class CodexRecordExtractor extends RecordExtractor {
171
+ /** Ordinals whose reply came from `task_complete`, which later fallbacks do not replace. */
172
+ finalReplies = new Set();
173
+ accept(record, timestamp, ordinal) {
174
+ // Every compaction writes one top-level `compacted` record; the matching
175
+ // event or item exists only in some Codex versions, so it is not counted.
176
+ if (record.type === 'compacted') {
177
+ this.signal('compaction', timestamp, ordinal);
178
+ return;
179
+ }
180
+ const payload = record.payload;
181
+ if (typeof payload !== 'object' || payload === null)
182
+ return;
183
+ if (record.type === 'response_item') {
184
+ if (payload.type === 'message' && payload.role === 'assistant') {
185
+ this.fallbackReply(blockText(contentBlocks(payload.content)), timestamp, ordinal);
186
+ }
187
+ return;
188
+ }
189
+ if (record.type !== 'event_msg')
190
+ return;
191
+ switch (payload.type) {
192
+ case 'turn_aborted':
193
+ this.signal('interrupt', timestamp, ordinal);
194
+ break;
195
+ case 'thread_rolled_back':
196
+ this.signal('rollback', timestamp, ordinal);
197
+ break;
198
+ case 'task_complete': {
199
+ const last = stringValue(payload.last_agent_message);
200
+ if (last?.trim() && this.include.replies && ordinal >= 0 && timestamp) {
201
+ this.replies.set(ordinal, { text: last, timestamp });
202
+ this.finalReplies.add(ordinal);
203
+ }
204
+ if (payload.error !== undefined && payload.error !== null) {
205
+ this.signal('apiError', timestamp, ordinal, { text: errorText(payload.error) });
206
+ }
207
+ break;
208
+ }
209
+ case 'error':
210
+ this.signal('apiError', timestamp, ordinal, { text: errorText(payload) });
211
+ break;
212
+ case 'agent_message':
213
+ this.fallbackReply(stringValue(payload.message) ?? '', timestamp, ordinal);
214
+ break;
215
+ case 'exec_command_end':
216
+ if (payload.status === 'failed' || nonZero(payload.exit_code)) {
217
+ this.signal('toolError', timestamp, ordinal, {
218
+ tool: 'exec_command',
219
+ text: commandText(payload),
220
+ });
221
+ }
222
+ break;
223
+ case 'patch_apply_end':
224
+ if (payload.success === false) {
225
+ this.signal('toolError', timestamp, ordinal, {
226
+ tool: 'apply_patch',
227
+ text: stringValue(payload.stderr) || stringValue(payload.stdout),
228
+ });
229
+ }
230
+ break;
231
+ case 'mcp_tool_call_end': {
232
+ const invocation = payload.invocation;
233
+ const failure = mcpFailure(payload.result);
234
+ if (failure !== null) {
235
+ this.signal('toolError', timestamp, ordinal, {
236
+ tool: invocation ? stringValue(invocation.tool) : undefined,
237
+ text: failure,
238
+ });
239
+ }
240
+ break;
241
+ }
242
+ case 'item_completed':
243
+ this.acceptItem(payload.item, timestamp, ordinal);
244
+ break;
245
+ }
246
+ }
247
+ acceptItem(item, timestamp, ordinal) {
248
+ if (typeof item !== 'object' || item === null)
249
+ return;
250
+ const row = item;
251
+ const failed = row.status === 'failed';
252
+ switch (row.type) {
253
+ case 'AgentMessage':
254
+ this.fallbackReply(blockText(contentBlocks(row.content)), timestamp, ordinal);
255
+ break;
256
+ case 'CommandExecution':
257
+ if (failed) {
258
+ this.signal('toolError', timestamp, ordinal, {
259
+ tool: 'exec_command',
260
+ text: commandText(row),
261
+ });
262
+ }
263
+ break;
264
+ case 'FileChange':
265
+ if (failed) {
266
+ this.signal('toolError', timestamp, ordinal, {
267
+ tool: 'apply_patch',
268
+ text: stringValue(row.stderr) || stringValue(row.stdout),
269
+ });
270
+ }
271
+ break;
272
+ case 'McpToolCall':
273
+ if (failed) {
274
+ this.signal('toolError', timestamp, ordinal, {
275
+ tool: stringValue(row.tool),
276
+ text: row.error !== undefined && row.error !== null
277
+ ? errorText(row.error)
278
+ : (mcpFailure(row.result) ?? undefined),
279
+ });
280
+ }
281
+ break;
282
+ }
283
+ }
284
+ /** The latest agent text of the turn, until `task_complete` settles it. */
285
+ fallbackReply(text, timestamp, ordinal) {
286
+ if (!this.include.replies || ordinal < 0 || !timestamp || !text.trim())
287
+ return;
288
+ if (this.finalReplies.has(ordinal))
289
+ return;
290
+ this.replies.set(ordinal, { text, timestamp });
291
+ }
292
+ }
293
+ /**
294
+ * The first `chars` UTF-16 units of `text` as a new string. A plain slice can
295
+ * keep the whole source string alive (V8 sliced strings), which for a tool's
296
+ * output may be megabytes per signal. A surrogate pair is never split.
297
+ */
298
+ function detachedPrefix(text, chars) {
299
+ const end = /[\uD800-\uDBFF]/.test(text.charAt(chars - 1)) ? chars - 1 : chars;
300
+ return Buffer.from(text.slice(0, end), 'utf8').toString('utf8');
301
+ }
302
+ function validTimestamp(value) {
303
+ return typeof value === 'string' && !Number.isNaN(Date.parse(value)) ? value : undefined;
304
+ }
305
+ function stringValue(value) {
306
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
307
+ }
308
+ function contentBlocks(content) {
309
+ if (typeof content === 'string')
310
+ return [{ type: 'text', text: content }];
311
+ if (!Array.isArray(content))
312
+ return [];
313
+ return content.filter((block) => typeof block === 'object' && block !== null);
314
+ }
315
+ /** Text of text-like blocks: Claude `text`, Codex `output_text`, Codex item `Text`. */
316
+ function blockText(blocks) {
317
+ return blocks
318
+ .filter((block) => (block.type === 'text' || block.type === 'output_text' || block.type === 'Text') &&
319
+ typeof block.text === 'string')
320
+ .map((block) => block.text)
321
+ .join('\n');
322
+ }
323
+ function toolResultText(content) {
324
+ return blockText(contentBlocks(content)).trim();
325
+ }
326
+ function nonZero(value) {
327
+ return typeof value === 'number' && value !== 0;
328
+ }
329
+ /** A failed command as `$ <command>` plus its output. */
330
+ function commandText(row) {
331
+ const command = Array.isArray(row.command)
332
+ ? row.command.filter((part) => typeof part === 'string').at(-1)
333
+ : stringValue(row.command);
334
+ const output = stringValue(row.aggregated_output) ?? stringValue(row.stderr) ?? stringValue(row.stdout) ?? '';
335
+ return command ? `$ ${command}\n${output}` : output;
336
+ }
337
+ function errorText(error) {
338
+ if (typeof error === 'string')
339
+ return error;
340
+ if (typeof error !== 'object' || error === null)
341
+ return undefined;
342
+ return stringValue(error.message);
343
+ }
344
+ /** The failure text of an MCP call result, or null when it succeeded. */
345
+ function mcpFailure(result) {
346
+ if (typeof result !== 'object' || result === null)
347
+ return null;
348
+ const row = result;
349
+ if ('Err' in row)
350
+ return typeof row.Err === 'string' ? row.Err : (errorText(row.Err) ?? '');
351
+ const ok = (row.Ok ?? row);
352
+ if (typeof ok !== 'object' || ok === null || ok.isError !== true)
353
+ return null;
354
+ return blockText(contentBlocks(ok.content));
355
+ }
@@ -78,6 +78,13 @@ export interface CollectUsageEventsOptions {
78
78
  /** Skip the on-disk cache entirely. */
79
79
  noCache?: boolean;
80
80
  onDiagnostic?: (diagnostic: SessionProviderDiagnostic) => void;
81
+ /**
82
+ * Stops the collection before listing and between sessions; the promise
83
+ * rejects with the signal's reason (an `AbortError` by default). Sessions
84
+ * read before the abort keep their cache entries; no partial session is
85
+ * cached.
86
+ */
87
+ signal?: AbortSignal;
81
88
  }
82
89
  export interface CollectUsageEventsResult {
83
90
  /** Events within the window, oldest first. */
@@ -290,12 +290,15 @@ async function collectUsageEvents(options) {
290
290
  const untilMs = toMs(options.until, 'until');
291
291
  const cacheDir = options.cacheDir ?? getUsageCacheDir();
292
292
  const useCache = !options.noCache;
293
+ const signal = options.signal;
294
+ signal?.throwIfAborted();
293
295
  const listed = await (0, sessionPreviews_1.listSessionPreviewsAsync)(options.providers, {
294
296
  since: options.since,
295
297
  workspacePath: options.workspacePath,
296
298
  limit: Number.MAX_SAFE_INTEGER,
297
299
  concurrency: options.concurrency,
298
300
  });
301
+ signal?.throwIfAborted();
299
302
  const diagnostics = [...listed.diagnostics];
300
303
  for (const diagnostic of listed.diagnostics)
301
304
  options.onDiagnostic?.(diagnostic);
@@ -306,6 +309,7 @@ async function collectUsageEvents(options) {
306
309
  let cacheMisses = 0;
307
310
  let wroteCache = false;
308
311
  for (const preview of listed.previews) {
312
+ signal?.throwIfAborted();
309
313
  const provider = providerById.get(preview.provider);
310
314
  if (!provider)
311
315
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-shared",
3
- "version": "0.27.3",
3
+ "version": "0.27.5",
4
4
  "description": "Shared data access layer for Sidekick — readers, types, providers, credentials, quota",
5
5
  "keywords": [
6
6
  "claude-code",