pi-cursor-sdk 0.1.15 → 0.1.17

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +56 -1
  2. package/README.md +20 -8
  3. package/docs/cursor-live-smoke-checklist.md +267 -0
  4. package/docs/cursor-model-ux-spec.md +15 -5
  5. package/docs/cursor-native-tool-replay.md +16 -5
  6. package/package.json +12 -5
  7. package/scripts/steering-rpc-smoke.mjs +238 -0
  8. package/scripts/tmux-live-smoke.sh +418 -0
  9. package/scripts/validate-smoke-jsonl.mjs +152 -0
  10. package/src/context.ts +180 -5
  11. package/src/cursor-bridge-contract.ts +27 -0
  12. package/src/cursor-edit-diff.ts +11 -0
  13. package/src/cursor-env-boolean.ts +22 -0
  14. package/src/cursor-live-run-accounting.ts +65 -0
  15. package/src/cursor-live-run-coordinator.ts +483 -0
  16. package/src/cursor-native-tool-display-registration.ts +93 -0
  17. package/src/cursor-native-tool-display-replay.ts +465 -0
  18. package/src/cursor-native-tool-display-state.ts +78 -0
  19. package/src/cursor-native-tool-display-tools.ts +102 -0
  20. package/src/cursor-native-tool-display.ts +10 -639
  21. package/src/cursor-partial-content-emitter.ts +121 -0
  22. package/src/cursor-pi-tool-bridge-abort.ts +133 -0
  23. package/src/cursor-pi-tool-bridge-diagnostics.ts +179 -0
  24. package/src/cursor-pi-tool-bridge-mcp.ts +118 -0
  25. package/src/cursor-pi-tool-bridge-run.ts +384 -0
  26. package/src/cursor-pi-tool-bridge-server.ts +182 -0
  27. package/src/cursor-pi-tool-bridge-snapshot.ts +88 -0
  28. package/src/cursor-pi-tool-bridge-types.ts +80 -0
  29. package/src/cursor-pi-tool-bridge.ts +77 -602
  30. package/src/cursor-provider-live-run-drain.ts +379 -0
  31. package/src/cursor-provider-turn-coordinator.ts +456 -0
  32. package/src/cursor-provider.ts +133 -1092
  33. package/src/cursor-question-tool.ts +7 -2
  34. package/src/cursor-record-utils.ts +26 -0
  35. package/src/cursor-sdk-output-filter.ts +100 -0
  36. package/src/cursor-sensitive-text.ts +37 -0
  37. package/src/cursor-session-agent.ts +372 -0
  38. package/src/cursor-session-cwd.ts +14 -19
  39. package/src/cursor-session-scope.ts +65 -0
  40. package/src/cursor-state.ts +38 -10
  41. package/src/cursor-tool-transcript.ts +28 -1229
  42. package/src/cursor-transcript-tool-formatters.ts +641 -0
  43. package/src/cursor-transcript-tool-specs.ts +441 -0
  44. package/src/cursor-transcript-utils.ts +276 -0
  45. package/src/cursor-usage-accounting.ts +71 -0
  46. package/src/index.ts +20 -3
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Validate assistant presence and usage fields in pi session JSONL files under a smoke directory.
4
+ */
5
+ import { readdirSync, readFileSync, statSync } from "node:fs";
6
+ import { join, relative } from "node:path";
7
+
8
+ function printHelp() {
9
+ console.log(`Validate assistant presence and usage metadata in pi smoke session JSONL files.
10
+
11
+ Usage:
12
+ node scripts/validate-smoke-jsonl.mjs <smoke-dir>
13
+ SMOKE_DIR=/tmp/pi-cursor-smoke node scripts/validate-smoke-jsonl.mjs
14
+
15
+ Arguments:
16
+ smoke-dir Directory containing smoke session subdirs and JSONL files.
17
+ Defaults to SMOKE_DIR when the positional arg is omitted.
18
+
19
+ Options:
20
+ -h, --help Show this help.
21
+
22
+ Exit codes:
23
+ 0 every scanned JSONL file has at least one assistant message and valid assistant usage metadata
24
+ 1 invalid arguments, unreadable directory, invalid JSONL, empty/no-assistant files, or usage validation failures
25
+ 2 no JSONL files found under the smoke directory
26
+
27
+ Enforced invariants:
28
+ - each scanned JSONL file contains parseable JSONL records
29
+ - each scanned JSONL file contains at least one persisted assistant message
30
+ - every persisted assistant message has usage metadata
31
+ - assistant usage input/output/totalTokens are non-negative numbers
32
+ - assistant usage cacheRead/cacheWrite are exactly 0
33
+
34
+ Notes:
35
+ - Prints one JSON summary line per scanned session file.
36
+ - Does not print session message contents or secrets.`);
37
+ }
38
+
39
+ function fail(message) {
40
+ console.error(`validate-smoke-jsonl: ${message}`);
41
+ process.exit(1);
42
+ }
43
+
44
+ function collectJsonlFiles(root) {
45
+ const files = [];
46
+ function walk(dir) {
47
+ for (const name of readdirSync(dir)) {
48
+ const path = join(dir, name);
49
+ const st = statSync(path);
50
+ if (st.isDirectory()) walk(path);
51
+ else if (path.endsWith(".jsonl")) files.push(path);
52
+ }
53
+ }
54
+ walk(root);
55
+ return files.sort();
56
+ }
57
+
58
+ function isNonNegativeNumber(value) {
59
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
60
+ }
61
+
62
+ function isBadUsage(usage) {
63
+ return (
64
+ !usage ||
65
+ typeof usage !== "object" ||
66
+ !isNonNegativeNumber(usage.input) ||
67
+ !isNonNegativeNumber(usage.output) ||
68
+ !isNonNegativeNumber(usage.totalTokens) ||
69
+ usage.cacheRead !== 0 ||
70
+ usage.cacheWrite !== 0
71
+ );
72
+ }
73
+
74
+ function parseJsonlFile(file) {
75
+ const lines = readFileSync(file, "utf8")
76
+ .split(/\r?\n/)
77
+ .map((line) => line.trim())
78
+ .filter(Boolean);
79
+ const records = [];
80
+ let parseErrorCount = 0;
81
+ for (const line of lines) {
82
+ try {
83
+ records.push(JSON.parse(line));
84
+ } catch {
85
+ parseErrorCount += 1;
86
+ }
87
+ }
88
+ return { lineCount: lines.length, records, parseErrorCount };
89
+ }
90
+
91
+ function main() {
92
+ const args = process.argv.slice(2);
93
+ if (args.includes("-h") || args.includes("--help")) {
94
+ printHelp();
95
+ return;
96
+ }
97
+
98
+ if (args.length > 1) {
99
+ fail("too many arguments; pass only the smoke directory");
100
+ }
101
+
102
+ const smokeDir = args[0] ?? process.env.SMOKE_DIR;
103
+ if (!smokeDir) {
104
+ fail("missing smoke directory; pass a path or set SMOKE_DIR");
105
+ }
106
+
107
+ let files;
108
+ try {
109
+ files = collectJsonlFiles(smokeDir);
110
+ } catch (error) {
111
+ fail(error instanceof Error ? error.message : String(error));
112
+ }
113
+
114
+ if (files.length === 0) {
115
+ console.error(`validate-smoke-jsonl: no JSONL files under ${smokeDir}`);
116
+ process.exit(2);
117
+ }
118
+
119
+ let failures = 0;
120
+ for (const file of files) {
121
+ let summary;
122
+ try {
123
+ const { lineCount, records, parseErrorCount } = parseJsonlFile(file);
124
+ const messages = records.filter((record) => record.type === "message").map((record) => record.message);
125
+ const assistants = messages.filter((message) => message?.role === "assistant");
126
+ const usage = assistants.map((message) => message.usage).filter(Boolean);
127
+ const badUsage = assistants.map((message) => message.usage).filter(isBadUsage);
128
+ const fileFailure = lineCount === 0 || parseErrorCount > 0 || assistants.length === 0 || usage.length !== assistants.length || badUsage.length > 0;
129
+ if (fileFailure) failures += 1;
130
+ summary = {
131
+ file: relative(smokeDir, file),
132
+ lineCount,
133
+ parseErrorCount,
134
+ messageCount: messages.length,
135
+ assistantCount: assistants.length,
136
+ usageCount: usage.length,
137
+ badUsageCount: badUsage.length,
138
+ };
139
+ } catch (error) {
140
+ failures += 1;
141
+ summary = {
142
+ file: relative(smokeDir, file),
143
+ readError: error instanceof Error ? error.message : String(error),
144
+ };
145
+ }
146
+ console.log(JSON.stringify(summary));
147
+ }
148
+
149
+ process.exit(failures === 0 ? 0 : 1);
150
+ }
151
+
152
+ main();
package/src/context.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { Context, Message, ToolCall } from "@earendil-works/pi-ai";
3
+ import { convertToLlm } from "@earendil-works/pi-coding-agent";
2
4
  import type { SDKImage } from "@cursor/sdk";
