pi-ui-extend 0.1.2 → 0.1.4

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 (58) hide show
  1. package/README.md +14 -3
  2. package/bin/pix.mjs +24 -1
  3. package/dist/app/app.d.ts +0 -1
  4. package/dist/app/app.js +4 -7
  5. package/dist/app/cli.js +3 -1
  6. package/dist/app/clipboard.d.ts +2 -0
  7. package/dist/app/clipboard.js +54 -1
  8. package/dist/app/conversation-entry-renderer.d.ts +0 -1
  9. package/dist/app/conversation-entry-renderer.js +2 -6
  10. package/dist/app/conversation-tool-renderer.js +2 -3
  11. package/dist/app/conversation-viewport.d.ts +0 -1
  12. package/dist/app/conversation-viewport.js +0 -1
  13. package/dist/app/dcp-stats.js +143 -14
  14. package/dist/app/install.d.ts +10 -0
  15. package/dist/app/install.js +135 -0
  16. package/dist/app/mouse-controller.d.ts +6 -6
  17. package/dist/app/mouse-controller.js +19 -1
  18. package/dist/app/nerd-font-controller.d.ts +6 -0
  19. package/dist/app/nerd-font-controller.js +98 -17
  20. package/dist/app/render-controller.js +5 -4
  21. package/dist/app/startup-checks.js +10 -7
  22. package/dist/app/toast-controller.d.ts +5 -2
  23. package/dist/app/toast-controller.js +7 -4
  24. package/dist/app/toast-renderer.d.ts +3 -0
  25. package/dist/app/toast-renderer.js +72 -11
  26. package/dist/app/types.d.ts +8 -4
  27. package/dist/config.d.ts +0 -3
  28. package/dist/config.js +0 -79
  29. package/dist/default-pix-config.js +2 -2
  30. package/dist/markdown-format.js +18 -1
  31. package/dist/ui.d.ts +5 -1
  32. package/dist/ui.js +2 -2
  33. package/external/pi-tools-suite/README.md +4 -4
  34. package/external/pi-tools-suite/licenses/opencode-dynamic-context-pruning-AGPL-3.0.txt +619 -0
  35. package/external/pi-tools-suite/package.json +1 -1
  36. package/external/pi-tools-suite/src/config.ts +5 -1
  37. package/external/pi-tools-suite/src/{compress → dcp}/config.ts +10 -70
  38. package/external/pi-tools-suite/src/{compress → dcp}/index.ts +16 -66
  39. package/external/pi-tools-suite/src/dcp/ui.ts +45 -0
  40. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +3 -2
  41. package/external/pi-tools-suite/src/index.ts +1 -1
  42. package/external/pi-tools-suite/src/tool-descriptions.ts +1 -1
  43. package/package.json +1 -1
  44. package/external/pi-tools-suite/src/compress/dcp-tui-filter.ts +0 -498
  45. package/external/pi-tools-suite/src/compress/ui.ts +0 -308
  46. /package/external/pi-tools-suite/src/{compress → dcp}/commands.ts +0 -0
  47. /package/external/pi-tools-suite/src/{compress → dcp}/compress-tool.ts +0 -0
  48. /package/external/pi-tools-suite/src/{compress → dcp}/compression-blocks.ts +0 -0
  49. /package/external/pi-tools-suite/src/{compress → dcp}/prompts.ts +0 -0
  50. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-candidates.ts +0 -0
  51. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-compression-blocks.ts +0 -0
  52. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-message-ids.ts +0 -0
  53. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-metadata.ts +0 -0
  54. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-nudge.ts +0 -0
  55. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-tools.ts +0 -0
  56. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-types.ts +0 -0
  57. /package/external/pi-tools-suite/src/{compress → dcp}/pruner.ts +0 -0
  58. /package/external/pi-tools-suite/src/{compress → dcp}/state.ts +0 -0
package/README.md CHANGED
@@ -16,8 +16,9 @@ The npm package is currently named `pi-ui-extend` and installs the `pix` CLI.
16
16
 
17
17
  ## Requirements
18
18
 
