pi-ui-extend 1.0.46 → 1.0.50
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 +12 -1
- package/bin/pix.mjs +12 -7
- package/dist/app/app.d.ts +1 -0
- package/dist/app/app.js +9 -0
- package/dist/app/commands/command-model-actions.js +3 -5
- package/dist/app/model/model-usage-controller.d.ts +6 -0
- package/dist/app/model/model-usage-controller.js +10 -4
- package/dist/bundled-extensions/question/tui.js +36 -2
- package/external/pi-tools-suite/src/dcp/compress-tool.ts +2 -1
- package/external/pi-tools-suite/src/dcp/context-map-telemetry.ts +96 -0
- package/external/pi-tools-suite/src/dcp/index.ts +37 -1
- package/external/pi-tools-suite/src/dcp/protocol-closed-ranges.ts +90 -0
- package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +28 -46
- package/external/pi-tools-suite/src/dcp/pruner-replay-context.ts +35 -0
- package/external/pi-tools-suite/src/dcp/pruner.ts +9 -1
- package/external/pi-tools-suite/src/todo/index.ts +2 -0
- package/external/pi-tools-suite/src/todo/todo.ts +1 -1
- package/package.json +13 -13
package/README.md
CHANGED
|
@@ -512,10 +512,21 @@ Pix implements the Pi extension UI surface for notifications, keyed toasts, widg
|
|
|
512
512
|
|
|
513
513
|
## Development
|
|
514
514
|
|
|
515
|
+
Use the exact Node.js version in `.node-version` for development and builds;
|
|
516
|
+
`.nvmrc` mirrors it for nvm users. No particular version manager is required:
|
|
517
|
+
all npm scripts use the `node` and `npm` already on your `PATH`.
|
|
518
|
+
The supported runtime range is `>=22.19.0 <25`, shared by Pix, ACP, and Desktop.
|
|
519
|
+
CI checks the pinned version on Linux, macOS, and Windows, plus the minimum
|
|
520
|
+
supported version on Linux.
|
|
521
|
+
|
|
515
522
|
```bash
|
|
516
523
|
git clone https://github.com/dnaroid/pix.git
|
|
517
524
|
cd pix
|
|
518
|
-
|
|
525
|
+
|
|
526
|
+
# Select the version from .node-version using your preferred installation method.
|
|
527
|
+
# With nvm (optional): nvm install && nvm use
|
|
528
|
+
node --version
|
|
529
|
+
npm ci
|
|
519
530
|
|
|
520
531
|
# Run from source
|
|
521
532
|
npm run dev -- --cwd /path/to/project
|
package/bin/pix.mjs
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { existsSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { delimiter, dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
const minimumNodeVersionLabel = "22.19.0";
|
|
6
|
+
const supportedNodeRange = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).engines.node;
|
|
8
7
|
const launcherPath = fileURLToPath(import.meta.url);
|
|
9
8
|
const packageRoot = dirname(dirname(launcherPath));
|
|
10
9
|
const mainPath = fileURLToPath(new URL("../dist/main.js", import.meta.url));
|
|
@@ -13,8 +12,8 @@ const installPath = fileURLToPath(new URL("../dist/app/cli/install.js", import.m
|
|
|
13
12
|
const cliArgs = process.argv.slice(2);
|
|
14
13
|
|
|
15
14
|
if (!isCurrentNodeSupported()) {
|
|
16
|
-
console.error(`[pix] Node ${
|
|
17
|
-
console.error("[pix] Install
|
|
15
|
+
console.error(`[pix] Node ${supportedNodeRange} is required; current Node is ${process.versions.node}.`);
|
|
16
|
+
console.error("[pix] Install a supported Node.js release and ensure node and npm use it on PATH.");
|
|
18
17
|
process.exit(1);
|
|
19
18
|
}
|
|
20
19
|
|
|
@@ -45,9 +44,15 @@ if (!existsSync(mainPath)) {
|
|
|
45
44
|
await import(new URL("../dist/main.js", import.meta.url));
|
|
46
45
|
|
|
47
46
|
function isCurrentNodeSupported() {
|
|
48
|
-
|
|
47
|
+
// Only the project's bounded stable range is accepted; fail closed if its format changes.
|
|
48
|
+
const range = /^>=(\d+)\.(\d+)\.(\d+) <(\d+)$/u.exec(supportedNodeRange);
|
|
49
|
+
const version = /^(\d+)\.(\d+)\.(\d+)$/u.exec(process.versions.node);
|
|
50
|
+
if (!range || !version) return false;
|
|
51
|
+
const minimumNodeVersion = range.slice(1, 4).map(Number);
|
|
52
|
+
const parts = version.slice(1).map(Number);
|
|
53
|
+
if (parts[0] >= Number(range[4])) return false;
|
|
49
54
|
for (let index = 0; index < minimumNodeVersion.length; index += 1) {
|
|
50
|
-
const current = parts[index]
|
|
55
|
+
const current = parts[index];
|
|
51
56
|
const minimum = minimumNodeVersion[index];
|
|
52
57
|
if (current > minimum) return true;
|
|
53
58
|
if (current < minimum) return false;
|
package/dist/app/app.d.ts
CHANGED
package/dist/app/app.js
CHANGED
|
@@ -185,6 +185,7 @@ export class PiUiExtendApp {
|
|
|
185
185
|
});
|
|
186
186
|
this.modelUsageController = new AppModelUsageController({
|
|
187
187
|
runtimeSession: () => this.runtime?.session,
|
|
188
|
+
draftSelection: () => this.draftModelUsageSelection(),
|
|
188
189
|
render: () => this.render(),
|
|
189
190
|
});
|
|
190
191
|
this.tabsController = new AppTabsController({
|
|
@@ -948,6 +949,7 @@ export class PiUiExtendApp {
|
|
|
948
949
|
this.draftModelRef = ref;
|
|
949
950
|
this.draftThinkingLevel = thinkingLevel;
|
|
950
951
|
this.draftModelOverrideRef = `${ref}:${thinkingLevel}`;
|
|
952
|
+
this.modelUsageController.observeSession(undefined);
|
|
951
953
|
this.setStatus("new conversation");
|
|
952
954
|
}
|
|
953
955
|
draftModelState() {
|
|
@@ -965,6 +967,13 @@ export class PiUiExtendApp {
|
|
|
965
967
|
return undefined;
|
|
966
968
|
return { modelLabel: state.modelRef, thinkingLabel: state.thinkingLevel };
|
|
967
969
|
}
|
|
970
|
+
draftModelUsageSelection() {
|
|
971
|
+
const state = this.draftModelState();
|
|
972
|
+
if (!state?.modelRef)
|
|
973
|
+
return undefined;
|
|
974
|
+
const model = state.models.find((candidate) => `${candidate.provider}/${candidate.id}` === state.modelRef);
|
|
975
|
+
return model ? { model, thinkingLevel: state.thinkingLevel } : undefined;
|
|
976
|
+
}
|
|
968
977
|
applyPixConfig(config) {
|
|
969
978
|
replaceRecord(this.pixConfig.toolRenderer, config.toolRenderer);
|
|
970
979
|
replaceRecord(this.pixConfig.outputFilters, config.outputFilters);
|
|
@@ -384,10 +384,8 @@ export class ModelCommandActions {
|
|
|
384
384
|
});
|
|
385
385
|
}
|
|
386
386
|
saveDefaultThinking(level) {
|
|
387
|
-
const runtime =
|
|
388
|
-
|
|
389
|
-
return;
|
|
390
|
-
const fallbackModelRef = runtime.session.model ? this.host.modelRef(runtime.session.model) : undefined;
|
|
387
|
+
const runtime = this.host.runtime();
|
|
388
|
+
const fallbackModelRef = runtime?.session.model ? this.host.modelRef(runtime.session.model) : undefined;
|
|
391
389
|
const saved = savePixDefaultThinking(level, fallbackModelRef);
|
|
392
390
|
if (!saved)
|
|
393
391
|
throw new Error("Set /default-model first or select a session model before setting default thinking");
|
|
@@ -396,7 +394,7 @@ export class ModelCommandActions {
|
|
|
396
394
|
kind: "system",
|
|
397
395
|
text: `Default thinking level set to ${saved.thinking ?? level} for ${saved.modelRef}. New sessions will use it unless --model is provided.`,
|
|
398
396
|
});
|
|
399
|
-
this.host.setSessionStatus(runtime
|
|
397
|
+
this.host.setSessionStatus(runtime?.session);
|
|
400
398
|
}
|
|
401
399
|
}
|
|
402
400
|
function formatDefaultModelRef(defaultModel) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { SessionModel } from "../types.js";
|
|
2
3
|
import { type ModelUsageDescriptor, type ModelUsageStatus } from "./model-usage-status.js";
|
|
3
4
|
export type AppModelUsageQuery = (descriptor: ModelUsageDescriptor) => Promise<ModelUsageStatus | undefined>;
|
|
4
5
|
export type ModelUsageRefreshResult = "refreshed" | "unavailable" | "failed";
|
|
@@ -12,6 +13,10 @@ export type ModelUsageRefreshStart = {
|
|
|
12
13
|
};
|
|
13
14
|
export type AppModelUsageControllerHost = {
|
|
14
15
|
runtimeSession(): AgentSession | undefined;
|
|
16
|
+
draftSelection?(): {
|
|
17
|
+
model: SessionModel;
|
|
18
|
+
thinkingLevel: string;
|
|
19
|
+
} | undefined;
|
|
15
20
|
render(): void;
|
|
16
21
|
};
|
|
17
22
|
export declare class AppModelUsageController {
|
|
@@ -32,4 +37,5 @@ export declare class AppModelUsageController {
|
|
|
32
37
|
private refresh;
|
|
33
38
|
private syncActiveModel;
|
|
34
39
|
private activeStatus;
|
|
40
|
+
private activeDescriptor;
|
|
35
41
|
}
|
|
@@ -45,7 +45,7 @@ export class AppModelUsageController {
|
|
|
45
45
|
refreshNow() {
|
|
46
46
|
const session = this.host.runtimeSession();
|
|
47
47
|
this.syncActiveModel(session);
|
|
48
|
-
const descriptor =
|
|
48
|
+
const descriptor = this.activeDescriptor(session);
|
|
49
49
|
if (!descriptor)
|
|
50
50
|
return { kind: "unsupported" };
|
|
51
51
|
const promise = this.refresh(true, descriptor);
|
|
@@ -54,7 +54,7 @@ export class AppModelUsageController {
|
|
|
54
54
|
tick(force = false) {
|
|
55
55
|
const session = this.host.runtimeSession();
|
|
56
56
|
this.syncActiveModel(session);
|
|
57
|
-
const descriptor =
|
|
57
|
+
const descriptor = this.activeDescriptor(session);
|
|
58
58
|
if (!descriptor)
|
|
59
59
|
return;
|
|
60
60
|
const cacheKey = descriptorCacheKey(descriptor);
|
|
@@ -68,7 +68,7 @@ export class AppModelUsageController {
|
|
|
68
68
|
}
|
|
69
69
|
refresh(force = false, activeDescriptor) {
|
|
70
70
|
const session = this.host.runtimeSession();
|
|
71
|
-
const descriptor = activeDescriptor ??
|
|
71
|
+
const descriptor = activeDescriptor ?? this.activeDescriptor(session);
|
|
72
72
|
if (!descriptor)
|
|
73
73
|
return undefined;
|
|
74
74
|
const modelKey = descriptorCacheKey(descriptor);
|
|
@@ -96,7 +96,7 @@ export class AppModelUsageController {
|
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
98
|
syncActiveModel(session) {
|
|
99
|
-
const nextModelKey = descriptorCacheKey(
|
|
99
|
+
const nextModelKey = descriptorCacheKey(this.activeDescriptor(session));
|
|
100
100
|
if (nextModelKey === this.activeModelKey)
|
|
101
101
|
return false;
|
|
102
102
|
this.activeModelKey = nextModelKey;
|
|
@@ -106,4 +106,10 @@ export class AppModelUsageController {
|
|
|
106
106
|
activeStatus() {
|
|
107
107
|
return this.activeModelKey ? this.statuses.get(this.activeModelKey) : undefined;
|
|
108
108
|
}
|
|
109
|
+
activeDescriptor(session) {
|
|
110
|
+
if (session)
|
|
111
|
+
return modelUsageDescriptor(session.model, session.thinkingLevel);
|
|
112
|
+
const draft = this.host.draftSelection?.();
|
|
113
|
+
return draft ? modelUsageDescriptor(draft.model, draft.thinkingLevel) : undefined;
|
|
114
|
+
}
|
|
109
115
|
}
|
|
@@ -325,6 +325,30 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
325
325
|
}
|
|
326
326
|
showReview();
|
|
327
327
|
}
|
|
328
|
+
function confirmMultipleAnswer() {
|
|
329
|
+
const question = currentQuestion();
|
|
330
|
+
if (!question.multiple)
|
|
331
|
+
return;
|
|
332
|
+
const selection = selections.get(question.id);
|
|
333
|
+
if (!selection || !selectionIsComplete(question, selection)) {
|
|
334
|
+
const existing = multipleSelection(question);
|
|
335
|
+
if (existing.customText !== undefined && !existing.customText.trim() && (existing.images?.length ?? 0) === 0) {
|
|
336
|
+
customError = "Custom Answer cannot be empty.";
|
|
337
|
+
}
|
|
338
|
+
else {
|
|
339
|
+
const minimum = question.minSelections ?? 1;
|
|
340
|
+
customError = `Choose at least ${minimum} answer${minimum === 1 ? "" : "s"}.`;
|
|
341
|
+
}
|
|
342
|
+
refresh();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
customError = undefined;
|
|
346
|
+
if (questionIndex < questions.length - 1) {
|
|
347
|
+
moveToQuestion(questionIndex + 1);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
submitOrAnswerRemaining();
|
|
351
|
+
}
|
|
328
352
|
function enterCustomMode() {
|
|
329
353
|
const question = currentQuestion();
|
|
330
354
|
const existing = selections.get(question.id);
|
|
@@ -410,7 +434,7 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
410
434
|
mode = "choices";
|
|
411
435
|
customError = undefined;
|
|
412
436
|
syncChoiceSelection();
|
|
413
|
-
|
|
437
|
+
confirmMultipleAnswer();
|
|
414
438
|
return;
|
|
415
439
|
}
|
|
416
440
|
selections.set(question.id, {
|
|
@@ -558,7 +582,17 @@ export async function runQuestionnaire(questions, ctx) {
|
|
|
558
582
|
goBack();
|
|
559
583
|
return;
|
|
560
584
|
}
|
|
561
|
-
if (
|
|
585
|
+
if (currentQuestion().multiple) {
|
|
586
|
+
if (data === " ") {
|
|
587
|
+
selectChoice(selectedChoiceIndex);
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (isKey(data, "enter")) {
|
|
591
|
+
confirmMultipleAnswer();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
else if (isKey(data, "enter") || data === " ") {
|
|
562
596
|
selectChoice(selectedChoiceIndex);
|
|
563
597
|
return;
|
|
564
598
|
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
} from "./compression-preview.js"
|
|
37
37
|
import { settleCompressionProgress } from "./compression-progress.js"
|
|
38
38
|
import { estimateMessageTokens } from "./pruner-metadata.js"
|
|
39
|
+
import { incompleteToolGroupGuidance } from "./protocol-closed-ranges.js"
|
|
39
40
|
import {
|
|
40
41
|
buildSummarySourceManifest,
|
|
41
42
|
hashSummarySourceManifest,
|
|
@@ -190,7 +191,7 @@ function validateProtocolClosedRanges(plans: ResolvedRangeBoundaries[], state: D
|
|
|
190
191
|
if (closure.incompleteToolGroup) {
|
|
191
192
|
throw new Error(
|
|
192
193
|
`Compression range ${plan.startId}..${plan.endId} intersects an incomplete tool group. ` +
|
|
193
|
-
|
|
194
|
+
incompleteToolGroupGuidance(state.conversationIndexSnapshot, plan.startId, plan.endId),
|
|
194
195
|
)
|
|
195
196
|
}
|
|
196
197
|
if (closure.expanded) {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { detectToolGroupSpans, findConversationIndexEntry } from "./conversation-index.js"
|
|
2
|
+
import { estimateMessageTokens, stripStaleDcpMetadataFromMessage } from "./pruner-metadata.js"
|
|
3
|
+
import type { CompressionCandidate } from "./pruner-types.js"
|
|
4
|
+
import type { DcpState } from "./state.js"
|
|
5
|
+
|
|
6
|
+
export interface DcpContextMapTelemetry {
|
|
7
|
+
readonly revision: number
|
|
8
|
+
readonly sessionEpoch: number
|
|
9
|
+
readonly generatedAt: number
|
|
10
|
+
readonly tokenEstimates: {
|
|
11
|
+
readonly candidate: number
|
|
12
|
+
readonly protected: number
|
|
13
|
+
readonly compressed: number
|
|
14
|
+
readonly retained: number
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type ContextMapKind = keyof DcpContextMapTelemetry["tokenEstimates"]
|
|
19
|
+
|
|
20
|
+
function isSafeNonnegativeInteger(value: unknown): value is number {
|
|
21
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Capture immediately after applyPruning, before any await or carrier changes. */
|
|
25
|
+
export function captureDcpContextTokenEstimates(
|
|
26
|
+
entries: DcpState["conversationIndexSnapshot"],
|
|
27
|
+
preparedMessages: readonly any[],
|
|
28
|
+
metadata: DcpState["messageMetaSnapshot"],
|
|
29
|
+
): number[] | undefined {
|
|
30
|
+
if (entries.length !== preparedMessages.length) return undefined
|
|
31
|
+
const tokens: number[] = []
|
|
32
|
+
for (let index = 0; index < entries.length; index++) {
|
|
33
|
+
const entry = entries[index]!
|
|
34
|
+
const message = preparedMessages[index]
|
|
35
|
+
if (entry.index !== index || entry.role !== (message?.role ?? "")) return undefined
|
|
36
|
+
const cached = entry.visibleId ? metadata.get(entry.visibleId) : undefined
|
|
37
|
+
// Reuse the body estimate made by this exact pruning pass. Non-addressable
|
|
38
|
+
// messages have no cache; only these need the ordinary content estimator.
|
|
39
|
+
const estimate = cached?.contentHash === entry.contentHash && cached.tokenEstimate !== undefined
|
|
40
|
+
? cached.tokenEstimate
|
|
41
|
+
: estimateMessageTokens(stripStaleDcpMetadataFromMessage(message))
|
|
42
|
+
if (!isSafeNonnegativeInteger(estimate)) return undefined
|
|
43
|
+
tokens.push(estimate)
|
|
44
|
+
}
|
|
45
|
+
return tokens
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Classifies the already-prepared provider projection without reading history or planning. */
|
|
49
|
+
export function projectDcpContextMapTelemetry(
|
|
50
|
+
entries: DcpState["conversationIndexSnapshot"],
|
|
51
|
+
candidate: CompressionCandidate | null,
|
|
52
|
+
preparedTokenEstimates: readonly number[] | undefined,
|
|
53
|
+
revision: number,
|
|
54
|
+
sessionEpoch: number,
|
|
55
|
+
generatedAt = Date.now(),
|
|
56
|
+
): DcpContextMapTelemetry | undefined {
|
|
57
|
+
if (
|
|
58
|
+
entries.length === 0 || entries.length !== preparedTokenEstimates?.length ||
|
|
59
|
+
!isSafeNonnegativeInteger(revision) || revision === 0 || !isSafeNonnegativeInteger(sessionEpoch) ||
|
|
60
|
+
!isSafeNonnegativeInteger(generatedAt) || generatedAt === 0 || !Number.isFinite(new Date(generatedAt).getTime())
|
|
61
|
+
) return undefined
|
|
62
|
+
|
|
63
|
+
const protectedIndexes = new Set<number>()
|
|
64
|
+
for (const group of detectToolGroupSpans(entries)) {
|
|
65
|
+
if (!group.complete) {
|
|
66
|
+
for (let index = group.startIndex; index <= group.endIndex; index++) protectedIndexes.add(index)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const candidateIndexes = new Set<number>()
|
|
70
|
+
if (candidate) {
|
|
71
|
+
const start = findConversationIndexEntry(entries, candidate.startId)?.index
|
|
72
|
+
const end = findConversationIndexEntry(entries, candidate.endId)?.index
|
|
73
|
+
if (start !== undefined && end !== undefined) {
|
|
74
|
+
for (let index = Math.min(start, end); index <= Math.max(start, end); index++) candidateIndexes.add(index)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const tokenEstimates: Record<ContextMapKind, number> = {
|
|
79
|
+
candidate: 0, protected: 0, compressed: 0, retained: 0,
|
|
80
|
+
}
|
|
81
|
+
for (let index = 0; index < entries.length; index++) {
|
|
82
|
+
const entry = entries[index]!
|
|
83
|
+
const tokens = preparedTokenEstimates[index]
|
|
84
|
+
if (!isSafeNonnegativeInteger(tokens)) return undefined
|
|
85
|
+
const kind: ContextMapKind = entry.origin === "dcp-control" ? "retained"
|
|
86
|
+
: protectedIndexes.has(index) ? "protected"
|
|
87
|
+
: candidateIndexes.has(index) ? "candidate"
|
|
88
|
+
: entry.origin === "block" && entry.blockId !== undefined ? "compressed"
|
|
89
|
+
: "retained"
|
|
90
|
+
if (!Number.isSafeInteger(tokenEstimates[kind] + tokens)) return undefined
|
|
91
|
+
tokenEstimates[kind] += tokens
|
|
92
|
+
}
|
|
93
|
+
const total = tokenEstimates.candidate + tokenEstimates.protected + tokenEstimates.compressed + tokenEstimates.retained
|
|
94
|
+
if (!Number.isSafeInteger(total) || total <= 0) return undefined
|
|
95
|
+
return { revision, sessionEpoch, generatedAt, tokenEstimates }
|
|
96
|
+
}
|
|
@@ -78,6 +78,7 @@ import { inferDcpBlockedReason, planDcpBudget, type DcpBlockedReason } from "./p
|
|
|
78
78
|
import { createBudgetedAutoCompressionBlock } from "./auto-compress-budget.js"
|
|
79
79
|
import { compressionPlanningTokens, outstandingCompressionTokens, resetCompressionProgress, routineRecoveryTokens, trackCompressionProgress } from "./compression-progress.js"
|
|
80
80
|
import { captureDcpTransactionGuard, cloneDcpTransactionState, runDcpStateTransaction, invalidateDcpStateOwner } from "./state-transaction.js"
|
|
81
|
+
import { captureDcpContextTokenEstimates, projectDcpContextMapTelemetry } from "./context-map-telemetry.js"
|
|
81
82
|
|
|
82
83
|
const PIX_DCP_RUNTIME_STATS_SYMBOL = Symbol.for("pix.dcp.runtime-stats")
|
|
83
84
|
|
|
@@ -163,7 +164,15 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
|
|
|
163
164
|
const state = dependencies.state ?? createState()
|
|
164
165
|
// Pix ACP runs one pi process per session. Expose only this tiny live metric
|
|
165
166
|
// to the patched RPC session-stats surface; DCP state remains extension-owned.
|
|
166
|
-
|
|
167
|
+
let contextMapRevision = 0
|
|
168
|
+
let contextMapPass = 0
|
|
169
|
+
let contextMap: ReturnType<typeof projectDcpContextMapTelemetry> | undefined
|
|
170
|
+
// This process-local bridge is read-only. Its map is produced only by the
|
|
171
|
+
// existing context pass, never by Desktop inspection or a streaming delta.
|
|
172
|
+
runtimeGlobals[PIX_DCP_RUNTIME_STATS_SYMBOL] = () => ({
|
|
173
|
+
tokensSaved: state.tokensSaved,
|
|
174
|
+
contextMap: contextMap?.sessionEpoch === state.sessionEpoch ? contextMap : undefined,
|
|
175
|
+
})
|
|
167
176
|
let journalMirror: DcpJournalMirror | undefined
|
|
168
177
|
let journalSupported = false
|
|
169
178
|
let journalBlockedReason: string | undefined
|
|
@@ -283,6 +292,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
|
|
|
283
292
|
try { ctx.ui.notify(message, "warning") } catch { /* Headless/notification failure is not a persistence failure. */ }
|
|
284
293
|
}
|
|
285
294
|
const invalidateOwner = () => {
|
|
295
|
+
contextMap = undefined
|
|
296
|
+
contextMapPass++
|
|
286
297
|
invalidateDcpStateOwner(state)
|
|
287
298
|
routinePressureTracker.reset()
|
|
288
299
|
// Successful exposure belongs to the old provider/branch too, not only
|
|
@@ -353,6 +364,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
|
|
|
353
364
|
|
|
354
365
|
// ── 5. session_start: restore state from session entries ──────────────────
|
|
355
366
|
pi.on("session_start", async (event, ctx) => {
|
|
367
|
+
contextMap = undefined
|
|
368
|
+
contextMapPass++
|
|
356
369
|
resetState(state)
|
|
357
370
|
routinePressureTracker.reset()
|
|
358
371
|
freshToolResults.reset()
|
|
@@ -387,6 +400,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
|
|
|
387
400
|
// Journal operations are committed at the mutation boundary; shutdown does
|
|
388
401
|
// not write a full runtime snapshot.
|
|
389
402
|
pi.on("session_shutdown", async () => {
|
|
403
|
+
contextMap = undefined
|
|
404
|
+
contextMapPass++
|
|
390
405
|
routinePressureTracker.reset()
|
|
391
406
|
freshToolResults.reset()
|
|
392
407
|
journalMirror = undefined
|
|
@@ -472,6 +487,9 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
|
|
|
472
487
|
// ── 10. context: apply pruning and inject nudges ──────────────────────────
|
|
473
488
|
pi.on("context", async (event, ctx) => {
|
|
474
489
|
const contextEpoch = state.sessionEpoch
|
|
490
|
+
const mapPass = ++contextMapPass
|
|
491
|
+
contextMap = undefined
|
|
492
|
+
let mapEntries = state.conversationIndexSnapshot
|
|
475
493
|
const effectiveConfig = configForContext(ctx)
|
|
476
494
|
ensureEphemeralJournal(ctx)
|
|
477
495
|
const contextMessages = event.messages
|
|
@@ -509,6 +527,18 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
|
|
|
509
527
|
state: summarizeDcpState(state, effectiveConfig),
|
|
510
528
|
...details,
|
|
511
529
|
}, ctx)
|
|
530
|
+
if (mapPass === contextMapPass) {
|
|
531
|
+
contextMap = providerReady
|
|
532
|
+
? projectDcpContextMapTelemetry(
|
|
533
|
+
mapEntries,
|
|
534
|
+
candidate ?? emergencyCompressionCandidate,
|
|
535
|
+
mapTokenEstimates,
|
|
536
|
+
++contextMapRevision,
|
|
537
|
+
contextEpoch,
|
|
538
|
+
Date.now(),
|
|
539
|
+
)
|
|
540
|
+
: undefined
|
|
541
|
+
}
|
|
512
542
|
return { messages }
|
|
513
543
|
}
|
|
514
544
|
|
|
@@ -545,6 +575,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
|
|
|
545
575
|
writeDcpDebugLog(effectiveConfig, "context.rehydrated_tool_records", { ...rehydration }, ctx)
|
|
546
576
|
}
|
|
547
577
|
let prunedMessages = applyPruning(contextMessages, state, effectiveConfig)
|
|
578
|
+
mapEntries = state.conversationIndexSnapshot
|
|
579
|
+
let mapTokenEstimates = captureDcpContextTokenEstimates(mapEntries, prunedMessages, state.messageMetaSnapshot)
|
|
548
580
|
// Stable IDs and any pruning decisions that affect this provider-visible
|
|
549
581
|
// projection must be committed before the request can use them.
|
|
550
582
|
await persistJournalState(ctx, state)
|
|
@@ -1011,6 +1043,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
|
|
|
1011
1043
|
// Re-apply pruning so the new block takes effect on this
|
|
1012
1044
|
// same context pass instead of the next one.
|
|
1013
1045
|
prunedMessages = preparedProjection ?? applyPruning(contextMessages, state, effectiveConfig)
|
|
1046
|
+
mapEntries = state.conversationIndexSnapshot
|
|
1047
|
+
mapTokenEstimates = captureDcpContextTokenEstimates(mapEntries, prunedMessages, state.messageMetaSnapshot)
|
|
1014
1048
|
const clearedAnchors = clearDcpNudgeAnchors(state)
|
|
1015
1049
|
warnedProgress.clear()
|
|
1016
1050
|
state.progressRecovery = undefined
|
|
@@ -1128,6 +1162,8 @@ export default async function dcpModule(pi: ExtensionAPI, dependencies: { config
|
|
|
1128
1162
|
)
|
|
1129
1163
|
if (emergencyPruneResult.prunedToolCallIds.length > 0) {
|
|
1130
1164
|
prunedMessages = applyPruning(contextMessages, state, effectiveConfig)
|
|
1165
|
+
mapEntries = state.conversationIndexSnapshot
|
|
1166
|
+
mapTokenEstimates = captureDcpContextTokenEstimates(mapEntries, prunedMessages, state.messageMetaSnapshot)
|
|
1131
1167
|
const clearedAnchors = clearDcpNudgeAnchors(state)
|
|
1132
1168
|
state.consecutiveIgnoredStrongNudges = 0
|
|
1133
1169
|
state.progressRecovery = undefined
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { closeConversationRange, detectToolGroupSpans } from "./conversation-index.js";
|
|
2
|
+
import type { ConversationIndexEntry } from "./state.js";
|
|
3
|
+
|
|
4
|
+
interface RangeBoundary {
|
|
5
|
+
id: string;
|
|
6
|
+
messageIndex: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function diagnosticId(id: string): string {
|
|
10
|
+
return id.length > 160 ? `${id.slice(0, 157)}...` : id;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Maximal closed runs inside an already policy-filtered selection. Incomplete
|
|
15
|
+
* groups are barriers, not reasons to discard all later completed work. Never
|
|
16
|
+
* widen across a retention boundary or an excluded message/block to close one.
|
|
17
|
+
*/
|
|
18
|
+
export function protocolClosedBoundaryRuns<T extends RangeBoundary>(
|
|
19
|
+
boundaries: T[],
|
|
20
|
+
entries: ConversationIndexEntry[],
|
|
21
|
+
): T[][] {
|
|
22
|
+
if (boundaries.length === 0 || entries.length === 0) return [];
|
|
23
|
+
const first = boundaries[0]!.messageIndex;
|
|
24
|
+
const last = boundaries[boundaries.length - 1]!.messageIndex;
|
|
25
|
+
const included = new Set(boundaries.map((boundary) => boundary.messageIndex));
|
|
26
|
+
const excluded = new Set(entries.filter((entry) =>
|
|
27
|
+
entry.index >= first && entry.index <= last && !included.has(entry.index) &&
|
|
28
|
+
!entry.passthrough && entry.origin !== "dcp-control",
|
|
29
|
+
).map((entry) => entry.index));
|
|
30
|
+
const barriers = [...excluded].map((index) => ({ startIndex: index, endIndex: index }));
|
|
31
|
+
for (const group of detectToolGroupSpans(entries)) {
|
|
32
|
+
if (group.endIndex < first || group.startIndex > last) continue;
|
|
33
|
+
if (!group.complete || group.startIndex < first || group.endIndex > last ||
|
|
34
|
+
entries.slice(group.startIndex, group.endIndex + 1).some((entry) => excluded.has(entry.index))) {
|
|
35
|
+
barriers.push(group);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
barriers.sort((a, b) => a.startIndex - b.startIndex);
|
|
39
|
+
|
|
40
|
+
const runs: T[][] = [];
|
|
41
|
+
let cursor = 0;
|
|
42
|
+
for (const barrier of barriers) {
|
|
43
|
+
const start = cursor;
|
|
44
|
+
while (cursor < boundaries.length && boundaries[cursor]!.messageIndex < barrier.startIndex) cursor++;
|
|
45
|
+
if (cursor > start) runs.push(boundaries.slice(start, cursor));
|
|
46
|
+
while (cursor < boundaries.length && boundaries[cursor]!.messageIndex <= barrier.endIndex) cursor++;
|
|
47
|
+
}
|
|
48
|
+
if (cursor < boundaries.length) runs.push(boundaries.slice(cursor));
|
|
49
|
+
|
|
50
|
+
// Use the executor's closure authority, including non-addressable entries.
|
|
51
|
+
return runs.filter((run) => {
|
|
52
|
+
const closure = closeConversationRange(entries, run[0]!.id, run[run.length - 1]!.id);
|
|
53
|
+
return closure && !closure.expanded && !closure.incompleteToolGroup;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Bounded recovery guidance for stale/manual selections; never changes them. */
|
|
58
|
+
export function incompleteToolGroupGuidance(
|
|
59
|
+
entries: ConversationIndexEntry[],
|
|
60
|
+
startId: string,
|
|
61
|
+
endId: string,
|
|
62
|
+
): string {
|
|
63
|
+
const closure = closeConversationRange(entries, startId, endId);
|
|
64
|
+
if (!closure) return "Choose another protocol-closed range.";
|
|
65
|
+
const blockers = detectToolGroupSpans(entries).filter((group) =>
|
|
66
|
+
!group.complete && group.startIndex <= closure.endIndex && group.endIndex >= closure.startIndex,
|
|
67
|
+
);
|
|
68
|
+
const details = blockers.slice(0, 3).map((group) => {
|
|
69
|
+
const assistant = entries[group.startIndex]!;
|
|
70
|
+
const results = new Set(entries.slice(group.startIndex + 1, group.endIndex + 1)
|
|
71
|
+
.filter((entry) => entry.role === "toolResult" || entry.role === "bashExecution")
|
|
72
|
+
.map((entry) => entry.toolCallId));
|
|
73
|
+
const missing = group.toolCallIds.filter((id) => !results.has(id));
|
|
74
|
+
return `${diagnosticId(assistant.visibleId ?? assistant.stableId)} (missing tool result: ${missing.slice(0, 3).map(diagnosticId).join(", ")}${missing.length > 3 ? ", …" : ""})`;
|
|
75
|
+
});
|
|
76
|
+
const boundaries = entries.flatMap((entry) => {
|
|
77
|
+
if (entry.index < closure.requestedStartIndex || entry.index > closure.requestedEndIndex ||
|
|
78
|
+
entry.passthrough || entry.origin === "dcp-control") return [];
|
|
79
|
+
const id = entry.blockId !== undefined ? `b${entry.blockId}` : entry.visibleId;
|
|
80
|
+
return id ? [{ id, messageIndex: entry.index }] : [];
|
|
81
|
+
});
|
|
82
|
+
const alternatives = protocolClosedBoundaryRuns(boundaries, entries);
|
|
83
|
+
const hints = alternatives.slice(0, 3).map((run) => `${run[0]!.id}..${run[run.length - 1]!.id}`);
|
|
84
|
+
return `Blocking group(s): ${details.join("; ")}${blockers.length > 3 ? "; …" : ""}. ` +
|
|
85
|
+
(hints.length > 0
|
|
86
|
+
? `Protocol-safe alternatives inside this selection: ${hints.join(", ")}${alternatives.length > 3 ? ", …" : ""}. `
|
|
87
|
+
: "No protocol-closed subrange is available inside this selection. ") +
|
|
88
|
+
"Choose a closed range or message-mode candidates outside the blocked groups. " +
|
|
89
|
+
"Changing only an endId beyond a blocked group will not help; wait only if its tool is actually still running.";
|
|
90
|
+
}
|
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
estimateMessageTokens,
|
|
6
6
|
} from "./pruner-metadata.js";
|
|
7
7
|
import { stableMessageKeys } from "./pruner-message-ids.js";
|
|
8
|
-
import { detectToolGroupSpans, findConversationIndexEntry } from "./conversation-index.js";
|
|
8
|
+
import { closeConversationRange, detectToolGroupSpans, findConversationIndexEntry } from "./conversation-index.js";
|
|
9
|
+
import { protocolClosedBoundaryRuns } from "./protocol-closed-ranges.js";
|
|
9
10
|
|
|
10
11
|
interface CandidateBoundary {
|
|
11
12
|
id: string;
|
|
@@ -120,58 +121,39 @@ function selectOldestSafePrefix(
|
|
|
120
121
|
maxSafeMessageIndex: number,
|
|
121
122
|
options?: CompressionCandidateSelectionOptions,
|
|
122
123
|
): CandidateBoundary[] | null {
|
|
123
|
-
|
|
124
|
-
if (!options || options.requiredSavingsTokens === undefined) {
|
|
125
|
-
let full = boundaries.slice();
|
|
126
|
-
while (full.length > 0 && full[0]!.isSystemReminder) full = full.slice(1);
|
|
127
|
-
while (full.length > 0 && full[full.length - 1]!.isSystemReminder) full = full.slice(0, -1);
|
|
128
|
-
if (full.length < Math.max(1, settings.minMessages)) return null;
|
|
129
|
-
if (full.reduce((sum, boundary) => sum + boundary.tokenEstimate, 0) < settings.minTokens) return null;
|
|
130
|
-
return full;
|
|
131
|
-
}
|
|
132
|
-
const requiredSavings = Math.max(0, Math.floor(options.requiredSavingsTokens));
|
|
124
|
+
const requiredSavings = Math.max(0, Math.floor(options?.requiredSavingsTokens ?? 0));
|
|
133
125
|
const estimatorMargin = Math.max(0, Math.floor(options?.estimatorMarginTokens ?? 0));
|
|
134
126
|
const targetSourceTokens = Math.max(settings.minTokens, requiredSavings + estimatorMargin);
|
|
135
127
|
const minMessages = Math.max(1, settings.minMessages);
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
let endMessageIndex = selected[selected.length - 1]!.messageIndex;
|
|
153
|
-
let changed = true;
|
|
154
|
-
while (changed) {
|
|
155
|
-
changed = false;
|
|
156
|
-
for (const group of groups) {
|
|
157
|
-
const overlaps = group.startIndex <= endMessageIndex && group.endIndex >= startMessageIndex;
|
|
158
|
-
if (!overlaps) continue;
|
|
159
|
-
if (!group.complete) return null;
|
|
160
|
-
if (group.endIndex > endMessageIndex) {
|
|
161
|
-
if (group.endIndex > maxSafeMessageIndex) return null;
|
|
162
|
-
endMessageIndex = group.endIndex;
|
|
163
|
-
changed = true;
|
|
128
|
+
const eligible = boundaries.filter((boundary) => boundary.messageIndex <= maxSafeMessageIndex);
|
|
129
|
+
while (eligible[0]?.isSystemReminder) eligible.shift();
|
|
130
|
+
while (eligible[eligible.length - 1]?.isSystemReminder) eligible.pop();
|
|
131
|
+
|
|
132
|
+
for (const run of protocolClosedBoundaryRuns(eligible, state.conversationIndexSnapshot)) {
|
|
133
|
+
while (run[0]?.isSystemReminder) run.shift();
|
|
134
|
+
while (run[run.length - 1]?.isSystemReminder) run.pop();
|
|
135
|
+
if (run.length === 0) continue;
|
|
136
|
+
let selectedEnd = run.length - 1;
|
|
137
|
+
if (options?.requiredSavingsTokens !== undefined) {
|
|
138
|
+
let selectedTokens = 0;
|
|
139
|
+
for (let index = 0; index < run.length; index++) {
|
|
140
|
+
selectedTokens += run[index]!.tokenEstimate;
|
|
141
|
+
if (index + 1 >= minMessages && selectedTokens >= targetSourceTokens) {
|
|
142
|
+
selectedEnd = index;
|
|
143
|
+
break;
|
|
164
144
|
}
|
|
165
145
|
}
|
|
166
146
|
}
|
|
167
|
-
|
|
147
|
+
const closure = closeConversationRange(state.conversationIndexSnapshot, run[0]!.id, run[selectedEnd]!.id);
|
|
148
|
+
if (!closure || closure.incompleteToolGroup || closure.startIndex !== run[0]!.messageIndex ||
|
|
149
|
+
closure.endIndex > run[run.length - 1]!.messageIndex) continue;
|
|
150
|
+
const selected = run.filter((boundary) => boundary.messageIndex <= closure.endIndex);
|
|
151
|
+
if (selected[selected.length - 1]!.messageIndex !== closure.endIndex) continue;
|
|
152
|
+
if (selected.length < minMessages) continue;
|
|
153
|
+
if (selected.reduce((sum, boundary) => sum + boundary.tokenEstimate, 0) < settings.minTokens) continue;
|
|
154
|
+
return selected;
|
|
168
155
|
}
|
|
169
|
-
|
|
170
|
-
while (selected.length > 0 && selected[0]!.isSystemReminder) selected = selected.slice(1);
|
|
171
|
-
while (selected.length > 0 && selected[selected.length - 1]!.isSystemReminder) selected = selected.slice(0, -1);
|
|
172
|
-
if (selected.length < minMessages) return null;
|
|
173
|
-
if (selected.reduce((sum, boundary) => sum + boundary.tokenEstimate, 0) < settings.minTokens) return null;
|
|
174
|
-
return selected;
|
|
156
|
+
return null;
|
|
175
157
|
}
|
|
176
158
|
|
|
177
159
|
export function detectCompressionCandidate(
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { DcpState } from "./state.js";
|
|
2
|
+
import { stableMessageKeys } from "./pruner-message-ids.js";
|
|
3
|
+
|
|
4
|
+
/** Match the SDK provider transform, not UI visibility or tool-result errors. */
|
|
5
|
+
export function isReplayableContextMessage(message: any): boolean {
|
|
6
|
+
return !(message?.role === "assistant" &&
|
|
7
|
+
(message.stopReason === "error" || message.stopReason === "aborted"));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* SDK retry removes failed attempts from live agent state but keeps them in
|
|
12
|
+
* JSONL. Reload restores them; the provider later omits error/aborted assistants.
|
|
13
|
+
* Normalize before exact block replay so those history-only attempts cannot
|
|
14
|
+
* split a range that was committed from the live, post-retry context.
|
|
15
|
+
*
|
|
16
|
+
* Older blocks may explicitly include such an attempt. Keep every referenced
|
|
17
|
+
* occurrence until the existing matcher has checked its exact ID/order/hash;
|
|
18
|
+
* neither membership arrays nor signed message contents are rewritten. Any
|
|
19
|
+
* remaining attempts are omitted after replay, before new IDs/plans are built.
|
|
20
|
+
*/
|
|
21
|
+
export function prepareDcpReplayMessages(messages: any[], state: DcpState): any[] {
|
|
22
|
+
if (messages.every(isReplayableContextMessage)) return messages;
|
|
23
|
+
|
|
24
|
+
const referencedIds = new Set<string>();
|
|
25
|
+
for (const block of state.compressionBlocks) {
|
|
26
|
+
if (!block.active) continue;
|
|
27
|
+
for (const members of [block.mutationMembers, block.sourceMembers]) {
|
|
28
|
+
for (const member of members ?? []) referencedIds.add(member.stableId);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const keys = stableMessageKeys(messages);
|
|
32
|
+
return messages.filter((message, index) =>
|
|
33
|
+
isReplayableContextMessage(message) || referencedIds.has(keys[index]!),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -5,6 +5,7 @@ import { stripStaleDcpMetadataFromMessage } from "./pruner-metadata.js";
|
|
|
5
5
|
import { injectMessageIds } from "./pruner-message-ids.js";
|
|
6
6
|
import { copyRawMutationHash } from "./conversation-index.js";
|
|
7
7
|
import { applyToolOutputPruning } from "./pruner-tools.js";
|
|
8
|
+
import { isReplayableContextMessage, prepareDcpReplayMessages } from "./pruner-replay-context.js";
|
|
8
9
|
|
|
9
10
|
export type {
|
|
10
11
|
CompressionCandidate,
|
|
@@ -54,7 +55,7 @@ export function applyPruning(
|
|
|
54
55
|
): any[] {
|
|
55
56
|
// Deep-clone each message and its content to prevent mutations from
|
|
56
57
|
// affecting the original objects across context events.
|
|
57
|
-
|
|
58
|
+
let msgs: any[] = messages.map((m: any) => {
|
|
58
59
|
const clone = { ...m };
|
|
59
60
|
if (Array.isArray(clone.content)) {
|
|
60
61
|
clone.content = clone.content.map((contentBlock: any) =>
|
|
@@ -70,6 +71,8 @@ export function applyPruning(
|
|
|
70
71
|
return stripped;
|
|
71
72
|
});
|
|
72
73
|
|
|
74
|
+
msgs = prepareDcpReplayMessages(msgs, state);
|
|
75
|
+
|
|
73
76
|
// 1. Count user turns → update state.currentTurn. Do this before inserting
|
|
74
77
|
// synthetic compression summaries; the raw session is the source of truth.
|
|
75
78
|
state.currentTurn = msgs.filter((m) => m.role === "user").length;
|
|
@@ -79,6 +82,11 @@ export function applyPruning(
|
|
|
79
82
|
syncCompressionBlocks(msgs, state, config);
|
|
80
83
|
applyCompressionBlocks(msgs, state);
|
|
81
84
|
|
|
85
|
+
// Terminal attempts retained solely for old exact ledgers must not become
|
|
86
|
+
// new addressable source, protocol groups or budget pressure. Raw session
|
|
87
|
+
// entries remain untouched; this is the same omission the provider makes.
|
|
88
|
+
msgs = msgs.filter(isReplayableContextMessage);
|
|
89
|
+
|
|
82
90
|
// Existing explicit/emergency pruning decisions are replayed, but routine
|
|
83
91
|
// context construction never discovers new retroactive deletions. A new user
|
|
84
92
|
// turn must remain append-only for provider prefix caching; destructive
|
|
@@ -10,6 +10,7 @@ import { applyTaskMutation } from "./state/state-reducer.js";
|
|
|
10
10
|
import { getState, replaceState } from "./state/store.js";
|
|
11
11
|
import {
|
|
12
12
|
activateTodoStateScope,
|
|
13
|
+
appendTodoStateSnapshot,
|
|
13
14
|
DEFAULT_PROMPT_GUIDELINES,
|
|
14
15
|
DEFAULT_PROMPT_SNIPPET,
|
|
15
16
|
publishTodoState,
|
|
@@ -456,6 +457,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
456
457
|
}
|
|
457
458
|
const autoClear = autoClearCompletedTodos(result.state);
|
|
458
459
|
replaceState(autoClear.state);
|
|
460
|
+
appendTodoStateSnapshot(pi as any, action, params as Record<string, unknown>);
|
|
459
461
|
publishTodoState(pi as any, ctx, action, params as Record<string, unknown>);
|
|
460
462
|
if (todoThinkingEnabled) applyTodoThinkingAfterCommit(result.state, ctx, { action, params });
|
|
461
463
|
try {
|
|
@@ -249,7 +249,7 @@ export function publishTodoState(
|
|
|
249
249
|
publishRpcSessionState(ctx as TodoStateEventContext | undefined, TODO_STATE_EVENT, snapshot);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
-
function appendTodoStateSnapshot(
|
|
252
|
+
export function appendTodoStateSnapshot(
|
|
253
253
|
pi: TodoStateEntryWriter,
|
|
254
254
|
action: TaskAction,
|
|
255
255
|
params: Record<string, unknown>,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-ui-extend",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.50",
|
|
4
4
|
"description": "Pix: a workspace-first terminal UI for Pi with tabs, readable tool activity, voice input, and bundled agent tools.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"repository": {
|
|
@@ -48,19 +48,19 @@
|
|
|
48
48
|
],
|
|
49
49
|
"scripts": {
|
|
50
50
|
"predev": "npm run link:pix --silent",
|
|
51
|
-
"dev": "
|
|
52
|
-
"start": "
|
|
51
|
+
"dev": "tsx src/main.ts",
|
|
52
|
+
"start": "tsx src/main.ts",
|
|
53
53
|
"prebuild:pix": "npm run generate-schemas --silent",
|
|
54
|
-
"build:pix": "
|
|
54
|
+
"build:pix": "tsc -p tsconfig.json",
|
|
55
55
|
"prelink:pix": "npm run build:pix --silent",
|
|
56
|
-
"link:pix": "
|
|
57
|
-
"watch:pix": "npm run link:pix --silent &&
|
|
58
|
-
"watch:all": "
|
|
56
|
+
"link:pix": "npm link --silent",
|
|
57
|
+
"watch:pix": "npm run link:pix --silent && node scripts/watch-pix.mjs",
|
|
58
|
+
"watch:all": "node scripts/watch-all.mjs",
|
|
59
59
|
"rebuild:all": "npm run build:desktop",
|
|
60
|
-
"check": "
|
|
60
|
+
"check": "npm run check:inner",
|
|
61
61
|
"precheck:inner": "npm run generate-schemas --silent",
|
|
62
62
|
"check:inner": "npm run sync:sdk-pin:check --silent && tsc --noEmit && npm run test:inner",
|
|
63
|
-
"test": "
|
|
63
|
+
"test": "npm run test:inner",
|
|
64
64
|
"test:inner": "node --import tsx --test \"tests/**/*.test.ts\"",
|
|
65
65
|
"test:tools-suite": "npm --prefix external/pi-tools-suite test",
|
|
66
66
|
"test:dcp-session-sim": "npm --prefix external/pi-tools-suite run test:dcp-session-sim",
|
|
@@ -75,12 +75,12 @@
|
|
|
75
75
|
"sync:pi-tools-suite:check": "node scripts/sync-pi-tools-suite.mjs --check",
|
|
76
76
|
"sync:sdk-pin": "node scripts/sync-sdk-pin.mjs",
|
|
77
77
|
"sync:sdk-pin:check": "node scripts/sync-sdk-pin.mjs --check",
|
|
78
|
-
"test:coverage": "
|
|
79
|
-
"update-sdk-references": "
|
|
78
|
+
"test:coverage": "node --import tsx --test --experimental-test-coverage --test-coverage-include=src/**/*.ts --test-coverage-exclude=src/main.ts --test-coverage-lines=94 --test-coverage-branches=80 --test-coverage-functions=94 \"tests/**/*.test.ts\"",
|
|
79
|
+
"update-sdk-references": "node .pi/skills/pi-sdk/scripts/update-references.mjs",
|
|
80
80
|
"prepack": "npm run generate-schemas --silent",
|
|
81
81
|
"pack:dry-run": "npm pack --dry-run",
|
|
82
|
-
"smoke-test": "
|
|
83
|
-
"smoke-test:cli": "
|
|
82
|
+
"smoke-test": "bash scripts/smoke-test-package.sh",
|
|
83
|
+
"smoke-test:cli": "node scripts/smoke-test-package-cli.mjs",
|
|
84
84
|
"release:check": "npm run check && npm run build:pix && npm run pack:dry-run",
|
|
85
85
|
"publish-npm": "bash scripts/publish.sh",
|
|
86
86
|
"generate-schemas": "tsx scripts/generate-schemas.ts",
|