portawhip 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +162 -0
- package/SECURITY.md +15 -0
- package/VISION.md +281 -0
- package/adapters/hooks/hook-stub.mjs +21 -0
- package/adapters/hooks/hook-stub.test.mjs +43 -0
- package/adapters/hooks/universal-hook.mjs +348 -0
- package/adapters/hooks/universal-hook.test.mjs +294 -0
- package/adapters/instructions/generate.mjs +114 -0
- package/core/fixtures/bad-recipe.yaml +5 -0
- package/core/fixtures/capability-graph.json +10 -0
- package/core/fixtures/skill-with-metadata/SKILL.md +15 -0
- package/core/registry/capability-docs.mjs +145 -0
- package/core/registry/capability-graph-compiler.mjs +90 -0
- package/core/registry/capability-graph.mjs +53 -0
- package/core/registry/capability-kind.mjs +9 -0
- package/core/registry/cli-enrich.mjs +316 -0
- package/core/registry/cli-enrich.test.mjs +119 -0
- package/core/registry/discover.mjs +379 -0
- package/core/registry/enrich.mjs +213 -0
- package/core/registry/enrich.test.mjs +53 -0
- package/core/registry/eval-harvest.mjs +93 -0
- package/core/registry/eval-harvest.test.mjs +64 -0
- package/core/registry/registry.mjs +197 -0
- package/core/router/concept-vector.mjs +91 -0
- package/core/router/concept-vector.test.mjs +36 -0
- package/core/router/curated-trigger.test.mjs +28 -0
- package/core/router/dense-embedder.mjs +242 -0
- package/core/router/fusion.mjs +18 -0
- package/core/router/hybrid-router.mjs +375 -0
- package/core/router/intent-evidence.mjs +60 -0
- package/core/router/prompt-hygiene.mjs +31 -0
- package/core/router/route-entry.mjs +74 -0
- package/core/router/router-cli.mjs +218 -0
- package/core/router/router-cli.test.mjs +25 -0
- package/core/router/router-eval.mjs +166 -0
- package/core/router/router-live.test.mjs +89 -0
- package/core/router/router.test.mjs +838 -0
- package/core/router/scorer.mjs +72 -0
- package/core/router/sparse-retriever.mjs +79 -0
- package/core/router/tokenize.mjs +42 -0
- package/core/state/bundle-state.mjs +124 -0
- package/core/state/bundle-state.test.mjs +175 -0
- package/core/state/config.mjs +115 -0
- package/core/state/feedback.mjs +129 -0
- package/core/state/feedback.test.mjs +181 -0
- package/core/state/runtime-root.test.mjs +22 -0
- package/core/state/stack-detect.mjs +102 -0
- package/core/state/stack-detect.test.mjs +64 -0
- package/core/surface/config-sync-backends.mjs +224 -0
- package/core/surface/connector-targets.mjs +205 -0
- package/core/surface/discover-hooks.mjs +151 -0
- package/core/surface/discover-hooks.test.mjs +63 -0
- package/core/surface/discover-surface.test.mjs +49 -0
- package/core/surface/extra-hosts.mjs +48 -0
- package/core/surface/extra-hosts.test.mjs +24 -0
- package/core/surface/hook-targets.mjs +102 -0
- package/core/surface/surface-copy-targets.mjs +37 -0
- package/core/surface/surface-inventory.mjs +106 -0
- package/core/surface/surface-matrix.mjs +133 -0
- package/core/surface/surface-matrix.test.mjs +90 -0
- package/docs/router-eval-set.jsonl +38 -0
- package/hooks.manifest.yaml +16 -0
- package/package.json +101 -0
- package/recipe.yaml +247 -0
- package/recipes/foundry.yaml +27 -0
- package/recipes/manifest.yaml +18 -0
- package/recipes/roles/backend-data.yaml +26 -0
- package/recipes/roles/coding.yaml +26 -0
- package/recipes/roles/frontend.yaml +14 -0
- package/recipes/roles/research.yaml +31 -0
- package/recipes/roles/secure.yaml +27 -0
- package/router.config.yaml +120 -0
- package/scripts/bundles.mjs +131 -0
- package/scripts/doctor.mjs +117 -0
- package/scripts/embedded-hooks.mjs +27 -0
- package/scripts/hosts.mjs +60 -0
- package/scripts/link/link-connectors.mjs +190 -0
- package/scripts/link/link-connectors.test.mjs +111 -0
- package/scripts/link/link-hooks.mjs +259 -0
- package/scripts/link/link-hooks.test.mjs +112 -0
- package/scripts/link/link-surfaces.mjs +188 -0
- package/scripts/link/link-surfaces.test.mjs +76 -0
- package/scripts/load.mjs +143 -0
- package/scripts/package-contract.test.mjs +26 -0
- package/scripts/surface-inventory.mjs +6 -0
- package/scripts/sync/agents-surface.mjs +55 -0
- package/scripts/sync/agents-surface.test.mjs +18 -0
- package/scripts/sync/auto-sync.mjs +135 -0
- package/scripts/sync/auto-sync.test.mjs +41 -0
- package/scripts/sync/import-surfaces.mjs +331 -0
- package/scripts/sync/import-surfaces.test.mjs +106 -0
- package/scripts/sync/sync-config.mjs +230 -0
- package/scripts/sync/sync-config.test.mjs +141 -0
- package/scripts/sync/sync-surfaces.mjs +229 -0
- package/scripts/sync/sync-surfaces.test.mjs +66 -0
- package/scripts/tui-actions.mjs +59 -0
- package/scripts/tui-actions.test.mjs +53 -0
- package/scripts/tui.mjs +782 -0
- package/scripts/uninstall-all.mjs +39 -0
- package/server/mcp-server.mjs +129 -0
- package/server/mcp-server.test.mjs +175 -0
- package/skills/portawhip/SKILL.md +80 -0
- package/surface-matrix.yaml +93 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Phase 2 step 3: idempotent insert/remove of a one-liner instruction block
|
|
3
|
+
// into a host's instruction file (CLAUDE.md / AGENTS.md / GEMINI.md), so the
|
|
4
|
+
// model knows to call the harness-router MCP server before starting a task.
|
|
5
|
+
// Marker comments make this reversible — never touches the rest of the file.
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
8
|
+
|
|
9
|
+
const START = "<!-- harness-router:start -->";
|
|
10
|
+
const END = "<!-- harness-router:end -->";
|
|
11
|
+
|
|
12
|
+
// Plain wording — safe default for hosts with no lazy tool-loading concept
|
|
13
|
+
// (Codex/AGENTS.md verified 8/8 route() calls with this text alone).
|
|
14
|
+
export const GENERIC_BLOCK = `${START}
|
|
15
|
+
Before starting a task, call \`route(task summary)\` on the harness-router MCP
|
|
16
|
+
server and follow any returned pointers. Pass only the positively requested action
|
|
17
|
+
and its direct object; do not copy the raw prompt. Drop chit-chat, venting,
|
|
18
|
+
background, and any rejected, negated, or hypothetical option. If a request is
|
|
19
|
+
buried in chat, route only the request; if the message names several distinct
|
|
20
|
+
actions, call route once per action.
|
|
21
|
+
Example: "ugh CI is flaky again, anyway find where we parse the auth token" ->
|
|
22
|
+
route("find the code that parses the auth token").
|
|
23
|
+
An empty result is normal and means nothing relevant is installed — proceed without it.
|
|
24
|
+
${END}`;
|
|
25
|
+
|
|
26
|
+
// Claude Code defers MCP tool schemas behind ToolSearch until looked up by
|
|
27
|
+
// name — confirmed live: harness-router's route/list_all showed as deferred
|
|
28
|
+
// at session start and were never called (0/8 in Phase 2 verify), because
|
|
29
|
+
// the generic wording never told the model to look them up first.
|
|
30
|
+
export const CLAUDE_CODE_BLOCK = `${START}
|
|
31
|
+
Before starting a task, call \`route(task summary)\` on the harness-router MCP
|
|
32
|
+
server and follow any returned pointers. Pass only the positively requested action
|
|
33
|
+
and its direct object; do not copy the raw prompt. Drop chit-chat, venting,
|
|
34
|
+
background, and any rejected, negated, or hypothetical option. If a request is
|
|
35
|
+
buried in chat, route only the request; if the message names several distinct
|
|
36
|
+
actions, call route once per action.
|
|
37
|
+
Example: "ugh CI is flaky again, anyway find where we parse the auth token" ->
|
|
38
|
+
route("find the code that parses the auth token").
|
|
39
|
+
If \`route\`/\`list_all\` show up as deferred/pending tools rather than directly
|
|
40
|
+
callable, first call ToolSearch with query
|
|
41
|
+
"select:mcp__harness-router__route,mcp__harness-router__list_all" to load them,
|
|
42
|
+
then call route(). An empty result from route() is normal and means nothing
|
|
43
|
+
relevant is installed — proceed without it.
|
|
44
|
+
${END}`;
|
|
45
|
+
|
|
46
|
+
export const CURSOR_RULE_BLOCK = `---
|
|
47
|
+
description: Route tasks through the project harness-router before starting work
|
|
48
|
+
alwaysApply: true
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
${GENERIC_BLOCK}`;
|
|
52
|
+
|
|
53
|
+
// Windsurf workspace rules live under .windsurf/rules/*.md and pick an
|
|
54
|
+
// activation mode via the `trigger` frontmatter field; always_on = injected
|
|
55
|
+
// into every request in the workspace. This is a dedicated harness-owned
|
|
56
|
+
// file (see owned:true in connector-targets), so it is written whole, not
|
|
57
|
+
// marker-upserted — the frontmatter must stay the first bytes of the file.
|
|
58
|
+
export const WINDSURF_RULE_BLOCK = `---
|
|
59
|
+
trigger: always_on
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
${GENERIC_BLOCK}`;
|
|
63
|
+
|
|
64
|
+
export function blockForVariant(variant = "generic") {
|
|
65
|
+
if (variant === "claude-code") return CLAUDE_CODE_BLOCK;
|
|
66
|
+
if (variant === "cursor-rule") return CURSOR_RULE_BLOCK;
|
|
67
|
+
if (variant === "windsurf-rule") return WINDSURF_RULE_BLOCK;
|
|
68
|
+
return GENERIC_BLOCK;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function upsertBlock(targetPath, block = GENERIC_BLOCK) {
|
|
72
|
+
const before = existsSync(targetPath) ? readFileSync(targetPath, "utf8") : "";
|
|
73
|
+
const startIdx = before.indexOf(START);
|
|
74
|
+
const endIdx = before.indexOf(END);
|
|
75
|
+
const after =
|
|
76
|
+
startIdx !== -1 && endIdx !== -1
|
|
77
|
+
? before.slice(0, startIdx) + block + before.slice(endIdx + END.length)
|
|
78
|
+
: before.length > 0
|
|
79
|
+
? `${before.trimEnd()}\n\n${block}\n`
|
|
80
|
+
: `${block}\n`;
|
|
81
|
+
writeFileSync(targetPath, after);
|
|
82
|
+
return after !== before;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function removeBlock(targetPath) {
|
|
86
|
+
if (!existsSync(targetPath)) return false;
|
|
87
|
+
const content = readFileSync(targetPath, "utf8");
|
|
88
|
+
const startIdx = content.indexOf(START);
|
|
89
|
+
const endIdx = content.indexOf(END);
|
|
90
|
+
if (startIdx === -1 || endIdx === -1) return false;
|
|
91
|
+
const before = content.slice(0, startIdx).replace(/\n+$/, "\n");
|
|
92
|
+
const after = content.slice(endIdx + END.length).replace(/^\n+/, "");
|
|
93
|
+
writeFileSync(targetPath, `${before}${after}`);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function main() {
|
|
98
|
+
const [, , command, targetPath, variant] = process.argv;
|
|
99
|
+
if (!targetPath || !["install", "remove"].includes(command)) {
|
|
100
|
+
console.error(
|
|
101
|
+
"usage: generate.mjs <install|remove> <path-to-CLAUDE.md-or-similar> [claude-code|generic]",
|
|
102
|
+
);
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const block = blockForVariant(variant);
|
|
107
|
+
const changed = command === "install" ? upsertBlock(targetPath, block) : removeBlock(targetPath);
|
|
108
|
+
console.log(`${command} ${targetPath}: ${changed ? "changed" : "no-op"}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
import { pathToFileURL } from "node:url";
|
|
112
|
+
|
|
113
|
+
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
114
|
+
if (isMain) main();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: viewport-audit
|
|
3
|
+
description: Inspect responsive viewport failures and layout overflow.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Viewport Audit
|
|
7
|
+
|
|
8
|
+
## When to Use
|
|
9
|
+
|
|
10
|
+
Use this skill when a task mentions clipped labels, mobile toolbar wrapping,
|
|
11
|
+
responsive overflow, or viewport-specific layout checks.
|
|
12
|
+
|
|
13
|
+
## Related
|
|
14
|
+
|
|
15
|
+
Pair with Playwright visual checks when browser rendering is available.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
function uniq(values) {
|
|
5
|
+
return [...new Set(values.filter((v) => typeof v === "string" && v.trim() !== ""))];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function skillFileFor(path) {
|
|
9
|
+
if (!path || !existsSync(path)) return null;
|
|
10
|
+
const stat = statSync(path);
|
|
11
|
+
if (stat.isFile()) return path;
|
|
12
|
+
if (!stat.isDirectory()) return null;
|
|
13
|
+
const skillFile = join(path, "SKILL.md");
|
|
14
|
+
return existsSync(skillFile) ? skillFile : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseFrontmatter(text) {
|
|
18
|
+
const match = String(text ?? "").match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
19
|
+
if (!match) return { data: {}, body: text };
|
|
20
|
+
const data = {};
|
|
21
|
+
for (const line of match[1].split(/\r?\n/)) {
|
|
22
|
+
const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
|
|
23
|
+
if (!field) continue;
|
|
24
|
+
data[field[1]] = field[2].replace(/^["']|["']$/g, "").trim();
|
|
25
|
+
}
|
|
26
|
+
return { data, body: text.slice(match[0].length) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function extractHeadings(body, max = 12) {
|
|
30
|
+
const headings = [];
|
|
31
|
+
for (const line of String(body ?? "").split(/\r?\n/)) {
|
|
32
|
+
const match = line.match(/^#{1,3}\s+(.+)$/);
|
|
33
|
+
if (!match) continue;
|
|
34
|
+
headings.push(match[1].replace(/\s+#$/, "").trim());
|
|
35
|
+
if (headings.length >= max) break;
|
|
36
|
+
}
|
|
37
|
+
return headings;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function sectionByHeading(body, patterns, maxChars = 1200) {
|
|
41
|
+
const lines = String(body ?? "").split(/\r?\n/);
|
|
42
|
+
const chunks = [];
|
|
43
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
44
|
+
const heading = lines[i].match(/^(#{1,3})\s+(.+)$/);
|
|
45
|
+
if (!heading) continue;
|
|
46
|
+
const title = heading[2].toLowerCase();
|
|
47
|
+
if (!patterns.some((pattern) => pattern.test(title))) continue;
|
|
48
|
+
const level = heading[1].length;
|
|
49
|
+
const collected = [];
|
|
50
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
51
|
+
const nextHeading = lines[j].match(/^(#{1,3})\s+/);
|
|
52
|
+
if (nextHeading && nextHeading[1].length <= level) break;
|
|
53
|
+
collected.push(lines[j]);
|
|
54
|
+
}
|
|
55
|
+
const text = collected
|
|
56
|
+
.join("\n")
|
|
57
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
58
|
+
.replace(/[`*_>\[\]()]/g, " ")
|
|
59
|
+
.replace(/\s+/g, " ")
|
|
60
|
+
.trim();
|
|
61
|
+
if (text) chunks.push(text);
|
|
62
|
+
}
|
|
63
|
+
return chunks.join("\n").slice(0, maxChars);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readSkillMetadata(path) {
|
|
67
|
+
try {
|
|
68
|
+
const skillFile = skillFileFor(path);
|
|
69
|
+
if (!skillFile) return {};
|
|
70
|
+
const text = readFileSync(skillFile, "utf8");
|
|
71
|
+
const { data, body } = parseFrontmatter(text);
|
|
72
|
+
return {
|
|
73
|
+
skillFile,
|
|
74
|
+
frontmatterName: data.name ?? null,
|
|
75
|
+
frontmatterDescription: data.description ?? null,
|
|
76
|
+
headings: extractHeadings(body),
|
|
77
|
+
activation: sectionByHeading(body, [
|
|
78
|
+
/when to use/,
|
|
79
|
+
/when to activate/,
|
|
80
|
+
/activation/,
|
|
81
|
+
/use when/,
|
|
82
|
+
/trigger/,
|
|
83
|
+
/routing/,
|
|
84
|
+
]),
|
|
85
|
+
related: sectionByHeading(body, [/related/, /coordination/, /handoff/], 600),
|
|
86
|
+
};
|
|
87
|
+
} catch {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function pointerFor(entry) {
|
|
93
|
+
// Every type:cli entry in this project installs via mise (scripts/load.mjs's
|
|
94
|
+
// loadCli always dispatches through it), so a bare command name only
|
|
95
|
+
// resolves if the invoking shell happens to have `mise activate` wired in
|
|
96
|
+
// - not guaranteed, and not something this project can set up on someone
|
|
97
|
+
// else's machine (VISION.md: cross-OS by detection, not a per-machine
|
|
98
|
+
// setup step). `mise exec --` always works, on every OS, with zero setup.
|
|
99
|
+
// Found live 2026-07-05: a bundle-installed CLI worked via mise but the
|
|
100
|
+
// bare command was "not found" in a shell with no mise activation.
|
|
101
|
+
if (entry.type === "cli" && entry.source) return `mise exec -- ${entry.source}`;
|
|
102
|
+
return entry.path ?? entry.source ?? null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function buildCapabilityDocs(index) {
|
|
106
|
+
return index.entries
|
|
107
|
+
.filter((entry) => entry.route)
|
|
108
|
+
.map((entry) => {
|
|
109
|
+
const triggers = Array.isArray(entry.route.triggers) ? entry.route.triggers : [];
|
|
110
|
+
const metadata = entry.type === "skill" ? readSkillMetadata(entry.path) : {};
|
|
111
|
+
const fields = {
|
|
112
|
+
id: entry.id,
|
|
113
|
+
type: entry.type,
|
|
114
|
+
origin: entry.origin,
|
|
115
|
+
pointer: pointerFor(entry),
|
|
116
|
+
source: entry.source ?? null,
|
|
117
|
+
path: entry.path ?? null,
|
|
118
|
+
description: entry.route.description,
|
|
119
|
+
triggers,
|
|
120
|
+
frontmatterName: metadata.frontmatterName ?? null,
|
|
121
|
+
frontmatterDescription: metadata.frontmatterDescription ?? null,
|
|
122
|
+
headings: metadata.headings ?? [],
|
|
123
|
+
activation: metadata.activation ?? null,
|
|
124
|
+
related: metadata.related ?? null,
|
|
125
|
+
readyMarker: entry.route.readyMarker ?? null,
|
|
126
|
+
readyHint: entry.route.readyHint ?? null,
|
|
127
|
+
action: entry.route.action ?? null,
|
|
128
|
+
};
|
|
129
|
+
const text = uniq([
|
|
130
|
+
entry.id,
|
|
131
|
+
entry.type,
|
|
132
|
+
entry.origin,
|
|
133
|
+
entry.route.description,
|
|
134
|
+
...triggers,
|
|
135
|
+
entry.source,
|
|
136
|
+
entry.path,
|
|
137
|
+
metadata.frontmatterName,
|
|
138
|
+
metadata.frontmatterDescription,
|
|
139
|
+
...(metadata.headings ?? []),
|
|
140
|
+
metadata.activation,
|
|
141
|
+
metadata.related,
|
|
142
|
+
]).join("\n");
|
|
143
|
+
return { ...fields, text };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { buildCapabilityDocs } from "./capability-docs.mjs";
|
|
4
|
+
import { capabilityKind } from "./capability-kind.mjs";
|
|
5
|
+
import { tokenize } from "../router/tokenize.mjs";
|
|
6
|
+
|
|
7
|
+
const GRAPH_STOPWORDS = new Set([
|
|
8
|
+
"agent",
|
|
9
|
+
"agents",
|
|
10
|
+
"code",
|
|
11
|
+
"data",
|
|
12
|
+
"file",
|
|
13
|
+
"files",
|
|
14
|
+
"project",
|
|
15
|
+
"task",
|
|
16
|
+
"tool",
|
|
17
|
+
"tools",
|
|
18
|
+
"use",
|
|
19
|
+
"user",
|
|
20
|
+
"workflow",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
function tokenSet(doc) {
|
|
24
|
+
const tokens = new Set(tokenize(doc.text).filter((token) => !GRAPH_STOPWORDS.has(token)));
|
|
25
|
+
for (const token of tokenize(doc.id)) tokens.add(token);
|
|
26
|
+
return tokens;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function overlapScore(left, right) {
|
|
30
|
+
let score = 0;
|
|
31
|
+
for (const token of left.tokens) {
|
|
32
|
+
if (right.tokens.has(token)) score += 1;
|
|
33
|
+
}
|
|
34
|
+
const rightId = right.doc.id.toLowerCase();
|
|
35
|
+
const leftId = left.doc.id.toLowerCase();
|
|
36
|
+
if (String(left.doc.text ?? "").toLowerCase().includes(rightId)) score += 4;
|
|
37
|
+
if (String(right.doc.text ?? "").toLowerCase().includes(leftId)) score += 4;
|
|
38
|
+
return score;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function edgeType(from, to) {
|
|
42
|
+
const fromKind = capabilityKind(from.type);
|
|
43
|
+
const toKind = capabilityKind(to.type);
|
|
44
|
+
if (fromKind === "skill" && toKind === "tool") return "skill_uses_tool";
|
|
45
|
+
if (fromKind === "tool" && toKind === "skill") return "tool_supports_skill";
|
|
46
|
+
if (fromKind === "skill" && toKind === "skill") return "related_skill";
|
|
47
|
+
return "related_tool";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function compileCapabilityGraph(
|
|
51
|
+
index,
|
|
52
|
+
{ maxEdgesPerNode = 3, minScore = 3 } = {},
|
|
53
|
+
) {
|
|
54
|
+
const docs = buildCapabilityDocs(index);
|
|
55
|
+
const prepared = docs.map((doc) => ({ doc, tokens: tokenSet(doc) }));
|
|
56
|
+
const edges = [];
|
|
57
|
+
|
|
58
|
+
for (const source of prepared) {
|
|
59
|
+
const scored = [];
|
|
60
|
+
for (const target of prepared) {
|
|
61
|
+
if (source.doc.id === target.doc.id) continue;
|
|
62
|
+
const score = overlapScore(source, target);
|
|
63
|
+
if (score >= minScore) {
|
|
64
|
+
scored.push({
|
|
65
|
+
from: source.doc.id,
|
|
66
|
+
to: target.doc.id,
|
|
67
|
+
type: edgeType(source.doc, target.doc),
|
|
68
|
+
weight: Math.min(1, Number((score / 8).toFixed(3))),
|
|
69
|
+
score,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
scored
|
|
74
|
+
.sort((a, b) => b.score - a.score || a.to.localeCompare(b.to))
|
|
75
|
+
.slice(0, maxEdgesPerNode)
|
|
76
|
+
.forEach(({ score, ...edge }) => edges.push(edge));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
generatedAt: new Date().toISOString(),
|
|
81
|
+
nodeCount: docs.length,
|
|
82
|
+
edgeCount: edges.length,
|
|
83
|
+
edges,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function writeCapabilityGraph(path, graph) {
|
|
88
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
89
|
+
writeFileSync(path, `${JSON.stringify(graph, null, 2)}\n`);
|
|
90
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export function loadCapabilityGraph(path) {
|
|
4
|
+
if (!path || !existsSync(path)) return { edges: [] };
|
|
5
|
+
const graph = JSON.parse(readFileSync(path, "utf8"));
|
|
6
|
+
return { edges: Array.isArray(graph.edges) ? graph.edges : [] };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function edgeWeight(edge) {
|
|
10
|
+
return typeof edge.weight === "number" ? edge.weight : 1;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildNeighborMap(graph) {
|
|
14
|
+
const neighbors = new Map();
|
|
15
|
+
for (const edge of graph.edges ?? []) {
|
|
16
|
+
if (!edge.from || !edge.to) continue;
|
|
17
|
+
const list = neighbors.get(edge.from) ?? [];
|
|
18
|
+
list.push({ id: edge.to, type: edge.type ?? "related", weight: edgeWeight(edge) });
|
|
19
|
+
neighbors.set(edge.from, list);
|
|
20
|
+
}
|
|
21
|
+
return neighbors;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function expandWithGraph(candidates, docs, graph, { boost = 0.25 } = {}) {
|
|
25
|
+
if (!candidates.length || !graph?.edges?.length) return candidates;
|
|
26
|
+
|
|
27
|
+
const docsById = new Map(docs.map((doc) => [doc.id, doc]));
|
|
28
|
+
const neighbors = buildNeighborMap(graph);
|
|
29
|
+
const merged = new Map(candidates.map((candidate) => [candidate.id, { ...candidate }]));
|
|
30
|
+
|
|
31
|
+
for (const candidate of candidates) {
|
|
32
|
+
for (const neighbor of neighbors.get(candidate.id) ?? []) {
|
|
33
|
+
const doc = docsById.get(neighbor.id);
|
|
34
|
+
if (!doc) continue;
|
|
35
|
+
const score = candidate.score * boost * neighbor.weight;
|
|
36
|
+
const current = merged.get(neighbor.id);
|
|
37
|
+
if (current) {
|
|
38
|
+
current.graphSeen = true;
|
|
39
|
+
} else {
|
|
40
|
+
merged.set(neighbor.id, {
|
|
41
|
+
id: neighbor.id,
|
|
42
|
+
doc,
|
|
43
|
+
score,
|
|
44
|
+
graphBoosted: true,
|
|
45
|
+
graphSource: candidate.id,
|
|
46
|
+
graphEdgeType: neighbor.type,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return [...merged.values()].sort((a, b) => b.score - a.score || a.id.localeCompare(b.id));
|
|
53
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function capabilityKind(type) {
|
|
2
|
+
if (type === "mcp" || type === "cli") return "tool";
|
|
3
|
+
return type;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function matchesSuggestKind(type, suggest = "any") {
|
|
7
|
+
if (!suggest || suggest === "any") return true;
|
|
8
|
+
return capabilityKind(type) === suggest;
|
|
9
|
+
}
|