pi-skillful 0.4.0 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,25 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.5.0] - 2026-10-09
10
+
11
+ ### Fixed
12
+
13
+ - Read the current theme when rendering session-toggle borders and fit the complete border at very narrow widths. Extend editor composition coverage for paste, padding, indicators, and disposal.
14
+
15
+ ### Changed
16
+
17
+ - Update skill visibility and session toggles through Pi's structured prompt API instead of replacing the rendered system prompt. Preserve unrelated sections and deliberate full-prompt overrides.
18
+ - Update the shared Pi development and contract-test baseline to 1.1.0; require Node.js >=22.19.0 to match the host runtime. Pi remains a host-supplied peer dependency.
19
+ - Share install telemetry mechanics through `@mocito/install-telemetry` while preserving Pi-specific settings and state paths.
20
+
21
+ ### Fixed
22
+
23
+ - Apply visibility settings to Pi 1.1.0's wrapped skills section, including when file readers are hidden behind codemode.
24
+ - Keep frontmatter and earlier extensions' visibility restrictions when session toggle slots are active.
25
+ - Restore hidden-skill startup colors on Pi 1.x while preserving theme changes, expanded resource paths, and reloads.
26
+ - Let `enableInstallTelemetry: false` override an enabled `PI_TELEMETRY` environment flag.
27
+
9
28
  ## [0.4.0] - 2026-07-28
10
29
 
11
30
  ### Added
package/CONTRIBUTING.md CHANGED
@@ -33,6 +33,14 @@ pi -e /path/to/pi-mono/packages/pi-skillful
33
33
 
34
34
  ## Pull request checklist
35
35
 
36
+ For prompt changes, also run the shared real-session contracts from the monorepo root:
37
+
38
+ ```bash
39
+ node --import tsx --test tests/structured-prompts.test.mjs
40
+ ```
41
+
42
+ These load both Skillful and Scout through Pi's SDK, inspect serialized provider requests and session history, and exercise the toggle editor wrapper. They use disposable profiles, synthetic credentials, temporary reference directories, and mocked provider responses; they do not use a live model or existing user settings. Coverage includes extension load order, explicit prompt overrides, hidden skill invocation, codemode, reload, resume, fork/tree navigation, and providers that collapse system updates.
43
+
36
44
  Before opening a pull request:
37
45
 
38
46
  - Run `npm run check`.
@@ -52,3 +60,9 @@ Before opening a pull request:
52
60
  ## Code of conduct
53
61
 
54
62
  This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
63
+ ## Pi 1.1.0 terminal audit
64
+
65
+ From the monorepo root, run `node --import tsx --test tests/tui-contracts.test.mjs`
66
+ alongside the package tests. `tests/TUI_AUDIT.md` records coverage, known host
67
+ limitations, and the disposable-profile physical-terminal acceptance procedure.
68
+ The startup patch remains isolated; no unreleased cursor API is required.
package/README.md CHANGED
@@ -102,6 +102,19 @@ Configured slots appear on the prompt editor's top border as `N skill-name`. Pro
102
102
 
103
103
  On app startup, non-hidden skills are active and hidden skills are inactive. Within a running Pi process, `/new` preserves the current toggle state for the new session. Resuming, forking, cloning, reloading, or restarting Pi resets toggle state from settings. Inline `/skill:name` invocation remains explicit and works even when that skill is inactive. Skills bundled in Pi packages are never modified by these toggles.
104
104
 
105
+ ### Prompt updates and extension composition
106
+
107
+ Toggle-border colors follow the current theme without another toggle or reload.
108
+ At very narrow widths, the complete border is clipped to the available columns.
109
+
110
+ Visibility and toggles use Pi's structured `systemPromptOptions.skills` API (Pi 1.1.0 or newer), not text replacement of the full system prompt. Pi records skill-list changes in the session transcript, alongside independent updates from other extensions such as Scout.
111
+
112
+ An active slot can lift a restriction from `skillful.hiddenSkills`, but does not clear `disable-model-invocation` frontmatter or a restriction applied by an earlier prompt handler. Later handlers see the updated list and can apply their own policy. Package-bundled skills remain outside Skillful's visibility controls and toggle slots.
113
+
114
+ A deliberate full-prompt override from another extension (`systemPrompt` or `forceSystemPrompt`) takes precedence. Skillful updates the structured skill list but does not rewrite that override; its author controls any skill descriptions embedded in the replacement text. Explicit skill invocation remains available.
115
+
116
+ Structured changes let Pi update individual prompt sections. Providers that cannot accept mid-conversation system changes may still need a full prompt checkpoint; cache savings are not guaranteed.
117
+
105
118
  ## Installation
