@hydraharness/harness-tool-skill 0.0.0-stage → 0.1.1-rc.6

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,116 @@
1
- # Temporary Holding Version
1
+ # @hydraharness/harness-tool-skill
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Bounded automatic skill routing, model-facing discovery, exact instruction loading, and deterministic user invocation.
4
+
5
+ Requires `ctx.agents`, `ctx.tools`, and `ctx.skills` (`inject: ['agents', 'tools', 'skills']`). The plugin does not inject a session-wide skill catalog. It may inject one strongly matched skill body for a direct user task.
6
+
7
+ ## Tool: `skill_search`
8
+
9
+ | Arg | Type | Notes |
10
+ |---|---|---|
11
+ | `query` | string (required) | Concise task keywords. Greetings, thanks, acknowledgements, casual chat, meta questions, and vague requests should not be searched. |
12
+
13
+ The tool snapshots the calling agent's cwd-sensitive registry view, keeps only model-invocable summaries, and ranks lexical matches across `name`, `description`, and `whenToUse`. Exact whole-name phrases rank first, then matched terms and metadata fields. Equal scores prefer an alias, then lexical name order. Each canonical definition contributes only its best-ranked name before count and byte limits apply. It does not call `ctx.skills.get()` and therefore never loads instruction bodies while searching.
14
+
15
+ One result returns at most `searchMaxResults` candidates (default `5`), caps each description or routing hint at `searchDescriptionMaxLength` characters (default `500`), and caps the complete rendered UTF-8 result at `searchMaxResultBytes` bytes (default `8192`). All limits are positive integers; the description limit has minimum `3`, and the byte limit must fit the fixed empty-result framing. `truncated: true` says matching candidates were omitted by a count or byte bound. `complete: false` says provider discovery was unstable or partially unavailable, so an empty result is not authoritative.
16
+
17
+ The rendered result is:
18
+
19
+ ```markdown
20
+ <skill_candidates complete="true" truncated="false">
21
+ - `<name>`: <normalized-and-capped-description>
22
+ Use when: <normalized-and-capped-routing-hint>
23
+ </skill_candidates>
24
+ Choose zero or one candidate. Call `skill` only for the best match; load another only when the task clearly requires an independent skill.
25
+ ```
26
+
27
+ An empty complete search renders `(none)` and means no skill should be loaded. This two-step contract keeps model-visible discovery cost bounded even when the registry contains many skills.
28
+
29
+ ## Automatic invocation
30
+
31
+ Before an accepted step reaches the model, direct-user text is ranked with the same lexical metadata scores as `skill_search`. The host loads exactly one skill only when the registry snapshot is complete and the best model-invocable candidate is unambiguous before lexical name tie-breaking. An exact whole name is strong only when the skill name has multiple terms. Other matches require at least two distinct name terms including the leading name term. Greetings, generic test requests, description-only matches, ties, and non-user text load nothing; `skill_search` remains the fallback.
32
+
33
+ An explicit `/name` gesture suppresses automatic routing for that step. Automatic discovery and loading fail open on stale, invalid, incomplete, or failing providers, while cancellation still stops the step. A successful injection uses durable `skill-invocation` source metadata with `trigger: 'automatic'`; direct gestures use `trigger: 'user'`.
34
+
35
+ English and Vietnamese negation or avoidance cues, including `not`, `don't`, `without`, `avoid`, `không`, and `đừng`, suppress automatic loading for the whole request. This conservative check leaves selection to the model-facing tools even if the negation concerns another part of the task. Explicit slash gestures retain their direct invocation behavior.
36
+
37
+ ## Tool: `skill`
38
+
39
+ | Arg | Type | Notes |
40
+ |---|---|---|
41
+ | `name` | string (required) | Exact kebab-case name returned by `skill_search` or explicitly named by the user. |
42
+
43
+ Execution uses the calling agent's `session.header.cwd`, validates the name, rejects non-model-invocable skills before loading, then rechecks policy on the loaded definition. A successful call returns canonical `{ name, provider, resourceBase?, content }`; its Native renderer produces one text result containing `<skill_content name="...">`, `<skill_resources>`, and `<skill_instructions>`.
44
+
45
+ Resource guidance resolves only paths or URLs explicitly referenced by the instructions against `resourceBase`. Scripts, references, and assets load on demand; the result does not enumerate a skill directory. An unresolved name reports that the skill is unknown or no longer available. Invalid names and model-disabled skills have distinct errors.
46
+
47
+ ## User-explicit invocation
48
+
49
+ A whitespace-bounded `/name` token in a claimed direct-user message deterministically loads a user-invocable skill and appends its full `<skill_content>` rendering after the other injections for that step. Tokens from non-user sources cannot forge the gesture; unknown or user-disabled names stay ordinary prose. This is the model-independent path for `disable-model-invocation` skills, takes precedence over automatic routing, and does not call `skill_search` or `skill`.
50
+
51
+ ## Model Experience
52
+
53
+ ### Tool schemas
54
+
55
+ #### What the model sees
56
+
57
+ The model sees the generated [`skill_search` and `skill` schemas](../../../docs/tool-catalog.md#hydraharness-tool-skill). No data-dependent skill roster is added to the request prefix.
58
+
59
+ #### Token effect
60
+
61
+ Fixed schema cost on each request where the tools are visible.
62
+
63
+ #### KV Cache effect
64
+
65
+ Prefix-stable while definitions and visibility remain unchanged.
66
+
67
+ ### Search result
68
+
69
+ #### What the model sees
70
+
71
+ A search adds only the bounded candidate result above.
72
+
73
+ #### Token effect
74
+
75
+ At most the configured rendered UTF-8 byte limit, retained in later requests until compaction.
76
+
77
+ #### KV Cache effect
78
+
79
+ Append-only after the reusable request prefix.
80
+
81
+ ### Loaded result
82
+
83
+ #### What the model sees
84
+
85
+ The selected provider's `<skill_content>`, resource guidance, and complete instructions. The tool does not add a duplicate injected copy.
86
+
87
+ #### Token effect
88
+
89
+ Data-dependent body tokens are resent on later steps until compaction.
90
+
91
+ #### KV Cache effect
92
+
93
+ Append-only after the reusable request prefix.
94
+
95
+ ### Tool errors
96
+
97
+ #### What the model sees
98
+
99
+ Invalid or stale selections return `Error: invalid skill name "<name>"`, `Error: skill "<name>" is unknown or no longer available`, or `Error: skill "<name>" is not available for model invocation`. Provider lookup failures use the same `Error: <message>` wrapper.
100
+
101
+ #### Token effect
102
+
103
+ Only a failing tool call adds the short retained error text.
104
+
105
+ #### KV Cache effect
106
+
107
+ Append-only after the reusable request prefix.
108
+
109
+ ## Known Limitations and Deferred Work
110
+
111
+ - Automatic routing and search use lexical metadata matching, not semantic retrieval. Add a semantic index only after measured routing misses justify its dependency and operational cost.
112
+ - The automatic-routing veto recognizes common English/Vietnamese cues; it is not a general intent classifier and does not cover every language or phrasing.
113
+ - Loaded instruction bodies have no size cap; a provider can return a body that consumes substantial next-step context.
114
+ - Resources are guidance, not attachments; the tools neither enumerate nor fetch referenced files.
115
+ - Loading is one-shot text; there is no partial, streaming, or cached-content handle.
116
+ - Body-only edits do not notify the model. A later exact load reads current content while earlier tool results remain historical facts.
package/lib/index.js ADDED
@@ -0,0 +1,439 @@
1
+ import { Buffer } from "node:buffer";
2
+ import z from "@hydraharness/schemastery";
3
+ import { defineTool } from "@hydraharness/harness-tools";
4
+ import { createUserMessage } from "@hydraharness/harness-llm";
5
+ import { escapeText, isModelInvocable, isSkillName, isUserInvocable, renderSkillContent } from "@hydraharness/harness-skill";
6
+ //#region lib/types/index.js
7
+ /**
8
+ * Bounded skill routing, model-facing search, and exact loading.
9
+ *
10
+ * @module @hydraharness/harness-tool-skill
11
+ */
12
+ const name = "tool-skill";
13
+ const inject = [
14
+ "agents",
15
+ "tools",
16
+ "skills"
17
+ ];
18
+ const DEFAULT_SEARCH_MAX_RESULTS = 5;
19
+ const DEFAULT_SEARCH_DESCRIPTION_MAX_LENGTH = 500;
20
+ const DEFAULT_SEARCH_MAX_RESULT_BYTES = 8192;
21
+ /** Validate and default the model-facing skill search configuration. */
22
+ const Config = z.object({
23
+ searchMaxResults: z.number().default(DEFAULT_SEARCH_MAX_RESULTS),
24
+ searchDescriptionMaxLength: z.number().default(DEFAULT_SEARCH_DESCRIPTION_MAX_LENGTH),
25
+ searchMaxResultBytes: z.number().default(DEFAULT_SEARCH_MAX_RESULT_BYTES)
26
+ });
27
+ /**
28
+ * Register bounded automatic routing, model-facing search, exact loading, and direct user invocation.
29
+ */
30
+ function apply(ctx, config = {}) {
31
+ const searchMaxResults = config.searchMaxResults ?? DEFAULT_SEARCH_MAX_RESULTS;
32
+ const searchDescriptionMaxLength = config.searchDescriptionMaxLength ?? DEFAULT_SEARCH_DESCRIPTION_MAX_LENGTH;
33
+ const searchMaxResultBytes = config.searchMaxResultBytes ?? DEFAULT_SEARCH_MAX_RESULT_BYTES;
34
+ assertPositiveInteger("searchMaxResults", searchMaxResults);
35
+ assertPositiveInteger("searchDescriptionMaxLength", searchDescriptionMaxLength, 3);
36
+ assertPositiveInteger("searchMaxResultBytes", searchMaxResultBytes, minimumSearchResultBytes());
37
+ const skillTool = defineTool({
38
+ name: "skill",
39
+ description: "Load the full instructions for exactly one skill. Use only an exact name returned by `skill_search` for the current task or explicitly named by the user; do not guess names or reload an inline <skill_content> block.",
40
+ parameters: { name: {
41
+ type: "string",
42
+ required: true,
43
+ description: "The exact skill name returned by `skill_search` or explicitly named by the user."
44
+ } },
45
+ output: {
46
+ schema: {
47
+ type: "object",
48
+ additionalProperties: false,
49
+ properties: {
50
+ name: {
51
+ type: "string",
52
+ required: true
53
+ },
54
+ provider: {
55
+ type: "string",
56
+ required: true
57
+ },
58
+ resourceBase: { oneOf: [
59
+ {
60
+ type: "object",
61
+ additionalProperties: false,
62
+ properties: {
63
+ kind: {
64
+ type: "string",
65
+ required: true,
66
+ const: "directory"
67
+ },
68
+ path: {
69
+ type: "string",
70
+ required: true
71
+ }
72
+ }
73
+ },
74
+ {
75
+ type: "object",
76
+ additionalProperties: false,
77
+ properties: {
78
+ kind: {
79
+ type: "string",
80
+ required: true,
81
+ const: "url"
82
+ },
83
+ url: {
84
+ type: "string",
85
+ required: true
86
+ }
87
+ }
88
+ },
89
+ {
90
+ type: "object",
91
+ additionalProperties: false,
92
+ properties: {
93
+ kind: {
94
+ type: "string",
95
+ required: true,
96
+ const: "opaque"
97
+ },
98
+ description: {
99
+ type: "string",
100
+ required: true
101
+ }
102
+ }
103
+ }
104
+ ] },
105
+ content: {
106
+ type: "string",
107
+ required: true
108
+ }
109
+ }
110
+ },
111
+ render: (_args, value) => [{
112
+ type: "text",
113
+ text: renderSkillContent(value)
114
+ }]
115
+ },
116
+ async execute(args, exec) {
117
+ if (!isSkillName(args.name)) throw new Error(`invalid skill name "${args.name}"`);
118
+ const lookup = {
119
+ cwd: exec.agent?.session.header.cwd,
120
+ signal: exec.signal,
121
+ scope: exec.agent
122
+ };
123
+ const summary = (await ctx.skills.list(lookup)).find((skill) => skill.name === args.name);
124
+ if (!summary) throw new Error(`skill "${args.name}" is unknown or no longer available`);
125
+ if (!isModelInvocable(summary)) throw new Error(`skill "${args.name}" is not available for model invocation`);
126
+ const skill = await ctx.skills.get(args.name, lookup);
127
+ if (!skill) throw new Error(`skill "${args.name}" is unknown or no longer available`);
128
+ if (!isModelInvocable(skill)) throw new Error(`skill "${args.name}" is not available for model invocation`);
129
+ return {
130
+ name: skill.name,
131
+ provider: skill.provider,
132
+ ...skill.resourceBase !== void 0 ? { resourceBase: { ...skill.resourceBase } } : {},
133
+ content: skill.content
134
+ };
135
+ },
136
+ presentCall(args) {
137
+ return {
138
+ card: "generic",
139
+ title: `Load skill ${args.name}`,
140
+ kind: "read",
141
+ rawInput: args.name
142
+ };
143
+ }
144
+ });
145
+ ctx.tools.register(skillTool);
146
+ const skillSearchTool = defineTool({
147
+ name: "skill_search",
148
+ description: "Find a bounded shortlist of skills for a substantive user task before loading one. Search with concise task keywords; do not call this for greetings, thanks, acknowledgements, casual chat, meta questions, or vague requests. An empty result means load no skill.",
149
+ parameters: { query: {
150
+ type: "string",
151
+ required: true,
152
+ description: "Concise keywords describing the user task, not a greeting or conversational filler."
153
+ } },
154
+ output: {
155
+ schema: {
156
+ type: "object",
157
+ additionalProperties: false,
158
+ properties: {
159
+ complete: {
160
+ type: "boolean",
161
+ required: true
162
+ },
163
+ truncated: {
164
+ type: "boolean",
165
+ required: true
166
+ },
167
+ matches: {
168
+ type: "array",
169
+ required: true,
170
+ items: {
171
+ type: "object",
172
+ additionalProperties: false,
173
+ properties: {
174
+ name: {
175
+ type: "string",
176
+ required: true
177
+ },
178
+ description: {
179
+ type: "string",
180
+ required: true
181
+ },
182
+ whenToUse: { type: "string" }
183
+ }
184
+ }
185
+ }
186
+ }
187
+ },
188
+ render: (_args, value) => [{
189
+ type: "text",
190
+ text: renderSkillSearchResult(value)
191
+ }]
192
+ },
193
+ async execute(args, exec) {
194
+ const snapshot = await ctx.skills.snapshot({
195
+ cwd: exec.agent?.session.header.cwd,
196
+ signal: exec.signal,
197
+ scope: exec.agent
198
+ });
199
+ exec.signal.throwIfAborted();
200
+ return boundedSearchResult(rankSkills(snapshot.skills.filter(isModelInvocable), args.query).map((entry) => entry.skill), snapshot.complete, searchMaxResults, searchDescriptionMaxLength, searchMaxResultBytes);
201
+ },
202
+ presentCall(args) {
203
+ return {
204
+ card: "generic",
205
+ title: "Search skills",
206
+ kind: "read",
207
+ rawInput: args.query
208
+ };
209
+ }
210
+ });
211
+ ctx.tools.register(skillSearchTool);
212
+ ctx.on("agent/pre-step", async ({ agent, messages, signal }, next) => {
213
+ const decision = await next();
214
+ if (decision.kind === "reject") return decision;
215
+ const names = invokedSkillNames(messages);
216
+ const task = directUserText(messages);
217
+ if (names.length === 0 && task === "") return decision;
218
+ signal.throwIfAborted();
219
+ const lookup = {
220
+ cwd: agent.session.header.cwd,
221
+ signal,
222
+ scope: agent
223
+ };
224
+ const injections = [];
225
+ if (names.length === 0) {
226
+ if (vetoesAutomaticSkill(task)) return decision;
227
+ try {
228
+ const snapshot = await ctx.skills.snapshot(lookup);
229
+ signal.throwIfAborted();
230
+ if (!snapshot.complete) return decision;
231
+ const selected = automaticallySelectedSkill(rankSkills(snapshot.skills.filter(isModelInvocable), task));
232
+ if (selected === void 0) return decision;
233
+ const skill = await ctx.skills.get(selected.name, lookup);
234
+ signal.throwIfAborted();
235
+ if (skill === void 0 || !isModelInvocable(skill)) return decision;
236
+ const source = {
237
+ kind: "skill-invocation",
238
+ name: skill.name,
239
+ trigger: "automatic",
240
+ form: "instructions"
241
+ };
242
+ injections.push(createUserMessage({
243
+ content: [{
244
+ type: "text",
245
+ text: renderSkillContent(skill)
246
+ }],
247
+ source
248
+ }));
249
+ } catch (error) {
250
+ signal.throwIfAborted();
251
+ ctx.logger.warn(`tool-skill: automatic routing failed: ${String(error)}`);
252
+ return decision;
253
+ }
254
+ }
255
+ for (const name of names) {
256
+ const skill = await ctx.skills.get(name, lookup);
257
+ signal.throwIfAborted();
258
+ if (skill === void 0 || !isUserInvocable(skill)) continue;
259
+ const source = {
260
+ kind: "skill-invocation",
261
+ name,
262
+ trigger: "user",
263
+ form: "instructions"
264
+ };
265
+ injections.push(createUserMessage({
266
+ content: [{
267
+ type: "text",
268
+ text: renderSkillContent(skill)
269
+ }],
270
+ source
271
+ }));
272
+ }
273
+ if (injections.length === 0) return decision;
274
+ return {
275
+ kind: "enter",
276
+ messages: [...decision.messages, ...injections]
277
+ };
278
+ });
279
+ }
280
+ const ROUTING_TERM = /[\p{L}\p{N}]+/gu;
281
+ /**
282
+ * Rank model-invocable skill summaries against task keywords without loading any body.
283
+ * @param skills - candidate summaries visible to the calling agent.
284
+ * @param query - task text or concise model-authored keywords.
285
+ * @returns matching summaries and scores in deterministic relevance order.
286
+ */
287
+ function rankSkills(skills, query) {
288
+ const queryPhrase = routingPhrase(query);
289
+ const queryTerms = new Set(queryPhrase.split(" ").filter(Boolean));
290
+ if (queryTerms.size === 0) return [];
291
+ const ranked = [];
292
+ for (const skill of skills) {
293
+ const namePhrase = routingPhrase(skill.name);
294
+ const nameTermList = namePhrase.split(" ");
295
+ const nameTerms = new Set(nameTermList);
296
+ const descriptionTerms = routingTerms(skill.description);
297
+ const whenToUseTerms = routingTerms(skill.whenToUse ?? "");
298
+ const matchedTerms = countMatches(queryTerms, new Set([
299
+ ...nameTerms,
300
+ ...descriptionTerms,
301
+ ...whenToUseTerms
302
+ ]));
303
+ const exactName = ` ${queryPhrase} `.includes(` ${namePhrase} `);
304
+ if (!exactName && matchedTerms === 0) continue;
305
+ ranked.push({
306
+ skill,
307
+ exactName,
308
+ matchedTerms,
309
+ nameMatches: countMatches(queryTerms, nameTerms),
310
+ nameTermCount: nameTerms.size,
311
+ leadingNameMatch: queryTerms.has(nameTermList[0]),
312
+ whenToUseMatches: countMatches(queryTerms, whenToUseTerms),
313
+ descriptionMatches: countMatches(queryTerms, descriptionTerms)
314
+ });
315
+ }
316
+ ranked.sort((left, right) => Number(right.exactName) - Number(left.exactName) || right.matchedTerms - left.matchedTerms || right.nameMatches - left.nameMatches || right.whenToUseMatches - left.whenToUseMatches || right.descriptionMatches - left.descriptionMatches || Number(right.skill.aliasFor !== void 0) - Number(left.skill.aliasFor !== void 0) || compareText(left.skill.name, right.skill.name));
317
+ const seen = /* @__PURE__ */ new Set();
318
+ return ranked.filter(({ skill }) => {
319
+ const canonical = skill.aliasFor ?? skill.name;
320
+ if (seen.has(canonical)) return false;
321
+ seen.add(canonical);
322
+ return true;
323
+ });
324
+ }
325
+ function vetoesAutomaticSkill(task) {
326
+ const text = task.normalize("NFKC");
327
+ return /(?:^|[^\p{L}\p{N}])(?:no|not|never|without|avoid|skip|stop|cannot|\p{L}+n['’]t)(?=$|[^\p{L}\p{N}])/iu.test(text) || /(?:^|[^\p{L}\p{N}])(?:không|đừng|chớ|ngừng|khong|dung)(?=$|[^\p{L}\p{N}])/iu.test(text);
328
+ }
329
+ function automaticallySelectedSkill(ranked) {
330
+ const best = ranked.find(isStrongAutomaticMatch);
331
+ if (best === void 0) return void 0;
332
+ const next = ranked.slice(ranked.indexOf(best) + 1).find(isStrongAutomaticMatch);
333
+ if (next !== void 0 && next.exactName === best.exactName && next.matchedTerms === best.matchedTerms && next.nameMatches === best.nameMatches && next.whenToUseMatches === best.whenToUseMatches && next.descriptionMatches === best.descriptionMatches) return void 0;
334
+ return best.skill;
335
+ }
336
+ function isStrongAutomaticMatch(candidate) {
337
+ return candidate.exactName ? candidate.nameTermCount >= 2 : candidate.matchedTerms >= 2 && candidate.nameMatches >= 2 && candidate.leadingNameMatch;
338
+ }
339
+ function boundedSearchResult(ranked, complete, maxResults, descriptionMaxLength, maxResultBytes) {
340
+ const candidates = ranked.slice(0, maxResults).map((skill) => {
341
+ const whenToUse = skill.whenToUse === void 0 ? void 0 : boundSearchText(skill.whenToUse, descriptionMaxLength);
342
+ return {
343
+ name: skill.name,
344
+ description: boundSearchText(skill.description, descriptionMaxLength),
345
+ ...whenToUse === void 0 || whenToUse === "" ? {} : { whenToUse }
346
+ };
347
+ });
348
+ const matches = [];
349
+ for (const candidate of candidates) {
350
+ const nextMatches = [...matches, candidate];
351
+ if (resultBytes({
352
+ complete,
353
+ truncated: nextMatches.length < ranked.length,
354
+ matches: nextMatches
355
+ }) > maxResultBytes) break;
356
+ matches.push(candidate);
357
+ }
358
+ return {
359
+ complete,
360
+ truncated: matches.length < ranked.length,
361
+ matches
362
+ };
363
+ }
364
+ function renderSkillSearchResult(result) {
365
+ const candidates = result.matches.length === 0 ? ["(none)"] : result.matches.flatMap((match) => [`- \`${match.name}\`: ${escapeText(match.description)}`, ...match.whenToUse === void 0 ? [] : [` Use when: ${escapeText(match.whenToUse)}`]]);
366
+ return [
367
+ `<skill_candidates complete="${result.complete}" truncated="${result.truncated}">`,
368
+ ...candidates,
369
+ "</skill_candidates>",
370
+ ...result.complete ? [] : ["Discovery was incomplete; an empty result does not prove that no matching skill exists."],
371
+ "Choose zero or one candidate. Call `skill` only for the best match; load another only when the task clearly requires an independent skill."
372
+ ].join("\n");
373
+ }
374
+ function minimumSearchResultBytes() {
375
+ return Math.max(...[true, false].flatMap((complete) => [true, false].map((truncated) => resultBytes({
376
+ complete,
377
+ truncated,
378
+ matches: []
379
+ }))));
380
+ }
381
+ function resultBytes(result) {
382
+ return Buffer.byteLength(renderSkillSearchResult(result), "utf8");
383
+ }
384
+ function routingPhrase(value) {
385
+ return (value.normalize("NFKD").replaceAll(/\p{M}/gu, "").toLowerCase().match(ROUTING_TERM) ?? []).join(" ");
386
+ }
387
+ function routingTerms(value) {
388
+ return new Set(routingPhrase(value).split(" ").filter(Boolean));
389
+ }
390
+ function countMatches(left, right) {
391
+ let count = 0;
392
+ for (const value of left) if (right.has(value)) count += 1;
393
+ return count;
394
+ }
395
+ function directUserText(messages) {
396
+ return messages.flatMap((message) => message.source.kind === "user" ? message.content.flatMap((block) => block.type === "text" ? [block.text] : []) : []).join("\n").trim();
397
+ }
398
+ function compareText(left, right) {
399
+ return Number(left > right) - Number(left < right);
400
+ }
401
+ /** Normalize and length-bound one model-visible summary field. */
402
+ function boundSearchText(value, maxLength) {
403
+ const normalized = value.replaceAll(/\s+/g, " ").trim();
404
+ return normalized.length <= maxLength ? normalized : `${normalized.slice(0, maxLength - 3)}...`;
405
+ }
406
+ function assertPositiveInteger(name, value, minimum = 1) {
407
+ if (!Number.isInteger(value) || value < minimum) throw new Error(`tool-skill: ${name} must be an integer greater than or equal to ${minimum}`);
408
+ }
409
+ /**
410
+ * A whitespace-bounded `/name` token (the public skill-name grammar) anywhere
411
+ * in the text — the same word-boundary shape the transcript chip decoration
412
+ * uses, so a gesture reads as one wherever it sits in the sentence. A second
413
+ * `/` or any non-boundary character breaks the match, which keeps file paths
414
+ * (`/usr/bin`) and fractions (`5/8`) out.
415
+ */
416
+ const SKILL_GESTURE = /(^|\s)\/([a-z0-9]+(?:-[a-z0-9]+)*)(?=\s|$)/g;
417
+ /**
418
+ * `/name` gesture tokens from the claimed user messages, deduplicated in
419
+ * first-seen order. Every text block of direct user input is scanned; no
420
+ * other source can forge a gesture.
421
+ * @param messages - the step's claimed batch.
422
+ * @returns candidate skill names, unvalidated against the registry.
423
+ */
424
+ function invokedSkillNames(messages) {
425
+ const names = [];
426
+ for (const message of messages) {
427
+ if (message.source.kind !== "user") continue;
428
+ for (const block of message.content) {
429
+ if (block.type !== "text") continue;
430
+ for (const match of block.text.matchAll(SKILL_GESTURE)) {
431
+ const name = match[2];
432
+ if (name !== void 0 && !names.includes(name)) names.push(name);
433
+ }
434
+ }
435
+ }
436
+ return names;
437
+ }
438
+ //#endregion
439
+ export { Config, apply, inject, name };
@@ -0,0 +1,23 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@hydraharness/harness-tool-skill`.
4
+ * @module @hydraharness/harness-tool-skill/invariant
5
+ */
6
+ const PACKAGE_NAME = "@hydraharness/harness-tool-skill";
7
+ /** Cordis companion plugin name. */
8
+ const name = "tool-skill-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
13
+ * relations are owned by the capability seam it calls.
14
+ */
15
+ const install = () => {};
16
+ /**
17
+ * Register this package's invariant companion.
18
+ * @param ctx - Cordis context carrying the invariant service.
19
+ * @returns the installed registration's disposer after setup succeeds.
20
+ */
21
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
22
+ //#endregion
23
+ export { apply, inject, name };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Bounded skill routing, model-facing search, and exact loading.
3
+ *
4
+ * @module @hydraharness/harness-tool-skill
5
+ */
6
+ import type { Context } from '@hydraharness/cordis';
7
+ import z from '@hydraharness/schemastery';
8
+ export declare const name = "tool-skill";
9
+ export declare const inject: string[];
10
+ /** Model-facing skill search configuration. */
11
+ export interface Config {
12
+ /** Maximum candidates returned by one search; minimum 1. */
13
+ searchMaxResults?: number;
14
+ /** Maximum normalized description or routing-hint length per candidate; minimum 3. */
15
+ searchDescriptionMaxLength?: number;
16
+ /** Maximum UTF-8 bytes in one rendered search result. */
17
+ searchMaxResultBytes?: number;
18
+ }
19
+ /** Validate and default the model-facing skill search configuration. */
20
+ export declare const Config: z<Config>;
21
+ /**
22
+ * Register bounded automatic routing, model-facing search, exact loading, and direct user invocation.
23
+ */
24
+ export declare function apply(ctx: Context, config?: Config): void;
25
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@hydraharness/harness-tool-skill`.
3
+ * @module @hydraharness/harness-tool-skill/invariant
4
+ */
5
+ import type { Context } from '@hydraharness/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "tool-skill-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,61 @@
1
1
  {
2
2
  "name": "@hydraharness/harness-tool-skill",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "description": "Bounded automatic skill routing, model-facing search, and exact loading for Hydra harness",
4
+ "hydra": {
5
+ "plugin": {
6
+ "application": "Let the agent search for relevant skills and load their full instructions."
7
+ }
8
+ },
9
+ "version": "0.1.1-rc.6",
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/MaiHongPhong1902/Hydra-Harness.git",
16
+ "directory": "packages/skill/tool-skill"
17
+ },
18
+ "type": "module",
19
+ "main": "lib/index.js",
20
+ "types": "lib/types/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./lib/types/index.d.ts",
24
+ "default": "./lib/index.js"
25
+ },
26
+ "./invariant": {
27
+ "types": "./lib/types/invariant.d.ts",
28
+ "default": "./lib/invariant.js"
29
+ },
30
+ "./src/*": "./src/*",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "files": [
34
+ "lib/index.js",
35
+ "lib/invariant.js",
36
+ "lib/types/**/*.d.ts"
37
+ ],
38
+ "license": "MIT",
39
+ "peerDependencies": {
40
+ "@hydraharness/harness-invariants": "^0.1.1-rc.6",
41
+ "@hydraharness/harness-llm": "^0.1.1-rc.6",
42
+ "@hydraharness/harness-skill": "^0.1.1-rc.6",
43
+ "@hydraharness/harness-tools": "^0.1.1-rc.6",
44
+ "@hydraharness/harness-agent": "^0.1.1-rc.6",
45
+ "@hydraharness/cordis": "^4.0.2"
46
+ },
47
+ "dependencies": {
48
+ "@hydraharness/schemastery": "^3.18.2"
49
+ },
50
+ "devDependencies": {
51
+ "@hydraharness/harness-invariants": "^0.1.1-rc.6",
52
+ "@hydraharness/harness-agent": "^0.1.1-rc.6",
53
+ "@hydraharness/harness-llm": "^0.1.1-rc.6",
54
+ "@hydraharness/harness-session": "^0.1.1-rc.6",
55
+ "@hydraharness/harness-scope": "^0.1.1-rc.6",
56
+ "@hydraharness/harness-skill": "^0.1.1-rc.6",
57
+ "@hydraharness/harness-skill-filesystem": "^0.1.1-rc.6",
58
+ "@hydraharness/harness-tools": "^0.1.1-rc.6",
59
+ "@hydraharness/cordis": "^4.0.2"
60
+ }
6
61
  }