@pi-unipi/utility 2.14.0 → 2.14.2

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/README.md CHANGED
@@ -68,6 +68,26 @@ Or edit `.unipi/config/util-settings.json` directly (migrated automatically from
68
68
 
69
69
  The badge is a persistent HUD overlay in the top-right corner showing the current session name. It auto-restores visibility on session restart.
70
70
 
71
+ ### Skill Startup Discovery
72
+
73
+ Controls whether Unipi's built-in skills are cataloged in the agent's system prompt at startup (default: on). Your own skills — global, project, settings-mounted, and third-party packages — always stay cataloged.
74
+
75
+ ```
76
+ /unipi:skills-settings # Interactive toggle (or /unipi:skills-settings on|off)
77
+ ```
78
+
79
+ Or edit `~/.pi/agent/settings.json` directly:
80
+
81
+ ```json
82
+ {
83
+ "unipi": {
84
+ "skills": { "discovery": false }
85
+ }
86
+ }
87
+ ```
88
+
89
+ When off, Unipi's bundled skills are removed from the `<available_skills>` catalog, so their metadata never populates agent context. They remain invocable via `/skill:name` — pi expands those commands by reading the skill file directly, independent of the prompt catalog. The filter is applied consistently every turn, so provider prefix caching is unaffected.
90
+
71
91
  ## Programmatic API
72
92
 
73
93
  | Module | Path | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/utility",
3
- "version": "2.14.0",
3
+ "version": "2.14.2",
4
4
  "description": "Utility commands and tools for Pi coding agent — lifecycle, diagnostics, cache, analytics, display, batch execution",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -36,7 +36,7 @@
36
36
  "access": "public"
37
37
  },
38
38
  "dependencies": {
39
- "@pi-unipi/core": "2.14.0"
39
+ "@pi-unipi/core": "2.14.2"
40
40
  },
41
41
  "devDependencies": {},
42
42
  "peerDependencies": {
package/src/commands.ts CHANGED
@@ -22,6 +22,7 @@ import { runDiagnostics, formatDiagnosticsReport } from "./diagnostics/engine.js
22
22
  import { getEnvironmentInfo, formatEnvironmentInfo } from "./tools/env.js";
23
23
  import type { NameBadgeState } from "./tui/name-badge-state.js";
24
24
  import { readBadgeSettings, updateBadgeSetting, formatBadgeSettings } from "./settings.js";
25
+ import { isSkillDiscoveryEnabled, saveSkillDiscoverySettings } from "./skill-discovery.js";
25
26
  import { UtilSettingsTui } from "./tui/util-settings-tui.js";
26
27
 
27
28
  /** Send a markdown response via pi.sendMessage */
@@ -134,6 +135,59 @@ export function registerNameBadgeCommands(
134
135
  },
135
136
  });
136
137
 