106
119
 
107
120
  Install from npm:
@@ -148,7 +161,7 @@ This package is source-distributed. Pi loads the TypeScript extensions directly
148
161
 
149
162
  Requirements:
150
163
 
151
- - Node.js >= 20.6.0
164
+ - Node.js >= 22.19.0
152
165
  - npm for local development commands
153
166
 
154
167
  Common commands:
package/SECURITY.md CHANGED
@@ -21,6 +21,8 @@ The maintainer will acknowledge reports as soon as practical and coordinate disc
21
21
 
22
22
  `pi-skillful` is a Pi package. Pi extensions execute with the same permissions as the local user running Pi. Users should review installed Pi packages and only install packages from sources they trust.
23
23
 
24
+ At startup, `@mocito/install-telemetry` sends a best-effort install/update ping to the configured telemetry endpoint once per package version unless CI, Pi offline/telemetry settings, or `enableInstallTelemetry: false` disables it. It contains only the package name/version and parsed platform/runtime/architecture; it does not include prompts, paths, configuration values, credentials, or provider responses.
25
+
24
26
  ### Progressive skill loading and trust boundary
25
27
 
26
28
  `pi-skillful` extends Pi's skill discovery to ancestor directories above the git repository root, loading `.agents/skills/` from every parent directory up to the filesystem root (excluding `~/.agents/skills/`, which Pi already loads globally).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-skillful",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Pi package with skill invocation and visibility improvements.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -45,9 +45,9 @@
45
45
  "@earendil-works/pi-tui": "*"
46
46
  },
47
47
  "devDependencies": {
48
- "@earendil-works/pi-coding-agent": "^0.80.10",
49
- "@earendil-works/pi-tui": "^0.80.10",
50
- "@types/node": "^26.1.1",
48
+ "@earendil-works/pi-coding-agent": "1.1.0",
49
+ "@earendil-works/pi-tui": "1.1.0",
50
+ "@types/node": "^26.6.3",
51
51
  "typescript": "^7.0.2"
52
52
  },
53
53
  "scripts": {
@@ -60,6 +60,9 @@
60
60
  "access": "public"
61
61
  },
62
62
  "engines": {
63
- "node": ">=20.6.0"
63
+ "node": ">=22.19.0"
64
+ },
65
+ "dependencies": {
66
+ "@mocito/install-telemetry": "0.1.1"
64
67
  }
65
68
  }
@@ -3,7 +3,6 @@ import {
3
3
  type AppKeybinding,
4
4
  type ExtensionAPI,
5
5
  type KeybindingsManager,
6
- type Skill,
7
6
  type Theme,
8
7
  } from "@earendil-works/pi-coding-agent";
9
8
  import type { AutocompleteProvider, EditorComponent, EditorTheme, Focusable, KeyId, TUI } from "@earendil-works/pi-tui";
@@ -16,7 +15,6 @@ import {
16
15
  type SkillToggleModifier,
17
16
  type SkillToggleSlot,
18
17
  } from "../config.js";
19
- import { replaceSkillsSection } from "../skill-prompt.js";
20
18
  import { isTopLevelSkill, listLoadedSkills } from "../skills.js";
21
19
 
22
20
  const STORE_KEY = Symbol.for("pi-skillful.sessionSkillTogglesStore");
@@ -40,7 +38,7 @@ interface SessionToggleState {
40
38
  installedEditor: boolean;
41
39
  previousEditorFactory: SkillfulEditorFactory | undefined;
42
40
  activeTui: TUI | undefined;
43
- theme: Theme | undefined;
41
+ theme: (() => Theme) | undefined;
44
42
  notify: (message: string, type?: "info" | "warning" | "error") => void;
45
43
  }
46
44
 
@@ -79,7 +77,7 @@ export default function sessionSkillToggles(pi: ExtensionAPI) {
79
77
  installedEditor: false,
80
78
  previousEditorFactory: undefined,
81
79
  activeTui: undefined,
82
- theme: ctx.ui.theme,
80
+ theme: () => ctx.ui.theme,
83
81
  notify: ctx.ui.notify.bind(ctx.ui),
84
82
  };
85
83
 
