@klhapp/skillmux 0.2.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 +147 -0
- package/LICENSE +21 -0
- package/README.md +410 -0
- package/config.example.toml +5 -0
- package/config.remote.example.toml +25 -0
- package/docs/configuration.md +100 -0
- package/docs/releasing.md +62 -0
- package/docs/schema.json +314 -0
- package/package.json +55 -0
- package/src/audit.ts +15 -0
- package/src/cli.ts +482 -0
- package/src/clients.ts +114 -0
- package/src/config.ts +338 -0
- package/src/db.ts +280 -0
- package/src/decision.ts +42 -0
- package/src/doctor.ts +68 -0
- package/src/eval.ts +74 -0
- package/src/init.ts +122 -0
- package/src/install.ts +113 -0
- package/src/lifecycle.ts +51 -0
- package/src/manifest.ts +105 -0
- package/src/metrics.ts +101 -0
- package/src/models.ts +20 -0
- package/src/rate-limiter.ts +110 -0
- package/src/readiness.ts +30 -0
- package/src/router-core.ts +435 -0
- package/src/rrf.ts +31 -0
- package/src/scan.ts +260 -0
- package/src/server.ts +318 -0
- package/src/stats.ts +165 -0
- package/src/sync.ts +182 -0
- package/src/types.ts +178 -0
- package/src/vault.ts +109 -0
package/src/stats.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type { AuditCandidate, AuditRow } from "./types";
|
|
3
|
+
|
|
4
|
+
export const SINCE_PATTERN = /^(\d+[hdwmy]|\d{4}-\d{2}-\d{2}([T ].+)?)$/;
|
|
5
|
+
|
|
6
|
+
export interface SkillStat {
|
|
7
|
+
skill_id: string;
|
|
8
|
+
matched_count: number;
|
|
9
|
+
candidate_count: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface NoMatchQuery {
|
|
13
|
+
query: string;
|
|
14
|
+
count: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface StatsResponse {
|
|
18
|
+
since: string;
|
|
19
|
+
until: string;
|
|
20
|
+
outcome_totals: { matched: number; ambiguous: number; no_match: number };
|
|
21
|
+
ambiguous_rate: number;
|
|
22
|
+
skills: SkillStat[];
|
|
23
|
+
top_no_match_queries: NoMatchQuery[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const RELATIVE_WINDOW = /^(\d+)([hdwmy])$/;
|
|
27
|
+
const UNIT_MS: Record<string, number> = {
|
|
28
|
+
h: 3_600_000,
|
|
29
|
+
d: 86_400_000,
|
|
30
|
+
w: 604_800_000,
|
|
31
|
+
m: 2_592_000_000,
|
|
32
|
+
y: 31_536_000_000,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function parseSince(since: string, now: Date = new Date()): Date {
|
|
36
|
+
if (!SINCE_PATTERN.test(since)) throw new Error(`invalid --since window: ${since}`);
|
|
37
|
+
|
|
38
|
+
const relative = RELATIVE_WINDOW.exec(since);
|
|
39
|
+
if (relative) {
|
|
40
|
+
const amount = Number(relative[1]);
|
|
41
|
+
const unitMs = UNIT_MS[relative[2]!]!;
|
|
42
|
+
return new Date(now.getTime() - amount * unitMs);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parsed = new Date(since);
|
|
46
|
+
if (Number.isNaN(parsed.getTime())) throw new Error(`invalid --since window: ${since}`);
|
|
47
|
+
return parsed;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function computeStats(rows: AuditRow[], since: Date, until: Date): StatsResponse {
|
|
51
|
+
const outcome_totals = { matched: 0, ambiguous: 0, no_match: 0 };
|
|
52
|
+
const skillStats = new Map<string, { matched_count: number; candidate_count: number }>();
|
|
53
|
+
const noMatchCounts = new Map<string, number>();
|
|
54
|
+
|
|
55
|
+
function statFor(skillId: string) {
|
|
56
|
+
let stat = skillStats.get(skillId);
|
|
57
|
+
if (!stat) {
|
|
58
|
+
stat = { matched_count: 0, candidate_count: 0 };
|
|
59
|
+
skillStats.set(skillId, stat);
|
|
60
|
+
}
|
|
61
|
+
return stat;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
outcome_totals[row.outcome]++;
|
|
66
|
+
|
|
67
|
+
const seenInRow = new Set<string>();
|
|
68
|
+
for (const candidate of row.candidates) {
|
|
69
|
+
if (seenInRow.has(candidate.skill_id)) continue;
|
|
70
|
+
seenInRow.add(candidate.skill_id);
|
|
71
|
+
statFor(candidate.skill_id).candidate_count++;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (row.outcome === "matched" && row.selected_skill_id) {
|
|
75
|
+
statFor(row.selected_skill_id).matched_count++;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (row.outcome === "no_match") {
|
|
79
|
+
noMatchCounts.set(row.query, (noMatchCounts.get(row.query) ?? 0) + 1);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const total = outcome_totals.matched + outcome_totals.ambiguous + outcome_totals.no_match;
|
|
84
|
+
const ambiguous_rate = total > 0 ? outcome_totals.ambiguous / total : 0;
|
|
85
|
+
|
|
86
|
+
const skills = [...skillStats.entries()]
|
|
87
|
+
.map(([skill_id, stat]) => ({ skill_id, ...stat }))
|
|
88
|
+
.sort((a, b) => b.matched_count - a.matched_count);
|
|
89
|
+
|
|
90
|
+
const top_no_match_queries = [...noMatchCounts.entries()]
|
|
91
|
+
.map(([query, count]) => ({ query, count }))
|
|
92
|
+
.sort((a, b) => b.count - a.count)
|
|
93
|
+
.slice(0, 20);
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
since: since.toISOString(),
|
|
97
|
+
until: until.toISOString(),
|
|
98
|
+
outcome_totals,
|
|
99
|
+
ambiguous_rate,
|
|
100
|
+
skills,
|
|
101
|
+
top_no_match_queries,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface AuditTableRow {
|
|
106
|
+
id: number;
|
|
107
|
+
ts: string;
|
|
108
|
+
query: string;
|
|
109
|
+
outcome: AuditRow["outcome"];
|
|
110
|
+
retrieval: AuditRow["retrieval"];
|
|
111
|
+
candidates: string;
|
|
112
|
+
selected_skill_id: string | null;
|
|
113
|
+
latency_ms: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function queryAuditRows(db: Database, sinceIso: string): AuditRow[] {
|
|
117
|
+
const rows = db
|
|
118
|
+
.query("SELECT id, ts, query, outcome, retrieval, candidates, selected_skill_id, latency_ms FROM audit WHERE ts >= ? ORDER BY ts ASC")
|
|
119
|
+
.all(sinceIso) as AuditTableRow[];
|
|
120
|
+
return rows.map((row) => ({
|
|
121
|
+
id: row.id,
|
|
122
|
+
ts: row.ts,
|
|
123
|
+
query: row.query,
|
|
124
|
+
outcome: row.outcome,
|
|
125
|
+
retrieval: row.retrieval,
|
|
126
|
+
candidates: JSON.parse(row.candidates) as AuditCandidate[],
|
|
127
|
+
selected_skill_id: row.selected_skill_id,
|
|
128
|
+
latency_ms: row.latency_ms,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getStats(db: Database, since: string, now: Date = new Date()): StatsResponse {
|
|
133
|
+
const sinceDate = parseSince(since, now);
|
|
134
|
+
const rows = queryAuditRows(db, sinceDate.toISOString());
|
|
135
|
+
return computeStats(rows, sinceDate, now);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function renderStatsText(stats: StatsResponse): string {
|
|
139
|
+
const lines: string[] = [];
|
|
140
|
+
lines.push(`window: ${stats.since} .. ${stats.until}`);
|
|
141
|
+
lines.push(
|
|
142
|
+
`outcomes: matched=${stats.outcome_totals.matched} ambiguous=${stats.outcome_totals.ambiguous} ` +
|
|
143
|
+
`no_match=${stats.outcome_totals.no_match} (ambiguous_rate=${stats.ambiguous_rate.toFixed(3)})`,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
lines.push("skills:");
|
|
147
|
+
if (stats.skills.length === 0) {
|
|
148
|
+
lines.push(" (none)");
|
|
149
|
+
} else {
|
|
150
|
+
for (const skill of stats.skills) {
|
|
151
|
+
lines.push(` ${skill.skill_id} matched=${skill.matched_count} candidate=${skill.candidate_count}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
lines.push("top no_match queries:");
|
|
156
|
+
if (stats.top_no_match_queries.length === 0) {
|
|
157
|
+
lines.push(" (none)");
|
|
158
|
+
} else {
|
|
159
|
+
for (const entry of stats.top_no_match_queries) {
|
|
160
|
+
lines.push(` "${entry.query}" (${entry.count})`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return lines.join("\n");
|
|
165
|
+
}
|
package/src/sync.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, relative } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const SKILLMUX_MARKER_FILENAME = ".skillmux";
|
|
6
|
+
export const LEGACY_MARKER_FILENAME = ".skr";
|
|
7
|
+
|
|
8
|
+
export interface SkillmuxMarker {
|
|
9
|
+
managed_by: "skillmux" | "skr";
|
|
10
|
+
target: string;
|
|
11
|
+
created_at: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readSkillmuxMarker(dir: string): SkillmuxMarker | null {
|
|
15
|
+
const newPath = join(dir, SKILLMUX_MARKER_FILENAME);
|
|
16
|
+
if (existsSync(newPath)) {
|
|
17
|
+
return JSON.parse(readFileSync(newPath, "utf-8")) as SkillmuxMarker;
|
|
18
|
+
}
|
|
19
|
+
const legacyPath = join(dir, LEGACY_MARKER_FILENAME);
|
|
20
|
+
if (existsSync(legacyPath)) {
|
|
21
|
+
return JSON.parse(readFileSync(legacyPath, "utf-8")) as SkillmuxMarker;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeSkillmuxMarker(dir: string, targetName: string): void {
|
|
27
|
+
const marker: SkillmuxMarker = {
|
|
28
|
+
managed_by: "skillmux",
|
|
29
|
+
target: targetName,
|
|
30
|
+
created_at: new Date().toISOString(),
|
|
31
|
+
};
|
|
32
|
+
writeFileSync(join(dir, SKILLMUX_MARKER_FILENAME), JSON.stringify(marker, null, 2));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SyncTargetParams {
|
|
36
|
+
vaultPath: string;
|
|
37
|
+
targetDir: string;
|
|
38
|
+
targetName: string;
|
|
39
|
+
coreSkillIds: string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SyncTargetResult {
|
|
43
|
+
added: string[];
|
|
44
|
+
removed: string[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SyncTargetOptions {
|
|
48
|
+
dryRun?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function syncTarget(params: SyncTargetParams, options: SyncTargetOptions = {}): SyncTargetResult {
|
|
52
|
+
const { vaultPath, targetDir, targetName, coreSkillIds } = params;
|
|
53
|
+
const { dryRun = false } = options;
|
|
54
|
+
|
|
55
|
+
if (!existsSync(targetDir)) {
|
|
56
|
+
if (dryRun) return { added: [...coreSkillIds], removed: [] };
|
|
57
|
+
mkdirSync(targetDir, { recursive: true });
|
|
58
|
+
for (const skillId of coreSkillIds) {
|
|
59
|
+
symlinkSync(join(vaultPath, skillId), join(targetDir, skillId));
|
|
60
|
+
}
|
|
61
|
+
writeSkillmuxMarker(targetDir, targetName);
|
|
62
|
+
return { added: [...coreSkillIds], removed: [] };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!readSkillmuxMarker(targetDir)) {
|
|
66
|
+
throw new Error(`not owned by skillmux — run skillmux init`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const desired = new Set(coreSkillIds);
|
|
70
|
+
const existing = readdirSync(targetDir).filter((name) => name !== SKILLMUX_MARKER_FILENAME && name !== LEGACY_MARKER_FILENAME);
|
|
71
|
+
|
|
72
|
+
const removed = existing.filter((name) => !desired.has(name));
|
|
73
|
+
const added = coreSkillIds.filter((skillId) => !existing.includes(skillId));
|
|
74
|
+
if (dryRun) return { added, removed };
|
|
75
|
+
|
|
76
|
+
for (const name of removed) unlinkSync(join(targetDir, name));
|
|
77
|
+
for (const skillId of added) symlinkSync(join(vaultPath, skillId), join(targetDir, skillId));
|
|
78
|
+
|
|
79
|
+
return { added, removed };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface AdoptTargetResult {
|
|
83
|
+
adopted: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Marks an existing directory as skillmux-owned without touching its content —
|
|
88
|
+
* the consented, one-time adoption skillmux init performs (see SkillmuxMarker in
|
|
89
|
+
* schema.json: "the only path allowed to create a .skillmux marker on a
|
|
90
|
+
* previously-unmarked directory"). syncTarget's fresh-dir case handles the
|
|
91
|
+
* "doesn't exist yet" side of that rule; this handles "already exists".
|
|
92
|
+
*/
|
|
93
|
+
export function adoptTarget(dir: string, targetName: string): AdoptTargetResult {
|
|
94
|
+
if (readSkillmuxMarker(dir)) return { adopted: false };
|
|
95
|
+
writeSkillmuxMarker(dir, targetName);
|
|
96
|
+
return { adopted: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface RestoreMonolithResult {
|
|
100
|
+
restored: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function restoreMonolith(targetDir: string, vaultPath: string): RestoreMonolithResult {
|
|
104
|
+
if (!readSkillmuxMarker(targetDir)) {
|
|
105
|
+
return { restored: false };
|
|
106
|
+
}
|
|
107
|
+
rmSync(targetDir, { recursive: true, force: true });
|
|
108
|
+
symlinkSync(vaultPath, targetDir);
|
|
109
|
+
return { restored: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function resolveProjectPinDir(targetDir: string, repo: string): string {
|
|
113
|
+
const rel = relative(homedir(), targetDir);
|
|
114
|
+
if (rel === "" || rel.startsWith("..")) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`target dir "${targetDir}" must be inside $HOME to compute a project pin dir (got relative path "${rel}")`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
return join(repo, rel);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ProjectGroupInput {
|
|
123
|
+
repos: string[];
|
|
124
|
+
skills: string[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface SyncProjectTargetsParams {
|
|
128
|
+
vaultPath: string;
|
|
129
|
+
targetDir: string;
|
|
130
|
+
targetName: string;
|
|
131
|
+
projectGroups: Record<string, ProjectGroupInput>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ProjectPinSyncResult extends SyncTargetResult {
|
|
135
|
+
group: string;
|
|
136
|
+
repo: string;
|
|
137
|
+
pinDir: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function syncProjectTargets(
|
|
141
|
+
params: SyncProjectTargetsParams,
|
|
142
|
+
options: SyncTargetOptions = {},
|
|
143
|
+
): ProjectPinSyncResult[] {
|
|
144
|
+
const { vaultPath, targetDir, targetName, projectGroups } = params;
|
|
145
|
+
const results: ProjectPinSyncResult[] = [];
|
|
146
|
+
|
|
147
|
+
for (const [group, projectGroup] of Object.entries(projectGroups)) {
|
|
148
|
+
for (const repo of projectGroup.repos) {
|
|
149
|
+
if (!existsSync(repo)) continue;
|
|
150
|
+
const pinDir = resolveProjectPinDir(targetDir, repo);
|
|
151
|
+
const result = syncTarget(
|
|
152
|
+
{ vaultPath, targetDir: pinDir, targetName, coreSkillIds: projectGroup.skills },
|
|
153
|
+
options,
|
|
154
|
+
);
|
|
155
|
+
results.push({ group, repo, pinDir, ...result });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return results;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export const HOOK_MARKER = "# managed-by: skillmux sync --install-hook";
|
|
163
|
+
export const LEGACY_HOOK_MARKER = "# managed-by: skr sync --install-hook";
|
|
164
|
+
|
|
165
|
+
export interface InstallHookResult {
|
|
166
|
+
installed: boolean;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function installPostMergeHook(vaultPath: string): InstallHookResult {
|
|
170
|
+
const hookPath = join(vaultPath, ".git", "hooks", "post-merge");
|
|
171
|
+
|
|
172
|
+
if (existsSync(hookPath)) {
|
|
173
|
+
const existing = readFileSync(hookPath, "utf-8");
|
|
174
|
+
if (existing.includes(HOOK_MARKER) || existing.includes(LEGACY_HOOK_MARKER)) return { installed: false };
|
|
175
|
+
throw new Error(`${hookPath} already exists and is not managed by skillmux — refusing to overwrite`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const script = `#!/bin/sh\n${HOOK_MARKER}\nskillmux sync\n`;
|
|
179
|
+
writeFileSync(hookPath, script);
|
|
180
|
+
chmodSync(hookPath, 0o755);
|
|
181
|
+
return { installed: true };
|
|
182
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
export interface RecallConfig {
|
|
2
|
+
k_lexical: number;
|
|
3
|
+
k_vector: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface Thresholds {
|
|
7
|
+
candidate_limit: number;
|
|
8
|
+
match_score?: number;
|
|
9
|
+
match_margin?: number;
|
|
10
|
+
candidate_floor?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type ONNXDevice =
|
|
14
|
+
| "cpu"
|
|
15
|
+
| "auto"
|
|
16
|
+
| "gpu"
|
|
17
|
+
| "wasm"
|
|
18
|
+
| "webgpu"
|
|
19
|
+
| "cuda"
|
|
20
|
+
| "dml"
|
|
21
|
+
| "coreml"
|
|
22
|
+
| "webnn"
|
|
23
|
+
| "webnn-npu"
|
|
24
|
+
| "webnn-gpu"
|
|
25
|
+
| "webnn-cpu";
|
|
26
|
+
|
|
27
|
+
export type ONNXDtype =
|
|
28
|
+
| "q8"
|
|
29
|
+
| "auto"
|
|
30
|
+
| "fp32"
|
|
31
|
+
| "fp16"
|
|
32
|
+
| "int8"
|
|
33
|
+
| "uint8"
|
|
34
|
+
| "q4"
|
|
35
|
+
| "bnb4"
|
|
36
|
+
| "q4f16"
|
|
37
|
+
| "q2"
|
|
38
|
+
| "q2f16"
|
|
39
|
+
| "q1"
|
|
40
|
+
| "q1f16";
|
|
41
|
+
|
|
42
|
+
export interface ModelConfig {
|
|
43
|
+
model: string;
|
|
44
|
+
device?: ONNXDevice;
|
|
45
|
+
dtype?: ONNXDtype;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface LocalInferenceConfig {
|
|
49
|
+
mode: "local";
|
|
50
|
+
bundle: string;
|
|
51
|
+
models_dir: string;
|
|
52
|
+
embedding: ModelConfig & { dimension: number };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface RemoteEmbeddingConfig {
|
|
56
|
+
provider: "openai";
|
|
57
|
+
base_url: string;
|
|
58
|
+
model: string;
|
|
59
|
+
dimension: number;
|
|
60
|
+
api_key_env?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RemoteRerankerConfig {
|
|
64
|
+
provider: "infinity";
|
|
65
|
+
base_url: string;
|
|
66
|
+
model: string;
|
|
67
|
+
api_key_env?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface RemoteInferenceConfig {
|
|
71
|
+
mode: "remote";
|
|
72
|
+
timeout_ms: number;
|
|
73
|
+
embedding: RemoteEmbeddingConfig;
|
|
74
|
+
reranker?: RemoteRerankerConfig;
|
|
75
|
+
thresholds?: Required<Omit<Thresholds, "candidate_limit">>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type InferenceConfig = LocalInferenceConfig | RemoteInferenceConfig;
|
|
79
|
+
|
|
80
|
+
export interface RateLimitConfig {
|
|
81
|
+
enabled: boolean;
|
|
82
|
+
requests_per_minute: number;
|
|
83
|
+
trust_proxy?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface ServerConfig {
|
|
87
|
+
auth_enabled: boolean;
|
|
88
|
+
auth_token_env: string;
|
|
89
|
+
allowed_origins: string[];
|
|
90
|
+
hostname?: string;
|
|
91
|
+
rate_limit?: RateLimitConfig;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface Config {
|
|
95
|
+
vault_path: string;
|
|
96
|
+
state_dir: string;
|
|
97
|
+
recall: RecallConfig;
|
|
98
|
+
thresholds: Thresholds;
|
|
99
|
+
inference: InferenceConfig;
|
|
100
|
+
server?: ServerConfig;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Candidate {
|
|
104
|
+
skill_id: string;
|
|
105
|
+
title: string;
|
|
106
|
+
description: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface RankedCandidate extends Candidate {
|
|
110
|
+
score: number | null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export type RetrievalCapability = "exact" | "reranked" | "hybrid" | "lexical";
|
|
114
|
+
|
|
115
|
+
export interface MatchedResult {
|
|
116
|
+
outcome: "matched";
|
|
117
|
+
retrieval: "exact" | "reranked";
|
|
118
|
+
skill_id: string;
|
|
119
|
+
title: string;
|
|
120
|
+
content_sha256: string;
|
|
121
|
+
score: number;
|
|
122
|
+
margin: number;
|
|
123
|
+
body: string;
|
|
124
|
+
files: string[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface AmbiguousResult {
|
|
128
|
+
outcome: "ambiguous";
|
|
129
|
+
retrieval: RetrievalCapability;
|
|
130
|
+
candidates: Candidate[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface NoMatchResult {
|
|
134
|
+
outcome: "no_match";
|
|
135
|
+
retrieval: RetrievalCapability;
|
|
136
|
+
message: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export type ResolveResult = MatchedResult | AmbiguousResult | NoMatchResult;
|
|
140
|
+
|
|
141
|
+
export interface ResolveSkillInput {
|
|
142
|
+
query: string;
|
|
143
|
+
/** Test/ops escape hatch: use lexical retrieval only. Not exposed on the MCP wire. */
|
|
144
|
+
forceLexical?: boolean;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface FetchSkillInput {
|
|
148
|
+
skill_id: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface FetchSkillResult {
|
|
152
|
+
skill_id: string;
|
|
153
|
+
title: string;
|
|
154
|
+
content_sha256: string;
|
|
155
|
+
body: string;
|
|
156
|
+
files: string[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export interface AuditCandidate {
|
|
160
|
+
skill_id: string;
|
|
161
|
+
score: number | null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface AuditRow {
|
|
165
|
+
id: number;
|
|
166
|
+
ts: string;
|
|
167
|
+
query: string;
|
|
168
|
+
outcome: "matched" | "ambiguous" | "no_match";
|
|
169
|
+
retrieval: RetrievalCapability;
|
|
170
|
+
candidates: AuditCandidate[];
|
|
171
|
+
selected_skill_id: string | null;
|
|
172
|
+
latency_ms: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface Clients {
|
|
176
|
+
embed(texts: string[]): Promise<Float32Array[]>;
|
|
177
|
+
rerank?: (query: string, docs: { skill_id: string; text: string }[]) => Promise<number[]>;
|
|
178
|
+
}
|
package/src/vault.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const SKILL_ID_PATTERN = /^[a-z0-9][a-z0-9-]{1,127}$/;
|
|
5
|
+
|
|
6
|
+
export interface VaultSkill {
|
|
7
|
+
skill_id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description: string;
|
|
10
|
+
aliases: string[];
|
|
11
|
+
body: string;
|
|
12
|
+
content_sha256: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function sha256Hex(data: string | Uint8Array): string {
|
|
16
|
+
return new Bun.CryptoHasher("sha256").update(data).digest("hex");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface Frontmatter {
|
|
20
|
+
name?: unknown;
|
|
21
|
+
description?: unknown;
|
|
22
|
+
aliases?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseSkillMd(skillId: string, raw: string): VaultSkill {
|
|
26
|
+
let fm: Frontmatter = {};
|
|
27
|
+
if (raw.startsWith("---\n")) {
|
|
28
|
+
const end = raw.indexOf("\n---", 4);
|
|
29
|
+
if (end === -1) throw new Error(`unterminated frontmatter in ${skillId}/SKILL.md`);
|
|
30
|
+
fm = (Bun.YAML.parse(raw.slice(4, end)) ?? {}) as Frontmatter;
|
|
31
|
+
}
|
|
32
|
+
const aliases = Array.isArray(fm.aliases) ? fm.aliases.map(String) : [];
|
|
33
|
+
return {
|
|
34
|
+
skill_id: skillId,
|
|
35
|
+
title: typeof fm.name === "string" && fm.name.length > 0 ? fm.name : skillId,
|
|
36
|
+
description: typeof fm.description === "string" ? fm.description : "",
|
|
37
|
+
aliases,
|
|
38
|
+
body: raw,
|
|
39
|
+
content_sha256: sha256Hex(raw),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Strict decode: invalid UTF-8 throws instead of silently mangling content. */
|
|
44
|
+
export function decodeUtf8Strict(bytes: Uint8Array): string {
|
|
45
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function readSkill(vaultPath: string, skillId: string): Promise<VaultSkill> {
|
|
49
|
+
const bytes = await Bun.file(join(vaultPath, skillId, "SKILL.md")).bytes();
|
|
50
|
+
return parseSkillMd(skillId, decodeUtf8Strict(bytes));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function scanVault(
|
|
54
|
+
vaultPath: string,
|
|
55
|
+
onInvalid?: (skillId: string, error: unknown) => void,
|
|
56
|
+
): Promise<VaultSkill[]> {
|
|
57
|
+
const skills: VaultSkill[] = [];
|
|
58
|
+
for (const entry of readdirSync(vaultPath, { withFileTypes: true })) {
|
|
59
|
+
if (!entry.isDirectory() || !SKILL_ID_PATTERN.test(entry.name)) continue;
|
|
60
|
+
try {
|
|
61
|
+
skills.push(await readSkill(vaultPath, entry.name));
|
|
62
|
+
} catch (error) {
|
|
63
|
+
onInvalid?.(entry.name, error);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return skills;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Relative paths of everything under the skill dir except SKILL.md itself, sorted. */
|
|
70
|
+
export function listSupportingFiles(vaultPath: string, skillId: string): string[] {
|
|
71
|
+
const root = join(vaultPath, skillId);
|
|
72
|
+
const files: string[] = [];
|
|
73
|
+
const walk = (dir: string) => {
|
|
74
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
75
|
+
const abs = join(dir, entry.name);
|
|
76
|
+
if (entry.isDirectory()) walk(abs);
|
|
77
|
+
else if (statSync(abs).isFile()) {
|
|
78
|
+
const rel = relative(root, abs);
|
|
79
|
+
if (rel !== "SKILL.md") files.push(rel);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
walk(root);
|
|
84
|
+
return files.sort();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getVaultMaxMtime(vaultPath: string): number {
|
|
88
|
+
try {
|
|
89
|
+
let maxMtime = statSync(vaultPath).mtimeMs;
|
|
90
|
+
const entries = readdirSync(vaultPath, { withFileTypes: true });
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
if (entry.isDirectory() && SKILL_ID_PATTERN.test(entry.name)) {
|
|
93
|
+
try {
|
|
94
|
+
const folderPath = join(vaultPath, entry.name);
|
|
95
|
+
const folderMtime = statSync(folderPath).mtimeMs;
|
|
96
|
+
maxMtime = Math.max(maxMtime, folderMtime);
|
|
97
|
+
|
|
98
|
+
const fileMtime = statSync(join(folderPath, "SKILL.md")).mtimeMs;
|
|
99
|
+
maxMtime = Math.max(maxMtime, fileMtime);
|
|
100
|
+
} catch {
|
|
101
|
+
// Ignore deleted files
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return maxMtime;
|
|
106
|
+
} catch {
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
}
|