138
+ // ─── /unipi:skills-settings — skill startup discovery toggle ────────────
139
+ pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.SKILLS_SETTINGS}`, {
140
+ description: "Toggle skill startup discovery (skills catalog in system prompt; default on)",
141
+ handler: async (args: string, ctx: ExtensionContext) => {
142
+ const describe = (enabled: boolean) =>
143
+ enabled
144
+ ? "Skill discovery: ON — skills are cataloged in the system prompt at startup"
145
+ : "Skill discovery: OFF — skills are invoke-only via /skill:name (no startup catalog)";
146
+
147
+ const arg = args.trim().toLowerCase();
148
+ const current = isSkillDiscoveryEnabled();
149
+
150
+ let next: boolean | undefined;
151
+ if (arg === "on" || arg === "true" || arg === "1") {
152
+ next = true;
153
+ } else if (arg === "off" || arg === "false" || arg === "0") {
154
+ next = false;
155
+ } else if (arg === "" || arg === "toggle") {
156
+ if (arg === "" && ctx.hasUI) {
157
+ const choice = await ctx.ui.select(
158
+ "Skill startup discovery",
159
+ [
160
+ `On — catalog skills in the system prompt${current ? " (current)" : ""}`,
161
+ `Off — invoke-only via /skill:name${current ? "" : " (current)"}`,
162
+ ],
163
+ );
164
+ next = choice ? choice.startsWith("On") : undefined;
165
+ } else {
166
+ next = !current;
167
+ }
168
+ }
169
+
170
+ if (next === undefined) {
171
+ // Cancelled or invalid arg — show current state.
172
+ const status = describe(current);
173
+ if (ctx.hasUI) {
174
+ ctx.ui.notify(status, "info");
175
+ } else {
176
+ sendResponse(pi, status);
177
+ }
178
+ return;
179
+ }
180
+
181
+ const ok = saveSkillDiscoverySettings({ discovery: next });
182
+ const message = ok ? describe(next) : "Failed to save skill discovery setting.";
183
+ if (ctx.hasUI) {
184
+ ctx.ui.notify(message, ok ? "info" : "warning");
185
+ } else {
186
+ sendResponse(pi, message);
187
+ }
188
+ },
189
+ });
190
+
137
191
  // ─── /unipi:util-settings — unified settings TUI ──────────────────────
138
192
  pi.registerCommand(`${UNIPI_PREFIX}${UTILITY_COMMANDS.UTIL_SETTINGS}`, {
139
193
  description: "Configure badge and diff settings via unified TUI overlay",
package/src/index.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  type UnipiBadgeGenerateRequestEvent,
25
25
  } from "@pi-unipi/core";
26
26
  import { registerUtilityCommands, registerNameBadgeCommands } from "./commands.js";
27
+ import { isSkillDiscoveryEnabled, stripBundledSkills } from "./skill-discovery.js";
27
28
  import { NameBadgeState } from "./tui/name-badge-state.js";
28
29
  import { readBadgeSettings } from "./settings.js";
29
30
  import { getLifecycle } from "./lifecycle/process.js";
@@ -60,6 +61,7 @@ const ALL_COMMANDS = [
60
61
  UTILITY_COMMANDS.BADGE_SETTINGS,
61
62
  UTILITY_COMMANDS.UTIL_SETTINGS,
62
63
  UTILITY_COMMANDS.PREFIX_CACHE,
64
+ UTILITY_COMMANDS.SKILLS_SETTINGS,
63
65
  ].map((cmd) => `unipi:${cmd}`);
64
66
 
65
67
  /** All tools registered by this module */
@@ -128,6 +130,19 @@ export default function (pi: ExtensionAPI) {
128
130
 
129
131
  // Capture session context for cross-event use (not needed if BADGE_GENERATE_REQUEST removed)
130
132
 
133
+ // Skill startup discovery gate — when disabled (unipi.skills.discovery: false),
134
+ // Unipi's bundled skills are removed from the <available_skills> catalog so
135
+ // they never populate agent context; the user's own skills (global, project,
136
+ // settings-mounted, third-party packages) stay cataloged. /skill:name
137
+ // invocation is unaffected either way: pi expands those commands by reading
138
+ // SKILL.md directly. Applied consistently per turn, so the provider prefix
139
+ // cache stays intact.
140
+ pi.on("before_agent_start", (event) => {
141
+ if (isSkillDiscoveryEnabled()) return undefined;
142
+ const filtered = stripBundledSkills(event.systemPrompt);
143
+ return filtered ? { systemPrompt: filtered } : undefined;
144
+ });
145
+
131
146
  // Register commands
132
147
  registerUtilityCommands(pi);
133
148
  registerNameBadgeCommands(pi, nameBadgeState);
@@ -0,0 +1,178 @@
1
+ /**
2
+ * @pi-unipi/utility — Skill Startup Discovery Gate
3
+ *
4
+ * Controls whether discovered skills are cataloged in the agent's system
5
+ * prompt at session start. Setting: `unipi.skills.discovery` in pi's
6
+ * settings.json (default: true).
7
+ *
8
+ * When off, the `<available_skills>` section is stripped from the system
9
+ * prompt every turn. Skills remain invocable via `/skill:name` — pi expands
10
+ * those commands by reading SKILL.md directly from disk, independent of the
11
+ * prompt catalog.
12
+ */
13
+
14
+ import * as fs from "node:fs";
15
+ import * as os from "node:os";
16
+ import * as path from "node:path";
17
+ import { UNIPI_SETTINGS_KEY } from "@pi-unipi/core";
18
+
19
+ /** Skill discovery settings */
20
+ export interface SkillDiscoverySettings {
21
+ /** Catalog skills in the system prompt at startup (default: true) */
22
+ discovery: boolean;
23
+ }
24
+
25
+ /** Default skill discovery settings */
26
+ export const DEFAULT_SKILL_DISCOVERY_SETTINGS: SkillDiscoverySettings = {
27
+ discovery: true,
28
+ };
29
+
30
+ /** System-prompt markers for the skills catalog (agentskills.io tags). */
31
+ const SKILLS_OPEN_TAG = "<available_skills>";
32
+ const SKILLS_CLOSE_TAG = "</available_skills>";
33
+
34
+ /**
35
+ * Get the path to pi's settings.json.
36
+ */
37
+ function getSettingsPath(): string {
38
+ const agentDir = process.env.PI_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
39
+ return path.join(agentDir, "settings.json");
40
+ }
41
+
42
+ /**
43
+ * Read the raw settings.json file.
44
+ * Returns null if the file doesn't exist or is malformed.
45
+ */
46
+ function readSettingsFile(): Record<string, unknown> | null {
47
+ try {
48
+ const settingsPath = getSettingsPath();
49
+ if (!fs.existsSync(settingsPath)) return null;
50
+ const raw = fs.readFileSync(settingsPath, "utf-8");
51
+ return JSON.parse(raw) as Record<string, unknown>;
52
+ } catch {
53
+ // Silently ignore — read failure falls back to defaults.
54
+ return null;
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Write settings back to settings.json.
60
+ */
61
+ function writeSettingsFile(settings: Record<string, unknown>): boolean {
62
+ try {
63
+ const settingsPath = getSettingsPath();
64
+ const dir = path.dirname(settingsPath);
65
+ if (!fs.existsSync(dir)) {
66
+ fs.mkdirSync(dir, { recursive: true });
67
+ }
68
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
69
+ return true;
70
+ } catch {
71
+ // Silently ignore — write failure is non-blocking.
72
+ return false;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Load skill discovery settings from settings.json.
78
+ * Falls back to defaults for any missing fields.
79
+ */
80
+ export function loadSkillDiscoverySettings(): SkillDiscoverySettings {
81
+ const raw = readSettingsFile();
82
+ if (!raw) return { ...DEFAULT_SKILL_DISCOVERY_SETTINGS };
83
+
84
+ try {
85
+ const unipi = raw[UNIPI_SETTINGS_KEY] as Record<string, unknown> | undefined;
86
+ const skills = unipi?.skills as Record<string, unknown> | undefined;
87
+ if (!skills) return { ...DEFAULT_SKILL_DISCOVERY_SETTINGS };
88
+ return {
89
+ discovery: typeof skills.discovery === "boolean" ? skills.discovery : DEFAULT_SKILL_DISCOVERY_SETTINGS.discovery,
90
+ };
91
+ } catch {
92
+ return { ...DEFAULT_SKILL_DISCOVERY_SETTINGS };
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Save skill discovery settings to settings.json.
98
+ * Merges with existing settings (preserves other keys).
99
+ */
100
+ export function saveSkillDiscoverySettings(partial: Partial<SkillDiscoverySettings>): boolean {
101
+ const raw = readSettingsFile() ?? {};
102
+ const unipi = (raw[UNIPI_SETTINGS_KEY] as Record<string, unknown>) ?? {};
103
+ const existing = (unipi.skills as Record<string, unknown>) ?? {};
104
+
105
+ unipi.skills = { ...existing, ...partial };
106
+ raw[UNIPI_SETTINGS_KEY] = unipi;
107
+
108
+ return writeSettingsFile(raw);
109
+ }
110
+
111
+ /**
112
+ * Whether skills are cataloged in the system prompt at startup.
113
+ */
114
+ export function isSkillDiscoveryEnabled(): boolean {
115
+ return loadSkillDiscoverySettings().discovery;
116
+ }
117
+
118
+ /**
119
+ * Match a skill location that belongs to Unipi's own bundled skills.
120
+ *
121
+ * - Installed via npm: `…/node_modules/@pi-unipi/<pkg>/skills/…`
122
+ * - Dev checkout (workspace/mise run): `…/unipi/packages/<pkg>/skills/…`
123
+ *
124
+ * Everything else (user global, project, settings-mounted, third-party
125
+ * packages) is NOT considered bundled and stays discoverable.
126
+ */
127
+ export function isBundledSkillLocation(location: string): boolean {
128
+ return location.includes("/@pi-unipi/") || /\/unipi\/packages\//.test(location);
129
+ }
130
+
131
+ /**
132
+ * Remove Unipi's bundled skills from the `<available_skills>` catalog in a
133
+ * system prompt, keeping every other skill discoverable. When no non-bundled
134
+ * skills remain, the whole section (with its intro paragraph) is removed.
135
+ *
136
+ * Anchor-based: section tags are agentskills.io spec (stable), entry
137
+ * splitting follows pi's `formatSkillsForPrompt` layout (` <skill>` entries
138
+ * with `<name>`/`<description>`/`<location>` children).
139
+ *
140
+ * Returns undefined when there is nothing to change (no section, or no
141
+ * bundled skills in it).
142
+ */
143
+ export function stripBundledSkills(systemPrompt: string): string | undefined {
144
+ const open = systemPrompt.indexOf(SKILLS_OPEN_TAG);
145
+ if (open === -1) return undefined;
146
+ const close = systemPrompt.indexOf(SKILLS_CLOSE_TAG, open);
147
+ if (close === -1) return undefined;
148
+ const sectionStart = open + SKILLS_OPEN_TAG.length;
149
+
150
+ const inner = systemPrompt.slice(sectionStart, close);
151
+ const entries = inner.split(/(?= <skill>)/g);
152
+ const kept: string[] = [];
153
+ let bundledCount = 0;
154
+ for (const entry of entries) {
155
+ if (!entry.includes("<skill>")) continue; // Whitespace between tags.
156
+ const locationMatch = entry.match(/<location>([^<]*)<\/location>/);
157
+ if (locationMatch && isBundledSkillLocation(locationMatch[1])) {
158
+ bundledCount++;
159
+ continue;
160
+ }
161
+ kept.push(entry);
162
+ }
163
+
164
+ if (bundledCount === 0) return undefined; // No bundled skills — no-op.
165
+
166
+ if (kept.length === 0) {
167
+ // Nothing left to catalog — remove the entire section (intro included).
168
+ // Layout: "<prev>\n\n<intro paragraph>\n\n<available_skills>…</available_skills>".
169
+ const p1 = systemPrompt.lastIndexOf("\n\n", open);
170
+ const prev = p1 === -1 ? -1 : systemPrompt.lastIndexOf("\n\n", p1 - 1);
171
+ const start = prev === -1 ? 0 : prev;
172
+ return systemPrompt.slice(0, start) + systemPrompt.slice(close + SKILLS_CLOSE_TAG.length);
173
+ }
174
+
175
+ // Rebuild the catalog with only the non-bundled entries.
176
+ const rebuiltInner = "\n" + kept.join("").replace(/\n+$/, "") + "\n";
177
+ return systemPrompt.slice(0, sectionStart) + rebuiltInner + systemPrompt.slice(close);
178
+ }