@openlines/opl-pi-sht 0.2.12 → 0.3.0

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 (81) hide show
  1. package/README.md +92 -84
  2. package/configs/opl-browser.json.sample +3 -2
  3. package/configs/opl-footer.json.sample +3 -2
  4. package/configs/opl-guardian.json.sample +8 -2
  5. package/configs/opl-modes.json.sample +5 -0
  6. package/configs/opl-webaccess.json.sample +7 -1
  7. package/extensions/opl-browser/README.md +81 -55
  8. package/extensions/opl-browser/browser.ts +244 -50
  9. package/extensions/opl-browser/config.ts +15 -7
  10. package/extensions/opl-browser/index.ts +5 -4
  11. package/extensions/opl-browser/package.json +4 -4
  12. package/extensions/opl-browser/validate.ts +313 -60
  13. package/extensions/opl-configurator/README.md +40 -0
  14. package/extensions/opl-configurator/configurator.ts +148 -0
  15. package/extensions/opl-configurator/index.ts +12 -0
  16. package/extensions/opl-ctxtrim/README.md +45 -14
  17. package/extensions/opl-ctxtrim/index.ts +33 -13
  18. package/extensions/opl-footer/README.md +31 -6
  19. package/extensions/opl-footer/config.ts +11 -10
  20. package/extensions/opl-footer/configure.ts +157 -115
  21. package/extensions/opl-footer/git-status.ts +58 -1
  22. package/extensions/opl-footer/icons.ts +3 -0
  23. package/extensions/opl-footer/index.ts +209 -105
  24. package/extensions/opl-footer/segments/caveman.ts +9 -1
  25. package/extensions/opl-footer/segments/chat-mode.ts +8 -1
  26. package/extensions/opl-footer/segments/compactions.ts +13 -0
  27. package/extensions/opl-footer/segments/context.ts +6 -5
  28. package/extensions/opl-footer/segments/index.ts +2 -0
  29. package/extensions/opl-footer/segments/mode-switcher.ts +14 -1
  30. package/extensions/opl-footer/segments/path.ts +1 -1
  31. package/extensions/opl-footer/segments/plan-mode.ts +8 -1
  32. package/extensions/opl-footer/theme.ts +5 -2
  33. package/extensions/opl-footer/types.ts +4 -0
  34. package/extensions/opl-guardian/README.md +17 -5
  35. package/extensions/opl-guardian/config.ts +72 -6
  36. package/extensions/opl-guardian/guardian.ts +42 -10
  37. package/extensions/opl-guardian/index.ts +52 -17
  38. package/extensions/opl-guardian/policies.ts +52 -16
  39. package/extensions/opl-init/README.md +12 -4
  40. package/extensions/opl-init/index.ts +228 -27
  41. package/extensions/opl-input/README.md +17 -3
  42. package/extensions/opl-input/config.ts +59 -5
  43. package/extensions/opl-input/configure.ts +144 -0
  44. package/extensions/opl-input/index.ts +38 -30
  45. package/extensions/opl-input/mode-style.ts +6 -2
  46. package/extensions/opl-modes/README.md +16 -4
  47. package/extensions/opl-modes/config.ts +62 -8
  48. package/extensions/opl-modes/index.ts +48 -17
  49. package/extensions/opl-modes/state.ts +50 -2
  50. package/extensions/opl-modes/types.ts +6 -0
  51. package/extensions/opl-modes/utils.ts +18 -8
  52. package/extensions/opl-questionnaire/index.ts +10 -4
  53. package/extensions/opl-questionnaire/types.ts +4 -0
  54. package/extensions/opl-simplebench/README.md +21 -15
  55. package/extensions/opl-simplebench/artifact.ts +7 -4
  56. package/extensions/opl-simplebench/benchmark.ts +96 -35
  57. package/extensions/opl-simplebench/coding.ts +9 -4
  58. package/extensions/opl-simplebench/index.ts +19 -19
  59. package/extensions/opl-simplebench/llama-server.ts +3 -2
  60. package/extensions/opl-simplebench/report.ts +0 -6
  61. package/extensions/opl-simplebench/research.ts +5 -4
  62. package/extensions/opl-simplebench/scoring.ts +4 -2
  63. package/extensions/opl-simplebench/util/config.ts +15 -8
  64. package/extensions/opl-simplebench/util/exec-env.ts +36 -0
  65. package/extensions/opl-simplebench/util/format.ts +2 -2
  66. package/extensions/opl-simplebench/util/providers.ts +23 -194
  67. package/extensions/opl-todo/README.md +5 -5
  68. package/extensions/opl-todo/config.ts +7 -2
  69. package/extensions/opl-todo/index.ts +50 -9
  70. package/extensions/opl-webaccess/README.md +31 -5
  71. package/extensions/opl-webaccess/config.ts +36 -7
  72. package/extensions/opl-webaccess/extract.ts +37 -47
  73. package/extensions/opl-webaccess/http.ts +259 -0
  74. package/extensions/opl-webaccess/index.ts +12 -6
  75. package/extensions/opl-webaccess/package.json +4 -9
  76. package/extensions/opl-webaccess/pdf.ts +4 -4
  77. package/extensions/opl-webaccess/search.ts +19 -8
  78. package/extensions/opl-webaccess/storage.ts +134 -2
  79. package/extensions/opl-webaccess/types.ts +4 -0
  80. package/extensions/opl-webaccess/utils.ts +221 -33
  81. package/package.json +19 -12
