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,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The active-bridge registry and the in-process HTTP/2 transport lifecycle that fills it.
|
|
3
|
+
*
|
|
4
|
+
* These two belong together: a transport parked mid-tool is only reachable through
|
|
5
|
+
* the registry, and every registry eviction path has to tear the transport down
|
|
6
|
+
* (cancel action + heartbeat timer) rather than just dropping the reference.
|
|
7
|
+
*
|
|
8
|
+
* Conversation/checkpoint state lives one layer up in ./session-state.ts, which
|
|
9
|
+
* imports this module — never the other way around.
|
|
10
|
+
*/
|
|
11
|
+
import { create, toBinary } from "@bufbuild/protobuf";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
AgentClientMessageSchema,
|
|
15
|
+
ClientHeartbeatSchema,
|
|
16
|
+
CancelActionSchema,
|
|
17
|
+
ConversationActionSchema,
|
|
18
|
+
} from "../proto/agent_pb.js";
|
|
19
|
+
import {
|
|
20
|
+
createBridge,
|
|
21
|
+
frameConnectMessage,
|
|
22
|
+
type BridgeFactory,
|
|
23
|
+
type BridgeHandle,
|
|
24
|
+
} from "../client/bridge.js";
|
|
25
|
+
import { getCursorAgentUrl } from "./config.js";
|
|
26
|
+
import { debugLog } from "./debug-log.js";
|
|
27
|
+
import {
|
|
28
|
+
ACTIVE_BRIDGE_TTL_MS,
|
|
29
|
+
resolveH2ConnectTimeoutMs,
|
|
30
|
+
resolveH2IdleTimeoutMs,
|
|
31
|
+
} from "./tuning.js";
|
|
32
|
+
import type { ActiveBridge } from "./types.js";
|
|
33
|
+
|
|
34
|
+
/** Test seam for the streaming HTTP/2 transport factory. */
|
|
35
|
+
export function getBridgeFactory(): BridgeFactory {
|
|
36
|
+
return bridgeFactory;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const activeBridges = new Map<string, ActiveBridge>();
|
|
40
|
+
|
|
41
|
+
const defaultBridgeFactory: BridgeFactory = (options) => createBridge(options, debugLog);
|
|
42
|
+
|
|
43
|
+
let bridgeFactory: BridgeFactory = defaultBridgeFactory;
|
|
44
|
+
|
|
45
|
+
export function setBridgeFactoryForTests(factory?: BridgeFactory): void {
|
|
46
|
+
bridgeFactory = factory ?? defaultBridgeFactory;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function clearActiveBridgeToolTimeout(active: ActiveBridge | undefined): void {
|
|
50
|
+
if (active?.toolTimeoutTimer) clearTimeout(active.toolTimeoutTimer);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function removeActiveBridge(bridgeKey: string): void {
|
|
54
|
+
clearActiveBridgeToolTimeout(activeBridges.get(bridgeKey));
|
|
55
|
+
activeBridges.delete(bridgeKey);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const idleBridges = new Map<
|
|
59
|
+
string,
|
|
60
|
+
{ bridge: BridgeHandle; idleTimer: ReturnType<typeof setTimeout> }
|
|
61
|
+
>();
|
|
62
|
+
|
|
63
|
+
function canReuseBridge(bridge: BridgeHandle): boolean {
|
|
64
|
+
return bridge.alive && bridge.reusable;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function destroyIdleBridge(bridgeKey: string): void {
|
|
68
|
+
const idle = idleBridges.get(bridgeKey);
|
|
69
|
+
if (!idle) return;
|
|
70
|
+
idleBridges.delete(bridgeKey);
|
|
71
|
+
clearTimeout(idle.idleTimer);
|
|
72
|
+
if (idle.bridge.alive) idle.bridge.end();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function destroyAllIdleBridges(): void {
|
|
76
|
+
for (const key of [...idleBridges.keys()]) destroyIdleBridge(key);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Keep a live HTTP/2 session around so the next user turn can skip reconnect + TLS. */
|
|
80
|
+
export function parkIdleBridge(bridgeKey: string, bridge: BridgeHandle): void {
|
|
81
|
+
// Drop the active-registry entry without closing the session — the leftover-turn
|
|
82
|
+
// path in native-core ends any transport still listed as active.
|
|
83
|
+
removeActiveBridge(bridgeKey);
|
|
84
|
+
destroyIdleBridge(bridgeKey);
|
|
85
|
+
if (!canReuseBridge(bridge)) {
|
|
86
|
+
if (bridge.alive) bridge.end();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
debugLog("bridge.park_idle", { bridgeKey });
|
|
90
|
+
const idleTimer = setTimeout(() => {
|
|
91
|
+
debugLog("bridge.idle_expired", { bridgeKey, ttlMs: ACTIVE_BRIDGE_TTL_MS });
|
|
92
|
+
destroyIdleBridge(bridgeKey);
|
|
93
|
+
}, ACTIVE_BRIDGE_TTL_MS);
|
|
94
|
+
idleTimer.unref?.();
|
|
95
|
+
idleBridges.set(bridgeKey, { bridge, idleTimer });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function takeIdleBridge(bridgeKey: string): BridgeHandle | undefined {
|
|
99
|
+
const idle = idleBridges.get(bridgeKey);
|
|
100
|
+
if (!idle) return undefined;
|
|
101
|
+
idleBridges.delete(bridgeKey);
|
|
102
|
+
clearTimeout(idle.idleTimer);
|
|
103
|
+
if (!canReuseBridge(idle.bridge)) {
|
|
104
|
+
if (idle.bridge.alive) idle.bridge.end();
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
return idle.bridge;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function armActiveBridgeTtl(
|
|
111
|
+
bridgeKey: string,
|
|
112
|
+
active: Omit<ActiveBridge, "toolTimeoutTimer">,
|
|
113
|
+
): ReturnType<typeof setTimeout> {
|
|
114
|
+
const toolTimeoutTimer = setTimeout(() => {
|
|
115
|
+
debugLog("bridge.active_ttl_expired", { bridgeKey, ttlMs: ACTIVE_BRIDGE_TTL_MS });
|
|
116
|
+
cleanupBridge(active.bridge, active.heartbeatTimer, bridgeKey);
|
|
117
|
+
}, ACTIVE_BRIDGE_TTL_MS);
|
|
118
|
+
toolTimeoutTimer.unref?.();
|
|
119
|
+
return toolTimeoutTimer;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function setActiveBridge(
|
|
123
|
+
bridgeKey: string,
|
|
124
|
+
active: Omit<ActiveBridge, "toolTimeoutTimer">,
|
|
125
|
+
): void {
|
|
126
|
+
clearActiveBridgeToolTimeout(activeBridges.get(bridgeKey));
|
|
127
|
+
const toolTimeoutTimer = armActiveBridgeTtl(bridgeKey, active);
|
|
128
|
+
activeBridges.set(bridgeKey, { ...active, toolTimeoutTimer });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Slide the parked-bridge TTL forward while the bridge is still useful. */
|
|
132
|
+
export function touchActiveBridge(bridgeKey: string): void {
|
|
133
|
+
const active = activeBridges.get(bridgeKey);
|
|
134
|
+
if (!active) return;
|
|
135
|
+
clearActiveBridgeToolTimeout(active);
|
|
136
|
+
active.toolTimeoutTimer = armActiveBridgeTtl(bridgeKey, active);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function makeHeartbeatBytes(): Uint8Array {
|
|
140
|
+
const heartbeat = create(AgentClientMessageSchema, {
|
|
141
|
+
message: { case: "clientHeartbeat", value: create(ClientHeartbeatSchema, {}) },
|
|
142
|
+
});
|
|
143
|
+
return frameConnectMessage(toBinary(AgentClientMessageSchema, heartbeat));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Cursor's MCP family carries Pi tool calls; native filesystem/shell/subagent tools stay hidden.
|
|
147
|
+
const PI_MCP_TOOL_TYPES = [
|
|
148
|
+
"mcp_tool_call",
|
|
149
|
+
"get_mcp_tools_tool_call",
|
|
150
|
+
"list_mcp_resources_tool_call",
|
|
151
|
+
"read_mcp_resource_tool_call",
|
|
152
|
+
"mcp_auth_tool_call",
|
|
153
|
+
] as const;
|
|
154
|
+
|
|
155
|
+
export function startBridge(
|
|
156
|
+
accessToken: string,
|
|
157
|
+
requestBytes: Uint8Array,
|
|
158
|
+
options: { bridgeKey?: string; hasMcpTools: boolean },
|
|
159
|
+
) {
|
|
160
|
+
const allowedTools = options.hasMcpTools ? PI_MCP_TOOL_TYPES : [];
|
|
161
|
+
const reused = options.bridgeKey ? takeIdleBridge(options.bridgeKey) : undefined;
|
|
162
|
+
let bridge;
|
|
163
|
+
if (reused) {
|
|
164
|
+
debugLog("bridge.reuse_idle", { bridgeKey: options?.bridgeKey });
|
|
165
|
+
reused.openStream!(accessToken, allowedTools);
|
|
166
|
+
reused.write(frameConnectMessage(requestBytes));
|
|
167
|
+
bridge = reused;
|
|
168
|
+
} else {
|
|
169
|
+
bridge = bridgeFactory({
|
|
170
|
+
accessToken,
|
|
171
|
+
rpcPath: "/agent.v1.AgentService/Run",
|
|
172
|
+
allowedTools,
|
|
173
|
+
url: getCursorAgentUrl(),
|
|
174
|
+
connectTimeoutMs: resolveH2ConnectTimeoutMs(process.env.PI_CURSOR_H2_CONNECT_TIMEOUT_MS),
|
|
175
|
+
idleTimeoutMs: resolveH2IdleTimeoutMs(process.env.PI_CURSOR_H2_IDLE_TIMEOUT_MS),
|
|
176
|
+
});
|
|
177
|
+
debugLog("bridge.start_run", { requestBytes });
|
|
178
|
+
bridge.write(frameConnectMessage(requestBytes));
|
|
179
|
+
}
|
|
180
|
+
// Keep heartbeats referenced so long tool pauses do not look idle to the transport.
|
|
181
|
+
// 15s interval: frequent enough to prevent mid-pause idle kills, low enough to
|
|
182
|
+
// avoid flooding a quiet stream with IPC chatter. Also slides the parked-bridge
|
|
183
|
+
// TTL so multi-round tool chains are not killed by the original park timestamp.
|
|
184
|
+
const heartbeatTimer = setInterval(() => {
|
|
185
|
+
bridge.write(makeHeartbeatBytes());
|
|
186
|
+
if (options?.bridgeKey) touchActiveBridge(options.bridgeKey);
|
|
187
|
+
}, 15_000);
|
|
188
|
+
return { bridge, heartbeatTimer };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function sendCancelAction(bridge: BridgeHandle): void {
|
|
192
|
+
debugLog("bridge.cancel_action", {});
|
|
193
|
+
const action = create(ConversationActionSchema, {
|
|
194
|
+
action: { case: "cancelAction", value: create(CancelActionSchema, {}) },
|
|
195
|
+
});
|
|
196
|
+
const clientMessage = create(AgentClientMessageSchema, {
|
|
197
|
+
message: { case: "conversationAction", value: action },
|
|
198
|
+
});
|
|
199
|
+
bridge.write(frameConnectMessage(toBinary(AgentClientMessageSchema, clientMessage)));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function cleanupBridge(
|
|
203
|
+
bridge: BridgeHandle,
|
|
204
|
+
heartbeatTimer: ReturnType<typeof setInterval>,
|
|
205
|
+
bridgeKey: string,
|
|
206
|
+
): void {
|
|
207
|
+
debugLog("bridge.cleanup", { bridgeKey, alive: bridge.alive });
|
|
208
|
+
clearInterval(heartbeatTimer);
|
|
209
|
+
clearActiveBridgeToolTimeout(activeBridges.get(bridgeKey));
|
|
210
|
+
if (bridge.alive) {
|
|
211
|
+
sendCancelAction(bridge);
|
|
212
|
+
bridge.end();
|
|
213
|
+
}
|
|
214
|
+
activeBridges.delete(bridgeKey);
|
|
215
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi's view of the turn in flight, as opposed to the wire history sent upstream.
|
|
3
|
+
*
|
|
4
|
+
* `live` means the wire current turn is Pi's own turn. `recovered` means recovery
|
|
5
|
+
* replaced the wire turn with a synthetic one, so Pi's turn is `inFlightTurn`
|
|
6
|
+
* extended by whatever the synthetic turn produces. Matching recovery against the
|
|
7
|
+
* bridge suffix after any earlier recovery is unrecoverable — the wire turn is
|
|
8
|
+
* only a suffix of Pi's turn, so exact tool-id matching could never succeed again.
|
|
9
|
+
*/
|
|
10
|
+
import type { ClientTranscript, ParsedTurn } from "./types.js";
|
|
11
|
+
|
|
12
|
+
export type { ClientTranscript } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export function liveTranscript(completedTurns: ParsedTurn[]): ClientTranscript {
|
|
15
|
+
return { kind: "live", completedTurns };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function recoveredTranscript(
|
|
19
|
+
completedTurns: ParsedTurn[],
|
|
20
|
+
inFlightTurn: ParsedTurn,
|
|
21
|
+
): ClientTranscript {
|
|
22
|
+
return { kind: "recovered", completedTurns, inFlightTurn };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The turn Pi will report as completed once this stream finishes.
|
|
27
|
+
* After recovery the wire current turn is synthetic; Pi's in-flight turn is the
|
|
28
|
+
* recorded one extended by whatever steps the synthetic turn produced.
|
|
29
|
+
*/
|
|
30
|
+
export function clientInFlightTurn(
|
|
31
|
+
transcript: ClientTranscript,
|
|
32
|
+
wireCurrentTurn: ParsedTurn,
|
|
33
|
+
): ParsedTurn {
|
|
34
|
+
if (transcript.kind === "live") return wireCurrentTurn;
|
|
35
|
+
const userImages = transcript.inFlightTurn.userImages ?? wireCurrentTurn.userImages;
|
|
36
|
+
const recovered: ParsedTurn = {
|
|
37
|
+
userText: transcript.inFlightTurn.userText,
|
|
38
|
+
steps: [...transcript.inFlightTurn.steps, ...wireCurrentTurn.steps],
|
|
39
|
+
};
|
|
40
|
+
if (userImages?.length) recovered.userImages = userImages;
|
|
41
|
+
return recovered;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Mark that recovery replaced the wire current turn with a synthetic one. */
|
|
45
|
+
export function withSyntheticCurrentTurn(
|
|
46
|
+
transcript: ClientTranscript,
|
|
47
|
+
preRecoveryCurrentTurn: ParsedTurn,
|
|
48
|
+
): ClientTranscript {
|
|
49
|
+
if (transcript.kind === "recovered") return transcript;
|
|
50
|
+
return recoveredTranscript(transcript.completedTurns, preRecoveryCurrentTurn);
|
|
51
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Normalize context-mode / session side-channel user messages into the system
|
|
3
|
+
* prompt so Cursor treats the real user turn as the task.
|
|
4
|
+
*
|
|
5
|
+
* Important: context-mode often appends injection text to the *same* user
|
|
6
|
+
* message as the real prompt ("hi\n\ncontext-mode active..."). Treating the
|
|
7
|
+
* whole message as side-channel swallows the real request. Mixed messages are
|
|
8
|
+
* split so only the infrastructure blocks move to the system prompt.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type OpenAIRole = "system" | "user" | "assistant" | "tool";
|
|
12
|
+
|
|
13
|
+
export interface OpenAIContentPart {
|
|
14
|
+
type: string;
|
|
15
|
+
text?: string;
|
|
16
|
+
data?: string;
|
|
17
|
+
mimeType?: string;
|
|
18
|
+
image_url?: { url?: string };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OpenAIMessage {
|
|
22
|
+
role: OpenAIRole;
|
|
23
|
+
content?: string | OpenAIContentPart[] | null;
|
|
24
|
+
tool_call_id?: string;
|
|
25
|
+
name?: string;
|
|
26
|
+
tool_calls?: unknown[];
|
|
27
|
+
/** Carried through untouched; see OpenAIMessage in ./types.ts. */
|
|
28
|
+
interrupted_notice?: string;
|
|
29
|
+
/** Replayed thinking from a prior assistant turn. */
|
|
30
|
+
thinking?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CONTEXT_MODE_SIDE_CHANNEL_PRIORITY =
|
|
34
|
+
"Provider infrastructure context only. The latest user message is the only task. " +
|
|
35
|
+
"Do not continue prior work, re-read files, or run compaction/session recovery " +
|
|
36
|
+
"unless the latest user message explicitly asks for that.";
|
|
37
|
+
|
|
38
|
+
const RESUME_CONTEXT_PRIORITY =
|
|
39
|
+
"This block is the recovered conversation context from a resumed or compacted session. " +
|
|
40
|
+
"Treat it as active memory of prior work and continue from it. " +
|
|
41
|
+
"The latest user message is the current instruction, not a new unrelated task.";
|
|
42
|
+
|
|
43
|
+
/** Markers that begin a side-channel block inside an otherwise normal user turn. */
|
|
44
|
+
const SIDE_CHANNEL_BLOCK_START =
|
|
45
|
+
/(?:^|\n)[ \t]*(?:context-mode active\b|\[context\]|\[pi-lens automated\b|<session_state\b|<session_resume\b|<active_memory\b|<compaction\b|<session_mode\b|Hierarchy:\s*ctx_batch_execute)/i;
|
|
46
|
+
|
|
47
|
+
export function textContent(content: OpenAIMessage["content"]): string {
|
|
48
|
+
if (content == null) return "";
|
|
49
|
+
if (typeof content === "string") return content;
|
|
50
|
+
return content
|
|
51
|
+
.filter((p) => p.type === "text" && p.text)
|
|
52
|
+
.map((p) => p.text as string)
|
|
53
|
+
.join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function contentHasImageParts(content: OpenAIMessage["content"]): boolean {
|
|
57
|
+
if (!Array.isArray(content)) return false;
|
|
58
|
+
return content.some(
|
|
59
|
+
(part) =>
|
|
60
|
+
part.type === "image_url" ||
|
|
61
|
+
part.type === "image" ||
|
|
62
|
+
(typeof part.mimeType === "string" && part.mimeType.startsWith("image/")),
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isContextModeSideChannelText(text: string): boolean {
|
|
67
|
+
const t = text.trim();
|
|
68
|
+
if (!t) return false;
|
|
69
|
+
return (
|
|
70
|
+
/^context-mode active\b/i.test(t) ||
|
|
71
|
+
/^\[context\]/i.test(t) ||
|
|
72
|
+
/(?:^|\n)[ \t]*\[pi-lens automated\b/i.test(t) ||
|
|
73
|
+
t.includes("<session_state") ||
|
|
74
|
+
t.includes("<session_resume") ||
|
|
75
|
+
t.includes("<active_memory>") ||
|
|
76
|
+
t.includes("<compaction") ||
|
|
77
|
+
t.includes("context_mode") ||
|
|
78
|
+
t.includes("Hierarchy: ctx_batch_execute") ||
|
|
79
|
+
/<\/?session_mode\b/i.test(t)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* True when the entire message is infrastructure-only (no residual user task).
|
|
85
|
+
* Mixed messages that *contain* side-channel markers still return false here if
|
|
86
|
+
* there is leading/trailing user text after splitting.
|
|
87
|
+
*/
|
|
88
|
+
export function isPureContextModeSideChannelText(text: string): boolean {
|
|
89
|
+
if (!isContextModeSideChannelText(text)) return false;
|
|
90
|
+
const { userText, sideText } = splitUserTextAndSideChannel(text);
|
|
91
|
+
return !userText.trim() && !!sideText.trim();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* True when a side-channel carries no useful session memory — only the standard
|
|
96
|
+
* context-mode hierarchy blurb and/or an empty/mode-only session_state.
|
|
97
|
+
* These add tokens without helping the model and should be dropped.
|
|
98
|
+
*/
|
|
99
|
+
export function isNoOpSideChannelText(text: string): boolean {
|
|
100
|
+
const t = text.trim();
|
|
101
|
+
if (!t) return true;
|
|
102
|
+
if (!isContextModeSideChannelText(t)) return false;
|
|
103
|
+
|
|
104
|
+
let rest = t;
|
|
105
|
+
// Strip standard hierarchy boilerplate lines.
|
|
106
|
+
rest = rest
|
|
107
|
+
.replace(/^context-mode active\b[^\n]*$/gim, "")
|
|
108
|
+
.replace(/^Read\/edit files[^\n]*$/gim, "")
|
|
109
|
+
.replace(/^Multi-command research[^\n]*$/gim, "")
|
|
110
|
+
.replace(/^Hierarchy:\s*ctx_batch_execute[^\n]*$/gim, "")
|
|
111
|
+
.replace(/\n{2,}/g, "\n")
|
|
112
|
+
.trim();
|
|
113
|
+
|
|
114
|
+
// Strip session_state blocks that only carry mode / empty shells.
|
|
115
|
+
rest = rest
|
|
116
|
+
.replace(/<session_state\b[^>]*>[\s\S]*?<\/session_state>/gi, (block) => {
|
|
117
|
+
if (sideChannelHasResumeMemory(block)) return block;
|
|
118
|
+
const inner = block
|
|
119
|
+
.replace(/<\/?session_state\b[^>]*>/gi, "")
|
|
120
|
+
.replace(/<session_mode\b[^>]*>[\s\S]*?<\/session_mode>/gi, "")
|
|
121
|
+
.replace(/<\/?(summary|turns|messages|memory)\b[^>]*>/gi, "")
|
|
122
|
+
.trim();
|
|
123
|
+
// Keep the block if it still has meaningful payload text.
|
|
124
|
+
return inner.length >= 40 ? block : "";
|
|
125
|
+
})
|
|
126
|
+
.replace(/\n{2,}/g, "\n")
|
|
127
|
+
.trim();
|
|
128
|
+
|
|
129
|
+
return rest.length < 24;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Split a user message that may concatenate real task text with context-mode /
|
|
134
|
+
* session_state injections.
|
|
135
|
+
*/
|
|
136
|
+
export function splitUserTextAndSideChannel(text: string): {
|
|
137
|
+
userText: string;
|
|
138
|
+
sideText: string;
|
|
139
|
+
} {
|
|
140
|
+
const raw = text ?? "";
|
|
141
|
+
if (!raw.trim()) return { userText: "", sideText: "" };
|
|
142
|
+
|
|
143
|
+
if (!isContextModeSideChannelText(raw)) {
|
|
144
|
+
return { userText: raw, sideText: "" };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const match = SIDE_CHANNEL_BLOCK_START.exec(raw);
|
|
148
|
+
if (!match || match.index === undefined) {
|
|
149
|
+
// Marker mentioned mid-prose (e.g. "what is a <session_state> block?") — keep
|
|
150
|
+
// the whole string as the user task rather than swallowing it.
|
|
151
|
+
return { userText: raw, sideText: "" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const start = match.index + (match[0].startsWith("\n") ? 1 : 0);
|
|
155
|
+
let userText = raw.slice(0, start).trim();
|
|
156
|
+
let sideText = raw.slice(start).trim();
|
|
157
|
+
|
|
158
|
+
// Side channel first, user task after the closing session_state block.
|
|
159
|
+
if (!userText && sideText) {
|
|
160
|
+
const closeIdx = sideText.toLowerCase().lastIndexOf("</session_state>");
|
|
161
|
+
if (closeIdx >= 0) {
|
|
162
|
+
const after = sideText.slice(closeIdx + "</session_state>".length).trim();
|
|
163
|
+
if (after && !isContextModeSideChannelText(after)) {
|
|
164
|
+
userText = after;
|
|
165
|
+
sideText = sideText.slice(0, closeIdx + "</session_state>".length).trim();
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// If the "user" residue is still only infrastructure, drop it.
|
|
171
|
+
if (userText && isPureSideResidue(userText)) {
|
|
172
|
+
sideText = [sideText, userText].filter(Boolean).join("\n\n");
|
|
173
|
+
userText = "";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// No recoverable user task and the message looks like a full injection dump.
|
|
177
|
+
if (!userText && sideText) {
|
|
178
|
+
return { userText: "", sideText };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { userText, sideText };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isPureSideResidue(text: string): boolean {
|
|
185
|
+
const t = text.trim();
|
|
186
|
+
if (!t) return true;
|
|
187
|
+
if (isContextModeSideChannelText(t) && t.length > 80) return true;
|
|
188
|
+
// Single-line tool hierarchy leftovers.
|
|
189
|
+
if (/^Hierarchy:\s*ctx_/i.test(t)) return true;
|
|
190
|
+
if (/^Read\/edit files/i.test(t)) return true;
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* True when a side-channel carries a compaction summary or session-resume memory
|
|
196
|
+
* that the model must treat as prior work, not as disposable infrastructure.
|
|
197
|
+
*/
|
|
198
|
+
export function isResumeOrCompactionSideChannel(text: string): boolean {
|
|
199
|
+
if (/<session_resume\b/i.test(text)) return true;
|
|
200
|
+
const summary = text.match(/<summary\b[^>]*>([\s\S]*?)<\/summary>/i)?.[1]?.trim() ?? "";
|
|
201
|
+
if (summary.length >= 8) return true;
|
|
202
|
+
const memory =
|
|
203
|
+
text.match(/<active_memory\b[^>]*>([\s\S]*?)<\/active_memory>/i)?.[1]?.trim() ?? "";
|
|
204
|
+
return memory.length >= 8;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function sideChannelHasResumeMemory(text: string): boolean {
|
|
208
|
+
return isResumeOrCompactionSideChannel(text);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** True when the system prompt holds folded session/compaction memory that a greeting must not drop. */
|
|
212
|
+
export function systemPromptHasSessionMemory(systemPrompt: string): boolean {
|
|
213
|
+
const t = systemPrompt ?? "";
|
|
214
|
+
if (!t) return false;
|
|
215
|
+
return (
|
|
216
|
+
/<provider_context\b/i.test(t) ||
|
|
217
|
+
/<session_state\b/i.test(t) ||
|
|
218
|
+
/<session_resume\b/i.test(t) ||
|
|
219
|
+
/<active_memory\b/i.test(t)
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function frameContextModeSideChannel(text: string): string {
|
|
224
|
+
const priority = isResumeOrCompactionSideChannel(text)
|
|
225
|
+
? RESUME_CONTEXT_PRIORITY
|
|
226
|
+
: CONTEXT_MODE_SIDE_CHANNEL_PRIORITY;
|
|
227
|
+
return (
|
|
228
|
+
`<provider_context source="context-mode">\n${text.trim()}\n</provider_context>\n\n` + priority
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function rebuildUserContent(
|
|
233
|
+
original: OpenAIMessage["content"],
|
|
234
|
+
userText: string,
|
|
235
|
+
): OpenAIMessage["content"] {
|
|
236
|
+
if (!Array.isArray(original)) return userText;
|
|
237
|
+
|
|
238
|
+
const imageParts = original.filter(
|
|
239
|
+
(part) =>
|
|
240
|
+
part.type === "image_url" ||
|
|
241
|
+
part.type === "image" ||
|
|
242
|
+
(typeof part.mimeType === "string" && part.mimeType.startsWith("image/")),
|
|
243
|
+
);
|
|
244
|
+
if (imageParts.length === 0) return userText;
|
|
245
|
+
return [{ type: "text", text: userText }, ...imageParts];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Fold pure side-channel user messages into the system prompt and keep the
|
|
250
|
+
* real user turns as the task. Mixed user messages are split in place.
|
|
251
|
+
*/
|
|
252
|
+
export function normalizeMessagesForCursor(messages: OpenAIMessage[]): OpenAIMessage[] {
|
|
253
|
+
const systemParts: string[] = [];
|
|
254
|
+
const sideParts: string[] = [];
|
|
255
|
+
const rest: OpenAIMessage[] = [];
|
|
256
|
+
|
|
257
|
+
for (const msg of messages) {
|
|
258
|
+
if (msg.role === "system") {
|
|
259
|
+
const text = textContent(msg.content);
|
|
260
|
+
if (text) systemParts.push(text);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (msg.role === "user") {
|
|
265
|
+
const text = textContent(msg.content);
|
|
266
|
+
if (!text.trim()) {
|
|
267
|
+
rest.push(msg);
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const { userText, sideText } = splitUserTextAndSideChannel(text);
|
|
272
|
+
|
|
273
|
+
if (sideText && !isNoOpSideChannelText(sideText)) sideParts.push(sideText);
|
|
274
|
+
|
|
275
|
+
if (!userText.trim()) {
|
|
276
|
+
// Pure side-channel (optionally keep images on a stub user turn).
|
|
277
|
+
if (contentHasImageParts(msg.content)) {
|
|
278
|
+
rest.push({
|
|
279
|
+
...msg,
|
|
280
|
+
content: rebuildUserContent(msg.content, ""),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (sideText) {
|
|
287
|
+
rest.push({
|
|
288
|
+
...msg,
|
|
289
|
+
content: rebuildUserContent(msg.content, userText),
|
|
290
|
+
});
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
rest.push(msg);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (sideParts.length === 0) {
|
|
299
|
+
if (systemParts.length === 0) return messages;
|
|
300
|
+
return [{ role: "system", content: systemParts.join("\n") }, ...rest];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const framed = frameContextModeSideChannel(sideParts.join("\n\n"));
|
|
304
|
+
// Put the real task framing *after* infrastructure context so models that
|
|
305
|
+
// overweight the end of the system prompt still see the priority rule last.
|
|
306
|
+
const system = systemParts.length > 0 ? `${systemParts.join("\n")}\n\n${framed}` : framed;
|
|
307
|
+
return [{ role: "system", content: system }, ...rest];
|
|
308
|
+
}
|