sidekick-shared 0.27.4 → 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sidekick-shared",
3
- "version": "0.27.4",
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",