19
- - Node.js `24.x` (`24.16.0` is pinned for development).
20
- - A terminal with good Unicode support. JetBrainsMono Nerd Font is recommended for the default icon theme.
19
+ - Node.js `>=22.19.0 <25` (`24.16.0` is pinned for development).
20
+ - A terminal with good Unicode support. JetBrainsMono Nerd Font is recommended for the default icon theme; Pix can install it for the current user on macOS, Linux, and Windows.
21
+ - Linux clipboard support: Pix first tries `wl-copy`, `xclip`, `xsel`, or `termux-clipboard-set`, then falls back to its bundled native clipboard package.
21
22
  - Optional for voice input: SoX (`rec`/`sox`), `ffmpeg`, or Linux `arecord`.
22
23
 
23
24
  Development uses `mise` when available. `.node-version` and `.nvmrc` are also provided for other Node version managers.
@@ -32,6 +33,16 @@ npm install -g pi-ui-extend --ignore-scripts
32
33
 
33
34
  The published package contains built JavaScript, the `pix` launcher, renderer extensions, documentation, and the bundled `pi-tools-suite` extension payload. Users do not need to clone the repository or build TypeScript locally.
34
35
 
36
+ After installation, run the setup check when installing on a new machine:
37
+
38
+ ```bash
39
+ pix install
40
+ # or report only:
41
+ pix install --check
42
+ ```
43
+
44
+ `pix install` checks the icon font, `pi` CLI availability, and clipboard helpers. The `pix` launcher also prepends Pix's bundled dependency bin directory to `PATH`, so a package-manager install can use the bundled Pi CLI even when a separate global `pi` command is not present.
45
+
35
46
  On startup, Pix ensures the bundled suite is available at:
36
47
 
