pi-skill-fit 0.0.0-stage → 1.1.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 +144 -2
- package/SKILL.md +63 -0
- package/bin/pi-skill-fit.js +4 -0
- package/docs/design.md +121 -0
- package/package.json +26 -4
- package/src/cli.js +100 -0
- package/src/extension.js +101 -0
- package/src/overrides.js +58 -0
- package/src/role.js +47 -0
- package/src/score.js +145 -0
- package/src/skills.js +90 -0
package/README.md
CHANGED
|
@@ -1,3 +1,145 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-skill-fit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Given a task, print the installed agent skills that fit it, most relevant first, with the absolute path of each `SKILL.md`.
|
|
4
|
+
|
|
5
|
+
**The problem:** an agent receives a ~130-skill index in its prompt and has to guess which files to load; nothing maps a concrete task to the small set of installed skills worth reading.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
node bin/pi-skill-fit.js "review the pull request for security issues" --limit 5
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
RANK NAME SCORE PATH
|
|
13
|
+
1 security-audit 5 /root/.agents/skills/security-audit/SKILL.md
|
|
14
|
+
2 code-review 4 /root/.agents/skills/code-review/SKILL.md
|
|
15
|
+
3 contract-review 3 /root/.agents/skills/contract-review/SKILL.md
|
|
16
|
+
4 review-reputation 3 /root/.agents/skills/review-reputation/SKILL.md
|
|
17
|
+
5 gentle-ai-branch-pr 2 /root/.pi/agent/npm/node_modules/gentle-pi/skills/branch-pr/SKILL.md
|
|
18
|
+
5 of 24 candidates, 19 dropped
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Pi extension
|
|
22
|
+
|
|
23
|
+
The package also ships a Pi extension (`src/extension.js`). At `before_agent_start` it
|
|
24
|
+
ranks the installed skills for the user's prompt in process and appends the selection
|
|
25
|
+
to the system prompt:
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
## Selected skills (pi-skill-fit)
|
|
29
|
+
- /root/.agents/skills/security-audit/SKILL.md — security-audit: security, review
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Rules: at most 5 entries, the matched tokens (max 4) are shown, the whole block is
|
|
33
|
+
capped at 700 characters, and token lists shrink before entries are dropped. The same
|
|
34
|
+
selection is injected once per session; a later prompt that selects a different set
|
|
35
|
+
injects the new set. Any failure is swallowed and reported once on stderr, so the
|
|
36
|
+
extension can never block the agent start.
|
|
37
|
+
|
|
38
|
+
Enable it with `pi -e ./src/extension.js`, or install the package and let the `pi`
|
|
39
|
+
manifest in `package.json` load it. Disable it either way:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
PI_SKILL_FIT=off pi ...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
or, in `settings.json`:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{ "extensionSettings": { "pi-skill-fit": false } }
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`false`, `"off"` and `{ "enabled": false }` all disable it. A value of `true` or a
|
|
52
|
+
missing entry leaves it enabled.
|
|
53
|
+
|
|
54
|
+
Known limitation: the `before_agent_start` context does not expose the active role, so
|
|
55
|
+
the extension cannot apply a role's `skills:` allowlist and uses the global selector.
|
|
56
|
+
The CLI keeps `--role` support. Role-level `extensionSettings` are resolved by
|
|
57
|
+
pi-ext-roles for programmatic consumers but are not delivered to the Pi CLI, so only
|
|
58
|
+
the settings file and the environment variable act as switches today.
|
|
59
|
+
|
|
60
|
+
## Requirements
|
|
61
|
+
|
|
62
|
+
Node.js >= 22. No runtime dependencies, no build step.
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm test
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Optionally expose the `pi-skill-fit` binary: `npm link`.
|
|
69
|
+
|
|
70
|
+
## Usage
|
|
71
|
+
|
|
72
|
+
```text
|
|
73
|
+
pi-skill-fit "<task text>" [--role <name>] [--limit <n>] [--json] [--explain] [--roots <dir>] [--overrides <path>]
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
| Flag | Meaning |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| `--role <name>` | Restrict candidates to the role's `skills:` allowlist from `/root/.pi/agent/pi-ext-roles/roles/<name>.md`. Only an explicit `!*` entry restricts; a role without `skills:` does not filter. |
|
|
79
|
+
| `--limit <n>` | Maximum selected skills, default 5, must be >= 1. Remaining matches are reported as dropped. |
|
|
80
|
+
| `--json` | Emit `{task, role, limit, selected, dropped, candidates}` instead of the table. Each selected skill includes a `matches` array with `{token, source, weight}`. |
|
|
81
|
+
| `--explain` | In table mode, append per-skill match details showing which task tokens matched and at what weight. |
|
|
82
|
+
| `--roots <dir>` | Skill root to scan; repeatable, replaces the default roots. |
|
|
83
|
+
| `--overrides <path>` | Read trigger overrides from this file instead of the default one. The file must exist and be valid. |
|
|
84
|
+
|
|
85
|
+
Default roots, in order: `/root/.agents/skills`, `/root/.pi/agent/skills`, then every `*/skills` directory under `/root/.pi/agent/npm/node_modules`. A skill is a directory containing `SKILL.md`.
|
|
86
|
+
|
|
87
|
+
Exit codes: `0` success, `2` invalid input (empty task, bad limit, missing role, malformed overrides), `1` internal error.
|
|
88
|
+
|
|
89
|
+
## Scoring model
|
|
90
|
+
|
|
91
|
+
1. Tokenize the task: lowercase, split on non-alphanumeric, drop tokens shorter than 3 characters, English and Italian stopwords, then apply light suffix normalisation.
|
|
92
|
+
2. A skill's name terms are its `name` split on non-alphanumeric separators; its description terms use the same tokenizer as the task.
|
|
93
|
+
3. For each distinct task token:
|
|
94
|
+
- `+3` if it matches a name term
|
|
95
|
+
- `+2` if it matches a `triggers:` alias or an override token (see below)
|
|
96
|
+
- `+1` if it matches a description term
|
|
97
|
+
- All three can apply.
|
|
98
|
+
4. Drop skills with score 0.
|
|
99
|
+
5. Sort by score descending, then name ascending, then path ascending; take `--limit`.
|
|
100
|
+
|
|
101
|
+
### Suffix normalisation
|
|
102
|
+
|
|
103
|
+
A light, dependency-free suffix stripper collapses common inflections:
|
|
104
|
+
- English: -ing, -tion, -sion, -ment, -ness, -ies, -ed, -er, -ly, -s
|
|
105
|
+
- Italian: -zione, -zioni, -mente, -ione, -ioni, -ando, -endo, -are, -ire, -ere, -ato, -ata, -ati, -ate, -ari
|
|
106
|
+
|
|
107
|
+
Each suffix has a minimum word length to avoid over-stripping (never below 3 characters).
|
|
108
|
+
|
|
109
|
+
### Trigger aliases
|
|
110
|
+
|
|
111
|
+
A skill's SKILL.md frontmatter may include a `triggers:` key (YAML list or comma-separated). These tokens are scored at weight 2 (between name=3 and description=1). Skills without this key behave exactly as before.
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
---
|
|
115
|
+
name: my-skill
|
|
116
|
+
description: Does something useful.
|
|
117
|
+
triggers: [audit, inspect, check]
|
|
118
|
+
---
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Trigger overrides
|
|
122
|
+
|
|
123
|
+
A skill's frontmatter belongs to its upstream author, and `skills update` overwrites it. To fix or extend the trigger vocabulary of an installed skill without editing it, keep a local override file instead:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"writing-for-agents": ["write", "writing", "documentation", "docs", "README", "guide", "manual"],
|
|
128
|
+
"research": ["investigate", "sources", "evidence", "paper"]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- Location: `--overrides <path>` if given, else `$XDG_CONFIG_HOME/pi-skill-fit/triggers.json`, else `~/.config/pi-skill-fit/triggers.json`. A missing default file means no overrides; it is not an error.
|
|
133
|
+
- Semantics: override tokens are normalised with the same tokenizer and suffix table as everything else, score at weight 2 (same as `triggers:`), and are added to the skill's own triggers, never replacing them.
|
|
134
|
+
- A name that is not installed is ignored silently.
|
|
135
|
+
- A malformed file (not JSON, not an object of string arrays, missing when named with `--overrides`) exits `2` with the path and the reason. It is never silently ignored.
|
|
136
|
+
- `--json` and `--explain` report these matches with `source: "override"`, distinct from `name`, `trigger` and `description`, so a ranking decision stays auditable.
|
|
137
|
+
|
|
138
|
+
## What it does NOT do
|
|
139
|
+
|
|
140
|
+
- It does not discover, install, update, or remove skills upstream. It only reads skill directories that already exist on this machine.
|
|
141
|
+
- It does not call a model, embed anything, or use the network. Scoring is token overlap only.
|
|
142
|
+
- It does not modify any skill, role, or Pi configuration file. Overrides live in its own config file and are read-only input to scoring.
|
|
143
|
+
- It does not judge skill quality and it does not read `SKILL.md` bodies: only `name`, `description` and `triggers` from the frontmatter.
|
|
144
|
+
|
|
145
|
+
See [docs/design.md](docs/design.md) for the design sources and scoring model.
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-skill-fit
|
|
3
|
+
description: "Deterministic selector that ranks installed agent capabilities by relevance to a query. Answers meta-questions about which capability to load or how to route a query to the right one."
|
|
4
|
+
triggers: [routing, selector, selezione, scegli, quale, load]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# pi-skill-fit
|
|
8
|
+
|
|
9
|
+
Given a task description, select at most 5 of the ~121 installed agent skills
|
|
10
|
+
that are relevant, and print the absolute path of each `SKILL.md`.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- An orchestrator needs to decide which skills to inject into a sub-agent.
|
|
15
|
+
- A user asks "which skill should I load for this task" or "pick skills for X".
|
|
16
|
+
- A delegator implements the skill-resolver protocol and needs a deterministic,
|
|
17
|
+
auditable selection step.
|
|
18
|
+
|
|
19
|
+
## Commands
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Basic usage
|
|
23
|
+
pi-skill-fit "<task text>"
|
|
24
|
+
|
|
25
|
+
# With options
|
|
26
|
+
pi-skill-fit "<task text>" --limit 5 --json --explain --role <name> --roots <dir>
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| Flag | Meaning |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `--role <name>` | Restrict candidates to the role's `skills:` allowlist. |
|
|
32
|
+
| `--limit <n>` | Maximum selected skills (default 5, >= 1). |
|
|
33
|
+
| `--json` | JSON output: `{task, role, limit, selected, dropped, candidates}`. |
|
|
34
|
+
| `--explain` | For each selected skill, print the task tokens that matched and their weights. |
|
|
35
|
+
| `--roots <dir>` | Skill root to scan; repeatable, replaces defaults. |
|
|
36
|
+
| `--overrides <path>` | Trigger overrides file; defaults to `$XDG_CONFIG_HOME/pi-skill-fit/triggers.json` or `~/.config/pi-skill-fit/triggers.json`. |
|
|
37
|
+
|
|
38
|
+
## Pi extension
|
|
39
|
+
|
|
40
|
+
The same selector ships as a Pi extension. It runs at `before_agent_start` and appends
|
|
41
|
+
the selected `SKILL.md` paths to the system prompt (max 5 entries, 700 characters).
|
|
42
|
+
Disable it with `PI_SKILL_FIT=off` or `extensionSettings: { "pi-skill-fit": false }`
|
|
43
|
+
in `settings.json`.
|
|
44
|
+
|
|
45
|
+
## Output contract
|
|
46
|
+
|
|
47
|
+
- **Table mode** (default): `RANK NAME SCORE PATH` rows, then a summary line.
|
|
48
|
+
With `--explain`: after the table, one line per skill showing `token(source:weight)` pairs.
|
|
49
|
+
- **JSON mode**: `selected[].matches[]` contains `{token, source, weight}` for auditability.
|
|
50
|
+
- Exit codes: `0` success, `2` invalid input, `1` internal error.
|
|
51
|
+
- Two runs with the same input produce byte-identical output (deterministic).
|
|
52
|
+
|
|
53
|
+
## What this does NOT do
|
|
54
|
+
|
|
55
|
+
This tool does **not** discover, install, update, or remove skills. It only reads
|
|
56
|
+
skill directories that already exist on this machine. For skill discovery and
|
|
57
|
+
installation, use:
|
|
58
|
+
|
|
59
|
+
- `skills find <query>` — search for skills upstream.
|
|
60
|
+
- `skills add <source>` — install a skill from a path or repository.
|
|
61
|
+
|
|
62
|
+
The `skills` CLI (`command -v skills`) owns the supply chain; `pi-skill-fit` owns
|
|
63
|
+
the task-to-skill routing once skills are installed.
|
package/docs/design.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Design
|
|
2
|
+
|
|
3
|
+
## Sources
|
|
4
|
+
|
|
5
|
+
This tool exists to implement, locally and deterministically, a selection step that three documents already describe by contract.
|
|
6
|
+
|
|
7
|
+
### 1. `~/.agents/skills/_shared/skill-resolver.md` (Gentle selection contract)
|
|
8
|
+
|
|
9
|
+
- Match on two dimensions: code/file context and task/action keywords.
|
|
10
|
+
- Prefer the smallest useful set; hard cap of five skills.
|
|
11
|
+
- Inject absolute `SKILL.md` paths, never summaries: the skill file stays the runtime contract.
|
|
12
|
+
- The delegator reports what it injected, so selection must name exact files.
|
|
13
|
+
|
|
14
|
+
Dictates: five-skill default cap, path-only output, trigger/description matching, no rewriting of skill content.
|
|
15
|
+
|
|
16
|
+
### 2. `~/git/personale/pi.dev/dotenv/docs/agent-skill-priority.md` (priority tiers)
|
|
17
|
+
|
|
18
|
+
- P0-P3 tiers bias which skills should load for a kind of work.
|
|
19
|
+
- Per-role required skills are declared, not inferred.
|
|
20
|
+
- Ownership rows say a selection aid must not duplicate workflow execution or model routing.
|
|
21
|
+
|
|
22
|
+
Diktat for v1: role assignment is read from the role files, and pi-skill-fit owns none of the work it recommends. Priority tiers are not scored in v1 because the tiers are prose, not machine-readable metadata.
|
|
23
|
+
|
|
24
|
+
### 3. `~/.pi/agent/pi-ext-roles/roles/*.md` (role allowlists)
|
|
25
|
+
|
|
26
|
+
- Each role frontmatter may declare `skills: [ ... ]`.
|
|
27
|
+
- `!*` is a selector meaning "start from nothing"; the remaining bare names are the allowlist.
|
|
28
|
+
- Roles without the field inherit everything.
|
|
29
|
+
|
|
30
|
+
Dictates: `--role` reads that file, applies only when `!*` is present, and errors (exit 2) naming the path when the role does not exist.
|
|
31
|
+
|
|
32
|
+
## Pi extension
|
|
33
|
+
|
|
34
|
+
The CLI selection is exposed to Pi through `src/extension.js`, a `before_agent_start`
|
|
35
|
+
handler that imports the same modules in process. It appends at most five entries and
|
|
36
|
+
four matched tokens per entry to `event.systemPromptOptions.appendSystemPrompt`, which
|
|
37
|
+
is the documented prompt-append surface; it does not force or replace the prompt, so the
|
|
38
|
+
structured sections and the prompt-cache prefix stay intact. The block is capped at 700
|
|
39
|
+
characters including its heading: token lists are shortened first, entries are dropped
|
|
40
|
+
after that.
|
|
41
|
+
|
|
42
|
+
State is per extension instance and keyed by session id. A selection signature is stored
|
|
43
|
+
when the block is injected, so an unchanged selection is not injected again. Selection
|
|
44
|
+
errors, a missing skill root, malformed overrides and unexpected prompt shapes are all
|
|
45
|
+
caught; the first one is logged on stderr and nothing reaches the agent start.
|
|
46
|
+
|
|
47
|
+
Switches: `PI_SKILL_FIT=off`, or `extensionSettings["pi-skill-fit"]` set to `false`,
|
|
48
|
+
`"off"` or `{ "enabled": false }` in `settings.json`.
|
|
49
|
+
|
|
50
|
+
Known limitation: the hook context does not carry the active role, so a role's `skills:`
|
|
51
|
+
allowlist cannot be applied here. The extension deliberately uses the global selector
|
|
52
|
+
rather than guessing a role; the CLI keeps `--role`. Role `extensionSettings` are a
|
|
53
|
+
pi-ext-roles contract for programmatic consumers and are not delivered to the Pi CLI, so
|
|
54
|
+
the settings file and the environment variable are the effective switches.
|
|
55
|
+
|
|
56
|
+
## Scoring model
|
|
57
|
+
|
|
58
|
+
1. Tokenize the task: lowercase, split on non-alphanumeric, drop tokens shorter than 3 characters, English and Italian stopwords, then normalise suffixes.
|
|
59
|
+
2. A skill's name terms are its `name` split on non-alphanumeric separators; its description terms use the same tokenizer as the task.
|
|
60
|
+
3. For each distinct task token: `+3` if it matches a name term, `+2` if it matches a `triggers:` alias, `+1` if it matches a description term. All three can apply.
|
|
61
|
+
4. Drop skills with score 0.
|
|
62
|
+
5. Sort by score descending, then name ascending, then path ascending; take `--limit`.
|
|
63
|
+
|
|
64
|
+
### Suffix normalisation
|
|
65
|
+
|
|
66
|
+
A light, dependency-free suffix stripper collapses common inflections so that e.g. "reviewing/reviews" match "review" and "documentazione/documenti" collapse. Each rule has a minimum word length threshold to avoid over-stripping (never below 3 characters).
|
|
67
|
+
|
|
68
|
+
| Suffix | Min length | Language |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| -zione, -zioni | 8 | IT |
|
|
71
|
+
| -mente | 8 | IT |
|
|
72
|
+
| -ione, -ioni | 7 | IT |
|
|
73
|
+
| -ando, -endo | 7 | IT |
|
|
74
|
+
| -are, -ire, -ere | 6 | IT |
|
|
75
|
+
| -ato, -ata, -ati, -ate, -ari | 6 | IT |
|
|
76
|
+
| -tion, -sion, -ment, -ness | 7 | EN |
|
|
77
|
+
| -ying | 7 | EN |
|
|
78
|
+
| -ing | 6 | EN |
|
|
79
|
+
| -ies, -ily | 6 | EN |
|
|
80
|
+
| -ed, -er, -ly | 5 | EN |
|
|
81
|
+
| -s | 4 | EN |
|
|
82
|
+
|
|
83
|
+
Rules are tried in order (longest suffix first). Only one suffix is stripped per token.
|
|
84
|
+
|
|
85
|
+
### Trigger aliases
|
|
86
|
+
|
|
87
|
+
A skill's SKILL.md frontmatter may include a `triggers:` key — a YAML inline list or comma-separated values. These tokens are scored at weight 2 (between name=3 and description=1). Skills without the key behave exactly as before.
|
|
88
|
+
|
|
89
|
+
### Trigger overrides
|
|
90
|
+
|
|
91
|
+
The same weight-2 tier is fed by a local override file: `{ "skill-name": ["term", ...] }` at `--overrides <path>`, else `$XDG_CONFIG_HOME/pi-skill-fit/triggers.json`, else `~/.config/pi-skill-fit/triggers.json`. Tokens are added to the skill's own triggers, never replacing them, and `matches[].source` is `override` so the decision is auditable.
|
|
92
|
+
|
|
93
|
+
Why a separate file and not more frontmatter: the frontmatter of an installed skill is third-party content. A selector that needs a producer's skill to carry a `write` trigger cannot wait for that producer, cannot edit `/root/.agents/skills` (a `skills update` overwrites it), and must not fork the skill. The overlay is local, outside every skill root, survives updates, and is the user's own statement of how they look for work. Weight 2 keeps it in the existing tier: an override is as strong as an author-declared trigger, weaker than a name, and cannot invent relevance for a skill that is not installed.
|
|
94
|
+
|
|
95
|
+
### Bilingual stopwords
|
|
96
|
+
|
|
97
|
+
Both English and Italian stopword lists are applied during tokenisation. This allows tasks written in Italian to match Italian-described skills without function words inflating scores.
|
|
98
|
+
|
|
99
|
+
Why deterministic: the input is the task string plus file contents on disk. There is no model, no sampling, no network, no randomness, no time or locale input beyond `localeCompare` on ASCII skill names. Two runs over the same tree produce byte-identical output, which the test suite asserts. The score is a coarse keyword-overlap heuristic; it is meant to narrow a large index to a handful of files a human or agent then reads, not to rank skills by quality.
|
|
100
|
+
|
|
101
|
+
## Open decisions
|
|
102
|
+
|
|
103
|
+
| Decision | Chosen default | Reasoning |
|
|
104
|
+
| --- | --- | --- |
|
|
105
|
+
| Skill identity | Frontmatter `name` wins, directory name fallback | Roles reference skills by frontmatter name, so selection must use the same key. |
|
|
106
|
+
| Missing `SKILL.md` | Not a skill | The contract is the file; a bare directory is not loadable. |
|
|
107
|
+
| Missing root | Skipped silently | Installations differ per machine; a missing optional root is not invalid input. |
|
|
108
|
+
| Symlinked duplicates | Deduplicated by real path, first root wins | Prevents the same skill appearing twice with two root-relative paths. |
|
|
109
|
+
| Frontmatter parsing | Single-line `name`/`description` only, no YAML dependency | Both keys are single-line in every installed skill; a parser dependency is not justified. |
|
|
110
|
+
| Name token filter | Name parts are not stopword/min-length filtered | A short or common name part (e.g. `ai`) is still a deliberate identity signal. |
|
|
111
|
+
| Weights | 3 for name, 1 for description | A skill named after the task is a stronger trigger than prose that mentions it. |
|
|
112
|
+
| Tie-break | Score, then name, then path | Name alone can collide across roots; path keeps the order total. |
|
|
113
|
+
| Role filter stage | Applied before scoring | Cheaper and equivalent, since the allowlist does not depend on the task. |
|
|
114
|
+
| `candidates` count | Skills with score > 0 after the role filter | Makes `selected + dropped == candidates` hold and keeps the report honest. |
|
|
115
|
+
| Priority tiers (P0-P3) | Not scored in v1 | The tiers are prose; encoding them would invent metadata the skills do not carry. |
|
|
116
|
+
| Description stemming | Light suffix normalisation, no dependency | Full stemming needs a dictionary; a short suffix table is auditable and deterministic. |
|
|
117
|
+
| Trigger aliases | `triggers:` frontmatter key, weight 2 | Between name (3) and description (1); allows skill authors to declare activation phrases. |
|
|
118
|
+
| Bilingual stopwords | English + Italian | Operator writes tasks in both languages; IT stopwords prevent function words from scoring. |
|
|
119
|
+
| Override weight | 2, same as `triggers:` | The overlay is an authoring substitute, not a stronger signal: it should fix vocabulary, not overpower a skill named after the task. |
|
|
120
|
+
| Override location | Own config file, never a skill root | Third-party frontmatter is overwritten by `skills update`; a file outside every root survives it and modifies no installed skill. |
|
|
121
|
+
| Missing default override file | No overrides, no warning | A missing optional file is the normal state on a fresh machine; only `--overrides <path>` is a promise the tool must keep. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-skill-fit",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "Deterministic selection of the installed skills relevant to a task",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pi-skill-fit": "bin/pi-skill-fit.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"SKILL.md",
|
|
13
|
+
"README.md",
|
|
14
|
+
"docs"
|
|
15
|
+
],
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./src/extension.js"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test test/*.test.js"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT"
|
|
28
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { discoverSkills, defaultRoots } from "./skills.js";
|
|
2
|
+
import { rankSkills } from "./score.js";
|
|
3
|
+
import { filterByRole, readRoleSkills } from "./role.js";
|
|
4
|
+
import { applyOverrides, defaultOverridesPath, loadOverrides } from "./overrides.js";
|
|
5
|
+
|
|
6
|
+
const USAGE =
|
|
7
|
+
'usage: pi-skill-fit "<task text>" [--role <name>] [--limit <n>] [--json] [--explain] [--roots <dir>] [--overrides <path>]';
|
|
8
|
+
|
|
9
|
+
function invalid(message) {
|
|
10
|
+
const error = new Error(`${message}\n${USAGE}`);
|
|
11
|
+
error.exitCode = 2;
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Parse CLI arguments into an options object. */
|
|
16
|
+
export function parseArgs(argv) {
|
|
17
|
+
const options = { task: null, role: null, limit: 5, json: false, explain: false, roots: [], overrides: null };
|
|
18
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
19
|
+
const arg = argv[index];
|
|
20
|
+
const value = () => {
|
|
21
|
+
const next = argv[index + 1];
|
|
22
|
+
if (next === undefined || next.startsWith("--")) invalid(`${arg} requires a value`);
|
|
23
|
+
index += 1;
|
|
24
|
+
return next;
|
|
25
|
+
};
|
|
26
|
+
if (arg === "--json") options.json = true;
|
|
27
|
+
else if (arg === "--explain") options.explain = true;
|
|
28
|
+
else if (arg === "--role") options.role = value();
|
|
29
|
+
else if (arg === "--limit") {
|
|
30
|
+
const raw = value();
|
|
31
|
+
if (!/^\d+$/.test(raw) || Number(raw) < 1) invalid(`--limit must be an integer >= 1, got ${raw}`);
|
|
32
|
+
options.limit = Number(raw);
|
|
33
|
+
} else if (arg === "--roots") options.roots.push(value());
|
|
34
|
+
else if (arg === "--overrides") options.overrides = value();
|
|
35
|
+
else if (arg.startsWith("--")) invalid(`unknown flag: ${arg}`);
|
|
36
|
+
else if (options.task === null) options.task = arg;
|
|
37
|
+
else invalid(`unexpected argument: ${arg}`);
|
|
38
|
+
}
|
|
39
|
+
if (options.task === null || options.task.trim() === "") invalid("task text is required");
|
|
40
|
+
return options;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function renderTable(selected, explain) {
|
|
44
|
+
const rows = [
|
|
45
|
+
["RANK", "NAME", "SCORE", "PATH"],
|
|
46
|
+
...selected.map((skill, index) => [String(index + 1), skill.name, String(skill.score), skill.path]),
|
|
47
|
+
];
|
|
48
|
+
const widths = rows[0].map((_, column) => Math.max(...rows.map((row) => row[column].length)));
|
|
49
|
+
let output = rows
|
|
50
|
+
.map((row) => row.map((cell, column) => (column === 3 ? cell : cell.padEnd(widths[column]))).join(" ").trimEnd())
|
|
51
|
+
.join("\n");
|
|
52
|
+
if (explain) {
|
|
53
|
+
output += "\n";
|
|
54
|
+
for (const skill of selected) {
|
|
55
|
+
const parts = skill.matches.map((m) => `${m.token}(${m.source}:${m.weight})`);
|
|
56
|
+
output += `\n ${skill.name}: ${parts.join(", ")}`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return output;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string[]} argv @returns {Promise<number>} exit code */
|
|
63
|
+
export async function main(argv) {
|
|
64
|
+
try {
|
|
65
|
+
const options = parseArgs(argv);
|
|
66
|
+
const allowlist = options.role === null ? null : await readRoleSkills(options.role);
|
|
67
|
+
const roots = options.roots.length > 0 ? options.roots : await defaultRoots();
|
|
68
|
+
const overrides = await loadOverrides(options.overrides ?? defaultOverridesPath(), {
|
|
69
|
+
required: options.overrides !== null,
|
|
70
|
+
});
|
|
71
|
+
const skills = applyOverrides(filterByRole(await discoverSkills(roots), allowlist), overrides);
|
|
72
|
+
const { candidates, selected, dropped } = rankSkills(skills, options.task, options.limit);
|
|
73
|
+
|
|
74
|
+
if (options.json) {
|
|
75
|
+
const selectedOut = selected.map((s) => ({
|
|
76
|
+
name: s.name,
|
|
77
|
+
score: s.score,
|
|
78
|
+
path: s.path,
|
|
79
|
+
matches: s.matches,
|
|
80
|
+
}));
|
|
81
|
+
process.stdout.write(
|
|
82
|
+
`${JSON.stringify({
|
|
83
|
+
task: options.task,
|
|
84
|
+
role: options.role,
|
|
85
|
+
limit: options.limit,
|
|
86
|
+
selected: selectedOut,
|
|
87
|
+
dropped,
|
|
88
|
+
candidates: candidates.length,
|
|
89
|
+
})}\n`,
|
|
90
|
+
);
|
|
91
|
+
} else {
|
|
92
|
+
if (selected.length > 0) process.stdout.write(`${renderTable(selected, options.explain)}\n`);
|
|
93
|
+
process.stdout.write(`${selected.length} of ${candidates.length} candidates, ${dropped} dropped\n`);
|
|
94
|
+
}
|
|
95
|
+
return 0;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
process.stderr.write(`pi-skill-fit: ${error.message}\n`);
|
|
98
|
+
return error.exitCode === 2 ? 2 : 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
package/src/extension.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { defaultOverridesPath, applyOverrides, loadOverrides } from "./overrides.js";
|
|
2
|
+
import { rankSkills } from "./score.js";
|
|
3
|
+
import { defaultRoots, discoverSkills } from "./skills.js";
|
|
4
|
+
|
|
5
|
+
export const HEADING = "## Selected skills (pi-skill-fit)";
|
|
6
|
+
export const MAX_BLOCK_CHARS = 700;
|
|
7
|
+
const MAX_ENTRIES = 5;
|
|
8
|
+
const MAX_TOKENS = 4;
|
|
9
|
+
const MAX_SESSIONS = 64;
|
|
10
|
+
|
|
11
|
+
function render(entries, tokenLimit) {
|
|
12
|
+
const lines = entries.map((entry) => {
|
|
13
|
+
const tokens = entry.tokens.slice(0, tokenLimit);
|
|
14
|
+
return `- ${entry.path} — ${entry.name}${tokens.length > 0 ? `: ${tokens.join(", ")}` : ""}`;
|
|
15
|
+
});
|
|
16
|
+
return [HEADING, ...lines].join("\n");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Build the prompt block. Token lists shrink before entries are dropped. */
|
|
20
|
+
export function buildBlock(selected, { maxChars = MAX_BLOCK_CHARS } = {}) {
|
|
21
|
+
const entries = selected
|
|
22
|
+
.slice(0, MAX_ENTRIES)
|
|
23
|
+
.map((skill) => ({
|
|
24
|
+
path: skill.path,
|
|
25
|
+
name: skill.name,
|
|
26
|
+
tokens: [...new Set((skill.matches ?? []).map((match) => match.token))].slice(0, MAX_TOKENS),
|
|
27
|
+
}));
|
|
28
|
+
if (entries.length === 0) return "";
|
|
29
|
+
for (let tokenLimit = MAX_TOKENS; tokenLimit >= 0; tokenLimit -= 1) {
|
|
30
|
+
const block = render(entries, tokenLimit);
|
|
31
|
+
if (block.length <= maxChars) return block;
|
|
32
|
+
}
|
|
33
|
+
const kept = entries.slice();
|
|
34
|
+
while (kept.length > 0 && render(kept, 0).length > maxChars) kept.pop();
|
|
35
|
+
return kept.length === 0 ? "" : render(kept, 0).slice(0, maxChars);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Rank installed skills for a prompt without spawning a process. */
|
|
39
|
+
export async function selectSkills(prompt, { roots, overrides } = {}) {
|
|
40
|
+
const skillRoots = roots ?? (await defaultRoots());
|
|
41
|
+
const overrideMap = overrides ?? (await loadOverrides(defaultOverridesPath()));
|
|
42
|
+
const skills = applyOverrides(await discoverSkills(skillRoots), overrideMap);
|
|
43
|
+
return rankSkills(skills, prompt, MAX_ENTRIES).selected;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function disabledBySettings(getSettings) {
|
|
47
|
+
try {
|
|
48
|
+
const namespace = getSettings()?.extensionSettings?.["pi-skill-fit"];
|
|
49
|
+
return namespace === false || namespace === "off" || namespace?.enabled === false;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function sessionKey(ctx) {
|
|
56
|
+
try {
|
|
57
|
+
const id = ctx?.sessionManager?.getSessionId?.();
|
|
58
|
+
return typeof id === "string" ? id : "default";
|
|
59
|
+
} catch {
|
|
60
|
+
return "default";
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Create the `before_agent_start` handler. Every failure is swallowed and
|
|
66
|
+
* reported at most once so selection can never block the agent start.
|
|
67
|
+
*/
|
|
68
|
+
export function createHandler({ select = selectSkills, getSettings = () => undefined, warn } = {}) {
|
|
69
|
+
const lastSelection = new Map();
|
|
70
|
+
let warned = false;
|
|
71
|
+
const report = warn ?? ((error) => process.stderr.write(`pi-skill-fit: ${error?.message ?? error}\n`));
|
|
72
|
+
return async function beforeAgentStart(event, ctx) {
|
|
73
|
+
try {
|
|
74
|
+
if (process.env.PI_SKILL_FIT === "off" || disabledBySettings(getSettings)) return;
|
|
75
|
+
if (event?.messageType !== "user" || typeof event.prompt !== "string" || event.prompt.trim() === "") return;
|
|
76
|
+
if (!event.systemPromptOptions || typeof event.systemPromptOptions !== "object") return;
|
|
77
|
+
// NOTE: the active role is not exposed by the hook context, so the role allowlist cannot
|
|
78
|
+
// be applied here; the CLI keeps it. See README for the limitation.
|
|
79
|
+
const selected = await select(event.prompt);
|
|
80
|
+
if (!Array.isArray(selected) || selected.length === 0) return;
|
|
81
|
+
const key = selected.map((skill) => `${skill.name}\u0000${skill.path}`).join("\n");
|
|
82
|
+
const session = sessionKey(ctx);
|
|
83
|
+
if (lastSelection.get(session) === key) return;
|
|
84
|
+
const block = buildBlock(selected);
|
|
85
|
+
if (block === "") return;
|
|
86
|
+
lastSelection.set(session, key);
|
|
87
|
+
if (lastSelection.size > MAX_SESSIONS) lastSelection.delete(lastSelection.keys().next().value);
|
|
88
|
+
const current = event.systemPromptOptions.appendSystemPrompt;
|
|
89
|
+
event.systemPromptOptions.appendSystemPrompt =
|
|
90
|
+
typeof current === "string" && current !== "" ? `${current}\n\n${block}` : block;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (warned) return;
|
|
93
|
+
warned = true;
|
|
94
|
+
report(error);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export default function piSkillFit(pi) {
|
|
100
|
+
pi.on("before_agent_start", createHandler({ getSettings: () => pi.getSettings?.() }));
|
|
101
|
+
}
|
package/src/overrides.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
/** Override file location: `$XDG_CONFIG_HOME/pi-skill-fit/triggers.json`, else `~/.config/...`. */
|
|
6
|
+
export function defaultOverridesPath(env = process.env) {
|
|
7
|
+
const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
8
|
+
return path.join(base, "pi-skill-fit", "triggers.json");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function invalid(file, reason) {
|
|
12
|
+
const error = new Error(`invalid overrides file ${file}: ${reason}`);
|
|
13
|
+
error.exitCode = 2;
|
|
14
|
+
return error;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Validate the `{ "skill-name": ["term", ...] }` shape. */
|
|
18
|
+
export function parseOverrides(text, file) {
|
|
19
|
+
let data;
|
|
20
|
+
try {
|
|
21
|
+
data = JSON.parse(text);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
throw invalid(file, error.message);
|
|
24
|
+
}
|
|
25
|
+
if (data === null || typeof data !== "object" || Array.isArray(data)) {
|
|
26
|
+
throw invalid(file, "expected an object mapping skill names to arrays of strings");
|
|
27
|
+
}
|
|
28
|
+
for (const [name, tokens] of Object.entries(data)) {
|
|
29
|
+
if (!Array.isArray(tokens)) throw invalid(file, `"${name}" must be an array of strings`);
|
|
30
|
+
for (const token of tokens) {
|
|
31
|
+
if (typeof token !== "string") throw invalid(file, `"${name}" contains a non-string entry`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return data;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Read the override map. A missing file is `{}` unless it was named explicitly,
|
|
39
|
+
* which is a user error worth reporting.
|
|
40
|
+
*/
|
|
41
|
+
export async function loadOverrides(file, { required = false } = {}) {
|
|
42
|
+
let text;
|
|
43
|
+
try {
|
|
44
|
+
text = await readFile(file, "utf8");
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.code === "ENOENT" && !required) return {};
|
|
47
|
+
if (error.code === "ENOENT") throw invalid(file, "file not found");
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
return parseOverrides(text, file);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Attach override tokens to installed skills; names that are not installed are ignored. */
|
|
54
|
+
export function applyOverrides(skills, overrides) {
|
|
55
|
+
return skills.map((skill) =>
|
|
56
|
+
Object.hasOwn(overrides, skill.name) ? { ...skill, overrides: overrides[skill.name] } : skill,
|
|
57
|
+
);
|
|
58
|
+
}
|
package/src/role.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_ROLE_DIR = "/root/.pi/agent/pi-ext-roles/roles";
|
|
5
|
+
|
|
6
|
+
function unquote(value) {
|
|
7
|
+
const trimmed = value.trim();
|
|
8
|
+
if (trimmed.length < 2) return trimmed;
|
|
9
|
+
const quote = trimmed[0];
|
|
10
|
+
if ((quote !== '"' && quote !== "'") || trimmed.at(-1) !== quote) return trimmed;
|
|
11
|
+
return trimmed.slice(1, -1).replace(/\\(["'\\])/g, "$1");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Read the `skills:` allowlist from a role prompt.
|
|
16
|
+
* `null` means the role declares no allowlist, so no filtering applies.
|
|
17
|
+
*/
|
|
18
|
+
export async function readRoleSkills(role, roleDir = DEFAULT_ROLE_DIR) {
|
|
19
|
+
if (/[\\/]/.test(role) || role === "." || role === "..") {
|
|
20
|
+
const error = new Error(`invalid role name: ${role}`);
|
|
21
|
+
error.exitCode = 2;
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
24
|
+
const file = path.join(roleDir, `${role}.md`);
|
|
25
|
+
const text = await readFile(file, "utf8").catch(() => {
|
|
26
|
+
const error = new Error(`role file not found: ${file}`);
|
|
27
|
+
error.exitCode = 2;
|
|
28
|
+
throw error;
|
|
29
|
+
});
|
|
30
|
+
const end = text.startsWith("---") ? text.indexOf("\n---", 3) : -1;
|
|
31
|
+
const block = end === -1 ? "" : text.slice(3, end);
|
|
32
|
+
const match = /^skills:\s*(.*)$/m.exec(block);
|
|
33
|
+
if (!match) return null;
|
|
34
|
+
const inner = match[1].trim().replace(/^\[/, "").replace(/\]$/, "").trim();
|
|
35
|
+
if (!inner) return null;
|
|
36
|
+
return inner
|
|
37
|
+
.split(",")
|
|
38
|
+
.map((entry) => unquote(entry))
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Apply a role allowlist. Only an explicit `!*` restricts candidates. */
|
|
43
|
+
export function filterByRole(skills, allowlist) {
|
|
44
|
+
if (!allowlist || !allowlist.includes("!*")) return skills;
|
|
45
|
+
const allowed = new Set(allowlist.filter((entry) => !entry.startsWith("!")));
|
|
46
|
+
return skills.filter((skill) => allowed.has(skill.name));
|
|
47
|
+
}
|
package/src/score.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
const STOPWORDS_EN = new Set(
|
|
2
|
+
"the and for with that this from into are was were you your its not but all any can had has have how our out use when where which who will would should could about after before also than then them they there these those their been being does did doing each more most other some such only over just like make made may much must need same him his her one two get got per via onto off own yet".split(
|
|
3
|
+
" ",
|
|
4
|
+
),
|
|
5
|
+
);
|
|
6
|
+
|
|
7
|
+
const STOPWORDS_IT = new Set(
|
|
8
|
+
"che non con per una gli del delle della dei sono stato essere dalla anche come alla alle degli delle nella nelle nella negli questo questa questi queste quello quella quelli quelle suo sua suoi sue loro quel nel sul dal dal tutto tutti tutta tutte fare fatto era erano uno dei tra fra dove quando poi ancora molto piu suoi miei senza verso dopo prima sopra sotto".split(
|
|
9
|
+
" ",
|
|
10
|
+
),
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Light suffix normalisation table. Strips inflectional suffixes to collapse
|
|
15
|
+
* common variants. Never produces a stem shorter than 4 characters.
|
|
16
|
+
* Applied after lowercasing; longest matching suffix wins.
|
|
17
|
+
*
|
|
18
|
+
* English: -ation, -tion, -sion, -ment, -ness, -ing, -ying, -ies, -ily, -ed, -er, -ly, -s
|
|
19
|
+
* Italian: -azione, -zione, -zioni, -mente, -ione, -ioni, -ando, -endo,
|
|
20
|
+
* -are, -ire, -ere, -ato, -ata, -ati, -ate, -ari, -i
|
|
21
|
+
*/
|
|
22
|
+
const SUFFIX_RULES = [
|
|
23
|
+
// Italian first (longer suffixes before shorter)
|
|
24
|
+
{ suffix: "azione", min: 10 },
|
|
25
|
+
{ suffix: "zione", min: 8 },
|
|
26
|
+
{ suffix: "zioni", min: 8 },
|
|
27
|
+
{ suffix: "mente", min: 8 },
|
|
28
|
+
{ suffix: "ione", min: 7 },
|
|
29
|
+
{ suffix: "ioni", min: 7 },
|
|
30
|
+
{ suffix: "ando", min: 7 },
|
|
31
|
+
{ suffix: "endo", min: 7 },
|
|
32
|
+
{ suffix: "are", min: 6 },
|
|
33
|
+
{ suffix: "ire", min: 6 },
|
|
34
|
+
{ suffix: "ere", min: 6 },
|
|
35
|
+
{ suffix: "ato", min: 6 },
|
|
36
|
+
{ suffix: "ata", min: 6 },
|
|
37
|
+
{ suffix: "ati", min: 6 },
|
|
38
|
+
{ suffix: "ate", min: 6 },
|
|
39
|
+
{ suffix: "ari", min: 6 },
|
|
40
|
+
{ suffix: "i", min: 8 },
|
|
41
|
+
// English (longer before shorter)
|
|
42
|
+
{ suffix: "ation", min: 8 },
|
|
43
|
+
{ suffix: "tion", min: 7 },
|
|
44
|
+
{ suffix: "sion", min: 7 },
|
|
45
|
+
{ suffix: "ment", min: 7 },
|
|
46
|
+
{ suffix: "ness", min: 7 },
|
|
47
|
+
{ suffix: "ying", min: 7 },
|
|
48
|
+
{ suffix: "ing", min: 6 },
|
|
49
|
+
{ suffix: "ies", min: 6 },
|
|
50
|
+
{ suffix: "ily", min: 6 },
|
|
51
|
+
{ suffix: "ed", min: 5 },
|
|
52
|
+
{ suffix: "er", min: 5 },
|
|
53
|
+
{ suffix: "ly", min: 5 },
|
|
54
|
+
{ suffix: "s", min: 4 },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const MIN_STEM_LENGTH = 4;
|
|
58
|
+
|
|
59
|
+
/** Strip one inflectional suffix if the stem stays >= 4 chars. */
|
|
60
|
+
export function normalizeSuffix(token) {
|
|
61
|
+
for (const { suffix, min } of SUFFIX_RULES) {
|
|
62
|
+
if (token.length >= min && token.endsWith(suffix)) {
|
|
63
|
+
const stem = token.slice(0, -suffix.length);
|
|
64
|
+
if (stem.length < MIN_STEM_LENGTH) return token;
|
|
65
|
+
return stem;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return token;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Lowercase, split on non-alphanumeric, drop short tokens and stopwords, normalize suffixes. */
|
|
72
|
+
export function tokenize(text) {
|
|
73
|
+
const tokens = new Set();
|
|
74
|
+
for (const raw of text.toLowerCase().split(/[^a-z0-9]+/)) {
|
|
75
|
+
if (raw.length < 3) continue;
|
|
76
|
+
if (STOPWORDS_EN.has(raw) || STOPWORDS_IT.has(raw)) continue;
|
|
77
|
+
tokens.add(normalizeSuffix(raw));
|
|
78
|
+
}
|
|
79
|
+
return tokens;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function nameTokens(name) {
|
|
83
|
+
return new Set(name.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Trigger and override aliases: same tokenizer and suffix table as the task text. */
|
|
87
|
+
function aliasTokens(values) {
|
|
88
|
+
const tokens = new Set();
|
|
89
|
+
for (const value of values ?? []) {
|
|
90
|
+
for (const part of value.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)) {
|
|
91
|
+
tokens.add(normalizeSuffix(part));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return tokens;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Score a skill against task tokens.
|
|
99
|
+
* Weights: name=3, triggers/overrides=2, description=1.
|
|
100
|
+
* Returns { score, matches } where matches is an array of { token, source, weight }.
|
|
101
|
+
*/
|
|
102
|
+
export function scoreSkill(taskTokens, skill) {
|
|
103
|
+
const names = nameTokens(skill.name);
|
|
104
|
+
const triggers = aliasTokens(skill.triggers);
|
|
105
|
+
const overrides = aliasTokens(skill.overrides);
|
|
106
|
+
const descriptions = tokenize(skill.description);
|
|
107
|
+
let score = 0;
|
|
108
|
+
const matches = [];
|
|
109
|
+
for (const token of taskTokens) {
|
|
110
|
+
if (names.has(token)) {
|
|
111
|
+
score += 3;
|
|
112
|
+
matches.push({ token, source: "name", weight: 3 });
|
|
113
|
+
}
|
|
114
|
+
// NOTE: a token in both a frontmatter trigger and an override scores once, as an override.
|
|
115
|
+
if (overrides.has(token)) {
|
|
116
|
+
score += 2;
|
|
117
|
+
matches.push({ token, source: "override", weight: 2 });
|
|
118
|
+
} else if (triggers.has(token)) {
|
|
119
|
+
score += 2;
|
|
120
|
+
matches.push({ token, source: "trigger", weight: 2 });
|
|
121
|
+
}
|
|
122
|
+
if (descriptions.has(token)) {
|
|
123
|
+
score += 1;
|
|
124
|
+
matches.push({ token, source: "description", weight: 1 });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { score, matches };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Score, drop zeros, order by score descending then name ascending.
|
|
132
|
+
* Returns the ranked candidates and the selected page.
|
|
133
|
+
*/
|
|
134
|
+
export function rankSkills(skills, task, limit) {
|
|
135
|
+
const taskTokens = tokenize(task);
|
|
136
|
+
const candidates = skills
|
|
137
|
+
.map((skill) => {
|
|
138
|
+
const { score, matches } = scoreSkill(taskTokens, skill);
|
|
139
|
+
return { name: skill.name, score, path: skill.path, matches };
|
|
140
|
+
})
|
|
141
|
+
.filter((skill) => skill.score > 0)
|
|
142
|
+
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name) || a.path.localeCompare(b.path));
|
|
143
|
+
const selected = candidates.slice(0, limit);
|
|
144
|
+
return { candidates, selected, dropped: candidates.length - selected.length };
|
|
145
|
+
}
|
package/src/skills.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Fixed roots, in resolution order. @type {string[]} */
|
|
5
|
+
export const DEFAULT_ROOTS = ["/root/.agents/skills", "/root/.pi/agent/skills"];
|
|
6
|
+
|
|
7
|
+
const NPM_ROOT = "/root/.pi/agent/npm/node_modules";
|
|
8
|
+
|
|
9
|
+
async function isDirectory(target) {
|
|
10
|
+
try {
|
|
11
|
+
return (await stat(target)).isDirectory();
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Default roots plus every skills directory under each Pi npm package. */
|
|
18
|
+
export async function defaultRoots() {
|
|
19
|
+
const roots = [...DEFAULT_ROOTS];
|
|
20
|
+
const entries = await readdir(NPM_ROOT, { withFileTypes: true }).catch(() => []);
|
|
21
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
22
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
|
23
|
+
const dir = path.join(NPM_ROOT, entry.name, "skills");
|
|
24
|
+
if (await isDirectory(dir)) roots.push(dir);
|
|
25
|
+
}
|
|
26
|
+
return roots;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function unquote(value) {
|
|
30
|
+
const trimmed = value.trim();
|
|
31
|
+
if (trimmed.length < 2) return trimmed;
|
|
32
|
+
const quote = trimmed[0];
|
|
33
|
+
if ((quote !== '"' && quote !== "'") || trimmed.at(-1) !== quote) return trimmed;
|
|
34
|
+
return trimmed.slice(1, -1).replace(/\\(["'\\])/g, "$1");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Minimal YAML frontmatter reader: only the single-line `name` and
|
|
39
|
+
* `description` keys are understood.
|
|
40
|
+
*/
|
|
41
|
+
export function parseFrontmatter(text) {
|
|
42
|
+
if (!text.startsWith("---")) return {};
|
|
43
|
+
const end = text.indexOf("\n---", 3);
|
|
44
|
+
if (end === -1) return {};
|
|
45
|
+
const fields = {};
|
|
46
|
+
for (const line of text.slice(3, end).split(/\r?\n/)) {
|
|
47
|
+
// NOTE: multi-line YAML scalars are not supported; v1 reads single-line keys.
|
|
48
|
+
const match = /^(name|description|triggers):\s*(.*)$/.exec(line);
|
|
49
|
+
if (match) {
|
|
50
|
+
if (match[1] === "triggers") {
|
|
51
|
+
// Parse as comma-separated or YAML inline list: triggers: [a, b] or triggers: a, b
|
|
52
|
+
const raw = match[2].trim().replace(/^\[/, "").replace(/\]$/, "").trim();
|
|
53
|
+
fields.triggers = raw ? raw.split(",").map((t) => unquote(t)).filter(Boolean) : [];
|
|
54
|
+
} else {
|
|
55
|
+
fields[match[1]] = unquote(match[2]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return fields;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Discover skill directories under each root. A skill directory is a
|
|
64
|
+
* directory containing `SKILL.md`; a missing root is skipped silently.
|
|
65
|
+
*/
|
|
66
|
+
export async function discoverSkills(roots) {
|
|
67
|
+
const skills = [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const root of roots) {
|
|
70
|
+
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
71
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
72
|
+
const dir = path.join(root, entry.name);
|
|
73
|
+
if (!(await isDirectory(dir))) continue;
|
|
74
|
+
const file = path.join(dir, "SKILL.md");
|
|
75
|
+
const text = await readFile(file, "utf8").catch(() => null);
|
|
76
|
+
if (text === null) continue;
|
|
77
|
+
const realDir = await realpath(dir).catch(() => dir);
|
|
78
|
+
if (seen.has(realDir)) continue;
|
|
79
|
+
seen.add(realDir);
|
|
80
|
+
const front = parseFrontmatter(text);
|
|
81
|
+
skills.push({
|
|
82
|
+
name: front.name || path.basename(dir),
|
|
83
|
+
description: front.description || "",
|
|
84
|
+
triggers: front.triggers || null,
|
|
85
|
+
path: path.join(realDir, "SKILL.md"),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return skills;
|
|
90
|
+
}
|