pi-provider-cursor-ask 0.1.0
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 +9 -0
- package/LICENSE +21 -0
- package/README.md +87 -0
- package/README.zh-CN.md +87 -0
- package/UPSTREAM_CHANGELOG.md +368 -0
- package/UPSTREAM_SOURCE.md +23 -0
- package/dist/index.js +54 -0
- package/package.json +97 -0
- package/src/auth/cli-credentials.ts +275 -0
- package/src/auth/consent.ts +25 -0
- package/src/auth/index.ts +23 -0
- package/src/auth/oauth.ts +282 -0
- package/src/auth/refresh-guard.ts +93 -0
- package/src/client/bridge.ts +673 -0
- package/src/client/cursor-wire.ts +213 -0
- package/src/client/h2-unary.ts +142 -0
- package/src/client/index.ts +18 -0
- package/src/config/index.ts +69 -0
- package/src/diagnostics/diagnostics.ts +116 -0
- package/src/diagnostics/index.ts +1 -0
- package/src/extension/auth.ts +99 -0
- package/src/extension/commands.ts +163 -0
- package/src/extension/compaction-guard.ts +86 -0
- package/src/extension/debug-hooks.ts +359 -0
- package/src/extension/index.ts +8 -0
- package/src/extension/provider.ts +277 -0
- package/src/extension/quota-adapter.ts +175 -0
- package/src/extension/report-dashboard.ts +133 -0
- package/src/identity.ts +16 -0
- package/src/index.ts +186 -0
- package/src/models/ask-catalog.ts +384 -0
- package/src/models/catalog.json +1163 -0
- package/src/models/cost.ts +126 -0
- package/src/models/index.ts +6 -0
- package/src/models/limits.ts +36 -0
- package/src/models/parameterized.ts +416 -0
- package/src/models/processing.ts +313 -0
- package/src/proto/agent_pb.ts +14577 -0
- package/src/stream/bridge-session.ts +215 -0
- package/src/stream/client-transcript.ts +51 -0
- package/src/stream/config.ts +5 -0
- package/src/stream/context-normalize.ts +308 -0
- package/src/stream/context-usage.ts +168 -0
- package/src/stream/debug-log.ts +316 -0
- package/src/stream/drift.ts +122 -0
- package/src/stream/images.ts +201 -0
- package/src/stream/index.ts +68 -0
- package/src/stream/interaction-query.ts +369 -0
- package/src/stream/message-parsing.ts +402 -0
- package/src/stream/model-cache.ts +100 -0
- package/src/stream/model-discovery.ts +242 -0
- package/src/stream/model-routing.ts +100 -0
- package/src/stream/native-core.ts +2121 -0
- package/src/stream/pi-adapter.ts +414 -0
- package/src/stream/protocol.ts +63 -0
- package/src/stream/recovery.ts +494 -0
- package/src/stream/request-build.ts +668 -0
- package/src/stream/root-prompt.ts +184 -0
- package/src/stream/run-journal.ts +474 -0
- package/src/stream/run-usage.ts +107 -0
- package/src/stream/server-messages.ts +777 -0
- package/src/stream/session-state.ts +499 -0
- package/src/stream/stream-writer.ts +211 -0
- package/src/stream/thinking-filter.ts +63 -0
- package/src/stream/tool-schema.ts +185 -0
- package/src/stream/transport-errors.ts +150 -0
- package/src/stream/tuning.ts +250 -0
- package/src/stream/types.ts +330 -0
- package/src/types/enums.ts +103 -0
- package/src/types/index.ts +4 -0
- package/src/usage.ts +262 -0
- package/src/utils/cache-dir.ts +39 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/security.ts +68 -0
- package/src/utils/util.ts +43 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/** Cursor-only context estimates. Billing totals must never become a context fallback. */
|
|
2
|
+
import { createHash, type Hash } from "node:crypto";
|
|
3
|
+
import type { Api, AssistantMessage, Context, Message, Model } from "@earendil-works/pi-ai";
|
|
4
|
+
import type { CursorBillingInfo } from "./run-usage.js";
|
|
5
|
+
|
|
6
|
+
export interface CursorUsageMetadata {
|
|
7
|
+
version: 1;
|
|
8
|
+
billing?: CursorBillingInfo;
|
|
9
|
+
context: {
|
|
10
|
+
tokens: number;
|
|
11
|
+
source: "checkpoint" | "estimate";
|
|
12
|
+
scope: string;
|
|
13
|
+
history: string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ContextMessage = Message & { cursorUsage?: CursorUsageMetadata };
|
|
18
|
+
export type CursorAssistantMessage = AssistantMessage & { cursorUsage?: CursorUsageMetadata };
|
|
19
|
+
|
|
20
|
+
export function positiveContextTokens(value: unknown): number | undefined {
|
|
21
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
22
|
+
? Math.ceil(value)
|
|
23
|
+
: undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function textTokens(text: string): number {
|
|
27
|
+
return Math.ceil(Buffer.byteLength(text, "utf8") / 4);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** A bounded image heuristic, not base64 length or an assertion of upstream tokenization. */
|
|
31
|
+
export function estimateMessageTokens(message: Pick<Message, "content">): number {
|
|
32
|
+
if (typeof message.content === "string") return 4 + textTokens(message.content);
|
|
33
|
+
let tokens = 4;
|
|
34
|
+
for (const block of message.content) {
|
|
35
|
+
switch (block.type) {
|
|
36
|
+
case "text":
|
|
37
|
+
tokens += textTokens(block.text);
|
|
38
|
+
break;
|
|
39
|
+
case "thinking":
|
|
40
|
+
tokens += textTokens(block.thinking);
|
|
41
|
+
break;
|
|
42
|
+
case "image":
|
|
43
|
+
tokens += 1200;
|
|
44
|
+
break;
|
|
45
|
+
case "toolCall":
|
|
46
|
+
tokens += textTokens(block.name) + textTokens(JSON.stringify(block.arguments));
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return tokens;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function appendMessageHash(hash: Hash, message: Message): void {
|
|
54
|
+
// Exclude accounting metadata/timestamps, but bind the anchor to the actual message prefix.
|
|
55
|
+
hash.update(JSON.stringify({ role: message.role, content: message.content }));
|
|
56
|
+
hash.update("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createCursorContextTracker(
|
|
60
|
+
model: Model<Api>,
|
|
61
|
+
context: Context = { messages: [] },
|
|
62
|
+
options?: { sessionId?: string; reasoning?: string },
|
|
63
|
+
) {
|
|
64
|
+
const scope = createHash("sha256")
|
|
65
|
+
.update(
|
|
66
|
+
JSON.stringify({
|
|
67
|
+
provider: model.provider,
|
|
68
|
+
model: model.id,
|
|
69
|
+
window: model.contextWindow,
|
|
70
|
+
session: options?.sessionId,
|
|
71
|
+
reasoning: options?.reasoning,
|
|
72
|
+
system: context.systemPrompt,
|
|
73
|
+
tools: context.tools,
|
|
74
|
+
}),
|
|
75
|
+
)
|
|
76
|
+
.digest("hex");
|
|
77
|
+
const history = createHash("sha256");
|
|
78
|
+
let inputTokens =
|
|
79
|
+
textTokens(context.systemPrompt ?? "") + textTokens(JSON.stringify(context.tools ?? []));
|
|
80
|
+
let rawInputTokens = inputTokens;
|
|
81
|
+
let anchorTokens: number | undefined;
|
|
82
|
+
let trailingTokens = 0;
|
|
83
|
+
for (const message of context.messages) {
|
|
84
|
+
appendMessageHash(history, message);
|
|
85
|
+
const estimated = estimateMessageTokens(message);
|
|
86
|
+
inputTokens += estimated;
|
|
87
|
+
rawInputTokens += estimated;
|
|
88
|
+
trailingTokens += estimated;
|
|
89
|
+
const saved = (message as ContextMessage).cursorUsage;
|
|
90
|
+
if (
|
|
91
|
+
message.role === "assistant" &&
|
|
92
|
+
message.provider === model.provider &&
|
|
93
|
+
message.model === model.id &&
|
|
94
|
+
saved?.version === 1 &&
|
|
95
|
+
saved.context &&
|
|
96
|
+
positiveContextTokens(saved.context.tokens) &&
|
|
97
|
+
saved.context.scope === scope &&
|
|
98
|
+
saved.context.history === history.copy().digest("hex")
|
|
99
|
+
) {
|
|
100
|
+
// An unchanged same-session prefix preserves Cursor's otherwise invisible prompt overhead.
|
|
101
|
+
// A fork/new session, compaction, history edit, model or system/tools change invalidates it.
|
|
102
|
+
inputTokens = saved.context.tokens;
|
|
103
|
+
anchorTokens = saved.context.tokens;
|
|
104
|
+
trailingTokens = 0;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const anchoredInputTokens = inputTokens;
|
|
108
|
+
let snapshot:
|
|
109
|
+
| { tokens: number; generatedAtSnapshot: number; checkpoint?: Uint8Array; fresh: boolean }
|
|
110
|
+
| undefined;
|
|
111
|
+
return {
|
|
112
|
+
begin(
|
|
113
|
+
mode: "history" | "checkpoint" | "live",
|
|
114
|
+
inheritedTokens?: number,
|
|
115
|
+
checkpoint?: Uint8Array,
|
|
116
|
+
): void {
|
|
117
|
+
const previous = snapshot;
|
|
118
|
+
snapshot = undefined;
|
|
119
|
+
// A server-side summary cannot calibrate a request that re-expanded the full Pi history.
|
|
120
|
+
inputTokens = mode === "history" ? rawInputTokens : anchoredInputTokens;
|
|
121
|
+
const inherited = positiveContextTokens(inheritedTokens);
|
|
122
|
+
if (mode !== "history" && inherited !== undefined) {
|
|
123
|
+
// The request actually resumes this checkpoint, even before the first Pi reply exists.
|
|
124
|
+
// Its observation may also be smaller than an older message anchor.
|
|
125
|
+
inputTokens = inherited + (anchorTokens !== undefined ? trailingTokens : 0);
|
|
126
|
+
}
|
|
127
|
+
if (
|
|
128
|
+
mode !== "history" &&
|
|
129
|
+
previous &&
|
|
130
|
+
(inherited === undefined ||
|
|
131
|
+
(checkpoint !== undefined &&
|
|
132
|
+
checkpoint === previous.checkpoint &&
|
|
133
|
+
inherited === previous.tokens))
|
|
134
|
+
) {
|
|
135
|
+
// Same-writer continuation must not lose its measured output boundary. Compare the
|
|
136
|
+
// actual checkpoint object, not token counts alone. A placeholder still retains the
|
|
137
|
+
// last observation as an estimate; neither case is a fresh post-recovery measurement.
|
|
138
|
+
snapshot = { ...previous, fresh: false };
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
observe(tokens: number, output: AssistantMessage, checkpoint?: Uint8Array): void {
|
|
142
|
+
const valid = positiveContextTokens(tokens);
|
|
143
|
+
if (valid === undefined) return;
|
|
144
|
+
// A genuine smaller positive snapshot (e.g. upstream summarization) must be accepted too.
|
|
145
|
+
snapshot = {
|
|
146
|
+
tokens: valid,
|
|
147
|
+
generatedAtSnapshot: estimateMessageTokens(output),
|
|
148
|
+
checkpoint,
|
|
149
|
+
fresh: true,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
finish(output: CursorAssistantMessage): CursorUsageMetadata["context"] {
|
|
153
|
+
const generated = estimateMessageTokens(output);
|
|
154
|
+
const suffix = snapshot ? Math.max(0, generated - snapshot.generatedAtSnapshot) : generated;
|
|
155
|
+
const tokens = Math.max(1, Math.ceil((snapshot?.tokens ?? inputTokens) + suffix));
|
|
156
|
+
const finalHistory = history.copy();
|
|
157
|
+
appendMessageHash(finalHistory, output);
|
|
158
|
+
const result: CursorUsageMetadata["context"] = {
|
|
159
|
+
tokens,
|
|
160
|
+
source: snapshot?.fresh && suffix === 0 ? "checkpoint" : "estimate",
|
|
161
|
+
scope,
|
|
162
|
+
history: finalHistory.digest("hex"),
|
|
163
|
+
};
|
|
164
|
+
output.cursorUsage = { version: 1, context: result };
|
|
165
|
+
return result;
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Diagnostics sinks for the stream runtime.
|
|
3
|
+
*
|
|
4
|
+
* Four separate channels, deliberately:
|
|
5
|
+
* - `debugLog` verbose JSONL, opt-in via PI_CURSOR_PROVIDER_DEBUG
|
|
6
|
+
* - `lifecycleLog` always-on compact log for diagnosing multi-minute stalls
|
|
7
|
+
* - `emitMetric` structured counters; defaults to lifecycleLog (never TUI)
|
|
8
|
+
* - `reportCursorAnomaly` key user-visible recoveries: lifecycle + optional TUI notify
|
|
9
|
+
*
|
|
10
|
+
* Everything here swallows its own errors: diagnostics must never break a turn.
|
|
11
|
+
* Payloads pass through `sanitizeForDebug`, which truncates strings, summarizes
|
|
12
|
+
* binary/image data, and redacts access tokens.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { appendFile } from "node:fs";
|
|
16
|
+
import { join as pathJoin } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { getCacheDir } from "../utils/cache-dir.js";
|
|
19
|
+
import { redactSecrets } from "../utils/security.js";
|
|
20
|
+
import { normalizeImageMimeType } from "./images.js";
|
|
21
|
+
import type { CursorRequestDebugSummary } from "./types.js";
|
|
22
|
+
|
|
23
|
+
let debugRequestCounter = 0;
|
|
24
|
+
|
|
25
|
+
let debugLogFilePath: string | undefined;
|
|
26
|
+
|
|
27
|
+
export const requestDebugByBody = new WeakMap<Uint8Array, CursorRequestDebugSummary>();
|
|
28
|
+
|
|
29
|
+
// `debugLog` guards every call site on this, including one per server message on the streaming
|
|
30
|
+
// hot path. `process.env` is a native-backed proxy whose property reads cost ~100x an ordinary
|
|
31
|
+
// one, so resolve the flag once instead of on every token. Resolution stays lazy so a value pi
|
|
32
|
+
// exports after this module loads is still picked up.
|
|
33
|
+
let streamDebugEnabled: boolean | undefined;
|
|
34
|
+
|
|
35
|
+
export function isStreamDebugEnabled(): boolean {
|
|
36
|
+
if (streamDebugEnabled === undefined) {
|
|
37
|
+
const raw = process.env.PI_CURSOR_PROVIDER_DEBUG?.trim().toLowerCase();
|
|
38
|
+
streamDebugEnabled = !!raw && raw !== "0" && raw !== "false" && raw !== "off";
|
|
39
|
+
}
|
|
40
|
+
return streamDebugEnabled;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Test seam: re-read PI_CURSOR_PROVIDER_DEBUG after mutating process.env. */
|
|
44
|
+
export function resetStreamDebugForTests(): void {
|
|
45
|
+
streamDebugEnabled = undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function truncateDebugString(value: string, max = 4000): string {
|
|
49
|
+
return value.length > max
|
|
50
|
+
? `${value.slice(0, max)}…<truncated ${value.length - max} chars>`
|
|
51
|
+
: value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function debugByteSummary(bytes: Uint8Array): { byteLength: number; sha256: string } {
|
|
55
|
+
return {
|
|
56
|
+
byteLength: bytes.length,
|
|
57
|
+
sha256: createHash("sha256").update(bytes).digest("hex").slice(0, 16),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function debugBase64ImageSummary(data: string): {
|
|
62
|
+
base64Length: number;
|
|
63
|
+
byteLength?: number;
|
|
64
|
+
sha256?: string;
|
|
65
|
+
decodeError?: boolean;
|
|
66
|
+
} {
|
|
67
|
+
const stripped = data.replace(/\s/g, "");
|
|
68
|
+
const bytes = Buffer.from(stripped, "base64");
|
|
69
|
+
if (bytes.length > 0) {
|
|
70
|
+
return { base64Length: data.length, ...debugByteSummary(new Uint8Array(bytes)) };
|
|
71
|
+
}
|
|
72
|
+
if (stripped.length > 0) {
|
|
73
|
+
return { base64Length: data.length, decodeError: true };
|
|
74
|
+
}
|
|
75
|
+
return { base64Length: data.length };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function summarizeDebugImageUrl(url: string): unknown {
|
|
79
|
+
const trimmed = url.trim();
|
|
80
|
+
const match = trimmed.match(/^data:([^;,]+)(?:;[^,]*)?;base64,(.*)$/is);
|
|
81
|
+
if (match) {
|
|
82
|
+
return {
|
|
83
|
+
mimeType: normalizeImageMimeType(match[1]!),
|
|
84
|
+
...debugBase64ImageSummary(match[2]!),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
url: trimmed.startsWith("data:image/")
|
|
89
|
+
? `<redacted data image ${trimmed.length} chars>`
|
|
90
|
+
: truncateDebugString(trimmed),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function summarizeDebugImageObject(value: Record<string, unknown>): unknown | undefined {
|
|
95
|
+
const imageUrl = value.image_url;
|
|
96
|
+
if (imageUrl && typeof imageUrl === "object") {
|
|
97
|
+
const url = (imageUrl as Record<string, unknown>).url;
|
|
98
|
+
if (typeof url === "string")
|
|
99
|
+
return { type: value.type ?? "image_url", image_url: summarizeDebugImageUrl(url) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const mimeType =
|
|
103
|
+
typeof value.mimeType === "string" ? normalizeImageMimeType(value.mimeType) : undefined;
|
|
104
|
+
if (!mimeType?.startsWith("image/")) return undefined;
|
|
105
|
+
const data = value.data;
|
|
106
|
+
if (typeof data === "string") {
|
|
107
|
+
return { type: value.type, mimeType, ...debugBase64ImageSummary(data) };
|
|
108
|
+
}
|
|
109
|
+
if (data instanceof Uint8Array || Buffer.isBuffer(data)) {
|
|
110
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
111
|
+
return { type: value.type, mimeType, ...debugByteSummary(bytes) };
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const SENSITIVE_DEBUG_KEYS = new Set([
|
|
117
|
+
"accesstoken",
|
|
118
|
+
"refreshtoken",
|
|
119
|
+
"authorization",
|
|
120
|
+
"access_token",
|
|
121
|
+
"refresh_token",
|
|
122
|
+
"code_verifier",
|
|
123
|
+
"cookie",
|
|
124
|
+
"workoscursorsessiontoken",
|
|
125
|
+
"apikey",
|
|
126
|
+
"api_key",
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
function isSensitiveDebugKey(key: string): boolean {
|
|
130
|
+
const normalized = key.replace(/[_-]/g, "").toLowerCase();
|
|
131
|
+
return (
|
|
132
|
+
SENSITIVE_DEBUG_KEYS.has(key.toLowerCase()) ||
|
|
133
|
+
SENSITIVE_DEBUG_KEYS.has(normalized) ||
|
|
134
|
+
key === "access" ||
|
|
135
|
+
key === "refresh"
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function sanitizeForDebug(value: unknown): unknown {
|
|
140
|
+
if (value == null) return value;
|
|
141
|
+
if (typeof value === "string") return redactSecrets(truncateDebugString(value));
|
|
142
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
143
|
+
if (value instanceof Uint8Array || Buffer.isBuffer(value)) {
|
|
144
|
+
const bytes = value instanceof Uint8Array ? value : new Uint8Array(value);
|
|
145
|
+
return {
|
|
146
|
+
__type: value instanceof Uint8Array ? "Uint8Array" : "Buffer",
|
|
147
|
+
...debugByteSummary(bytes),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(value)) return value.map((item) => sanitizeForDebug(item));
|
|
151
|
+
if (value instanceof Map) {
|
|
152
|
+
return {
|
|
153
|
+
__type: "Map",
|
|
154
|
+
size: value.size,
|
|
155
|
+
entries: Array.from(value.entries())
|
|
156
|
+
.slice(0, 20)
|
|
157
|
+
.map(([k, v]) => [sanitizeForDebug(k), sanitizeForDebug(v)]),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (typeof value === "object") {
|
|
161
|
+
const imageSummary = summarizeDebugImageObject(value as Record<string, unknown>);
|
|
162
|
+
if (imageSummary) return imageSummary;
|
|
163
|
+
const entries = Object.entries(value as Record<string, unknown>).map(([key, inner]) => {
|
|
164
|
+
if (isSensitiveDebugKey(key)) return [key, "<redacted>"] as const;
|
|
165
|
+
if (key === "data" && typeof inner === "string")
|
|
166
|
+
return [key, `<redacted base64 ${inner.length} chars>`] as const;
|
|
167
|
+
if (key === "url" && typeof inner === "string" && inner.startsWith("data:image/")) {
|
|
168
|
+
return [key, `<redacted data image ${inner.length} chars>`] as const;
|
|
169
|
+
}
|
|
170
|
+
return [key, sanitizeForDebug(inner)] as const;
|
|
171
|
+
});
|
|
172
|
+
return Object.fromEntries(entries);
|
|
173
|
+
}
|
|
174
|
+
return String(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function getDebugLogFilePath(): string {
|
|
178
|
+
const configured = process.env.PI_CURSOR_PROVIDER_DEBUG_FILE?.trim();
|
|
179
|
+
if (configured) return configured;
|
|
180
|
+
if (debugLogFilePath) return debugLogFilePath;
|
|
181
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
182
|
+
debugLogFilePath = pathJoin(
|
|
183
|
+
getCacheDir() ?? process.cwd(),
|
|
184
|
+
`pi-cursor-provider-debug-${stamp}-${process.pid}.log`,
|
|
185
|
+
);
|
|
186
|
+
return debugLogFilePath;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function debugLog(event: string, data?: Record<string, unknown>): void {
|
|
190
|
+
if (!isStreamDebugEnabled()) return;
|
|
191
|
+
try {
|
|
192
|
+
const line = JSON.stringify({
|
|
193
|
+
ts: new Date().toISOString(),
|
|
194
|
+
pid: process.pid,
|
|
195
|
+
event,
|
|
196
|
+
...(data ? (sanitizeForDebug(data) as Record<string, unknown>) : {}),
|
|
197
|
+
});
|
|
198
|
+
const file = getDebugLogFilePath();
|
|
199
|
+
appendFile(file, `${line}\n`, { encoding: "utf8", mode: 0o600 }, (error) => {
|
|
200
|
+
if (error) console.error("[pi-cursor-provider] failed to write debug log", error);
|
|
201
|
+
});
|
|
202
|
+
} catch (error) {
|
|
203
|
+
console.error("[pi-cursor-provider] failed to write debug log", error);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Always-on compact lifecycle log for diagnosing multi-minute stalls. */
|
|
208
|
+
let lifecycleLogPath: string | undefined;
|
|
209
|
+
const MAX_LIFECYCLE_LOG_BYTES = 10 * 1024 * 1024;
|
|
210
|
+
let lifecycleBytesWritten = 0;
|
|
211
|
+
|
|
212
|
+
export function getLifecycleLogPath(): string {
|
|
213
|
+
const configured = process.env.PI_CURSOR_LIFECYCLE_LOG?.trim();
|
|
214
|
+
if (configured) return configured;
|
|
215
|
+
if (lifecycleLogPath) return lifecycleLogPath;
|
|
216
|
+
lifecycleLogPath = pathJoin(
|
|
217
|
+
getCacheDir() ?? process.cwd(),
|
|
218
|
+
`pi-cursor-lifecycle-${process.pid}.jsonl`,
|
|
219
|
+
);
|
|
220
|
+
return lifecycleLogPath;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function lifecycleLog(event: string, data?: Record<string, unknown>): void {
|
|
224
|
+
try {
|
|
225
|
+
const line = JSON.stringify({
|
|
226
|
+
ts: new Date().toISOString(),
|
|
227
|
+
pid: process.pid,
|
|
228
|
+
event,
|
|
229
|
+
...(data ? (sanitizeForDebug(data) as Record<string, unknown>) : {}),
|
|
230
|
+
});
|
|
231
|
+
const encodedBytes = Buffer.byteLength(line) + 1;
|
|
232
|
+
if (lifecycleBytesWritten + encodedBytes > MAX_LIFECYCLE_LOG_BYTES) return;
|
|
233
|
+
lifecycleBytesWritten += encodedBytes;
|
|
234
|
+
appendFile(getLifecycleLogPath(), `${line}\n`, { encoding: "utf8", mode: 0o600 }, () => {});
|
|
235
|
+
} catch {
|
|
236
|
+
// Never throw from diagnostics.
|
|
237
|
+
}
|
|
238
|
+
// Also mirror into verbose debug log when enabled.
|
|
239
|
+
debugLog(`lifecycle.${event}`, data);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export type MetricEmitter = (event: string, data: Record<string, unknown>) => void;
|
|
243
|
+
|
|
244
|
+
const defaultMetricEmitter: MetricEmitter = (event, data) => {
|
|
245
|
+
lifecycleLog(event, data);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
let metricEmitter: MetricEmitter = defaultMetricEmitter;
|
|
249
|
+
|
|
250
|
+
export function emitMetric(event: string, data: Record<string, unknown>): void {
|
|
251
|
+
try {
|
|
252
|
+
metricEmitter(event, data);
|
|
253
|
+
} catch (error) {
|
|
254
|
+
console.error("[pi-cursor-provider] failed to emit metric", error);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export type CursorNotifyLevel = "info" | "warning" | "error";
|
|
259
|
+
export type CursorNotifySink = (message: string, level?: CursorNotifyLevel) => void;
|
|
260
|
+
|
|
261
|
+
let cursorNotifySink: CursorNotifySink | undefined;
|
|
262
|
+
|
|
263
|
+
/** Register a TUI notify sink from extension context. Omit to clear. */
|
|
264
|
+
export function setCursorNotifySink(sink?: CursorNotifySink): void {
|
|
265
|
+
cursorNotifySink = sink;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Persist a rare user-visible anomaly to the lifecycle file.
|
|
270
|
+
* Notifies the TUI when a sink is registered; otherwise no-op unless this is a
|
|
271
|
+
* real failure with no other sink, in which case a short human line goes to stderr.
|
|
272
|
+
*/
|
|
273
|
+
export function reportCursorAnomaly(
|
|
274
|
+
event: string,
|
|
275
|
+
message: string,
|
|
276
|
+
data?: Record<string, unknown>,
|
|
277
|
+
options?: { level?: CursorNotifyLevel; stderrIfNoSink?: boolean },
|
|
278
|
+
): void {
|
|
279
|
+
lifecycleLog(event, data);
|
|
280
|
+
const level = options?.level ?? "warning";
|
|
281
|
+
if (cursorNotifySink) {
|
|
282
|
+
try {
|
|
283
|
+
cursorNotifySink(message, level);
|
|
284
|
+
} catch {
|
|
285
|
+
// Never throw from diagnostics.
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (options?.stderrIfNoSink) {
|
|
290
|
+
try {
|
|
291
|
+
console.error(message);
|
|
292
|
+
} catch {
|
|
293
|
+
// Never throw from diagnostics.
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function nextDebugRequestId(): string {
|
|
299
|
+
debugRequestCounter += 1;
|
|
300
|
+
return `req-${debugRequestCounter}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function decodeRequestForTests(requestBody: Uint8Array): CursorRequestDebugSummary {
|
|
304
|
+
return requestDebugByBody.get(requestBody) ?? { systemPrompt: "", selectedImages: [] };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function redactForDebug(value: string): string {
|
|
308
|
+
return value
|
|
309
|
+
.replace(/([A-Z0-9_]*TOKEN[A-Z0-9_]*=)[^\s,;]+/gi, "$1[redacted]")
|
|
310
|
+
.replace(/(Bearer\s+)[A-Za-z0-9._~+/-]+/gi, "$1[redacted]");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Test seam: replace the metric sink (used by __testInternals). */
|
|
314
|
+
export function setMetricEmitter(factory?: MetricEmitter): void {
|
|
315
|
+
metricEmitter = factory ?? defaultMetricEmitter;
|
|
316
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire-protocol drift detection.
|
|
3
|
+
*
|
|
4
|
+
* Cursor can change `agent.v1` at any time, and the failure mode that costs the
|
|
5
|
+
* most to debug is the quiet one: a server message case we don't know about is
|
|
6
|
+
* skipped, nothing is answered, and the turn parks until the idle watchdog fires
|
|
7
|
+
* with a generic timeout. The user sees "it hung"; the log says nothing.
|
|
8
|
+
*
|
|
9
|
+
* So every unrecognized case and every unknown protobuf field is recorded here
|
|
10
|
+
* instead of being silently dropped. Signals are:
|
|
11
|
+
* - counted in-process and surfaced by `/cursor.doctor`
|
|
12
|
+
* - written to the always-on lifecycle log
|
|
13
|
+
* - appended to the stream error message when a turn actually fails
|
|
14
|
+
*
|
|
15
|
+
* Recording a signal is not itself an error. Unknown *fields* are routine when
|
|
16
|
+
* Cursor ships ahead of our schema and are usually harmless. Unknown *message
|
|
17
|
+
* cases* are the ones that strand a turn — `kind` keeps them distinguishable.
|
|
18
|
+
*/
|
|
19
|
+
import { lifecycleLog } from "./debug-log.js";
|
|
20
|
+
import { setLastDriftSignal } from "../diagnostics/diagnostics.js";
|
|
21
|
+
import { DriftKind, type DriftKindType } from "../types/enums.js";
|
|
22
|
+
|
|
23
|
+
export { DriftKind, type DriftKindType } from "../types/enums.js";
|
|
24
|
+
|
|
25
|
+
/** Cases that can strand a turn, as opposed to being merely informational. */
|
|
26
|
+
const STRANDING_KINDS = new Set<DriftKind>([
|
|
27
|
+
DriftKind.ServerMessage,
|
|
28
|
+
DriftKind.ExecMessage,
|
|
29
|
+
DriftKind.InteractionQuery,
|
|
30
|
+
DriftKind.KvMessage,
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
export interface DriftSignal {
|
|
34
|
+
kind: DriftKind;
|
|
35
|
+
detail: string;
|
|
36
|
+
count: number;
|
|
37
|
+
firstSeenIso: string;
|
|
38
|
+
lastSeenIso: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const signals = new Map<string, DriftSignal>();
|
|
42
|
+
|
|
43
|
+
/** Bounded so a pathological stream cannot grow this map without limit. */
|
|
44
|
+
const MAX_TRACKED_SIGNALS = 64;
|
|
45
|
+
|
|
46
|
+
function signalKey(kind: DriftKind | DriftKindType, detail: string): string {
|
|
47
|
+
return `${kind}:${detail}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Records one drift observation. Safe to call on a hot path: repeat observations
|
|
52
|
+
* only bump a counter, and only the first of each kind+detail is logged.
|
|
53
|
+
*/
|
|
54
|
+
export function recordDriftSignal(
|
|
55
|
+
kind: DriftKind | DriftKindType,
|
|
56
|
+
detail: string | undefined,
|
|
57
|
+
): void {
|
|
58
|
+
const normalized = (detail ?? "unknown").slice(0, 80);
|
|
59
|
+
const key = signalKey(kind, normalized);
|
|
60
|
+
const now = new Date().toISOString();
|
|
61
|
+
|
|
62
|
+
const existing = signals.get(key);
|
|
63
|
+
if (existing) {
|
|
64
|
+
existing.count += 1;
|
|
65
|
+
existing.lastSeenIso = now;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (signals.size >= MAX_TRACKED_SIGNALS) return;
|
|
70
|
+
signals.set(key, {
|
|
71
|
+
kind: kind as DriftKind,
|
|
72
|
+
detail: normalized,
|
|
73
|
+
count: 1,
|
|
74
|
+
firstSeenIso: now,
|
|
75
|
+
lastSeenIso: now,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Log only on first sighting — a drifted stream would otherwise spam the log.
|
|
79
|
+
lifecycleLog("wire_drift", {
|
|
80
|
+
kind,
|
|
81
|
+
detail: normalized,
|
|
82
|
+
stranding: STRANDING_KINDS.has(kind as DriftKind),
|
|
83
|
+
});
|
|
84
|
+
setLastDriftSignal(`${kind}:${normalized}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Records unknown protobuf fields on a decoded message. `$unknown` is populated
|
|
89
|
+
* by @bufbuild/protobuf whenever the wire carries fields our schema lacks, which
|
|
90
|
+
* is the earliest signal that `proto/agent.proto` is behind Cursor.
|
|
91
|
+
*/
|
|
92
|
+
export function recordUnknownFields(context: string, message: unknown): void {
|
|
93
|
+
const unknown = (message as { $unknown?: readonly { no: number }[] } | null)?.$unknown;
|
|
94
|
+
if (!unknown || unknown.length === 0) return;
|
|
95
|
+
const fields = [...new Set(unknown.map((f) => f.no))].sort((a, b) => a - b).join(",");
|
|
96
|
+
recordDriftSignal(DriftKind.UnknownFields, `${context}#${fields}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getDriftSignals(): DriftSignal[] {
|
|
100
|
+
return [...signals.values()].sort((a, b) => b.count - a.count);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True when something was seen that can actually strand a turn. */
|
|
104
|
+
export function hasStrandingDrift(): boolean {
|
|
105
|
+
return [...signals.values()].some((s) => STRANDING_KINDS.has(s.kind));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** One-line summary for `/cursor.doctor` and error messages; empty when clean. */
|
|
109
|
+
export function formatDriftSummary(limit = 4): string {
|
|
110
|
+
const all = getDriftSignals();
|
|
111
|
+
if (all.length === 0) return "";
|
|
112
|
+
const shown = all
|
|
113
|
+
.slice(0, limit)
|
|
114
|
+
.map((s) => `${s.kind}:${s.detail}${s.count > 1 ? `x${s.count}` : ""}`)
|
|
115
|
+
.join(", ");
|
|
116
|
+
const rest = all.length > limit ? ` (+${all.length - limit} more)` : "";
|
|
117
|
+
return `${shown}${rest}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resetDriftSignalsForTests(): void {
|
|
121
|
+
signals.clear();
|
|
122
|
+
}
|