pi-ui-extend 1.0.41 → 1.0.42

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.
Files changed (124) hide show
  1. package/README.md +10 -2
  2. package/dist/app/app.d.ts +3 -0
  3. package/dist/app/app.js +26 -0
  4. package/dist/app/commands/command-host.d.ts +1 -0
  5. package/dist/app/commands/command-model-actions.js +6 -1
  6. package/dist/app/commands/command-registry.js +1 -1
  7. package/dist/app/commands/command-session-actions.js +9 -3
  8. package/dist/app/commands/reload-context-inventory.d.ts +11 -0
  9. package/dist/app/commands/reload-context-inventory.js +59 -0
  10. package/dist/app/extensions/extension-actions-controller.d.ts +1 -0
  11. package/dist/app/extensions/extension-actions-controller.js +6 -0
  12. package/dist/app/extensions/subagent-catalog-state.d.ts +9 -0
  13. package/dist/app/extensions/subagent-catalog-state.js +23 -0
  14. package/dist/app/rendering/editor-panels.js +10 -4
  15. package/dist/app/session/session-lifecycle-controller.d.ts +1 -0
  16. package/dist/app/session/session-lifecycle-controller.js +6 -0
  17. package/dist/app/session/tabs-controller.d.ts +1 -0
  18. package/dist/app/session/tabs-controller.js +19 -1
  19. package/dist/app/subagents/subagents-files.js +60 -1
  20. package/dist/app/subagents/subagents-model.d.ts +1 -0
  21. package/dist/app/subagents/subagents-model.js +18 -2
  22. package/dist/app/types.d.ts +5 -0
  23. package/dist/default-pix-config.js +10 -0
  24. package/dist/schemas/index.d.ts +1 -0
  25. package/dist/schemas/index.js +1 -0
  26. package/dist/schemas/pi-tools-suite-schema.d.ts +80 -58
  27. package/dist/schemas/pi-tools-suite-schema.js +43 -78
  28. package/dist/schemas/pix-schema.d.ts +7 -0
  29. package/dist/schemas/pix-schema.js +14 -0
  30. package/dist/schemas/tasks-schema.d.ts +18 -0
  31. package/dist/schemas/tasks-schema.js +41 -0
  32. package/docs/concurrency.md +6 -0
  33. package/docs/desktop-mvp.md +12 -1
  34. package/docs/desktop-task-manager.md +93 -82
  35. package/external/pi-tools-suite/README.md +37 -50
  36. package/external/pi-tools-suite/docs/browser-qa-subagent.md +19 -0
  37. package/external/pi-tools-suite/docs/evals.md +1 -1
  38. package/external/pi-tools-suite/docs/session-recovery.md +40 -14
  39. package/external/pi-tools-suite/docs/subagent-model-pools.md +38 -27
  40. package/external/pi-tools-suite/src/async-subagents/agents/frontier-review.md +23 -0
  41. package/external/pi-tools-suite/src/async-subagents/agents/presets.jsonc +16 -0
  42. package/external/pi-tools-suite/src/async-subagents/agents/research.md +5 -3
  43. package/external/pi-tools-suite/src/async-subagents/commands.ts +14 -70
  44. package/external/pi-tools-suite/src/async-subagents/core/activity.ts +33 -0
  45. package/external/pi-tools-suite/src/async-subagents/core/agent-catalog.ts +5 -4
  46. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +1 -1
  47. package/external/pi-tools-suite/src/async-subagents/core/agents-dir.ts +8 -3
  48. package/external/pi-tools-suite/src/async-subagents/core/config.ts +122 -312
  49. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +15 -2
  50. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +6 -2
  51. package/external/pi-tools-suite/src/async-subagents/core/state.ts +40 -0
  52. package/external/pi-tools-suite/src/async-subagents/core/types.ts +7 -0
  53. package/external/pi-tools-suite/src/async-subagents/index.ts +69 -5
  54. package/external/pi-tools-suite/src/async-subagents/lib.ts +7 -8
  55. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +8 -4
  56. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +3 -2
  57. package/external/pi-tools-suite/src/async-subagents/types.ts +1 -0
  58. package/external/pi-tools-suite/src/config.ts +41 -1
  59. package/external/pi-tools-suite/src/context-gateway/config.ts +11 -2
  60. package/external/pi-tools-suite/src/context-gateway/enforcement.ts +102 -0
  61. package/external/pi-tools-suite/src/context-gateway/index.ts +73 -19
  62. package/external/pi-tools-suite/src/context-gateway/storeless-capabilities.ts +3 -3
  63. package/external/pi-tools-suite/src/context-gateway/telemetry.ts +101 -28
  64. package/external/pi-tools-suite/src/context-gateway/types.ts +9 -3
  65. package/external/pi-tools-suite/src/context-inventory.ts +99 -0
  66. package/external/pi-tools-suite/src/dcp/auto-compress.ts +96 -44
  67. package/external/pi-tools-suite/src/dcp/commands.ts +18 -0
  68. package/external/pi-tools-suite/src/dcp/compress-tool.ts +170 -45
  69. package/external/pi-tools-suite/src/dcp/compression-blocks.ts +6 -58
  70. package/external/pi-tools-suite/src/dcp/compression-preview.ts +9 -0
  71. package/external/pi-tools-suite/src/dcp/config.ts +56 -13
  72. package/external/pi-tools-suite/src/dcp/prompts.ts +83 -192
  73. package/external/pi-tools-suite/src/dcp/protected-continuity.ts +175 -0
  74. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +1 -1
  75. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +6 -10
  76. package/external/pi-tools-suite/src/dcp/pruner-tools.ts +6 -3
  77. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +13 -9
  78. package/external/pi-tools-suite/src/index.ts +7 -1
  79. package/external/pi-tools-suite/src/resource-registry/index.ts +2290 -0
  80. package/external/pi-tools-suite/src/shell-command-policy.ts +219 -0
  81. package/external/pi-tools-suite/src/tool-descriptions.ts +3 -4
  82. package/package.json +4 -2
  83. package/schemas/pi-tools-suite.json +427 -280
  84. package/schemas/pix.json +24 -0
  85. package/schemas/tasks.json +132 -0
  86. package/skills/spec-wiki/SKILL.md +55 -0
  87. package/skills/spec-wiki/evals/evals.json +132 -0
  88. package/skills/spec-wiki/evals/files/bootstrap/architecture/rfc-007.md +14 -0
  89. package/skills/spec-wiki/evals/files/bootstrap/docs/how-to-pay.md +4 -0
  90. package/skills/spec-wiki/evals/files/bootstrap/docs/overview.md +9 -0
  91. package/skills/spec-wiki/evals/files/bootstrap/requirements/payment-behavior.md +17 -0
  92. package/skills/spec-wiki/evals/files/bootstrap/src/payments.ts +12 -0
  93. package/skills/spec-wiki/evals/files/bootstrap/tests/payments.test.ts +11 -0
  94. package/skills/spec-wiki/evals/files/focused/src/session.ts +5 -0
  95. package/skills/spec-wiki/evals/files/focused/tests/session.test.ts +17 -0
  96. package/skills/spec-wiki/evals/files/maintenance/low-signal/incoming/capture.txt +2 -0
  97. package/skills/spec-wiki/evals/files/maintenance/low-signal/src/capture.ts +3 -0
  98. package/skills/spec-wiki/evals/files/maintenance/move/docs/old-job-contract.md +18 -0
  99. package/skills/spec-wiki/evals/files/maintenance/move/src/job.ts +5 -0
  100. package/skills/spec-wiki/evals/files/maintenance/new-relation/docs/session-refresh.md +24 -0
  101. package/skills/spec-wiki/evals/files/maintenance/new-relation/incoming/refresh-worker.ts +7 -0
  102. package/skills/spec-wiki/evals/files/maintenance/new-relation/src/session.ts +7 -0
  103. package/skills/spec-wiki/evals/files/maintenance/no-impact/docs/auth-contract.md +17 -0
  104. package/skills/spec-wiki/evals/files/maintenance/no-impact/incoming/telemetry-format.ts +3 -0
  105. package/skills/spec-wiki/evals/files/maintenance/no-impact/src/auth.ts +3 -0
  106. package/skills/spec-wiki/evals/files/query/docs/how-to-run-jobs.md +4 -0
  107. package/skills/spec-wiki/evals/files/query/docs/spec-overview.md +9 -0
  108. package/skills/spec-wiki/evals/files/query/features/jobs/cancellation-contract.md +15 -0
  109. package/skills/spec-wiki/evals/files/query/features/jobs/retry-spec.md +6 -0
  110. package/skills/spec-wiki/evals/maintenance_regression.py +311 -0
  111. package/skills/spec-wiki/evals/retrieval-eval.md +37 -0
  112. package/skills/spec-wiki/evals/retrieval-evals.json +37 -0
  113. package/skills/spec-wiki/evals/trigger-evals.json +94 -0
  114. package/skills/spec-wiki/references/bootstrap.md +100 -0
  115. package/skills/spec-wiki/references/focused-spec.md +117 -0
  116. package/skills/spec-wiki/references/index-schema.md +330 -0
  117. package/skills/spec-wiki/references/maintenance.md +134 -0
  118. package/skills/spec-wiki/references/query.md +74 -0
  119. package/skills/spec-wiki/scripts/spec_wiki.py +2076 -0
  120. package/docs/desktop-markdown-media.md +0 -77
  121. package/external/pi-tools-suite/docs/dcp-emergency-current-turn.md +0 -102
  122. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +0 -54
  123. package/external/pi-tools-suite/src/skill-installer/index.ts +0 -333
  124. package/skills/spec-lite/SKILL.md +0 -140