@@ -90,13 +88,13 @@ export default function sessionSkillToggles(pi: ExtensionAPI) {
90
88
  pi.on("before_agent_start", (event) => {
91
89
  if (state.slots.length === 0 || !event.systemPromptOptions.skills?.length) return;
92
90
 
93
- const updatedSkills: Skill[] = event.systemPromptOptions.skills.map((skill) =>
94
- isTopLevelSkill(skill) ? { ...skill, disableModelInvocation: !isSkillActive(normalizeSkillName(skill.name)) } : skill,
91
+ // Active slots lift only Skillful's own restriction, never frontmatter or
92
+ // restrictions already applied by another prompt handler.
93
+ event.systemPromptOptions.skills = event.systemPromptOptions.skills.map((skill) =>
94
+ isTopLevelSkill(skill) && !isSkillActive(normalizeSkillName(skill.name))
95
+ ? { ...skill, disableModelInvocation: true }
96
+ : skill,
95
97
  );
96
-
97
- const systemPrompt = replaceSkillsSection(event.systemPrompt, updatedSkills);
98
- if (!systemPrompt) return;
99
- return { systemPrompt };
100
98
  });
101
99
 
102
100
  pi.on("session_shutdown", (event, ctx) => {
@@ -373,7 +371,7 @@ function renderToggleBorder(width: number, borderColor: (text: string) => string
373
371
  const fittedContent = truncateToWidth(renderToggleSegments(available), available, "");
374
372
  const used = BORDER_PREFIX_WIDTH + visibleWidth(fittedContent) + BORDER_SUFFIX_WIDTH;
375
373
  const fill = borderColor("─".repeat(Math.max(0, width - used)));
376
- return `${borderColor(BORDER_PREFIX)}${fittedContent}${BORDER_SUFFIX}${fill}`;
374
+ return truncateToWidth(`${borderColor(BORDER_PREFIX)}${fittedContent}${BORDER_SUFFIX}${fill}`, width, "");
377
375
  }
378
376
 
379
377
  function renderToggleSegments(availableWidth: number): string {
@@ -396,5 +394,5 @@ function renderToggleSegments(availableWidth: number): string {
396
394
  }
397
395
 
398
396
  function stateThemeFg(color: "accent" | "muted", text: string): string {
399
- return state.theme?.fg(color, text) ?? text;
397
+ return state.theme?.().fg(color, text) ?? text;
400
398
  }
@@ -1,3 +1,4 @@
1
+ import { stripVTControlCharacters } from "node:util";
1
2
  import {
2
3
  DynamicBorder,
3
4
  getSettingsListTheme,
@@ -19,20 +20,23 @@ import {
19
20
  writeProjectSkillfulOverride,
20
21
  writeToggleSlots,
21
22
  } from "../config.js";
22
- import { replaceSkillsSection } from "../skill-prompt.js";
23
23
  import { isTopLevelSkill, listLoadedSkills, type LoadedSkillInfo } from "../skills.js";
24
24
  import { hasActiveSessionSkillToggles, refreshSessionSkillToggles } from "./session-skill-toggles.js";
25
25
  const SCOPES: SkillfulScope[] = ["global", "project"];
26
26
  const STORE_KEY = Symbol.for("pi-skillful.skillVisibilityStore");
27
- const STARTUP_PATCH_KEY = Symbol.for("pi-skillful.startupPatchV3");
27
+ // Install the new component contract even after reloading a V3-patched process.
28
+ const STARTUP_PATCH_KEY = Symbol.for("pi-skillful.startupPatchV4");
28
29
 
29
30
  interface SkillVisibilityStore {
30
31
  hiddenSkillsByCwd: Map<string, Set<string>>;
31
32
  theme: Theme | null;
33
+ getTheme?: () => Theme;
32
34
  }
33
35
 
34
36
  interface ExpandableTextLike {
35
- getCollapsedText: () => string;
37
+ getCollapsedText?: () => string;
38
+ build?: () => string;
39
+ invalidate?: () => void;
36
40
  setText: (text: string) => void;
37
41
  }
38
42
 
@@ -43,6 +47,7 @@ interface BoxLike {
43
47
  interface InteractiveModeLike {
44
48
  loadedResourcesContainer?: BoxLike;
45
49
  showLoadedResources?: (options?: unknown) => void;
50
+ getStartupExpansionState?: () => boolean;
46
51
  session?: { resourceLoader?: { getSkills: () => { skills: Skill[]; diagnostics: unknown[] } } };
47
52
  sessionManager?: { getCwd?: () => string };
48
53
  }
@@ -84,6 +89,7 @@ export default function skillVisibility(pi: ExtensionAPI) {
84
89
 
85
90
  pi.on("session_start", async (_event, ctx) => {
86
91
  store.theme = ctx.ui.theme;
92
+ store.getTheme = () => ctx.ui.theme;
87
93
  await refreshHiddenSkillCache(ctx.cwd, ctx.isProjectTrusted());
88
94
  });
89
95
 
@@ -93,12 +99,9 @@ export default function skillVisibility(pi: ExtensionAPI) {
93
99
  const hidden = await refreshHiddenSkillCache(ctx.cwd, ctx.isProjectTrusted());
94
100
  if (hidden.size === 0 || !event.systemPromptOptions.skills?.length) return;
95
101
 
96
- const filteredSkills: Skill[] = event.systemPromptOptions.skills.map((skill) =>
102
+ event.systemPromptOptions.skills = event.systemPromptOptions.skills.map((skill) =>
97
103
  isTopLevelSkill(skill) && hidden.has(skill.name) ? { ...skill, disableModelInvocation: true } : skill,
98
104
  );
99
- const systemPrompt = replaceSkillsSection(event.systemPrompt, filteredSkills);
100
- if (!systemPrompt) return;
101
- return { systemPrompt };
102
105
  });
103
106
 
104
107
  pi.registerCommand("skillful", {
@@ -184,7 +187,6 @@ export function installStartupSkillListPatch(
184
187
  return result;
185
188
  };
186
189
 
187
- const childrenBefore = this.loadedResourcesContainer?.children.length ?? 0;
188
190
  try {
189
191
  original.call(this, options);
190
192
  } finally {
@@ -193,16 +195,36 @@ export function installStartupSkillListPatch(
193
195
 
194
196
  if (rawSkillNames.length === 0 || !cwd || !this.loadedResourcesContainer) return;
195
197
 
196
- const hidden = store.hiddenSkillsByCwd.get(cwd) ?? new Set<string>();
197
- const children = this.loadedResourcesContainer.children;
198
- for (let index = childrenBefore; index < children.length; index++) {
199
- const child = children[index] as ExpandableTextLike | undefined;
198
+ const colorized = () => buildColorizedSkillList(
199
+ rawSkillNames,
200
+ store.hiddenSkillsByCwd.get(cwd) ?? new Set<string>(),
201
+ store.getTheme?.() ?? store.theme,
202
+ );
203
+ const collapsedText = buildColorizedSkillList(rawSkillNames, new Set(), null);
204
+ // Pi clears and rebuilds this container, including during /reload.
205
+ for (const entry of this.loadedResourcesContainer.children) {
206
+ const child = entry as ExpandableTextLike | undefined;
207
+ if (child && typeof child.build === "function") {
208
+ // Pi 1.x closes over expansion state in ThemedText.build instead of
209
+ // exposing getCollapsedText. Decorate only the compact skill list;
210
+ // leave expanded paths and other sections to Pi. Rebuild colors on
211
+ // invalidation so theme changes never retain captured ANSI strings.
212
+ const build = child.build;
213
+ if (!stripVTControlCharacters(build.call(child)).startsWith("[Skills]\n")) continue;
214
+ child.build = () => {
215
+ const text = build.call(child);
216
+ return stripVTControlCharacters(text) === collapsedText ? colorized() : text;
217
+ };
218
+ child.invalidate?.();
219
+ break;
220
+ }
200
221
  if (!child || typeof child.getCollapsedText !== "function") continue;
201
222
  if (!child.getCollapsedText().includes("[Skills]")) continue;
202
223
 
203
- const colorized = buildColorizedSkillList(rawSkillNames, hidden, store.theme);
204
- child.getCollapsedText = () => colorized;
205
- child.setText(colorized);
224
+ // Retain the older Pi component contract without collapsing an
225
+ // initially expanded resource listing.
226
+ child.getCollapsedText = colorized;
227
+ if (!this.getStartupExpansionState?.()) child.setText(colorized());
206
228
  break;
207
229
  }
208
230
  };
@@ -1,12 +1,11 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { mkdir, writeFile } from "node:fs/promises";
3
- import { fileURLToPath } from "node:url";
4
2
  import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { reportInstallTelemetry as report } from "@mocito/install-telemetry";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
 
7
7
  const PACKAGE_NAME = "pi-skillful";
8
- const INSTALL_TELEMETRY_URL = "https://mocito.dev/api/report-install";
9
- const INSTALL_TELEMETRY_TIMEOUT_MS = 5000;
8
+ const INSTALL_TELEMETRY_ENDPOINT = "https://mocito.dev/api/report-install";
10
9
  const CI_ENVIRONMENT_VARIABLES = [
11
10
  "APPVEYOR",
12
11
  "BITBUCKET_BUILD_NUMBER",
@@ -24,10 +23,6 @@ const CI_ENVIRONMENT_VARIABLES = [
24
23
  "VERCEL",
25
24
  ];
26
25
 
27
- interface InstallTelemetryState {
28
- lastReportedVersion?: string;
29
- }
30
-
31
26
  interface PiSettingsDocument {
32
27
  enableInstallTelemetry?: unknown;
33
28
  }
@@ -51,18 +46,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
51
46
  return normalized !== "0" && normalized !== "false" && normalized !== "no";
52
47
  }
53
48
 
54
- function isCiEnvironment(): boolean {
55
- if (isTruthyEnvFlag(process.env.CI)) return true;
56
- return CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(process.env[name]));
57
- }
58
-
59
- function isInstallTelemetryEnabled(): boolean {
60
- if (isCiEnvironment()) return false;
61
- if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
62
- if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
49
+ export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
50
+ if (isTruthyEnvFlag(env.CI)) return false;
51
+ if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
52
+ if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
63
53
 
64
- const settings = readJsonFile(join(getAgentDir(), "settings.json")) as PiSettingsDocument;
65
- return settings.enableInstallTelemetry !== false;
54
+ const settings = readJsonFile(settingsPath) as PiSettingsDocument;
55
+ if (settings.enableInstallTelemetry === false) return false;
56
+ if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
57
+ return true;
66
58
  }
67
59
 
68
60
  function getPackageVersion(): string {
@@ -70,35 +62,16 @@ function getPackageVersion(): string {
70
62
  return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
71
63
  }
72
64
 
73
- function getInstallTelemetryUserAgent(version: string): string {
74
- const runtimeVersions = process.versions as NodeJS.ProcessVersions & { bun?: string };
75
- const runtime = runtimeVersions.bun ? `bun/${runtimeVersions.bun}` : `node/${process.version}`;
76
- return `${PACKAGE_NAME}/${version} (${process.platform}; ${runtime}; ${process.arch})`;
77
- }
78
-
79
- async function reportInstallTelemetryAsync(): Promise<void> {
65
+ export function reportInstallTelemetry(): void {
80
66
  try {
81
- if (!isInstallTelemetryEnabled()) return;
82
-
83
- const version = getPackageVersion();
84
- const extensionsDir = join(getAgentDir(), "extensions");
85
- const statePath = join(extensionsDir, "skillful-install.json");
86
- const state = readJsonFile(statePath) as InstallTelemetryState;
87
- if (state.lastReportedVersion === version) return;
88
-
89
- await mkdir(extensionsDir, { recursive: true });
90
- await writeFile(statePath, `${JSON.stringify({ lastReportedVersion: version }, null, 2)}\n`, "utf8");
91
-
92
- const params = new URLSearchParams({ tool: PACKAGE_NAME, version });
93
- await fetch(`${INSTALL_TELEMETRY_URL}?${params.toString()}`, {
94
- headers: { "User-Agent": getInstallTelemetryUserAgent(version) },
95
- signal: AbortSignal.timeout(INSTALL_TELEMETRY_TIMEOUT_MS),
96
- });
67
+ void report({
68
+ endpoint: INSTALL_TELEMETRY_ENDPOINT,
69
+ tool: PACKAGE_NAME,
70
+ version: getPackageVersion(),
71
+ statePath: join(getAgentDir(), "extensions", "skillful-install.json"),
72
+ enabled: isInstallTelemetryEnabled(),
73
+ }).catch(() => undefined);
97
74
  } catch {
98
- // Best-effort telemetry: ignore settings, filesystem, and network failures.
75
+ // Best-effort telemetry: ignore local policy and filesystem failures.
99
76
  }
100
77
  }
101
-
102
- export function reportInstallTelemetry(): void {
103
- void reportInstallTelemetryAsync();
104
- }
@@ -1,8 +0,0 @@
1
- import { formatSkillsForPrompt, type Skill } from "@earendil-works/pi-coding-agent";
2
-
3
- export const SKILLS_SECTION_PATTERN = /\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<\/available_skills>/;
4
-
5
- export function replaceSkillsSection(systemPrompt: string, skills: Skill[]): string | undefined {
6
- const next = systemPrompt.replace(SKILLS_SECTION_PATTERN, formatSkillsForPrompt(skills));
7
- return next === systemPrompt ? undefined : next;
8
- }