pi-ui-extend 1.0.47 → 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.
@@ -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
- runtimeGlobals[PIX_DCP_RUNTIME_STATS_SYMBOL] = () => ({ tokensSaved: state.tokensSaved })
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,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
- const msgs: any[] = messages.map((m: any) => {
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.47",
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": {