@@ -1,15 +1,18 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { classifyShellCommand, type ShellCommandClassification } from "../shell-command-policy.js";
2
3
 
3
4
  import {
4
- CONTEXT_GATEWAY_ENFORCE_UNAVAILABLE,
5
+ contextGatewayBudgetForClass,
5
6
  loadContextGatewayConfig,
6
7
  } from "./config.js";
7
- import { ContextGatewayTelemetry } from "./telemetry.js";
8
+ import { planContextGatewayEnforcement } from "./enforcement.js";
9
+ import { classifyContextGatewayTool, ContextGatewayTelemetry } from "./telemetry.js";
8
10
  import type {
9
11
  ContextGatewayEffectiveMode,
10
12
  ContextGatewayMode,
11
13
  ContextGatewayResolvedConfig,
12
14
  ContextGatewayRuntimeState,
15
+ ContextGatewayToolClass,
13
16
  } from "./types.js";
14
17
 
15
18
  export interface RegisterContextGatewayOptions {
@@ -25,23 +28,23 @@ function formatStatus(runtime: RegisteredContextGateway): string {
25
28
  const telemetry = runtime.telemetry.snapshot();
26
29
  return [
27
30
  `Context Gateway requested=${runtime.requestedMode}, effective=${runtime.effectiveMode}.`,
28
- `Observed results=${telemetry.results}, errors=${telemetry.errors}, upstreamTruncated=${telemetry.upstreamTruncatedResults}, overBudget=${telemetry.overBudgetResults}, unbound results=${telemetry.unboundResults}.`,
29
- `Repeat candidates=${telemetry.repeatCandidateCount}, retrieval calls=${telemetry.retrievalCalls}.`,
31
+ `Observed results=${telemetry.results}, errors=${telemetry.errors}, upstreamTruncated=${telemetry.upstreamTruncatedResults}, overBudget=${telemetry.overBudgetResults}, enforced=${telemetry.enforcedResults}, unbound results=${telemetry.unboundResults}.`,
32
+ `Repeat exact reads=${telemetry.repeatCandidateCount}, same-source different-range reads=${telemetry.sameSourceDifferentRangeCount}, retrieval calls=${telemetry.retrievalCalls}.`,
30
33
  `Native policy results=${telemetry.nativePolicy.results}, refusals=${telemetry.nativePolicy.refusals}, full overrides=${telemetry.nativePolicy.fullOverrides}.`,
31
- `Observed contentBytes=${telemetry.contentBytes}, textBytes=${telemetry.textBytes}, detailsBytes=${telemetry.detailsBytes}, potentialBytesOverBudget=${telemetry.potentialBytesOverBudget}.`,
34
+ `Source contentBytes=${telemetry.contentBytes}, deliveredContentBytes=${telemetry.deliveredContentBytes}, actualBytesSaved=${telemetry.actualBytesSaved}, potentialBytesOverBudget=${telemetry.potentialBytesOverBudget}, detailsBytes=${telemetry.detailsBytes}.`,
32
35
  ].join("\n");
33
36
  }
34
37
 
35
38
  function formatDoctor(runtime: RegisteredContextGateway): string {
36
39
  const lines = [
37
40
  `Context Gateway doctor: requested=${runtime.requestedMode}, effective=${runtime.effectiveMode}.`,
38
- "Capture: read=limited; bash=limited+temp-output; repo_*=suite-adapter-not-wired; ast_grep=suite-adapter-not-wired.",
41
+ "Budgets: read=maxExactReadBytes; repo search/AST/structure=maxSearchBytes; other results=maxResultBytes; compact inline view=maxInlineBytes.",
42
+ "Enforce: recognised complete simple test/build output=bounded parser compact; partial/unknown/compound/upstream-truncated/non-text=passthrough.",
43
+ "Read: full passthrough even when over budget; exact recovery/archive is not wired, so irreversible read truncation is disabled.",
44
+ "Repo tools: producer-native compact is supported with repoDiscovery.profile=native-compact; Gateway never post-hoc slices repo output.",
39
45
  "External paths: MCP=current Pix adapter unsupported; direct parent browser capture=unsupported.",
40
46
  "Storage: disabled; no durable archive, index, quota reservation, or retrieval tools are active.",
41
47
  ];
42
- if (runtime.requestedMode === "enforce") {
43
- lines.push("BLOCKED: enforce is unavailable in P01; effective mode is off.");
44
- }
45
48
  for (const issue of runtime.config.issues) lines.push(`Config issue: ${issue}`);
46
49
  return lines.join("\n");
47
50
  }
@@ -57,8 +60,9 @@ export function registerContextGateway(
57
60
  const config = (options.loadConfig ?? (() => loadContextGatewayConfig()))();
58
61
  const telemetry = options.telemetry ?? new ContextGatewayTelemetry();
59
62
  let requestedMode = config.mode;
60
- let effectiveMode: ContextGatewayEffectiveMode = requestedMode === "enforce" ? "off" : requestedMode;
61
- const inFlightToolCalls = new Set<string>();
63
+ let effectiveMode: ContextGatewayEffectiveMode = requestedMode;
64
+ type InFlight = { toolClass: ContextGatewayToolClass; shell: ShellCommandClassification };
65
+ const inFlightToolCalls = new Map<string, InFlight>();
62
66
 
63
67
  const resetRuntimeTelemetry = (): void => {
64
68
  inFlightToolCalls.clear();
@@ -71,9 +75,6 @@ export function registerContextGateway(
71
75
  config,
72
76
  telemetry,
73
77
  setRuntimeMode(mode) {
74
- if (mode === "enforce") {
75
- return { ok: false, message: CONTEXT_GATEWAY_ENFORCE_UNAVAILABLE };
76
- }
77
78
  if (mode !== effectiveMode && inFlightToolCalls.size > 0) {
78
79
  return {
79
80
  ok: false,
@@ -88,14 +89,67 @@ export function registerContextGateway(
88
89
  };
89
90
 
90
91
  pi.on("tool_call", async (event) => {
91
- if (typeof event.toolCallId === "string") inFlightToolCalls.add(event.toolCallId);
92
- if (effectiveMode === "observe") telemetry.recordToolCall(event);
92
+ if (typeof event.toolCallId === "string" && typeof event.toolName === "string") {
93
+ const toolClass = classifyContextGatewayTool(event.toolName);
94
+ inFlightToolCalls.set(event.toolCallId, {
95
+ toolClass,
96
+ shell: toolClass === "shell"
97
+ ? classifyShellCommand(event.input)
98
+ : { scope: "unknown", kind: "unknown" },
99
+ });
100
+ }
101
+ if (effectiveMode === "observe" || effectiveMode === "enforce") telemetry.recordToolCall(event);
93
102
  return undefined;
94
103
  });
95
104
 
96
105
  pi.on("tool_result", async (event) => {
97
106
  try {
98
- if (effectiveMode === "observe") telemetry.recordToolResult(event, config.budgets.maxResultBytes);
107
+ if (effectiveMode === "off") return undefined;
108
+ const binding = typeof event.toolCallId === "string"
109
+ ? inFlightToolCalls.get(event.toolCallId)
110
+ : undefined;
111
+ const classBudget = binding
112
+ ? contextGatewayBudgetForClass(binding.toolClass, config.budgets)
113
+ : config.budgets.maxResultBytes;
114
+ const enforcement = effectiveMode === "enforce" && binding
115
+ ? planContextGatewayEnforcement({
116
+ event,
117
+ toolClass: binding.toolClass,
118
+ shell: binding.shell,
119
+ budgetBytes: classBudget,
120
+ maxInlineBytes: Math.min(
121
+ config.budgets.maxInlineBytes,
122
+ classBudget,
123
+ ),
124
+ })
125
+ : undefined;
126
+ telemetry.recordToolResult(event, config.budgets, {
127
+ maxInlineBytes: config.budgets.maxInlineBytes,
128
+ ...(enforcement ? {
129
+ delivery: {
130
+ representation: enforcement.representation,
131
+ contentBytes: enforcement.contentBytes,
132
+ textBytes: enforcement.textBytes,
133
+ },
134
+ } : {}),
135
+ });
136
+ if (enforcement?.representation === "test-build-compact") {
137
+ const originalDetails = event.details && typeof event.details === "object" && !Array.isArray(event.details)
138
+ ? event.details as Record<string, unknown>
139
+ : {};
140
+ return {
141
+ content: enforcement.content as any,
142
+ details: {
143
+ ...originalDetails,
144
+ contextGateway: {
145
+ version: 1,
146
+ representation: enforcement.representation,
147
+ sourceContentBytes: enforcement.sourceContentBytes,
148
+ deliveredContentBytes: enforcement.contentBytes,
149
+ },
150
+ },
151
+ };
152
+ }
99
153
  } finally {
100
154
  if (typeof event.toolCallId === "string") inFlightToolCalls.delete(event.toolCallId);
101
155
  }
@@ -118,7 +172,7 @@ export function registerContextGateway(
118
172
  });
119
173
 
120
174
  pi.registerCommand("context-gateway", {
121
- description: "Show Context Gateway status/doctor output or change the runtime-only off/observe mode.",
175
+ description: "Show Context Gateway status/doctor output or change runtime-only off/observe/enforce mode.",
122
176
  handler: async (rawArgs, ctx) => {
123
177
  const args = rawArgs.trim().split(/\s+/).filter(Boolean);
124
178
  const action = (args[0] ?? "status").toLowerCase();
@@ -127,7 +181,7 @@ export function registerContextGateway(
127
181
  return;
128
182
  }
129
183
  if (action === "doctor") {
130
- notify(ctx, formatDoctor(runtime), runtime.requestedMode === "enforce" ? "warning" : "info");
184
+ notify(ctx, formatDoctor(runtime), runtime.config.issues.length > 0 ? "warning" : "info");
131
185
  return;
132
186
  }
133
187
  if (action === "mode") {
@@ -1,5 +1,5 @@
1
1
  export type StorelessCapabilityStatus = "supported" | "limited" | "unsupported";
2
- export type StorelessCapabilityStrategy = "native-passthrough" | "metadata-only" | "pure-parser-candidate" | "none";
2
+ export type StorelessCapabilityStrategy = "native-passthrough" | "metadata-only" | "pure-parser-candidate" | "pure-parser-compact" | "none";
3
3
 
4
4
  export interface StorelessCapabilityEntry {
5
5
  surface:
@@ -27,9 +27,9 @@ export const STORELESS_CAPABILITIES: readonly StorelessCapabilityEntry[] = Objec
27
27
  {
28
28
  surface: "test-build",
29
29
  status: "limited",
30
- strategy: "pure-parser-candidate",
30
+ strategy: "pure-parser-compact",
31
31
  lifetime: "current-result",
32
- reason: "Bun/TAP/bounded TypeScript parsing is observe-only; production delivery remains passthrough without a recovery/lifetime contract.",
32
+ reason: "Enforce compacts only recognised complete simple Bun/TAP/bounded TypeScript output; partial, compound, truncated, or unknown results remain passthrough.",
33
33
  },
34
34
  {
35
35
  surface: "mutation-lsp",
@@ -1,8 +1,11 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { classifyShellCommand } from "../shell-command-policy.js";
2
3
 
3
4
  import { accountContextGatewayParts } from "./accounting.js";
5
+ import { contextGatewayBudgetForClass } from "./config.js";
4
6
  import { parseTestBuildOutput, planProspectiveTestOutputDelivery } from "./test-output-parser.js";
5
7
  import type {
8
+ ContextGatewayBudgets,
6
9
  ContextGatewayClassTelemetry,
7
10
  ContextGatewayNativePolicyReason,
8
11
  ContextGatewayNativePolicyTelemetry,
@@ -40,6 +43,7 @@ const NATIVE_POLICY_REASONS = new Set<ContextGatewayNativePolicyReason>([
40
43
  type CallBinding = {
41
44
  toolClass: ContextGatewayToolClass;
42
45
  inputFingerprint?: string;
46
+ readSourceFingerprint?: string;
43
47
  shellCommandScope?: "simple" | "compound" | "unknown";
44
48
  };
45
49
 
@@ -60,12 +64,15 @@ function emptyCounters(): ContextGatewayClassTelemetry {
60
64
  results: 0,
61
65
  errors: 0,
62
66
  contentBytes: 0,
67
+ deliveredContentBytes: 0,
63
68
  textBytes: 0,
64
69
  imageBytes: 0,
65
70
  detailsBytes: 0,
66
71
  upstreamTruncatedResults: 0,
67
72
  overBudgetResults: 0,
68
73
  potentialBytesOverBudget: 0,
74
+ enforcedResults: 0,
75
+ actualBytesSaved: 0,
69
76
  };
70
77
  }
71
78
 
@@ -92,13 +99,46 @@ function inputFingerprint(toolName: string, input: unknown): string {
92
99
  return createHash("sha256").update(`${toolName}\u0000${encoded}`).digest("hex");
93
100
  }
94
101
 
95
- function shellCommandScope(input: unknown): "simple" | "compound" | "unknown" {
96
- if (!input || typeof input !== "object" || Array.isArray(input)) return "unknown";
97
- const command = (input as Record<string, unknown>).command;
98
- if (typeof command !== "string" || command.trim().length === 0) return "unknown";
99
- // Conservative only: false positives merely keep passthrough. This does not
100
- // parse, rewrite, approve, or execute the shell command.
101
- return /[\r\n;|`]|&&|\$\(/.test(command) ? "compound" : "simple";
102
+ function normalizedReadPath(value: unknown): string | undefined {
103
+ if (typeof value !== "string") return undefined;
104
+ const raw = value.trim().replace(/\\/g, "/").replace(/\/{2,}/g, "/");
105
+ if (!raw) return undefined;
106
+ const prefix = raw.startsWith("/") ? "/" : /^[A-Za-z]:\//.test(raw) ? raw.slice(0, 3).toLowerCase() : "";
107
+ const body = prefix === "/" ? raw.slice(1) : prefix ? raw.slice(3) : raw;
108
+ const stack: string[] = [];
109
+ for (const part of body.split("/")) {
110
+ if (!part || part === ".") continue;
111
+ if (part === "..") {
112
+ if (stack.length > 0 && stack.at(-1) !== "..") stack.pop();
113
+ else if (!prefix) stack.push(part);
114
+ continue;
115
+ }
116
+ stack.push(part);
117
+ }
118
+ return `${prefix}${stack.join("/")}` || prefix || ".";
119
+ }
120
+
121
+ function normalizedReadRangeValue(value: unknown): number | undefined {
122
+ if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) return value;
123
+ if (typeof value === "string" && /^\d+$/.test(value.trim())) {
124
+ const parsed = Number(value);
125
+ if (Number.isSafeInteger(parsed)) return parsed;
126
+ }
127
+ return undefined;
128
+ }
129
+
130
+ export function contextGatewayReadIdentity(input: unknown): { exact?: string; source?: string } {
131
+ if (!input || typeof input !== "object" || Array.isArray(input)) return {};
132
+ const record = input as Record<string, unknown>;
133
+ const path = normalizedReadPath(record.path ?? record.file ?? record.filePath);
134
+ if (!path) return {};
135
+ const source = inputFingerprint("read-source", { path });
136
+ const exact = inputFingerprint("read-exact", {
137
+ path,
138
+ offset: normalizedReadRangeValue(record.offset ?? record.start ?? record.line),
139
+ limit: normalizedReadRangeValue(record.limit ?? record.lines ?? record.count),
140
+ });
141
+ return { source, exact };
102
142
  }
103
143
 
104
144
  function byteLengthJson(value: unknown): number {
@@ -209,19 +249,31 @@ export function classifyContextGatewayTool(toolName: string): ContextGatewayTool
209
249
 
210
250
  export function observeToolResult(
211
251
  event: ToolResultLike,
212
- maxResultBytes: number,
213
- options: { toolClass?: ContextGatewayToolClass; shellCommandScope?: "simple" | "compound" | "unknown" } = {},
252
+ budget: number | ContextGatewayBudgets,
253
+ options: {
254
+ toolClass?: ContextGatewayToolClass;
255
+ shellCommandScope?: "simple" | "compound" | "unknown";
256
+ maxInlineBytes?: number;
257
+ delivery?: { representation: "passthrough" | "test-build-compact"; contentBytes: number; textBytes: number };
258
+ } = {},
214
259
  ): ContextGatewayObservation {
215
260
  const toolName = typeof event.toolName === "string" ? event.toolName : "unknown";
216
261
  const sizes = contentSizes(event.content);
217
262
  const detailsBytes = byteLengthJson(event.details);
218
- const potentialBytesOverBudget = Math.max(0, sizes.contentBytes - maxResultBytes);
219
263
  const toolClass = options.toolClass ?? classifyContextGatewayTool(toolName);
264
+ const budgetBytes = typeof budget === "number" ? budget : contextGatewayBudgetForClass(toolClass, budget);
265
+ const potentialBytesOverBudget = Math.max(0, sizes.contentBytes - budgetBytes);
220
266
  const completeness = isTruncated(event.details) ? "upstream-truncated" : "unknown";
267
+ const delivery = options.delivery ?? {
268
+ representation: "passthrough" as const,
269
+ contentBytes: sizes.contentBytes,
270
+ textBytes: sizes.textBytes,
271
+ };
221
272
  const observation: ContextGatewayObservation = {
222
273
  version: 1,
223
274
  toolClass,
224
275
  outcome: event.isError === true ? "error" : "success",
276
+ budgetBytes,
225
277
  source: {
226
278
  kind: "tool-result-boundary",
227
279
  completeness,
@@ -231,13 +283,10 @@ export function observeToolResult(
231
283
  detailsBytes,
232
284
  },
233
285
  view: { kind: "text-content", textBytes: sizes.textBytes },
234
- delivery: {
235
- representation: "passthrough",
236
- contentBytes: sizes.contentBytes,
237
- textBytes: sizes.textBytes,
238
- },
239
- overBudget: sizes.contentBytes > maxResultBytes,
286
+ delivery,
287
+ overBudget: sizes.contentBytes > budgetBytes,
240
288
  potentialBytesOverBudget,
289
+ actualBytesSaved: Math.max(0, sizes.contentBytes - delivery.contentBytes),
241
290
  };
242
291
  if (toolClass === "shell") {
243
292
  const parsed = parseTestBuildOutput({
@@ -246,7 +295,11 @@ export function observeToolResult(
246
295
  upstreamTruncated: completeness === "upstream-truncated",
247
296
  });
248
297
  const commandScope = options.shellCommandScope ?? "unknown";
249
- const prospective = planProspectiveTestOutputDelivery(parsed, maxResultBytes, { commandScope });
298
+ const prospective = planProspectiveTestOutputDelivery(
299
+ parsed,
300
+ options.maxInlineBytes ?? budgetBytes,
301
+ { commandScope },
302
+ );
250
303
  observation.testOutput = {
251
304
  parserVersion: 1,
252
305
  classification: parsed.classification,
@@ -268,8 +321,10 @@ export class ContextGatewayTelemetry {
268
321
  private byClass: Partial<Record<ContextGatewayToolClass, ContextGatewayClassTelemetry>> = {};
269
322
  private bindings = new Map<string, CallBinding>();
270
323
  private seenInputFingerprints = new Set<string>();
324
+ private seenReadSourceFingerprints = new Set<string>();
271
325
  private unboundResults = 0;
272
326
  private repeatCandidateCount = 0;
327
+ private sameSourceDifferentRangeCount = 0;
273
328
  private retrievalCalls = 0;
274
329
  private nativePolicy: ContextGatewayNativePolicyTelemetry = {
275
330
  results: 0,
@@ -286,27 +341,39 @@ export class ContextGatewayTelemetry {
286
341
  if (normalizedToolName === "artifact_read" || normalizedToolName === "artifact_search") {
287
342
  this.retrievalCalls += 1;
288
343
  }
289
- const fingerprint = normalizedToolName === "read"
290
- ? inputFingerprint(event.toolName, event.input)
291
- : undefined;
344
+ const readFingerprint = normalizedToolName === "read" ? contextGatewayReadIdentity(event.input) : {};
345
+ const fingerprint = readFingerprint.exact;
292
346
  if (fingerprint) {
293
347
  if (this.seenInputFingerprints.has(fingerprint)) this.repeatCandidateCount += 1;
294
- else this.seenInputFingerprints.add(fingerprint);
348
+ else {
349
+ if (readFingerprint.source && this.seenReadSourceFingerprints.has(readFingerprint.source)) {
350
+ this.sameSourceDifferentRangeCount += 1;
351
+ }
352
+ this.seenInputFingerprints.add(fingerprint);
353
+ }
295
354
  }
355
+ if (readFingerprint.source) this.seenReadSourceFingerprints.add(readFingerprint.source);
356
+ const toolClass = classifyContextGatewayTool(event.toolName);
296
357
  this.bindings.set(event.toolCallId, {
297
- toolClass: classifyContextGatewayTool(event.toolName),
358
+ toolClass,
298
359
  inputFingerprint: fingerprint,
299
- ...(classifyContextGatewayTool(event.toolName) === "shell"
300
- ? { shellCommandScope: shellCommandScope(event.input) }
360
+ readSourceFingerprint: readFingerprint.source,
361
+ ...(toolClass === "shell"
362
+ ? { shellCommandScope: classifyShellCommand(event.input).scope }
301
363
  : {}),
302
364
  });
303
365
  }
304
366
 
305
- recordToolResult(event: ToolResultLike, maxResultBytes: number): ContextGatewayObservation {
367
+ recordToolResult(
368
+ event: ToolResultLike,
369
+ budget: number | ContextGatewayBudgets,
370
+ options: { delivery?: { representation: "passthrough" | "test-build-compact"; contentBytes: number; textBytes: number }; maxInlineBytes?: number } = {},
371
+ ): ContextGatewayObservation {
306
372
  const binding = typeof event.toolCallId === "string" ? this.bindings.get(event.toolCallId) : undefined;
307
- const observation = observeToolResult(event, maxResultBytes, {
373
+ const observation = observeToolResult(event, budget, {
308
374
  ...(binding ? { toolClass: binding.toolClass } : {}),
309
375
  ...(binding?.shellCommandScope ? { shellCommandScope: binding.shellCommandScope } : {}),
376
+ ...options,
310
377
  });
311
378
  if (typeof event.toolCallId !== "string" || !binding) {
312
379
  this.unboundResults += 1;
@@ -357,8 +424,8 @@ export class ContextGatewayTelemetry {
357
424
  }
358
425
 
359
426
  /** Backward-compatible convenience for observation-only callers without a tool_call event. */
360
- record(event: ToolResultLike, maxResultBytes: number): ContextGatewayObservation {
361
- const observation = observeToolResult(event, maxResultBytes);
427
+ record(event: ToolResultLike, budget: number | ContextGatewayBudgets): ContextGatewayObservation {
428
+ const observation = observeToolResult(event, budget);
362
429
  this.aggregateObservation(event, observation);
363
430
  return observation;
364
431
  }
@@ -368,8 +435,10 @@ export class ContextGatewayTelemetry {
368
435
  this.byClass = {};
369
436
  this.bindings.clear();
370
437
  this.seenInputFingerprints.clear();
438
+ this.seenReadSourceFingerprints.clear();
371
439
  this.unboundResults = 0;
372
440
  this.repeatCandidateCount = 0;
441
+ this.sameSourceDifferentRangeCount = 0;
373
442
  this.retrievalCalls = 0;
374
443
  this.nativePolicy = { results: 0, refusals: 0, fullOverrides: 0, byReason: {} };
375
444
  this.testOutput = emptyTestOutputTelemetry();
@@ -383,6 +452,7 @@ export class ContextGatewayTelemetry {
383
452
  pendingCalls: this.bindings.size,
384
453
  unboundResults: this.unboundResults,
385
454
  repeatCandidateCount: this.repeatCandidateCount,
455
+ sameSourceDifferentRangeCount: this.sameSourceDifferentRangeCount,
386
456
  retrievalCalls: this.retrievalCalls,
387
457
  nativePolicy: {
388
458
  ...this.nativePolicy,
@@ -404,12 +474,15 @@ export class ContextGatewayTelemetry {
404
474
  target.results += 1;
405
475
  if (observation.outcome === "error") target.errors += 1;
406
476
  target.contentBytes += observation.source.contentBytes;
477
+ target.deliveredContentBytes += observation.delivery.contentBytes;
407
478
  target.textBytes += observation.source.textBytes;
408
479
  target.imageBytes += observation.source.imageBytes;
409
480
  target.detailsBytes += observation.source.detailsBytes;
410
481
  if (observation.source.completeness === "upstream-truncated") target.upstreamTruncatedResults += 1;
411
482
  if (observation.overBudget) target.overBudgetResults += 1;
412
483
  target.potentialBytesOverBudget += observation.potentialBytesOverBudget;
484
+ if (observation.delivery.representation !== "passthrough") target.enforcedResults += 1;
485
+ target.actualBytesSaved += observation.actualBytesSaved;
413
486
  }
414
487
  }
415
488
 
@@ -1,6 +1,6 @@
1
1
  export type ContextGatewayMode = "off" | "observe" | "enforce";
2
2
 
3
- export type ContextGatewayEffectiveMode = Exclude<ContextGatewayMode, "enforce">;
3
+ export type ContextGatewayEffectiveMode = ContextGatewayMode;
4
4
 
5
5
  export interface ContextGatewayBudgets {
6
6
  maxInlineBytes: number;
@@ -10,7 +10,7 @@ export interface ContextGatewayBudgets {
10
10
  maxSearchMatches: number;
11
11
  }
12
12
 
13
- /** Parsed layered config. `enforce` remains a valid requested value so doctor can refuse it explicitly. */
13
+ /** Parsed layered config. `enforce` is selective and fail-open for unsupported result classes. */
14
14
  export interface ContextGatewayResolvedConfig {
15
15
  mode: ContextGatewayMode;
16
16
  budgets: ContextGatewayBudgets;
@@ -48,7 +48,7 @@ export interface ContextGatewayObservedView {
48
48
  }
49
49
 
50
50
  export interface ContextGatewayObservedDelivery {
51
- representation: "passthrough";
51
+ representation: "passthrough" | "test-build-compact";
52
52
  contentBytes: number;
53
53
  textBytes: number;
54
54
  }
@@ -81,11 +81,13 @@ export interface ContextGatewayObservation {
81
81
  version: 1;
82
82
  toolClass: ContextGatewayToolClass;
83
83
  outcome: ContextGatewayExecutionOutcome;
84
+ budgetBytes: number;
84
85
  source: ContextGatewayObservedSource;
85
86
  view: ContextGatewayObservedView;
86
87
  delivery: ContextGatewayObservedDelivery;
87
88
  overBudget: boolean;
88
89
  potentialBytesOverBudget: number;
90
+ actualBytesSaved: number;
89
91
  testOutput?: ContextGatewayTestOutputObservation;
90
92
  }
91
93
 
@@ -93,12 +95,15 @@ export interface ContextGatewayClassTelemetry {
93
95
  results: number;
94
96
  errors: number;
95
97
  contentBytes: number;
98
+ deliveredContentBytes: number;
96
99
  textBytes: number;
97
100
  imageBytes: number;
98
101
  detailsBytes: number;
99
102
  upstreamTruncatedResults: number;
100
103
  overBudgetResults: number;
101
104
  potentialBytesOverBudget: number;
105
+ enforcedResults: number;
106
+ actualBytesSaved: number;
102
107
  }
103
108
 
104
109
  export type ContextGatewayNativePolicyReason =
@@ -134,6 +139,7 @@ export interface ContextGatewayTelemetrySnapshot extends ContextGatewayClassTele
134
139
  pendingCalls: number;
135
140
  unboundResults: number;
136
141
  repeatCandidateCount: number;
142
+ sameSourceDifferentRangeCount: number;
137
143
  retrievalCalls: number;
138
144
  nativePolicy: ContextGatewayNativePolicyTelemetry;
139
145
  testOutput: ContextGatewayTestOutputTelemetry;
@@ -0,0 +1,99 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ currentModelRef,
4
+ filterSubagentConfigForParentModel,
5
+ loadSubagentConfig,
6
+ } from "./async-subagents/lib.js";
7
+ import { publishRpcSessionState } from "./lib/rpc-session-state.js";
8
+
9
+ export const CONTEXT_INVENTORY_EVENT = "pi-tools-suite:context-inventory";
10
+
11
+ export interface ContextInventoryState {
12
+ version: 1;
13
+ reason?: "startup" | "reload" | "new" | "resume" | "fork" | "model_select";
14
+ sessionId?: string;
15
+ sessionFile?: string;
16
+ model?: string;
17
+ thinking?: string;
18
+ tools: string[];
19
+ skills: string[];
20
+ agents?: string[];
21
+ }
22
+
23
+ export function createContextInventoryState(
24
+ pi: Pick<ExtensionAPI, "getActiveTools" | "getCommands">,
25
+ ctx: Pick<ExtensionContext, "cwd" | "model" | "sessionManager">,
26
+ reason?: ContextInventoryState["reason"],
27
+ ): ContextInventoryState {
28
+ const tools = unique(pi.getActiveTools());
29
+ const skillsReadable = tools.includes("read") || tools.includes("bash");
30
+ const skills = skillsReadable
31
+ ? unique(pi.getCommands()
32
+ .filter((command) => command.source === "skill")
33
+ .map((command) => command.name.replace(/^skill:/u, "")))
34
+ .sort(compareText)
35
+ : [];
36
+ const model = currentModelRef(ctx.model);
37
+ const thinking = typeof (ctx as { thinkingLevel?: unknown }).thinkingLevel === "string"
38
+ ? (ctx as { thinkingLevel?: string }).thinkingLevel?.trim() || undefined
39
+ : undefined;
40
+ const agents = tools.includes("subagents") ? effectiveAgentTypes(ctx.cwd, model) : [];
41
+ const sessionId = safeSessionId(ctx.sessionManager);
42
+ const sessionFile = safeSessionFile(ctx.sessionManager);
43
+ return {
44
+ version: 1,
45
+ ...(reason ? { reason } : {}),
46
+ ...(sessionId ? { sessionId } : {}),
47
+ ...(sessionFile ? { sessionFile } : {}),
48
+ ...(model ? { model } : {}),
49
+ ...(thinking ? { thinking } : {}),
50
+ tools,
51
+ skills,
52
+ ...(agents === undefined ? {} : { agents }),
53
+ };
54
+ }
55
+
56
+ export function publishContextInventoryState(
57
+ pi: ExtensionAPI,
58
+ ctx: ExtensionContext,
59
+ reason?: ContextInventoryState["reason"],
60
+ ): void {
61
+ const state = createContextInventoryState(pi, ctx, reason);
62
+ pi.events?.emit?.(CONTEXT_INVENTORY_EVENT, state);
63
+ publishRpcSessionState(ctx, CONTEXT_INVENTORY_EVENT, state);
64
+ }
65
+
66
+ function effectiveAgentTypes(cwd: string, model: string | undefined): string[] | undefined {
67
+ try {
68
+ const config = filterSubagentConfigForParentModel(loadSubagentConfig(cwd), model);
69
+ return Object.keys(config.types).sort(compareText);
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ }
74
+
75
+ function safeSessionId(sessionManager: ExtensionContext["sessionManager"]): string | undefined {
76
+ try {
77
+ const sessionId = sessionManager.getSessionId();
78
+ return sessionId?.trim() || undefined;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ function safeSessionFile(sessionManager: ExtensionContext["sessionManager"]): string | undefined {
85
+ try {
86
+ const sessionFile = sessionManager.getSessionFile();
87
+ return sessionFile?.trim() || undefined;
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ function unique(values: readonly string[]): string[] {
94
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
95
+ }
96
+
97
+ function compareText(left: string, right: string): number {
98
+ return left.localeCompare(right);
99
+ }