5
+ import { getCursorPiBridgeContractText } from "./cursor-bridge-contract.js";
3
6
  import { getCursorReplayPromptLabel } from "./cursor-tool-names.js";
4
7
 
5
8
  export interface CursorPrompt {
@@ -13,9 +16,14 @@ export interface CursorPromptOptions {
13
16
  imageTokenEstimate?: number;
14
17
  }
15
18
 
16
- const DEFAULT_CHARS_PER_TOKEN = 4;
19
+ export const CURSOR_APPROX_CHARS_PER_TOKEN = 4;
20
+ export const CURSOR_IMAGE_TOKEN_ESTIMATE = 1200;
17
21
  const SECTION_SEPARATOR = "\n\n";
18
22
 
23
+ function normalizePiContextMessages(messages: Context["messages"]): Message[] {
24
+ return convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
25
+ }
26
+
19
27
  function isTextBlock(block: { type: string }): block is { type: "text"; text: string } {
20
28
  return block.type === "text";
21
29
  }
@@ -131,7 +139,7 @@ function applyPromptBudget(
131
139
  return [...sectionsBeforeMessages, ...messageSections.map((section) => section.text), ...sectionsAfterMessages];
132
140
  }
133
141
 
134
- const charsPerToken = options.charsPerToken ?? DEFAULT_CHARS_PER_TOKEN;
142
+ const charsPerToken = options.charsPerToken ?? CURSOR_APPROX_CHARS_PER_TOKEN;
135
143
  const maxChars = Math.max(1, Math.floor(maxInputTokens * charsPerToken));
136
144
  const requiredMessageSections = messageSections.filter((section) => section.index === latestUserMessageIndex);
137
145
  const requiredCost = [...sectionsBeforeMessages, ...requiredMessageSections.map((section) => section.text), ...sectionsAfterMessages].reduce(
@@ -167,11 +175,177 @@ function applyPromptBudget(
167
175
  return [...sectionsBeforeMessages, ...budgetNotice, ...includedMessages, ...sectionsAfterMessages];
168
176
  }
169
177
 
178
+ export function estimateCursorTextTokens(text: string, options: Pick<CursorPromptOptions, "charsPerToken"> = {}): number {
179
+ const charsPerToken = options.charsPerToken ?? CURSOR_APPROX_CHARS_PER_TOKEN;
180
+ return Math.ceil(text.length / charsPerToken);
181
+ }
182
+
183
+ export function estimateCursorPromptTokens(prompt: CursorPrompt, options: Pick<CursorPromptOptions, "charsPerToken" | "imageTokenEstimate"> = {}): number {
184
+ return estimateCursorTextTokens(prompt.text, options) + prompt.images.length * (options.imageTokenEstimate ?? CURSOR_IMAGE_TOKEN_ESTIMATE);
185
+ }
186
+
187
+ export function estimateCursorPromptMessageTokens(message: Message, options: Pick<CursorPromptOptions, "charsPerToken"> = {}): number {
188
+ const text = formatMessage(message);
189
+ return text ? estimateCursorTextTokens(text, options) : 0;
190
+ }
191
+
192
+ export function estimateCursorContextTokens(context: Context, options: CursorPromptOptions = {}): number {
193
+ return estimateCursorPromptTokens(buildCursorPrompt(context, options), options);
194
+ }
195
+
196
+ interface CursorContextFingerprintPayload {
197
+ systemHash: string;
198
+ messageHashes: string[];
199
+ }
200
+
201
+ function hashCursorContextValue(value: string): string {
202
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
203
+ }
204
+
205
+ function serializeMessageForFingerprint(message: Message, index: number): string {
206
+ switch (message.role) {
207
+ case "user": {
208
+ const text =
209
+ typeof message.content === "string"
210
+ ? message.content
211
+ : JSON.stringify(message.content);
212
+ return hashCursorContextValue(`user:${message.timestamp ?? index}:${text}`);
213
+ }
214
+ case "assistant":
215
+ return hashCursorContextValue(`assistant:${message.timestamp ?? index}:${JSON.stringify(message.content)}`);
216
+ case "toolResult":
217
+ return hashCursorContextValue(
218
+ `toolResult:${message.timestamp ?? index}:${message.toolCallId}:${message.toolName}:${JSON.stringify(message.content)}:${message.isError === true}`,
219
+ );
220
+ }
221
+ }
222
+
223
+ function serializeRawPiMessageForFingerprint(message: Context["messages"][number], index: number): string {
224
+ const role = (message as { role?: string }).role;
225
+ switch (role) {
226
+ case "branchSummary": {
227
+ const entry = message as { summary?: string; fromId?: string; timestamp?: number };
228
+ return hashCursorContextValue(
229
+ `branchSummary:${entry.timestamp ?? index}:${entry.fromId ?? ""}:${entry.summary ?? ""}`,
230
+ );
231
+ }
232
+ case "compactionSummary": {
233
+ const entry = message as { summary?: string; tokensBefore?: number; timestamp?: number };
234
+ return hashCursorContextValue(
235
+ `compactionSummary:${entry.timestamp ?? index}:${entry.tokensBefore ?? ""}:${entry.summary ?? ""}`,
236
+ );
237
+ }
238
+ case "custom": {
239
+ const entry = message as { customType?: string; content?: unknown; timestamp?: number };
240
+ return hashCursorContextValue(
241
+ `custom:${entry.timestamp ?? index}:${entry.customType ?? ""}:${JSON.stringify(entry.content)}`,
242
+ );
243
+ }
244
+ case "bashExecution": {
245
+ const entry = message as {
246
+ command?: string;
247
+ output?: string;
248
+ exitCode?: number | null;
249
+ cancelled?: boolean;
250
+ excludeFromContext?: boolean;
251
+ timestamp?: number;
252
+ };
253
+ if (entry.excludeFromContext) {
254
+ return hashCursorContextValue(`bashExecution:excluded:${entry.timestamp ?? index}`);
255
+ }
256
+ return hashCursorContextValue(
257
+ `bashExecution:${entry.timestamp ?? index}:${entry.command ?? ""}:${entry.output ?? ""}:${entry.exitCode ?? ""}:${entry.cancelled === true}`,
258
+ );
259
+ }
260
+ default:
261
+ return serializeMessageForFingerprint(message as Message, index);
262
+ }
263
+ }
264
+
265
+ function parseCursorContextFingerprint(fingerprint: string): CursorContextFingerprintPayload | undefined {
266
+ try {
267
+ const parsed = JSON.parse(fingerprint) as CursorContextFingerprintPayload;
268
+ if (!parsed || typeof parsed.systemHash !== "string" || !Array.isArray(parsed.messageHashes)) return undefined;
269
+ if (!parsed.messageHashes.every((entry) => typeof entry === "string")) return undefined;
270
+ return parsed;
271
+ } catch {
272
+ return undefined;
273
+ }
274
+ }
275
+
276
+ export function computeCursorContextFingerprint(context: Context): string {
277
+ const payload: CursorContextFingerprintPayload = {
278
+ systemHash: hashCursorContextValue(context.systemPrompt ?? ""),
279
+ messageHashes: context.messages.map((message, index) => serializeRawPiMessageForFingerprint(message, index)),
280
+ };
281
+ return JSON.stringify(payload);
282
+ }
283
+
284
+ export function shouldBootstrapCursorSend(
285
+ sendState: { bootstrapped: boolean; contextFingerprint: string },
286
+ context: Context,
287
+ ): boolean {
288
+ if (!sendState.bootstrapped) return true;
289
+ const previous = parseCursorContextFingerprint(sendState.contextFingerprint);
290
+ if (!previous) return true;
291
+ const current = parseCursorContextFingerprint(computeCursorContextFingerprint(context));
292
+ if (!current) return true;
293
+ if (current.systemHash !== previous.systemHash) return true;
294
+ if (current.messageHashes.length < previous.messageHashes.length) return true;
295
+ if (current.messageHashes.length > previous.messageHashes.length) {
296
+ for (let index = previous.messageHashes.length; index < context.messages.length; index += 1) {
297
+ const role = (context.messages[index] as { role?: string }).role;
298
+ if (role === "branchSummary" || role === "compactionSummary") return true;
299
+ }
300
+ }
301
+ for (let index = 0; index < previous.messageHashes.length; index += 1) {
302
+ if (current.messageHashes[index] !== previous.messageHashes[index]) return true;
303
+ }
304
+ return false;
305
+ }
306
+
307
+ export function buildCursorIncrementalPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
308
+ // Incremental sends omit the full Cursor SDK tool boundary block; the session agent retains prior bootstrap context.
309
+ const messages = normalizePiContextMessages(context.messages);
310
+ const latestUserMessageIndex = getLatestUserMessageIndex(messages);
311
+ const latestUserMessage = latestUserMessageIndex >= 0 ? messages[latestUserMessageIndex] : undefined;
312
+ const latestUserText = latestUserMessage ? formatMessage(latestUserMessage) : undefined;
313
+ const sectionsBeforeMessages = [
314
+ "Continue the conversation using Cursor SDK capabilities only. Do not list, promise, or call pi-only tools from earlier context as if they were available.",
315
+ ];
316
+ if (context.systemPrompt) {
317
+ sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
318
+ }
319
+ const latestUserMessageSections =
320
+ latestUserText && latestUserMessageIndex >= 0 ? [{ index: latestUserMessageIndex, text: latestUserText }] : [];
321
+ const images = extractLatestImages(messages);
322
+ const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
323
+ const budgetOptions =
324
+ options.maxInputTokens === undefined
325
+ ? options
326
+ : { ...options, maxInputTokens: Math.max(1, options.maxInputTokens - imageTokenReserve) };
327
+ const parts = applyPromptBudget(sectionsBeforeMessages, latestUserMessageSections, [], latestUserMessageIndex, budgetOptions);
328
+ return { text: parts.join(SECTION_SEPARATOR), images };
329
+ }
330
+
331
+ export function buildCursorSendPrompt(
332
+ context: Context,
333
+ options: CursorPromptOptions,
334
+ sendState: { bootstrapped: boolean; contextFingerprint: string },
335
+ ): { prompt: CursorPrompt; bootstrap: boolean } {
336
+ const bootstrap = shouldBootstrapCursorSend(sendState, context);
337
+ if (bootstrap) {
338
+ return { prompt: buildCursorPrompt(context, options), bootstrap: true };
339
+ }
340
+ return { prompt: buildCursorIncrementalPrompt(context, options), bootstrap: false };
341
+ }
342
+
170
343
  export function buildCursorPrompt(context: Context, options: CursorPromptOptions = {}): CursorPrompt {
171
344
  const sectionsBeforeMessages: string[] = [
172
345
  [
173
346
  "Cursor SDK tool boundary:",
174
347
  "You can call only tools actually exposed by Cursor SDK in this run. Pi tool names, replay tool names, and transcript tool names are context only, not callable capabilities.",
348
+ getCursorPiBridgeContractText(),
175
349
  "If asked to list or exercise available tools, list and exercise Cursor SDK tools only; do not claim access to pi-side tools from the system prompt unless Cursor exposes an equivalent tool that runs.",
176
350
  "Use pi__cursor_ask_question for material choices if exposed.",
177
351
  "Web: use Cursor web/search/browser/MCP or say web search is not configured; do not claim WebSearch/WebFetch unless Cursor executes them.",
@@ -184,7 +358,8 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
184
358
  sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(context.systemPrompt)}`);
185
359
  }
186
360
 
187
- const messageSections = context.messages
361
+ const messages = normalizePiContextMessages(context.messages);
362
+ const messageSections = messages
188
363
  .map((msg, index) => {
189
364
  const text = formatMessage(msg);
190
365
  return text ? { index, text } : undefined;
@@ -196,7 +371,7 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
196
371
  "If web research is requested, do not claim it unless a Cursor web/search/browser/MCP tool ran.",
197
372
  ].join("\n"),
198
373
  ];
199
- const images = extractLatestImages(context.messages);
374
+ const images = extractLatestImages(messages);
200
375
  const imageTokenReserve = images.length * (options.imageTokenEstimate ?? 0);
201
376
  const budgetOptions =
202
377
  options.maxInputTokens === undefined
@@ -206,7 +381,7 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
206
381
  sectionsBeforeMessages,
207
382
  messageSections,
208
383
  sectionsAfterMessages,
209
- getLatestUserMessageIndex(context.messages),
384
+ getLatestUserMessageIndex(messages),
210
385
  budgetOptions,
211
386
  );
212
387
  const text = parts.join(SECTION_SEPARATOR);
@@ -0,0 +1,27 @@
1
+ export const CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX = "pi__";
2
+
3
+ const CURSOR_PI_BRIDGE_CONTRACT_LINES = [
4
+ "Pi bridge contract:",
5
+ `${CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX}* names are live Cursor MCP bridge tool names only when exposed in the current run.`,
6
+ `Call the ${CURSOR_PI_BRIDGE_MCP_TOOL_PREFIX}* MCP tool name, not the real pi tool name shown in pi history or transcripts.`,
7
+ "Bridged calls execute through normal pi tool flow, so pi shows the real pi tool name and returns a normal pi tool result.",
8
+ "Replay IDs, replay labels, and transcript tool names are display-only/context-only, not callable tools.",
9
+ "Cursor-native host tools, settings, plugins, and configured MCP servers are separate from the pi bridge.",
10
+ ] as const;
11
+
12
+ export function getCursorPiBridgeContractText(): string {
13
+ return CURSOR_PI_BRIDGE_CONTRACT_LINES.join("\n");
14
+ }
15
+
16
+ export function buildCursorPiBridgeMcpToolDescription(options: {
17
+ piToolName: string;
18
+ mcpToolName: string;
19
+ piToolDescription: string;
20
+ }): string {
21
+ return [
22
+ options.piToolDescription,
23
+ "",
24
+ getCursorPiBridgeContractText(),
25
+ `This run exposes real pi tool ${options.piToolName} as Cursor MCP tool ${options.mcpToolName}.`,
26
+ ].join("\n");
27
+ }
@@ -0,0 +1,11 @@
1
+ const CURSOR_EDIT_DIFF_FIELD_ORDER = ["diffString", "diff", "unifiedDiff", "patch"] as const;
2
+
3
+ export function resolveCursorEditDiff(source: unknown): string | undefined {
4
+ if (!source || typeof source !== "object") return undefined;
5
+ const record = source as Record<string, unknown>;
6
+ for (const key of CURSOR_EDIT_DIFF_FIELD_ORDER) {
7
+ const value = record[key];
8
+ if (typeof value === "string" && value.length > 0) return value;
9
+ }
10
+ return undefined;
11
+ }
@@ -0,0 +1,22 @@
1
+ const DISABLED_ENV_VALUES = new Set(["0", "false", "off", "none", "no", "disabled"]);
2
+ const ENABLED_ENV_VALUES = new Set(["1", "true", "on", "yes", "enabled"]);
3
+
4
+ function normalizeEnvBoolean(raw: string | undefined): string | undefined {
5
+ const normalized = raw?.trim().toLowerCase();
6
+ return normalized || undefined;
7
+ }
8
+
9
+ export function parseOptionalEnvBoolean(raw: string | undefined): boolean | undefined {
10
+ const normalized = normalizeEnvBoolean(raw);
11
+ if (!normalized) return undefined;
12
+ if (DISABLED_ENV_VALUES.has(normalized)) return false;
13
+ if (ENABLED_ENV_VALUES.has(normalized)) return true;
14
+ return undefined;
15
+ }
16
+
17
+ export function parseEnvBoolean(
18
+ raw: string | undefined,
19
+ defaultValue: boolean,
20
+ ): boolean {
21
+ return parseOptionalEnvBoolean(raw) ?? defaultValue;
22
+ }
@@ -0,0 +1,65 @@
1
+ import type { Context, Message, ToolResultMessage } from "@earendil-works/pi-ai";
2
+ import { CURSOR_APPROX_CHARS_PER_TOKEN, estimateCursorPromptMessageTokens } from "./context.js";
3
+
4
+ export interface CursorLiveRunAccountingState {
5
+ promptInputTokens: number;
6
+ promptInputTokensReported: boolean;
7
+ consumedToolResultIds: ReadonlySet<string>;
8
+ }
9
+
10
+ export interface CursorLiveToolResultConsumption {
11
+ state: CursorLiveRunAccountingState;
12
+ toolResults: ToolResultMessage[];
13
+ toolResultInputTokens: number;
14
+ toolCallIds: string[];
15
+ }
16
+
17
+ export function createCursorLiveRunAccountingState(promptInputTokens: number): CursorLiveRunAccountingState {
18
+ return {
19
+ promptInputTokens,
20
+ promptInputTokensReported: false,
21
+ consumedToolResultIds: new Set(),
22
+ };
23
+ }
24
+
25
+ function asToolResultMessage(message: Message): ToolResultMessage | undefined {
26
+ return message.role === "toolResult" ? message : undefined;
27
+ }
28
+
29
+ export function consumeCursorLiveToolResults(
30
+ state: CursorLiveRunAccountingState,
31
+ context: Context,
32
+ isMatchingToolResult: (toolResult: ToolResultMessage) => boolean,
33
+ ): CursorLiveToolResultConsumption {
34
+ const consumedToolResultIds = new Set(state.consumedToolResultIds);
35
+ const toolResults: ToolResultMessage[] = [];
36
+ let toolResultInputTokens = 0;
37
+
38
+ for (const message of context.messages) {
39
+ const toolResult = asToolResultMessage(message);
40
+ if (!toolResult) continue;
41
+ if (consumedToolResultIds.has(toolResult.toolCallId)) continue;
42
+ if (!isMatchingToolResult(toolResult)) continue;
43
+ consumedToolResultIds.add(toolResult.toolCallId);
44
+ toolResults.push(toolResult);
45
+ toolResultInputTokens += estimateCursorPromptMessageTokens(toolResult, { charsPerToken: CURSOR_APPROX_CHARS_PER_TOKEN });
46
+ }
47
+
48
+ return {
49
+ state: { ...state, consumedToolResultIds },
50
+ toolResults,
51
+ toolResultInputTokens,
52
+ toolCallIds: toolResults.map((toolResult) => toolResult.toolCallId),
53
+ };
54
+ }
55
+
56
+ export function takeCursorLiveTurnInputTokens(
57
+ state: CursorLiveRunAccountingState,
58
+ toolResultInputTokens: number,
59
+ ): { state: CursorLiveRunAccountingState; sessionInputTokens: number } {
60
+ const promptInputTokens = state.promptInputTokensReported ? 0 : state.promptInputTokens;
61
+ return {
62
+ state: state.promptInputTokensReported ? state : { ...state, promptInputTokensReported: true },
63
+ sessionInputTokens: promptInputTokens + toolResultInputTokens,
64
+ };
65
+ }