@@ -0,0 +1,148 @@
1
+ import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { TUI } from "@earendil-works/pi-tui";
4
+
5
+ // The configurator seam. Instrumented extensions publish entries on this
6
+ // globalThis array; the shell reads it lazily when /configurator runs, so
7
+ // extension load order does not matter. The entry shape is duplicated by hand
8
+ // on the publishing side (e.g. opl-footer/configure.ts) because install.sh
9
+ // copies one extension directory at a time and cross-directory imports are
10
+ // forbidden.
11
+ export const CONFIGURATOR_REGISTRY_KEY = "__oplConfiguratorTabs";
12
+
13
+ /** What the shell hands to a tab's factory. */
14
+ export interface ConfiguratorTabApi {
15
+ ctx: ExtensionContext;
16
+ tui: TUI;
17
+ theme: Theme;
18
+ keybindings: KeybindingsManager;
19
+ done: (result: void) => void;
20
+ }
21
+
22
+ /** A component with the same contract as ctx.ui.custom's return value. */
23
+ export interface ConfiguratorTabComponent {
24
+ render(width: number): string[];
25
+ invalidate?(): void;
26
+ handleInput?(data: string): void;
27
+ }
28
+
29
+ export interface ConfiguratorTab {
30
+ /** Unique extension id, e.g. "opl-footer". */
31
+ id: string;
32
+ /** Single lowercase letter that focuses this tab from anywhere in the shell. */
33
+ hotkey: string;
34
+ /** Short tab-bar label, e.g. "Footer". */
35
+ label: string;
36
+ create: (api: ConfiguratorTabApi) => ConfiguratorTabComponent;
37
+ }
38
+
39
+ function isConfiguratorTab(value: unknown): value is ConfiguratorTab {
40
+ if (typeof value !== "object" || value === null) return false;
41
+ const entry = value as Record<string, unknown>;
42
+ return (
43
+ typeof entry.id === "string" &&
44
+ entry.id.length > 0 &&
45
+ typeof entry.hotkey === "string" &&
46
+ /^[a-z]$/.test(entry.hotkey) &&
47
+ typeof entry.label === "string" &&
48
+ entry.label.length > 0 &&
49
+ typeof entry.create === "function"
50
+ );
51
+ }
52
+
53
+ /** Read the registry, dropping malformed entries and duplicate ids/hotkeys. */
54
+ export function readConfiguratorTabs(): ConfiguratorTab[] {
55
+ const raw = (globalThis as Record<string, unknown>)[CONFIGURATOR_REGISTRY_KEY];
56
+ if (!Array.isArray(raw)) return [];
57
+
58
+ const tabs: ConfiguratorTab[] = [];
59
+ const seenIds = new Set<string>();
60
+ const seenHotkeys = new Set<string>();
61
+ for (const entry of raw) {
62
+ if (!isConfiguratorTab(entry)) continue;
63
+ if (seenIds.has(entry.id) || seenHotkeys.has(entry.hotkey)) continue;
64
+ seenIds.add(entry.id);
65
+ seenHotkeys.add(entry.hotkey);
66
+ tabs.push(entry);
67
+ }
68
+ return tabs;
69
+ }
70
+
71
+ export async function showConfigurator(ctx: ExtensionContext): Promise<void> {
72
+ if (ctx.mode !== "tui") {
73
+ ctx.ui.notify("/configurator requires TUI mode", "error");
74
+ return;
75
+ }
76
+
77
+ const tabs = readConfiguratorTabs();
78
+ if (tabs.length === 0) {
79
+ ctx.ui.notify("Nothing exposes a /configurator tab yet", "error");
80
+ return;
81
+ }
82
+
83
+ await ctx.ui.custom<void>((tui, theme, keybindings, done) => {
84
+ const built: Array<{ tab: ConfiguratorTab; component: ConfiguratorTabComponent }> = [];
85
+ for (const tab of tabs) {
86
+ try {
87
+ const component = tab.create({ ctx, tui, theme, keybindings, done });
88
+ if (component && typeof component.render === "function") built.push({ tab, component });
89
+ } catch {
90
+ // A broken tab must not take the whole configurator down.
91
+ }
92
+ }
93
+
94
+ if (built.length === 0) {
95
+ return {
96
+ render: (width: number) => [truncateToWidth(theme.fg("dim", "No instrumented extensions could be loaded."), width)],
97
+ invalidate() {},
98
+ };
99
+ }
100
+
101
+ let activeIndex = 0;
102
+ const clamp = () => {
103
+ activeIndex = Math.min(activeIndex, built.length - 1);
104
+ };
105
+
106
+ const focusByHotkey = (data: string): boolean => {
107
+ if (data.length !== 1) return false;
108
+ const key = data.toLowerCase();
109
+ const index = built.findIndex(({ tab }) => tab.hotkey === key);
110
+ if (index === -1) return false;
111
+ activeIndex = index;
112
+ return true;
113
+ };
114
+
115
+ return {
116
+ render(width: number) {
117
+ clamp();
118
+ const tabBar = built
119
+ .map(({ tab }, index) => theme.fg(index === activeIndex ? "accent" : "dim", `[${tab.hotkey}] ${tab.label}`))
120
+ .join(" ");
121
+ const active = built[activeIndex]!;
122
+ return [
123
+ truncateToWidth(theme.bold("OPL Configurator"), width),
124
+ truncateToWidth(tabBar, width),
125
+ truncateToWidth(theme.fg("dim", "tab hotkey switch · esc close"), width),
126
+ ...active.component.render(width),
127
+ ];
128
+ },
129
+ invalidate() {
130
+ clamp();
131
+ built[activeIndex]?.component.invalidate?.();
132
+ },
133
+ handleInput(data: string) {
134
+ if (matchesKey(data, Key.escape)) {
135
+ done(undefined);
136
+ return;
137
+ }
138
+ if (focusByHotkey(data)) {
139
+ tui.requestRender();
140
+ return;
141
+ }
142
+ clamp();
143
+ built[activeIndex]?.component.handleInput?.(data);
144
+ tui.requestRender();
145
+ },
146
+ };
147
+ });
148
+ }
@@ -0,0 +1,12 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { showConfigurator } from "./configurator.js";
4
+
5
+ export default function configurator(pi: ExtensionAPI): void {
6
+ pi.registerCommand("configurator", {
7
+ description: "Configure instrumented OPL extensions through hotkey tabs",
8
+ handler: async (_args, ctx) => {
9
+ await showConfigurator(ctx);
10
+ },
11
+ });
12
+ }
@@ -1,6 +1,6 @@
1
1
  # opl-ctxtrim
