@aeondave/pi-skill-optimizer 3.0.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/BENCHMARK.md +354 -0
- package/CHANGELOG.md +117 -0
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/assets/banner.png +0 -0
- package/docs/ARCHITECTURE.md +66 -0
- package/docs/CONFIGURATION.md +138 -0
- package/docs/RANKING.md +155 -0
- package/package.json +85 -0
- package/src/activation.ts +88 -0
- package/src/aliases.ts +111 -0
- package/src/config.ts +446 -0
- package/src/corpus.ts +416 -0
- package/src/evaluation.ts +692 -0
- package/src/generate.ts +365 -0
- package/src/history.ts +147 -0
- package/src/index.ts +1362 -0
- package/src/init-profile.ts +96 -0
- package/src/init-prompt.ts +10 -0
- package/src/init-readiness.ts +123 -0
- package/src/optimize.ts +424 -0
- package/src/output-recovery.ts +19 -0
- package/src/output.ts +629 -0
- package/src/persistence.ts +535 -0
- package/src/profile.ts +253 -0
- package/src/prompts/init-profile.md +40 -0
- package/src/request.ts +304 -0
- package/src/skill-loader.ts +138 -0
- package/src/skills.ts +1230 -0
- package/src/stats.ts +178 -0
- package/src/tools.ts +548 -0
- package/src/usage.ts +256 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# How AUTO works
|
|
2
|
+
|
|
3
|
+
[Back to the README](../README.md)
|
|
4
|
+
|
|
5
|
+
### Stable skill base
|
|
6
|
+
|
|
7
|
+
AUTO renders a query-independent index in the stable prompt prefix:
|
|
8
|
+
|
|
9
|
+
```xml
|
|
10
|
+
<!--skill-optimizer:auto:v2-->
|
|
11
|
+
<skill_index format="tsv" columns="name,intent">
|
|
12
|
+
skill-name\tShort routing intent
|
|
13
|
+
</skill_index>
|
|
14
|
+
<skill_resolver>Use skill_search to search, load, inspect resources, or request a location.</skill_resolver>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The index contains no filesystem paths. With the same catalog, profile, and configuration, its bytes remain unchanged across requests. `catalogBudgetChars` is a request-global soft budget shared by every catalog found in one request. Intents yield first, but the combined all-name floor may exceed the budget.
|
|
18
|
+
|
|
19
|
+
## Request-specific prefetch
|
|
20
|
+
|
|
21
|
+
AUTO extracts intent only from genuine human text in Anthropic, OpenAI Chat and Responses, Gemini, Mistral, and Pi's structured `pi-messages` request shapes. A catalog may live in a system or developer block, but neither is treated as human intent. Assistant text, tool or function results, injected context, image metadata, and empty input cannot create a ranking signal.
|
|
22
|
+
|
|
23
|
+
Ranking uses exact matching, BM25F, bounded name and alias typo recovery, reciprocal-rank fusion, diversity, profile evidence, and decayed usage evidence. It scores full source descriptions, then appends a bounded set of verbatim skill names and descriptions (not the full `SKILL.md` instructions) to the latest human block. The overlay is separate from the stable prefix, fingerprinted, idempotent, and omitted when there is no valid target.
|
|
24
|
+
|
|
25
|
+
Optional init-generated activation tags distinguish specialists, workflow methods
|
|
26
|
+
and behavioral discipline. An admitted specialist can bring one activity-matched
|
|
27
|
+
supporting skill into the ordinary prefetch budget; this does not recursively
|
|
28
|
+
expand dependencies or treat a behavioral tag as universal. See
|
|
29
|
+
[contextual routing](RANKING.md#contextual-and-behavioral-skills).
|
|
30
|
+
|
|
31
|
+
## Dynamic tools
|
|
32
|
+
|
|
33
|
+
AUTO keeps protected and predicted tools active and leaves the rest behind `tool_search`. Search is bounded and paginated. A returned result is activated through Pi's runtime API without replacing already active tools. Core, configured, and previously used tools remain callable, so an in-progress tool loop cannot be stranded. Weak or absent routing signal fails open to every permitted tool; safety wins, but that request may retain the full schema cost.
|
|
34
|
+
|
|
35
|
+
Selection is recalculated before each agent run, not just the first request. Used tools and explicit search results stay active for the session. The discoverable set starts from Pi's initially active tools plus the optimizer's own resolvers; tools that were initially unavailable are not silently added. This is schema routing, not authorization: another extension's call-time permission checks remain authoritative.
|
|
36
|
+
|
|
37
|
+
See Pi's [extension and dynamic-tool API](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/extensions.md).
|
|
38
|
+
|
|
39
|
+
## Recoverable output
|
|
40
|
+
|
|
41
|
+
The default `smart` reducer is deterministic and UTF-8 byte-aware. It preserves protected evidence and accepts a reduction only when the complete returned representation clears both configured saving thresholds. Size and line settings trigger reduction; they are not hard output caps. Protected evidence may make the returned result exceed them.
|
|
42
|
+
|
|
43
|
+
Homogeneous JSON object arrays may use a lossless columnar form after an exact internal round trip. With `outputMode: "extract"` and an explicit `outputModel`, sufficiently large text may first use model-assisted extraction. Extracted content must be an ordered verbatim subsequence, retain protected evidence, and provide material savings. Rejection falls back to smart reduction, then to the original.
|
|
44
|
+
|
|
45
|
+
Before AUTO returns any reduced result, it stores the complete original in a local, immutable, content-addressed archive. Provider-visible recovery text contains only an opaque identifier. `retrieve_output` verifies and resolves that identifier with bounded line access.
|
|
46
|
+
|
|
47
|
+
Savings include the recovery label and are checked before an archive write. The optimizer's own resolver results are exempt from reduction, avoiding a second handle in place of the original you just requested. Custom-tool failures throw through Pi's error path instead of returning a success-shaped error object.
|
|
48
|
+
|
|
49
|
+
Columnar conversion conservatively leaves unsafe integers, non-finite numbers,
|
|
50
|
+
negative zero and noncanonical numeric spellings untouched. Parsing twice is not
|
|
51
|
+
a losslessness oracle when both parses can round the same value. Invalid Unicode
|
|
52
|
+
strings also fail open. Exact recovery retains the source's original line endings;
|
|
53
|
+
it does not normalize Windows output to LF.
|
|
54
|
+
|
|
55
|
+
History deduplication keeps the first large successful result intact and replaces only later exact copies. Errors, images, mixed media, small results, and protected evidence are never deduplicated.
|
|
56
|
+
|
|
57
|
+
## Scale and limits
|
|
58
|
+
|
|
59
|
+
AUTO reduces metadata in the tested catalogs, but does not guarantee model discovery.
|
|
60
|
+
|
|
61
|
+
- The current contract keeps every eligible skill name in the stable index. Its minimum size therefore grows linearly with the number and length of names. This all-name information floor cannot be compressed away while preserving immediate name-level discovery.
|
|
62
|
+
- A catalog with 100,000 skills is outside the scale demonstrated by the current 332-skill benchmark. It may still produce an impractically large name-only base.
|
|
63
|
+
- Extreme catalogs require an additional resolver-only, namespace-sharded, or server-side paginated discovery layer. That would deliberately change the current all-name visibility guarantee.
|
|
64
|
+
- Arbitrarily large system prompts are not automatically reduced. AUTO transforms recognized skill catalogs, manages tool definitions, and processes eligible tool results; it does not summarize unrelated policy, instructions, or conversation content.
|
|
65
|
+
- Provider tokenization, caching, and pricing remain provider-controlled. Stable bytes improve cacheability but do not prove a cache hit or guarantee a specific bill.
|
|
66
|
+
- Very small catalogs or outputs may remain unchanged when framing overhead would eliminate the saving.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Configuration and runtime reference
|
|
2
|
+
|
|
3
|
+
[Back to the README](../README.md)
|
|
4
|
+
|
|
5
|
+
## Runtime tools
|
|
6
|
+
|
|
7
|
+
| Tool | Purpose |
|
|
8
|
+
| --- | --- |
|
|
9
|
+
| `skill_search` | Paginated skill search plus exact `load`, contained `resource`, and explicit `location` actions |
|
|
10
|
+
| `tool_search` | Search deferred tool definitions and activate bounded matches additively |
|
|
11
|
+
| `retrieve_output` | Recover archived output or a deduplicated history result by opaque identifier |
|
|
12
|
+
|
|
13
|
+
Exact skill loading accepts only registered names. Resource and location resolution use canonical paths and containment checks to reject traversal and symlink escape. Search cursors bind to the query, catalog and ranked name order; a changed profile or usage prior that reorders results requires a new search, rather than duplicating or skipping a page. This is an exact local resolver guarantee, not a guarantee that the model will select the right skill; see the separate [ranking diagnostics](RANKING.md) and [historical model benchmark](../BENCHMARK.md).
|
|
14
|
+
|
|
15
|
+
`excludeSkills` removes names from discovery and search, not from explicit exact loading. It is a routing preference, not an access-control boundary. The three resolver tools return their content without another output-reduction pass, so full skill instructions and recovered originals remain readable.
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
| Command | Purpose |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| `/skill-optimizer` | Show setup readiness, AUTO state, catalog budget, critical skills, usage prior, tool activation, provider telemetry, and savings |
|
|
22
|
+
| `/skill-optimizer init` | Generate or incrementally update routing hints and contextual activation tags |
|
|
23
|
+
| `/skill-optimizer audit` | Report description issues without editing source skills |
|
|
24
|
+
|
|
25
|
+
The optional setup hint is informational, not an error: ordinary routing works
|
|
26
|
+
without init. It appears on the first eligible interactive prompt, at most once
|
|
27
|
+
per profile scope, readiness state and init version in the current session.
|
|
28
|
+
Session metadata preserves that suppression across reload/resume. It is not sent
|
|
29
|
+
to the model and does not run init or incur model cost itself. Headless sessions,
|
|
30
|
+
disabled/out-of-scope routing and empty catalogs do not emit it. Status remains
|
|
31
|
+
available even after the hint has been shown. Nonempty hand-authored profiles
|
|
32
|
+
without generation metadata are treated as manual, not as a failed setup.
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
Global configuration lives at `~/.pi/agent/skill-optimizer/config.json`. A project can override it with `.pi/skill-optimizer/config.json`.
|
|
37
|
+
|
|
38
|
+
| Key | Default | Purpose |
|
|
39
|
+
| --- | ---: | --- |
|
|
40
|
+
| `catalogBudgetChars` | `8000` | Request-global soft budget for stable-index intents |
|
|
41
|
+
| `intentMaxChars` | `96` | Maximum rendered intent length |
|
|
42
|
+
| `prefetchTarget` | `5` | Ordinary prefetch target |
|
|
43
|
+
| `prefetchMin` | `3` | Minimum adaptive prefetch count |
|
|
44
|
+
| `prefetchMax` | `8` | Maximum adaptive prefetch count |
|
|
45
|
+
| `prefetchBudgetChars` | `6000` | Full-definition overlay budget |
|
|
46
|
+
| `fuzzyCandidateLimit` | `8` | Bound for name and alias typo recovery |
|
|
47
|
+
| `alwaysSkills` | `[]` | Skills favored for routing and prefetch |
|
|
48
|
+
| `excludeSkills` | `[]` | Skills intentionally removed from discovery |
|
|
49
|
+
| `providers` | `[]` | Provider allowlist; empty means all |
|
|
50
|
+
| `toolPrefetchMax` | `5` | Maximum proactive tool activation |
|
|
51
|
+
| `toolSearchPageSize` | `8` | Default tool-search page size |
|
|
52
|
+
| `alwaysTools` | `[]` | Tools that remain active |
|
|
53
|
+
| `historyDedupMinBytes` | `4096` | Minimum result size for history deduplication |
|
|
54
|
+
| `usageHalfLifeDays` | `30` | Usage-evidence decay half-life |
|
|
55
|
+
| `usageMaxEntries` | `2048` | Persistent usage-evidence bound |
|
|
56
|
+
| `usageStaleDays` | `180` | Age for pruning stale one-off evidence |
|
|
57
|
+
| `outputMode` | `"smart"` | `"off"`, `"smart"`, or guarded `"extract"` |
|
|
58
|
+
| `outputMaxLines` | `400` | Line-count activation threshold |
|
|
59
|
+
| `outputMaxBytes` | `16000` | UTF-8 byte activation threshold |
|
|
60
|
+
| `outputMinSavingsBytes` | `512` | Minimum absolute saving |
|
|
61
|
+
| `outputMinSavingsRatio` | `0.1` | Minimum relative saving |
|
|
62
|
+
| `outputTools` | `["*"]` | Eligible tool-name patterns |
|
|
63
|
+
| `outputModel` | `""` | Explicit model for guarded extraction |
|
|
64
|
+
| `outputExtractMinBytes` | `32000` | Minimum input size for extraction |
|
|
65
|
+
| `outputExtractExclude` | common listing commands | Tools excluded from extraction |
|
|
66
|
+
| `outputArchiveTtlHours` | `168` | Archive retention period |
|
|
67
|
+
|
|
68
|
+
Configuration is also available through `PI_SKILL_OPTIMIZER_*` environment variables. Lists are comma-separated. The bypass exists only as `PI_SKILL_OPTIMIZER_DISABLE` and uses boolean semantics; there is no `disable` configuration key. Output exceptions retain the exact names `PI_SKILL_OPTIMIZER_OUTPUT_MIN_SAVINGS_RATIO` and `PI_SKILL_OPTIMIZER_OUTPUT_EXCLUDE`.
|
|
69
|
+
|
|
70
|
+
Configuration refreshes at session start, before each agent run, and when you use a `/skill-optimizer` command. Provider and tool-result hooks reuse that snapshot instead of rereading files on every event. The environment bypass is checked immediately. Profile and usage state are retained separately; refreshing settings does not reset pending usage or telemetry.
|
|
71
|
+
|
|
72
|
+
## RTK coexistence
|
|
73
|
+
|
|
74
|
+
Ownership is decided per result:
|
|
75
|
+
|
|
76
|
+
| Result | Owner |
|
|
77
|
+
| --- | --- |
|
|
78
|
+
| Shell output already handled by RTK | RTK only |
|
|
79
|
+
| Shell output not handled by RTK | AUTO eligible |
|
|
80
|
+
| Read, web, MCP, and other eligible output | AUTO eligible |
|
|
81
|
+
| Already reduced output | No second reduction |
|
|
82
|
+
|
|
83
|
+
RTK never changes skill discovery or dynamic tool activation.
|
|
84
|
+
|
|
85
|
+
## Telemetry and persistence
|
|
86
|
+
|
|
87
|
+
Provider telemetry records only authoritative usage fields:
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
requests, input, output, cacheRead, cacheWrite, totalCost
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
AUTO does not infer cache hits from stable bytes. Characters, UTF-8 bytes, provider tokens, and cost remain separate metrics.
|
|
94
|
+
|
|
95
|
+
Profile updates use atomic replacement with revision checks; usage and statistics merge lock-serialized additive deltas. Archive content is immutable and SHA-256 verified. A separate expiry record belongs to each archive: saving identical content may extend its retention, but a reader or project with a shorter TTL cannot shorten it. Legacy archives without expiry records get a fixed seven-day lifetime from their last modification; corrupt expiry records fail closed without deleting content. Usage evidence is bounded and decays over time.
|
|
96
|
+
|
|
97
|
+
An old lock is reclaimed only when its recorded process is no longer alive;
|
|
98
|
+
elapsed time alone must not interrupt a slow or suspended writer. Unknown lock
|
|
99
|
+
ownership fails closed rather than risking a concurrent overwrite.
|
|
100
|
+
|
|
101
|
+
Recovery preserves original LF, CRLF, CR and mixed line endings, including the selected lines' terminators. Adjacent pages concatenate to the original text; asking past the end returns an empty page. Invalid Unicode strings containing unpaired UTF-16 surrogates are left unreduced rather than archived with replacement characters.
|
|
102
|
+
|
|
103
|
+
## Privacy
|
|
104
|
+
|
|
105
|
+
- Normal hooks never collect a benchmark corpus.
|
|
106
|
+
- Corpus building and real-provider evaluation are explicit commands.
|
|
107
|
+
- Remote evaluation receives only validated, sanitized cases with keyed irreversible identifiers.
|
|
108
|
+
- Raw private sessions, catalogs, paths, credentials, hostnames, and account identifiers are not published.
|
|
109
|
+
- Generated profile queries are routing hints, not benchmark labels.
|
|
110
|
+
- Full output archives stay local.
|
|
111
|
+
- `.pi/skill-optimizer/benchmark/` must not be committed.
|
|
112
|
+
|
|
113
|
+
## Routing profile initialization
|
|
114
|
+
|
|
115
|
+
`init` is explicit model work. It notifies at start and completion while per-batch progress stays in the transient status line. It batches full descriptions by skill count and UTF-8 input size, validates complete coverage and a successful provider stop reason, retries only retryable failures, and checkpoints each successful batch so interrupted work is not lost. A changed skill whose regeneration fails stays unhashed and inactive until retry, rather than serving stale routing. Init also repairs global/project profile ownership without another model call. Ordinary discovery does not call a model.
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
The profile is optional. Without one, AUTO still uses the catalog's names and descriptions. Run `init` when you want generated routing hints, and rerun it after changing your skills. It uses the current Pi model and can incur provider cost.
|
|
119
|
+
|
|
120
|
+
Profile init version 5 also generates optional `activation` tags. Each installed
|
|
121
|
+
skill can be a `specialist`, `workflow`, or `behavioral` skill and declare applicable
|
|
122
|
+
activities. For example, the profile may contain:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{
|
|
126
|
+
"activation": {
|
|
127
|
+
"python-patterns": { "kind": "specialist", "tasks": ["software-change"] },
|
|
128
|
+
"software-architecture": { "kind": "workflow", "tasks": ["software-design", "software-change"] },
|
|
129
|
+
"test-driven-development": { "kind": "behavioral", "tasks": ["software-change"] }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
These are example entries inside `profile.json`, not `config.json`, and only make
|
|
135
|
+
sense for installed skills. They allow bounded contextual support without turning
|
|
136
|
+
every behavioral skill into a permanent default. The next explicit init upgrades
|
|
137
|
+
older generated profiles; no regeneration or model call happens on ordinary
|
|
138
|
+
startup. See [contextual routing and its limits](RANKING.md#contextual-and-behavioral-skills).
|
package/docs/RANKING.md
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Ranking decisions
|
|
2
|
+
|
|
3
|
+
[Back to the README](../README.md)
|
|
4
|
+
|
|
5
|
+
## Why this is still a local lexical router
|
|
6
|
+
|
|
7
|
+
The job is to find a few useful entries in a known skill catalog, before the model
|
|
8
|
+
starts work. Ordinary routing must be deterministic, cheap to run, and usable
|
|
9
|
+
without another provider or a generated profile. AUTO's hybrid is a reasonable
|
|
10
|
+
fit for those constraints; it is not a claim that lexical retrieval is universally
|
|
11
|
+
better than embeddings or that these weights are optimal.
|
|
12
|
+
|
|
13
|
+
The current pipeline is:
|
|
14
|
+
|
|
15
|
+
1. Normalize the latest genuine human text and expand catalog-supported aliases.
|
|
16
|
+
2. Score names, short intents, full descriptions and optional profile queries with
|
|
17
|
+
field-weighted BM25F. Compact display text is not the only searchable text.
|
|
18
|
+
3. Add token-bounded name/example matches and bounded name/alias typo candidates.
|
|
19
|
+
4. Combine the rankings with weighted reciprocal-rank fusion (RRF). Decayed usage
|
|
20
|
+
can reinforce an already relevant candidate, not invent relevance.
|
|
21
|
+
5. Apply negative hints and a small cluster-diversity adjustment, then select
|
|
22
|
+
within adaptive count and character budgets. Explicitly protected skills have
|
|
23
|
+
their own guarantee; they can exceed ordinary prefetch budgets.
|
|
24
|
+
|
|
25
|
+
The stable all-name index is separate from this ranking. A miss in prefetch does
|
|
26
|
+
not remove a skill from the library or its exact resolver.
|
|
27
|
+
|
|
28
|
+
## Contextual and behavioral skills
|
|
29
|
+
|
|
30
|
+
Literal similarity is not the whole job. A task that selects a language specialist
|
|
31
|
+
can also need architecture or test-first discipline without naming either.
|
|
32
|
+
An optional, name-owned `activation` entry in the profile records two tags:
|
|
33
|
+
|
|
34
|
+
- `kind`: `specialist`, `workflow`, or `behavioral`.
|
|
35
|
+
- `tasks`: a small subset of shared activities such as `software-change`,
|
|
36
|
+
`software-review`, `docs-write`, or `research`.
|
|
37
|
+
|
|
38
|
+
Explicit `/skill-optimizer init` assigns these from each skill's description.
|
|
39
|
+
The fixed activity vocabulary works across init batches: no full-catalog model
|
|
40
|
+
request or runtime inference call is needed. Old profiles still work; rerun init
|
|
41
|
+
to regenerate them with the new tags. That regeneration uses your selected model
|
|
42
|
+
and can incur cost. This update does not run it automatically.
|
|
43
|
+
|
|
44
|
+
The router first selects a positively matched, tagged specialist. It can then
|
|
45
|
+
reserve **one ordinary prefetch slot** for a workflow or behavioral skill sharing
|
|
46
|
+
an activity tag, even with no lexical match to the user's words. This is one-hop
|
|
47
|
+
support, not recursive expansion. It retains the primary specialist, honors
|
|
48
|
+
exclusions and negative hints, and uses the same count and character budget.
|
|
49
|
+
Already selected skills are not duplicated; a one-slot budget leaves only the
|
|
50
|
+
primary. An explicit exact-name selection takes precedence.
|
|
51
|
+
|
|
52
|
+
Contextual search entries carry the reason `contextual`, without pretending that
|
|
53
|
+
they had a lexical score. Direct search ordering is retained. `clusters` represent
|
|
54
|
+
confusable substitutes only; complementary phases are not penalized as substitutes.
|
|
55
|
+
|
|
56
|
+
The `behavioral` tag does **not** mean always-on. Truly universal disciplines still
|
|
57
|
+
belong in `critical`; task-specific design, debugging and testing do not. An empty
|
|
58
|
+
or unrelated request cannot trigger support just from its tags or usage history.
|
|
59
|
+
|
|
60
|
+
These are routing hints, not a dependency solver or proof that the LLM classified
|
|
61
|
+
every skill correctly. Activity tags are deliberately coarse and the router does
|
|
62
|
+
not infer task phases from tool output or inspect repository files. The contextual
|
|
63
|
+
tests use reviewed fixtures; they do not establish generated-profile quality.
|
|
64
|
+
|
|
65
|
+
The typo fallback is deliberately conservative: it runs only when fewer than
|
|
66
|
+
60% of query terms are covered by the catalog or its aliases. A typo surrounded
|
|
67
|
+
by enough known words can therefore be missed. This is a known quality tradeoff,
|
|
68
|
+
not a promise to repair every misspelling.
|
|
69
|
+
|
|
70
|
+
## What the algorithm comparison can tell us
|
|
71
|
+
|
|
72
|
+
Run the diagnostic fixture locally:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
npm run bench:ranking
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
It compares BM25F-only ordering, AUTO without fuzzy candidates, and full AUTO on
|
|
79
|
+
the same 14 synthetic skills, 18 labeled positive queries and four no-signal
|
|
80
|
+
queries. It prints Recall@1, Recall@5, mean reciprocal rank and no-signal
|
|
81
|
+
promotions. It also rejects the regression where `git` was treated as an exact
|
|
82
|
+
name inside `digital`.
|
|
83
|
+
|
|
84
|
+
A separate two-case support diagnostic checks architecture and behavioral guidance
|
|
85
|
+
without literal query overlap, and verifies no promotion on unrelated input. It
|
|
86
|
+
uses reviewed activation tags, not init-generated labels, and does not enter the
|
|
87
|
+
18-query lexical comparison below.
|
|
88
|
+
|
|
89
|
+
These are hand-authored diagnostics, **not a held-out or production dataset**.
|
|
90
|
+
They include typos and nearby tasks, but no generated profile, learned usage or
|
|
91
|
+
cluster-diversity labels. The BM25F baseline reuses AUTO's lexical component; it is
|
|
92
|
+
not an independent implementation. Results can reveal a tradeoff or regression,
|
|
93
|
+
not establish the best router for all users. Keep the denominators and report
|
|
94
|
+
weaker results as well as improvements.
|
|
95
|
+
|
|
96
|
+
Local diagnostic run, 2026-09-28 (working tree, Node 26.3.0, Windows x64):
|
|
97
|
+
|
|
98
|
+
| Variant | Recall@1 | Recall@5 | MRR | No-signal promotions |
|
|
99
|
+
| --- | --- | --- | --- | --- |
|
|
100
|
+
| BM25F only | 17/18 (94.4%) | 17/18 (94.4%) | 0.944 | 0/4 |
|
|
101
|
+
| AUTO without fuzzy | 15/18 (83.3%) | 17/18 (94.4%) | 0.889 | 0/4 |
|
|
102
|
+
| AUTO | 16/18 (88.9%) | 18/18 (100%) | 0.944 | 0/4 |
|
|
103
|
+
|
|
104
|
+
The hybrid recovers the final relevant item within five results, but does not win
|
|
105
|
+
top-one accuracy. This is why no new weight set or replacement is justified by
|
|
106
|
+
this fixture alone. Re-run the command for current-tree results.
|
|
107
|
+
|
|
108
|
+
| Candidate | Why keep or consider it | What is still missing |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| BM25F + exact/fuzzy + RRF | Local, reproducible, no training labels or score calibration required | Representative relevance labels for weight/confidence tuning |
|
|
111
|
+
| BM25F alone | Simpler comparator; can lead on straightforward lexical queries | Name-typo recovery and broader intent coverage |
|
|
112
|
+
| Calibrated score fusion | Retains score magnitude that rank fusion discards | A separate tuning set and held-out evaluation |
|
|
113
|
+
| Embeddings / reranking | Could help paraphrases with little shared vocabulary | Measured quality gain, latency and privacy/cost tradeoffs; not implemented or benchmarked here |
|
|
114
|
+
|
|
115
|
+
Do not tune defaults merely to win this tiny fixture. Before replacing the router,
|
|
116
|
+
use separately reviewed relevance labels, keep tuning and evaluation cases
|
|
117
|
+
separate, compare identical catalogs and budgets, and measure both false
|
|
118
|
+
promotions and missed skills. The [private evaluation protocol](../BENCHMARK.md)
|
|
119
|
+
defines the broader gates; running it is an explicit operation.
|
|
120
|
+
|
|
121
|
+
## Performance versus quality
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npm run bench:perf
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
This measures `optimizePayload` with 201 messages and 284 / 2,000 synthetic skills.
|
|
128
|
+
Each catalog gets one cold call and 30 warm calls; mean, p50 and p95 are reported
|
|
129
|
+
along with runtime/platform. Warm calls reuse the same source request and query,
|
|
130
|
+
not an already transformed payload. The size check must remain deterministic.
|
|
131
|
+
|
|
132
|
+
This is request-preparation latency, **not Pi startup, terminal redraw or model
|
|
133
|
+
latency**. There is no universal millisecond gate: compare repeated runs on the
|
|
134
|
+
same host. Index caching should remove repeated analysis without changing scores,
|
|
135
|
+
and caches must remain bounded and invalidate when catalog/profile content changes.
|
|
136
|
+
|
|
137
|
+
## Research behind the choices
|
|
138
|
+
|
|
139
|
+
BM25F combines weighted document fields before term saturation, rather than
|
|
140
|
+
summing independently saturated field scores. The original formulation is
|
|
141
|
+
[Robertson, Zaragoza and Taylor, 2004](https://www.researchgate.net/publication/221613382_Simple_BM25_extension_to_multiple_weighted_fields)
|
|
142
|
+
(author-uploaded paper).
|
|
143
|
+
|
|
144
|
+
RRF combines positions rather than requiring compatible score scales. Its common
|
|
145
|
+
constant of 60 comes from experiments in
|
|
146
|
+
[Cormack, Clarke and Buettcher, 2009](https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf),
|
|
147
|
+
not a universal optimum for skill catalogs.
|
|
148
|
+
|
|
149
|
+
Later work found tuned convex score fusion stronger than RRF in its evaluated
|
|
150
|
+
lexical/semantic settings, and found RRF sensitive to its parameters. See
|
|
151
|
+
[Bruch, Gai and Ingber, 2023](https://arxiv.org/abs/2210.11934).
|
|
152
|
+
Those experiments do not directly validate or invalidate this skill router.
|
|
153
|
+
The design judgment here is to retain the local hybrid until project-specific
|
|
154
|
+
evaluation justifies a replacement, while fixing demonstrated correctness and
|
|
155
|
+
implementation-cost problems now.
|
package/package.json
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aeondave/pi-skill-optimizer",
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Less repeated context for Pi: local skill routing, on-demand tools, and recoverable output reduction.",
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"keywords": [
|
|
11
|
+
"pi-package",
|
|
12
|
+
"pi-extension",
|
|
13
|
+
"optimization",
|
|
14
|
+
"tokens",
|
|
15
|
+
"skills",
|
|
16
|
+
"system-prompt",
|
|
17
|
+
"tool-output",
|
|
18
|
+
"mcp"
|
|
19
|
+
],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"author": "AeonDave",
|
|
22
|
+
"homepage": "https://github.com/AeonDave/pi-skill-optimizer#readme",
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/AeonDave/pi-skill-optimizer.git"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/AeonDave/pi-skill-optimizer/issues"
|
|
29
|
+
},
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=26.3.0"
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"src",
|
|
35
|
+
"docs/ARCHITECTURE.md",
|
|
36
|
+
"docs/CONFIGURATION.md",
|
|
37
|
+
"docs/RANKING.md",
|
|
38
|
+
"assets/banner.png",
|
|
39
|
+
"README.md",
|
|
40
|
+
"BENCHMARK.md",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"CHANGELOG.md"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"typecheck": "tsc --noEmit",
|
|
46
|
+
"test": "node --import tsx --test test/request.test.ts test/history.test.ts test/skills.test.ts test/skill-loader.test.ts test/tools.test.ts test/optimize.test.ts test/profile.test.ts test/activation.test.ts test/generate.test.ts test/init.test.ts test/init-readiness.test.ts test/config.test.ts test/fuzz.test.ts test/output.test.ts test/output-recovery.test.ts test/runtime.test.ts test/host-runtime.test.ts test/stats.test.ts test/usage.test.ts test/persistence.test.ts test/corpus.test.ts test/evaluation.test.ts test/luna.test.ts test/release-metadata.test.ts",
|
|
47
|
+
"measure": "node --import tsx scripts/measure.ts",
|
|
48
|
+
"bench": "node --import tsx scripts/bench.ts",
|
|
49
|
+
"bench:perf": "node --import tsx scripts/perf-bench.ts",
|
|
50
|
+
"bench:ranking": "node --import tsx scripts/ranking-bench.ts",
|
|
51
|
+
"bench:output": "node --import tsx scripts/output-bench.ts",
|
|
52
|
+
"corpus:build": "node --import tsx scripts/build-real-corpus.ts",
|
|
53
|
+
"bench:real": "node --import tsx scripts/evaluate-real-corpus.ts"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@earendil-works/pi-ai": "*",
|
|
57
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
58
|
+
"typebox": "*"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"@earendil-works/pi-ai": {
|
|
62
|
+
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"@earendil-works/pi-coding-agent": {
|
|
65
|
+
"optional": true
|
|
66
|
+
},
|
|
67
|
+
"typebox": {
|
|
68
|
+
"optional": true
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"devDependencies": {
|
|
72
|
+
"@earendil-works/pi-ai": "^0.87.1",
|
|
73
|
+
"@earendil-works/pi-coding-agent": "^0.87.1",
|
|
74
|
+
"@types/node": "^26.6.3",
|
|
75
|
+
"tsx": "^4.23.13",
|
|
76
|
+
"typebox": "^1.3.34",
|
|
77
|
+
"typescript": "^5.9.3"
|
|
78
|
+
},
|
|
79
|
+
"pi": {
|
|
80
|
+
"extensions": [
|
|
81
|
+
"./src/index.ts"
|
|
82
|
+
],
|
|
83
|
+
"image": "https://raw.githubusercontent.com/AeonDave/pi-skill-optimizer/v3.0.0/assets/banner.png"
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const ACTIVATION_TASKS = [
|
|
2
|
+
"software-design",
|
|
3
|
+
"software-change",
|
|
4
|
+
"software-debug",
|
|
5
|
+
"software-test",
|
|
6
|
+
"software-review",
|
|
7
|
+
"software-read",
|
|
8
|
+
"docs-write",
|
|
9
|
+
"docs-review",
|
|
10
|
+
"data-analysis",
|
|
11
|
+
"research",
|
|
12
|
+
"operations",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export type ActivationTask = (typeof ACTIVATION_TASKS)[number];
|
|
16
|
+
export type SkillActivationKind = "specialist" | "workflow" | "behavioral";
|
|
17
|
+
|
|
18
|
+
export interface SkillActivation {
|
|
19
|
+
kind: SkillActivationKind;
|
|
20
|
+
tasks: ActivationTask[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const ACTIVATION_KINDS: ReadonlySet<string> = new Set(["specialist", "workflow", "behavioral"]);
|
|
24
|
+
const ACTIVATION_TASK_SET: ReadonlySet<string> = new Set(ACTIVATION_TASKS);
|
|
25
|
+
|
|
26
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
27
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isActivationKind(value: unknown): value is SkillActivationKind {
|
|
31
|
+
return typeof value === "string" && ACTIVATION_KINDS.has(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Keep only exact names and validated activation tags from untrusted generated JSON. */
|
|
35
|
+
export function normalizeActivation(value: unknown): Record<string, SkillActivation> {
|
|
36
|
+
const normalized: Record<string, SkillActivation> = {};
|
|
37
|
+
if (!isRecord(value)) return normalized;
|
|
38
|
+
|
|
39
|
+
for (const [rawOwner, rawActivation] of Object.entries(value)) {
|
|
40
|
+
const owner = rawOwner.trim();
|
|
41
|
+
if (!owner || Object.prototype.hasOwnProperty.call(normalized, owner) || !isRecord(rawActivation)) continue;
|
|
42
|
+
if (!isActivationKind(rawActivation.kind) || !Array.isArray(rawActivation.tasks)) continue;
|
|
43
|
+
|
|
44
|
+
const tasks: ActivationTask[] = [];
|
|
45
|
+
const seen = new Set<ActivationTask>();
|
|
46
|
+
for (const task of rawActivation.tasks) {
|
|
47
|
+
if (typeof task !== "string" || !ACTIVATION_TASK_SET.has(task)) continue;
|
|
48
|
+
const activationTask = task as ActivationTask;
|
|
49
|
+
if (seen.has(activationTask)) continue;
|
|
50
|
+
seen.add(activationTask);
|
|
51
|
+
tasks.push(activationTask);
|
|
52
|
+
}
|
|
53
|
+
if (tasks.length === 0) continue;
|
|
54
|
+
|
|
55
|
+
Object.defineProperty(normalized, owner, {
|
|
56
|
+
value: { kind: rawActivation.kind, tasks },
|
|
57
|
+
enumerable: true,
|
|
58
|
+
configurable: true,
|
|
59
|
+
writable: true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Return one-hop workflow/behavioral companions with shared task tags. */
|
|
67
|
+
export function contextualSkillNames(
|
|
68
|
+
activation: Readonly<Record<string, SkillActivation>> | undefined,
|
|
69
|
+
primaryName: string | undefined,
|
|
70
|
+
): string[] {
|
|
71
|
+
if (!activation || !primaryName || !Object.prototype.hasOwnProperty.call(activation, primaryName)) return [];
|
|
72
|
+
const primary = activation[primaryName];
|
|
73
|
+
if (!primary || !Array.isArray(primary.tasks) || primary.tasks.length === 0) return [];
|
|
74
|
+
|
|
75
|
+
const primaryTasks = new Set(primary.tasks);
|
|
76
|
+
const candidates: Array<{ name: string; overlap: number }> = [];
|
|
77
|
+
for (const [name, candidate] of Object.entries(activation)) {
|
|
78
|
+
if (name === primaryName || (candidate.kind !== "workflow" && candidate.kind !== "behavioral")) continue;
|
|
79
|
+
const candidateTasks = new Set(candidate.tasks);
|
|
80
|
+
let overlap = 0;
|
|
81
|
+
for (const task of primaryTasks) if (candidateTasks.has(task)) overlap += 1;
|
|
82
|
+
if (overlap > 0) candidates.push({ name, overlap });
|
|
83
|
+
}
|
|
84
|
+
candidates.sort((left, right) =>
|
|
85
|
+
right.overlap - left.overlap || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0),
|
|
86
|
+
);
|
|
87
|
+
return candidates.map(({ name }) => name);
|
|
88
|
+
}
|
package/src/aliases.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
export type QueryAliasMap = ReadonlyMap<string, readonly string[]>;
|
|
2
|
+
export type AliasRecord = Record<string, string[]>;
|
|
3
|
+
|
|
4
|
+
// Static, reviewed vocabulary only. Catalog filtering below prevents an alias
|
|
5
|
+
// from adding terms that do not exist in the active catalog.
|
|
6
|
+
export const CANDIDATE_QUERY_ALIASES: Readonly<Record<string, readonly string[]>> = Object.freeze({
|
|
7
|
+
ad: ["active", "directory", "kerberos", "ldap"],
|
|
8
|
+
adb: ["android", "mobile"],
|
|
9
|
+
apk: ["android", "mobile", "application"],
|
|
10
|
+
authn: ["authentication", "identity"],
|
|
11
|
+
authz: ["authorization", "permission"],
|
|
12
|
+
aws: ["cloud", "amazon"],
|
|
13
|
+
azure: ["cloud", "microsoft"],
|
|
14
|
+
binary: ["reverse", "reversing", "exploitation"],
|
|
15
|
+
bug: ["debug", "debugging", "defect"],
|
|
16
|
+
ci: ["cicd", "pipeline"],
|
|
17
|
+
cli: ["command", "shell"],
|
|
18
|
+
container: ["docker", "kubernetes"],
|
|
19
|
+
crack: ["cracking", "password", "hash"],
|
|
20
|
+
ctf: ["challenge", "exploit"],
|
|
21
|
+
db: ["database", "sql"],
|
|
22
|
+
debug: ["debugging", "gdb", "windbg"],
|
|
23
|
+
deploy: ["deployment", "release"],
|
|
24
|
+
docker: ["container"],
|
|
25
|
+
forensic: ["forensics", "artifact"],
|
|
26
|
+
fuzz: ["fuzzer", "fuzzing"],
|
|
27
|
+
git: ["github", "repository"],
|
|
28
|
+
incident: ["forensics", "response"],
|
|
29
|
+
js: ["javascript", "typescript", "node"],
|
|
30
|
+
k8s: ["kubernetes", "container"],
|
|
31
|
+
malware: ["reverse", "analysis"],
|
|
32
|
+
mobile: ["android", "ios", "application"],
|
|
33
|
+
network: ["packet", "traffic", "protocol"],
|
|
34
|
+
osint: ["intelligence", "reconnaissance"],
|
|
35
|
+
pentest: ["security", "assessment"],
|
|
36
|
+
plugin: ["extension"],
|
|
37
|
+
pr: ["pull", "request", "review"],
|
|
38
|
+
pwn: ["binary", "exploitation"],
|
|
39
|
+
py: ["python"],
|
|
40
|
+
recon: ["reconnaissance", "discovery", "enumeration"],
|
|
41
|
+
recover: ["recovery"],
|
|
42
|
+
refactor: ["architecture", "design"],
|
|
43
|
+
reverse: ["reversing", "analysis"],
|
|
44
|
+
rtk: ["output", "compression"],
|
|
45
|
+
sqli: ["sql", "injection"],
|
|
46
|
+
test: ["testing", "tests"],
|
|
47
|
+
token: ["context", "prompt"],
|
|
48
|
+
tunnel: ["pivot", "proxy"],
|
|
49
|
+
vuln: ["vulnerability", "security"],
|
|
50
|
+
web: ["http", "browser"],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const OVERBROAD_TARGETS = new Set(["app", "code", "file", "tool", "use", "workflow"]);
|
|
54
|
+
|
|
55
|
+
function normalizeToken(value: string): string {
|
|
56
|
+
return value
|
|
57
|
+
.normalize("NFKD")
|
|
58
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
59
|
+
.trim()
|
|
60
|
+
.toLocaleLowerCase("en-US")
|
|
61
|
+
.replace(/[^\p{L}\p{N}#+.-]+/gu, "-")
|
|
62
|
+
.replace(/^-+|-+$/g, "");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function normalizeAliasRecord(value: unknown): AliasRecord {
|
|
66
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
67
|
+
const out: AliasRecord = {};
|
|
68
|
+
for (const [rawSource, rawTargets] of Object.entries(value)) {
|
|
69
|
+
const source = normalizeToken(rawSource);
|
|
70
|
+
if (!source || !Array.isArray(rawTargets)) continue;
|
|
71
|
+
const targets = [...new Set(rawTargets
|
|
72
|
+
.filter((target): target is string => typeof target === "string")
|
|
73
|
+
.map(normalizeToken)
|
|
74
|
+
.filter((target) => target && target !== source && !OVERBROAD_TARGETS.has(target)))];
|
|
75
|
+
if (targets.length > 0) out[source] = targets;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Build an immutable alias view scoped to one catalog. */
|
|
81
|
+
export function buildCatalogAliases(
|
|
82
|
+
hasTerm: (term: string) => boolean,
|
|
83
|
+
extraCandidates: AliasRecord = {},
|
|
84
|
+
): QueryAliasMap {
|
|
85
|
+
const candidates = { ...CANDIDATE_QUERY_ALIASES, ...normalizeAliasRecord(extraCandidates) };
|
|
86
|
+
const aliases = new Map<string, readonly string[]>();
|
|
87
|
+
for (const [source, rawTargets] of Object.entries(candidates)) {
|
|
88
|
+
const targets = [...new Set(rawTargets
|
|
89
|
+
.map(normalizeToken)
|
|
90
|
+
.filter((target) => target && !OVERBROAD_TARGETS.has(target) && hasTerm(target)))];
|
|
91
|
+
if (targets.length > 0) aliases.set(normalizeToken(source), Object.freeze(targets));
|
|
92
|
+
}
|
|
93
|
+
return aliases;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function expandQueryTokens(tokens: readonly string[], aliases: QueryAliasMap): string[] {
|
|
97
|
+
const expanded: string[] = [];
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
for (const rawToken of tokens) {
|
|
100
|
+
const token = normalizeToken(rawToken);
|
|
101
|
+
if (!token || seen.has(token)) continue;
|
|
102
|
+
seen.add(token);
|
|
103
|
+
expanded.push(token);
|
|
104
|
+
for (const target of aliases.get(token) ?? []) {
|
|
105
|
+
if (seen.has(target)) continue;
|
|
106
|
+
seen.add(target);
|
|
107
|
+
expanded.push(target);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return expanded;
|
|
111
|
+
}
|