37
48
  ```text
@@ -185,7 +196,7 @@ Runtime requirements:
185
196
 
186
197
  - Optional npm package `vosk`. Pix installs or rebuilds it automatically with scripts enabled on first voice start if the native binding is missing.
187
198
  - A local recorder: SoX (`rec`/`sox`) preferred, or `ffmpeg`; Linux also supports `arecord`.
188
- - JetBrainsMono Nerd Font for default app icons. On macOS, Pix checks this at startup and can install the Homebrew cask `font-jetbrains-mono-nerd-font` when it is missing.
199
+ - JetBrainsMono Nerd Font for default app icons. Pix checks this at startup and can install the font for the current user on macOS, Linux, and Windows when it is missing.
189
200
 
190
201
  If your terminal renders missing glyphs, start Pix with `PIX_USE_FALLBACK_ICONS=1` or set `iconTheme` to `fallback` in `~/.config/pi/pix.jsonc`.
191
202
 
package/bin/pix.mjs CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync, readdirSync, statSync } from "node:fs";
4
- import { dirname, join } from "node:path";
4
+ import { delimiter, dirname, join } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
7
  const minimumNodeVersion = [22, 19, 0];
8
8
  const minimumNodeVersionLabel = "22.19.0";
9
+ const launcherPath = fileURLToPath(import.meta.url);
10
+ const packageRoot = dirname(dirname(launcherPath));
9
11
  const mainPath = fileURLToPath(new URL("../dist/main.js", import.meta.url));
10
12
  const updatePath = fileURLToPath(new URL("../dist/app/update.js", import.meta.url));
13
+ const installPath = fileURLToPath(new URL("../dist/app/install.js", import.meta.url));
11
14
  const distPath = dirname(mainPath);
12
15
  const rawArgs = process.argv.slice(2);
13
16
  const childArgs = [];
@@ -40,6 +43,15 @@ if (childArgs[0] === "update") {
40
43
  process.exit(await runPixUpdateCli(childArgs.slice(1)));
41
44
  }
42
45
 
46
+ if (childArgs[0] === "install" || childArgs[0] === "setup") {
47
+ if (!existsSync(installPath)) {
48
+ console.error("pix install is not built yet. Run `npm run build:pix` or update from a published package.");
49
+ process.exit(1);
50
+ }
51
+ const { runPixInstallCli } = await import(new URL("../dist/app/install.js", import.meta.url));
52
+ process.exit(await runPixInstallCli(childArgs.slice(1), { env: pixChildEnv() }));
53
+ }
54
+
43
55
  if (!existsSync(mainPath)) {
44
56
  console.error("pix is not built yet. Run `npm run build:pix` or `npm run watch:pix`.");
45
57
  process.exit(1);
@@ -82,6 +94,7 @@ function isCurrentNodeSupported() {
82
94
  function startChild() {
83
95
  child = spawn(process.execPath, [mainPath, ...childArgs], {
84
96
  stdio: "inherit",
97
+ env: pixChildEnv(),
85
98
  });
86
99
 
87
100
  child.on("error", (error) => {
@@ -103,6 +116,16 @@ function startChild() {
103
116
  });
104
117
  }
105
118
 
119
+ function pixChildEnv() {
120
+ const env = { ...process.env };
121
+ const bundledBinPath = join(packageRoot, "node_modules", ".bin");
122
+ if (existsSync(bundledBinPath)) {
123
+ env.PATH = [bundledBinPath, env.PATH ?? ""].filter(Boolean).join(delimiter);
124
+ env.PIX_BUNDLED_PI_BIN = bundledBinPath;
125
+ }
126
+ return env;
127
+ }
128
+
106
129
  function startDistPolling() {
107
130
  const pollInterval = Number(process.env.PIX_RELOAD_POLL_MS ?? 1000);
108
131
  distPollTimer = setInterval(() => {
package/dist/app/app.d.ts CHANGED
@@ -19,7 +19,6 @@ export declare class PiUiExtendApp {
19
19
  private readonly extensionActions;
20
20
  private readonly pixConfig;
21
21
  private readonly outputFilters;
22
- private readonly suppressPendingDcpIdMetadata;
23
22
  private readonly commandController;
24
23
  private readonly inputActions;
25
24
  private readonly inputController;
package/dist/app/app.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { THEMES } from "../theme.js";
2
2
  import { InputEditor } from "../input-editor.js";
3
- import { compileOutputFilterPatterns, loadPixConfig, outputFiltersRemoveDcpIdMetadataLine, resolveToolRule, } from "../config.js";
3
+ import { compileOutputFilterPatterns, loadPixConfig, resolveToolRule, } from "../config.js";
4
4
  import { AppCommandController } from "./command-controller.js";
5
5
  import { ConversationViewport } from "./conversation-viewport.js";
6
6
  import { EditorLayoutRenderer } from "./editor-layout-renderer.js";
@@ -64,7 +64,6 @@ export class PiUiExtendApp {
64
64
  extensionActions;
65
65
  pixConfig;
66
66
  outputFilters;
67
- suppressPendingDcpIdMetadata;
68
67
  commandController;
69
68
  inputActions;
70
69
  inputController;
@@ -367,7 +366,6 @@ export class PiUiExtendApp {
367
366
  suppressExtensionWidget: (key) => this.extensionUiController.suppressWidget(key),
368
367
  });
369
368
  this.outputFilters = compileOutputFilterPatterns(this.pixConfig.outputFilters.patterns);
370
- this.suppressPendingDcpIdMetadata = outputFiltersRemoveDcpIdMetadataLine(this.outputFilters);
371
369
  this.conversationViewport = new ConversationViewport({
372
370
  get entries() { return app.entries; },
373
371
  get session() { return app.runtime?.session; },
@@ -379,7 +377,6 @@ export class PiUiExtendApp {
379
377
  colors: this.theme.colors,
380
378
  pixConfig: this.pixConfig,
381
379
  outputFilters: this.outputFilters,
382
- suppressPendingDcpIdMetadata: this.suppressPendingDcpIdMetadata,
383
380
  hasDynamicConversationBlock: () => this.popupMenus.hasDynamicConversationBlock(),
384
381
  isDynamicConversationBlock: (entry) => this.popupMenus.isDynamicConversationBlock(entry),
385
382
  renderInlineUserMessageMenu: (entry, context) => this.popupMenus.renderInlineUserMessageMenu(entry, context),
@@ -493,7 +490,7 @@ export class PiUiExtendApp {
493
490
  void this.tabsController.closeTab(tabId);
494
491
  },
495
492
  toastEntry: (toastId) => this.toastController.toast.entry(toastId),
496
- showToast: (message, kind) => this.showToast(message, kind),
493
+ showToast: (message, kind, options) => this.showToast(message, kind, options),
497
494
  dismissToast: (toastId) => this.toastController.dismissToast(toastId),
498
495
  refreshModelUsageStatus: () => this.refreshModelUsageStatusFromClick(),
499
496
  toggleAllThinkingExpanded: () => {
@@ -842,8 +839,8 @@ export class PiUiExtendApp {
842
839
  this.showToast("Failed to refresh model usage limits", "error");
843
840
  });
844
841
  }
845
- showToast(message, kind = "info") {
846
- this.toastController.showToast(message, kind);
842
+ showToast(message, kind = "info", options) {
843
+ this.toastController.showToast(message, kind, options);
847
844
  }
848
845
  clearToastTimers() {
849
846
  this.toastController.clearToastTimers();
package/dist/app/cli.js CHANGED
@@ -3,12 +3,14 @@ import { parseThemeName } from "../theme.js";
3
3
  export function usage() {
4
4
  return `Usage: pix [--cwd <path>] [--no-session] [--session <path>] [--theme dark|light] [--model <provider/model[:thinking]>]
