pi-umbra 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/README.md CHANGED
@@ -10,22 +10,25 @@ tool, parallel pi branches (read-only, or editing in their own git worktree) wit
10
10
  pi install npm:pi-umbra
11
11
  ```
12
12
 
13
- The nine packages ship bundled inside this one tarball. Three of them patch pi's bundle, so
14
- run the three scripts once now and again after every pi update, then restart pi:
13
+ The eight packages ship bundled inside this one tarball. Two of them patch pi's bundle, so
14
+ run the two scripts once now and again after every pi update, then restart pi:
15
15
 
16
16
  ```sh
17
17
  node ~/.pi/agent/npm/node_modules/pi-umbra/node_modules/pi-umbra-shimmer/patch.mjs
18
- node ~/.pi/agent/npm/node_modules/pi-umbra/node_modules/pi-umbra-skill-matcher/patch.mjs
19
18
  node ~/.pi/agent/npm/node_modules/pi-umbra/node_modules/pi-umbra-subagents/patch.mjs
20
19
  ```
21
20
 
21
+ Coming from 0.4 or older: pi-umbra-skill-matcher is gone, because pi completes and runs
22
+ `/skill:name` itself since 1.0. Its bundle patch stays in pi until pi is reinstalled, and until
23
+ then a `/` typed mid-sentence opens file completion. Reinstall pi to drop it:
24
+ `npm install -g @earendil-works/pi-coding-agent`.
25
+
22
26
  | Package | What it adds |
23
27
  |---|---|
24
28
  | [pi-umbra-theme](https://www.npmjs.com/package/pi-umbra-theme) | seven themes, terminal background, footer, gutter, tool cards, image viewer, working line |
25
29
  | [pi-umbra-inputbar](https://www.npmjs.com/package/pi-umbra-inputbar) | `❯` prompt, session name on the input border, `/umb-color` |
26
30
  | [pi-umbra-ask](https://www.npmjs.com/package/pi-umbra-ask) | a tool the model calls to ask you a question and wait |
27
31
  | [pi-umbra-shimmer](https://www.npmjs.com/package/pi-umbra-shimmer) | a wave of colour through the working line, `/umb-shimmer` |
28
- | [pi-umbra-skill-matcher](https://www.npmjs.com/package/pi-umbra-skill-matcher) | `/name` runs `skill:name`, completion mid-sentence |
29
32
  | [pi-umbra-copy-chat](https://www.npmjs.com/package/pi-umbra-copy-chat) | `/umb-copy-chat`, the session on the clipboard |
30
33
  | [pi-umbra-preview](https://www.npmjs.com/package/pi-umbra-preview) | `/umb-preview`, render a document in the browser |
31
34
  | [pi-umbra-help](https://www.npmjs.com/package/pi-umbra-help) | `/umb-help` and `/umb-doctor` |
@@ -20,7 +20,7 @@ Reports the faults that give no error of their own:
20
20
 
21
21
  | Check | Why |
22
22
  |---|---|
23
- | a bundle patch is missing | after a pi update, the shimmer and the mid-sentence skill menu stop without a word |
23
+ | a bundle patch is missing | after a pi update, the shimmer and /umb-loop stop without a word |
24
24
  | two skills share a name | `~/.pi/agent/skills`, `<project>/.pi/skills` and `~/.agents/skills` are read in that order and the first copy wins, even when a later one is newer |
25
25
  | the terminal kept its own background | the theme is not shown in full; it says where to set the colour in your terminal |
26
26
  | no truecolor announced | `COLORTERM` is not set and the terminal is not a known truecolor one, so colours may band |
@@ -29,12 +29,6 @@ assert.equal(
29
29
  "no shimmer command, so its patch is not a fault",
30
30
  );
31
31
 
32
- // mid-slash guards a feature with no command of its own, so it always counts.
33
- for (const id of ["mid-slash"]) {
34
- const findings = diagnose(at({ patches: { ...clean.patches, [id]: false }, commands: [] }));
35
- assert.equal(findings.length, 1, `${id} must report with no command installed`);
36
- assert.match(findings[0]!.detail, /patch\.mjs/, "and must say how to fix it");
37
- }
38
32
 
39
33
  // --- collisions --------------------------------------------------------------------------------
40
34
  // The first root to claim a name wins, which is pi's own rule. What matters is naming the loser,
@@ -120,5 +114,6 @@ assert.match(stale[0]!.title, /pi-umbra-rename is no longer part of umbra/);
120
114
  assert.match(stale[0]!.detail, /pi remove npm:pi-umbra-rename/, "with the command that removes it");
121
115
  const loose = diagnose(at({ commands: [...clean.commands, "umb-rename"] }));
122
116
  assert.match(loose[0]!.detail, /still registered by something/, "a leftover file is found by its command");
117
+ assert.equal(diagnose(at({ packages: ["pi-umbra-skill-matcher"] })).length, 1, "a retired package with no command is found in settings");
123
118
 
124
119
  console.log("umbra-help.check.ts ok - diagnose 14 cases, truecolor 7, shadowed 4, help 6");
@@ -41,18 +41,13 @@ export const PROBES: Probe[] = [
41
41
  without: "/umb-loop refuses to start",
42
42
  command: "umb-loop",
43
43
  },
44
- {
45
- id: "mid-slash",
46
- marker: "umbraMidSlash",
47
- feature: "mid-sentence skill completion",
48
- without: "a /name inside a sentence opens no menu",
49
- },
50
44
  ];
51
45
 
52
46
  // Packages that were umbra once and are not any more, with what replaced them. An install that
53
47
  // still carries one runs code nobody maintains, and pi never says so: the package loads fine.
54
- export const RETIRED: { name: string; command: string; why: string }[] = [
48
+ export const RETIRED: { name: string; command?: string; why: string }[] = [
55
49
  { name: "pi-umbra-rename", command: "umb-rename", why: "pi's own /name sets the session name and the terminal title since pi 1.0" },
50
+ { name: "pi-umbra-skill-matcher", why: "pi completes /skill:name at the start of a line and runs it itself since pi 1.0; reinstall pi too, to drop its bundle patch" },
56
51
  ];
57
52
 
58
53
  export type Facts = {
@@ -96,7 +91,8 @@ export const diagnose = (facts: Facts): Finding[] => {
96
91
 
97
92
  for (const retired of RETIRED) {
98
93
  const listed = facts.packages.includes(retired.name);
99
- if (!listed && !facts.commands.includes(retired.command)) continue;
94
+ // A package with no command of its own is found only through settings.
95
+ if (!listed && !(retired.command && facts.commands.includes(retired.command))) continue;
100
96
  findings.push({
101
97
  level: "warn",
102
98
  title: `${retired.name} is no longer part of umbra`,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-umbra-help",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Two commands over the pi-umbra family: /umb-help lists what is installed and how to configure it, /umb-doctor reports the faults that would otherwise stay silent.",
5
5
  "keywords": [
6
6
  "pi-package",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-umbra",
3
- "version": "0.4.0",
4
- "description": "Every small thing pi does not build in, in one install: seven themes and the chrome around them, the input bar, a shimmer through the working indicator, mid-prompt skill completion, ask, copy-chat, preview, help, and parallel pi branches (read-only, or editing in their own git worktree) with a live agent tree.",
3
+ "version": "0.5.0",
4
+ "description": "Every small thing pi does not build in, in one install: seven themes and the chrome around them, the input bar, a shimmer through the working indicator, ask, copy-chat, preview, help, and parallel pi branches (read-only, or editing in their own git worktree) with a live agent tree.",
5
5
  "keywords": [
6
6
  "pi-package",
7
7
  "pi-theme",
@@ -32,7 +32,6 @@
32
32
  "./node_modules/pi-umbra-inputbar/extensions",
33
33
  "./node_modules/pi-umbra-preview/extensions",
34
34
  "./node_modules/pi-umbra-shimmer/extensions",
35
- "./node_modules/pi-umbra-skill-matcher/extensions",
36
35
  "./node_modules/pi-umbra-subagents/extensions/umbra-subagents.ts",
37
36
  "./node_modules/pi-umbra-subagents/extensions/umbra-loop.ts"
38
37
  ],
@@ -47,11 +46,10 @@
47
46
  "pi-umbra-theme": "0.3.0",
48
47
  "pi-umbra-ask": "0.1.2",
49
48
  "pi-umbra-copy-chat": "0.1.3",
50
- "pi-umbra-help": "0.1.5",
49
+ "pi-umbra-help": "0.1.6",
51
50
  "pi-umbra-inputbar": "0.1.5",
52
51
  "pi-umbra-preview": "0.1.3",
53
52
  "pi-umbra-shimmer": "0.1.6",
54
- "pi-umbra-skill-matcher": "0.1.5",
55
53
  "pi-umbra-subagents": "0.2.0"
56
54
  },
57
55
  "bundledDependencies": [
@@ -62,7 +60,6 @@
62
60
  "pi-umbra-inputbar",
63
61
  "pi-umbra-preview",
64
62
  "pi-umbra-shimmer",
65
- "pi-umbra-skill-matcher",
66
63
  "pi-umbra-subagents"
67
64
  ],
68
65
  "peerDependencies": {
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 grkn
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
@@ -1,45 +0,0 @@
1
- # pi-umbra-skill-matcher
2
-
3
- Makes `/huh` run the skill `huh`, and opens skill completion in the middle of a sentence.
4
-
5
- ```sh
6
- pi install npm:pi-umbra-skill-matcher
7
- node ~/.pi/agent/npm/node_modules/pi-umbra-skill-matcher/patch.mjs
8
- ```
9
-
10
- Restart pi after the patch.
11
-
12
- ## What changes
13
-
14
- pi's completion menu lists a skill as `/huh`, but only `/skill:huh` runs it, so picking it
15
- from the menu ends in "Unknown command".
16
-
17
- - At the start of the line, skill rows complete to `/skill:name`, and `/skill:hu` still finds
18
- `huh`. Up to 12 skill rows are shown.
19
- - `/huh` typed in full runs the skill. A name that another command already uses is left to
20
- that command.
21
- - In the middle of a sentence, `/` opens the same menu, and the skill name is inserted as
22
- plain `/name` text. The skill does not run; the model reads the name.
23
-
24
- The mid-sentence menu opens only for the last word on the line, when it has no space in it
25
- and follows text and a space. `src/foo` or an indented `/foo` stays a path.
26
-
27
- ## The patch
28
-
29
- The command table and the rule that keeps the slash menu at the start of the line are
30
- inside pi's bundle, out of reach of the extension API. `patch.mjs` makes four small edits
31
- there.
32
-
33
- A pi update replaces the bundle and removes the patch without any error, so run the script
34
- again after every update.
35
-
36
- | Command | Effect |
37
- |---|---|
38
- | `node .../patch.mjs` | apply the patch; a part already applied is skipped |
39
- | `node .../patch.mjs --check` | change nothing, exit 1 if a part is missing |
40
-
41
- Set `PI_UMBRA_PI` to the `pi-coding-agent` directory if pi is not found. If pi's code has
42
- changed and a patch no longer fits, the script says so and writes nothing for it.
43
- Reinstalling pi removes every patch.
44
-
45
- MIT.
@@ -1,94 +0,0 @@
1
- // Runs skill-fuzzy against pi's REAL built-in provider, so a change in pi's prefix/value
2
- // contract fails here instead of silently in the TUI.
3
- // bun run umbra-skill-matcher.check.ts
4
- import assert from "node:assert";
5
- import { CombinedAutocompleteProvider } from "@earendil-works/pi-tui";
6
- import skillFuzzy, { midPromptWord } from "../extensions/umbra-skill-matcher.ts";
7
-
8
- const COMMANDS = [
9
- { name: "huh", source: "skill", description: "Restate the last message plainly." },
10
- { name: "hula-hola", source: "skill", description: "dance" },
11
- { name: "what-the-hex", source: "skill", description: "hex" },
12
- { name: "picasso", source: "skill", description: "frontend" },
13
- { name: "call-council", source: "skill", description: "council skill" },
14
- { name: "call-council", source: "extension", description: "council extension" },
15
- { name: "help", source: "builtin", description: "help" },
16
- ];
17
-
18
- let wrap: (current: unknown) => any;
19
- const pi = {
20
- getCommands: () => COMMANDS,
21
- on: (_event: string, handler: (e: unknown, ctx: unknown) => void) =>
22
- handler({}, { ui: { addAutocompleteProvider: (factory: typeof wrap) => (wrap = factory) } }),
23
- };
24
- skillFuzzy(pi as never);
25
-
26
- const builtin = new CombinedAutocompleteProvider(COMMANDS as never, process.cwd());
27
- const provider = wrap!(builtin);
28
- const labels = async (typed: string) => {
29
- const got = await provider.getSuggestions([typed], 0, typed.length, { signal: AbortSignal.timeout(5000) });
30
- return (got?.items ?? []).map((item: { label: string }) => item.label);
31
- };
32
-
33
- // The user's three examples: a shorter query lists more skills, all in the runnable form.
34
- // pi's matcher is subsequence-based, not contains, so "/huh" also reaches "hula-hola" (h-u-h).
35
- // That is pi's own ranking to own; all this checks is that the exact match leads.
36
- assert.equal((await labels("/huh"))[0], "skill:huh");
37
- assert.ok((await labels("/hu")).includes("skill:huh"), "/hu must offer skill:huh");
38
- assert.ok((await labels("/hu")).includes("skill:hula-hola"), "/hu must offer skill:hula-hola");
39
- assert.ok((await labels("/h")).includes("skill:what-the-hex"), "/h must reach a mid-word match");
40
-
41
- // A skill is never offered under the bare name that does not run.
42
- assert.ok(!(await labels("/pic")).includes("picasso"), "bare picasso must be gone");
43
- assert.deepEqual(await labels("/pic"), ["skill:picasso"]);
44
-
45
- // "skill:" typed by hand still finds it, which plain fuzzy on the bare name cannot.
46
- assert.ok((await labels("/skill:pic")).includes("skill:picasso"), "explicit prefix must still match");
47
-
48
- // call-council belongs to an extension too, so its bare form stays runnable and untouched.
49
- assert.ok((await labels("/call")).includes("call-council"), "extension command must survive");
50
-
51
- // Browsing the whole list with a bare "/" must show the runnable form too, not just a search.
52
- const all = await labels("/");
53
- assert.ok(all.includes("skill:huh"), "bare / must list skills in runnable form");
54
- assert.ok(!all.includes("huh"), "bare / must not leak the name that does not run");
55
- assert.ok(all.includes("help"), "bare / must keep non-skill commands");
56
-
57
- // Selecting the row writes exactly "/skill:huh " — one slash, one trailing space.
58
- const item = { value: "skill:huh", label: "skill:huh" };
59
- const applied = provider.applyCompletion(["/huh"], 0, 4, item, "/huh");
60
- assert.equal(applied.lines[0], "/skill:huh ");
61
- assert.equal(applied.cursorCol, 11);
62
-
63
- console.log("umbra-skill-matcher: all checks passed");
64
-
65
- // --- mid-sentence ---------------------------------------------------------------------------
66
- // The token test, first on its own. These are the shapes the repatch entries also decide with,
67
- // so a change here without a change there means the menu opens on something it cannot fill.
68
- assert.equal(midPromptWord("run this /pic"), "pic", "a token after real text is ours");
69
- assert.equal(midPromptWord("run this /"), "", "an empty token is ours - that is the keystroke");
70
- assert.equal(midPromptWord("/pic"), null, "the line-start form belongs to the built-in provider");
71
- assert.equal(midPromptWord(" /pic"), null, "an indented line start is still a line start");
72
- assert.equal(midPromptWord("open src/pic"), null, "a path is not a command");
73
- assert.equal(midPromptWord("open ~/pic"), null, "nor is a home path");
74
- assert.equal(midPromptWord("run /pic then"), null, "the token must end at the cursor");
75
-
76
- // Mid-sentence rows carry the bare skill name, because nothing dispatches them: pi runs a command
77
- // only when the message starts with a slash. The name is what pi's own <available_skills> block
78
- // lists, so it is the form the model can act on.
79
- const mid = async (typed: string) => {
80
- const got = await provider.getSuggestions([typed], 0, typed.length, { signal: AbortSignal.timeout(5000) });
81
- return (got?.items ?? []).map((item: { value: string }) => item.value);
82
- };
83
- assert.deepEqual(await mid("use the /pic"), ["/picasso"]);
84
- assert.ok(!(await mid("use the /pic")).includes("/skill:picasso"), "no dispatcher prefix mid-sentence");
85
- assert.ok((await mid("use the /call")).includes("/call-council"), "skills only, once");
86
- assert.equal((await mid("use the /call")).length, 1, "the extension twin must not double the row");
87
- assert.equal((await mid("use the /zzz")).length, 0, "no match means no menu");
88
-
89
- // Selecting a mid-sentence row keeps the sentence and writes one slash.
90
- const midItem = { value: "/picasso", label: "picasso" };
91
- const midApplied = provider.applyCompletion(["use the /pic"], 0, 12, midItem, "/pic");
92
- assert.equal(midApplied.lines[0], "use the /picasso");
93
-
94
- console.log("umbra-skill-matcher: mid-sentence checks passed");
@@ -1,124 +0,0 @@
1
- // pi registers a skill in the completion list under its bare name ("huh"), but only
2
- // "/skill:huh" actually runs one. So the built-in dropdown offers "/huh", the user presses
3
- // enter, and pi answers "Unknown command". The matching itself is already fuzzy; the name it
4
- // matches against is what is wrong.
5
- //
6
- // This wraps the built-in provider and rewrites every skill row to the form that runs. It also
7
- // re-runs the match against the prefixed name, so typing "/skill:hu" still finds "skill:huh"
8
- // after the built-in fuzzy pass has given up on it.
9
- //
10
- // It also fills the mid-sentence slash menu that two repatch entries open. pi's editor gates the
11
- // menu on the line starting with "/", so a "/name" written inside a sentence never reached a
12
- // provider at all; the patches lift that gate and the branch below decides what appears.
13
- //
14
- // Contract taken from pi-tui's CombinedAutocompleteProvider: `prefix` is the text before the
15
- // cursor INCLUDING the leading slash, and `item.value` is the bare command name — applyCompletion
16
- // writes `${before}/${value} ` itself, so the value carries neither slash nor trailing space.
17
- // That holds only while the token starts the line. Mid-sentence the same method falls through to
18
- // `${before}${value}`, which is why those rows carry their own slash.
19
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
20
-
21
- const SKILL_PREFIX = "skill:";
22
- const MAX_ROWS = 12;
23
-
24
- type Item = { value: string; label: string; description?: string };
25
- type Suggestions = { items: Item[]; prefix: string } | null;
26
-
27
- // Only complete while the command word is the whole line: "/pic" completes, "/skill:picasso go"
28
- // does not, because there the user is writing the argument.
29
- const commandWord = (before: string): string | null => {
30
- const match = /^\/(\S*)$/.exec(before);
31
- return match ? match[1] : null;
32
- };
33
-
34
- // The same token test the two repatch entries use to open the slash menu mid-sentence, so the
35
- // menu and its contents agree on what counts: last token on the line, no space in it, and real
36
- // text before it that ends in whitespace. "src/foo" and an indented " /foo" both fail, which
37
- // leaves paths and line-start commands to the built-in provider.
38
- export const midPromptWord = (before: string): string | null => {
39
- const slash = before.lastIndexOf("/");
40
- if (slash < 1 || before.slice(slash + 1).includes(" ")) return null;
41
- const head = before.slice(0, slash);
42
- return head.trim() === "" || head === head.trimEnd() ? null : before.slice(slash + 1);
43
- };
44
-
45
- export default function skillFuzzy(pi: ExtensionAPI) {
46
- pi.on("session_start", (_event, ctx: ExtensionContext) => {
47
- ctx.ui.addAutocompleteProvider((current) => ({
48
- triggerCharacters: ["/"],
49
-
50
- async getSuggestions(lines: string[], line: number, col: number, options: never) {
51
- const before = (lines[line] ?? "").slice(0, col);
52
-
53
- // pi dispatches a command only when the whole message starts with a slash, so a
54
- // "/name" written mid-sentence is never run - it is text the model reads. pi's
55
- // system prompt already lists every skill and tells the model to open the
56
- // matching SKILL.md, so the bare name is what belongs here. The "skill:" form
57
- // below exists for the dispatcher, and the dispatcher never sees this one.
58
- const midWord = midPromptWord(before);
59
- if (midWord !== null) {
60
- const query = midWord.toLowerCase();
61
- const items = pi
62
- .getCommands()
63
- .filter((command) => command.source === "skill" && command.name.toLowerCase().includes(query))
64
- .slice(0, MAX_ROWS)
65
- .map((command) => ({
66
- // applyCompletion writes `${before}${value}` for a token that does not
67
- // start the line, so the value carries its own slash.
68
- value: `/${command.name}`,
69
- label: command.name,
70
- description: command.description ?? "skill",
71
- }));
72
- return items.length === 0 ? null : { items, prefix: `/${midWord}` };
73
- }
74
-
75
- const base = (await current.getSuggestions(lines, line, col, options)) as Suggestions;
76
- const typed = commandWord(before);
77
- if (typed === null) return base;
78
-
79
- const commands = pi.getCommands();
80
- // "call-council" is both a skill and an extension command, and there the bare
81
- // "/call-council" is the real one. Only touch a name nothing else claims.
82
- const taken = new Set(
83
- commands.filter((command) => command.source !== "skill").map((command) => command.name),
84
- );
85
- const skills = commands.filter(
86
- (command) => command.source === "skill" && !taken.has(command.name),
87
- );
88
- const byName = new Map(skills.map((command) => [command.name, command]));
89
-
90
- const asSkill = (name: string, description?: string): Item => ({
91
- value: `${SKILL_PREFIX}${name}`,
92
- label: `${SKILL_PREFIX}${name}`,
93
- description: description ?? byName.get(name)?.description ?? "skill",
94
- });
95
-
96
- const fixed = (base?.items ?? []).map((item) =>
97
- byName.has(item.value) ? asSkill(item.value, item.description) : item,
98
- );
99
-
100
- const query = typed.toLowerCase().replace(/^skill:/, "");
101
- const shown = new Set(fixed.map((item) => item.value));
102
- const extra = query
103
- ? skills
104
- .filter((command) => command.name.toLowerCase().includes(query))
105
- .map((command) => asSkill(command.name))
106
- .filter((item) => !shown.has(item.value))
107
- .slice(0, MAX_ROWS)
108
- : [];
109
-
110
- const items = [...fixed, ...extra];
111
- if (items.length === 0) return null;
112
- return { items, prefix: base?.prefix ?? `/${typed}` };
113
- },
114
-
115
- applyCompletion(lines: string[], line: number, col: number, item: Item, prefix: string) {
116
- return current.applyCompletion(lines, line, col, item, prefix);
117
- },
118
-
119
- shouldTriggerFileCompletion(lines: string[], line: number, col: number) {
120
- return current.shouldTriggerFileCompletion?.(lines, line, col) ?? true;
121
- },
122
- }));
123
- });
124
- }
@@ -1,37 +0,0 @@
1
- {
2
- "name": "pi-umbra-skill-matcher",
3
- "version": "0.1.5",
4
- "description": "Type /huh and reach skill:huh. pi lists every skill under its bare name, which is not the name that dispatches.",
5
- "keywords": [
6
- "pi-package",
7
- "pi-extension",
8
- "autocomplete",
9
- "skills"
10
- ],
11
- "author": "grkn",
12
- "license": "MIT",
13
- "repository": {
14
- "type": "git",
15
- "url": "git+https://github.com/grknbyk/pi-umbra.git",
16
- "directory": "pi-umbra-skill-matcher"
17
- },
18
- "homepage": "https://github.com/grknbyk/pi-umbra/tree/main/pi-umbra-skill-matcher#readme",
19
- "bugs": "https://github.com/grknbyk/pi-umbra/issues",
20
- "type": "module",
21
- "files": [
22
- "extensions",
23
- "checks",
24
- "README.md",
25
- "patch.mjs",
26
- "LICENSE"
27
- ],
28
- "pi": {
29
- "extensions": [
30
- "./extensions"
31
- ]
32
- },
33
- "peerDependencies": {
34
- "@earendil-works/pi-coding-agent": "*",
35
- "@earendil-works/pi-tui": "*"
36
- }
37
- }
@@ -1,136 +0,0 @@
1
- // pi-umbra-skill-matcher needs 4 seams that pi's extension API does not expose, so it edits
2
- // pi's built bundle. Run it after installing, and again after every pi update:
3
- //
4
- // node <this file> apply
5
- // node <this file> --check report only, write nothing, exit 1 if anything is missing
6
- //
7
- // pi runs dist/bundle/cli.js, a self-contained bundle, and the chunk names are content-hashed,
8
- // so every patch finds its own place by matching its own text rather than by line number.
9
- //
10
- // Nothing here is destructive. Each patch is additive, a patch whose text no longer matches is
11
- // reported instead of forced, and reinstalling pi returns the bundle to stock. A pi upgrade
12
- // replaces the bundle and drops every patch silently, which is what --check is for.
13
- import { execSync } from "node:child_process";
14
- import { createRequire } from "node:module";
15
- import { existsSync, realpathSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
16
- import { homedir } from "node:os";
17
- import { dirname, join } from "node:path";
18
-
19
- // PI_UMBRA_PI points this at another pi tree. Otherwise the pi that runs is the one to patch:
20
- // the `pi` on PATH, followed through its symlink. Windows puts a .cmd shim there instead, so
21
- // npm's global root (%APPDATA%\npm\node_modules) comes next, then bun's. The copy npm installs
22
- // beside this package as a peer dependency is last: pi never runs it. The first that has a built
23
- // bundle wins.
24
- const resolvePi = () => {
25
- if (process.env.PI_UMBRA_PI) return process.env.PI_UMBRA_PI;
26
- const quiet = { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 20_000 };
27
- const candidates = [];
28
- try {
29
- const bin = execSync(process.platform === "win32" ? "where pi" : "command -v pi", quiet).split(/\r?\n/)[0].trim();
30
- for (let dir = dirname(realpathSync(bin)); dir !== dirname(dir); dir = dirname(dir)) candidates.push(dir);
31
- } catch {}
32
- try {
33
- const root = execSync("npm root -g", quiet).trim();
34
- if (root) candidates.push(join(root, "@earendil-works", "pi-coding-agent"));
35
- } catch {}
36
- candidates.push(join(homedir(), ".bun", "install", "global", "node_modules", "@earendil-works", "pi-coding-agent"));
37
- try {
38
- candidates.push(dirname(createRequire(import.meta.url).resolve("@earendil-works/pi-coding-agent/package.json")));
39
- } catch {}
40
- return candidates.find((dir) => existsSync(join(dir, "dist", "bundle"))) ?? candidates[0];
41
- };
42
-
43
- const BUNDLE = join(resolvePi(), "dist/bundle");
44
- if (!existsSync(BUNDLE)) {
45
- console.error(`no pi bundle at ${BUNDLE} - set PI_UMBRA_PI to your pi install`);
46
- process.exit(1);
47
- }
48
-
49
- const PATCHES = [
50
- {
51
- // A skill is reachable as /skill:huh only. The prefix is hardcoded at both sites below,
52
- // with no setting for it. The dispatch map now holds each skill under both names, so
53
- // /huh works and any /skill:huh already in muscle memory or in a doc keeps working.
54
- // Only the bare name is offered in autocomplete, to keep the list one line per skill.
55
- reason: "/huh instead of /skill:huh",
56
- from: "let commandName=`skill:${skill.name}`;this.skillCommands.set(commandName,skill.filePath),skillCommandList.push({name:commandName,",
57
- to: "let commandName=skill.name;this.skillCommands.set(`skill:${skill.name}`,skill.filePath),this.skillCommands.set(commandName,skill.filePath),skillCommandList.push({name:commandName,",
58
- },
59
- {
60
- reason: "the command list names skills without the prefix too",
61
- needs: "/huh instead of /skill:huh",
62
- from: "skills=this._resourceLoader.getSkills().skills.map(skill=>({name:`skill:${skill.name}`,",
63
- to: "skills=this._resourceLoader.getSkills().skills.map(skill=>({name:skill.name,",
64
- },
65
- {
66
- // The slash menu only ever opens on a line that starts with "/": `isAtStartOfMessage`
67
- // gates the keystroke and `isInSlashCommandContext` gates every letter after it. A
68
- // "/name" written in the middle of a sentence therefore reaches no provider at all, and
69
- // an extension cannot fix that - it is never asked. These two open the door;
70
- // umbra-skill-matcher decides what stands behind it.
71
- //
72
- // `umbraMidSlash` is deliberately narrow, so a path keeps behaving like a path: the
73
- // token must be the last one on the line, hold no space, and sit after real text that
74
- // ends in whitespace. "src/foo" fails on the last clause, " /foo" on the one before it.
75
- // `head!==head.trimEnd()` is how "ends in a space or a tab" is written without putting an
76
- // escape inside a patch string.
77
- reason: "a mid-sentence /name opens the slash menu",
78
- from: `isInSlashCommandContext(textBeforeCursor){return this.isSlashMenuAllowed()&&textBeforeCursor.trimStart().startsWith("/")}`,
79
- to: `isInSlashCommandContext(textBeforeCursor){return this.isSlashMenuAllowed()&&textBeforeCursor.trimStart().startsWith("/")||this.umbraMidSlash(textBeforeCursor)}umbraMidSlash(t){let i=t.lastIndexOf("/");if(i<1)return!1;if(t.slice(i+1).indexOf(" ")>=0)return!1;let head=t.slice(0,i);return head.trim()!==""&&head!==head.trimEnd()}`,
80
- },
81
- {
82
- // The companion to the patch above: without it the menu opens on the second character
83
- // rather than on the slash, because typing "/" is handled by its own branch that ends at
84
- // `isAtStartOfMessage`. At that moment the token after the slash is still empty, which is
85
- // exactly the shape `umbraMidSlash` accepts.
86
- reason: "typing a mid-sentence slash opens the menu on the slash",
87
- needs: "a mid-sentence /name opens the slash menu",
88
- from: `char==="/"&&this.isAtStartOfMessage()`,
89
- to: `char==="/"&&(this.isAtStartOfMessage()||this.umbraMidSlash((this.state.lines[this.state.cursorLine]||"").slice(0,this.state.cursorCol)))`,
90
- },
91
- ];
92
-
93
- const files = [join(BUNDLE, "cli.js"), ...readdirSync(join(BUNDLE, "chunks")).map((name) => join(BUNDLE, "chunks", name))];
94
- const CHECK = process.argv.includes("--check");
95
- const unapplied = [];
96
- const gone = [];
97
-
98
- for (const patch of PATCHES) {
99
- // It would call a method, or offer a name, that only the patch it needs provides.
100
- if (gone.includes(patch.needs)) {
101
- gone.push(`${patch.reason} (held back: needs "${patch.needs}")`);
102
- continue;
103
- }
104
- let done = false;
105
- for (const path of files) {
106
- const source = readFileSync(path, "utf8");
107
- // A multi-line patch is written with newline escapes; match whatever line ending the file uses.
108
- const eol = source.includes("\r\n") ? "\r\n" : "\n";
109
- const from = patch.from.split("\n").join(eol);
110
- const to = patch.to.split("\n").join(eol);
111
- if (source.includes(to)) {
112
- if (!CHECK) console.log(`already patched: ${patch.reason}`);
113
- done = true;
114
- break;
115
- }
116
- if (!source.includes(from)) continue;
117
- if (CHECK) {
118
- unapplied.push(patch.reason);
119
- done = true;
120
- break;
121
- }
122
- writeFileSync(path, source.replace(from, to));
123
- console.log(`patched: ${patch.reason}`);
124
- done = true;
125
- break;
126
- }
127
- // Neither the original text nor the patched text is there, so pi changed the code this
128
- // patch names. Report it rather than force anything.
129
- if (!done) gone.push(patch.reason);
130
- }
131
-
132
- for (const reason of unapplied) console.log(`NOT APPLIED: ${reason}`);
133
- for (const reason of gone) console.log(`SEAM GONE: ${reason}`);
134
- const bad = unapplied.length + gone.length;
135
- console.log(bad ? `${bad} of ${PATCHES.length} not in place` : `all ${PATCHES.length} patches applied`);
136
- process.exit(bad ? 1 : 0);