portable-agent-layer 0.63.2 → 0.64.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 +7 -3
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/skills/analyze-pdf/SKILL.md +11 -0
- package/assets/skills/analyze-youtube/SKILL.md +12 -0
- package/assets/skills/consulting-report/SKILL.md +9 -0
- package/assets/skills/council/SKILL.md +32 -0
- package/assets/skills/create-pdf/SKILL.md +13 -0
- package/assets/skills/create-skill/SKILL.md +14 -2
- package/assets/skills/create-skill/authoring-guide.md +10 -1
- package/assets/skills/create-subagent/SKILL.md +22 -4
- package/assets/skills/{research → deep-research}/SKILL.md +32 -1
- package/assets/skills/entities/SKILL.md +10 -0
- package/assets/skills/extract-wisdom/SKILL.md +12 -0
- package/assets/skills/first-principles/SKILL.md +8 -0
- package/assets/skills/frontend-design/SKILL.md +14 -0
- package/assets/skills/fyzz-chat-api/SKILL.md +10 -0
- package/assets/skills/humanize/SKILL.md +13 -1
- package/assets/skills/opinion/SKILL.md +11 -0
- package/assets/skills/pal-analyze/SKILL.md +11 -0
- package/assets/skills/pal-reflect/SKILL.md +10 -0
- package/assets/skills/playwright/SKILL.md +13 -0
- package/assets/skills/presentation/SKILL.md +12 -0
- package/assets/skills/projects/SKILL.md +16 -0
- package/assets/skills/reflect/SKILL.md +13 -0
- package/assets/skills/telos/SKILL.md +12 -0
- package/assets/skills/think/SKILL.md +9 -0
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +3 -0
- package/assets/templates/pal-settings.json +1 -0
- package/package.json +1 -1
- package/src/cli/index.ts +2 -2
- package/src/cli/skill.ts +47 -3
- package/src/hooks/handlers/inject-retrieval.ts +41 -27
- package/src/hooks/lib/readme-sync.ts +30 -10
- package/src/hooks/lib/skill-match.ts +129 -0
- package/src/hooks/lib/skill-triggers.ts +82 -0
- package/src/targets/lib.ts +60 -3
- package/src/targets/opencode/plugin.ts +2 -6
- package/src/tools/skill-doctor.ts +130 -5
- package/assets/skills/review/SKILL.md +0 -20
- package/assets/skills/summarize/SKILL.md +0 -16
- /package/assets/skills/{research → deep-research}/tools/gemini-search.ts +0 -0
- /package/assets/skills/{research → deep-research}/tools/grok-search.ts +0 -0
- /package/assets/skills/{research → deep-research}/tools/perplexity-search.ts +0 -0
|
@@ -213,6 +213,9 @@ Brief description.
|
|
|
213
213
|
|
|
214
214
|
┌─────────────────────┐
|
|
215
215
|
│ User Prompt Submit │──► UserPromptOrchestrator.ts
|
|
216
|
+
│ │ - Prompt context (one merged system-reminder):
|
|
217
|
+
│ │ contextual steering, skill trigger matches,
|
|
218
|
+
│ │ prior-lesson retrieval
|
|
216
219
|
│ │ - Rating capture (explicit/implicit)
|
|
217
220
|
│ │ - Session naming (first prompt)
|
|
218
221
|
└─────────────────────┘
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* doctor Check prerequisites and system health
|
|
18
18
|
* usage Summarize token usage and cost
|
|
19
19
|
* skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
|
|
20
|
-
* skill doctor <name>
|
|
20
|
+
* skill doctor <name|--all> Evaluate one skill, or every installed skill, against the authoring best practices
|
|
21
21
|
* subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
|
|
22
22
|
* subagent doctor <name> Evaluate a subagent against the authoring best practices
|
|
23
23
|
* debug [on|off] Enable / disable verbose hook debug logging
|
|
@@ -283,7 +283,7 @@ function showHelp() {
|
|
|
283
283
|
pal cli knowledge <sub> [args] Query & manage the knowledge store
|
|
284
284
|
(search · graph · stats · hubs · find · show · add · ls)
|
|
285
285
|
pal cli skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
|
|
286
|
-
pal cli skill doctor <name>
|
|
286
|
+
pal cli skill doctor <name|--all> Evaluate one skill, or every installed skill
|
|
287
287
|
pal cli skill author-model Print the flagship model that authors skills for the active agent
|
|
288
288
|
pal cli subagent link <name> Install a personal ~/.pal/agents/<name>.md into installed agents
|
|
289
289
|
pal cli subagent doctor <name> Evaluate a subagent against the authoring best practices
|
package/src/cli/skill.ts
CHANGED
|
@@ -5,16 +5,59 @@
|
|
|
5
5
|
* every installed agent so it is discoverable.
|
|
6
6
|
* pal cli skill doctor <name> Evaluate ~/.pal/skills/<name>/ against the
|
|
7
7
|
* skill-authoring best practices.
|
|
8
|
+
* pal cli skill doctor --all Evaluate every installed skill, one line each.
|
|
8
9
|
* pal cli skill author-model Print the flagship model configured to author
|
|
9
10
|
* skills for the active agent (empty if none).
|
|
10
11
|
*/
|
|
11
12
|
|
|
13
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
12
14
|
import { resolve } from "node:path";
|
|
13
15
|
import { getActiveAgent } from "../hooks/lib/agent";
|
|
14
16
|
import { flagshipAuthorModel } from "../hooks/lib/models";
|
|
15
17
|
import { palHome } from "../hooks/lib/paths";
|
|
16
18
|
import { linkPersonalSkill, log } from "../targets/lib";
|
|
17
|
-
import { formatReport, lintSkill } from "../tools/skill-doctor";
|
|
19
|
+
import { formatReport, formatSummary, lintSkill } from "../tools/skill-doctor";
|
|
20
|
+
|
|
21
|
+
/** Entry names under ~/.pal/skills/, sorted; dangling links included. */
|
|
22
|
+
function skillEntries(dir: string): string[] {
|
|
23
|
+
if (!existsSync(dir)) return [];
|
|
24
|
+
return readdirSync(dir).sort();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A listed entry that does not exist can only be a symlink whose target is gone. */
|
|
28
|
+
function isDanglingLink(path: string): boolean {
|
|
29
|
+
return !existsSync(path);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Lint every installed skill, one summary line each. Exits 1 if any has errors. */
|
|
33
|
+
function doctorAll(): number {
|
|
34
|
+
const dir = resolve(palHome(), "skills");
|
|
35
|
+
const entries = skillEntries(dir);
|
|
36
|
+
|
|
37
|
+
for (const name of entries.filter((n) => isDanglingLink(resolve(dir, n)))) {
|
|
38
|
+
log.warn(`Skipped ${name}: link target is gone — run 'pal cli install' to prune it`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const names = entries.filter((n) => {
|
|
42
|
+
const path = resolve(dir, n);
|
|
43
|
+
return !isDanglingLink(path) && statSync(path).isDirectory();
|
|
44
|
+
});
|
|
45
|
+
if (names.length === 0) {
|
|
46
|
+
log.warn(`No skills found in ${dir}`);
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const reports = names.map((n) => lintSkill(resolve(dir, n)));
|
|
51
|
+
for (const report of reports) console.log(formatSummary(report));
|
|
52
|
+
|
|
53
|
+
const failing = reports.filter((r) => r.errors > 0).length;
|
|
54
|
+
const warning = reports.filter((r) => r.errors === 0 && r.warnings > 0).length;
|
|
55
|
+
const clean = reports.length - failing - warning;
|
|
56
|
+
console.log(
|
|
57
|
+
`\n${reports.length} skills — ${failing} failing, ${warning} with warnings, ${clean} clean`
|
|
58
|
+
);
|
|
59
|
+
return failing > 0 ? 1 : 0;
|
|
60
|
+
}
|
|
18
61
|
|
|
19
62
|
export async function runSkill(args: string[]): Promise<number> {
|
|
20
63
|
const [sub, name] = args;
|
|
@@ -26,8 +69,9 @@ export async function runSkill(args: string[]): Promise<number> {
|
|
|
26
69
|
}
|
|
27
70
|
|
|
28
71
|
if (sub === "doctor") {
|
|
72
|
+
if (name === "--all") return doctorAll();
|
|
29
73
|
if (!name) {
|
|
30
|
-
log.error("Usage: pal cli skill doctor <name>");
|
|
74
|
+
log.error("Usage: pal cli skill doctor <name|--all>");
|
|
31
75
|
return 1;
|
|
32
76
|
}
|
|
33
77
|
const report = lintSkill(resolve(palHome(), "skills", name));
|
|
@@ -60,6 +104,6 @@ export async function runSkill(args: string[]): Promise<number> {
|
|
|
60
104
|
}
|
|
61
105
|
}
|
|
62
106
|
|
|
63
|
-
log.error("Usage: pal cli skill <link|doctor|author-model> [name]");
|
|
107
|
+
log.error("Usage: pal cli skill <link|doctor|author-model> [name|--all]");
|
|
64
108
|
return 1;
|
|
65
109
|
}
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Called from UserPromptOrchestrator. Reads the retrieval index, ranks the prompt
|
|
5
5
|
* against the corpus, prints a `<system-reminder>` block to stdout (Claude Code
|
|
6
|
-
* prepends UserPromptSubmit hook stdout to the prompt). Fail-closed: any error
|
|
7
|
-
*
|
|
6
|
+
* prepends UserPromptSubmit hook stdout to the prompt). Fail-closed: any error
|
|
7
|
+
* produces empty output, never blocks the prompt.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { isCodex, isCursor } from "../lib/agent";
|
|
@@ -12,35 +12,41 @@ import { logDebug, logError } from "../lib/log";
|
|
|
12
12
|
import { runRetrieval } from "../lib/retrieval";
|
|
13
13
|
import { ensureIndex } from "../lib/retrieval-index";
|
|
14
14
|
import { isEnabled } from "../lib/settings";
|
|
15
|
+
import { getSkillReminder } from "../lib/skill-match";
|
|
15
16
|
import { getSteeringReminder } from "../lib/steering";
|
|
16
17
|
|
|
17
|
-
const
|
|
18
|
+
const BUDGET_MS = 250;
|
|
18
19
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
/** Run sync work on the prompt path, containing any throw. A synchronous call cannot
|
|
21
|
+
* be preempted on a single thread, so the budget is measured and logged, never
|
|
22
|
+
* enforced — an overrun still returns its result rather than being discarded.
|
|
23
|
+
* @lintignore exported for test/inject-retrieval.test.ts */
|
|
24
|
+
export function withinBudget<T>(work: () => T, ms: number): T | null {
|
|
25
|
+
const started = performance.now();
|
|
26
|
+
try {
|
|
27
|
+
return work();
|
|
28
|
+
} catch (err) {
|
|
29
|
+
logError("inject-retrieval", err);
|
|
30
|
+
return null;
|
|
31
|
+
} finally {
|
|
32
|
+
const elapsed = performance.now() - started;
|
|
33
|
+
if (elapsed > ms) {
|
|
34
|
+
logDebug("inject-retrieval", `over budget: ${elapsed.toFixed(0)}ms > ${ms}ms`);
|
|
30
35
|
}
|
|
31
|
-
}
|
|
36
|
+
}
|
|
32
37
|
}
|
|
33
38
|
|
|
34
|
-
/** Returns the retrieval reminder string, or null if nothing to inject.
|
|
39
|
+
/** Returns the retrieval reminder string, or null if nothing to inject.
|
|
40
|
+
* @lintignore exercised directly by test/inject-retrieval.test.ts */
|
|
35
41
|
export async function getRetrievalReminder(prompt: string): Promise<string | null> {
|
|
36
42
|
if (!prompt?.trim()) return null;
|
|
37
43
|
if (!isEnabled("learningInjection")) return null;
|
|
38
44
|
|
|
39
|
-
const result =
|
|
45
|
+
const result = withinBudget(() => {
|
|
40
46
|
const index = ensureIndex();
|
|
41
47
|
if (index.corpusSize === 0) return null;
|
|
42
48
|
return runRetrieval(prompt, index, process.cwd());
|
|
43
|
-
},
|
|
49
|
+
}, BUDGET_MS);
|
|
44
50
|
|
|
45
51
|
if (!result?.reminder) return null;
|
|
46
52
|
|
|
@@ -73,15 +79,23 @@ function writeForAgent(reminder: string): void {
|
|
|
73
79
|
}
|
|
74
80
|
}
|
|
75
81
|
|
|
76
|
-
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
82
|
+
/** Merge every prompt-time source — contextual steering, skill matches, prior-lesson
|
|
83
|
+
* retrieval — into one payload, or null when none of them produced anything.
|
|
84
|
+
* @lintignore dynamically imported by opencode plugin */
|
|
85
|
+
export async function getPromptContext(prompt: string): Promise<string | null> {
|
|
86
|
+
const parts = [
|
|
87
|
+
getSteeringReminder(prompt),
|
|
88
|
+
getSkillReminder(prompt),
|
|
89
|
+
await getRetrievalReminder(prompt),
|
|
90
|
+
].filter((p): p is string => Boolean(p));
|
|
91
|
+
|
|
92
|
+
return parts.length > 0 ? parts.join("\n\n") : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Gather all prompt-time context and do the one per-agent write. Returns the
|
|
96
|
+
* combined reminder that was injected, or null if there was nothing to inject. */
|
|
79
97
|
export async function injectPromptContext(prompt: string): Promise<string | null> {
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
const parts = [steering, retrieval].filter((p): p is string => Boolean(p));
|
|
83
|
-
if (parts.length === 0) return null;
|
|
84
|
-
const combined = parts.join("\n\n");
|
|
85
|
-
writeForAgent(combined);
|
|
98
|
+
const combined = await getPromptContext(prompt);
|
|
99
|
+
if (combined) writeForAgent(combined);
|
|
86
100
|
return combined;
|
|
87
101
|
}
|
|
@@ -79,15 +79,30 @@ function extractEnvVars(): string[] {
|
|
|
79
79
|
return [...vars];
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
82
|
+
/**
|
|
83
|
+
* Names of the skills PAL ships — one directory per skill, each holding a
|
|
84
|
+
* SKILL.md. Reading the directory rather than loose `.md` files matters: the
|
|
85
|
+
* folder-per-skill layout is what the runtime loads, and matching on files
|
|
86
|
+
* silently yields nothing, which makes every skill check pass vacuously.
|
|
87
|
+
*/
|
|
88
|
+
export function shippedSkillNames(): string[] {
|
|
89
|
+
const skillsDir = resolve(palPkg(), "assets", "skills");
|
|
86
90
|
if (!existsSync(skillsDir)) return [];
|
|
87
91
|
|
|
88
|
-
return readdirSync(skillsDir)
|
|
89
|
-
.filter((
|
|
90
|
-
.map((
|
|
92
|
+
return readdirSync(skillsDir, { withFileTypes: true })
|
|
93
|
+
.filter((e) => e.isDirectory() && existsSync(resolve(skillsDir, e.name, "SKILL.md")))
|
|
94
|
+
.map((e) => e.name)
|
|
95
|
+
.sort();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Skill names the README's "## Skills" table claims PAL ships. Scoped to that
|
|
100
|
+
* one section: other tables list commands and agents in the same row shape, and
|
|
101
|
+
* matching them would report every command as a retired skill.
|
|
102
|
+
*/
|
|
103
|
+
function documentedSkillNames(readme: string): string[] {
|
|
104
|
+
const section = /^## Skills$([\s\S]*?)(?=^## )/m.exec(readme)?.[1] ?? "";
|
|
105
|
+
return Array.from(section.matchAll(/^\|\s*`([a-z0-9-]+)`\s*\|/gm), (m) => m[1]);
|
|
91
106
|
}
|
|
92
107
|
|
|
93
108
|
/** Validate that README.md documents all code surfaces. */
|
|
@@ -118,12 +133,17 @@ export function validateReadmeSync(): SyncResult {
|
|
|
118
133
|
}
|
|
119
134
|
}
|
|
120
135
|
|
|
121
|
-
// Check skills —
|
|
122
|
-
const
|
|
123
|
-
const undocumentedSkills =
|
|
136
|
+
// Check skills — every shipped skill has a row, and no row outlives its skill
|
|
137
|
+
const shipped = shippedSkillNames();
|
|
138
|
+
const undocumentedSkills = shipped.filter((name) => !readme.includes(name));
|
|
124
139
|
if (undocumentedSkills.length > 0) {
|
|
125
140
|
issues.push(`Skills not documented in README: ${undocumentedSkills.join(", ")}`);
|
|
126
141
|
}
|
|
127
142
|
|
|
143
|
+
const retired = documentedSkillNames(readme).filter((name) => !shipped.includes(name));
|
|
144
|
+
if (retired.length > 0) {
|
|
145
|
+
issues.push(`README documents skills that no longer ship: ${retired.join(", ")}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
128
148
|
return { ok: issues.length === 0, issues };
|
|
129
149
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill matching — deterministic trigger lookup that names the skills a prompt
|
|
3
|
+
* probably wants, injected at prompt time.
|
|
4
|
+
*
|
|
5
|
+
* A skill declares its own `metadata.triggers` in SKILL.md; `generateSkillIndex`
|
|
6
|
+
* copies them into skill-index.json. Here the prompt and every trigger are
|
|
7
|
+
* normalized the same way (lowercase, punctuation to spaces, space-padded), so a
|
|
8
|
+
* plain substring test is already a whole-word test and multi-word phrases work
|
|
9
|
+
* without a regex. Pure + fail-open, like the steering classifier it rides with.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { resolve } from "node:path";
|
|
14
|
+
import { paths } from "./paths";
|
|
15
|
+
import { isEnabled } from "./settings";
|
|
16
|
+
|
|
17
|
+
interface SkillIndexEntry {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
triggers?: string[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface SkillIndex {
|
|
24
|
+
skills: Record<string, SkillIndexEntry>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SkillMatch {
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
score: number;
|
|
31
|
+
matched: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_MATCHES = 3;
|
|
35
|
+
const MAX_SKILL_BYTES = 700;
|
|
36
|
+
const MAX_DESCRIPTION_CHARS = 100;
|
|
37
|
+
const PHRASE_WEIGHT = 3;
|
|
38
|
+
const WORD_WEIGHT = 1;
|
|
39
|
+
|
|
40
|
+
/** Lowercase, punctuation to spaces, space-padded — so `includes` tests whole words. */
|
|
41
|
+
function normalize(text: string): string {
|
|
42
|
+
return ` ${text
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
45
|
+
.trim()} `;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readSkillIndex(): SkillIndex | null {
|
|
49
|
+
const path = resolve(paths.state(), "skill-index.json");
|
|
50
|
+
if (!existsSync(path)) return null;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(readFileSync(path, "utf-8")) as SkillIndex;
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function weightOf(trigger: string): number {
|
|
59
|
+
return trigger.trim().includes(" ") ? PHRASE_WEIGHT : WORD_WEIGHT;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Rank the indexed skills whose triggers appear in the prompt, best first. */
|
|
63
|
+
export function matchSkills(prompt: string, index: SkillIndex): SkillMatch[] {
|
|
64
|
+
const haystack = normalize(prompt);
|
|
65
|
+
if (haystack.trim() === "") return [];
|
|
66
|
+
|
|
67
|
+
const matches: SkillMatch[] = [];
|
|
68
|
+
for (const entry of Object.values(index.skills ?? {})) {
|
|
69
|
+
let score = 0;
|
|
70
|
+
const matched: string[] = [];
|
|
71
|
+
for (const trigger of entry.triggers ?? []) {
|
|
72
|
+
const needle = normalize(trigger);
|
|
73
|
+
if (needle.trim() === "" || !haystack.includes(needle)) continue;
|
|
74
|
+
score += weightOf(needle);
|
|
75
|
+
matched.push(trigger);
|
|
76
|
+
}
|
|
77
|
+
if (score > 0)
|
|
78
|
+
matches.push({ name: entry.name, description: entry.description, score, matched });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return matches
|
|
82
|
+
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
|
|
83
|
+
.slice(0, MAX_MATCHES);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function summarize(description: string): string {
|
|
87
|
+
if (description.length <= MAX_DESCRIPTION_CHARS) return description;
|
|
88
|
+
return `${description.slice(0, MAX_DESCRIPTION_CHARS).trimEnd()}…`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function line(match: SkillMatch): string {
|
|
92
|
+
const matched = match.matched.map((trigger) => `"${trigger}"`).join(", ");
|
|
93
|
+
return `- ${match.name} — ${summarize(match.description)} (matched: ${matched})`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Build the skill-match <system-reminder> for a prompt, or null if nothing matches. */
|
|
97
|
+
export function getSkillReminder(prompt: string): string | null {
|
|
98
|
+
if (!isEnabled("skillMatching")) return null;
|
|
99
|
+
if (!prompt?.trim()) return null;
|
|
100
|
+
|
|
101
|
+
const index = readSkillIndex();
|
|
102
|
+
if (!index) return null;
|
|
103
|
+
|
|
104
|
+
let matches: SkillMatch[];
|
|
105
|
+
try {
|
|
106
|
+
matches = matchSkills(prompt, index);
|
|
107
|
+
} catch {
|
|
108
|
+
return null; // fail-open: never block a prompt on a matcher error
|
|
109
|
+
}
|
|
110
|
+
if (matches.length === 0) return null;
|
|
111
|
+
|
|
112
|
+
const lines: string[] = [];
|
|
113
|
+
let budget = MAX_SKILL_BYTES;
|
|
114
|
+
for (const match of matches) {
|
|
115
|
+
const rendered = line(match);
|
|
116
|
+
const cost = Buffer.byteLength(rendered);
|
|
117
|
+
if (cost > budget) break; // byte-cap: drop the overflow tail, keep top matches
|
|
118
|
+
lines.push(rendered);
|
|
119
|
+
budget -= cost;
|
|
120
|
+
}
|
|
121
|
+
if (lines.length === 0) return null;
|
|
122
|
+
|
|
123
|
+
return [
|
|
124
|
+
"<system-reminder>",
|
|
125
|
+
"Potential matching skills: these matched trigger words in your prompt. Invoke one with the Skill tool if it fits the request; ignore them if none do.",
|
|
126
|
+
...lines,
|
|
127
|
+
"</system-reminder>",
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SKILL.md trigger declarations — the words and phrases a prompt carries when it
|
|
3
|
+
* wants a given skill.
|
|
4
|
+
*
|
|
5
|
+
* `metadata` is the only frontmatter key Anthropic's skill spec reserves for
|
|
6
|
+
* third-party tooling, so triggers live under it; a top-level `triggers:` key
|
|
7
|
+
* fails skill packaging with an unexpected-key error. Shared by the skill-index
|
|
8
|
+
* generator (which publishes them) and the skill doctor (which warns when a
|
|
9
|
+
* skill declares none).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Indented lines belonging to the frontmatter `metadata:` map, or [] when absent. */
|
|
13
|
+
function metadataBlock(frontmatter: string): string[] {
|
|
14
|
+
const lines = frontmatter.split("\n");
|
|
15
|
+
const start = lines.findIndex((line) => /^metadata:\s*$/.test(line));
|
|
16
|
+
if (start === -1) return [];
|
|
17
|
+
|
|
18
|
+
const block: string[] = [];
|
|
19
|
+
for (const line of lines.slice(start + 1)) {
|
|
20
|
+
if (line.trim() === "") continue;
|
|
21
|
+
if (!/^\s/.test(line)) break;
|
|
22
|
+
block.push(line);
|
|
23
|
+
}
|
|
24
|
+
return block;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Normalize one authored trigger: unquote, collapse whitespace, lowercase. */
|
|
28
|
+
function normalizeTrigger(raw: string): string {
|
|
29
|
+
return raw
|
|
30
|
+
.trim()
|
|
31
|
+
.replace(/^["']|["']$/g, "")
|
|
32
|
+
.replace(/\s+/g, " ")
|
|
33
|
+
.trim()
|
|
34
|
+
.toLowerCase();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Split a YAML flow sequence — `["a", "b"]` or `[a, b]` — into its items. */
|
|
38
|
+
function splitFlowSequence(value: string): string[] {
|
|
39
|
+
try {
|
|
40
|
+
const parsed: unknown = JSON.parse(value);
|
|
41
|
+
if (Array.isArray(parsed)) return parsed.map((item) => String(item));
|
|
42
|
+
} catch {
|
|
43
|
+
/* not strict JSON — fall through to the permissive split */
|
|
44
|
+
}
|
|
45
|
+
return value.slice(1, -1).split(",");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Items of a YAML block sequence: the `- item` lines directly under a key. */
|
|
49
|
+
function blockSequenceItems(lines: string[]): string[] {
|
|
50
|
+
const items: string[] = [];
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
const item = /^\s*-\s+(.*)$/.exec(line);
|
|
53
|
+
if (!item) break;
|
|
54
|
+
items.push(item[1]);
|
|
55
|
+
}
|
|
56
|
+
return items;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Author-declared `metadata.triggers` — the words and phrases a prompt is matched
|
|
61
|
+
* against. Accepts either YAML shape:
|
|
62
|
+
*
|
|
63
|
+
* metadata: | metadata:
|
|
64
|
+
* triggers: | triggers: ["make a deck", "slides"]
|
|
65
|
+
* - make a deck |
|
|
66
|
+
* - slides |
|
|
67
|
+
*
|
|
68
|
+
* `metadata` is the only frontmatter key Anthropic's skill spec reserves for
|
|
69
|
+
* third-party tooling; a top-level `triggers:` key fails skill packaging.
|
|
70
|
+
*/
|
|
71
|
+
export function declaredTriggers(frontmatter: string): string[] {
|
|
72
|
+
const block = metadataBlock(frontmatter);
|
|
73
|
+
const at = block.findIndex((line) => /^\s*triggers:/.test(line));
|
|
74
|
+
if (at === -1) return [];
|
|
75
|
+
|
|
76
|
+
const inline = /^\s*triggers:\s*(\S.*)$/.exec(block[at])?.[1];
|
|
77
|
+
const raw = inline
|
|
78
|
+
? splitFlowSequence(inline)
|
|
79
|
+
: blockSequenceItems(block.slice(at + 1));
|
|
80
|
+
|
|
81
|
+
return [...new Set(raw.map(normalizeTrigger).filter(Boolean))];
|
|
82
|
+
}
|
package/src/targets/lib.ts
CHANGED
|
@@ -10,14 +10,16 @@ import {
|
|
|
10
10
|
mkdirSync,
|
|
11
11
|
readdirSync,
|
|
12
12
|
readFileSync,
|
|
13
|
+
readlinkSync,
|
|
13
14
|
rmSync,
|
|
14
15
|
symlinkSync,
|
|
15
16
|
unlinkSync,
|
|
16
17
|
writeFileSync,
|
|
17
18
|
} from "node:fs";
|
|
18
19
|
import { homedir } from "node:os";
|
|
19
|
-
import { resolve } from "node:path";
|
|
20
|
+
import { dirname, resolve, sep } from "node:path";
|
|
20
21
|
import { assets, palHome, platform } from "../hooks/lib/paths";
|
|
22
|
+
import { declaredTriggers } from "../hooks/lib/skill-triggers";
|
|
21
23
|
|
|
22
24
|
// --- Colored logging ---
|
|
23
25
|
|
|
@@ -775,6 +777,10 @@ export function copySkills(claudeSkillsDir: string): number {
|
|
|
775
777
|
const linkType = process.platform === "win32" ? "junction" : "dir";
|
|
776
778
|
let count = 0;
|
|
777
779
|
|
|
780
|
+
for (const name of pruneStaleSkillLinks(claudeSkillsDir)) {
|
|
781
|
+
log.info(`Removed stale skill link: ${name}`);
|
|
782
|
+
}
|
|
783
|
+
|
|
778
784
|
for (const name of readdirSync(skillsDir)) {
|
|
779
785
|
const srcDir = resolve(skillsDir, name);
|
|
780
786
|
if (!existsSync(resolve(srcDir, "SKILL.md"))) continue;
|
|
@@ -797,6 +803,55 @@ export function copySkills(claudeSkillsDir: string): number {
|
|
|
797
803
|
return count;
|
|
798
804
|
}
|
|
799
805
|
|
|
806
|
+
/** True when `link` is a symlink whose target no longer exists. */
|
|
807
|
+
function isDanglingSymlink(link: string): boolean {
|
|
808
|
+
try {
|
|
809
|
+
return lstatSync(link).isSymbolicLink() && !existsSync(link);
|
|
810
|
+
} catch {
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** True when the symlink at `link` points at `root` or somewhere beneath it. */
|
|
816
|
+
function symlinkPointsInto(link: string, root: string): boolean {
|
|
817
|
+
try {
|
|
818
|
+
const target = resolve(dirname(link), readlinkSync(link));
|
|
819
|
+
return target === root || target.startsWith(root + sep);
|
|
820
|
+
} catch {
|
|
821
|
+
return false;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* Remove discovery links left behind when a shipped skill is renamed or
|
|
827
|
+
* retired. Ownership is read from where a link points, not from metadata:
|
|
828
|
+
* a dead link has no SKILL.md to read, but its target path still says
|
|
829
|
+
* whether PAL created it.
|
|
830
|
+
*
|
|
831
|
+
* ~/.pal/skills/<name> → pruned when dangling and pointing into assets/skills/
|
|
832
|
+
* <agent>/skills/<name> → pruned when dangling and pointing into ~/.pal/skills/
|
|
833
|
+
*
|
|
834
|
+
* Personal skills are real directories, so they are never candidates, and a
|
|
835
|
+
* user's own symlinks to anywhere else are left alone even when broken.
|
|
836
|
+
*/
|
|
837
|
+
function pruneStaleSkillLinks(agentSkillsDir: string): string[] {
|
|
838
|
+
const ownedTrees = [
|
|
839
|
+
{ dir: PAL_SKILLS_DIR, root: assets.skills() },
|
|
840
|
+
{ dir: agentSkillsDir, root: PAL_SKILLS_DIR },
|
|
841
|
+
];
|
|
842
|
+
const removed: string[] = [];
|
|
843
|
+
for (const { dir, root } of ownedTrees) {
|
|
844
|
+
if (!existsSync(dir)) continue;
|
|
845
|
+
for (const name of readdirSync(dir)) {
|
|
846
|
+
const link = resolve(dir, name);
|
|
847
|
+
if (!isDanglingSymlink(link) || !symlinkPointsInto(link, root)) continue;
|
|
848
|
+
unlinkSync(link);
|
|
849
|
+
removed.push(name);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return removed;
|
|
853
|
+
}
|
|
854
|
+
|
|
800
855
|
/**
|
|
801
856
|
* Agent skills directories that need a per-skill discovery link.
|
|
802
857
|
*
|
|
@@ -1319,7 +1374,7 @@ interface SkillIndex {
|
|
|
1319
1374
|
skills: Record<string, SkillIndexEntry>;
|
|
1320
1375
|
}
|
|
1321
1376
|
|
|
1322
|
-
/**
|
|
1377
|
+
/** Fallback triggers for a skill that declares none: keywords mined from its description. */
|
|
1323
1378
|
function extractTriggers(description: string): string[] {
|
|
1324
1379
|
// Extract "Use when ..." phrases and key terms
|
|
1325
1380
|
const triggers = new Set<string>();
|
|
@@ -1378,10 +1433,12 @@ export function generateSkillIndex(): number {
|
|
|
1378
1433
|
const skillName = nameMatch[1].trim();
|
|
1379
1434
|
const description = descMatch?.[1]?.trim() ?? "";
|
|
1380
1435
|
|
|
1436
|
+
const declared = declaredTriggers(fm);
|
|
1437
|
+
|
|
1381
1438
|
index.skills[skillName] = {
|
|
1382
1439
|
name: skillName,
|
|
1383
1440
|
description,
|
|
1384
|
-
triggers: extractTriggers(description),
|
|
1441
|
+
triggers: declared.length > 0 ? declared : extractTriggers(description),
|
|
1385
1442
|
};
|
|
1386
1443
|
index.totalSkills++;
|
|
1387
1444
|
} catch {
|
|
@@ -63,11 +63,9 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
63
63
|
const { captureRating } = await lib<typeof import("../../hooks/handlers/rating")>(
|
|
64
64
|
"../handlers/rating.ts"
|
|
65
65
|
);
|
|
66
|
-
const {
|
|
66
|
+
const { getPromptContext } = await lib<
|
|
67
67
|
typeof import("../../hooks/handlers/inject-retrieval")
|
|
68
68
|
>("../handlers/inject-retrieval.ts");
|
|
69
|
-
const { getSteeringReminder } =
|
|
70
|
-
await lib<typeof import("../../hooks/lib/steering")>("steering.ts");
|
|
71
69
|
|
|
72
70
|
function partsToText(parts: Array<Record<string, unknown>>): string {
|
|
73
71
|
return parts
|
|
@@ -163,9 +161,7 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
163
161
|
const text = partsToText(output.parts ?? []);
|
|
164
162
|
if (!text.trim()) return;
|
|
165
163
|
|
|
166
|
-
const
|
|
167
|
-
const steering = getSteeringReminder(text);
|
|
168
|
-
const injectedText = [steering, retrieval].filter(Boolean).join("\n\n");
|
|
164
|
+
const injectedText = (await getPromptContext(text)) ?? "";
|
|
169
165
|
logPromptSnapshot(text, injectedText || null);
|
|
170
166
|
|
|
171
167
|
await Promise.allSettled([
|