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.
- package/CHANGELOG.md +21 -0
- package/README.md +79 -11
- package/dist/index.d.ts +3 -0
- package/dist/index.js +13 -11
- package/dist/node.d.ts +7 -1
- package/dist/node.js +8 -2
- package/dist/promptHistory.d.ts +66 -0
- package/dist/promptHistory.js +37 -18
- package/dist/sessionPromptHistory.d.ts +120 -0
- package/dist/sessionPromptHistory.js +278 -0
- package/dist/sessionPromptSignals.d.ts +56 -0
- package/dist/sessionPromptSignals.js +355 -0
- package/dist/usage/usageEvents.d.ts +7 -0
- package/dist/usage/usageEvents.js +4 -0
- package/package.json +1 -1
|
@@ -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;
|