5
5
  pix update [--check] [--force]
6
+ pix install [--check]
6
7
  npm run dev -- [--cwd <path>] [--no-session] [--session <path>] [--theme dark|light] [--model <provider/model[:thinking]>]
7
8
 
8
9
  Examples:
9
10
  pix --cwd ../pi-mono
10
11
  pix --cwd ../pi-mono --theme light --model anthropic/claude-sonnet-4-20250514:medium
11
- pix update --check`;
12
+ pix update --check
13
+ pix install --check`;
12
14
  }
13
15
  export function parseArgs(argv) {
14
16
  let cwd = process.cwd();
@@ -1 +1,3 @@
1
1
  export declare function copyTextToClipboard(text: string): void;
2
+ export declare function clipboardSupportAvailable(env?: NodeJS.ProcessEnv): boolean;
3
+ export declare function clipboardInstallHint(): string;
@@ -1,4 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ const require = createRequire(import.meta.url);
2
4
  export function copyTextToClipboard(text) {
3
5
  const commands = clipboardCommands();
4
6
  for (const [command, args] of commands) {
@@ -6,7 +8,24 @@ export function copyTextToClipboard(text) {
6
8
  if (!result.error && result.status === 0)
7
9
  return;
8
10
  }
9
- throw new Error("No clipboard command found");
11
+ if (copyWithNativeClipboard(text))
12
+ return;
13
+ throw new Error(`No clipboard command found. ${clipboardInstallHint()}`);
14
+ }
15
+ export function clipboardSupportAvailable(env = process.env) {
16
+ if (clipboardCommands().some(([command]) => commandExists(command, env)))
17
+ return true;
18
+ return resolveNativeClipboardEntrypoint() !== undefined;
19
+ }
20
+ export function clipboardInstallHint() {
21
+ if (process.platform === "linux") {
22
+ return "Install wl-clipboard for Wayland or xclip/xsel for X11 (for example: sudo apt install wl-clipboard xclip xsel).";
23
+ }
24
+ if (process.platform === "darwin")
25
+ return "Install pbcopy or check macOS clipboard permissions.";
26
+ if (process.platform === "win32")
27
+ return "Install clip.exe or check Windows clipboard access.";
28
+ return "Install a platform clipboard command.";
10
29
  }
11
30
  function clipboardCommands() {
12
31
  switch (process.platform) {
@@ -19,6 +38,40 @@ function clipboardCommands() {
19
38
  ["wl-copy", []],
20
39
  ["xclip", ["-selection", "clipboard"]],
21
40
  ["xsel", ["--clipboard", "--input"]],
41
+ ["termux-clipboard-set", []],
22
42
  ];
23
43
  }
24
44
  }
45
+ function copyWithNativeClipboard(text) {
46
+ const entrypoint = resolveNativeClipboardEntrypoint();
47
+ if (!entrypoint)
48
+ return false;
49
+ const script = `
50
+ import { createRequire } from "node:module";
51
+ import { readFileSync } from "node:fs";
52
+ const require = createRequire(${JSON.stringify(import.meta.url)});
53
+ const clipboard = require(${JSON.stringify(entrypoint)});
54
+ await clipboard.setText(readFileSync(0, "utf8"));
55
+ `;
56
+ const result = spawnSync(process.execPath, ["--input-type=module", "-e", script], {
57
+ input: text,
58
+ stdio: ["pipe", "ignore", "ignore"],
59
+ timeout: 3_000,
60
+ });
61
+ return !result.error && result.status === 0;
62
+ }
63
+ function resolveNativeClipboardEntrypoint() {
64
+ try {
65
+ return require.resolve("@mariozechner/clipboard");
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ function commandExists(command, env) {
72
+ const names = process.platform === "win32" ? [command, command.replace(/\.exe$/iu, ".cmd"), command.replace(/\.exe$/iu, ".bat")] : [command];
73
+ return names.some((name) => spawnSync(process.platform === "win32" ? "where" : "sh", process.platform === "win32" ? [name] : ["-lc", `command -v ${shellQuote(name)}`], { env, stdio: "ignore" }).status === 0);
74
+ }
75
+ function shellQuote(value) {
76
+ return `'${value.replaceAll("'", `'\\''`)}'`;
77
+ }
@@ -11,7 +11,6 @@ export type ConversationEntryRenderOptions = {
11
11
  colors: Theme["colors"];
12
12
  pixConfig: PixConfig;
13
13
  outputFilters: readonly RegExp[];
14
- suppressPendingDcpIdMetadata: boolean;
15
14
  superCompactTools?: boolean;
16
15
  allThinkingExpanded?: boolean;
17
16
  renderInlineUserMessageMenu: (entry: Extract<Entry, {
@@ -1,4 +1,4 @@
1
- import { applyOutputFilters, stripDcpDisplayMetadata, suppressPendingDcpIdMetadataLine } from "../config.js";
1
+ import { applyOutputFilters } from "../config.js";
2
2
  import { renderMarkdownTextLines } from "../markdown-format.js";
3
3
  import { attachImageClickTargets } from "./image-click-targets.js";
4
4
  import { horizontalPaddingLayout, padHorizontalText, wrapText } from "./render-text.js";
@@ -65,7 +65,7 @@ function renderCustomEntry(entry, width) {
65
65
  }));
66
66
  }
67
67
  function renderAssistantLines(text, width, options) {
68
- const displayText = displayAssistantText(text, options.outputFilters, options.suppressPendingDcpIdMetadata);
68
+ const displayText = applyOutputFilters(text, options.outputFilters).trimEnd();
69
69
  if (!displayText)
70
70
  return [];
71
71
  return renderMarkdownTextLines(displayText, width).map((line) => ({
@@ -75,7 +75,3 @@ function renderAssistantLines(text, width, options) {
75
75
  ...(line.syntaxHighlight ? { syntaxHighlight: line.syntaxHighlight } : {}),
76
76
  }));
77
77
  }
78
- function displayAssistantText(text, outputFilters, suppressPendingDcpIdMetadata) {
79
- const filtered = stripDcpDisplayMetadata(applyOutputFilters(text, outputFilters)).trimEnd();
80
- return suppressPendingDcpIdMetadata ? suppressPendingDcpIdMetadataLine(filtered) : filtered;
81
- }
@@ -1,4 +1,4 @@
1
- import { resolveColor, resolveToolRule, stripDcpDisplayMetadata } from "../config.js";
1
+ import { resolveColor, resolveToolRule } from "../config.js";
2
2
  import { formatMarkdownTables, markdownSyntaxHighlightsForText } from "../markdown-format.js";
3
3
  import { renderToolDisplay } from "../tool-renderers/index.js";
4
4
  import { DEFAULT_THINKING_TOOL_RULE, SUBAGENT_STATUSES, THINKING_TOOL_NAME, TODO_TOOL_NAME } from "./constants.js";
@@ -47,8 +47,7 @@ export function renderConversationToolEntry(entry, width, options) {
47
47
  }
48
48
  export function renderThinkingEntry(entry, width, options) {
49
49
  const rule = resolveThinkingToolRule(options.pixConfig);
50
- const displayText = stripDcpDisplayMetadata(entry.text);
51
- const markdownText = displayText ? formatMarkdownTables(displayText, Math.max(1, width - 2)) : "";
50
+ const markdownText = entry.text ? formatMarkdownTables(entry.text, Math.max(1, width - 2)) : "";
52
51
  const expandedText = trimTrailingBlankLines(markdownText);
53
52
  const compactExpandedText = options.superCompactTools ? removeBlankLines(expandedText) : expandedText;
54
53
  const forceExpanded = Boolean(options.allThinkingExpanded);
@@ -12,7 +12,6 @@ export type ConversationViewportHost = {
12
12
  readonly colors: Theme["colors"];
13
13
  readonly pixConfig: PixConfig;
14
14
  readonly outputFilters: readonly RegExp[];
15
- readonly suppressPendingDcpIdMetadata: boolean;
16
15
  readonly superCompactTools?: boolean;
17
16
  readonly allThinkingExpanded?: boolean;
18
17
  hasDynamicConversationBlock?(): boolean;
@@ -65,7 +65,6 @@ export class ConversationViewport {
65
65
  colors: this.host.colors,
66
66
  pixConfig: this.host.pixConfig,
67
67
  outputFilters: this.host.outputFilters,
68
- suppressPendingDcpIdMetadata: this.host.suppressPendingDcpIdMetadata,
69
68
  superCompactTools: Boolean(this.host.superCompactTools),
70
69
  allThinkingExpanded: Boolean(this.host.allThinkingExpanded),
71
70
  renderInlineUserMessageMenu: (userEntry, context) => this.host.renderInlineUserMessageMenu(userEntry, context),
@@ -1,22 +1,39 @@
1
1
  import { normalizeToolName, parseArgsText } from "../tool-renderers/utils.js";
2
+ const NUDGE_TYPES = ["turn", "iteration", "context-soft", "context-strong"];
2
3
  export function formatDcpStatsToast(session) {
3
4
  const stats = collectDcpSessionStats(session);
4
- const parts = [
5
- `context ${formatContextUsage(stats)}`,
6
- `freed ${formatCompactNumber(stats.tokensSaved)} tokens`,
7
- `${stats.runs.toLocaleString()} ${plural(stats.runs, "run")}`,
8
- stats.items > 0 ? `${stats.items.toLocaleString()} ${plural(stats.items, "item")}` : undefined,
9
- stats.summaryTokens > 0 ? `${formatCompactNumber(stats.summaryTokens)} summary` : undefined,
10
- stats.activeBlocks != null && stats.totalBlocks != null ? `blocks ${stats.activeBlocks}/${stats.totalBlocks}` : undefined,
11
- stats.prunedTools > 0 ? `pruned ${stats.prunedTools.toLocaleString()} tools` : undefined,
12
- ].filter((part) => Boolean(part));
13
- return `DCP: ${parts.join(" · ")}`;
5
+ const nudgeStats = collectDcpNudgeStats(session);
6
+ const activeBlocks = stats.activeBlocks ?? 0;
7
+ const totalBlocks = stats.totalBlocks ?? stats.activeBlocks ?? 0;
8
+ const totalNudgeEvents = nudgeStats.emitted + nudgeStats.upgraded;
9
+ const activeAnchors = NUDGE_TYPES.reduce((sum, type) => sum + nudgeStats.activeByType[type], 0);
10
+ const lines = [
11
+ "DCP Session Statistics:",
12
+ ` Tokens saved (estimated): ${fmt(stats.tokensSaved)}`,
13
+ ` Total pruning operations: ${fmt(stats.totalPruneCount)}`,
14
+ ` Compression blocks active: ${activeBlocks} / ${totalBlocks} total`,
15
+ " Manual mode: off",
16
+ "",
17
+ "Nudge telemetry:",
18
+ ` Sent: ${fmt(nudgeStats.emitted)} emitted, ${fmt(nudgeStats.upgraded)} upgraded`,
19
+ ` By type: ${NUDGE_TYPES.map((type) => `${type}=${fmt(nudgeStats.byType[type])}`).join(", ")}`,
20
+ ` Active anchors: ${fmt(activeAnchors)}${activeAnchors > 0 ? ` (${NUDGE_TYPES.map((type) => `${type}=${fmt(nudgeStats.activeByType[type])}`).join(", ")})` : ""}`,
21
+ ` Cleared after compress: ${fmt(nudgeStats.clearedEvents)} time${nudgeStats.clearedEvents === 1 ? "" : "s"} (${fmt(nudgeStats.clearedAnchors)} anchor${nudgeStats.clearedAnchors === 1 ? "" : "s"})`,
22
+ ` Compliance proxy: ${fmt(nudgeStats.clearedEvents)} compress-after-nudge / ${fmt(totalNudgeEvents)} nudge event${totalNudgeEvents === 1 ? "" : "s"} (${pct(nudgeStats.clearedEvents, totalNudgeEvents)})`,
23
+ nudgeStats.last
24
+ ? ` Last nudge: ${nudgeStats.last.type} ${nudgeStats.last.event} at ${formatDate(nudgeStats.last.createdAt)} (${formatContextPercent(nudgeStats.last.contextPercent)})`
25
+ : " Last nudge: none recorded",
26
+ "",
27
+ `Context: ${formatContextUsage(stats)}`,
28
+ ];
29
+ return lines.join("\n");
14
30
  }
15
31
  function collectDcpSessionStats(session) {
16
32
  const usage = session.getContextUsage();
17
33
  const stats = {
18
34
  runs: 0,
19
35
  tokensSaved: 0,
36
+ totalPruneCount: 0,
20
37
  items: 0,
21
38
  summaryTokens: 0,
22
39
  prunedTools: 0,
@@ -24,9 +41,15 @@ function collectDcpSessionStats(session) {
24
41
  ...(usage?.contextWindow != null ? { contextWindow: usage.contextWindow } : {}),
25
42
  ...(usage?.percent != null ? { contextPercent: usage.percent } : {}),
26
43
  };
27
- for (const entry of session.sessionManager.getBranch()) {
44
+ const branch = session.sessionManager.getBranch();
45
+ const latestState = latestCustomEntryData(branch, "dcp-state");
46
+ if (latestState)
47
+ applyDcpStateStats(stats, latestState);
48
+ for (const entry of branch) {
28
49
  if (entry.type !== "message")
29
50
  continue;
51
+ if (latestState)
52
+ continue;
30
53
  const message = entry.message;
31
54
  if (message.role !== "toolResult")
32
55
  continue;
@@ -39,6 +62,7 @@ function collectDcpSessionStats(session) {
39
62
  continue;
40
63
  stats.runs += 1;
41
64
  stats.tokensSaved += numberValue(result.tokensSaved) ?? 0;
65
+ stats.totalPruneCount = Math.max(stats.totalPruneCount, numberValue(result.totalPruneCount) ?? 0);
42
66
  stats.items += numberValue(result.itemCount) ?? sumDefined(numberValue(result.ranges), numberValue(result.messages)) ?? 0;
43
67
  stats.summaryTokens += numberValue(result.totalSummaryTokens) ?? 0;
44
68
  stats.prunedTools += numberValue(result.prunedTools) ?? 0;
@@ -60,6 +84,91 @@ function collectDcpSessionStats(session) {
60
84
  }
61
85
  return stats;
62
86
  }
87
+ function applyDcpStateStats(stats, data) {
88
+ stats.tokensSaved = numberValue(data.tokensSaved) ?? stats.tokensSaved;
89
+ stats.totalPruneCount = numberValue(data.totalPruneCount) ?? stats.totalPruneCount;
90
+ const blocks = Array.isArray(data.compressionBlocks) ? data.compressionBlocks : undefined;
91
+ if (blocks) {
92
+ stats.totalBlocks = blocks.length;
93
+ stats.activeBlocks = blocks.filter((block) => isRecord(block) && block.active !== false).length;
94
+ }
95
+ if (Array.isArray(data.prunedToolIds))
96
+ stats.prunedTools = data.prunedToolIds.length;
97
+ }
98
+ function collectDcpNudgeStats(session) {
99
+ const stats = {
100
+ emitted: 0,
101
+ upgraded: 0,
102
+ clearedEvents: 0,
103
+ clearedAnchors: 0,
104
+ byType: { "turn": 0, "iteration": 0, "context-soft": 0, "context-strong": 0 },
105
+ activeByType: { "turn": 0, "iteration": 0, "context-soft": 0, "context-strong": 0 },
106
+ };
107
+ const branch = session.sessionManager.getBranch();
108
+ const latestState = latestCustomEntryData(branch, "dcp-state");
109
+ if (latestState)
110
+ applyActiveAnchorStats(stats, latestState);
111
+ for (const entry of branch) {
112
+ const data = customEntryData(entry, "dcp-nudge");
113
+ if (!data)
114
+ continue;
115
+ const event = data.event;
116
+ if ((event === "emitted" || event === "upgraded") && isNudgeType(data.type)) {
117
+ if (event === "emitted")
118
+ stats.emitted += 1;
119
+ else
120
+ stats.upgraded += 1;
121
+ stats.byType[data.type] += 1;
122
+ const createdAt = numberValue(data.createdAt);
123
+ const contextPercent = typeof data.contextPercent === "number" || data.contextPercent === null ? data.contextPercent : undefined;
124
+ if (!stats.last || (createdAt ?? 0) >= (stats.last.createdAt ?? 0)) {
125
+ stats.last = {
126
+ type: data.type,
127
+ event,
128
+ ...(createdAt !== undefined ? { createdAt } : {}),
129
+ ...(contextPercent !== undefined ? { contextPercent } : {}),
130
+ };
131
+ }
132
+ }
133
+ else if (event === "cleared") {
134
+ stats.clearedEvents += 1;
135
+ stats.clearedAnchors += Math.max(0, numberValue(data.clearedAnchors) ?? 0);
136
+ }
137
+ }
138
+ return stats;
139
+ }
140
+ function applyActiveAnchorStats(stats, data) {
141
+ stats.activeByType = { "turn": 0, "iteration": 0, "context-soft": 0, "context-strong": 0 };
142
+ const anchors = Array.isArray(data.nudgeAnchors) ? data.nudgeAnchors : [];
143
+ for (const anchor of anchors) {
144
+ if (isRecord(anchor) && isNudgeType(anchor.type))
145
+ stats.activeByType[anchor.type] += 1;
146
+ }
147
+ const last = isRecord(data.lastNudge) ? data.lastNudge : undefined;
148
+ if (last && isNudgeType(last.type) && !stats.last) {
149
+ const contextPercent = numberValue(last.contextPercent);
150
+ const createdAt = numberValue(last.createdAt);
151
+ stats.last = {
152
+ type: last.type,
153
+ event: "emitted",
154
+ ...(createdAt !== undefined ? { createdAt } : {}),
155
+ ...(contextPercent !== undefined ? { contextPercent: contextPercent * 100 } : {}),
156
+ };
157
+ }
158
+ }
159
+ function customEntryData(entry, customType) {
160
+ if (!isRecord(entry) || entry.type !== "custom" || entry.customType !== customType)
161
+ return undefined;
162
+ return isRecord(entry.data) ? entry.data : undefined;
163
+ }
164
+ function latestCustomEntryData(entries, customType) {
165
+ for (let i = entries.length - 1; i >= 0; i--) {
166
+ const data = customEntryData(entries[i], customType);
167
+ if (data)
168
+ return data;
169
+ }
170
+ return undefined;
171
+ }
63
172
  function parseToolResultText(content) {
64
173
  const parsed = parseArgsText(textContent(content));
65
174
  return isRecord(parsed) ? parsed : undefined;
@@ -87,6 +196,29 @@ function formatContextUsage(stats) {
87
196
  return `${percentText} of ${formatCompactNumber(window)}`;
88
197
  return percentText;
89
198
  }
199
+ function isNudgeType(value) {
200
+ return typeof value === "string" && NUDGE_TYPES.includes(value);
201
+ }
202
+ function fmt(n) {
203
+ return Math.round(n).toLocaleString();
204
+ }
205
+ function pct(numerator, denominator) {
206
+ if (denominator <= 0)
207
+ return "n/a";
208
+ return `${((numerator / denominator) * 100).toFixed(1)}%`;
209
+ }
210
+ function formatDate(ts) {
211
+ if (typeof ts !== "number" || !Number.isFinite(ts) || ts <= 0)
212
+ return "unknown time";
213
+ return new Date(ts).toLocaleString();
214
+ }
215
+ function formatContextPercent(value) {
216
+ if (value === null)
217
+ return "unknown context";
218
+ if (typeof value !== "number" || !Number.isFinite(value))
219
+ return "unknown context";
220
+ return `${value.toFixed(1)}% context`;
221
+ }
90
222
  function numberValue(value) {
91
223
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
92
224
  }
@@ -108,9 +240,6 @@ function formatPercent(value) {
108
240
  function trimDecimal(value) {
109
241
  return value.toFixed(1).replace(/\.0$/, "");
110
242
  }
111
- function plural(count, word) {
112
- return count === 1 ? word : `${word}s`;
113
- }
114
243
  function isRecord(value) {
115
244
  return typeof value === "object" && value !== null && !Array.isArray(value);
116
245
  }
@@ -0,0 +1,10 @@
1
+ export type PixInstallCliOptions = {
2
+ checkOnly: boolean;
3
+ help: boolean;
4
+ };
5
+ export type PixInstallCliContext = {
6
+ env?: NodeJS.ProcessEnv;
7
+ };
8
+ export declare function pixInstallUsage(): string;
9
+ export declare function parsePixInstallArgs(argv: readonly string[]): PixInstallCliOptions;
10
+ export declare function runPixInstallCli(argv?: readonly string[], context?: PixInstallCliContext): Promise<number>;