pi-cursor-sdk 0.3.6 → 0.3.8
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 +25 -0
- package/README.md +16 -5
- package/dist/context.js +19 -4
- package/dist/cursor-agents-context.js +22 -8
- package/dist/cursor-context-tools.js +3 -1
- package/dist/cursor-live-run-coordinator.js +9 -6
- package/dist/cursor-partial-content-emitter.js +12 -0
- package/dist/cursor-pi-context.js +30 -0
- package/dist/cursor-provider-live-run-drain.js +19 -6
- package/dist/cursor-provider-turn-coordinator.js +18 -0
- package/dist/cursor-provider-turn-emit.js +6 -0
- package/dist/cursor-provider-turn-prepare.js +10 -5
- package/dist/cursor-record-utils.js +19 -0
- package/dist/cursor-skill-tool.js +1 -1
- package/docs/cursor-dogfood-checklist.md +1 -1
- package/docs/cursor-live-smoke-checklist.md +1 -1
- package/docs/cursor-model-ux-spec.md +2 -0
- package/docs/cursor-testing-lessons.md +25 -1
- package/docs/platform-smoke-implementation.md +2 -0
- package/docs/platform-smoke.md +1 -1
- package/package.json +2 -2
- package/scripts/build.mjs +2 -1
- package/scripts/lib/local-resume-smoke-harness.mjs +1 -6
- package/scripts/local-resume-smoke.mjs +43 -4
- package/scripts/platform-smoke/card-detect.mjs +2 -4
- package/scripts/platform-smoke/live-suite-runner.mjs +5 -7
- package/scripts/platform-smoke/pty-capture.mjs +3 -10
- package/scripts/platform-smoke/render-ansi.mjs +2 -5
- package/src/context.ts +27 -4
- package/src/cursor-agents-context.ts +24 -8
- package/src/cursor-context-tools.ts +3 -1
- package/src/cursor-live-run-coordinator.ts +10 -6
- package/src/cursor-partial-content-emitter.ts +13 -0
- package/src/cursor-pi-context.ts +44 -0
- package/src/cursor-provider-live-run-drain.ts +16 -9
- package/src/cursor-provider-turn-coordinator.ts +15 -0
- package/src/cursor-provider-turn-emit.ts +6 -0
- package/src/cursor-provider-turn-prepare.ts +10 -5
- package/src/cursor-record-utils.ts +19 -0
- package/src/cursor-skill-tool.ts +1 -1
|
@@ -33,7 +33,7 @@ When changing provider/runtime behavior, ask whether the bug spans **pi extensio
|
|
|
33
33
|
Native replay routing intentionally uses two layers:
|
|
34
34
|
|
|
35
35
|
1. **Extension resync** (`before_agent_start`, `turn_start`) updates pi's active tool set via `syncRegisteredNativeCursorToolsForModel`. This fixes the common case where plan-mode execute strips `grep`/`find`/`cursor` before the next turn.
|
|
36
|
-
2. **Provider routing** uses the
|
|
36
|
+
2. **Provider routing** uses the **request tool snapshot** captured when `streamCursor()` starts (`getActiveContextToolNames` in `src/cursor-context-tools.ts`): legacy `context.tools` on stock Pi 0.84/0.85, or tools replayed by the host's public helpers on transcript-only Pi. An explicit empty snapshot is not an absent legacy snapshot. It does not read live `pi.getActiveTools()` mid-stream. The bridge intentionally retains its separate registry-owned surface.
|
|
37
37
|
|
|
38
38
|
`src/cursor-native-replay-routing.ts` centralizes provider-side routing against the same `context.tools` snapshot:
|
|
39
39
|
|
|
@@ -48,6 +48,28 @@ Disposition outcomes:
|
|
|
48
48
|
|
|
49
49
|
If resync runs but `context.tools` is still stale (e.g. only `read` listed), the provider must **not** emit `toolUse` for inactive tools. `test/cursor-native-replay-stress.test.ts` covers that stale-snapshot path.
|
|
50
50
|
|
|
51
|
+
## Stock/transcript provider contract
|
|
52
|
+
|
|
53
|
+
`test/cursor-provider-pi-context.test.ts` drives real `ModelRuntime` / `ModelRegistry` requests into `streamCursor`, with only Cursor SDK execution mocked. Run it against each supported host with all Pi peer imports pinned to that host (including nested native imports); a top-level package version alone is not resolution evidence. Cover stock 0.84.0, stock 0.85.1, and the transcript host. Transcript-only cases are not stock features and are skipped there.
|
|
54
|
+
|
|
55
|
+
The test covers bootstrap/incremental prompts, empty-vs-absent request tools, native replay/drain, cloud fresh/bootstrap selection, and actual host prompt serialization for context files and skills. It is offline contract evidence, not a replacement for the required live platform/cloud release gates.
|
|
56
|
+
|
|
57
|
+
### Native package identity regression
|
|
58
|
+
|
|
59
|
+
Pi's manifest loads the shipped `src/index.ts` graph, while scripts and programmatic consumers retain `dist/`. Native ESM imports of compiled JS can bypass Pi's host-peer aliases when another Pi version is installed beside the extension. A TypeScript wrapper around compiled JS does not fix transitive ownership. Do not remove local peers or use an import hook to turn this regression green.
|
|
60
|
+
|
|
61
|
+
`test/native-package-host.test.ts` packs and normally installs the package with both 0.84.0 and 0.85.1 peers, then exercises the actual manifest via the installed development host's SDK and CLI (including the bundled CLI when shipped by that host; Pi 0.84 ships only the modular CLI). It also checks a minimal native managed install. For each additional pinned host, run the same offline probe against the installed tarball (not this checkout):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
node scripts/check-pi-host-runtime.mjs /path/to/pi-coding-agent /path/to/installed/pi-cursor-sdk --out=/new/evidence/sdk
|
|
65
|
+
node scripts/check-pi-host-runtime.mjs /path/to/pi-coding-agent /path/to/installed/pi-cursor-sdk --cli=modular --out=/new/evidence/modular
|
|
66
|
+
node scripts/check-pi-host-runtime.mjs /path/to/pi-coding-agent /path/to/installed/pi-cursor-sdk --cli=bundled --out=/new/evidence/bundled
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The probe asserts host stream identity, ordinary prompt/tool replay, recorded native tool emission and replay execution without reading the fixture path, final drain, lazy bridge/SDK/SQLite imports and bundled ripgrep lookup. The SDK lane also invokes the actual manifest-registered provider callback with no key, requiring the expected auth preflight error before SDK execution. It uses no import redirection and makes no Cursor requests. The recorded tool fixture is offline replay evidence, not a claim of a live SDK tool call.
|
|
70
|
+
|
|
71
|
+
Every host must pass the common provider, context and replay checks. Add `--verify-fork-checkpoints` only to qualify the fork's native `SystemMessage.replace` support; explicit qualification fails on hosts lacking those semantics or transcript helpers. This additional host check is not a Cursor requirement. Published Pi 0.85.1 uses its supported shorthand context instead of synthetic transcript messages. None of these probes replaces the live platform gate.
|
|
72
|
+
|
|
51
73
|
## Auth: use `auth.json`, not only env
|
|
52
74
|
|
|
53
75
|
pi resolves Cursor auth in this order:
|
|
@@ -171,6 +193,8 @@ Session summaries can hide per-message usage bugs. When investigating token or c
|
|
|
171
193
|
|
|
172
194
|
The compaction poison fixture mirrors the observed failure shape: one assistant message with `RunResult`-sized input/cache-read counts near 1M immediately before compaction. Regression coverage should prove that such usage falls back to bounded pi estimates before it reaches `AssistantMessage.usage`.
|
|
173
195
|
|
|
196
|
+
Compaction continuity needs content evidence, not just a new agent ID or a successful recall reply. The compaction smoke checks the returned and persisted summaries for the exact marker before recall, verifies the original user was cut and the native kept context has no other marker source, and checks the actual bootstrap inputs too. Both post-compaction and restart recall reject tool calls: retained session/debug files must not become a filesystem fallback. Keep the raw summary and journals on failure as well as success; an unchanged later model success does not erase an earlier summary-content failure or prove a deterministic provider fix.
|
|
197
|
+
|
|
174
198
|
### False-positive edge case (2026-05-23)
|
|
175
199
|
|
|
176
200
|
Plan-strip live smoke can make Cursor `read` testing docs that *document* replay failure strings. A naive whole-record JSON scan reported four failures from one successful `read` toolResult (`isError: false`).
|
|
@@ -18,6 +18,8 @@ Required behavior:
|
|
|
18
18
|
- fail when a card/evidence item has the wrong success/error state;
|
|
19
19
|
- fail when footer/status is missing or unreadable.
|
|
20
20
|
|
|
21
|
+
The plain-text card inventory uses Node's `stripVTControlCharacters` on the complete captured ANSI stream. OSC-8 hyperlinks may use either BEL or ST (ESC followed by a backslash) terminators, and a PTY chunk may split either sequence. Do not strip chunks independently or use a greedy OSC regex: that can consume the visible `package.json` label, successful read content, or subsequent cards even when xterm renders them correctly. Raw ANSI remains unchanged for independent xterm/PNG rendering and secret scanning; exact card patterns and JSONL success/error assertions still apply. `test/fixtures/platform-read-hyperlinks.json` retains minimal native/bridge headers from real captures with only the local hyperlink target anonymized.
|
|
22
|
+
|
|
21
23
|
Meaningful gap closed: earlier card assertions could pass when the prompt mentioned `pi__read` or a missing-file path even if the actual tool card/result never rendered. The gate now requires JSONL result evidence and per-evidence rendered screenshots for native read, native shell success/failure, native edit diffs, bridge read success/failure, and bridge shell success.
|
|
22
24
|
|
|
23
25
|
## Registry visual classification
|
package/docs/platform-smoke.md
CHANGED
|
@@ -206,7 +206,7 @@ The copy/switch lane copies a session file containing resume custom entries, swi
|
|
|
206
206
|
|
|
207
207
|
The fallback lane rewrites a persisted handle to a missing local SDK `agent-*`, verifies create+bootstrap fallback, and asserts the continuity notice is emitted in `pi-stream-events.jsonl`.
|
|
208
208
|
|
|
209
|
-
The compaction lane uses an isolated temp pi settings file with `compaction.keepRecentTokens: 1` to force manual compaction without huge dummy prompts.
|
|
209
|
+
The compaction lane uses an isolated temp pi settings file with `compaction.keepRecentTokens: 1` to force manual compaction without huge dummy prompts. Before recall, it requires the exact marker in both the returned and native persisted summary, agreement on the kept boundary, and removal of the original marker-bearing user from the kept context. Native `get_messages` and the subsequent captured provider inputs must supply the marker only through that summary. The post-compaction agent must bootstrap without reusing the baseline or captured summarizer agents, persist `compactionGeneration: 1`, and resume as the same agent after restart. Both recall turns must return the marker without tool calls, so searching retained QA files cannot satisfy continuity. The lane retains its journals/debug artifacts and `compaction-boundary.json` on success and failure.
|
|
210
210
|
|
|
211
211
|
The default/opt-out lane verifies the built-in local resume default resumes, then verifies `PI_CURSOR_LOCAL_RESUME=0` opts out and creates a new agent while bootstrapping the transcript.
|
|
212
212
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-cursor-sdk",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.8",
|
|
4
4
|
"description": "pi provider extension backed by @cursor/sdk local and cloud agents",
|
|
5
5
|
"author": "Mitch Fultz (https://github.com/fitchmultz)",
|
|
6
6
|
"license": "MIT",
|
|
@@ -187,7 +187,7 @@
|
|
|
187
187
|
},
|
|
188
188
|
"pi": {
|
|
189
189
|
"extensions": [
|
|
190
|
-
"./
|
|
190
|
+
"./src/index.ts"
|
|
191
191
|
]
|
|
192
192
|
},
|
|
193
193
|
"allowScripts": {
|
package/scripts/build.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Purpose: Produce
|
|
3
|
+
* Purpose: Produce compiled runtime files for scripts and programmatic consumers.
|
|
4
|
+
* Pi's manifest loads the shipped source graph through its native TypeScript loader.
|
|
4
5
|
* Responsibilities: Run TypeScript emit into a staging directory, then atomically swap it
|
|
5
6
|
* into dist/ so a failed TypeScript emit never destroys a previously working dist.
|
|
6
7
|
* Usage: `npm run build`; also invoked by scripts/prepare.mjs during install lifecycles.
|
|
@@ -307,7 +307,7 @@ function metadataFiles(artifactDir) {
|
|
|
307
307
|
return files.sort((a, b) => statSync(a).mtimeMs - statSync(b).mtimeMs);
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
function readMetadataSince(artifactDir, seenPaths) {
|
|
310
|
+
export function readMetadataSince(artifactDir, seenPaths) {
|
|
311
311
|
return metadataFiles(artifactDir)
|
|
312
312
|
.filter((metadataPath) => !seenPaths.has(metadataPath))
|
|
313
313
|
.map((metadataPath) => ({
|
|
@@ -383,11 +383,6 @@ export function resumeEntryCount(entries) {
|
|
|
383
383
|
return resumeEntries(entries).length;
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
-
export function compactionEntryCount(entries) {
|
|
387
|
-
return (entries.entries ?? []).filter((entry) => entry?.type === "compaction")
|
|
388
|
-
.length;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
386
|
function entryText(entry) {
|
|
392
387
|
const content = entry?.message?.content;
|
|
393
388
|
if (typeof content === "string") return content;
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
assistantEntryContaining,
|
|
9
9
|
buildLocalResumeSmokeEnv,
|
|
10
10
|
cleanupArtifactRoot,
|
|
11
|
-
compactionEntryCount,
|
|
12
11
|
createRunContext,
|
|
13
12
|
fail,
|
|
14
13
|
getEntries,
|
|
@@ -17,6 +16,7 @@ import {
|
|
|
17
16
|
parseTimeout,
|
|
18
17
|
promptAbortAndRead,
|
|
19
18
|
promptAndRead,
|
|
19
|
+
readMetadataSince,
|
|
20
20
|
reportFailure,
|
|
21
21
|
resumeEntries,
|
|
22
22
|
scrubSmokeText,
|
|
@@ -514,10 +514,33 @@ async function runCompactionSmoke() {
|
|
|
514
514
|
});
|
|
515
515
|
assertTurnMetadata("compaction baseline", baseline, { resumedAgent: false });
|
|
516
516
|
preCompactionAgentId = baseline.metadata.run.agentId;
|
|
517
|
+
const originalUser = userEntryContaining(await getEntries(rpc), marker);
|
|
518
|
+
if (!originalUser) fail("compaction baseline did not persist the marker-bearing user message");
|
|
517
519
|
const result = await rpcData(rpc, "compact", { customInstructions: `Preserve the exact marker ${marker}.` }, timeoutMs);
|
|
518
|
-
if (!result.summary || typeof result.tokensBefore !== "number") fail("manual compaction did not return a summary result", JSON.stringify(result, null, 2));
|
|
519
520
|
const compactedEntries = await getEntries(rpc);
|
|
520
|
-
|
|
521
|
+
const compaction = compactedEntries.entries.findLast((entry) => entry.type === "compaction");
|
|
522
|
+
const active = await rpcData(rpc, "get_messages");
|
|
523
|
+
const summarizerTurns = readMetadataSince(artifactRoot, seenMetadata);
|
|
524
|
+
// Save the native boundary before assertions or any recall turn can inspect files.
|
|
525
|
+
const boundaryPath = join(artifactRoot, "compaction-boundary.json");
|
|
526
|
+
writeFileSync(boundaryPath, JSON.stringify({ result, compaction, originalUserEntryId: originalUser.id, activeMessages: active.messages, summarizerTurns }, null, 2));
|
|
527
|
+
if (typeof result.summary !== "string" || typeof result.tokensBefore !== "number") fail("manual compaction did not return a summary result", boundaryPath);
|
|
528
|
+
if (!result.summary.includes(marker)) fail("returned compaction summary lost the exact marker", boundaryPath);
|
|
529
|
+
if (!compaction) fail("manual compaction did not append a compaction entry", boundaryPath);
|
|
530
|
+
if (typeof compaction.summary !== "string" || !compaction.summary.includes(marker)) fail("persisted compaction summary lost the exact marker", boundaryPath);
|
|
531
|
+
if (compaction.summary !== result.summary || compaction.firstKeptEntryId !== result.firstKeptEntryId) fail("returned and persisted compaction boundaries disagree", boundaryPath);
|
|
532
|
+
const originalIndex = compactedEntries.entries.findIndex((entry) => entry.id === originalUser.id);
|
|
533
|
+
const keptIndex = compactedEntries.entries.findIndex((entry) => entry.id === compaction.firstKeptEntryId);
|
|
534
|
+
const compactionIndex = compactedEntries.entries.indexOf(compaction);
|
|
535
|
+
if (originalIndex < 0 || keptIndex <= originalIndex || keptIndex >= compactionIndex) fail("compaction did not move the kept boundary past the original marker-bearing user", boundaryPath);
|
|
536
|
+
// Ask the host for its active messages; do not reproduce its cut/replay algorithm.
|
|
537
|
+
const summaryMessage = active.messages.find((message) => message.role === "compactionSummary" && message.summary === compaction.summary);
|
|
538
|
+
if (!summaryMessage) fail("native active context does not contain the persisted compaction summary", boundaryPath);
|
|
539
|
+
if (active.messages.some((message) => message !== summaryMessage && JSON.stringify(message).includes(marker))) fail("kept context independently supplies the compaction marker", boundaryPath);
|
|
540
|
+
for (const turn of summarizerTurns) {
|
|
541
|
+
assertNotResumedFrom("compaction summarizer", turn, preCompactionAgentId);
|
|
542
|
+
seenMetadata.add(turn.metadataPath);
|
|
543
|
+
}
|
|
521
544
|
|
|
522
545
|
const postCompaction = await promptAndRead({
|
|
523
546
|
rpc,
|
|
@@ -527,6 +550,19 @@ async function runCompactionSmoke() {
|
|
|
527
550
|
seenMetadata,
|
|
528
551
|
});
|
|
529
552
|
assertNotResumedFrom("post-compaction turn", postCompaction, preCompactionAgentId);
|
|
553
|
+
for (const turn of summarizerTurns) assertNotResumedFrom("post-compaction turn versus summarizer", postCompaction, turn.metadata.run.agentId);
|
|
554
|
+
if (postCompaction.metadata.providerMeta?.sendPlan?.mode !== "bootstrap") fail("post-compaction turn did not bootstrap the compacted context", postCompaction.metadataPath);
|
|
555
|
+
// Inspect the actual provider inputs too: the native summary must be the only marker source.
|
|
556
|
+
const postDir = dirname(postCompaction.metadataPath);
|
|
557
|
+
const escapedSummary = JSON.stringify(compaction.summary).slice(1, -1);
|
|
558
|
+
for (const file of ["context-snapshot.json", "send-payload.json"]) {
|
|
559
|
+
const path = join(postDir, file);
|
|
560
|
+
const input = JSON.stringify(JSON.parse(readFileSync(path, "utf8")));
|
|
561
|
+
if (!input.includes(escapedSummary)) fail("post-compaction input omitted the persisted summary", path);
|
|
562
|
+
if (input.replace(escapedSummary, "").includes(marker)) fail("post-compaction input independently supplies the marker outside the summary", path);
|
|
563
|
+
}
|
|
564
|
+
const postSteps = readFileSync(join(postDir, "on-step.jsonl"), "utf8").split("\n").filter(Boolean).map(JSON.parse);
|
|
565
|
+
if (postSteps.some((row) => row.step?.type === "toolCall")) fail("post-compaction recall used tools instead of summary-only recall", postCompaction.metadataPath);
|
|
530
566
|
if (!postCompaction.text.includes(`MARKER=${marker}`)) fail("post-compaction turn did not recall marker", JSON.stringify({ expected: `MARKER=${marker}`, actual: postCompaction.text }, null, 2));
|
|
531
567
|
postCompactionAgentId = postCompaction.metadata.run.agentId;
|
|
532
568
|
const postHandle = latestResumeEntry(await getEntries(rpc));
|
|
@@ -544,11 +580,14 @@ async function runCompactionSmoke() {
|
|
|
544
580
|
});
|
|
545
581
|
assertTurnMetadata("post-compaction restart", restart, { resumedAgent: true });
|
|
546
582
|
if (restart.metadata.run.agentId !== postCompactionAgentId) fail("post-compaction restart did not resume the post-compaction agent", JSON.stringify({ expected: postCompactionAgentId, actual: restart.metadata.run.agentId }, null, 2));
|
|
583
|
+
const restartSteps = readFileSync(join(dirname(restart.metadataPath), "on-step.jsonl"), "utf8").split("\n").filter(Boolean).map(JSON.parse);
|
|
584
|
+
if (restartSteps.some((row) => row.step?.type === "toolCall")) fail("post-compaction restart used tools instead of recalling the marker", restart.metadataPath);
|
|
585
|
+
if (!restart.text.includes(`MARKER=${marker}`)) fail("post-compaction restart did not recall marker", JSON.stringify({ expected: `MARKER=${marker}`, actual: restart.text }, null, 2));
|
|
547
586
|
});
|
|
548
587
|
console.log("local-resume-compaction-smoke-ok");
|
|
549
588
|
console.error(scrubSmokeText(`[local-resume-smoke] pre-compaction ${preCompactionAgentId} replaced by and resumed post-compaction ${postCompactionAgentId}`));
|
|
550
589
|
} finally {
|
|
551
|
-
|
|
590
|
+
console.error(scrubSmokeText(`[local-resume-smoke] retained compaction evidence: ${artifactRoot}`));
|
|
552
591
|
}
|
|
553
592
|
}
|
|
554
593
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
|
+
import { stripVTControlCharacters } from "node:util";
|
|
11
12
|
import { matchesWrappedLineAt } from "./wrapped-line-match.mjs";
|
|
12
13
|
|
|
13
14
|
const CARD_PATTERNS = [
|
|
@@ -35,10 +36,7 @@ const CARD_PATTERNS = [
|
|
|
35
36
|
];
|
|
36
37
|
|
|
37
38
|
function cleanLine(line) {
|
|
38
|
-
return line
|
|
39
|
-
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
40
|
-
.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "")
|
|
41
|
-
.replace(/\r/g, "");
|
|
39
|
+
return stripVTControlCharacters(line).replace(/\r/g, "");
|
|
42
40
|
}
|
|
43
41
|
|
|
44
42
|
function matchesCardAt(lines, index, card) {
|
|
@@ -13,6 +13,7 @@ import { chmodSync, copyFileSync, existsSync, mkdirSync, readdirSync, readFileSy
|
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
14
|
import { dirname, join, resolve } from "node:path";
|
|
15
15
|
import { setTimeout as delay } from "node:timers/promises";
|
|
16
|
+
import { stripVTControlCharacters } from "node:util";
|
|
16
17
|
import { redactSecrets, writePlatformArtifactBundle } from "./artifacts.mjs";
|
|
17
18
|
import { extractContentText, jsonlHasAssistantFinalTextMarker } from "./jsonl-text.mjs";
|
|
18
19
|
import { getScenario, renderPrompt } from "./scenarios.mjs";
|
|
@@ -237,10 +238,6 @@ function prepareSharedPackedInstall(prepDir, logDir, artifactDir, packageName) {
|
|
|
237
238
|
return prepared;
|
|
238
239
|
}
|
|
239
240
|
|
|
240
|
-
function stripANSI(text) {
|
|
241
|
-
return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "");
|
|
242
|
-
}
|
|
243
|
-
|
|
244
241
|
function ptySpawnCommand(args) {
|
|
245
242
|
const cliEntry = resolve(process.cwd(), "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
|
|
246
243
|
if (!existsSync(cliEntry)) throw new Error(`Pi CLI entry not found: ${cliEntry}`);
|
|
@@ -278,7 +275,6 @@ async function runPtyPi({ artifactDir, ptyCommand, env, cwd, sessionDir, finalMa
|
|
|
278
275
|
}
|
|
279
276
|
|
|
280
277
|
let ansi = "";
|
|
281
|
-
let plain = "";
|
|
282
278
|
const events = [];
|
|
283
279
|
const startedAt = Date.now();
|
|
284
280
|
const { file, args } = ptyCommand;
|
|
@@ -297,7 +293,6 @@ async function runPtyPi({ artifactDir, ptyCommand, env, cwd, sessionDir, finalMa
|
|
|
297
293
|
let exitEvent;
|
|
298
294
|
child.onData((data) => {
|
|
299
295
|
ansi += data;
|
|
300
|
-
plain += stripANSI(data);
|
|
301
296
|
events.push({ type: "output", elapsedMs: Date.now() - startedAt, bytes: data.length });
|
|
302
297
|
});
|
|
303
298
|
child.onExit((event) => {
|
|
@@ -313,7 +308,9 @@ async function runPtyPi({ artifactDir, ptyCommand, env, cwd, sessionDir, finalMa
|
|
|
313
308
|
let abortObserved = false;
|
|
314
309
|
const abortStartedPath = join(cwd, ".debug", "platform-smoke", "abort-started.txt");
|
|
315
310
|
while (Date.now() - startedAt < waitMs) {
|
|
316
|
-
|
|
311
|
+
// PTY chunks may split an OSC-8 link. Strip the complete captured stream,
|
|
312
|
+
// not individual chunks, and preserve the label between open/close links.
|
|
313
|
+
const currentPlain = stripVTControlCharacters(ansi);
|
|
317
314
|
const responsePlain = currentPlain.slice(responseStartOffset);
|
|
318
315
|
if (finalMarker && responsePlain.includes(finalMarker)) finalMarkerSeen = true;
|
|
319
316
|
if (finalMarkerSeen && sessionJsonlMeetsRequirements(sessionDir, scenario) && sessionJsonlHasAssistantMarker(sessionDir, finalMarker)) {
|
|
@@ -364,6 +361,7 @@ async function runPtyPi({ artifactDir, ptyCommand, env, cwd, sessionDir, finalMa
|
|
|
364
361
|
await waitForSessionJsonl(sessionDir, null, startedAt, events);
|
|
365
362
|
await delay(1_000);
|
|
366
363
|
|
|
364
|
+
const plain = stripVTControlCharacters(ansi);
|
|
367
365
|
writeRedactedTextFile(join(artifactDir, "terminal.ansi"), ansi);
|
|
368
366
|
writeRedactedTextFile(join(artifactDir, "terminal.txt"), plain);
|
|
369
367
|
writeFileSync(join(artifactDir, "pty.events.jsonl"), events.map((event) => JSON.stringify(event)).join("\n") + "\n");
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { spawn } from "node:child_process";
|
|
9
9
|
import { writeFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
|
+
import { stripVTControlCharacters } from "node:util";
|
|
11
12
|
|
|
12
13
|
const COLS = 150;
|
|
13
14
|
const ROWS = 45;
|
|
@@ -27,7 +28,6 @@ export async function capturePTY(dir, command, opts = {}) {
|
|
|
27
28
|
const txtPath = resolve(dir, "terminal.txt");
|
|
28
29
|
|
|
29
30
|
let ansiBuffer = "";
|
|
30
|
-
let txtBuffer = "";
|
|
31
31
|
|
|
32
32
|
function appendEvent(event) {
|
|
33
33
|
appendFileSync(eventsPath, JSON.stringify(event) + "\n");
|
|
@@ -35,8 +35,6 @@ export async function capturePTY(dir, command, opts = {}) {
|
|
|
35
35
|
function appendOutput(data) {
|
|
36
36
|
const text = typeof data === "string" ? data : data.toString();
|
|
37
37
|
ansiBuffer += text;
|
|
38
|
-
// Strip ANSI for plain text
|
|
39
|
-
txtBuffer += stripANSI(text);
|
|
40
38
|
}
|
|
41
39
|
|
|
42
40
|
// Try node-pty first
|
|
@@ -79,7 +77,7 @@ export async function capturePTY(dir, command, opts = {}) {
|
|
|
79
77
|
});
|
|
80
78
|
|
|
81
79
|
writeFileSync(ansiPath, ansiBuffer);
|
|
82
|
-
writeFileSync(txtPath,
|
|
80
|
+
writeFileSync(txtPath, stripVTControlCharacters(ansiBuffer));
|
|
83
81
|
|
|
84
82
|
return { code: code ?? (signal ? 1 : 0), signal, ansiPath, txtPath, eventsPath };
|
|
85
83
|
} else {
|
|
@@ -111,17 +109,12 @@ export async function capturePTY(dir, command, opts = {}) {
|
|
|
111
109
|
});
|
|
112
110
|
|
|
113
111
|
writeFileSync(ansiPath, ansiBuffer);
|
|
114
|
-
writeFileSync(txtPath,
|
|
112
|
+
writeFileSync(txtPath, stripVTControlCharacters(ansiBuffer));
|
|
115
113
|
|
|
116
114
|
return { code: code ?? (signal ? 1 : 0), signal, ansiPath, txtPath, eventsPath };
|
|
117
115
|
}
|
|
118
116
|
}
|
|
119
117
|
|
|
120
|
-
/** Strip ANSI escape sequences from a string. */
|
|
121
|
-
function stripANSI(str) {
|
|
122
|
-
return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
|
|
123
|
-
}
|
|
124
|
-
|
|
125
118
|
/** Self-test: can we create a simple PTY? */
|
|
126
119
|
export async function ptySelfTest() {
|
|
127
120
|
const dir = resolve(process.cwd(), ".artifacts", "pty-self-test");
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { resolve } from "node:path";
|
|
10
|
+
import { stripVTControlCharacters } from "node:util";
|
|
10
11
|
|
|
11
12
|
import { buildTerminalHtml, writeTerminalScreenshot } from "../lib/cursor-visual-render.mjs";
|
|
12
13
|
|
|
@@ -14,14 +15,10 @@ const COLS = 150;
|
|
|
14
15
|
const ROWS = 45;
|
|
15
16
|
const HISTORY_LINES = 3_000;
|
|
16
17
|
|
|
17
|
-
function stripANSI(text) {
|
|
18
|
-
return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, "");
|
|
19
|
-
}
|
|
20
|
-
|
|
21
18
|
/** Render terminal.ansi to terminal.html using the shared xterm.js renderer. */
|
|
22
19
|
export async function renderHTML(ansiPath, htmlPath, options = {}) {
|
|
23
20
|
const ansi = readFileSync(ansiPath, "utf8");
|
|
24
|
-
const plain = options.plain ??
|
|
21
|
+
const plain = options.plain ?? stripVTControlCharacters(ansi);
|
|
25
22
|
const html = buildTerminalHtml({
|
|
26
23
|
ansi,
|
|
27
24
|
plain,
|
package/src/context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { getCursorConversationMessages, resolveCursorPiContext } from "./cursor-pi-context.js";
|
|
2
3
|
import type { Context, Message, ToolCall } from "@earendil-works/pi-ai";
|
|
3
4
|
import { convertToLlm } from "@earendil-works/pi-coding-agent";
|
|
4
5
|
import type { AgentModeOption, SDKImage } from "@cursor/sdk";
|
|
@@ -85,7 +86,7 @@ function getCursorBootstrapTailSections(
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
function normalizePiContextMessages(messages: Context["messages"]): Message[] {
|
|
88
|
-
return convertToLlm(messages as Parameters<typeof convertToLlm>[0]);
|
|
89
|
+
return convertToLlm(getCursorConversationMessages({ messages }) as Parameters<typeof convertToLlm>[0]);
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
function isTextBlock(block: { type: string }): block is { type: "text"; text: string } {
|
|
@@ -137,6 +138,15 @@ function formatToolCall(toolCall: ToolCall): string {
|
|
|
137
138
|
|
|
138
139
|
function sanitizeSystemPromptForCursor(systemPrompt: string): string {
|
|
139
140
|
let sanitized = systemPrompt;
|
|
141
|
+
// Transcript-era Pi wraps its built-in tool catalog/rules in XML sections.
|
|
142
|
+
sanitized = sanitized.replace(
|
|
143
|
+
/<tools>\n[\s\S]*?\n\nIn addition to the tools above, you may have access to other custom tools depending on the project\.\n<\/tools>/g,
|
|
144
|
+
"Pi tool catalog omitted: Cursor can call only Cursor SDK tools exposed in this run.",
|
|
145
|
+
);
|
|
146
|
+
sanitized = sanitized.replace(
|
|
147
|
+
/<rules>\n[\s\S]*?\n<\/rules>\n\n(?=<docs>\nPi documentation )/g,
|
|
148
|
+
"<rules>\n- Be concise in your responses.\n- Show file paths clearly when working with files.\n</rules>\n\n",
|
|
149
|
+
);
|
|
140
150
|
sanitized = sanitized.replace(
|
|
141
151
|
/Available tools:\n[\s\S]*?\n\nIn addition to the tools above, you may have access to other custom tools depending on the project\.\n\n/g,
|
|
142
152
|
"Pi tool catalog omitted: Cursor can call only Cursor SDK tools exposed in this run.\n\n",
|
|
@@ -279,12 +289,24 @@ function serializeMessageForFingerprint(message: Message, index: number): string
|
|
|
279
289
|
return hashCursorContextValue(
|
|
280
290
|
`toolResult:${message.timestamp ?? index}:${message.toolCallId}:${message.toolName}:${JSON.stringify(message.content)}:${message.isError === true}`,
|
|
281
291
|
);
|
|
292
|
+
default:
|
|
293
|
+
// System messages are handled by serializeRawPiMessageForFingerprint.
|
|
294
|
+
throw new Error("Unsupported Pi message role in Cursor context fingerprint.");
|
|
282
295
|
}
|
|
283
296
|
}
|
|
284
297
|
|
|
285
298
|
function serializeRawPiMessageForFingerprint(message: Context["messages"][number], index: number): string {
|
|
286
299
|
const role = (message as { role?: string }).role;
|
|
287
300
|
switch (role) {
|
|
301
|
+
case "system": {
|
|
302
|
+
const entry = message as {
|
|
303
|
+
content: unknown; sections?: unknown; toolsAdded?: unknown; toolsRemoved?: unknown;
|
|
304
|
+
};
|
|
305
|
+
return hashCursorContextValue(JSON.stringify({
|
|
306
|
+
role, content: entry.content, sections: entry.sections,
|
|
307
|
+
toolsAdded: entry.toolsAdded, toolsRemoved: entry.toolsRemoved,
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
288
310
|
case "branchSummary": {
|
|
289
311
|
const entry = message as { summary?: string; fromId?: string; timestamp?: number };
|
|
290
312
|
return hashCursorContextValue(
|
|
@@ -337,7 +359,7 @@ function parseCursorContextFingerprint(fingerprint: string): CursorContextFinger
|
|
|
337
359
|
|
|
338
360
|
export function computeCursorContextFingerprint(context: Context): string {
|
|
339
361
|
const payload: CursorContextFingerprintPayload = {
|
|
340
|
-
systemHash: hashCursorContextValue(context
|
|
362
|
+
systemHash: hashCursorContextValue(JSON.stringify(resolveCursorPiContext(context))),
|
|
341
363
|
messageHashes: context.messages.map((message, index) => serializeRawPiMessageForFingerprint(message, index)),
|
|
342
364
|
};
|
|
343
365
|
return JSON.stringify(payload);
|
|
@@ -412,8 +434,9 @@ export function buildCursorPrompt(context: Context, options: CursorPromptOptions
|
|
|
412
434
|
sectionsBeforeMessages.push(options.toolManifest);
|
|
413
435
|
}
|
|
414
436
|
|
|
415
|
-
|
|
416
|
-
|
|
437
|
+
const { systemPrompt } = resolveCursorPiContext(context);
|
|
438
|
+
if (systemPrompt) {
|
|
439
|
+
sectionsBeforeMessages.push(`System instructions from pi:\n${sanitizeSystemPromptForCursor(systemPrompt)}`);
|
|
417
440
|
}
|
|
418
441
|
|
|
419
442
|
const messages = normalizePiContextMessages(context.messages);
|
|
@@ -114,9 +114,21 @@ export function serializePiProjectInstructionsBlock(file: PiAgentsContextFile):
|
|
|
114
114
|
return `${PI_PROJECT_INSTRUCTIONS_OPEN_PREFIX}${file.path}">\n${file.content}\n${PI_PROJECT_INSTRUCTIONS_CLOSE}\n\n`;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/**
|
|
118
|
-
export function serializePiProjectContextSection(
|
|
117
|
+
/** The two supported Pi project-context serializations; legacy includes outer spacing. */
|
|
118
|
+
export function serializePiProjectContextSection(
|
|
119
|
+
contextFiles: readonly PiAgentsContextFile[],
|
|
120
|
+
format: "legacy" | "transcript" = "legacy",
|
|
121
|
+
): string {
|
|
119
122
|
if (contextFiles.length === 0) return "";
|
|
123
|
+
if (format === "transcript") {
|
|
124
|
+
return [
|
|
125
|
+
"<project_context>",
|
|
126
|
+
"Project-specific instructions and guidelines:",
|
|
127
|
+
"",
|
|
128
|
+
contextFiles.map((file) => serializePiProjectInstructionsBlock(file).trimEnd()).join("\n\n"),
|
|
129
|
+
"</project_context>",
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
120
132
|
return `${PI_PROJECT_CONTEXT_OPEN}${contextFiles.map(serializePiProjectInstructionsBlock).join("")}${PI_PROJECT_CONTEXT_CLOSE}`;
|
|
121
133
|
}
|
|
122
134
|
|
|
@@ -138,12 +150,16 @@ export function removePiAgentsContextFromSystemPrompt(
|
|
|
138
150
|
}
|
|
139
151
|
if (!removedAny) return systemPrompt;
|
|
140
152
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
153
|
+
// Match only the exact known files in either supported serialization; never
|
|
154
|
+
// strip arbitrary user-authored XML or neighboring custom sections.
|
|
155
|
+
for (const format of ["legacy", "transcript"] as const) {
|
|
156
|
+
const originalSection = serializePiProjectContextSection(contextFiles, format);
|
|
157
|
+
const start = systemPrompt.indexOf(originalSection);
|
|
158
|
+
if (start < 0) continue;
|
|
159
|
+
const replacementSection = serializePiProjectContextSection(retainedContextFiles, format);
|
|
160
|
+
return systemPrompt.slice(0, start) + replacementSection + systemPrompt.slice(start + originalSection.length);
|
|
161
|
+
}
|
|
162
|
+
return systemPrompt;
|
|
147
163
|
}
|
|
148
164
|
|
|
149
165
|
export function resolveCursorFacingSystemPrompt(
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Context } from "@earendil-works/pi-ai";
|
|
2
|
+
import { resolveCursorPiContext } from "./cursor-pi-context.js";
|
|
2
3
|
|
|
3
4
|
/** Tool names from the provider context snapshot at stream start (not live pi.getActiveTools()). */
|
|
4
5
|
export function getActiveContextToolNames(context: Context): ReadonlySet<string> | undefined {
|
|
5
|
-
|
|
6
|
+
const { tools } = resolveCursorPiContext(context);
|
|
7
|
+
return tools === undefined ? undefined : new Set(tools.map((tool) => tool.name));
|
|
6
8
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Context, ToolResultMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import { getCursorConversationMessages } from "./cursor-pi-context.js";
|
|
2
3
|
import type { SDKAgent } from "@cursor/sdk";
|
|
3
4
|
import {
|
|
4
5
|
consumeCursorLiveToolResults,
|
|
@@ -27,6 +28,7 @@ export type CursorLiveQueuedEvent =
|
|
|
27
28
|
| { type: "thinking-delta"; text: string }
|
|
28
29
|
| { type: "thinking-completed" }
|
|
29
30
|
| { type: "text-delta"; text: string }
|
|
31
|
+
| { type: "text-completed" }
|
|
30
32
|
| { type: "tool"; tool: CursorNativeToolDisplayItem }
|
|
31
33
|
| { type: "bridge-tool"; request: CursorPiBridgeToolRequest };
|
|
32
34
|
|
|
@@ -155,16 +157,17 @@ interface CursorLiveRunPrivateState {
|
|
|
155
157
|
}
|
|
156
158
|
|
|
157
159
|
export function hasTrailingUserMessagesAfterToolResults(context: Context): boolean {
|
|
158
|
-
|
|
160
|
+
const messages = getCursorConversationMessages(context);
|
|
161
|
+
let index = messages.length - 1;
|
|
159
162
|
let sawTrailingUser = false;
|
|
160
|
-
while (index >= 0 &&
|
|
163
|
+
while (index >= 0 && messages[index]?.role === "user") {
|
|
161
164
|
sawTrailingUser = true;
|
|
162
165
|
index -= 1;
|
|
163
166
|
}
|
|
164
167
|
if (!sawTrailingUser) return false;
|
|
165
168
|
|
|
166
169
|
let sawToolResult = false;
|
|
167
|
-
while (index >= 0 &&
|
|
170
|
+
while (index >= 0 && messages[index]?.role === "toolResult") {
|
|
168
171
|
sawToolResult = true;
|
|
169
172
|
index -= 1;
|
|
170
173
|
}
|
|
@@ -418,13 +421,14 @@ export function createCursorLiveRunCoordinator(deps: CursorLiveRunCoordinatorDep
|
|
|
418
421
|
},
|
|
419
422
|
|
|
420
423
|
getPendingFromContext(context, getReplayId): CursorLiveRun | undefined {
|
|
421
|
-
|
|
422
|
-
|
|
424
|
+
const messages = getCursorConversationMessages(context);
|
|
425
|
+
let index = messages.length - 1;
|
|
426
|
+
while (index >= 0 && messages[index]?.role === "user") {
|
|
423
427
|
index -= 1;
|
|
424
428
|
}
|
|
425
429
|
|
|
426
430
|
for (; index >= 0; index -= 1) {
|
|
427
|
-
const message =
|
|
431
|
+
const message = messages[index];
|
|
428
432
|
if (message.role !== "toolResult") break;
|
|
429
433
|
const replayId = getReplayId(message.toolCallId);
|
|
430
434
|
if (replayId) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AssistantMessage, AssistantMessageEventStream } from "@earendil-works/pi-ai";
|
|
2
2
|
|
|
3
3
|
const DEFAULT_THINKING_TRACE_MAX_CHARS = 50000;
|
|
4
|
+
export const CURSOR_TEXT_MESSAGE_SEPARATOR = "\n\n";
|
|
4
5
|
|
|
5
6
|
export interface CursorPartialContentEmitterOptions {
|
|
6
7
|
stream: AssistantMessageEventStream;
|
|
@@ -12,6 +13,7 @@ export interface CursorPartialContentEmitterOptions {
|
|
|
12
13
|
export class CursorPartialContentEmitter {
|
|
13
14
|
private thinkingContentIndex = -1;
|
|
14
15
|
private textContentIndex = -1;
|
|
16
|
+
private textMessageCompleted = false;
|
|
15
17
|
private activityTraceChars = 0;
|
|
16
18
|
private activityTraceTruncated = false;
|
|
17
19
|
|
|
@@ -86,10 +88,21 @@ export class CursorPartialContentEmitter {
|
|
|
86
88
|
});
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
completeTextMessage(): void {
|
|
92
|
+
// Defer the separator until more text arrives: an exact final answer must
|
|
93
|
+
// not gain trailing whitespace. A fresh Pi tool-use turn needs no prefix.
|
|
94
|
+
this.textMessageCompleted = this.partial.content.some((block) => block.type === "text" && block.text.length > 0);
|
|
95
|
+
}
|
|
96
|
+
|
|
89
97
|
appendTextDelta(delta: string, options?: { closeThinking?: boolean }): void {
|
|
90
98
|
const closeThinking = options?.closeThinking ?? this.mutuallyExclusive;
|
|
91
99
|
if (closeThinking) this.closeThinking();
|
|
92
100
|
if (!delta) return;
|
|
101
|
+
if (this.textMessageCompleted) {
|
|
102
|
+
this.textMessageCompleted = false;
|
|
103
|
+
this.appendTextDelta(CURSOR_TEXT_MESSAGE_SEPARATOR, { closeThinking: false });
|
|
104
|
+
this.closeText();
|
|
105
|
+
}
|
|
93
106
|
if (this.textContentIndex < 0) {
|
|
94
107
|
this.textContentIndex = this.partial.content.length;
|
|
95
108
|
this.partial.content.push({ type: "text", text: "" });
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as piAi from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Context, Message, Tool } from "@earendil-works/pi-ai";
|
|
3
|
+
|
|
4
|
+
// Stock 0.84/0.85 do not export transcript helpers. Keep the namespace import in
|
|
5
|
+
// Pi's static extension graph: the host loader supplies its own peer module.
|
|
6
|
+
// Context remains the common structural input (its shorthand fields are optional);
|
|
7
|
+
// transcript hosts pass messages only. Never implement Pi's replay rules here.
|
|
8
|
+
interface PiTranscriptHelpers {
|
|
9
|
+
normalizeContext(context: Context): { messages: readonly { role: string }[] };
|
|
10
|
+
getCurrentSystemPrompt(messages: readonly { role: string }[]): string;
|
|
11
|
+
getCurrentTools(messages: readonly { role: string }[]): Tool[];
|
|
12
|
+
}
|
|
13
|
+
const optionalHelpers = piAi as typeof piAi & Partial<PiTranscriptHelpers>;
|
|
14
|
+
const transcriptHelpers = typeof optionalHelpers.normalizeContext === "function"
|
|
15
|
+
&& typeof optionalHelpers.getCurrentSystemPrompt === "function"
|
|
16
|
+
&& typeof optionalHelpers.getCurrentTools === "function"
|
|
17
|
+
? optionalHelpers as PiTranscriptHelpers : undefined;
|
|
18
|
+
|
|
19
|
+
export function isCursorSystemMessage(message: { role: string }): boolean {
|
|
20
|
+
return message.role === "system";
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Conversation-only view for Cursor text/history and trailing tool-result scans. */
|
|
24
|
+
export function getCursorConversationMessages(context: Pick<Context, "messages">): Message[] {
|
|
25
|
+
return context.messages.filter((message) => !isCursorSystemMessage(message));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveCursorPiContext(context: Context): { systemPrompt: string; tools: Tool[] | undefined } {
|
|
29
|
+
if (!transcriptHelpers) {
|
|
30
|
+
if (context.messages.some(isCursorSystemMessage)) {
|
|
31
|
+
throw new Error("Pi transcript context requires the host's public transcript replay helpers.");
|
|
32
|
+
}
|
|
33
|
+
return { systemPrompt: context.systemPrompt ?? "", tools: context.tools };
|
|
34
|
+
}
|
|
35
|
+
const messages = transcriptHelpers.normalizeContext(context).messages;
|
|
36
|
+
const legacyWithoutTools = ("systemPrompt" in context || "tools" in context)
|
|
37
|
+
&& context.tools === undefined && !context.messages.some(isCursorSystemMessage);
|
|
38
|
+
return {
|
|
39
|
+
systemPrompt: transcriptHelpers.getCurrentSystemPrompt(messages),
|
|
40
|
+
// A messages-only provider request is authoritative, including no declarations.
|
|
41
|
+
// An explicitly legacy shorthand context retains its absent-snapshot semantics.
|
|
42
|
+
tools: legacyWithoutTools ? undefined : transcriptHelpers.getCurrentTools(messages),
|
|
43
|
+
};
|
|
44
|
+
}
|