@xynogen/pix-core 0.2.1 → 0.2.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-core",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Pi extension — core UI/UX bundle (welcome banner, footer, model picker, self-update)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -4,25 +4,82 @@
4
4
  * Built-in commands can't be removed via the extension API, so we edit Pi's
5
5
  * compiled slash-commands.js directly. Done on every load: idempotent and
6
6
  * self-healing across Pi upgrades, so no manual repatch is ever needed.
7
+ *
8
+ * Resolution strategy (in order):
9
+ * 1. Locate the `pi` binary via PATH → infer package root from its realpath.
10
+ * The binary is always at <pkg>/dist/cli.js so ../../ is the package root.
11
+ * 2. Probe well-known global install locations (bun, npm).
12
+ * 3. Fall back to createRequire against the extension's own node_modules
13
+ * (works when pi and the extension share the same install tree).
7
14
  */
8
15
 
9
- import { readFileSync, writeFileSync } from "node:fs";
16
+ import { execSync } from "node:child_process";
17
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
10
18
  import { createRequire } from "node:module";
11
- import { dirname, resolve } from "node:path";
19
+ import { homedir } from "node:os";
20
+ import { dirname, join, resolve } from "node:path";
12
21
 
13
- const HOST_PACKAGE = "@earendil-works/pi-coding-agent";
14
22
  const MODEL_COMMAND_LINE =
15
23
  '{ name: "model", description: "Select model (opens selector UI)" },';
16
24
 
