@aragon-agent/core 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +126 -0
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/engine/agent-loop.d.ts +52 -0
- package/dist/engine/agent-loop.js +260 -0
- package/dist/engine/agent.d.ts +129 -0
- package/dist/engine/agent.js +283 -0
- package/dist/engine/message-manager.d.ts +26 -0
- package/dist/engine/message-manager.js +39 -0
- package/dist/engine/steering.d.ts +27 -0
- package/dist/engine/steering.js +51 -0
- package/dist/engine/watchdog.d.ts +24 -0
- package/dist/engine/watchdog.js +50 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +63 -0
- package/dist/llm/model-registry.d.ts +51 -0
- package/dist/llm/model-registry.js +277 -0
- package/dist/llm/provider.d.ts +72 -0
- package/dist/llm/provider.js +77 -0
- package/dist/llm/providers/anthropic.d.ts +23 -0
- package/dist/llm/providers/anthropic.js +420 -0
- package/dist/llm/providers/google.d.ts +22 -0
- package/dist/llm/providers/google.js +336 -0
- package/dist/llm/providers/index.d.ts +67 -0
- package/dist/llm/providers/index.js +127 -0
- package/dist/llm/providers/openai.d.ts +22 -0
- package/dist/llm/providers/openai.js +353 -0
- package/dist/llm/stream-utils.d.ts +33 -0
- package/dist/llm/stream-utils.js +132 -0
- package/dist/llm/types.d.ts +129 -0
- package/dist/llm/types.js +17 -0
- package/dist/sandbox/console-capture.d.ts +29 -0
- package/dist/sandbox/console-capture.js +56 -0
- package/dist/sandbox/http-bridge.d.ts +47 -0
- package/dist/sandbox/http-bridge.js +143 -0
- package/dist/sandbox/isolated-vm-sandbox.d.ts +50 -0
- package/dist/sandbox/isolated-vm-sandbox.js +226 -0
- package/dist/sandbox/tool-bridge.d.ts +42 -0
- package/dist/sandbox/tool-bridge.js +98 -0
- package/dist/sandbox/types.d.ts +84 -0
- package/dist/sandbox/types.js +8 -0
- package/dist/skills/constants.d.ts +122 -0
- package/dist/skills/constants.js +179 -0
- package/dist/skills/disclosure.d.ts +146 -0
- package/dist/skills/disclosure.js +788 -0
- package/dist/skills/frontmatter.d.ts +27 -0
- package/dist/skills/frontmatter.js +177 -0
- package/dist/skills/index.d.ts +21 -0
- package/dist/skills/index.js +18 -0
- package/dist/skills/skill-find-tool.d.ts +41 -0
- package/dist/skills/skill-find-tool.js +117 -0
- package/dist/skills/skill-registry.d.ts +96 -0
- package/dist/skills/skill-registry.js +208 -0
- package/dist/skills/skill-tool.d.ts +44 -0
- package/dist/skills/skill-tool.js +96 -0
- package/dist/skills/tool-policy.d.ts +91 -0
- package/dist/skills/tool-policy.js +272 -0
- package/dist/skills/types.d.ts +271 -0
- package/dist/skills/types.js +8 -0
- package/dist/skills/validate.d.ts +37 -0
- package/dist/skills/validate.js +264 -0
- package/dist/tools/executor.d.ts +54 -0
- package/dist/tools/executor.js +206 -0
- package/dist/tools/helpers.d.ts +69 -0
- package/dist/tools/helpers.js +87 -0
- package/dist/tools/registry.d.ts +58 -0
- package/dist/tools/registry.js +119 -0
- package/dist/tools/types.d.ts +140 -0
- package/dist/tools/types.js +7 -0
- package/dist/tools/validator.d.ts +46 -0
- package/dist/tools/validator.js +210 -0
- package/dist/types.d.ts +56 -0
- package/dist/types.js +7 -0
- package/package.json +115 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SKILL.md frontmatter parser — a deliberately STRICT SUBSET of YAML (D9).
|
|
3
|
+
*
|
|
4
|
+
* Why not depend on `yaml`: `@aragon-agent/core` ships zero required runtime
|
|
5
|
+
* dependencies (only `ajv` / `isolated-vm` as optional), and real SKILL.md
|
|
6
|
+
* frontmatter only ever uses scalars, quoted strings, inline arrays and block
|
|
7
|
+
* arrays. Unknown keys are preserved verbatim in `data` so a future Anthropic
|
|
8
|
+
* field never breaks parsing.
|
|
9
|
+
*
|
|
10
|
+
* Every rule below is numbered to match spec §4.3 and has a dedicated test.
|
|
11
|
+
*/
|
|
12
|
+
export interface ParsedFrontmatter {
|
|
13
|
+
data: Record<string, string | string[]>;
|
|
14
|
+
/** Everything after the closing delimiter, `trimStart()`-ed. */
|
|
15
|
+
body: string;
|
|
16
|
+
/** Non-fatal warnings (duplicate keys, tab indentation, bad key syntax). */
|
|
17
|
+
errors: string[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Parse the frontmatter block of a SKILL.md.
|
|
21
|
+
*
|
|
22
|
+
* Returns `null` when there is no usable frontmatter at all — no opening `---`,
|
|
23
|
+
* no closing delimiter, or a block that blows past the hard limits of rule 8.
|
|
24
|
+
* `null` is never an exception: a malformed skill degrades to `invalid` in the
|
|
25
|
+
* registry and stays visible to the user (§4.4), it does not crash discovery.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseFrontmatter(source: string): ParsedFrontmatter | null;
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SKILL.md frontmatter parser — a deliberately STRICT SUBSET of YAML (D9).
|
|
3
|
+
*
|
|
4
|
+
* Why not depend on `yaml`: `@aragon-agent/core` ships zero required runtime
|
|
5
|
+
* dependencies (only `ajv` / `isolated-vm` as optional), and real SKILL.md
|
|
6
|
+
* frontmatter only ever uses scalars, quoted strings, inline arrays and block
|
|
7
|
+
* arrays. Unknown keys are preserved verbatim in `data` so a future Anthropic
|
|
8
|
+
* field never breaks parsing.
|
|
9
|
+
*
|
|
10
|
+
* Every rule below is numbered to match spec §4.3 and has a dedicated test.
|
|
11
|
+
*/
|
|
12
|
+
import { FRONTMATTER_MAX_BYTES, FRONTMATTER_MAX_LINES } from './constants.js';
|
|
13
|
+
const KEY_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
14
|
+
/** Rule 1: strip a UTF-8 BOM and normalize CRLF / lone CR to LF. */
|
|
15
|
+
function normalizeSource(source) {
|
|
16
|
+
const noBom = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
|
|
17
|
+
return noBom.replace(/\r\n?/g, '\n');
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Rule 5: `#` only starts a comment at the start of a line or after whitespace,
|
|
21
|
+
* so a URL fragment (`homepage: https://x/y#v1`) survives. Text inside a leading
|
|
22
|
+
* quoted string is skipped first — `description: "a # b"` keeps its hash.
|
|
23
|
+
*/
|
|
24
|
+
function stripComment(value) {
|
|
25
|
+
const quote = value[0];
|
|
26
|
+
let i = 0;
|
|
27
|
+
if (quote === '"' || quote === "'") {
|
|
28
|
+
i = 1;
|
|
29
|
+
while (i < value.length) {
|
|
30
|
+
if (value[i] === '\\' && quote === '"') {
|
|
31
|
+
i += 2;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (value[i] === quote) {
|
|
35
|
+
i += 1;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
i += 1;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (; i < value.length; i += 1) {
|
|
42
|
+
if (value[i] !== '#')
|
|
43
|
+
continue;
|
|
44
|
+
if (i === 0 || /\s/.test(value[i - 1]))
|
|
45
|
+
return value.slice(0, i);
|
|
46
|
+
}
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
/** Rule 4c: remove surrounding quotes, honouring only `\"` and `\\`. */
|
|
50
|
+
function dequote(value) {
|
|
51
|
+
if (value.length < 2)
|
|
52
|
+
return value;
|
|
53
|
+
const quote = value[0];
|
|
54
|
+
if ((quote !== '"' && quote !== "'") || value[value.length - 1] !== quote)
|
|
55
|
+
return value;
|
|
56
|
+
const inner = value.slice(1, -1);
|
|
57
|
+
if (quote === "'")
|
|
58
|
+
return inner.replace(/''/g, "'");
|
|
59
|
+
return inner.replace(/\\(["\\])/g, '$1');
|
|
60
|
+
}
|
|
61
|
+
/** Rule 4b: `[a, b, "c d"]` → `['a', 'b', 'c d']`. */
|
|
62
|
+
function parseInlineArray(value) {
|
|
63
|
+
const inner = value.slice(1, -1).trim();
|
|
64
|
+
if (inner.length === 0)
|
|
65
|
+
return [];
|
|
66
|
+
return inner
|
|
67
|
+
.split(',')
|
|
68
|
+
.map((item) => dequote(item.trim()))
|
|
69
|
+
.filter((item) => item.length > 0);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Parse the frontmatter block of a SKILL.md.
|
|
73
|
+
*
|
|
74
|
+
* Returns `null` when there is no usable frontmatter at all — no opening `---`,
|
|
75
|
+
* no closing delimiter, or a block that blows past the hard limits of rule 8.
|
|
76
|
+
* `null` is never an exception: a malformed skill degrades to `invalid` in the
|
|
77
|
+
* registry and stays visible to the user (§4.4), it does not crash discovery.
|
|
78
|
+
*/
|
|
79
|
+
export function parseFrontmatter(source) {
|
|
80
|
+
const text = normalizeSource(source);
|
|
81
|
+
const lines = text.split('\n');
|
|
82
|
+
// Rule 2: the opening `---`, optionally preceded by blank lines.
|
|
83
|
+
let start = 0;
|
|
84
|
+
while (start < lines.length && lines[start].trim().length === 0)
|
|
85
|
+
start += 1;
|
|
86
|
+
if (start >= lines.length || lines[start].trim() !== '---')
|
|
87
|
+
return null;
|
|
88
|
+
let end = -1;
|
|
89
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
90
|
+
const trimmed = lines[i].trim();
|
|
91
|
+
if (trimmed === '---' || trimmed === '...') {
|
|
92
|
+
end = i;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (end === -1)
|
|
97
|
+
return null;
|
|
98
|
+
const blockLines = lines.slice(start + 1, end);
|
|
99
|
+
// Rule 8: hard limits — a pathological file must not become a parsing DoS.
|
|
100
|
+
if (blockLines.length > FRONTMATTER_MAX_LINES)
|
|
101
|
+
return null;
|
|
102
|
+
if (Buffer.byteLength(blockLines.join('\n'), 'utf-8') > FRONTMATTER_MAX_BYTES)
|
|
103
|
+
return null;
|
|
104
|
+
const data = {};
|
|
105
|
+
const errors = [];
|
|
106
|
+
let pendingArrayKey = null;
|
|
107
|
+
/** Keys opened with an empty value — only these may collapse back to `''`. */
|
|
108
|
+
const emptyValueKeys = new Set();
|
|
109
|
+
const setKey = (key, value) => {
|
|
110
|
+
// Rule 7: last one wins, but say so.
|
|
111
|
+
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
|
112
|
+
errors.push(`duplicate key "${key}" - the last value wins`);
|
|
113
|
+
}
|
|
114
|
+
data[key] = value;
|
|
115
|
+
};
|
|
116
|
+
for (let i = 0; i < blockLines.length; i += 1) {
|
|
117
|
+
const rawLine = blockLines[i];
|
|
118
|
+
if (rawLine.trim().length === 0)
|
|
119
|
+
continue;
|
|
120
|
+
const indent = /^[ \t]*/.exec(rawLine)[0];
|
|
121
|
+
// Rule 6: tabs are never valid YAML indentation.
|
|
122
|
+
if (indent.includes('\t')) {
|
|
123
|
+
errors.push(`line ${i + 1}: tab indentation is not supported`);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const trimmed = rawLine.trim();
|
|
127
|
+
if (trimmed.startsWith('#'))
|
|
128
|
+
continue;
|
|
129
|
+
// Block-array continuation (rule 4a).
|
|
130
|
+
if (pendingArrayKey !== null && trimmed.startsWith('- ')) {
|
|
131
|
+
const item = dequote(stripComment(trimmed.slice(2).trim()).trim());
|
|
132
|
+
if (item.length > 0)
|
|
133
|
+
data[pendingArrayKey].push(item);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
pendingArrayKey = null;
|
|
137
|
+
if (indent.length > 0) {
|
|
138
|
+
errors.push(`line ${i + 1}: unexpected indentation outside a block list`);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// Rule 3: split on the FIRST colon.
|
|
142
|
+
const colon = trimmed.indexOf(':');
|
|
143
|
+
if (colon === -1) {
|
|
144
|
+
errors.push(`line ${i + 1}: not a "key: value" pair`);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const key = trimmed.slice(0, colon).trim();
|
|
148
|
+
if (!KEY_PATTERN.test(key)) {
|
|
149
|
+
errors.push(`line ${i + 1}: invalid key "${key}"`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
// Trim BEFORE stripping comments: `stripComment` only recognizes a leading
|
|
153
|
+
// quote at index 0, so a value like ` "a # b"` would otherwise lose its hash.
|
|
154
|
+
const rawValue = stripComment(trimmed.slice(colon + 1).trim()).trim();
|
|
155
|
+
if (rawValue.length === 0) {
|
|
156
|
+
// Empty value: either a block array follows, or it really is empty.
|
|
157
|
+
setKey(key, []);
|
|
158
|
+
emptyValueKeys.add(key);
|
|
159
|
+
pendingArrayKey = key;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
emptyValueKeys.delete(key);
|
|
163
|
+
if (rawValue.startsWith('[') && rawValue.endsWith(']')) {
|
|
164
|
+
setKey(key, parseInlineArray(rawValue));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
setKey(key, dequote(rawValue));
|
|
168
|
+
}
|
|
169
|
+
// An empty-valued key with no `- ` items behind it was a scalar all along.
|
|
170
|
+
// `keywords: []` is NOT touched — it was written as an explicit empty array.
|
|
171
|
+
for (const key of emptyValueKeys) {
|
|
172
|
+
const value = data[key];
|
|
173
|
+
if (Array.isArray(value) && value.length === 0)
|
|
174
|
+
data[key] = '';
|
|
175
|
+
}
|
|
176
|
+
return { data, body: lines.slice(end + 1).join('\n').trimStart(), errors };
|
|
177
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills submodule barrel — reachable as `@aragon-agent/core/skills`.
|
|
3
|
+
*
|
|
4
|
+
* The root barrel (`src/index.ts`) re-exports the documented public face; this
|
|
5
|
+
* subpath additionally exposes the internals the CLI needs (staged-install
|
|
6
|
+
* validation, always-block rendering, byte helpers) without widening the frozen
|
|
7
|
+
* top-level API surface. Same "root barrel + subpath" split the tools module
|
|
8
|
+
* already uses.
|
|
9
|
+
*/
|
|
10
|
+
export * from './constants.js';
|
|
11
|
+
export * from './types.js';
|
|
12
|
+
export { parseFrontmatter } from './frontmatter.js';
|
|
13
|
+
export type { ParsedFrontmatter } from './frontmatter.js';
|
|
14
|
+
export { validateSkillFrontmatter, validateStagedSkill, normalizeFrontmatter, checkStagedPath, hasControlChar, } from './validate.js';
|
|
15
|
+
export { sanitizeForPromptBlock, renderSkillCatalog, renderSkillBody, renderSkillInvocation, renderAlwaysSkills, renderCatalogLine, renderSkillFindResults, renderSkillFindNoMatch, applySkillArguments, splitArguments, suggestSkillNames, catalogRecords, rankCatalogRecords, classifyBundledFile, byteLength, truncateToBytes, } from './disclosure.js';
|
|
16
|
+
export { normalizeToolAlias, resolveDeclaredTool, computeToolPolicy, evaluateToolCall, renderDenyEscalation, renderDenyEscalationNotice, SKILL_TOOL_ALIASES, SKILL_TOOL_KNOWN_ABSENT, } from './tool-policy.js';
|
|
17
|
+
export { SkillRegistry } from './skill-registry.js';
|
|
18
|
+
export { createSkillTool } from './skill-tool.js';
|
|
19
|
+
export type { SkillToolDeps } from './skill-tool.js';
|
|
20
|
+
export { createSkillFindTool, matchSkills } from './skill-find-tool.js';
|
|
21
|
+
export type { SkillFindToolDeps } from './skill-find-tool.js';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills submodule barrel — reachable as `@aragon-agent/core/skills`.
|
|
3
|
+
*
|
|
4
|
+
* The root barrel (`src/index.ts`) re-exports the documented public face; this
|
|
5
|
+
* subpath additionally exposes the internals the CLI needs (staged-install
|
|
6
|
+
* validation, always-block rendering, byte helpers) without widening the frozen
|
|
7
|
+
* top-level API surface. Same "root barrel + subpath" split the tools module
|
|
8
|
+
* already uses.
|
|
9
|
+
*/
|
|
10
|
+
export * from './constants.js';
|
|
11
|
+
export * from './types.js';
|
|
12
|
+
export { parseFrontmatter } from './frontmatter.js';
|
|
13
|
+
export { validateSkillFrontmatter, validateStagedSkill, normalizeFrontmatter, checkStagedPath, hasControlChar, } from './validate.js';
|
|
14
|
+
export { sanitizeForPromptBlock, renderSkillCatalog, renderSkillBody, renderSkillInvocation, renderAlwaysSkills, renderCatalogLine, renderSkillFindResults, renderSkillFindNoMatch, applySkillArguments, splitArguments, suggestSkillNames, catalogRecords, rankCatalogRecords, classifyBundledFile, byteLength, truncateToBytes, } from './disclosure.js';
|
|
15
|
+
export { normalizeToolAlias, resolveDeclaredTool, computeToolPolicy, evaluateToolCall, renderDenyEscalation, renderDenyEscalationNotice, SKILL_TOOL_ALIASES, SKILL_TOOL_KNOWN_ABSENT, } from './tool-policy.js';
|
|
16
|
+
export { SkillRegistry } from './skill-registry.js';
|
|
17
|
+
export { createSkillTool } from './skill-tool.js';
|
|
18
|
+
export { createSkillFindTool, matchSkills } from './skill-find-tool.js';
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `skill_find` tool — the escape hatch for a truncated Level 1 catalog
|
|
3
|
+
* (spec §5.4 / D-A1).
|
|
4
|
+
*
|
|
5
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* THIS SEARCHES WHAT IS INSTALLED. IT NEVER TOUCHES THE NETWORK.
|
|
7
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
*
|
|
9
|
+
* The boundary is the whole point (D-A4). Iteration 1 deliberately shipped no
|
|
10
|
+
* search tool, on the reasoning that the catalog already showed everything.
|
|
11
|
+
* `SKILL_CATALOG_MAX_BYTES` is 6000 and a CJK description can cost 660 bytes a
|
|
12
|
+
* line, so that premise fails at roughly ten skills — and when it fails, the
|
|
13
|
+
* omitted skills become invisible to the model with no error anywhere (F1/F2).
|
|
14
|
+
* This tool restores reachability for exactly that case and nothing more.
|
|
15
|
+
*
|
|
16
|
+
* If it ever returned installable-but-not-installed candidates, "the agent can
|
|
17
|
+
* install skills" would quietly change meaning from "the user pointed at a
|
|
18
|
+
* source and the model did the work" to "the model picks its own dependencies".
|
|
19
|
+
* That is a different security model, and it is not this one.
|
|
20
|
+
*
|
|
21
|
+
* Pure logic, like `skill-tool.ts`: the registry is injected, and this module
|
|
22
|
+
* imports no `node:*` (enforced by `no-host-coupling.test.ts`).
|
|
23
|
+
*/
|
|
24
|
+
import type { AgentTool } from '../tools/types.js';
|
|
25
|
+
import type { SkillRegistry } from './skill-registry.js';
|
|
26
|
+
import type { SkillRecord } from './types.js';
|
|
27
|
+
export interface SkillFindToolDeps {
|
|
28
|
+
registry: SkillRegistry;
|
|
29
|
+
maxBytes?: number;
|
|
30
|
+
defaultLimit?: number;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Deterministic three-stage match; the first stage that yields anything wins.
|
|
34
|
+
*
|
|
35
|
+
* Stage 2 is AND across tokens, not OR. With OR, a query like "pdf form" would
|
|
36
|
+
* drag in every skill whose description happens to contain "form" — and because
|
|
37
|
+
* the result block has its own byte budget, the one skill the model actually
|
|
38
|
+
* needed could be the entry that gets dropped off the end.
|
|
39
|
+
*/
|
|
40
|
+
export declare function matchSkills(query: string, records: SkillRecord[]): SkillRecord[];
|
|
41
|
+
export declare function createSkillFindTool(deps: SkillFindToolDeps): AgentTool;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `skill_find` tool — the escape hatch for a truncated Level 1 catalog
|
|
3
|
+
* (spec §5.4 / D-A1).
|
|
4
|
+
*
|
|
5
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
6
|
+
* THIS SEARCHES WHAT IS INSTALLED. IT NEVER TOUCHES THE NETWORK.
|
|
7
|
+
* ══════════════════════════════════════════════════════════════════════════
|
|
8
|
+
*
|
|
9
|
+
* The boundary is the whole point (D-A4). Iteration 1 deliberately shipped no
|
|
10
|
+
* search tool, on the reasoning that the catalog already showed everything.
|
|
11
|
+
* `SKILL_CATALOG_MAX_BYTES` is 6000 and a CJK description can cost 660 bytes a
|
|
12
|
+
* line, so that premise fails at roughly ten skills — and when it fails, the
|
|
13
|
+
* omitted skills become invisible to the model with no error anywhere (F1/F2).
|
|
14
|
+
* This tool restores reachability for exactly that case and nothing more.
|
|
15
|
+
*
|
|
16
|
+
* If it ever returned installable-but-not-installed candidates, "the agent can
|
|
17
|
+
* install skills" would quietly change meaning from "the user pointed at a
|
|
18
|
+
* source and the model did the work" to "the model picks its own dependencies".
|
|
19
|
+
* That is a different security model, and it is not this one.
|
|
20
|
+
*
|
|
21
|
+
* Pure logic, like `skill-tool.ts`: the registry is injected, and this module
|
|
22
|
+
* imports no `node:*` (enforced by `no-host-coupling.test.ts`).
|
|
23
|
+
*/
|
|
24
|
+
import { errorResult, textResult } from '../tools/helpers.js';
|
|
25
|
+
import { SKILL_FIND_DEFAULT_LIMIT, SKILL_FIND_MAX_LIMIT, SKILL_FIND_MAX_TOKENS, } from './constants.js';
|
|
26
|
+
import { renderSkillFindResults, suggestSkillNames } from './disclosure.js';
|
|
27
|
+
const DESCRIPTION = 'Search the skills installed on this machine by name, description or keyword. Use this when the ' +
|
|
28
|
+
'available-skills list says some skills were not shown, or when you suspect a skill exists for ' +
|
|
29
|
+
'the task but you cannot see it. This searches ONLY what is already installed - it never reaches ' +
|
|
30
|
+
'the network and never returns skills you could install. If nothing matches, ask the user for a ' +
|
|
31
|
+
'source instead of inventing one.';
|
|
32
|
+
/**
|
|
33
|
+
* Skills a search may return.
|
|
34
|
+
*
|
|
35
|
+
* `activation: manual` is excluded on purpose (D-A3). Manual means "do not put
|
|
36
|
+
* this in front of the model; only the user may invoke it with `/<name>`".
|
|
37
|
+
* Letting search surface it would silently downgrade that setting from an
|
|
38
|
+
* exclusion to a ranking penalty, which is not what the author asked for.
|
|
39
|
+
*/
|
|
40
|
+
function searchable(registry) {
|
|
41
|
+
return registry
|
|
42
|
+
.list()
|
|
43
|
+
.filter((r) => !r.disabled && !r.invalid && r.frontmatter.activation !== 'manual');
|
|
44
|
+
}
|
|
45
|
+
/** The haystack for one record: name, description and keywords, lowercased. */
|
|
46
|
+
function haystack(record) {
|
|
47
|
+
return `${record.name} ${record.description} ${record.frontmatter.keywords.join(' ')}`.toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Deterministic three-stage match; the first stage that yields anything wins.
|
|
51
|
+
*
|
|
52
|
+
* Stage 2 is AND across tokens, not OR. With OR, a query like "pdf form" would
|
|
53
|
+
* drag in every skill whose description happens to contain "form" — and because
|
|
54
|
+
* the result block has its own byte budget, the one skill the model actually
|
|
55
|
+
* needed could be the entry that gets dropped off the end.
|
|
56
|
+
*/
|
|
57
|
+
export function matchSkills(query, records) {
|
|
58
|
+
const normalized = query.trim().toLowerCase();
|
|
59
|
+
if (normalized.length === 0)
|
|
60
|
+
return [];
|
|
61
|
+
const exact = records.filter((r) => r.name.toLowerCase() === normalized);
|
|
62
|
+
if (exact.length > 0)
|
|
63
|
+
return exact;
|
|
64
|
+
const tokens = normalized.split(/\s+/).filter(Boolean).slice(0, SKILL_FIND_MAX_TOKENS);
|
|
65
|
+
if (tokens.length === 0)
|
|
66
|
+
return [];
|
|
67
|
+
return records.filter((r) => {
|
|
68
|
+
const text = haystack(r);
|
|
69
|
+
return tokens.every((token) => text.includes(token));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
export function createSkillFindTool(deps) {
|
|
73
|
+
return {
|
|
74
|
+
name: 'skill_find',
|
|
75
|
+
label: 'Find Skill',
|
|
76
|
+
description: DESCRIPTION,
|
|
77
|
+
parameters: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
properties: {
|
|
80
|
+
query: {
|
|
81
|
+
type: 'string',
|
|
82
|
+
description: 'Words describing the capability you need, e.g. "pdf form" or "deploy preview".',
|
|
83
|
+
},
|
|
84
|
+
limit: { type: 'integer', description: 'Max results. Default 10, max 25.' },
|
|
85
|
+
},
|
|
86
|
+
required: ['query'],
|
|
87
|
+
},
|
|
88
|
+
async execute(_id, rawParams) {
|
|
89
|
+
const params = rawParams;
|
|
90
|
+
const query = typeof params.query === 'string' ? params.query.trim() : '';
|
|
91
|
+
if (query.length === 0)
|
|
92
|
+
return errorResult('skill_find requires a "query" parameter.');
|
|
93
|
+
const candidates = searchable(deps.registry);
|
|
94
|
+
const matched = matchSkills(query, candidates);
|
|
95
|
+
// Out-of-range limits are clamped rather than rejected: a model that asks
|
|
96
|
+
// for 500 results wants "as many as you have", and failing the call would
|
|
97
|
+
// cost a turn to relearn a bound it can simply be given.
|
|
98
|
+
const requested = typeof params.limit === 'number' && Number.isFinite(params.limit)
|
|
99
|
+
? Math.floor(params.limit)
|
|
100
|
+
: (deps.defaultLimit ?? SKILL_FIND_DEFAULT_LIMIT);
|
|
101
|
+
const limit = Math.min(SKILL_FIND_MAX_LIMIT, Math.max(1, requested));
|
|
102
|
+
// Stage 3: near-miss names, but only when the first two stages came up
|
|
103
|
+
// empty — otherwise a good match would be diluted with guesses.
|
|
104
|
+
const suggestions = matched.length === 0 ? suggestSkillNames(query, candidates.map((r) => r.name), 3) : [];
|
|
105
|
+
return textResult(renderSkillFindResults(query, matched.slice(0, limit), {
|
|
106
|
+
total: candidates.length,
|
|
107
|
+
// The PRE-slice count, so `limit` shows up as "+N more matches not
|
|
108
|
+
// shown" instead of quietly passing a truncated list off as the
|
|
109
|
+
// complete one — which is the exact failure mode (F1) this tool was
|
|
110
|
+
// added to undo, and it would be embarrassing to reintroduce here.
|
|
111
|
+
matchedTotal: matched.length,
|
|
112
|
+
...(deps.maxBytes !== undefined ? { maxBytes: deps.maxBytes } : {}),
|
|
113
|
+
suggestions,
|
|
114
|
+
}));
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory skill registry (spec §10.1). Pure state: no filesystem, no network.
|
|
3
|
+
*
|
|
4
|
+
* Precedence (D7) is decided by the ORDER records are added — the CLI scanner
|
|
5
|
+
* walks roots low-to-high (bundled → user → project → env), so a later `add()`
|
|
6
|
+
* of the same name wins and the loser is recorded in `shadowed[]` rather than
|
|
7
|
+
* silently dropped. `/skills` renders that as `~shadowed by project`, which is
|
|
8
|
+
* the difference between "my edit did nothing" being a mystery and being a
|
|
9
|
+
* one-line answer.
|
|
10
|
+
*/
|
|
11
|
+
import type { SkillRecord } from './types.js';
|
|
12
|
+
export declare class SkillRegistry {
|
|
13
|
+
private readonly byName;
|
|
14
|
+
/** Skills loaded via the `skill` tool during THIS session. */
|
|
15
|
+
private readonly active;
|
|
16
|
+
private readonly frameActive;
|
|
17
|
+
private readonly framePending;
|
|
18
|
+
/**
|
|
19
|
+
* Announce-once ledgers (§5.5 / P1-2). They live here rather than in the tool
|
|
20
|
+
* wrapper because this class is already the sole authority on when a turn
|
|
21
|
+
* begins — a `Set` closed over in `withToolPolicy` would be session-scoped and
|
|
22
|
+
* would silence `warn` mode after its very first message.
|
|
23
|
+
*/
|
|
24
|
+
private readonly warnedThisTurn;
|
|
25
|
+
private readonly failOpenNoticed;
|
|
26
|
+
private readonly deniedThisTurn;
|
|
27
|
+
/**
|
|
28
|
+
* Insert a record, resolving a same-name collision in favour of the newcomer
|
|
29
|
+
* (higher-precedence root) while preserving the loser as a shadow entry.
|
|
30
|
+
*/
|
|
31
|
+
add(record: SkillRecord): void;
|
|
32
|
+
get(name: string): SkillRecord | undefined;
|
|
33
|
+
/** Every record, including disabled and invalid ones, sorted by name. */
|
|
34
|
+
list(): SkillRecord[];
|
|
35
|
+
names(): string[];
|
|
36
|
+
/** Level 1-eligible records in canonical order (scope desc, then name asc). */
|
|
37
|
+
catalog(): SkillRecord[];
|
|
38
|
+
/** Records that should be injected as Level 2 at session start (§5.4). */
|
|
39
|
+
alwaysOn(extraNames?: string[]): SkillRecord[];
|
|
40
|
+
setDisabled(name: string, disabled: boolean): void;
|
|
41
|
+
/** Idempotent: marks a skill as loaded and reports whether it already was. */
|
|
42
|
+
activate(name: string): boolean;
|
|
43
|
+
isActive(name: string): boolean;
|
|
44
|
+
get activeNames(): string[];
|
|
45
|
+
/**
|
|
46
|
+
* Drop the session's loaded-skill set. Called by `/reset` (P2-9) — without it
|
|
47
|
+
* the "already loaded earlier in this conversation" hint would keep claiming
|
|
48
|
+
* a skill is in a context that no longer exists.
|
|
49
|
+
*
|
|
50
|
+
* Clearing the frames here rather than at the call site is deliberate (§7.3):
|
|
51
|
+
* "remember to call the other method too" is not a design, and the symptom of
|
|
52
|
+
* forgetting — a ceiling surviving a `/reset` — would be attributed to
|
|
53
|
+
* anything but this.
|
|
54
|
+
*/
|
|
55
|
+
clearActive(): void;
|
|
56
|
+
/** The `skill` tool succeeded: this skill constrains the turn from now on. */
|
|
57
|
+
enterFrame(name: string): void;
|
|
58
|
+
/** A `/<skill-name>` command ran: constrain the turn its message is about to open. */
|
|
59
|
+
queueFrame(name: string): void;
|
|
60
|
+
/** A new user message arrived: the previous turn's ceiling expires, queued ones promote. */
|
|
61
|
+
beginUserTurn(): void;
|
|
62
|
+
/**
|
|
63
|
+
* A user message arrived MID-RUN (`steer`). Promotes the queue without
|
|
64
|
+
* clearing what is already in force (D-G3): the turn has not ended, so neither
|
|
65
|
+
* has the skill procedure — and letting an interjection lift the ceiling would
|
|
66
|
+
* make "ask a follow-up question" the documented way around it.
|
|
67
|
+
*
|
|
68
|
+
* The turn ledgers are deliberately NOT reset: this is the same turn.
|
|
69
|
+
*/
|
|
70
|
+
absorbPendingFrames(): void;
|
|
71
|
+
/** `/skills unload` — drops the ceiling. Does NOT reclaim context; nothing can. */
|
|
72
|
+
clearFrames(): void;
|
|
73
|
+
private resetTurnLedgers;
|
|
74
|
+
get frameNames(): string[];
|
|
75
|
+
/** Names queued by a slash command, awaiting the user message they will constrain. */
|
|
76
|
+
get pendingFrameNames(): string[];
|
|
77
|
+
/**
|
|
78
|
+
* The frame's records that STILL EXIST and are still usable.
|
|
79
|
+
*
|
|
80
|
+
* Unresolvable / disabled / invalid names are dropped from the result but KEPT
|
|
81
|
+
* in `frameActive`, so `frameNames` can still say which skill was supposed to
|
|
82
|
+
* be constraining this turn. The caller owes the user one notice per lifted
|
|
83
|
+
* skill (§5.6f): uninstall, disable and a flip to `integrity: strict` all end
|
|
84
|
+
* with the ceiling gone, and a ceiling that disappears without a word is the
|
|
85
|
+
* exact failure class this whole iteration exists to remove.
|
|
86
|
+
*/
|
|
87
|
+
frameRecords(): SkillRecord[];
|
|
88
|
+
/** True on the first `(skill, tool)` pair this TURN; false afterwards. */
|
|
89
|
+
noteWarnOnce(skill: string, tool: string): boolean;
|
|
90
|
+
/** True on the first mention of `key` this SESSION; false afterwards. */
|
|
91
|
+
noteFailOpenOnce(key: string): boolean;
|
|
92
|
+
/** Count this refusal and return the running total for the tool, this turn. */
|
|
93
|
+
countDeny(tool: string): number;
|
|
94
|
+
/** Swap the whole table after a rescan, preserving the active set. */
|
|
95
|
+
replaceAll(records: SkillRecord[]): void;
|
|
96
|
+
}
|