@nijaru/pi-provider-compaction 0.0.6 → 0.0.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/README.md +2 -2
- package/dist/index.js +84 -30
- package/index.ts +40 -14
- package/package.json +4 -4
- package/protocol.ts +24 -3
- package/provider.ts +5 -0
- package/replay.ts +22 -5
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-provider-compaction
|
|
2
2
|
|
|
3
|
-
Experimental provider-native compaction for [Pi](https://github.com/earendil-works/pi), with a meaningful portable summary saved alongside each native checkpoint. Minimum runtime: Pi 0.87.0. Development checks target Pi 1.
|
|
3
|
+
Experimental provider-native compaction for [Pi](https://github.com/earendil-works/pi), with a meaningful portable summary saved alongside each native checkpoint. Minimum runtime: Pi 0.87.0. Development checks target Pi 1.3.0.
|
|
4
4
|
|
|
5
5
|
**This experimental design is not production-validated. Do not enable it globally yet.** Real runner/adapter fixtures and a Codex live smoke test pass; broader backend and deployment validation remains incomplete.
|
|
6
6
|
|
|
@@ -73,7 +73,7 @@ pi --extension ./dist/index.js
|
|
|
73
73
|
|
|
74
74
|
The manifest loads the built `dist/index.js`, including a bundled stateless Responses serializer validator; source or host dependency changes require a rebuild. `bun run check` rebuilds first and tests the artifact against Pi's actual host serializer, including function-call history replayed as grammar tools. Keep global installation disabled while testing. Publishing and global activation are separate approval steps.
|
|
75
75
|
|
|
76
|
-
Tests include real Pi 1.
|
|
76
|
+
Tests include real Pi 1.3.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images, prompt updates, cross-model thinking and mid-conversation effort changes. Set `PI_COMPACTOR_FIXTURE_PATH` to a `pi-compactor` entrypoint to check SDK flag precedence in both extension orders. The optional installed-fast-mode fixture (`PI_FAST_MODE_FIXTURE_PATH`) checks actual `pi-fast-mode` composition across `/reload`; it creates an isolated fast-mode configuration.
|
|
77
77
|
|
|
78
78
|
Authenticated protocol smoke tests are separate and send three small requests:
|
|
79
79
|
|
package/dist/index.js
CHANGED
|
@@ -200,6 +200,8 @@ function installObserver(pi, ctx, observe) {
|
|
|
200
200
|
const id = model.provider;
|
|
201
201
|
const native = registry.getRegisteredNativeProvider(id);
|
|
202
202
|
if (native) {
|
|
203
|
+
if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api))
|
|
204
|
+
return;
|
|
203
205
|
const isInstalled = () => registry.getRegisteredNativeProvider(id) === wrapper;
|
|
204
206
|
const guarded = (...args) => {
|
|
205
207
|
if (!isInstalled())
|
|
@@ -637,19 +639,13 @@ function transformMessages(messages, model, normalizeToolCallId) {
|
|
|
637
639
|
const isSameModel = assistantMsg.provider === model.provider && assistantMsg.api === model.api && assistantMsg.model === model.id;
|
|
638
640
|
const transformedContent = assistantMsg.content.flatMap((block) => {
|
|
639
641
|
if (block.type === "thinking") {
|
|
640
|
-
if (
|
|
641
|
-
return
|
|
642
|
-
|
|
643
|
-
if (isSameModel && block.thinkingSignature)
|
|
642
|
+
if (!isSameModel)
|
|
643
|
+
return [];
|
|
644
|
+
if (block.redacted || block.thinkingSignature)
|
|
644
645
|
return block;
|
|
645
646
|
if (!block.thinking || block.thinking.trim() === "")
|
|
646
647
|
return [];
|
|
647
|
-
|
|
648
|
-
return block;
|
|
649
|
-
return {
|
|
650
|
-
type: "text",
|
|
651
|
-
text: block.thinking
|
|
652
|
-
};
|
|
648
|
+
return block;
|
|
653
649
|
}
|
|
654
650
|
if (block.type === "text") {
|
|
655
651
|
if (isSameModel)
|
|
@@ -840,6 +836,12 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
|
|
|
840
836
|
});
|
|
841
837
|
};
|
|
842
838
|
const includeInitialSystemMessage = options?.includeSystemPrompt ?? true;
|
|
839
|
+
const pushConfigurationUpdate = (effort) => {
|
|
840
|
+
messages.push({
|
|
841
|
+
type: "configuration_update",
|
|
842
|
+
reasoning: { effort }
|
|
843
|
+
});
|
|
844
|
+
};
|
|
843
845
|
const compat = model.compat;
|
|
844
846
|
const instructionRole = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
|
|
845
847
|
let msgIndex = 0;
|
|
@@ -946,6 +948,9 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
|
|
|
946
948
|
}
|
|
947
949
|
if (output.length === 0)
|
|
948
950
|
continue;
|
|
951
|
+
if (options?.configurationUpdateEffort !== undefined && isSameModel && msg.providerThinkingLevel !== undefined) {
|
|
952
|
+
pushConfigurationUpdate(msg.providerThinkingLevel);
|
|
953
|
+
}
|
|
949
954
|
messages.push(...output);
|
|
950
955
|
} else if (msg.role === "toolResult") {
|
|
951
956
|
const [callId] = msg.toolCallId.split("|");
|
|
@@ -967,6 +972,8 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
|
|
|
967
972
|
if (!isLeadingSystemMessage)
|
|
968
973
|
msgIndex++;
|
|
969
974
|
}
|
|
975
|
+
if (options?.configurationUpdateEffort !== undefined)
|
|
976
|
+
pushConfigurationUpdate(options.configurationUpdateEffort);
|
|
970
977
|
return messages;
|
|
971
978
|
}
|
|
972
979
|
function convertResponsesTools(tools, options) {
|
|
@@ -1223,6 +1230,15 @@ async function collectProviderUsage(stream, signal) {
|
|
|
1223
1230
|
throw new Error("Native compaction provider stream ended without usage");
|
|
1224
1231
|
return usage;
|
|
1225
1232
|
}
|
|
1233
|
+
|
|
1234
|
+
class NativeCompactionValidationError extends Error {
|
|
1235
|
+
usage;
|
|
1236
|
+
constructor(message, usage) {
|
|
1237
|
+
super(message);
|
|
1238
|
+
this.usage = usage;
|
|
1239
|
+
this.name = "NativeCompactionValidationError";
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1226
1242
|
async function requestResponsesCompact(request) {
|
|
1227
1243
|
let preparedPayload;
|
|
1228
1244
|
let compacted;
|
|
@@ -1249,9 +1265,13 @@ async function requestResponsesCompact(request) {
|
|
|
1249
1265
|
if (!response.ok)
|
|
1250
1266
|
return response;
|
|
1251
1267
|
const parsed = await readJsonBounded(response, request.signal);
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1268
|
+
try {
|
|
1269
|
+
if (!isObject(parsed))
|
|
1270
|
+
throw new Error("Responses Compact returned a non-object response");
|
|
1271
|
+
compacted = { response: parsed, output: validateCompactedOutput(parsed) };
|
|
1272
|
+
} catch (error) {
|
|
1273
|
+
throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error));
|
|
1274
|
+
}
|
|
1255
1275
|
return syntheticCompletion(parsed, preparedPayload);
|
|
1256
1276
|
} catch (error) {
|
|
1257
1277
|
bridgeError = error;
|
|
@@ -1263,6 +1283,7 @@ async function requestResponsesCompact(request) {
|
|
|
1263
1283
|
headers: request.headers,
|
|
1264
1284
|
env: request.env,
|
|
1265
1285
|
signal: request.signal,
|
|
1286
|
+
...request.serviceTier === undefined ? {} : { serviceTier: request.serviceTier },
|
|
1266
1287
|
transport: "sse",
|
|
1267
1288
|
cacheRetention: "none",
|
|
1268
1289
|
maxRetries: 0,
|
|
@@ -1281,6 +1302,8 @@ async function requestResponsesCompact(request) {
|
|
|
1281
1302
|
throw bridgeError;
|
|
1282
1303
|
throw error;
|
|
1283
1304
|
});
|
|
1305
|
+
if (bridgeError instanceof NativeCompactionValidationError)
|
|
1306
|
+
throw new NativeCompactionValidationError(bridgeError.message, usage);
|
|
1284
1307
|
if (bridgeError)
|
|
1285
1308
|
throw bridgeError;
|
|
1286
1309
|
if (!compacted || dispatches !== 1)
|
|
@@ -1425,6 +1448,7 @@ async function requestRemoteV2(request) {
|
|
|
1425
1448
|
headers: request.headers,
|
|
1426
1449
|
env: request.env,
|
|
1427
1450
|
signal: request.signal,
|
|
1451
|
+
...request.serviceTier === undefined ? {} : { serviceTier: request.serviceTier },
|
|
1428
1452
|
transport: "sse",
|
|
1429
1453
|
cacheRetention: "none",
|
|
1430
1454
|
maxRetries: 0,
|
|
@@ -1452,7 +1476,11 @@ async function requestRemoteV2(request) {
|
|
|
1452
1476
|
}
|
|
1453
1477
|
async function requestProviderCompaction(request) {
|
|
1454
1478
|
const result = await (request.protocol === "remote-v2" ? requestRemoteV2(request) : requestResponsesCompact(request));
|
|
1455
|
-
|
|
1479
|
+
try {
|
|
1480
|
+
return { ...result, output: validateCompactedOutput(result) };
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error), result.usage);
|
|
1483
|
+
}
|
|
1456
1484
|
}
|
|
1457
1485
|
|
|
1458
1486
|
// replay.ts
|
|
@@ -1463,7 +1491,7 @@ var SNAPSHOT_TTL_MS = 5 * 60000;
|
|
|
1463
1491
|
function equal(left, right) {
|
|
1464
1492
|
return JSON.stringify(left) === JSON.stringify(right);
|
|
1465
1493
|
}
|
|
1466
|
-
function serializeInput(model, context, declarations = context) {
|
|
1494
|
+
function serializeInput(model, context, declarations = context, configurationUpdateEffort) {
|
|
1467
1495
|
const compat = model.compat;
|
|
1468
1496
|
const flag = (name) => compat?.[name] === true;
|
|
1469
1497
|
const codex = model.api === "openai-codex-responses";
|
|
@@ -1471,6 +1499,7 @@ function serializeInput(model, context, declarations = context) {
|
|
|
1471
1499
|
const tools = resolveTranscript2(declarations, flag("supportsMidConvoSystemMessages"));
|
|
1472
1500
|
return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
|
|
1473
1501
|
includeSystemPrompt: !codex,
|
|
1502
|
+
configurationUpdateEffort,
|
|
1474
1503
|
grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
|
|
1475
1504
|
supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
|
|
1476
1505
|
supportsAdditionalTools: flag("supportsAdditionalTools"),
|
|
@@ -1495,12 +1524,15 @@ function capturePreparedRequest(model, context, canonical, payload, reject = ()
|
|
|
1495
1524
|
reject("snapshot exceeds size limit");
|
|
1496
1525
|
return;
|
|
1497
1526
|
}
|
|
1498
|
-
const
|
|
1527
|
+
const last = payload.input.at(-1);
|
|
1528
|
+
const compat = model.compat;
|
|
1529
|
+
const configurationUpdateEffort = model.api === "openai-responses" && compat?.supportsMidConvoEffort === true && last?.type === "configuration_update" && isObject(last.reasoning) && typeof last.reasoning.effort === "string" ? last.reasoning.effort : undefined;
|
|
1530
|
+
const expected = serializeInput(model, context, context, configurationUpdateEffort);
|
|
1499
1531
|
if (!equal(expected, payload.input)) {
|
|
1500
1532
|
reject("prepared input differs from serializer");
|
|
1501
1533
|
return;
|
|
1502
1534
|
}
|
|
1503
|
-
return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload), portableInput: expected, createdAt: Date.now() };
|
|
1535
|
+
return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload), portableInput: expected, configurationUpdateEffort, createdAt: Date.now() };
|
|
1504
1536
|
}
|
|
1505
1537
|
function promptCount(input) {
|
|
1506
1538
|
let count = 0;
|
|
@@ -1508,6 +1540,10 @@ function promptCount(input) {
|
|
|
1508
1540
|
count++;
|
|
1509
1541
|
return count;
|
|
1510
1542
|
}
|
|
1543
|
+
function serializePrefix(model, snapshot, prefix) {
|
|
1544
|
+
const input = serializeInput(model, prefix, snapshot.context, snapshot.configurationUpdateEffort);
|
|
1545
|
+
return snapshot.configurationUpdateEffort === undefined ? input : input.slice(0, -1);
|
|
1546
|
+
}
|
|
1511
1547
|
function closedTools(context) {
|
|
1512
1548
|
const pending = new Set;
|
|
1513
1549
|
for (const message of context.messages) {
|
|
@@ -1551,7 +1587,7 @@ function selectCoveredPrefix(model, snapshot, prefix, reject = () => {}) {
|
|
|
1551
1587
|
reject("projected prefix differs from request context");
|
|
1552
1588
|
return;
|
|
1553
1589
|
}
|
|
1554
|
-
const expected =
|
|
1590
|
+
const expected = serializePrefix(model, snapshot, mapped);
|
|
1555
1591
|
if (!equal(snapshot.portableInput.slice(0, expected.length), expected)) {
|
|
1556
1592
|
reject("serialized prefix does not match prepared input");
|
|
1557
1593
|
return;
|
|
@@ -1584,7 +1620,7 @@ function summarySlot(model, snapshot, summaryPrefix) {
|
|
|
1584
1620
|
const mapped = mappedPrefix(snapshot, summaryPrefix);
|
|
1585
1621
|
if (!mapped)
|
|
1586
1622
|
return;
|
|
1587
|
-
const prefix =
|
|
1623
|
+
const prefix = serializePrefix(model, snapshot, mapped);
|
|
1588
1624
|
const index = promptCount(prefix);
|
|
1589
1625
|
if (prefix.length !== index + 1 || !equal(snapshot.portableInput.slice(0, prefix.length), prefix))
|
|
1590
1626
|
return;
|
|
@@ -1763,7 +1799,8 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1763
1799
|
return;
|
|
1764
1800
|
}
|
|
1765
1801
|
if (!options.onPayload) {
|
|
1766
|
-
|
|
1802
|
+
if (captureStatus !== "eligible prepared request captured")
|
|
1803
|
+
captureRejected("no payload callback");
|
|
1767
1804
|
return;
|
|
1768
1805
|
}
|
|
1769
1806
|
if (generic(owner)) {
|
|
@@ -1782,7 +1819,7 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1782
1819
|
if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options))
|
|
1783
1820
|
return false;
|
|
1784
1821
|
try {
|
|
1785
|
-
return
|
|
1822
|
+
return unchanged(owner.sessionManager.getBranch(), branch.length, branchHash);
|
|
1786
1823
|
} catch {
|
|
1787
1824
|
retire();
|
|
1788
1825
|
return false;
|
|
@@ -1814,8 +1851,9 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1814
1851
|
const routeHash = route(model, options);
|
|
1815
1852
|
const policyHash = digest(requestPolicy(model, prepared));
|
|
1816
1853
|
const latest = latestCompaction(branch);
|
|
1854
|
+
const payloadModel = isObject(payload) && typeof payload.model === "string" ? payload.model : undefined;
|
|
1817
1855
|
let result = payload;
|
|
1818
|
-
if (active && latest?.id === active.entryId && latest.summary === active.summary &&
|
|
1856
|
+
if (active && latest?.id === active.entryId && latest.summary === active.summary && active.payloadModel === payloadModel && unchanged(branch, active.branchLength, active.branchHash) && active.routeHash === routeHash && active.policyHash === policyHash) {
|
|
1819
1857
|
const details = readNativeCompactionDetails(latest.details);
|
|
1820
1858
|
const projection = owner.sessionManager.buildSessionProjection();
|
|
1821
1859
|
const contribution = projection.entries.find((entry) => entry.sourceEntry.id === latest.id);
|
|
@@ -1838,7 +1876,7 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1838
1876
|
prepared,
|
|
1839
1877
|
model: structuredClone(model),
|
|
1840
1878
|
provider,
|
|
1841
|
-
options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch },
|
|
1879
|
+
options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch, ...typeof options.serviceTier === "string" ? { serviceTier: options.serviceTier } : {} },
|
|
1842
1880
|
branchLength: branch.length,
|
|
1843
1881
|
branchHash,
|
|
1844
1882
|
routeHash,
|
|
@@ -1892,6 +1930,7 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1892
1930
|
removeInvalidation();
|
|
1893
1931
|
});
|
|
1894
1932
|
pi.on("session_before_compact", async (event, context) => {
|
|
1933
|
+
compactionStatus = "preparing compaction";
|
|
1895
1934
|
const capture = snapshot;
|
|
1896
1935
|
snapshot = undefined;
|
|
1897
1936
|
pending = undefined;
|
|
@@ -1925,7 +1964,7 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1925
1964
|
const input = selectCoveredPrefix(capture.model, capture.prepared, prefix, fallback);
|
|
1926
1965
|
if (!input)
|
|
1927
1966
|
return;
|
|
1928
|
-
const current = () => ticket === generation && !event.signal.aborted && supportedRouteOptions(capture.model, capture.options) && !generic(context) && context.modelRegistry.getProvider(capture.model.provider) === capture.provider &&
|
|
1967
|
+
const current = () => ticket === generation && !event.signal.aborted && supportedRouteOptions(capture.model, capture.options) && !generic(context) && context.modelRegistry.getProvider(capture.model.provider) === capture.provider && unchanged(context.sessionManager.getBranch(), branch.length, branchHash);
|
|
1929
1968
|
const auth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
1930
1969
|
if (!current())
|
|
1931
1970
|
return { cancel: true };
|
|
@@ -1938,7 +1977,10 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1938
1977
|
try {
|
|
1939
1978
|
portable = await compact(event.preparation, capture.model, auth.apiKey, auth.headers, event.customInstructions, event.signal, context.thinkingLevel, capture.provider.streamSimple.bind(capture.provider), auth.env, undefined, undefined, capture.sessionId);
|
|
1940
1979
|
} catch {
|
|
1941
|
-
|
|
1980
|
+
if (!current())
|
|
1981
|
+
return { cancel: true };
|
|
1982
|
+
fallback("portable summary generation failed");
|
|
1983
|
+
return;
|
|
1942
1984
|
}
|
|
1943
1985
|
if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY)
|
|
1944
1986
|
return { cancel: true };
|
|
@@ -1962,7 +2004,8 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1962
2004
|
headers: capture.options.headers,
|
|
1963
2005
|
env: capture.options.env,
|
|
1964
2006
|
signal: event.signal,
|
|
1965
|
-
fetch: capture.options.fetch
|
|
2007
|
+
fetch: capture.options.fetch,
|
|
2008
|
+
...capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }
|
|
1966
2009
|
});
|
|
1967
2010
|
if (!current())
|
|
1968
2011
|
return { cancel: true };
|
|
@@ -1989,11 +2032,13 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
1989
2032
|
};
|
|
1990
2033
|
pending = { details, capture, summary: portable.summary };
|
|
1991
2034
|
return { compaction: { ...portable, usage: combineUsage(native.usage, portable.usage), details } };
|
|
1992
|
-
} catch {
|
|
2035
|
+
} catch (error) {
|
|
1993
2036
|
if (!current())
|
|
1994
2037
|
return { cancel: true };
|
|
1995
2038
|
fallback("native acquisition or validation failed");
|
|
1996
|
-
return {
|
|
2039
|
+
return {
|
|
2040
|
+
compaction: error instanceof NativeCompactionValidationError && error.usage !== undefined ? { ...portable, usage: combineUsage(error.usage, portable.usage) } : portable
|
|
2041
|
+
};
|
|
1997
2042
|
}
|
|
1998
2043
|
});
|
|
1999
2044
|
pi.on("session_compact", (event, context) => {
|
|
@@ -2011,9 +2056,18 @@ Last attempt: ${compactionStatus}`, "info");
|
|
|
2011
2056
|
return;
|
|
2012
2057
|
const branch = context.sessionManager.getBranch();
|
|
2013
2058
|
const coverage = candidate.details.coverage;
|
|
2014
|
-
|
|
2059
|
+
const index = branch.findIndex((entry) => entry.id === event.compactionEntry.id);
|
|
2060
|
+
if (!coverage || index < 0 || event.compactionEntry.firstKeptEntryId !== coverage.firstKeptEntryId || event.compactionEntry.parentId !== coverage.sourceLeafId || digest(branch.slice(0, index)) !== coverage.sourceHash)
|
|
2015
2061
|
return;
|
|
2016
|
-
active = {
|
|
2062
|
+
active = {
|
|
2063
|
+
entryId: event.compactionEntry.id,
|
|
2064
|
+
summary: candidate.summary,
|
|
2065
|
+
branchLength: index + 1,
|
|
2066
|
+
branchHash: digest(branch.slice(0, index + 1)),
|
|
2067
|
+
routeHash: candidate.capture.routeHash,
|
|
2068
|
+
policyHash: candidate.capture.policyHash,
|
|
2069
|
+
...typeof candidate.capture.prepared.payload.model === "string" ? { payloadModel: candidate.capture.prepared.payload.model } : {}
|
|
2070
|
+
};
|
|
2017
2071
|
});
|
|
2018
2072
|
pi.on("session_compact_failed", () => {
|
|
2019
2073
|
pending = undefined;
|
package/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { compact, convertToLlm, type ExtensionAPI, type ExtensionContext, type S
|
|
|
5
5
|
import { resolveCompactionModelPolicy } from "./policy";
|
|
6
6
|
import { installObserver, type InstalledObserver, type ObserveRequest } from "./provider";
|
|
7
7
|
import { capturePreparedRequest, equal, requestPolicy, selectCoveredPrefix, substituteSummary, summarySlot, type PreparedRequest } from "./replay";
|
|
8
|
-
import { isObject, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
|
|
8
|
+
import { isObject, NativeCompactionValidationError, requestProviderCompaction, resolveNativeProtocol, supportsResponsesApi, validateCompactedOutput, type NativeProtocol, type ResponseItem } from "./protocol";
|
|
9
9
|
|
|
10
10
|
export const NATIVE_COMPACTION_TYPE = "pi-provider-compaction/openai-responses";
|
|
11
11
|
export const NATIVE_COMPACTION_VERSION = 3;
|
|
@@ -124,7 +124,7 @@ interface Captured {
|
|
|
124
124
|
prepared: PreparedRequest;
|
|
125
125
|
model: Model<any>;
|
|
126
126
|
provider: Provider;
|
|
127
|
-
options: ProviderStreamOptions | SimpleStreamOptions;
|
|
127
|
+
options: (ProviderStreamOptions | SimpleStreamOptions) & { serviceTier?: string };
|
|
128
128
|
branchLength: number;
|
|
129
129
|
branchHash: string;
|
|
130
130
|
routeHash: string;
|
|
@@ -134,12 +134,13 @@ interface Captured {
|
|
|
134
134
|
}
|
|
135
135
|
interface Active {
|
|
136
136
|
entryId: string;
|
|
137
|
-
detailsHash: string;
|
|
138
137
|
summary: string;
|
|
139
138
|
branchLength: number;
|
|
140
139
|
branchHash: string;
|
|
141
140
|
routeHash: string;
|
|
142
141
|
policyHash: string;
|
|
142
|
+
/** Model field of the prepared payload; a request hook rewrite invalidates native state. */
|
|
143
|
+
payloadModel?: string;
|
|
143
144
|
}
|
|
144
145
|
|
|
145
146
|
export default function (pi: ExtensionAPI): void {
|
|
@@ -190,7 +191,12 @@ export default function (pi: ExtensionAPI): void {
|
|
|
190
191
|
}
|
|
191
192
|
if (options.sessionId !== manager.getSessionId()) return;
|
|
192
193
|
if (!supportsNativeCompaction(model)) { captureRejected("unsupported API"); return; }
|
|
193
|
-
if (!options.onPayload) {
|
|
194
|
+
if (!options.onPayload) {
|
|
195
|
+
// The portable-summary request re-enters this observer without a payload
|
|
196
|
+
// callback. Do not overwrite the main request's capture diagnostic.
|
|
197
|
+
if (captureStatus !== "eligible prepared request captured") captureRejected("no payload callback");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
194
200
|
if (generic(owner)) { captureRejected("generic compaction model takes precedence"); return; }
|
|
195
201
|
// The Azure adapter also accepts API-specific overrides and process-env
|
|
196
202
|
// fallbacks. Those must not silently select a route absent from our evidence.
|
|
@@ -201,7 +207,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
201
207
|
const canonical = transcript(manager.buildSessionProjection().messages);
|
|
202
208
|
const current = () => {
|
|
203
209
|
if (generation !== ticket || ctx !== owner || options.signal?.aborted || !supportedRouteOptions(model, options)) return false;
|
|
204
|
-
try { return
|
|
210
|
+
try { return unchanged(owner.sessionManager.getBranch(), branch.length, branchHash); }
|
|
205
211
|
catch { retire(); return false; }
|
|
206
212
|
};
|
|
207
213
|
return async (payload) => {
|
|
@@ -225,8 +231,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
225
231
|
const routeHash = route(model, options);
|
|
226
232
|
const policyHash = digest(requestPolicy(model, prepared));
|
|
227
233
|
const latest = latestCompaction(branch);
|
|
234
|
+
const payloadModel = isObject(payload) && typeof payload.model === "string" ? payload.model : undefined;
|
|
228
235
|
let result = payload;
|
|
229
|
-
if (active && latest?.id === active.entryId && latest.summary === active.summary &&
|
|
236
|
+
if (active && latest?.id === active.entryId && latest.summary === active.summary && active.payloadModel === payloadModel && unchanged(branch, active.branchLength, active.branchHash) && active.routeHash === routeHash && active.policyHash === policyHash) {
|
|
230
237
|
const details = readNativeCompactionDetails(latest.details);
|
|
231
238
|
const projection = owner.sessionManager.buildSessionProjection();
|
|
232
239
|
const contribution = projection.entries.find((entry) => entry.sourceEntry.id === latest.id);
|
|
@@ -244,7 +251,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
244
251
|
captureStatus = "eligible prepared request captured";
|
|
245
252
|
snapshot = {
|
|
246
253
|
prepared, model: structuredClone(model), provider,
|
|
247
|
-
options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch },
|
|
254
|
+
options: { apiKey: options.apiKey, headers: structuredClone(options.headers), env: structuredClone(options.env), fetch: options.fetch, ...(typeof (options as { serviceTier?: unknown }).serviceTier === "string" ? { serviceTier: (options as { serviceTier: string }).serviceTier } : {}) },
|
|
248
255
|
branchLength: branch.length, branchHash, routeHash, authHash: routeDigest(auth), policyHash,
|
|
249
256
|
sessionId: owner.sessionManager.getSessionId(),
|
|
250
257
|
};
|
|
@@ -281,6 +288,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
281
288
|
pi.on("session_shutdown", () => { if (typeof removeInvalidation === "function") removeInvalidation(); });
|
|
282
289
|
|
|
283
290
|
pi.on("session_before_compact", async (event, context) => {
|
|
291
|
+
compactionStatus = "preparing compaction";
|
|
284
292
|
const capture = snapshot;
|
|
285
293
|
snapshot = undefined; pending = undefined;
|
|
286
294
|
if (!capture) { fallback(`no eligible snapshot (${captureStatus})`); return; }
|
|
@@ -297,7 +305,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
297
305
|
const prefix = transcript(projection.entries.slice(0, boundary).flatMap((entry) => entry.messages));
|
|
298
306
|
const input = selectCoveredPrefix(capture.model, capture.prepared, prefix, fallback);
|
|
299
307
|
if (!input) return;
|
|
300
|
-
const current = () => ticket === generation && !event.signal.aborted && supportedRouteOptions(capture.model, capture.options) && !generic(context) && context.modelRegistry.getProvider(capture.model.provider) === capture.provider &&
|
|
308
|
+
const current = () => ticket === generation && !event.signal.aborted && supportedRouteOptions(capture.model, capture.options) && !generic(context) && context.modelRegistry.getProvider(capture.model.provider) === capture.provider && unchanged(context.sessionManager.getBranch(), branch.length, branchHash);
|
|
301
309
|
const auth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
302
310
|
if (!current()) return { cancel: true };
|
|
303
311
|
if (!auth.ok || routeDigest(auth) !== capture.authHash) { fallback("auth changed since capture"); return; }
|
|
@@ -307,7 +315,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
307
315
|
portable = await compact(event.preparation, capture.model, auth.apiKey, auth.headers as Record<string, string> | undefined, event.customInstructions, event.signal, context.thinkingLevel, capture.provider.streamSimple.bind(capture.provider), auth.env, undefined, undefined, capture.sessionId);
|
|
308
316
|
} catch {
|
|
309
317
|
// No result exists to return; let Pi own its ordinary failure/fallback behavior.
|
|
310
|
-
|
|
318
|
+
if (!current()) return { cancel: true };
|
|
319
|
+
fallback("portable summary generation failed");
|
|
320
|
+
return undefined;
|
|
311
321
|
}
|
|
312
322
|
if (!current() || !portable.summary.trim() || portable.summary === NATIVE_COMPACTION_SUMMARY) return { cancel: true };
|
|
313
323
|
try {
|
|
@@ -320,6 +330,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
320
330
|
input, preparedPayload: capture.prepared.payload, protocol,
|
|
321
331
|
apiKey: capture.options.apiKey, headers: capture.options.headers, env: capture.options.env,
|
|
322
332
|
signal: event.signal, fetch: capture.options.fetch,
|
|
333
|
+
...(capture.options.serviceTier === undefined ? {} : { serviceTier: capture.options.serviceTier }),
|
|
323
334
|
});
|
|
324
335
|
if (!current()) return { cancel: true };
|
|
325
336
|
const finalAuth = await context.modelRegistry.getApiKeyAndHeaders(context.model);
|
|
@@ -334,12 +345,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
334
345
|
};
|
|
335
346
|
pending = { details, capture, summary: portable.summary };
|
|
336
347
|
return { compaction: { ...portable, usage: combineUsage(native.usage, portable.usage), details } };
|
|
337
|
-
} catch {
|
|
348
|
+
} catch (error) {
|
|
338
349
|
if (!current()) return { cancel: true };
|
|
339
350
|
// Do not expose exception text: provider errors may contain request data.
|
|
340
351
|
fallback("native acquisition or validation failed");
|
|
341
|
-
// The portable request already succeeded. Never pay for a duplicate summary
|
|
342
|
-
|
|
352
|
+
// The portable request already succeeded. Never pay for a duplicate summary,
|
|
353
|
+
// and keep the native call's usage when the endpoint answered but produced
|
|
354
|
+
// an unusable window.
|
|
355
|
+
return {
|
|
356
|
+
compaction: error instanceof NativeCompactionValidationError && error.usage !== undefined
|
|
357
|
+
? { ...portable, usage: combineUsage(error.usage, portable.usage) }
|
|
358
|
+
: portable,
|
|
359
|
+
};
|
|
343
360
|
}
|
|
344
361
|
});
|
|
345
362
|
pi.on("session_compact", (event, context) => {
|
|
@@ -352,8 +369,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
352
369
|
if (!candidate || !equal(event.compactionEntry.details, candidate.details) || event.compactionEntry.summary !== candidate.summary) return;
|
|
353
370
|
const branch = context.sessionManager.getBranch();
|
|
354
371
|
const coverage = candidate.details.coverage;
|
|
355
|
-
|
|
356
|
-
|
|
372
|
+
const index = branch.findIndex((entry) => entry.id === event.compactionEntry.id);
|
|
373
|
+
if (!coverage || index < 0 || event.compactionEntry.firstKeptEntryId !== coverage.firstKeptEntryId || event.compactionEntry.parentId !== coverage.sourceLeafId || digest(branch.slice(0, index)) !== coverage.sourceHash) return;
|
|
374
|
+
active = {
|
|
375
|
+
entryId: event.compactionEntry.id,
|
|
376
|
+
summary: candidate.summary,
|
|
377
|
+
branchLength: index + 1,
|
|
378
|
+
branchHash: digest(branch.slice(0, index + 1)),
|
|
379
|
+
routeHash: candidate.capture.routeHash,
|
|
380
|
+
policyHash: candidate.capture.policyHash,
|
|
381
|
+
...(typeof candidate.capture.prepared.payload.model === "string" ? { payloadModel: candidate.capture.prepared.payload.model } : {}),
|
|
382
|
+
};
|
|
357
383
|
});
|
|
358
384
|
pi.on("session_compact_failed", () => { pending = undefined; snapshot = undefined; compactionStatus = "compaction failed or cancelled"; });
|
|
359
385
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nijaru/pi-provider-compaction",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Provider-native context compaction adapters for Pi",
|
|
6
6
|
"keywords": [
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"LICENSE"
|
|
45
45
|
],
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@earendil-works/pi-ai": "1.
|
|
48
|
-
"@earendil-works/pi-agent-core": "1.
|
|
49
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
47
|
+
"@earendil-works/pi-ai": "1.3.0",
|
|
48
|
+
"@earendil-works/pi-agent-core": "1.3.0",
|
|
49
|
+
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
50
50
|
"@types/bun": "latest",
|
|
51
51
|
"oxlint": "^1.81.0",
|
|
52
52
|
"typescript": "^6.0.3"
|
package/protocol.ts
CHANGED
|
@@ -225,10 +225,20 @@ interface TransportRequest {
|
|
|
225
225
|
apiKey?: string;
|
|
226
226
|
headers?: ProviderHeaders;
|
|
227
227
|
env?: Record<string, string>;
|
|
228
|
+
/** Cost-relevant request option captured with the original request. */
|
|
229
|
+
serviceTier?: string;
|
|
228
230
|
signal: AbortSignal;
|
|
229
231
|
fetch?: typeof globalThis.fetch;
|
|
230
232
|
}
|
|
231
233
|
|
|
234
|
+
/** The provider answered and was billed, but its window cannot be committed. */
|
|
235
|
+
export class NativeCompactionValidationError extends Error {
|
|
236
|
+
constructor(message: string, readonly usage?: Usage) {
|
|
237
|
+
super(message);
|
|
238
|
+
this.name = "NativeCompactionValidationError";
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
232
242
|
export interface TransportResponse {
|
|
233
243
|
output: ResponseItem[];
|
|
234
244
|
usage: Usage;
|
|
@@ -258,8 +268,12 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
258
268
|
});
|
|
259
269
|
if (!response.ok) return response;
|
|
260
270
|
const parsed = await readJsonBounded(response, request.signal);
|
|
261
|
-
|
|
262
|
-
|
|
271
|
+
try {
|
|
272
|
+
if (!isObject(parsed)) throw new Error("Responses Compact returned a non-object response");
|
|
273
|
+
compacted = { response: parsed, output: validateCompactedOutput(parsed) };
|
|
274
|
+
} catch (error) {
|
|
275
|
+
throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error));
|
|
276
|
+
}
|
|
263
277
|
return syntheticCompletion(parsed, preparedPayload);
|
|
264
278
|
} catch (error) {
|
|
265
279
|
bridgeError = error;
|
|
@@ -272,6 +286,7 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
272
286
|
headers: request.headers,
|
|
273
287
|
env: request.env,
|
|
274
288
|
signal: request.signal,
|
|
289
|
+
...(request.serviceTier === undefined ? {} : { serviceTier: request.serviceTier }),
|
|
275
290
|
transport: "sse",
|
|
276
291
|
cacheRetention: "none",
|
|
277
292
|
maxRetries: 0,
|
|
@@ -288,6 +303,7 @@ async function requestResponsesCompact(request: TransportRequest): Promise<Trans
|
|
|
288
303
|
if (bridgeError) throw bridgeError;
|
|
289
304
|
throw error;
|
|
290
305
|
});
|
|
306
|
+
if (bridgeError instanceof NativeCompactionValidationError) throw new NativeCompactionValidationError(bridgeError.message, usage);
|
|
291
307
|
if (bridgeError) throw bridgeError;
|
|
292
308
|
if (!compacted || dispatches !== 1) throw new Error("Responses Compact did not complete exactly one request");
|
|
293
309
|
return { output: compacted.output, usage };
|
|
@@ -410,6 +426,7 @@ async function requestRemoteV2(request: TransportRequest): Promise<TransportResp
|
|
|
410
426
|
headers: request.headers,
|
|
411
427
|
env: request.env,
|
|
412
428
|
signal: request.signal,
|
|
429
|
+
...(request.serviceTier === undefined ? {} : { serviceTier: request.serviceTier }),
|
|
413
430
|
transport: "sse",
|
|
414
431
|
cacheRetention: "none",
|
|
415
432
|
maxRetries: 0,
|
|
@@ -436,5 +453,9 @@ export async function requestProviderCompaction(
|
|
|
436
453
|
request: TransportRequest & { protocol: NativeProtocol },
|
|
437
454
|
): Promise<TransportResponse> {
|
|
438
455
|
const result = await (request.protocol === "remote-v2" ? requestRemoteV2(request) : requestResponsesCompact(request));
|
|
439
|
-
|
|
456
|
+
try {
|
|
457
|
+
return { ...result, output: validateCompactedOutput(result) };
|
|
458
|
+
} catch (error) {
|
|
459
|
+
throw new NativeCompactionValidationError(error instanceof Error ? error.message : String(error), result.usage);
|
|
460
|
+
}
|
|
440
461
|
}
|
package/provider.ts
CHANGED
|
@@ -59,6 +59,11 @@ export function installObserver(pi: ExtensionAPI, ctx: ExtensionContext, observe
|
|
|
59
59
|
const id = model.provider;
|
|
60
60
|
const native = registry.getRegisteredNativeProvider(id);
|
|
61
61
|
if (native) {
|
|
62
|
+
// Pi composes a native base only when its catalog serves the active API.
|
|
63
|
+
// Otherwise the request goes to the global API provider and bypasses this
|
|
64
|
+
// wrapper; claiming an observer there would report native compaction as
|
|
65
|
+
// installed while never capturing anything.
|
|
66
|
+
if (typeof native.getModels !== "function" || !native.getModels().some((entry) => entry.api === model.api)) return;
|
|
62
67
|
const isInstalled = () => registry.getRegisteredNativeProvider(id) === wrapper;
|
|
63
68
|
const guarded: ObserveRequest = (...args) => {
|
|
64
69
|
if (!isInstalled()) return;
|
package/replay.ts
CHANGED
|
@@ -14,7 +14,7 @@ export function equal(left: unknown, right: unknown): boolean {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/** Validator only. No serialized item from this function is transmitted. */
|
|
17
|
-
export function serializeInput(model: Model<any>, context: TranscriptContext, declarations = context): ResponseItem[] {
|
|
17
|
+
export function serializeInput(model: Model<any>, context: TranscriptContext, declarations = context, configurationUpdateEffort?: string): ResponseItem[] {
|
|
18
18
|
const compat = model.compat as Record<string, unknown> | undefined;
|
|
19
19
|
const flag = (name: string) => compat?.[name] === true;
|
|
20
20
|
const codex = model.api === "openai-codex-responses";
|
|
@@ -22,6 +22,7 @@ export function serializeInput(model: Model<any>, context: TranscriptContext, de
|
|
|
22
22
|
const tools = resolveTranscript(declarations, flag("supportsMidConvoSystemMessages"));
|
|
23
23
|
return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
|
|
24
24
|
includeSystemPrompt: !codex,
|
|
25
|
+
configurationUpdateEffort,
|
|
25
26
|
grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
|
|
26
27
|
supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
|
|
27
28
|
supportsAdditionalTools: flag("supportsAdditionalTools"),
|
|
@@ -40,6 +41,8 @@ export interface PreparedRequest {
|
|
|
40
41
|
payload: JsonObject & { input: ResponseItem[] };
|
|
41
42
|
/** The portable wire input before our sole summary substitution. */
|
|
42
43
|
portableInput: ResponseItem[];
|
|
44
|
+
/** The final request-only effort update; historical efforts come from the transcript. */
|
|
45
|
+
configurationUpdateEffort?: string;
|
|
43
46
|
replacement?: { index: number; count: number };
|
|
44
47
|
createdAt: number;
|
|
45
48
|
}
|
|
@@ -52,9 +55,16 @@ export function capturePreparedRequest(
|
|
|
52
55
|
// Reject stateful provider requests: coverage must be the complete visible input.
|
|
53
56
|
if (payload.previous_response_id || payload.conversation) { reject("stateful request"); return; }
|
|
54
57
|
if (Buffer.byteLength(JSON.stringify({ context, payload })) > MAX_SNAPSHOT_BYTES) { reject("snapshot exceeds size limit"); return; }
|
|
55
|
-
|
|
58
|
+
// Read the actual prepared request's mode, not provider/auth guesses. Exact conversion
|
|
59
|
+
// below still validates every historical update, its position and its complete shape.
|
|
60
|
+
const last = payload.input.at(-1);
|
|
61
|
+
const compat = model.compat as Record<string, unknown> | undefined;
|
|
62
|
+
const configurationUpdateEffort = model.api === "openai-responses" && compat?.supportsMidConvoEffort === true &&
|
|
63
|
+
last?.type === "configuration_update" && isObject(last.reasoning) && typeof last.reasoning.effort === "string"
|
|
64
|
+
? last.reasoning.effort : undefined;
|
|
65
|
+
const expected = serializeInput(model, context, context, configurationUpdateEffort);
|
|
56
66
|
if (!equal(expected, payload.input)) { reject("prepared input differs from serializer"); return; }
|
|
57
|
-
return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload) as PreparedRequest["payload"], portableInput: expected, createdAt: Date.now() };
|
|
67
|
+
return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload) as PreparedRequest["payload"], portableInput: expected, configurationUpdateEffort, createdAt: Date.now() };
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
function promptCount(input: readonly ResponseItem[]): number {
|
|
@@ -63,6 +73,13 @@ function promptCount(input: readonly ResponseItem[]): number {
|
|
|
63
73
|
return count;
|
|
64
74
|
}
|
|
65
75
|
|
|
76
|
+
function serializePrefix(model: Model<any>, snapshot: PreparedRequest, prefix: TranscriptContext): ResponseItem[] {
|
|
77
|
+
const input = serializeInput(model, prefix, snapshot.context, snapshot.configurationUpdateEffort);
|
|
78
|
+
// The converter appends the next reply's effort even to a partial transcript.
|
|
79
|
+
// It is not historical coverage and must remain in the live suffix on replay.
|
|
80
|
+
return snapshot.configurationUpdateEffort === undefined ? input : input.slice(0, -1);
|
|
81
|
+
}
|
|
82
|
+
|
|
66
83
|
/** No open/synthetic tool exchange may cross the coverage boundary. */
|
|
67
84
|
function closedTools(context: TranscriptContext): boolean {
|
|
68
85
|
const pending = new Set<string>();
|
|
@@ -94,7 +111,7 @@ export function selectCoveredPrefix(model: Model<any>, snapshot: PreparedRequest
|
|
|
94
111
|
if (!closedTools(prefix)) { reject("prefix has an incomplete tool exchange or failed assistant"); return; }
|
|
95
112
|
const mapped = mappedPrefix(snapshot, prefix);
|
|
96
113
|
if (!mapped) { reject("projected prefix differs from request context"); return; }
|
|
97
|
-
const expected =
|
|
114
|
+
const expected = serializePrefix(model, snapshot, mapped);
|
|
98
115
|
if (!equal(snapshot.portableInput.slice(0, expected.length), expected)) { reject("serialized prefix does not match prepared input"); return; }
|
|
99
116
|
const start = promptCount(expected);
|
|
100
117
|
let end = expected.length;
|
|
@@ -121,7 +138,7 @@ export function substituteSummary(
|
|
|
121
138
|
export function summarySlot(model: Model<any>, snapshot: PreparedRequest, summaryPrefix: TranscriptContext): number | undefined {
|
|
122
139
|
const mapped = mappedPrefix(snapshot, summaryPrefix);
|
|
123
140
|
if (!mapped) return;
|
|
124
|
-
const prefix =
|
|
141
|
+
const prefix = serializePrefix(model, snapshot, mapped);
|
|
125
142
|
const index = promptCount(prefix);
|
|
126
143
|
if (prefix.length !== index + 1 || !equal(snapshot.portableInput.slice(0, prefix.length), prefix)) return;
|
|
127
144
|
return index;
|