pi-cursor-sdk 0.3.8 → 0.3.9
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 +13 -0
- package/README.md +9 -10
- package/dist/cursor-provider-live-run-drain.js +7 -9
- package/dist/cursor-record-utils.js +0 -19
- package/docs/cursor-dogfood-checklist.md +1 -1
- package/docs/cursor-live-smoke-checklist.md +1 -1
- package/docs/cursor-testing-lessons.md +0 -18
- package/docs/platform-smoke.md +1 -1
- package/package.json +6 -5
- package/scripts/build.mjs +1 -2
- package/scripts/lib/local-resume-smoke-harness.mjs +6 -1
- package/scripts/local-resume-smoke.mjs +4 -43
- package/scripts/platform-smoke/artifact-secrets.mjs +54 -0
- package/scripts/platform-smoke/artifacts.mjs +12 -7
- package/scripts/platform-smoke/live-suite-runner.mjs +3 -1
- package/scripts/platform-smoke/target-runtime.mjs +7 -0
- package/scripts/platform-smoke/targets.mjs +3 -1
- package/src/cursor-provider-live-run-drain.ts +7 -7
- package/src/cursor-record-utils.ts +0 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.9 - 2026-09-19
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Serialize replay tool-call arguments once at the Pi transcript boundary to satisfy Pi 0.86.1's JSON-only contract without sharing mutable SDK payloads.
|
|
8
|
+
- Preserve secret-scanned JSON and JSONL smoke artifacts byte-for-byte, and fail qualification when retained structured evidence cannot be parsed.
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Restore the compiled `dist/index.js` Pi entrypoint and the pre-0.3.8 tool-argument handling. Released Pi 0.85.1 did not require the source-loader or newer transcript-type changes.
|
|
13
|
+
- Remove the additional source-host compatibility matrix, probes, and compaction qualification added with 0.3.8. Keep the terminal capture detector fix, which corrects QA evidence rather than runtime rendering.
|
|
14
|
+
- Extension filters changed for 0.3.8 must target `dist/index.js` again. Keep exclusions for both entrypoints when disabling Cursor across versions; see README's “Existing extension filters”.
|
|
15
|
+
|
|
3
16
|
## 0.3.8 - 2026-09-19
|
|
4
17
|
|
|
5
18
|
### Fixed
|
package/README.md
CHANGED
|
@@ -72,12 +72,9 @@ pi install https://github.com/fitchmultz/pi-cursor-sdk
|
|
|
72
72
|
|
|
73
73
|
### Existing extension filters
|
|
74
74
|
|
|
75
|
-
The Pi entrypoint is
|
|
75
|
+
The Pi entrypoint is `dist/index.js`. If you changed extension filters for 0.3.8's `src/index.ts` entrypoint, restore their compiled-entry equivalents: `+src/index.ts` → `+dist/index.js`, `-src/index.ts` → `-dist/index.js`, and `!src/**` → `!dist/**`.
|
|
76
76
|
|
|
77
|
-
|
|
78
|
-
- Glob filters: for example, `dist/**` → `src/**` and `!dist/**` → `!src/**`. Adjust other old-dist patterns to match the new entrypoint with the same intent.
|
|
79
|
-
|
|
80
|
-
To keep Cursor disabled across upgrades and rollback, retain `-dist/index.js` and add `-src/index.ts`; the old exclusion alone no longer disables Cursor. The package does not rewrite user or project settings.
|
|
77
|
+
To keep Cursor disabled across both entrypoints, retain both `-src/index.ts` and `-dist/index.js`. The package does not rewrite user or project settings.
|
|
81
78
|
|
|
82
79
|
### Project-local install
|
|
83
80
|
|
|
@@ -100,13 +97,11 @@ Without `--approve`, the project-local extension still runs after Pi trusts the
|
|
|
100
97
|
For development from this repository:
|
|
101
98
|
|
|
102
99
|
```bash
|
|
103
|
-
npm install # runs prepare
|
|
100
|
+
npm install # runs prepare, which compiles src/ into dist/ (the manifest entry pi loads)
|
|
104
101
|
pi --approve -e . --model cursor/grok-4.6
|
|
105
102
|
```
|
|
106
103
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
The host-identity fix adds initial startup/load work: three fresh-process loader measurements on macOS with Node 24.21.0, Pi fork `90e6`, a warm Jiti disk cache, and network denied took 0.85–1.24 s for source versus 0.08–0.13 s for dist. These are environment-specific loader timings, not full TUI startup measurements or timing guarantees. That host caches extension factories for the same cwd; the initial-load cost does not describe every in-process `/reload`.
|
|
104
|
+
After editing `src/`, run `npm run build` before the next `pi -e .` run, or pi loads the previous build.
|
|
110
105
|
|
|
111
106
|
## Configure your Cursor SDK API key
|
|
112
107
|
|
|
@@ -684,6 +679,10 @@ Cursor native replay is a display enhancement for TUI sessions and structured JS
|
|
|
684
679
|
|
|
685
680
|
## Development
|
|
686
681
|
|
|
682
|
+
The source qualification baseline is official Pi **0.86.1**, with optional wildcard host peers and Cursor SDK **1.0.27** unchanged. Run `npm ci --ignore-scripts` then `npm run check:compat` in an empty HOME/agent profile for credential-free qualification. This builds the manifest entry, runs existing type/unit/package checks, and tests the compiled provider's registration plus production prompt shaping against native Pi transcript/tool transitions. Replay arguments must match their JSON stream deltas. Tests use no live Cursor service.
|
|
683
|
+
|
|
684
|
+
This is not Cursor authentication, desktop/cloud execution, visual, or all-platform release proof. The existing `smoke:platform:all` and applicable `smoke:cloud` gates remain required; a passing compatibility check does not make a runtime change release-ready. Older advertised Pi/Node floors require their own matrix evidence.
|
|
685
|
+
|
|
687
686
|
Run checks:
|
|
688
687
|
|
|
689
688
|
```bash
|
|
@@ -719,7 +718,7 @@ npm install
|
|
|
719
718
|
CURSOR_API_KEY="your-key" pi --approve -e . --model cursor/grok-4.6
|
|
720
719
|
```
|
|
721
720
|
|
|
722
|
-
After editing `src/`, run `npm run build`
|
|
721
|
+
After editing `src/`, run `npm run build` before the next `pi -e .` run, or pi loads the previous build.
|
|
723
722
|
|
|
724
723
|
Maintainer design notes live in [`docs/cursor-model-ux-spec.md`](docs/cursor-model-ux-spec.md).
|
|
725
724
|
|
|
@@ -9,7 +9,6 @@ import { trimCurrentTurnAlreadyEmittedCursorText } from "./cursor-run-final-text
|
|
|
9
9
|
import { formatCursorSdkAbortMessage, resolveCursorSdkAbortCause } from "./cursor-provider-errors.js";
|
|
10
10
|
import { formatInactiveCursorReplayTrace } from "./cursor-native-replay-trace.js";
|
|
11
11
|
import { partitionNativeToolsByActiveContext } from "./cursor-native-replay-routing.js";
|
|
12
|
-
import { isJsonObject } from "./cursor-record-utils.js";
|
|
13
12
|
export const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
|
|
14
13
|
const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
|
|
15
14
|
let cursorNativeReplayIdleDisposeMs = DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS;
|
|
@@ -129,17 +128,17 @@ function emitCursorLiveQueuedEvent(turn, event, run) {
|
|
|
129
128
|
function emitCursorNativeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, tools, debugRecorder) {
|
|
130
129
|
const shouldTerminate = run.done && !run.finalText?.trim() && !cursorLiveRuns.peekEvent(run);
|
|
131
130
|
for (const tool of tools) {
|
|
132
|
-
if (!isJsonObject(tool.args))
|
|
133
|
-
throw new Error("Cursor replay tool arguments must be a JSON object");
|
|
134
131
|
const contentIndex = partial.content.length;
|
|
132
|
+
// Pi persists JSON arguments: keep the completed call identical to its wire delta.
|
|
133
|
+
const serializedArgs = JSON.stringify(tool.args);
|
|
135
134
|
partial.content.push({
|
|
136
135
|
type: "toolCall",
|
|
137
136
|
id: tool.id,
|
|
138
137
|
name: tool.toolName,
|
|
139
|
-
arguments:
|
|
138
|
+
arguments: JSON.parse(serializedArgs),
|
|
140
139
|
});
|
|
141
140
|
stream.push({ type: "toolcall_start", contentIndex, partial });
|
|
142
|
-
stream.push({ type: "toolcall_delta", contentIndex, delta:
|
|
141
|
+
stream.push({ type: "toolcall_delta", contentIndex, delta: serializedArgs, partial });
|
|
143
142
|
const block = partial.content[contentIndex];
|
|
144
143
|
if (block.type === "toolCall")
|
|
145
144
|
stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
|
|
@@ -175,17 +174,16 @@ function emitInactiveCursorReplayTrace(turn, tools, debugRecorder) {
|
|
|
175
174
|
}
|
|
176
175
|
function emitCursorBridgeToolUseTurn(stream, partial, model, context, run, toolResultInputTokens, requests) {
|
|
177
176
|
for (const request of requests) {
|
|
178
|
-
if (!isJsonObject(request.args))
|
|
179
|
-
throw new Error("Cursor bridge tool arguments must be a JSON object");
|
|
180
177
|
const contentIndex = partial.content.length;
|
|
178
|
+
const serializedArgs = JSON.stringify(request.args);
|
|
181
179
|
partial.content.push({
|
|
182
180
|
type: "toolCall",
|
|
183
181
|
id: request.piToolCallId,
|
|
184
182
|
name: request.piToolName,
|
|
185
|
-
arguments:
|
|
183
|
+
arguments: JSON.parse(serializedArgs),
|
|
186
184
|
});
|
|
187
185
|
stream.push({ type: "toolcall_start", contentIndex, partial });
|
|
188
|
-
stream.push({ type: "toolcall_delta", contentIndex, delta:
|
|
186
|
+
stream.push({ type: "toolcall_delta", contentIndex, delta: serializedArgs, partial });
|
|
189
187
|
const block = partial.content[contentIndex];
|
|
190
188
|
if (block.type === "toolCall")
|
|
191
189
|
stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
|
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
/** Narrow untyped SDK/MCP arguments at the Pi transcript boundary without rewriting them. */
|
|
2
|
-
export function isJsonObject(value) {
|
|
3
|
-
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
4
|
-
&& isJsonValue(value, new Set());
|
|
5
|
-
}
|
|
6
|
-
function isJsonValue(value, ancestors) {
|
|
7
|
-
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
8
|
-
return true;
|
|
9
|
-
if (typeof value === "number")
|
|
10
|
-
return Number.isFinite(value);
|
|
11
|
-
if (typeof value !== "object" || ancestors.has(value))
|
|
12
|
-
return false;
|
|
13
|
-
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)
|
|
14
|
-
return false;
|
|
15
|
-
ancestors.add(value);
|
|
16
|
-
const valid = Object.values(value).every(entry => isJsonValue(entry, ancestors));
|
|
17
|
-
ancestors.delete(value);
|
|
18
|
-
return valid;
|
|
19
|
-
}
|
|
20
1
|
export function asRecord(value) {
|
|
21
2
|
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
22
3
|
}
|
|
@@ -4,7 +4,7 @@ Short maintainer checklist for **minimal-surface** validation after prompt, brid
|
|
|
4
4
|
|
|
5
5
|
## Minimal environment
|
|
6
6
|
|
|
7
|
-
- Build first after any `src/` edit: `npm run build` (
|
|
7
|
+
- Build first after any `src/` edit: `npm run build` (the pi manifest loads compiled `dist/`)
|
|
8
8
|
- Extension only: `pi --approve -e . --cursor-no-fast --model cursor/grok-4.6`
|
|
9
9
|
- Fresh session dir: `--session-dir /tmp/pi-cursor-dogfood-<id>`
|
|
10
10
|
- Baseline surface (no ambient Cursor MCP/rules):
|
|
@@ -8,7 +8,7 @@ Use this manual checklist during development and debugging of Cursor provider/ru
|
|
|
8
8
|
|
|
9
9
|
## Inner-loop rule
|
|
10
10
|
|
|
11
|
-
- Build first: `npm run build` after any `src/` edit
|
|
11
|
+
- Build first: `npm run build` after any `src/` edit — the pi manifest loads compiled `dist/`, so unbuilt runs validate stale code. (the cloud/steering/local-resume/provider-debug launchers rebuild automatically even when run directly with `node scripts/...`; `smoke:live`/`smoke:visual`/`smoke:isolated` build via their npm scripts; direct `pi -e .` invocations do not build.)
|
|
12
12
|
- Run from a clean working tree except for the intended branch diff.
|
|
13
13
|
- Use the local extension under test: `pi --approve -e . --cursor-no-fast --model cursor/grok-4.6`.
|
|
14
14
|
- Use a temporary `--session-dir` for every run.
|
|
@@ -54,22 +54,6 @@ If resync runs but `context.tools` is still stale (e.g. only `read` listed), the
|
|
|
54
54
|
|
|
55
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
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
|
-
|
|
73
57
|
## Auth: use `auth.json`, not only env
|
|
74
58
|
|
|
75
59
|
pi resolves Cursor auth in this order:
|
|
@@ -193,8 +177,6 @@ Session summaries can hide per-message usage bugs. When investigating token or c
|
|
|
193
177
|
|
|
194
178
|
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`.
|
|
195
179
|
|
|
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
|
-
|
|
198
180
|
### False-positive edge case (2026-05-23)
|
|
199
181
|
|
|
200
182
|
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`).
|
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. It verifies the pre-compaction SDK agent is not reused, the new handle records `compactionGeneration: 1`, and restart resumes the post-compaction agent.
|
|
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.9",
|
|
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",
|
|
@@ -120,6 +120,7 @@
|
|
|
120
120
|
"node": ">=22.19.0"
|
|
121
121
|
},
|
|
122
122
|
"scripts": {
|
|
123
|
+
"check:compat": "npm run build && npm run verify && node --test test/native-provider.test.mjs",
|
|
123
124
|
"build": "node ./scripts/build.mjs",
|
|
124
125
|
"prepare": "node ./scripts/prepare.mjs",
|
|
125
126
|
"typecheck": "npm run typecheck:src && npm run typecheck:tests && npm run typecheck:replay-compile",
|
|
@@ -175,9 +176,9 @@
|
|
|
175
176
|
"typebox": "*"
|
|
176
177
|
},
|
|
177
178
|
"devDependencies": {
|
|
178
|
-
"@earendil-works/pi-ai": "0.
|
|
179
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
180
|
-
"@earendil-works/pi-tui": "0.
|
|
179
|
+
"@earendil-works/pi-ai": "0.86.1",
|
|
180
|
+
"@earendil-works/pi-coding-agent": "0.86.1",
|
|
181
|
+
"@earendil-works/pi-tui": "0.86.1",
|
|
181
182
|
"@xterm/xterm": "^6.0.0",
|
|
182
183
|
"node-pty": "^1.1.0",
|
|
183
184
|
"playwright": "^1.60.0",
|
|
@@ -187,7 +188,7 @@
|
|
|
187
188
|
},
|
|
188
189
|
"pi": {
|
|
189
190
|
"extensions": [
|
|
190
|
-
"./
|
|
191
|
+
"./dist/index.js"
|
|
191
192
|
]
|
|
192
193
|
},
|
|
193
194
|
"allowScripts": {
|
package/scripts/build.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Purpose: Produce compiled runtime files
|
|
4
|
-
* Pi's manifest loads the shipped source graph through its native TypeScript loader.
|
|
3
|
+
* Purpose: Produce the compiled runtime files that the Pi extension manifest loads.
|
|
5
4
|
* Responsibilities: Run TypeScript emit into a staging directory, then atomically swap it
|
|
6
5
|
* into dist/ so a failed TypeScript emit never destroys a previously working dist.
|
|
7
6
|
* 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
|
-
|
|
310
|
+
function readMetadataSince(artifactDir, seenPaths) {
|
|
311
311
|
return metadataFiles(artifactDir)
|
|
312
312
|
.filter((metadataPath) => !seenPaths.has(metadataPath))
|
|
313
313
|
.map((metadataPath) => ({
|
|
@@ -383,6 +383,11 @@ 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
|
+
|
|
386
391
|
function entryText(entry) {
|
|
387
392
|
const content = entry?.message?.content;
|
|
388
393
|
if (typeof content === "string") return content;
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
assistantEntryContaining,
|
|
9
9
|
buildLocalResumeSmokeEnv,
|
|
10
10
|
cleanupArtifactRoot,
|
|
11
|
+
compactionEntryCount,
|
|
11
12
|
createRunContext,
|
|
12
13
|
fail,
|
|
13
14
|
getEntries,
|
|
@@ -16,7 +17,6 @@ import {
|
|
|
16
17
|
parseTimeout,
|
|
17
18
|
promptAbortAndRead,
|
|
18
19
|
promptAndRead,
|
|
19
|
-
readMetadataSince,
|
|
20
20
|
reportFailure,
|
|
21
21
|
resumeEntries,
|
|
22
22
|
scrubSmokeText,
|
|
@@ -514,33 +514,10 @@ 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");
|
|
519
517
|
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));
|
|
520
519
|
const compactedEntries = await getEntries(rpc);
|
|
521
|
-
|
|
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
|
-
}
|
|
520
|
+
if (compactionEntryCount(compactedEntries) < 1) fail("manual compaction did not append a compaction entry");
|
|
544
521
|
|
|
545
522
|
const postCompaction = await promptAndRead({
|
|
546
523
|
rpc,
|
|
@@ -550,19 +527,6 @@ async function runCompactionSmoke() {
|
|
|
550
527
|
seenMetadata,
|
|
551
528
|
});
|
|
552
529
|
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);
|
|
566
530
|
if (!postCompaction.text.includes(`MARKER=${marker}`)) fail("post-compaction turn did not recall marker", JSON.stringify({ expected: `MARKER=${marker}`, actual: postCompaction.text }, null, 2));
|
|
567
531
|
postCompactionAgentId = postCompaction.metadata.run.agentId;
|
|
568
532
|
const postHandle = latestResumeEntry(await getEntries(rpc));
|
|
@@ -580,14 +544,11 @@ async function runCompactionSmoke() {
|
|
|
580
544
|
});
|
|
581
545
|
assertTurnMetadata("post-compaction restart", restart, { resumedAgent: true });
|
|
582
546
|
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));
|
|
586
547
|
});
|
|
587
548
|
console.log("local-resume-compaction-smoke-ok");
|
|
588
549
|
console.error(scrubSmokeText(`[local-resume-smoke] pre-compaction ${preCompactionAgentId} replaced by and resumed post-compaction ${postCompactionAgentId}`));
|
|
589
550
|
} finally {
|
|
590
|
-
|
|
551
|
+
cleanupArtifactRoot(artifactRoot);
|
|
591
552
|
}
|
|
592
553
|
}
|
|
593
554
|
|
|
@@ -71,6 +71,60 @@ export function redactSecrets(text) {
|
|
|
71
71
|
return redacted;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** JSON syntax is not secret material. Scan decoded scalar values with the same
|
|
75
|
+
* canonical rules, retaining key/value context for auth fields. Never regex-scrub
|
|
76
|
+
* a serialized document: escaped quotes, numeric tokens and scoped names break. */
|
|
77
|
+
export function scanArtifactSecrets(path, content) {
|
|
78
|
+
if (!/\.jsonl?$/i.test(path) || isBinaryArtifactContent(content)) return scanForSecrets(content);
|
|
79
|
+
const text = String(content);
|
|
80
|
+
const documents = /\.jsonl$/i.test(path) ? text.split(/\r?\n/).filter((line) => line.trim()) : [text];
|
|
81
|
+
try {
|
|
82
|
+
for (const document of documents) JSON.parse(document);
|
|
83
|
+
} catch {
|
|
84
|
+
// Malformed evidence is never repaired. Scan its bytes and let the
|
|
85
|
+
// structural gate report it independently (without echoing input).
|
|
86
|
+
return scanForSecrets(content);
|
|
87
|
+
}
|
|
88
|
+
const violations = new Set();
|
|
89
|
+
const scan = (value) => scanForSecrets(value).forEach((violation) => violations.add(violation));
|
|
90
|
+
for (const document of documents) {
|
|
91
|
+
// Native JSON validation above owns grammar. Scan the original tokens, not
|
|
92
|
+
// the parsed object: duplicate members (including entire subtrees) survive
|
|
93
|
+
// in transported bytes even though JSON.parse overwrites them.
|
|
94
|
+
let key;
|
|
95
|
+
for (const match of document.matchAll(/("(?:[^"\\]|\\.)*")(\s*:)?|[{}\[\],]|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null/g)) {
|
|
96
|
+
const token = match[1] ?? match[0];
|
|
97
|
+
if ("{}[],".includes(token)) {
|
|
98
|
+
key = undefined;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
// Decode string escapes, but retain number spelling (no rounding/overflow).
|
|
102
|
+
const value = match[1] ? JSON.parse(token) : token;
|
|
103
|
+
scan(value);
|
|
104
|
+
if (match[2]) key = value;
|
|
105
|
+
else {
|
|
106
|
+
// A space after ':' keeps syntax from looking like SCP credentials.
|
|
107
|
+
if (key !== undefined) scan(`${JSON.stringify(key)}: ${match[1] ? JSON.stringify(value) : token}`);
|
|
108
|
+
key = undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return [...violations];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function structuredArtifactViolations(path, content) {
|
|
116
|
+
if (!/\.jsonl?$/i.test(path)) return [];
|
|
117
|
+
const lines = /\.jsonl$/i.test(path) ? String(content).split(/\r?\n/) : [String(content)];
|
|
118
|
+
const violations = [];
|
|
119
|
+
for (const [index, line] of lines.entries()) {
|
|
120
|
+
if (/\.jsonl$/i.test(path) && !line.trim()) continue;
|
|
121
|
+
try { JSON.parse(line); } catch {
|
|
122
|
+
violations.push(/\.jsonl$/i.test(path) ? `invalid JSONL at line ${index + 1}` : "invalid JSON");
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return violations;
|
|
126
|
+
}
|
|
127
|
+
|
|
74
128
|
export function isBinaryArtifactContent(value) {
|
|
75
129
|
return Buffer.isBuffer(value) && (!isUtf8(value) || value.includes(0));
|
|
76
130
|
}
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
boundedFileSnapshot, openRegularFileNoFollow, walkArtifactTree,
|
|
19
19
|
writeBundleSpillFile, writeExtractedFiles,
|
|
20
20
|
} from "./artifact-fs-safety.mjs";
|
|
21
|
-
import { isBinaryArtifactContent, redactSecrets, scanForSecrets } from "./artifact-secrets.mjs";
|
|
21
|
+
import { isBinaryArtifactContent, redactSecrets, scanArtifactSecrets, scanForSecrets, structuredArtifactViolations } from "./artifact-secrets.mjs";
|
|
22
22
|
|
|
23
23
|
export {
|
|
24
24
|
MAX_BUNDLE_AGGREGATE_BYTES, MAX_BUNDLE_FILE_BYTES, MAX_BUNDLE_FILE_COUNT,
|
|
@@ -182,7 +182,7 @@ export function scanArtifacts(dir) {
|
|
|
182
182
|
failures.push({ file, violation: `artifact scan ${snapshot.reason}` });
|
|
183
183
|
return;
|
|
184
184
|
}
|
|
185
|
-
for (const violation of
|
|
185
|
+
for (const violation of [...scanArtifactSecrets(path, snapshot.content), ...structuredArtifactViolations(path, snapshot.content)]) findings.push({ file, violation });
|
|
186
186
|
},
|
|
187
187
|
});
|
|
188
188
|
return failures.length > 0 ? failures : findings;
|
|
@@ -265,7 +265,7 @@ export function buildPlatformArtifactBundle(root, pathPrefix = "") {
|
|
|
265
265
|
return;
|
|
266
266
|
}
|
|
267
267
|
const { content } = snapshot;
|
|
268
|
-
for (const violation of
|
|
268
|
+
for (const violation of scanArtifactSecrets(path, content)) findings.push({ file, violation });
|
|
269
269
|
if (pathViolations.length > 0 || !shouldTransportBundleFile(canonicalRoot, path, pathPrefix)) return;
|
|
270
270
|
if (isBinaryArtifactContent(content)) {
|
|
271
271
|
limitReasons.push("binary-content");
|
|
@@ -308,7 +308,7 @@ function validatePlatformArtifactBundle(bundle) {
|
|
|
308
308
|
aggregateBytes += file.size;
|
|
309
309
|
if (aggregateBytes > MAX_BUNDLE_AGGREGATE_BYTES) return undefined;
|
|
310
310
|
const content = decodeCanonicalBase64(file.contentBase64, file.size);
|
|
311
|
-
if (!content || content.length !== file.size || isBinaryArtifactContent(content) ||
|
|
311
|
+
if (!content || content.length !== file.size || isBinaryArtifactContent(content) || scanArtifactSecrets(file.path, content).length > 0) return undefined;
|
|
312
312
|
files.push({ path: file.path, content });
|
|
313
313
|
}
|
|
314
314
|
for (const path of paths) {
|
|
@@ -406,9 +406,12 @@ export function extractPlatformArtifactBundle(outputDir, stdout) {
|
|
|
406
406
|
|
|
407
407
|
const files = [];
|
|
408
408
|
const violations = [];
|
|
409
|
+
let structuredValid = true;
|
|
409
410
|
for (const file of decodedFiles) {
|
|
410
411
|
const text = file.content.toString("utf8");
|
|
411
|
-
|
|
412
|
+
const malformed = structuredArtifactViolations(file.path, file.content);
|
|
413
|
+
structuredValid &&= malformed.length === 0;
|
|
414
|
+
violations.push(...malformed.map((violation) => ({ file: file.path, violation })));
|
|
412
415
|
if (file.path.endsWith("redaction-violations.json")) {
|
|
413
416
|
try {
|
|
414
417
|
const parsed = JSON.parse(text);
|
|
@@ -417,11 +420,13 @@ export function extractPlatformArtifactBundle(outputDir, stdout) {
|
|
|
417
420
|
})));
|
|
418
421
|
} catch {}
|
|
419
422
|
}
|
|
420
|
-
|
|
423
|
+
// validatePlatformArtifactBundle already secret-scanned these bytes.
|
|
424
|
+
// Keep even malformed evidence verbatim for diagnosis; never repair it.
|
|
425
|
+
files.push({ path: file.path, content: /\.jsonl?$/i.test(file.path) ? file.content : Buffer.from(redactSecrets(text)) });
|
|
421
426
|
}
|
|
422
427
|
|
|
423
428
|
const succeeded = writeExtractedFiles(outputDir, files);
|
|
424
|
-
return succeeded ? { ok:
|
|
429
|
+
return succeeded ? { ok: structuredValid, violations } : failed;
|
|
425
430
|
}
|
|
426
431
|
|
|
427
432
|
function collectFiles(root) {
|
|
@@ -439,7 +439,9 @@ function writeJsonlArtifacts(artifactDir, sessionDir) {
|
|
|
439
439
|
const jsonlFiles = findJsonlFiles(sessionDir);
|
|
440
440
|
writeRedactedTextFile(join(artifactDir, "session-jsonl-files.txt"), jsonlFiles.join("\n") + (jsonlFiles.length ? "\n" : ""));
|
|
441
441
|
if (jsonlFiles[0]) {
|
|
442
|
-
|
|
442
|
+
// The bundle boundary secret-scans structured evidence before transport.
|
|
443
|
+
// Text redaction here corrupts JSON escaping and numeric fields.
|
|
444
|
+
copyFileSync(jsonlFiles[0], join(artifactDir, "session.jsonl"));
|
|
443
445
|
}
|
|
444
446
|
return jsonlFiles;
|
|
445
447
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "./artifact-bundle-contract.mjs";
|
|
12
12
|
import { redactSecrets } from "./artifact-secrets.mjs";
|
|
13
13
|
import {
|
|
14
|
+
scanArtifacts,
|
|
14
15
|
writeCommand,
|
|
15
16
|
writeExitCode,
|
|
16
17
|
writeManifest,
|
|
@@ -29,6 +30,12 @@ export function finalizeSuiteArtifacts(
|
|
|
29
30
|
summaryData,
|
|
30
31
|
expectedFiles,
|
|
31
32
|
) {
|
|
33
|
+
const malformed = scanArtifacts(suiteDir).filter(({ violation }) => violation.startsWith("invalid JSON"));
|
|
34
|
+
checks = [...checks, {
|
|
35
|
+
id: "structured-artifacts-parseable",
|
|
36
|
+
fn: () => malformed.length === 0,
|
|
37
|
+
error: malformed.map(({ file, violation }) => `${file}: ${violation}`).join("; "),
|
|
38
|
+
}];
|
|
32
39
|
const assertions = runAssertions(suiteDir, checks);
|
|
33
40
|
writeSummary(suiteDir, { ...summaryData, ok: assertions.ok });
|
|
34
41
|
const expected = assertions.ok
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { scanArtifactSecrets } from "./artifact-secrets.mjs";
|
|
9
10
|
import { resolve } from "node:path";
|
|
10
11
|
|
|
11
12
|
import {
|
|
@@ -604,7 +605,8 @@ async function executeLiveSuite(config, targetName, suiteName, suiteDir, slug, l
|
|
|
604
605
|
},
|
|
605
606
|
}));
|
|
606
607
|
const violations = [
|
|
607
|
-
...scanForSecrets(result.stdout + result.stderr + terminalText
|
|
608
|
+
...scanForSecrets(result.stdout + result.stderr + terminalText).map((violation) => ({ file: "process-output", violation })),
|
|
609
|
+
...scanArtifactSecrets(jsonlPath, jsonlRaw).map((violation) => ({ file: "artifacts/session.jsonl", violation })),
|
|
608
610
|
...bundle.violations,
|
|
609
611
|
...scanArtifacts(suiteDir),
|
|
610
612
|
];
|
|
@@ -28,7 +28,6 @@ import { formatCursorSdkAbortMessage, resolveCursorSdkAbortCause } from "./curso
|
|
|
28
28
|
import { formatInactiveCursorReplayTrace } from "./cursor-native-replay-trace.js";
|
|
29
29
|
import { partitionNativeToolsByActiveContext } from "./cursor-native-replay-routing.js";
|
|
30
30
|
import type { CursorSdkEventDebugRecorder } from "./cursor-sdk-event-debug.js";
|
|
31
|
-
import { isJsonObject } from "./cursor-record-utils.js";
|
|
32
31
|
|
|
33
32
|
export const DEFAULT_CURSOR_NATIVE_REPLAY_IDLE_DISPOSE_MS = 5 * 60 * 1000;
|
|
34
33
|
const CURSOR_NATIVE_REPLAY_TOOL_ID_PATTERN = /^(cursor-replay-\d+-\d+)-tool-\d+$/;
|
|
@@ -181,16 +180,17 @@ function emitCursorNativeToolUseTurn(
|
|
|
181
180
|
): void {
|
|
182
181
|
const shouldTerminate = run.done && !run.finalText?.trim() && !cursorLiveRuns.peekEvent(run);
|
|
183
182
|
for (const tool of tools) {
|
|
184
|
-
if (!isJsonObject(tool.args)) throw new Error("Cursor replay tool arguments must be a JSON object");
|
|
185
183
|
const contentIndex = partial.content.length;
|
|
184
|
+
// Pi persists JSON arguments: keep the completed call identical to its wire delta.
|
|
185
|
+
const serializedArgs = JSON.stringify(tool.args);
|
|
186
186
|
partial.content.push({
|
|
187
187
|
type: "toolCall",
|
|
188
188
|
id: tool.id,
|
|
189
189
|
name: tool.toolName,
|
|
190
|
-
arguments:
|
|
190
|
+
arguments: JSON.parse(serializedArgs),
|
|
191
191
|
});
|
|
192
192
|
stream.push({ type: "toolcall_start", contentIndex, partial });
|
|
193
|
-
stream.push({ type: "toolcall_delta", contentIndex, delta:
|
|
193
|
+
stream.push({ type: "toolcall_delta", contentIndex, delta: serializedArgs, partial });
|
|
194
194
|
const block = partial.content[contentIndex];
|
|
195
195
|
if (block.type === "toolCall") stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
|
|
196
196
|
if (recordCursorNativeToolDisplay({ ...tool, terminate: shouldTerminate })) {
|
|
@@ -238,16 +238,16 @@ function emitCursorBridgeToolUseTurn(
|
|
|
238
238
|
requests: CursorPiBridgeToolRequest[],
|
|
239
239
|
): void {
|
|
240
240
|
for (const request of requests) {
|
|
241
|
-
if (!isJsonObject(request.args)) throw new Error("Cursor bridge tool arguments must be a JSON object");
|
|
242
241
|
const contentIndex = partial.content.length;
|
|
242
|
+
const serializedArgs = JSON.stringify(request.args);
|
|
243
243
|
partial.content.push({
|
|
244
244
|
type: "toolCall",
|
|
245
245
|
id: request.piToolCallId,
|
|
246
246
|
name: request.piToolName,
|
|
247
|
-
arguments:
|
|
247
|
+
arguments: JSON.parse(serializedArgs),
|
|
248
248
|
});
|
|
249
249
|
stream.push({ type: "toolcall_start", contentIndex, partial });
|
|
250
|
-
stream.push({ type: "toolcall_delta", contentIndex, delta:
|
|
250
|
+
stream.push({ type: "toolcall_delta", contentIndex, delta: serializedArgs, partial });
|
|
251
251
|
const block = partial.content[contentIndex];
|
|
252
252
|
if (block.type === "toolCall") stream.push({ type: "toolcall_end", contentIndex, toolCall: block, partial });
|
|
253
253
|
}
|
|
@@ -1,22 +1,3 @@
|
|
|
1
|
-
import type { JsonValue } from "@earendil-works/pi-ai";
|
|
2
|
-
|
|
3
|
-
/** Narrow untyped SDK/MCP arguments at the Pi transcript boundary without rewriting them. */
|
|
4
|
-
export function isJsonObject(value: unknown): value is Record<string, JsonValue> {
|
|
5
|
-
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
6
|
-
&& isJsonValue(value, new Set());
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
function isJsonValue(value: unknown, ancestors: Set<object>): boolean {
|
|
10
|
-
if (value === null || typeof value === "string" || typeof value === "boolean") return true;
|
|
11
|
-
if (typeof value === "number") return Number.isFinite(value);
|
|
12
|
-
if (typeof value !== "object" || ancestors.has(value)) return false;
|
|
13
|
-
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
|
|
14
|
-
ancestors.add(value);
|
|
15
|
-
const valid = Object.values(value).every(entry => isJsonValue(entry, ancestors));
|
|
16
|
-
ancestors.delete(value);
|
|
17
|
-
return valid;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
1
|
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
21
2
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined;
|
|
22
3
|
}
|