2
2
 
3
- Trims the verbose `ctx_*` tool descriptions that the third-party [context-mode](https://github.com/mksglu/context-mode) MCP bridge injects into every provider request. It edits only the serialized outbound payload through Pi's documented `before_provider_request` hook, so the installed context-mode package and its tool execution are never modified.
3
+ Trims description prose for eleven known `ctx_*` tools from [context-mode](https://github.com/mksglu/context-mode) on Pi's outbound provider requests. Provider support handles different encodings of those same tool definitions; it does not expand trimming to other tools or general context. The extension edits the serialized request through `before_provider_request`, leaving the installed context-mode package and tool execution unchanged.
4
4
 
5
5
  ## Commands, tools, and configuration
6
6
 
@@ -8,23 +8,41 @@ No commands, tools, or configuration. Loading the extension is the entire interf
8
8
 
9
9
  ## What it does
10
10
 
11
- context-mode registers eleven `ctx_*` tools whose descriptions carry multi-paragraph "Think-in-Code" prose, `WHEN`/`WHEN NOT` sections, and worked examples. Those descriptions are serialized into the tool schema on **every** provider request. `opl-ctxtrim` replaces each known tool's top-level description with a concise equivalent and shortens nested JSON Schema parameter descriptions to their first sentence.
11
+ The allowlist covers eleven context-mode `ctx_*` tools whose descriptions carry multi-paragraph "Think-in-Code" prose, `WHEN`/`WHEN NOT` sections, and worked examples. Those descriptions are serialized into the tool schema on **every** provider request. `opl-ctxtrim` replaces each known tool's top-level description with a concise equivalent and shortens nested JSON Schema parameter descriptions to their first sentence.
12
12
 
13
13
  Preserved exactly: tool names, execution routing, schema structure, `required`, `enum`, `default`, numeric bounds, and strict-mode flags. Only human-readable `description` prose changes.
14
14
 
15
- Supported provider payload shapes:
15
+ ### Scope
16
16
 
17
- - OpenAI Responses / Anthropic / Google: `{ name, description, parameters }`
18
- - OpenAI Chat Completions / Mistral: `{ function: { name, description, parameters } }`
19
- - Amazon Bedrock Converse: `toolConfig.tools[].toolSpec`
17
+ The curated description table is the whole policy. A tool is trimmed only when its exact name is an own entry in `CTX_DESCRIPTIONS`; inherited JavaScript property names do not match. Matching is by name, not server identity, so another tool using an allowlisted name receives the same substitution:
20
18
 
21
- Any unrecognized payload shape, any non-`ctx_*` tool, and any `ctx_*` tool not in the built-in description map are returned unchanged (fail open). This means a future context-mode release that adds or renames a tool is left untouched at runtime until the extension is updated; the test suite flags such tools for review.
19
+ - New or renamed `ctx_*` tools absent from the table pass through untouched until explicitly reviewed and added.
20
+ - Tools whose names are absent from the table, including built-in Pi tools and other MCP tools, remain unchanged.
21
+ - Unrecognized payload shapes fail open: the original payload is returned.
22
+
23
+ Adding a tool to the table is a source change, not a configuration; there is no config file to edit.
24
+
25
+ ### Provider tool shapes
26
+
27
+ Pi serializes the same context-mode tool definitions differently for each provider. These handlers locate the declarations before applying the same eleven-name policy.
28
+
29
+ | Provider format | Tool name location | Parameter schema location |
30
+ |---|---|---|
31
+ | OpenAI Responses | `tools[].name` | `tools[].parameters` |
32
+ | OpenAI Chat Completions / Mistral | `tools[].function.name` | `tools[].function.parameters` |
33
+ | Anthropic | `tools[].name` | `tools[].input_schema` |
34
+ | Google/Gemini | `tools[].functionDeclarations[].name` | Declaration's `parametersJsonSchema` or `parameters` |
35
+ | Amazon Bedrock Converse | `toolConfig.tools[].toolSpec.name` | `toolSpec.inputSchema.json` |
36
+
37
+ The locator also accepts `toolSpecifications[]`, `tool_config` wrappers and direct named declarations with `parameters`, `input_schema` or `parametersJsonSchema`. These are structural compatibility paths, not additional tool policies. Messages, system prompts, routing anchors, tool results and indexed content are not trimmed. Unknown shapes are returned unchanged.
38
+
39
+ A context-mode release that adds or renames a `ctx_*` tool is invisible to the table, and therefore invisible to the savings too. The test suite prints those tools as `uncovered ctx_* tools (review after upgrade)` so the gap is a review item rather than a silent loss. The measured server snapshot was v1.0.169; that number is not a compatibility guarantee.
22
40
 
23
41
  ## Token savings
24
42
 
25
43
  ### Reproducible schema measurement
26
44
 
27
- The test suite queries the installed context-mode **v1.0.169** server (11 `ctx_*` tools), serializes its real `tools/list` result as an OpenAI Responses tool array, and compares bytes before and after trimming:
45
+ The test suite queries the installed context-mode server (v1.0.169 at the time of writing, 11 `ctx_*` tools), serializes its real `tools/list` result as an OpenAI Responses tool array, and compares bytes before and after trimming:
28
46
 
29
47
  | Schema state | Bytes | Change from original |
30
48
  |---|---:|---:|
@@ -32,9 +50,20 @@ The test suite queries the installed context-mode **v1.0.169** server (11 `ctx_*
32
50
  | Current `opl-ctxtrim` output | 9,152 | **18,867 fewer (67.3%)** |
33
51
  | Hypothetical empty descriptions, schemas retained | 4,683 | 23,336 fewer (83.3%) |
34
52
 
35
- The final row is a ceiling, not a recommended configuration. Current replacements retain compact tool guidance and parameter descriptions; empty descriptions would remove another 4,469 bytes but make tool selection and argument construction less reliable. The remaining 4,683 bytes are mostly required schema structure, parameter names, types, enums, bounds, and `required` fields.
53
+ Those three rows are one payload in one shape. The suite also measures the other supported shapes, using the same eleven curated tool names with synthetic verbose descriptions so the comparison across shapes is like-for-like rather than dependent on whatever context-mode happens to ship:
54
+
55
+ | Shape | Original | Trimmed | Saved |
56
+ |---|---:|---:|---:|
57
+ | OpenAI Chat/Responses array | 21,912 | 5,666 | 16,246 (74.1%) |
58
+ | Google/Gemini `functionDeclarations` | 21,752 | 5,726 | 16,026 (73.7%) |
59
+
60
+ The 21,912-byte and 21,752-byte originals are synthetic fixtures and are smaller than the 28,019-byte real payload above; do not read them as three views of one number.
61
+
62
+ So a Gemini request is trimmed like the others instead of passing through whole. All of it is conditional on the curated tools being present: a session without context-mode sends no `ctx_*` declaration, matches nothing in the table, and the payload leaves the extension byte-identical - no saving, no risk.
63
+
64
+ The empty-description row in the first table is a ceiling, not a recommended configuration: current replacements retain compact tool guidance and parameter descriptions, and emptying them would remove another 4,469 bytes while making tool selection and argument construction less reliable. The remaining 4,683 bytes are mostly required schema structure, parameter names, types, enums, bounds, and `required` fields.
36
65
 
37
- Run `npm run test:opl-ctxtrim` to regenerate the first two rows. It also reports a rough 3-4 bytes-per-token estimate of 4,717-6,289 tokens per request. Treat that as a byte heuristic, not billing data.
66
+ Run `npm run test:opl-ctxtrim` to regenerate the tables. Dividing the 18,867-byte Responses saving by the usual 3-4 bytes per token gives roughly 4,700-6,300 tokens per request. Treat that as a byte heuristic, not billing data; it belongs to the Responses shape, and the other two shapes save 16,246 and 16,026 bytes.
38
67
 
39
68
  ### Observed provider accounting
40
69
 
@@ -70,9 +99,11 @@ Removing or lazily exposing entire `ctx_*` tools could save more than descriptio
70
99
 
71
100
  ## Architecture
72
101
 
73
- ```text
74
- index.ts before_provider_request handler; CTX_DESCRIPTIONS map;
75
- provider-shape detection; description-only trimming.
76
- ```
102
+ ### index.ts
103
+ - before_provider_request handler
104
+ - CTX_DESCRIPTIONS map (the only names ever rewritten)
105
+ - tool-array discovery across provider shapes
106
+ - tools[], functionDeclarations, toolSpecifications, toolConfig / tool_config
107
+ - description-only trimming of parameters recursively
77
108
 
78
109
  Exports `trimPayload`, `CTX_DESCRIPTIONS`, and `shortenParamDescription` for testing. The extension is self-contained and dependency-free.
@@ -1,11 +1,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
- // opl-ctxtrim trims the verbose descriptions that the third-party context-mode
4
- // MCP bridge attaches to its ctx_* tools. It edits only the serialized provider
5
- // payload in the documented `before_provider_request` hook, so the installed
6
- // context-mode package (and its tool execution) is never modified. Tool names,
7
- // schema structure, required fields, enums, defaults, bounds, and strict flags
8
- // are preserved; only human-readable `description` prose is shortened.
3
+ // opl-ctxtrim trims only the known context-mode ctx_* tool descriptions listed in
4
+ // CTX_DESCRIPTIONS. Provider handlers locate those same definitions in the different
5
+ // request formats Pi emits; they do not widen the tool policy or trim conversation data.
6
+ // Only the serialized payload is edited through before_provider_request. The installed
7
+ // context-mode package, tool execution, names and schema validation rules stay unchanged.
9
8
 
10
9
  // Concise top-level descriptions for the eleven ctx_* tools shipped by
11
10
  // context-mode v1.0.169. Any ctx_* tool NOT listed here is left unchanged so a
@@ -38,7 +37,7 @@ export const CTX_DESCRIPTIONS: Record<string, string> = {
38
37
  // Shorten a nested JSON Schema parameter description to its first sentence or
39
38
  // line. Only `description` text is touched, never validation keywords.
40
39
  export function shortenParamDescription(text: string): string {
41
- const firstLine = text.split("\n", 1)[0].trim();
40
+ const firstLine = (text.split("\n", 1)[0] ?? "").trim();
42
41
  const sentenceEnd = firstLine.search(/\.\s|\.$/);
43
42
  const clipped = sentenceEnd >= 0 ? firstLine.slice(0, sentenceEnd + 1) : firstLine;
44
43
  return clipped.length > 160 ? `${clipped.slice(0, 157).trimEnd()}...` : clipped;
@@ -61,7 +60,7 @@ function shortenSchemaDescriptions(node: unknown): void {
61
60
  }
62
61
  }
63
62
 
64
- // A tool entry across the three supported provider shapes exposes a name, a
63
+ // A tool entry across the supported provider shapes exposes a name, a
65
64
  // holder object carrying `description`, and (optionally) a parameters schema.
66
65
  interface ToolView {
67
66
  name: string;
@@ -92,12 +91,13 @@ function viewTool(tool: unknown): ToolView | null {
92
91
  }
93
92
  }
94
93
 
95
- // OpenAI Responses / Anthropic / Google: { name, description, parameters|input_schema }
94
+ // OpenAI Responses / Anthropic / Google declarations:
95
+ // { name, description, parameters | input_schema | parametersJsonSchema }
96
96
  if (typeof record.name === "string") {
97
97
  return {
98
98
  name: record.name,
99
99
  descriptionHolder: record,
100
- parameters: record.parameters ?? record.input_schema,
100
+ parameters: record.parameters ?? record.input_schema ?? record.parametersJsonSchema,
101
101
  };
102
102
  }
103
103
 
@@ -106,6 +106,7 @@ function viewTool(tool: unknown): ToolView | null {
106
106
 
107
107
  // Trim a single tool view in place when it is a known ctx_* tool.
108
108
  function trimToolView(view: ToolView): boolean {
109
+ if (!Object.hasOwn(CTX_DESCRIPTIONS, view.name)) return false;
109
110
  const concise = CTX_DESCRIPTIONS[view.name];
110
111
  if (!concise) return false;
111
112
  if (typeof view.descriptionHolder.description === "string") {
@@ -117,12 +118,31 @@ function trimToolView(view: ToolView): boolean {
117
118
  return true;
118
119
  }
119
120
 
121
+ // Keys that hold a declaration array directly, per provider dialect.
122
+ const DECLARATION_KEYS = ["functionDeclarations", "toolSpecifications"];
123
+ // Keys that wrap tool settings; Google uses camelCase, some gateways snake_case.
124
+ const TOOL_CONFIG_KEYS = ["toolConfig", "tool_config"];
125
+
126
+ function arraysInto(target: unknown[][], value: unknown, keys: string[]): void {
127
+ if (!value || typeof value !== "object") return;
128
+ const record = value as Record<string, unknown>;
129
+ if (Array.isArray(record.tools)) target.push(record.tools);
130
+ for (const key of keys) {
131
+ if (Array.isArray(record[key])) target.push(record[key] as unknown[]);
132
+ }
133
+ }
134
+
120
135
  // Locate the tool arrays inside a provider payload across supported shapes.
121
136
  function toolArrays(payload: Record<string, unknown>): unknown[][] {
122
137
  const arrays: unknown[][] = [];
123
- if (Array.isArray(payload.tools)) arrays.push(payload.tools);
124
- const toolConfig = payload.toolConfig as Record<string, unknown> | undefined;
125
- if (toolConfig && Array.isArray(toolConfig.tools)) arrays.push(toolConfig.tools);
138
+ if (Array.isArray(payload.tools)) {
139
+ arrays.push(payload.tools);
140
+ // Google (Generative AI and Vertex) nests declarations one level deeper:
141
+ // tools: [{ functionDeclarations: [{ name, description, parametersJsonSchema }] }]
142
+ // and pi-ai emits exactly this shape (pi-ai dist/api/google-shared.js:338).
143
+ for (const entry of payload.tools) arraysInto(arrays, entry, DECLARATION_KEYS);
144
+ }
145
+ for (const key of TOOL_CONFIG_KEYS) arraysInto(arrays, payload[key], DECLARATION_KEYS);
126
146
  return arrays;
127
147
  }
128
148
 
@@ -4,7 +4,7 @@ A customizable three-row footer for the Pi coding agent. It shows model, path, G
4
4
 
5
5
  ## Commands, flags, and shortcuts
6
6
 
7
- `/configure-opl` provides six row/side tabs to toggle or reorder standard segments and their trailing separators, applying changes immediately. Press `r` for reorder view, then `,`/`.` to move the selected segment. The `status` segment shows `Working` during agent execution, `Waiting` while Pi tools run, and `Ready` when the agent settles. Colors, icons, literal text, and other options remain JSON-only. Nerd Font detection can be overridden with `FOOTER_NERD_FONTS=1` or `FOOTER_NERD_FONTS=0`.
7
+ `/configurator` opens the `opl-configurator` shell, whose Footer tab (hotkey `f`) provides six row/side tabs to toggle or reorder standard segments and their trailing separators, applying changes immediately. Press `r` for reorder view, then `,`/`.` to move the selected segment. The `status` segment shows `Working` during agent execution, `Waiting` while Pi tools run, and `Ready` when the agent settles. Colors, icons, literal text, and other options remain JSON-only. Nerd Font detection can be overridden with `FOOTER_NERD_FONTS=1` or `FOOTER_NERD_FONTS=0`.
8
8
 
9
9
  ## Extension features
10
10
 
@@ -33,7 +33,7 @@ The third row is populated after the first completed turn. Its session and perfo
33
33
  - **Token and cost tracking**: total, cache, input/output, and accumulated cost segments
34
34
  - **Codex subscription quota**: optional exact 5-hour and weekly ChatGPT Codex usage percentages with reset countdowns and stale-snapshot fallback
35
35
  - **OpenRouter key-limit usage**: optional per-key paid-usage amount, limit, and percentage with stale-snapshot fallback
36
- - **Session statistics**: prompt, API-call, and model tool-call counts
36
+ - **Session statistics**: prompt, API-call, model tool-call, and compaction counts
37
37
  - **Performance statistics**: cumulative LLM/tool duration, most recent user-prompt-to-completion turnaround time, average time to first token, output rate, and cache-hit percentage
38
38
  - **Thinking, mode, and status indicators**: thinking-level colors plus caveman, plan, chat, unified mode, and `Working`/`Waiting`/`Ready` status segments
39
39
  - **Nerd Font support**: automatic detection with plain-icon fallbacks
@@ -45,6 +45,10 @@ Create `~/.pi/agent/configs/opl-footer.json` or copy the tracked example from [`
45
45
 
46
46
  The config is cached for five seconds. Changes normally appear automatically; use `/reload` or restart Pi if needed.
47
47
 
48
+ ### Layout cost
49
+
50
+ A row whose segments are all disabled renders nothing: the line and its divider are dropped, so a one-row layout costs two lines (a leading blank plus the row) and a three-row layout still costs six. A segment that throws during render shows `[?]` in its own cell instead of blanking the whole footer, and an unexpected session entry (a message without a `usage` block, for example) reads as zero rather than taking the rows with it. Branch-derived counters and the context estimate are cached by session, leaf, branch length, context window and whether usage needs estimating. Tree navigation and compaction invalidate the cache; equal-length branches refresh their values and canonical usage removes the estimate marker. Unchanged renders reuse the cached facts.
51
+
48
52
  ### Git probing
49
53
 
50
54
  The `git` segment reads branch (500 ms cache) and dirty counts (1 s cache) so an idle footer never spawns more than a couple of `git` processes per second. Two guards keep that floor honest:
@@ -52,30 +56,50 @@ The `git` segment reads branch (500 ms cache) and dirty counts (1 s cache) so an
52
56
  - The probes are skipped entirely when no configured row contains `git`, so a layout without the segment pays nothing for it.
53
57
  - In a directory that is not a repository, the first failed probe arms a 30 second back-off instead of re-running `git` every second forever. A mid-session `git init` or `git clone` clears it immediately (matched on tool results), as does any `write`/`edit` invalidation; a probe that fails inside a real repository (for example a locked index) is confirmed with `git rev-parse --is-inside-work-tree` and keeps the normal one-second cadence. A probe that finishes after an invalidation is dropped outright, so the failure that happened *before* a `git init` cannot push the fresh repository back into a 30 second back-off.
54
58
 
59
+ Probes are also hardened against the repository itself. A worktree can declare programs - `[diff "x"] textconv`, `core.fsmonitor`, `core.hooksPath`, or a system or user config naming a helper - that git executes while answering what looks like a read-only query, so every call goes through a guard: `--no-pager`, `-c core.fsmonitor=false`, `-c core.hooksPath=`, `-c protocol.ext.allow=never`, `-c credential.helper=`, `GIT_CONFIG_NOSYSTEM`, `GIT_ATTR_NOSYSTEM`, `GIT_TERMINAL_PROMPT=0` and `GIT_OPTIONAL_LOCKS=0`, plus `--no-ext-diff --no-textconv` on diff-family subcommands. The probes only ever ask for branch names and porcelain status, and the guard block is duplicated from `opl-init` because installs are per-directory; `tests/net-guard-parity.test.mjs` fails if the two copies drift.
55
60
  ```json
56
61
  {
57
62
  "row1LeftSegments": ["pi", "separator", "model", "separator", "path", "git"],
58
63
  "row1RightSegments": ["context_pct"],
59
- "row2LeftSegments": ["thinking", "separator", "mode_switcher"],
64
+ "row2LeftSegments": ["thinking", "separator", "caveman", "separator", "mode_switcher"],
60
65
  "row2RightSegments": ["token_total", "separator", "cost"],
61
66
  "row3LeftSegments": ["session_stats"],
62
67
  "row3RightSegments": ["perf_stats"]
63
68
  }
64
69
  ```
65
70
 
71
+ That is the shipped default layout (`DEFAULT_CONFIG` in `config.ts`); `codex_usage` and `openrouter_usage` start disabled and are added by editing the arrays or using `/configurator`.
72
+
66
73
  See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json.sample) for a complete example. Segment IDs, color fields, context-bar options, thinking-level colors, and icon overrides are documented below. Colors accept Pi theme tokens, six-digit hex, or the three-digit `#abc` shorthand (expanded to `#aabbcc`); the context bar gradient resolves both forms to RGB. An unknown token or malformed hex renders that text uncolored instead of failing the footer render, so a typo in `colors` or in `opl-modes`' `appearance.modeColor` costs you a color, not the footer.
67
74
 
68
75
  ## Architecture
69
76
 
70
77
  `index.ts` installs the footer and lifecycle tracking. `segments/` renders configurable values; `theme.ts`, `types.ts`, and `config.ts` provide styling and JSON configuration; session and performance statistics are collected in process memory and reconstructed from session history where possible.
71
78
 
79
+ ## Cross-extension seams
80
+
81
+ While a footer is mounted, `opl-footer` publishes `__footerRequestRender` so another
82
+ extension (`opl-modes`) can ask for a re-render outside a turn, and it reads `__agentMode`,
83
+ `__planMode`, `__chatMode` and `__caveman` for the `mode_switcher`, `plan_mode`, `chat_mode`
84
+ and `caveman` segments. Both sides are optional at install time, so the reads are validated rather than trusted:
85
+ only a `{ mode: <non-empty string> }` is honoured, a malformed or missing value renders
86
+ `Mode: Normal`, and a `modeColor` that is not a string is ignored.
87
+ `session_shutdown` deletes the published trigger, because it closes over this session's TUI
88
+ and a stale one would hand a dead component to the next session.
89
+
90
+ For configuration, `opl-footer` publishes one entry on `globalThis.__oplConfiguratorTabs`
91
+ (id `opl-footer`, hotkey `f`, label `Footer`) whose factory returns the same
92
+ settings/reorder screen. The `/configurator` shell in `opl-configurator` owns the command
93
+ and reads that array lazily; the entry is registered once at load and the factory is called
94
+ fresh on every `/configurator` invocation.
95
+
72
96
  ## Available Segments
73
97
 
74
98
  | Segment | Description | Notes |
75
99
  |---------|-------------|-------|
76
100
  | `pi` | π symbol in accent blue | `pi` icon can be modified in config file |
77
101
  | `model` | Model name in pink + `(provider)` in dim | No icon; provider omitted if unavailable |
78
- | `path` | Current working directory | `segmentOptions.path.mode`: `"full"` (default) · `"abbreviated"` · `"basename"` |
102
+ | `path` | Session directory (`ExtensionContext.cwd`), not the directory the harness was launched from | `segmentOptions.path.mode`: `"full"` (default) · `"abbreviated"` · `"basename"` |
79
103
  | `git` | Git branch and dirty indicators | `showBranch`, `showStaged`, `showUnstaged`, `showUntracked` (all bool) |
80
104
  | `context_pct` | Gradient bar + `X.X%` + max tokens | Bar fully configurable via `segmentOptions.contextBar` (see below). % and max tokens use `contextLabel` colour. Max tokens formatted with K/M suffix (e.g. `128k`, `2M`). Usage comes from `ctx.getContextUsage()`; when Pi reports it as unknown right after compaction, the segment estimates the rebuilt projection with Pi’s `estimateTokens()` and marks the bar and values with `≈` until a fresh assistant response provides exact usage. If no projection is available, it renders `(--%)` rather than a stale value. Set `DEBUG_PCT` in `context.ts` to a number (0–100) to pin the bar at a fixed value for visual testing. |
81
105
  | `cost` | `$<amount>` (4 decimals, e.g. `$0.0123`) | `$` dim, amount in `cost` colour (`muted` by default). Four decimals keep cheap local or short sessions distinguishable instead of pinning at `$0.00`. Shows dim `(no pricing)` when the session total is zero on a non-local model (provider has no pricing configured), and `(local model)` for local models |
@@ -91,6 +115,7 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
91
115
  | `cache_write` | Cache write tokens (hidden if zero) | — |
92
116
  | `context_total` | Total context window size | — |
93
117
  | `session_stats` | Prompt, API-call, and tool-call counts | Hidden until the first prompt |
118
+ | `compactions` | Manual and automatic compaction count from the active session branch, including resumed-session history | Nerd Font zip-box icon or 📦 fallback; hidden at zero |
94
119
  | `perf_stats` | LLM/tool timing, TTFT, output rate, and cache-hit percentage | Hidden until the first prompt |
95
120
  | `status` | `Working`, `Waiting`, or `Ready` | `accent`, `warning`, and `success` theme colors respectively; optional and hidden by default |
96
121
  | `codex_usage` | `5h 76% ↻2h18m · W 37% ↻3d7h` | Exact remaining ChatGPT Codex subscription quota; OAuth-only, optional and hidden by default; a retained snapshot is marked `(stale)` after refresh failure |
@@ -100,13 +125,13 @@ See the tracked [`configs/opl-footer.json.sample`](../../configs/opl-footer.json
100
125
 
101
126
  ## Codex Subscription Usage
102
127
 
103
- Add `codex_usage` to any row through `/configure-opl` or `opl-footer.json`. While the active model is the OAuth-authenticated `openai-codex` provider, the footer asks Pi's model registry to resolve the same short-lived OAuth access token that Pi uses for the selected model, then sends `GET https://chatgpt.com/backend-api/wham/usage` directly from the local Pi process. The request carries `Authorization: Bearer <Pi OAuth token>`, `Accept: application/json`, a fixed `User-Agent: opl-footer-codex-usage`, and the token's `chatgpt_account_id` JWT claim as `chatgpt-account-id` when present. It refreshes after session start, model selection, each completed assistant response, each tool completion, and settled agent runs. Refreshes are non-blocking, with a 30-second floor and a 15-second request timeout; events within that floor coalesce into one trailing refresh, including events received during an in-flight request. Pending refreshes are cancelled on session reset, shutdown, or switching away from Codex, and recheck whether the segment is enabled before fetching. It classifies the returned windows by duration rather than response order. This is ChatGPT subscription quota, not OpenAI Platform API-key usage or billing.
128
+ Add `codex_usage` to any row through `/configurator` or `opl-footer.json`. While the active model is the OAuth-authenticated `openai-codex` provider, the footer asks Pi's model registry to resolve the same short-lived OAuth access token that Pi uses for the selected model, then sends `GET https://chatgpt.com/backend-api/wham/usage` directly from the local Pi process. The request carries `Authorization: Bearer <Pi OAuth token>`, `Accept: application/json`, a fixed `User-Agent: opl-footer-codex-usage`, and the token's `chatgpt_account_id` JWT claim as `chatgpt-account-id` when present. It refreshes after session start, model selection, each completed assistant response, each tool completion, and settled agent runs. Refreshes are non-blocking, with a 30-second floor and a 15-second request timeout; events within that floor coalesce into one trailing refresh, including events received during an in-flight request. Pending refreshes are cancelled on session reset, shutdown, or switching away from Codex, and recheck whether the segment is enabled before fetching. It classifies the returned windows by duration rather than response order. This is ChatGPT subscription quota, not OpenAI Platform API-key usage or billing.
104
129
 
105
130
  No OAuth token, account ID, or response body is written to disk, logged, added to the Pi session, or sent anywhere other than `chatgpt.com` for that request. **The footer retains only the parsed 5-hour/weekly percentage and reset time in process memory for the active session.** The endpoint is an internal ChatGPT backend API and can change without notice. A failed refresh keeps the most recent exact snapshot and marks it `(stale)`; if no request has ever succeeded, the segment stays hidden. Disabling the segment prevents future requests and discards any result from a request already in flight.
106
131
 
107
132
  ## OpenRouter Usage
108
133
 
109
- Add `openrouter_usage` to any row through `/configure-opl` or `opl-footer.json`. While the selected model provider is `openrouter`, the footer resolves the same API key Pi uses for inference and sends `GET https://openrouter.ai/api/v1/key` from the local Pi process. It renders the configured per-key limit as `$<limit - limit_remaining> / $<limit> (<percent>%)`; an absent, malformed, or unlimited key cap leaves the segment hidden. The numerator deliberately uses OpenRouter's authoritative `limit_remaining`, rather than `usage` or `byok_usage`, because those ledgers may not both count against the key limit.
134
+ Add `openrouter_usage` to any row through `/configurator` or `opl-footer.json`. While the selected model provider is `openrouter`, the footer resolves the same API key Pi uses for inference and sends `GET https://openrouter.ai/api/v1/key` from the local Pi process. It renders the configured per-key limit as `$<limit - limit_remaining> / $<limit> (<percent>%)`; an absent, malformed, or unlimited key cap leaves the segment hidden. The numerator deliberately uses OpenRouter's authoritative `limit_remaining`, rather than `usage` or `byok_usage`, because those ledgers may not both count against the key limit.
110
135
 
111
136
  It refreshes after session start, model selection, each completed assistant response, each tool completion, and settled agent runs. Refreshes are non-blocking, with a 30-second floor and a 15-second request timeout; events within that floor coalesce into one trailing refresh, including events received during an in-flight request. Pending refreshes are cancelled on session reset, shutdown, or switching away from OpenRouter, and recheck whether the segment is enabled before fetching. No API key or response body is written to disk, logged, added to the Pi session, or sent anywhere other than `openrouter.ai`. A failed refresh keeps the most recent exact snapshot and marks it `(stale)`; if no request has ever succeeded, the segment stays hidden. Disabling the segment prevents future requests and discards any result from a request already in flight.
112
137
 
@@ -1,5 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
3
4
  import type { FooterUserConfig, StatusLineSegmentId, ColorScheme, StatusLineSegmentOptions } from "./types.js";
4
5
  import { getDefaultColors } from "./theme.js";
5
6
  import type { IconSet } from "./icons.js";
@@ -27,7 +28,7 @@ export const CONFIGURABLE_SEGMENTS: StatusLineSegmentId[] = [
27
28
  "pi", "model", "path", "git", "thinking", "caveman", "plan_mode",
28
29
  "chat_mode", "mode_switcher", "token_in", "token_out", "token_total",
29
30
  "cache_read", "cache_write", "cost", "context_pct", "context_total",
30
- "session_stats", "perf_stats", "status", "codex_usage", "openrouter_usage",
31
+ "session_stats", "compactions", "perf_stats", "status", "codex_usage", "openrouter_usage",
31
32
  ];
32
33
 
33
34
  const DEFAULT_LAYOUTS: Record<FooterLayoutKey, StatusLineSegmentId[]> = {
@@ -54,9 +55,9 @@ let userConfigCache: FooterUserConfig | null = null;
54
55
  let userConfigCacheTime = 0;
55
56
  const CACHE_TTL = 5000; // 5 seconds
56
57
 
57
- function getConfigPath(): string {
58
- const homeDir = process.env.HOME || process.env.USERPROFILE || "";
59
- return join(homeDir, ".pi", "agent", "configs", "opl-footer.json");
58
+ /** Resolved per call so a custom `PI_AGENT_DIR` (pi's own agent dir) is honoured. */
59
+ export function configPath(): string {
60
+ return join(getAgentDir(), "configs", "opl-footer.json");
60
61
  }
61
62
 
62
63
  export function loadUserConfig(): FooterUserConfig | null {
@@ -65,10 +66,10 @@ export function loadUserConfig(): FooterUserConfig | null {
65
66
  return userConfigCache;
66
67
  }
67
68
 
68
- const configPath = getConfigPath();
69
+ const file = configPath();
69
70
  try {
70
- if (existsSync(configPath)) {
71
- const content = readFileSync(configPath, "utf-8");
71
+ if (existsSync(file)) {
72
+ const content = readFileSync(file, "utf-8");
72
73
  const parsed = JSON.parse(content);
73
74
  userConfigCache = parsed as FooterUserConfig;
74
75
  userConfigCacheTime = now;
@@ -178,9 +179,9 @@ export function setSegmentSeparator(
178
179
  }
179
180
 
180
181
  export function saveUserConfig(config: FooterUserConfig): void {
181
- const configPath = getConfigPath();
182
- mkdirSync(dirname(configPath), { recursive: true });
183
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
182
+ const file = configPath();
183
+ mkdirSync(dirname(file), { recursive: true });
184
+ writeFileSync(file, JSON.stringify(config, null, 2) + "\n", "utf-8");
184
185
  clearUserConfigCache();
185
186
  }
186
187