17
- /** Locate the host's compiled slash-commands.js, or null if it can't be found. */
18
- function findSlashCommandsFile(): string | null {
25
+ /** Candidate slash-commands.js paths, most-specific first. */
26
+ function candidatePaths(): string[] {
27
+ const paths: string[] = [];
28
+
29
+ // 1. Resolve via the running `pi` binary → its realpath gives the dist dir.
30
+ try {
31
+ const piReal = execSync("realpath $(which pi)", {
32
+ encoding: "utf8",
33
+ stdio: ["pipe", "pipe", "pipe"],
34
+ }).trim();
35
+ if (piReal) {
36
+ // piReal = /.../pi-coding-agent/dist/cli.js → dist/ → ../dist/core/
37
+ const distCore = resolve(dirname(piReal), "core");
38
+ paths.push(join(distCore, "slash-commands.js"));
39
+ }
40
+ } catch {
41
+ // `pi` not on PATH or `which`/`realpath` unavailable — skip
42
+ }
43
+
44
+ // 2. Well-known global install locations.
45
+ const home = homedir();
46
+ const globalRoots = [
47
+ join(home, ".bun", "install", "global", "node_modules"),
48
+ join(home, ".npm-global", "lib", "node_modules"),
49
+ "/usr/local/lib/node_modules",
50
+ "/usr/lib/node_modules",
51
+ ];
52
+ for (const root of globalRoots) {
53
+ paths.push(
54
+ join(
55
+ root,
56
+ "@earendil-works",
57
+ "pi-coding-agent",
58
+ "dist",
59
+ "core",
60
+ "slash-commands.js",
61
+ ),
62
+ );
63
+ }
64
+
65
+ // 3. Fallback: createRequire from this file (works when extension is co-installed).
19
66
  try {
20
67
  const require = createRequire(import.meta.url);
21
- const entry = require.resolve(HOST_PACKAGE);
22
- return resolve(dirname(entry), "core", "slash-commands.js");
68
+ const entry = require.resolve("@earendil-works/pi-coding-agent");
69
+ paths.push(resolve(dirname(entry), "core", "slash-commands.js"));
23
70
  } catch {
24
- return null;
71
+ // local resolution failed — skip
25
72
  }
73
+
74
+ return paths;
75
+ }
76
+
77
+ /** Locate the host's compiled slash-commands.js, or null if not found. */
78
+ function findSlashCommandsFile(): string | null {
79
+ for (const p of candidatePaths()) {
80
+ if (existsSync(p)) return p;
81
+ }
82
+ return null;
26
83
  }
27
84
 
28
85
  /**
@@ -37,7 +94,7 @@ export function patchOutBuiltinModelCommand(): void {
37
94
  try {
38
95
  source = readFileSync(file, "utf8");
39
96
  } catch {
40
- return; // file not present (different Pi layout) — nothing to do
97
+ return;
41
98
  }
42
99
 
43
100
  if (!source.includes(MODEL_COMMAND_LINE)) return; // already patched
@@ -58,3 +115,6 @@ export function patchOutBuiltinModelCommand(): void {
58
115
  function escapeRegExp(text: string): string {
59
116
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
60
117
  }
118
+
119
+ // Export for tests
120
+ export { candidatePaths, findSlashCommandsFile };
package/src/index.ts CHANGED
@@ -16,7 +16,6 @@
16
16
  */
17
17
 
18
18
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
- import registerSkillLoader from "@xynogen/pix-skills";
20
19
  import registerAgentSop from "./commands/agent-sop/agent-sop.ts";
21
20
  import registerClear from "./commands/clear/clear.ts";
22
21
  import registerDiff from "./commands/diff/diff.ts";
@@ -32,7 +31,6 @@ import registerWelcome from "./ui/welcome.ts";
32
31
 
33
32
  export default function (pi: ExtensionAPI): void {
34
33
  registerAgentSop(pi);
35
- registerSkillLoader(pi);
36
34
  registerWelcome(pi);
37
35
  registerFooter(pi);
38
36
  registerDiagnostics(pi);
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
- import {
4
+ import registerCapabilityNudge, {
5
5
  buildOrientation,
6
6
  CAPABILITY_REMINDER,
7
7
  countInvocableSkills,
@@ -52,11 +52,9 @@ describe("CAPABILITY_REMINDER", () => {
52
52
  expect(CAPABILITY_REMINDER).toContain("read_skills()");
53
53
  });
54
54
 
55
- test("points at /toolbox slash command for discovery (not a function call)", () => {
56
- expect(CAPABILITY_REMINDER).toContain("/toolbox");
57
- // must NOT imply toolbox is a callable function
55
+ test("does not mention user-only toolbox command", () => {
56
+ expect(CAPABILITY_REMINDER).not.toContain("/toolbox");
58
57
  expect(CAPABILITY_REMINDER).not.toContain("toolbox(");
59
- expect(CAPABILITY_REMINDER).toContain("function definitions");
60
58
  });
61
59
  });
62
60
 
@@ -132,13 +130,11 @@ describe("buildOrientation", () => {
132
130
  expect(out).toContain("2 skills");
133
131
  });
134
132
 
135
- test("explains how to use read_skills() and /toolbox for discovery", () => {
133
+ test("explains how to use read_skills() without user-only toolbox", () => {
136
134
  const out = buildOrientation([tool("read", "builtin")], []);
137
135
  expect(out).toContain("read_skills()");
138
- expect(out).toContain("/toolbox");
139
- // toolbox must NOT appear as a function call
136
+ expect(out).not.toContain("/toolbox");
140
137
  expect(out).not.toContain("toolbox(");
141
- expect(out.toLowerCase()).toMatch(/discover|enable/);
142
138
  });
143
139
 
144
140
  test("calls out gated tools and points at toolbox to enable them", () => {
@@ -153,7 +149,7 @@ describe("buildOrientation", () => {
153
149
  ["read", "grep"], // active set: 2 gated out
154
150
  );
155
151
  expect(out).toContain("2 are gated");
156
- expect(out).toContain("enable via toolbox");
152
+ expect(out).toContain("function definitions");
157
153
  });
158
154
 
159
155
  test("singular phrasing when exactly one tool is gated", () => {
@@ -163,7 +159,7 @@ describe("buildOrientation", () => {
163
159
  ["read"],
164
160
  );
165
161
  expect(out).toContain("1 is gated");
166
- expect(out).toContain("enable via toolbox");
162
+ expect(out).toContain("function definitions");
167
163
  });
168
164
 
169
165
  test("no gate line when nothing is gated", () => {
@@ -213,6 +209,33 @@ describe("buildOrientation", () => {
213
209
  });
214
210
  });
215
211
 
212
+ describe("registerCapabilityNudge", () => {
213
+ test("injects orientation into system prompt, not a custom message", async () => {
214
+ let handler: ((event: { systemPrompt?: string }) => unknown) | undefined;
215
+ const pi = {
216
+ on(event: string, fn: typeof handler) {
217
+ if (event === "before_agent_start") handler = fn;
218
+ },
219
+ getAllTools() {
220
+ return [tool("read", "builtin")];
221
+ },
222
+ getActiveTools() {
223
+ return ["read"];
224
+ },
225
+ } as never;
226
+
227
+ registerCapabilityNudge(pi);
228
+ const result = (await handler?.({ systemPrompt: "BASE" })) as {
229
+ systemPrompt?: string;
230
+ message?: unknown;
231
+ };
232
+
233
+ expect(result.message).toBeUndefined();
234
+ expect(result.systemPrompt).toStartWith("BASE\n\nToolbelt:");
235
+ expect(result.systemPrompt).toContain("Orientation only");
236
+ });
237
+ });
238
+
216
239
  describe("graphifyHint", () => {
217
240
  const tmpDir = join(import.meta.dir, ".graphify-hint-test-tmp");
218
241
 
@@ -4,7 +4,7 @@
4
4
  * Models drift toward improvising mid-session, forgetting they can ask the
5
5
  * user, search the web, pull library docs (context7), use LSP, or invoke an
6
6
  * Agent Skill instead of guessing. Fires on EVERY user prompt via
7
- * `before_agent_start`, emitted as ONE hidden message (`display: false`).
7
+ * `before_agent_start`, injected into the system prompt for the turn.
8
8
  *
9
9
  * Two modes:
10
10
  * 1. FIRST prompt of the session — an orientation block: a high-level
@@ -31,12 +31,13 @@ import type {
31
31
  type LoadedSkill = NonNullable<BuildSystemPromptOptions["skills"]>[number];
32
32
 
33
33
  /** The standing per-turn reminder. Kept terse — it ships on every turn. */
34
+ /** How often (in turns after the first) to re-send the capability reminder. */
35
+ const REMINDER_INTERVAL = 10;
36
+
34
37
  export const CAPABILITY_REMINDER =
35
38
  "Reminder — check knowledge resources " +
36
39
  "(skills/tools/MCP/web/user) before improvising. " +
37
- "Matching skill? Call read_skills() first. " +
38
- "Use /toolbox to discover/enable gated tools. " +
39
- "All tools callable via function definitions.";
40
+ "Matching skill? Call read_skills() first.";
40
41
 
41
42
  /**
42
43
  * Build the optional graphify hint line.
@@ -93,7 +94,7 @@ export function partitionTools(
93
94
  * Build the one-time orientation block shown on the FIRST prompt. Describes the
94
95
  * shape of the toolbelt (counts) and how to explore it via `toolbox`, plus the
95
96
  * sorted skill names so the model knows what exists by name without a dump of
96
- * descriptions (those live in the system prompt / are searchable via toolbox).
97
+ * descriptions (those live in the system prompt / are searchable via tools).
97
98
  */
98
99
  export function buildOrientation(
99
100
  tools: ToolInfo[] | undefined,
@@ -119,7 +120,7 @@ export function buildOrientation(
119
120
  // Lead with the gate — tools not described in the prompt are still callable
120
121
  // via function definitions, but the model doesn't know about them.
121
122
  const gateLine = gated
122
- ? `${gated} ${gated === 1 ? "is" : "are"} gated (kept out of the prompt to save context) — enable via toolbox to discover. All tools are always callable via function definitions.`
123
+ ? `${gated} ${gated === 1 ? "is" : "are"} gated (kept out of the prompt to save context). All tools are always callable via function definitions.`
123
124
  : undefined;
124
125
 
125
126
  const lines = [
@@ -128,8 +129,7 @@ export function buildOrientation(
128
129
  if (gateLine) lines.push(gateLine);
129
130
  lines.push(
130
131
  "Don't improvise what a capability covers — ask the user, search the web, or pull docs first.",
131
- "`read_skills()` lists/loads bundled skills — call it when a skill matches your task. " +
132
- "/toolbox (slash command) discovers and enables gated tools.",
132
+ "`read_skills()` lists/loads bundled skills — call it when a skill matches your task.",
133
133
  );
134
134
  if (skillNames.length) {
135
135
  lines.push(`Skills: ${skillNames.join(", ")}.`);
@@ -147,14 +147,14 @@ export function buildOrientation(
147
147
  }
148
148
 
149
149
  export default function registerCapabilityNudge(pi: ExtensionAPI): void {
150
- let orientationSent = false;
150
+ let turnCount = 0;
151
151
 
152
152
  pi.on("before_agent_start", async (event) => {
153
153
  const skills = event.systemPromptOptions?.skills;
154
+ turnCount++;
154
155
 
155
156
  let content: string;
156
- if (!orientationSent) {
157
- orientationSent = true;
157
+ if (turnCount === 1) {
158
158
  let tools: ToolInfo[] | undefined;
159
159
  try {
160
160
  tools = pi.getAllTools();
@@ -170,21 +170,20 @@ export default function registerCapabilityNudge(pi: ExtensionAPI): void {
170
170
  activeToolNames = undefined;
171
171
  }
172
172
  content = buildOrientation(tools, skills, activeToolNames);
173
- } else {
174
- // Per-turn reminder append graphify hint when graph exists
173
+ } else if (turnCount % REMINDER_INTERVAL === 1) {
174
+ // Fire reminder every REMINDER_INTERVAL turns (turn 11, 21, ...)
175
175
  const cwd = process.cwd();
176
176
  const gHint = graphifyHint(cwd);
177
177
  content = gHint
178
178
  ? `${CAPABILITY_REMINDER}\n${gHint}`
179
179
  : CAPABILITY_REMINDER;
180
+ } else {
181
+ return; // no nudge this turn
180
182
  }
181
183
 
182
- return {
183
- message: {
184
- customType: "pix-capability-nudge",
185
- content,
186
- display: false,
187
- },
188
- };
184
+ const existing = event.systemPrompt ?? "";
185
+ const systemPrompt = existing ? `${existing}\n\n${content}` : content;
186
+
187
+ return { systemPrompt };
189
188
  });
190
189
  }
package/src/ui/welcome.ts CHANGED
@@ -296,7 +296,7 @@ export default function (pi: ExtensionAPI) {
296
296
  pi.on("session_start", (_event, ctx) => {
297
297
  dismissed = false;
298
298
 
299
- // Snapshot static values now (model may change mid-session)
299
+ // cwd is static; model can change via /model so keep it mutable
300
300
  const modelId = ctx.model?.id ?? "—";
301
301
  const cwd = shortCwd(ctx.cwd);
302
302
 
@@ -320,15 +320,12 @@ export default function (pi: ExtensionAPI) {
320
320
  "welcome",
321
321
  (tui: { requestRender(): void }, theme: Theme) => {
322
322
  requestRender = () => tui.requestRender();
323
- const logoLines = buildLogoLines(
324
- theme as unknown as Theme,
325
- modelId,
326
- cwd,
327
- );
328
323
 
329
324
  return {
330
325
  render: () => {
331
326
  const t = theme as unknown as Theme;
327
+ // Re-read modelId each render so /model changes show live
328
+ const logoLines = buildLogoLines(t, modelId, cwd);
332
329
  return [...logoLines, ...buildCheckLines(t, CHECKS), ""];
333
330
  },
334
331
  dispose() {