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,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation state that outlives a single bridge: checkpoints, blob stores,
|
|
3
|
+
* mid-pause snapshots, and the keys everything is filed under.
|
|
4
|
+
*
|
|
5
|
+
* A Cursor turn can lose its bridge while parked waiting for tool results. What
|
|
6
|
+
* survives that is stored here — an upstream checkpoint when we have one, plus
|
|
7
|
+
* enough history fingerprinting for ./recovery.ts to decide between resuming
|
|
8
|
+
* from the checkpoint and rebuilding the whole conversation.
|
|
9
|
+
*
|
|
10
|
+
* Entries are keyed by Pi session id when available, falling back to a hash of
|
|
11
|
+
* the message history, and are evicted on a TTL so an abandoned session cannot
|
|
12
|
+
* pin its blob store forever.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
15
|
+
|
|
16
|
+
import { fromBinary } from "@bufbuild/protobuf";
|
|
17
|
+
|
|
18
|
+
import { ConversationStateStructureSchema } from "../proto/agent_pb.js";
|
|
19
|
+
import {
|
|
20
|
+
activeBridges,
|
|
21
|
+
cleanupBridge,
|
|
22
|
+
destroyAllIdleBridges,
|
|
23
|
+
destroyIdleBridge,
|
|
24
|
+
} from "./bridge-session.js";
|
|
25
|
+
import { debugLog } from "./debug-log.js";
|
|
26
|
+
import { textContent } from "./message-parsing.js";
|
|
27
|
+
import {
|
|
28
|
+
clearStoredMidPauseMetadata as clearStoredMidPauseMetadataImpl,
|
|
29
|
+
fingerprintCompletedTurns as fingerprintCompletedTurnsImpl,
|
|
30
|
+
} from "./recovery.js";
|
|
31
|
+
import {
|
|
32
|
+
deleteConversationJournal,
|
|
33
|
+
evictStaleJournals,
|
|
34
|
+
readConversationJournal,
|
|
35
|
+
writeConversationJournal,
|
|
36
|
+
} from "./run-journal.js";
|
|
37
|
+
import {
|
|
38
|
+
CONVERSATION_TTL_MS,
|
|
39
|
+
MAX_ACTIVE_BLOB_ENTRIES,
|
|
40
|
+
MAX_CHECKPOINT_BYTES,
|
|
41
|
+
MAX_CONVERSATION_BLOB_BYTES,
|
|
42
|
+
} from "./tuning.js";
|
|
43
|
+
import type {
|
|
44
|
+
ChatCompletionRequest,
|
|
45
|
+
OpenAIMessage,
|
|
46
|
+
ParsedTurn,
|
|
47
|
+
StoredConversation,
|
|
48
|
+
} from "./types.js";
|
|
49
|
+
|
|
50
|
+
export const conversationStates = new Map<string, StoredConversation>();
|
|
51
|
+
|
|
52
|
+
const sessionLocks = new Map<string, Promise<void>>();
|
|
53
|
+
|
|
54
|
+
function persistJournal(convKey: string, stored: StoredConversation): void {
|
|
55
|
+
writeConversationJournal(convKey, stored);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolve conversation state: memory first, then durable journal.
|
|
60
|
+
* Hydrates the in-memory map on a journal hit so later turns share one object.
|
|
61
|
+
*/
|
|
62
|
+
export function getOrHydrateConversation(convKey: string): StoredConversation | undefined {
|
|
63
|
+
const memory = conversationStates.get(convKey);
|
|
64
|
+
if (memory) return memory;
|
|
65
|
+
const fromDisk = readConversationJournal(convKey);
|
|
66
|
+
if (!fromDisk) return undefined;
|
|
67
|
+
conversationStates.set(convKey, fromDisk);
|
|
68
|
+
return fromDisk;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function cleanupAllSessionState(): void {
|
|
72
|
+
debugLog("session.cleanup_all", {
|
|
73
|
+
activeBridgeCount: activeBridges.size,
|
|
74
|
+
conversationCount: conversationStates.size,
|
|
75
|
+
});
|
|
76
|
+
for (const [bridgeKey, active] of activeBridges) {
|
|
77
|
+
cleanupBridge(active.bridge, active.heartbeatTimer, bridgeKey);
|
|
78
|
+
}
|
|
79
|
+
destroyAllIdleBridges();
|
|
80
|
+
conversationStates.clear();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function evictStaleConversations(now = Date.now()): void {
|
|
84
|
+
for (const [key, stored] of conversationStates) {
|
|
85
|
+
if (!stored.sessionScoped && now - stored.lastAccessMs > CONVERSATION_TTL_MS) {
|
|
86
|
+
debugLog("conversation.evict", { key, stored, now });
|
|
87
|
+
conversationStates.delete(key);
|
|
88
|
+
deleteConversationJournal(key);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
evictStaleJournals(now);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function fingerprintCompletedTurns(turns: ParsedTurn[]): string {
|
|
95
|
+
return fingerprintCompletedTurnsImpl(turns);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function clearStoredMidPauseMetadata(stored: StoredConversation): void {
|
|
99
|
+
clearStoredMidPauseMetadataImpl(stored);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function clearStoredCheckpoint(stored: StoredConversation, clearBlobStore = false): void {
|
|
103
|
+
stored.checkpoint = null;
|
|
104
|
+
delete stored.checkpointSource;
|
|
105
|
+
delete stored.checkpointTurnCount;
|
|
106
|
+
delete stored.checkpointHistoryFingerprint;
|
|
107
|
+
clearStoredMidPauseMetadata(stored);
|
|
108
|
+
if (clearBlobStore) stored.blobStore.clear();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A live KV blob miss means Cursor asked for history we no longer hold.
|
|
113
|
+
* Drop the checkpoint and rotate the conversation id so the next turn rebuilds
|
|
114
|
+
* from Pi's transcript instead of replaying holes.
|
|
115
|
+
*/
|
|
116
|
+
export function markBlobMiss(convKey: string): void {
|
|
117
|
+
const stored = conversationStates.get(convKey);
|
|
118
|
+
if (!stored) return;
|
|
119
|
+
debugLog("conversation.blob_miss_invalidate", {
|
|
120
|
+
convKey,
|
|
121
|
+
hadCheckpoint: !!stored.checkpoint,
|
|
122
|
+
});
|
|
123
|
+
clearStoredCheckpoint(stored, false);
|
|
124
|
+
stored.conversationId = randomUUID();
|
|
125
|
+
persistJournal(convKey, stored);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Checkpoint bytes are replayed straight into `fromBinary` when a request is built. A truncated or
|
|
130
|
+
* otherwise undecodable checkpoint would throw there and fail the turn — and, because nothing
|
|
131
|
+
* clears it, every later turn in the conversation too. Decoding once here turns that permanent
|
|
132
|
+
* break into a one-time discard and a rebuild.
|
|
133
|
+
*/
|
|
134
|
+
function checkpointDecodes(checkpoint: Uint8Array): boolean {
|
|
135
|
+
try {
|
|
136
|
+
fromBinary(ConversationStateStructureSchema, checkpoint);
|
|
137
|
+
return true;
|
|
138
|
+
} catch {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function discardStaleCheckpointIfNeeded(
|
|
144
|
+
stored: StoredConversation,
|
|
145
|
+
turns: ParsedTurn[],
|
|
146
|
+
requestId: string,
|
|
147
|
+
convKey: string,
|
|
148
|
+
): void {
|
|
149
|
+
// Tier 2 extends staleness validation to metadata-only mid-pause snapshots.
|
|
150
|
+
if (!stored.checkpoint) return;
|
|
151
|
+
|
|
152
|
+
const currentTurnCount = turns.length;
|
|
153
|
+
const currentHistoryFingerprint = fingerprintCompletedTurns(turns);
|
|
154
|
+
const storedCheckpointTurnCount = stored.checkpointTurnCount;
|
|
155
|
+
const storedCheckpointHistoryFingerprint = stored.checkpointHistoryFingerprint;
|
|
156
|
+
|
|
157
|
+
// A checkpoint recorded at the end of a turn stores turnCount = completedTurns.length + 1
|
|
158
|
+
// (it includes the just-finished turn in its history). When a tool-result recovery request
|
|
159
|
+
// arrives for that same turn, turns.length is still the pre-tool count. Allow the off-by-one
|
|
160
|
+
// when mid-pause metadata confirms we are in a tool continuation so the checkpoint survives
|
|
161
|
+
// long enough for planRecovery to use it.
|
|
162
|
+
const hasMidPauseForThisTurn =
|
|
163
|
+
stored.midPauseTurnCount === currentTurnCount &&
|
|
164
|
+
!!stored.midPausePendingToolCalls?.length &&
|
|
165
|
+
!!stored.midPauseHistoryFingerprint &&
|
|
166
|
+
stored.midPauseHistoryFingerprint === currentHistoryFingerprint;
|
|
167
|
+
const checkpointIsForNextTurnCount = storedCheckpointTurnCount === currentTurnCount + 1;
|
|
168
|
+
const skipTurnCountCheck = hasMidPauseForThisTurn && checkpointIsForNextTurnCount;
|
|
169
|
+
|
|
170
|
+
const reason =
|
|
171
|
+
stored.checkpoint.byteLength > MAX_CHECKPOINT_BYTES
|
|
172
|
+
? "checkpoint_oversized"
|
|
173
|
+
: !checkpointDecodes(stored.checkpoint)
|
|
174
|
+
? "checkpoint_undecodable"
|
|
175
|
+
: storedCheckpointTurnCount === undefined || !storedCheckpointHistoryFingerprint
|
|
176
|
+
? "missing_checkpoint_metadata"
|
|
177
|
+
: !skipTurnCountCheck && storedCheckpointTurnCount !== currentTurnCount
|
|
178
|
+
? "completed_turn_count_mismatch"
|
|
179
|
+
: !skipTurnCountCheck &&
|
|
180
|
+
storedCheckpointHistoryFingerprint !== currentHistoryFingerprint
|
|
181
|
+
? "completed_history_fingerprint_mismatch"
|
|
182
|
+
: undefined;
|
|
183
|
+
|
|
184
|
+
if (!reason) return;
|
|
185
|
+
|
|
186
|
+
debugLog("chat.discard_checkpoint", {
|
|
187
|
+
requestId,
|
|
188
|
+
convKey,
|
|
189
|
+
reason,
|
|
190
|
+
checkpointBytes: stored.checkpoint.byteLength,
|
|
191
|
+
storedCheckpointTurnCount,
|
|
192
|
+
currentTurnCount,
|
|
193
|
+
hasMidPauseForThisTurn,
|
|
194
|
+
storedCheckpointHistoryFingerprint,
|
|
195
|
+
currentHistoryFingerprint,
|
|
196
|
+
});
|
|
197
|
+
clearStoredCheckpoint(stored, true);
|
|
198
|
+
|
|
199
|
+
// Compaction and other history rewrites make the Cursor conversation identity
|
|
200
|
+
// stale. Start a fresh conversation so we rebuild from Pi's (summarized)
|
|
201
|
+
// transcript instead of replaying a checkpoint with holes.
|
|
202
|
+
const historyRewritten =
|
|
203
|
+
reason === "completed_turn_count_mismatch" ||
|
|
204
|
+
reason === "completed_history_fingerprint_mismatch";
|
|
205
|
+
if (historyRewritten) {
|
|
206
|
+
stored.conversationId = randomUUID();
|
|
207
|
+
debugLog("conversation.rotated", {
|
|
208
|
+
requestId,
|
|
209
|
+
convKey,
|
|
210
|
+
reason,
|
|
211
|
+
conversationId: stored.conversationId,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
persistJournal(convKey, stored);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function trimBlobStore(
|
|
218
|
+
store: Map<string, Uint8Array>,
|
|
219
|
+
maxBytes = MAX_CONVERSATION_BLOB_BYTES,
|
|
220
|
+
maxEntries = Number.POSITIVE_INFINITY,
|
|
221
|
+
): { removed: number; totalBytes: number } {
|
|
222
|
+
let totalBytes = 0;
|
|
223
|
+
for (const value of store.values()) totalBytes += value.byteLength;
|
|
224
|
+
const withinBudget = () => totalBytes <= maxBytes && store.size <= maxEntries;
|
|
225
|
+
if (withinBudget()) return { removed: 0, totalBytes };
|
|
226
|
+
|
|
227
|
+
let removed = 0;
|
|
228
|
+
// Map iteration order is insertion order — drop oldest blobs first.
|
|
229
|
+
for (const key of store.keys()) {
|
|
230
|
+
if (withinBudget()) break;
|
|
231
|
+
const value = store.get(key);
|
|
232
|
+
if (!value) continue;
|
|
233
|
+
totalBytes -= value.byteLength;
|
|
234
|
+
store.delete(key);
|
|
235
|
+
removed += 1;
|
|
236
|
+
}
|
|
237
|
+
return { removed, totalBytes };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function mergeBlobStore(
|
|
241
|
+
stored: StoredConversation,
|
|
242
|
+
blobStore: Map<string, Uint8Array>,
|
|
243
|
+
): void {
|
|
244
|
+
// Delete-then-set moves each still-referenced blob to the end of the insertion order, which is
|
|
245
|
+
// what `trimBlobStore` treats as newest. Plain `set` leaves an existing key in place, so the
|
|
246
|
+
// system-prompt blob — written first on every build, and pointed at by `rootPromptMessagesJson`
|
|
247
|
+
// in every checkpoint — stayed permanently oldest and would be the first thing evicted.
|
|
248
|
+
for (const [k, v] of blobStore) {
|
|
249
|
+
stored.blobStore.delete(k);
|
|
250
|
+
stored.blobStore.set(k, v);
|
|
251
|
+
}
|
|
252
|
+
const trimmed = trimBlobStore(
|
|
253
|
+
stored.blobStore,
|
|
254
|
+
MAX_CONVERSATION_BLOB_BYTES,
|
|
255
|
+
MAX_ACTIVE_BLOB_ENTRIES,
|
|
256
|
+
);
|
|
257
|
+
if (trimmed.removed > 0) {
|
|
258
|
+
debugLog("conversation.blob_store_trimmed", {
|
|
259
|
+
removed: trimmed.removed,
|
|
260
|
+
totalBytes: trimmed.totalBytes,
|
|
261
|
+
entries: stored.blobStore.size,
|
|
262
|
+
maxBytes: MAX_CONVERSATION_BLOB_BYTES,
|
|
263
|
+
maxEntries: MAX_ACTIVE_BLOB_ENTRIES,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
stored.lastAccessMs = Date.now();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function commitStoredCheckpoint(
|
|
270
|
+
stored: StoredConversation,
|
|
271
|
+
checkpointBytes: Uint8Array,
|
|
272
|
+
blobStore: Map<string, Uint8Array>,
|
|
273
|
+
completedTurns: ParsedTurn[],
|
|
274
|
+
currentTurn: ParsedTurn,
|
|
275
|
+
convKey?: string,
|
|
276
|
+
): void {
|
|
277
|
+
const completedHistory = [...completedTurns, currentTurn];
|
|
278
|
+
mergeBlobStore(stored, blobStore);
|
|
279
|
+
stored.checkpoint = checkpointBytes;
|
|
280
|
+
stored.checkpointSource = "upstream";
|
|
281
|
+
stored.checkpointTurnCount = completedHistory.length;
|
|
282
|
+
stored.checkpointHistoryFingerprint = fingerprintCompletedTurns(completedHistory);
|
|
283
|
+
clearStoredMidPauseMetadata(stored);
|
|
284
|
+
stored.lastAccessMs = Date.now();
|
|
285
|
+
if (convKey) persistJournal(convKey, stored);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function persistAbortedConversationState(
|
|
289
|
+
convKey: string,
|
|
290
|
+
latestCheckpoint: Uint8Array | null,
|
|
291
|
+
blobStore: Map<string, Uint8Array>,
|
|
292
|
+
completedTurns: ParsedTurn[],
|
|
293
|
+
currentTurn: ParsedTurn,
|
|
294
|
+
pendingToolCalls: Array<{ toolCallId: string; toolName: string }> = [],
|
|
295
|
+
): void {
|
|
296
|
+
const stored = conversationStates.get(convKey);
|
|
297
|
+
if (!stored) return;
|
|
298
|
+
|
|
299
|
+
// An interrupted tool pause must retain its matching snapshot. Treating its
|
|
300
|
+
// checkpoint as a completed turn clears that metadata, making the next tool
|
|
301
|
+
// result reject a valid off-by-one checkpoint as stale.
|
|
302
|
+
if (pendingToolCalls.length > 0) {
|
|
303
|
+
commitStoredCheckpointMidPause(
|
|
304
|
+
stored,
|
|
305
|
+
latestCheckpoint,
|
|
306
|
+
blobStore,
|
|
307
|
+
completedTurns,
|
|
308
|
+
pendingToolCalls,
|
|
309
|
+
convKey,
|
|
310
|
+
);
|
|
311
|
+
debugLog("native.stream.abort_state_saved", {
|
|
312
|
+
convKey,
|
|
313
|
+
hasCheckpoint: !!latestCheckpoint,
|
|
314
|
+
completedTurnCount: completedTurns.length,
|
|
315
|
+
pendingToolCallIds: pendingToolCalls.map((call) => call.toolCallId),
|
|
316
|
+
currentTurn,
|
|
317
|
+
});
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// The stream did not finish this turn. `commitStoredCheckpoint` would count
|
|
322
|
+
// `currentTurn` as completed (turnCount+1) and drop mid-pause metadata, which
|
|
323
|
+
// makes the next tool-continuation / idle-retry treat a still-valid checkpoint
|
|
324
|
+
// as stale. Keep the checkpoint keyed to the completed history only.
|
|
325
|
+
if (latestCheckpoint) {
|
|
326
|
+
mergeBlobStore(stored, blobStore);
|
|
327
|
+
stored.checkpoint = latestCheckpoint;
|
|
328
|
+
stored.checkpointSource = "upstream";
|
|
329
|
+
stored.checkpointTurnCount = completedTurns.length;
|
|
330
|
+
stored.checkpointHistoryFingerprint = fingerprintCompletedTurns(completedTurns);
|
|
331
|
+
stored.lastAccessMs = Date.now();
|
|
332
|
+
persistJournal(convKey, stored);
|
|
333
|
+
} else {
|
|
334
|
+
// Blob ids referenced by the retained Pi history must outlive the cancelled
|
|
335
|
+
// bridge even when Cursor has not emitted a checkpoint yet.
|
|
336
|
+
mergeBlobStore(stored, blobStore);
|
|
337
|
+
stored.lastAccessMs = Date.now();
|
|
338
|
+
persistJournal(convKey, stored);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
debugLog("native.stream.abort_state_saved", {
|
|
342
|
+
convKey,
|
|
343
|
+
hasCheckpoint: !!latestCheckpoint,
|
|
344
|
+
completedTurnCount: completedTurns.length,
|
|
345
|
+
currentTurn,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function commitStoredCheckpointMidPause(
|
|
350
|
+
stored: StoredConversation,
|
|
351
|
+
checkpointBytes: Uint8Array | null,
|
|
352
|
+
blobStore: Map<string, Uint8Array>,
|
|
353
|
+
completedTurns: ParsedTurn[],
|
|
354
|
+
pendingToolCalls: Array<{ toolCallId: string; toolName: string }>,
|
|
355
|
+
convKey?: string,
|
|
356
|
+
): void {
|
|
357
|
+
mergeBlobStore(stored, blobStore);
|
|
358
|
+
const completedHistoryFingerprint = fingerprintCompletedTurns(completedTurns);
|
|
359
|
+
if (checkpointBytes) {
|
|
360
|
+
stored.checkpoint = checkpointBytes;
|
|
361
|
+
stored.checkpointSource = "upstream";
|
|
362
|
+
stored.checkpointTurnCount = completedTurns.length;
|
|
363
|
+
stored.checkpointHistoryFingerprint = completedHistoryFingerprint;
|
|
364
|
+
} else {
|
|
365
|
+
// Metadata-only snapshots intentionally discard any older upstream checkpoint so later
|
|
366
|
+
// recovery code cannot accidentally treat stale bytes as authoritative for this pause.
|
|
367
|
+
stored.checkpoint = null;
|
|
368
|
+
stored.checkpointSource = "absent";
|
|
369
|
+
delete stored.checkpointTurnCount;
|
|
370
|
+
delete stored.checkpointHistoryFingerprint;
|
|
371
|
+
}
|
|
372
|
+
stored.midPausePendingToolCalls = pendingToolCalls.map((c) => ({
|
|
373
|
+
toolCallId: c.toolCallId,
|
|
374
|
+
toolName: c.toolName,
|
|
375
|
+
}));
|
|
376
|
+
stored.midPauseTurnCount = completedTurns.length;
|
|
377
|
+
stored.midPauseHistoryFingerprint = completedHistoryFingerprint;
|
|
378
|
+
stored.midPauseRecordedAtMs = Date.now();
|
|
379
|
+
stored.lastAccessMs = Date.now();
|
|
380
|
+
if (convKey) persistJournal(convKey, stored);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export interface HandleBridgeCloseMidPauseInput {
|
|
384
|
+
stored: StoredConversation | undefined;
|
|
385
|
+
latestCheckpoint: Uint8Array | null;
|
|
386
|
+
blobStore: Map<string, Uint8Array>;
|
|
387
|
+
completedTurns: ParsedTurn[];
|
|
388
|
+
pendingExecs: Array<{ toolCallId: string; toolName: string }>;
|
|
389
|
+
convKey?: string;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export function handleBridgeCloseMidPause(input: HandleBridgeCloseMidPauseInput): {
|
|
393
|
+
committed: boolean;
|
|
394
|
+
} {
|
|
395
|
+
if (!input.stored) return { committed: false };
|
|
396
|
+
commitStoredCheckpointMidPause(
|
|
397
|
+
input.stored,
|
|
398
|
+
input.latestCheckpoint,
|
|
399
|
+
input.blobStore,
|
|
400
|
+
input.completedTurns,
|
|
401
|
+
input.pendingExecs,
|
|
402
|
+
input.convKey,
|
|
403
|
+
);
|
|
404
|
+
return { committed: true };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function deriveRequestLockKey(body: ChatCompletionRequest): string {
|
|
408
|
+
const sessionId = derivePiSessionId(body);
|
|
409
|
+
if (sessionId) return `session:${sessionId}`;
|
|
410
|
+
return `anonymous:${deriveConversationKey(body.messages)}`;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export async function withSessionLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
|
414
|
+
const previous = sessionLocks.get(key) ?? Promise.resolve();
|
|
415
|
+
let release!: () => void;
|
|
416
|
+
const current = new Promise<void>((resolve) => {
|
|
417
|
+
release = resolve;
|
|
418
|
+
});
|
|
419
|
+
const chained = previous.catch(() => {}).then(() => current);
|
|
420
|
+
sessionLocks.set(key, chained);
|
|
421
|
+
await previous.catch(() => {});
|
|
422
|
+
try {
|
|
423
|
+
return await fn();
|
|
424
|
+
} finally {
|
|
425
|
+
release();
|
|
426
|
+
if (sessionLocks.get(key) === chained) sessionLocks.delete(key);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export function derivePiSessionId(
|
|
431
|
+
body: Pick<ChatCompletionRequest, "pi_session_id" | "user">,
|
|
432
|
+
): string | undefined {
|
|
433
|
+
const raw = body.pi_session_id ?? body.user;
|
|
434
|
+
if (typeof raw !== "string") return undefined;
|
|
435
|
+
const trimmed = raw.trim();
|
|
436
|
+
return trimmed ? trimmed : undefined;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function deriveBridgeKeyFromSessionId(sessionId: string): string {
|
|
440
|
+
return createHash("sha256").update(`bridge:${sessionId}`).digest("hex").slice(0, 16);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function deriveConversationKeyFromSessionId(sessionId: string): string {
|
|
444
|
+
return createHash("sha256").update(`conv:${sessionId}`).digest("hex").slice(0, 16);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function deriveBridgeKey(messages: OpenAIMessage[], sessionId?: string): string {
|
|
448
|
+
if (sessionId) return deriveBridgeKeyFromSessionId(sessionId);
|
|
449
|
+
const firstSystemMsg = messages.find((m) => m.role === "system");
|
|
450
|
+
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
451
|
+
const firstSystemText = firstSystemMsg ? textContent(firstSystemMsg.content) : "";
|
|
452
|
+
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
453
|
+
return createHash("sha256")
|
|
454
|
+
.update(`bridge:${firstSystemText}\0${firstUserText}`)
|
|
455
|
+
.digest("hex")
|
|
456
|
+
.slice(0, 16);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function deriveConversationKey(messages: OpenAIMessage[], sessionId?: string): string {
|
|
460
|
+
if (sessionId) return deriveConversationKeyFromSessionId(sessionId);
|
|
461
|
+
const firstSystemMsg = messages.find((m) => m.role === "system");
|
|
462
|
+
const firstUserMsg = messages.find((m) => m.role === "user");
|
|
463
|
+
const firstSystemText = firstSystemMsg ? textContent(firstSystemMsg.content) : "";
|
|
464
|
+
const firstUserText = firstUserMsg ? textContent(firstUserMsg.content) : "";
|
|
465
|
+
return createHash("sha256")
|
|
466
|
+
.update(`conv:${firstSystemText}\0${firstUserText}`)
|
|
467
|
+
.digest("hex")
|
|
468
|
+
.slice(0, 16);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export function cleanupSessionState(sessionId?: string): void {
|
|
472
|
+
if (!sessionId) return;
|
|
473
|
+
const bridgeKey = deriveBridgeKeyFromSessionId(sessionId);
|
|
474
|
+
const convKey = deriveConversationKeyFromSessionId(sessionId);
|
|
475
|
+
const active = activeBridges.get(bridgeKey);
|
|
476
|
+
debugLog("session.cleanup", {
|
|
477
|
+
sessionId,
|
|
478
|
+
bridgeKey,
|
|
479
|
+
convKey,
|
|
480
|
+
hasActiveBridge: !!active,
|
|
481
|
+
hadConversation: conversationStates.has(convKey),
|
|
482
|
+
});
|
|
483
|
+
if (active) cleanupBridge(active.bridge, active.heartbeatTimer, bridgeKey);
|
|
484
|
+
destroyIdleBridge(bridgeKey);
|
|
485
|
+
// Drop the in-memory copy so a long-lived process cannot pin a large blob store
|
|
486
|
+
// after the user switched away. The journal stays so /resume can hydrate.
|
|
487
|
+
conversationStates.delete(convKey);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function deterministicConversationId(convKey: string): string {
|
|
491
|
+
const hex = createHash("sha256").update(`cursor-conv-id:${convKey}`).digest("hex").slice(0, 32);
|
|
492
|
+
return [
|
|
493
|
+
hex.slice(0, 8),
|
|
494
|
+
hex.slice(8, 12),
|
|
495
|
+
`4${hex.slice(13, 16)}`,
|
|
496
|
+
`${(0x8 | (parseInt(hex[16], 16) & 0x3)).toString(16)}${hex.slice(17, 20)}`,
|
|
497
|
+
hex.slice(20, 32),
|
|
498
|
+
].join("-");
|
|
499
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stream writer adapter converting internal events to Pi AssistantMessageEventStream.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Api, AssistantMessageEventStream, Context, Model } from "@earendil-works/pi-ai";
|
|
6
|
+
import type {
|
|
7
|
+
CursorRunUsage,
|
|
8
|
+
NativeBlockKind,
|
|
9
|
+
NativeStreamWriter,
|
|
10
|
+
PendingExec,
|
|
11
|
+
StreamState,
|
|
12
|
+
} from "./types.js";
|
|
13
|
+
import { applyCursorUsage, createCursorAssistantMessage } from "./pi-adapter.js";
|
|
14
|
+
import { parseToolCallArguments } from "./message-parsing.js";
|
|
15
|
+
import { createCursorContextTracker, type CursorAssistantMessage } from "./context-usage.js";
|
|
16
|
+
import { lifecycleLog, reportCursorAnomaly } from "./debug-log.js";
|
|
17
|
+
|
|
18
|
+
export function createNativeStreamWriter(
|
|
19
|
+
stream: AssistantMessageEventStream,
|
|
20
|
+
model: Model<Api>,
|
|
21
|
+
context?: Context,
|
|
22
|
+
options?: { sessionId?: string; reasoning?: string },
|
|
23
|
+
): NativeStreamWriter {
|
|
24
|
+
const output: CursorAssistantMessage = createCursorAssistantMessage(model);
|
|
25
|
+
const contextTracker = createCursorContextTracker(model, context, options);
|
|
26
|
+
const carriedReceipts = new Set<CursorRunUsage>();
|
|
27
|
+
let started = false;
|
|
28
|
+
let closed = false;
|
|
29
|
+
let active: { kind: NativeBlockKind; contentIndex: number; ended: boolean } | undefined;
|
|
30
|
+
|
|
31
|
+
const ensureStarted = (): void => {
|
|
32
|
+
if (started) return;
|
|
33
|
+
started = true;
|
|
34
|
+
stream.push({ type: "start", partial: output });
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const endActiveBlock = (): void => {
|
|
38
|
+
if (!active || active.ended) return;
|
|
39
|
+
const block = output.content[active.contentIndex];
|
|
40
|
+
if (active.kind === "text" && block?.type === "text") {
|
|
41
|
+
stream.push({
|
|
42
|
+
type: "text_end",
|
|
43
|
+
contentIndex: active.contentIndex,
|
|
44
|
+
content: block.text,
|
|
45
|
+
partial: output,
|
|
46
|
+
});
|
|
47
|
+
} else if (active.kind === "thinking" && block?.type === "thinking") {
|
|
48
|
+
stream.push({
|
|
49
|
+
type: "thinking_end",
|
|
50
|
+
contentIndex: active.contentIndex,
|
|
51
|
+
content: block.thinking,
|
|
52
|
+
partial: output,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
active.ended = true;
|
|
56
|
+
active = undefined;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const ensureBlock = (kind: NativeBlockKind): number => {
|
|
60
|
+
ensureStarted();
|
|
61
|
+
if (active?.kind === kind && !active.ended) return active.contentIndex;
|
|
62
|
+
endActiveBlock();
|
|
63
|
+
const contentIndex = output.content.length;
|
|
64
|
+
if (kind === "text") {
|
|
65
|
+
output.content.push({ type: "text", text: "" });
|
|
66
|
+
stream.push({ type: "text_start", contentIndex, partial: output });
|
|
67
|
+
} else {
|
|
68
|
+
output.content.push({ type: "thinking", thinking: "" });
|
|
69
|
+
stream.push({ type: "thinking_start", contentIndex, partial: output });
|
|
70
|
+
}
|
|
71
|
+
active = { kind, contentIndex, ended: false };
|
|
72
|
+
return contentIndex;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const finishUsage = (reason: string, state?: StreamState): void => {
|
|
76
|
+
const snapshot = contextTracker.finish(output);
|
|
77
|
+
const billing = applyCursorUsage(output, model, state, snapshot.tokens);
|
|
78
|
+
for (const receipt of carriedReceipts) {
|
|
79
|
+
const extra = createCursorAssistantMessage(model);
|
|
80
|
+
const info = applyCursorUsage(
|
|
81
|
+
extra,
|
|
82
|
+
model,
|
|
83
|
+
{
|
|
84
|
+
toolCallIndex: 0,
|
|
85
|
+
pendingExecs: [],
|
|
86
|
+
outputTokens: 0,
|
|
87
|
+
totalTokens: 0,
|
|
88
|
+
turnEnded: true,
|
|
89
|
+
runUsage: receipt,
|
|
90
|
+
},
|
|
91
|
+
snapshot.tokens,
|
|
92
|
+
);
|
|
93
|
+
if (info.status !== "reported" && info.status !== "partial") continue;
|
|
94
|
+
for (const key of ["input", "output", "cacheRead", "cacheWrite"] as const) {
|
|
95
|
+
output.usage[key] += extra.usage[key];
|
|
96
|
+
output.usage.cost[key] += extra.usage.cost[key];
|
|
97
|
+
}
|
|
98
|
+
output.usage.cost.total += extra.usage.cost.total;
|
|
99
|
+
billing.carriedReceipts = (billing.carriedReceipts ?? 0) + 1;
|
|
100
|
+
if (info.status === "partial") billing.status = "partial";
|
|
101
|
+
}
|
|
102
|
+
if (billing.status === "pending" && reason !== "toolUse") billing.status = "unavailable";
|
|
103
|
+
output.cursorUsage!.billing = billing;
|
|
104
|
+
if (billing.status === "partial" || billing.status === "unavailable") {
|
|
105
|
+
lifecycleLog("usage_incomplete", { modelId: model.id, reason, ...billing });
|
|
106
|
+
}
|
|
107
|
+
if (billing.status === "partial") {
|
|
108
|
+
reportCursorAnomaly(
|
|
109
|
+
"usage_incomplete",
|
|
110
|
+
"Cursor returned an incomplete billing split; displayed costs include only known buckets.",
|
|
111
|
+
{ modelId: model.id, ...billing },
|
|
112
|
+
{ level: "warning" },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
if (snapshot.source === "estimate" && reason !== "toolUse") {
|
|
116
|
+
lifecycleLog("usage_context_estimated", {
|
|
117
|
+
modelId: model.id,
|
|
118
|
+
reason,
|
|
119
|
+
contextTokens: snapshot.tokens,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
output,
|
|
126
|
+
contextSnapshot(tokens: number, checkpoint?: Uint8Array) {
|
|
127
|
+
if (!closed) contextTracker.observe(tokens, output, checkpoint);
|
|
128
|
+
},
|
|
129
|
+
contextMode(mode, tokens, checkpoint) {
|
|
130
|
+
if (!closed) contextTracker.begin(mode, tokens, checkpoint);
|
|
131
|
+
},
|
|
132
|
+
carryUsage(usage) {
|
|
133
|
+
if (!closed && usage.modelId === model.id) carriedReceipts.add(usage);
|
|
134
|
+
},
|
|
135
|
+
get closed() {
|
|
136
|
+
return closed;
|
|
137
|
+
},
|
|
138
|
+
start: ensureStarted,
|
|
139
|
+
text(delta: string) {
|
|
140
|
+
if (closed || !delta) return;
|
|
141
|
+
const contentIndex = ensureBlock("text");
|
|
142
|
+
const block = output.content[contentIndex];
|
|
143
|
+
if (block?.type !== "text") return;
|
|
144
|
+
block.text += delta;
|
|
145
|
+
stream.push({ type: "text_delta", contentIndex, delta, partial: output });
|
|
146
|
+
},
|
|
147
|
+
thinking(delta: string) {
|
|
148
|
+
if (closed || !delta) return;
|
|
149
|
+
const contentIndex = ensureBlock("thinking");
|
|
150
|
+
const block = output.content[contentIndex];
|
|
151
|
+
if (block?.type !== "thinking") return;
|
|
152
|
+
block.thinking += delta;
|
|
153
|
+
stream.push({ type: "thinking_delta", contentIndex, delta, partial: output });
|
|
154
|
+
},
|
|
155
|
+
toolCall(exec: PendingExec) {
|
|
156
|
+
if (closed) return;
|
|
157
|
+
ensureStarted();
|
|
158
|
+
endActiveBlock();
|
|
159
|
+
const contentIndex = output.content.length;
|
|
160
|
+
const parsedArguments = parseToolCallArguments(exec.decodedArgs);
|
|
161
|
+
const block = {
|
|
162
|
+
type: "toolCall" as const,
|
|
163
|
+
id: exec.toolCallId,
|
|
164
|
+
name: exec.toolName,
|
|
165
|
+
arguments: {},
|
|
166
|
+
};
|
|
167
|
+
output.content.push(block);
|
|
168
|
+
stream.push({ type: "toolcall_start", contentIndex, partial: output });
|
|
169
|
+
block.arguments = parsedArguments;
|
|
170
|
+
stream.push({
|
|
171
|
+
type: "toolcall_delta",
|
|
172
|
+
contentIndex,
|
|
173
|
+
delta: exec.decodedArgs,
|
|
174
|
+
partial: output,
|
|
175
|
+
});
|
|
176
|
+
stream.push({
|
|
177
|
+
type: "toolcall_end",
|
|
178
|
+
contentIndex,
|
|
179
|
+
toolCall: {
|
|
180
|
+
type: "toolCall",
|
|
181
|
+
id: exec.toolCallId,
|
|
182
|
+
name: exec.toolName,
|
|
183
|
+
arguments: parsedArguments,
|
|
184
|
+
},
|
|
185
|
+
partial: output,
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
done(reason: "stop" | "length" | "toolUse", state?: StreamState) {
|
|
189
|
+
if (closed) return;
|
|
190
|
+
ensureStarted();
|
|
191
|
+
endActiveBlock();
|
|
192
|
+
// Intermediate tool replies publish context, not an invented per-inference bill.
|
|
193
|
+
finishUsage(reason, state);
|
|
194
|
+
output.stopReason = reason;
|
|
195
|
+
stream.push({ type: "done", reason, message: output });
|
|
196
|
+
closed = true;
|
|
197
|
+
stream.end(output);
|
|
198
|
+
},
|
|
199
|
+
error(message: string, reason: "error" | "aborted", state?: StreamState) {
|
|
200
|
+
if (closed) return;
|
|
201
|
+
ensureStarted();
|
|
202
|
+
endActiveBlock();
|
|
203
|
+
finishUsage(reason, state);
|
|
204
|
+
output.stopReason = reason;
|
|
205
|
+
output.errorMessage = message;
|
|
206
|
+
stream.push({ type: "error", reason, error: output });
|
|
207
|
+
closed = true;
|
|
208
|
+
stream.end(output);
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|