@caupulican/pi-adaptative 0.80.76 → 0.80.78
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 +20 -0
- package/dist/core/agent-session.d.ts +16 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +29 -0
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/learning/skill-curator.d.ts +71 -0
- package/dist/core/learning/skill-curator.d.ts.map +1 -0
- package/dist/core/learning/skill-curator.js +179 -0
- package/dist/core/learning/skill-curator.js.map +1 -0
- package/dist/core/resource-loader.d.ts.map +1 -1
- package/dist/core/resource-loader.js +10 -4
- package/dist/core/resource-loader.js.map +1 -1
- package/dist/core/slash-commands.d.ts.map +1 -1
- package/dist/core/slash-commands.js +1 -0
- package/dist/core/slash-commands.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +7 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +38 -0
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/examples/extensions/custom-provider-anthropic/package-lock.json +2 -2
- package/examples/extensions/custom-provider-anthropic/package.json +1 -1
- package/examples/extensions/custom-provider-gitlab-duo/package.json +1 -1
- package/examples/extensions/sandbox/package-lock.json +2 -2
- package/examples/extensions/sandbox/package.json +1 -1
- package/examples/extensions/with-deps/package-lock.json +2 -2
- package/examples/extensions/with-deps/package.json +1 -1
- package/npm-shrinkwrap.json +12 -12
- package/package.json +4 -4
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill curator (Hermes-parity #32). Reflection (R7) promotes recurring procedures into SKILL.md files;
|
|
3
|
+
* without curation they accumulate forever, bloating tool/context and raising per-turn cost. The curator
|
|
4
|
+
* tracks usage of PROMOTED skills (frontmatter `promoted: true`) and PROPOSES — never auto-applies —
|
|
5
|
+
* archiving stale ones and consolidating overlapping ones. Hand-authored user skills are never touched.
|
|
6
|
+
*
|
|
7
|
+
* Design (locked with agy): propose-only, session-start + idle triggers (not per-turn), restorable
|
|
8
|
+
* archive (non-destructive), and consolidation is a flagged suggestion (never an auto-merge).
|
|
9
|
+
*/
|
|
10
|
+
/** Per-promoted-skill signal the proposal logic reasons over. Pure data — no I/O. */
|
|
11
|
+
export interface PromotedSkillInfo {
|
|
12
|
+
name: string;
|
|
13
|
+
/** When the skill file was created (ms epoch); guards a freshly-promoted skill from instant archival. */
|
|
14
|
+
createdMs: number;
|
|
15
|
+
/** Last time the skill was loaded/used (ms epoch); 0 if never used. */
|
|
16
|
+
lastUsedMs: number;
|
|
17
|
+
useCount: number;
|
|
18
|
+
/** Tokens from name+description+body, for overlap detection. */
|
|
19
|
+
keywords: string[];
|
|
20
|
+
}
|
|
21
|
+
export interface CuratorOptions {
|
|
22
|
+
/** A promoted skill unused and older than this many days is proposed for archival. Default 30. */
|
|
23
|
+
staleDays: number;
|
|
24
|
+
/** Token-Jaccard ≥ this between two promoted skills flags them for consolidation. Default 0.5. */
|
|
25
|
+
overlapThreshold: number;
|
|
26
|
+
/** Current time (ms epoch); injected so the proposal logic stays pure/testable. */
|
|
27
|
+
now: number;
|
|
28
|
+
}
|
|
29
|
+
export declare const DEFAULT_CURATOR_OPTIONS: Omit<CuratorOptions, "now">;
|
|
30
|
+
export interface CurationProposals {
|
|
31
|
+
/** Promoted skills proposed for (restorable) archival, with a human reason. */
|
|
32
|
+
archive: Array<{
|
|
33
|
+
name: string;
|
|
34
|
+
reason: string;
|
|
35
|
+
}>;
|
|
36
|
+
/** Pairs of promoted skills that overlap enough to consider merging (flag only, never auto-merge). */
|
|
37
|
+
consolidate: Array<{
|
|
38
|
+
names: [string, string];
|
|
39
|
+
overlap: number;
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Pure proposal logic: decide which promoted skills to PROPOSE archiving (stale + unused) and which pairs
|
|
44
|
+
* overlap enough to PROPOSE consolidating. Returns suggestions only; the caller applies them on approval.
|
|
45
|
+
*/
|
|
46
|
+
export declare function computeCurationProposals(skills: PromotedSkillInfo[], opts: CuratorOptions): CurationProposals;
|
|
47
|
+
/**
|
|
48
|
+
* Filesystem layer over {@link computeCurationProposals}: reads promoted SKILL.md files + the usage
|
|
49
|
+
* sidecar, and archives/restores skills non-destructively. The current time is injected so callers (and
|
|
50
|
+
* tests) control "now".
|
|
51
|
+
*/
|
|
52
|
+
export declare class SkillCurator {
|
|
53
|
+
private readonly skillsDir;
|
|
54
|
+
private readonly archiveDir;
|
|
55
|
+
private readonly usageFile;
|
|
56
|
+
constructor(skillsDir: string);
|
|
57
|
+
/** Record that a promoted skill was loaded/used (bumps count + last-used). Best-effort. */
|
|
58
|
+
recordUse(name: string, now: number): void;
|
|
59
|
+
/** Build the proposals from the current promoted-skill corpus. */
|
|
60
|
+
proposeCuration(now: number, options?: Partial<Omit<CuratorOptions, "now">>): CurationProposals;
|
|
61
|
+
/** Move a promoted skill into `.archive/` (restorable). Returns true if archived. */
|
|
62
|
+
archiveSkill(name: string): boolean;
|
|
63
|
+
/** Restore an archived skill back into the active skills dir. Returns true if restored. */
|
|
64
|
+
restoreSkill(name: string): boolean;
|
|
65
|
+
loadPromotedSkills(): PromotedSkillInfo[];
|
|
66
|
+
private isPromoted;
|
|
67
|
+
private loadUsage;
|
|
68
|
+
}
|
|
69
|
+
/** True if a SKILL.md's YAML frontmatter declares `promoted: true` (reflection-generated). */
|
|
70
|
+
export declare function isPromotedFrontmatter(content: string): boolean;
|
|
71
|
+
//# sourceMappingURL=skill-curator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skill-curator.d.ts","sourceRoot":"","sources":["../../../src/core/learning/skill-curator.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,uFAAqF;AACrF,MAAM,WAAW,iBAAiB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,yGAAyG;IACzG,SAAS,EAAE,MAAM,CAAC;IAClB,uEAAuE;IACvE,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,gEAAgE;IAChE,QAAQ,EAAE,MAAM,EAAE,CAAC;CACnB;AAED,MAAM,WAAW,cAAc;IAC9B,kGAAkG;IAClG,SAAS,EAAE,MAAM,CAAC;IAClB,oGAAkG;IAClG,gBAAgB,EAAE,MAAM,CAAC;IACzB,mFAAmF;IACnF,GAAG,EAAE,MAAM,CAAC;CACZ;AAED,eAAO,MAAM,uBAAuB,EAAE,IAAI,CAAC,cAAc,EAAE,KAAK,CAG/D,CAAC;AAEF,MAAM,WAAW,iBAAiB;IACjC,+EAA+E;IAC/E,OAAO,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACjD,sGAAsG;IACtG,WAAW,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACjE;AAED;;;GAGG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,iBAAiB,EAAE,EAAE,IAAI,EAAE,cAAc,GAAG,iBAAiB,CAgC7G;AAWD;;;;GAIG;AACH,qBAAa,YAAY;IACxB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IAEnC,YAAY,SAAS,EAAE,MAAM,EAI5B;IAED,2FAA2F;IAC3F,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CASzC;IAED,kEAAkE;IAClE,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,OAAO,CAAC,IAAI,CAAC,cAAc,EAAE,KAAK,CAAC,CAAM,GAAG,iBAAiB,CAOlG;IAED,qFAAqF;IACrF,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAUlC;IAED,2FAA2F;IAC3F,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAUlC;IAED,kBAAkB,IAAI,iBAAiB,EAAE,CA+BxC;IAED,OAAO,CAAC,UAAU;IAQlB,OAAO,CAAC,SAAS;CAOjB;AAED,8FAA8F;AAC9F,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAI9D","sourcesContent":["/**\n * Skill curator (Hermes-parity #32). Reflection (R7) promotes recurring procedures into SKILL.md files;\n * without curation they accumulate forever, bloating tool/context and raising per-turn cost. The curator\n * tracks usage of PROMOTED skills (frontmatter `promoted: true`) and PROPOSES — never auto-applies —\n * archiving stale ones and consolidating overlapping ones. Hand-authored user skills are never touched.\n *\n * Design (locked with agy): propose-only, session-start + idle triggers (not per-turn), restorable\n * archive (non-destructive), and consolidation is a flagged suggestion (never an auto-merge).\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { jaccard, tokenize } from \"../tools/skill-audit.ts\";\n\n/** Per-promoted-skill signal the proposal logic reasons over. Pure data — no I/O. */\nexport interface PromotedSkillInfo {\n\tname: string;\n\t/** When the skill file was created (ms epoch); guards a freshly-promoted skill from instant archival. */\n\tcreatedMs: number;\n\t/** Last time the skill was loaded/used (ms epoch); 0 if never used. */\n\tlastUsedMs: number;\n\tuseCount: number;\n\t/** Tokens from name+description+body, for overlap detection. */\n\tkeywords: string[];\n}\n\nexport interface CuratorOptions {\n\t/** A promoted skill unused and older than this many days is proposed for archival. Default 30. */\n\tstaleDays: number;\n\t/** Token-Jaccard ≥ this between two promoted skills flags them for consolidation. Default 0.5. */\n\toverlapThreshold: number;\n\t/** Current time (ms epoch); injected so the proposal logic stays pure/testable. */\n\tnow: number;\n}\n\nexport const DEFAULT_CURATOR_OPTIONS: Omit<CuratorOptions, \"now\"> = {\n\tstaleDays: 30,\n\toverlapThreshold: 0.5,\n};\n\nexport interface CurationProposals {\n\t/** Promoted skills proposed for (restorable) archival, with a human reason. */\n\tarchive: Array<{ name: string; reason: string }>;\n\t/** Pairs of promoted skills that overlap enough to consider merging (flag only, never auto-merge). */\n\tconsolidate: Array<{ names: [string, string]; overlap: number }>;\n}\n\n/**\n * Pure proposal logic: decide which promoted skills to PROPOSE archiving (stale + unused) and which pairs\n * overlap enough to PROPOSE consolidating. Returns suggestions only; the caller applies them on approval.\n */\nexport function computeCurationProposals(skills: PromotedSkillInfo[], opts: CuratorOptions): CurationProposals {\n\tconst staleMs = opts.staleDays * 86_400_000;\n\tconst archive: CurationProposals[\"archive\"] = [];\n\tfor (const s of skills) {\n\t\t// \"Stale\" = never recently used AND not freshly promoted: measure age from the most recent of\n\t\t// last-use / creation so a brand-new skill isn't archived before it has had a chance to be used.\n\t\tconst lastSeen = Math.max(s.lastUsedMs, s.createdMs);\n\t\tconst ageMs = opts.now - lastSeen;\n\t\tif (ageMs > staleMs) {\n\t\t\tconst days = Math.floor(ageMs / 86_400_000);\n\t\t\tarchive.push({\n\t\t\t\tname: s.name,\n\t\t\t\treason: s.useCount === 0 ? `never used, ${days}d old` : `unused for ${days}d (${s.useCount} total uses)`,\n\t\t\t});\n\t\t}\n\t}\n\n\tconst consolidate: CurationProposals[\"consolidate\"] = [];\n\tconst archiving = new Set(archive.map((a) => a.name));\n\tfor (let i = 0; i < skills.length; i++) {\n\t\tfor (let j = i + 1; j < skills.length; j++) {\n\t\t\tconst a = skills[i];\n\t\t\tconst b = skills[j];\n\t\t\t// Don't propose consolidating something already proposed for archival.\n\t\t\tif (archiving.has(a.name) || archiving.has(b.name)) continue;\n\t\t\tconst overlap = jaccard(a.keywords, b.keywords);\n\t\t\tif (overlap >= opts.overlapThreshold) {\n\t\t\t\tconsolidate.push({ names: [a.name, b.name], overlap });\n\t\t\t}\n\t\t}\n\t}\n\treturn { archive, consolidate };\n}\n\ninterface UsageRecord {\n\tlastUsedMs: number;\n\tuseCount: number;\n}\ntype UsageMap = Record<string, UsageRecord>;\n\n/** Cap on how much of a skill body feeds keyword extraction (keeps overlap detection cheap). */\nconst KEYWORD_SOURCE_CAP = 4000;\n\n/**\n * Filesystem layer over {@link computeCurationProposals}: reads promoted SKILL.md files + the usage\n * sidecar, and archives/restores skills non-destructively. The current time is injected so callers (and\n * tests) control \"now\".\n */\nexport class SkillCurator {\n\tprivate readonly skillsDir: string;\n\tprivate readonly archiveDir: string;\n\tprivate readonly usageFile: string;\n\n\tconstructor(skillsDir: string) {\n\t\tthis.skillsDir = skillsDir;\n\t\tthis.archiveDir = join(skillsDir, \".archive\");\n\t\tthis.usageFile = join(skillsDir, \".usage.json\");\n\t}\n\n\t/** Record that a promoted skill was loaded/used (bumps count + last-used). Best-effort. */\n\trecordUse(name: string, now: number): void {\n\t\ttry {\n\t\t\tconst usage = this.loadUsage();\n\t\t\tconst prev = usage[name] ?? { lastUsedMs: 0, useCount: 0 };\n\t\t\tusage[name] = { lastUsedMs: now, useCount: prev.useCount + 1 };\n\t\t\twriteFileSync(this.usageFile, JSON.stringify(usage, null, 2), \"utf-8\");\n\t\t} catch {\n\t\t\t// usage tracking must never disrupt a turn\n\t\t}\n\t}\n\n\t/** Build the proposals from the current promoted-skill corpus. */\n\tproposeCuration(now: number, options: Partial<Omit<CuratorOptions, \"now\">> = {}): CurationProposals {\n\t\tconst skills = this.loadPromotedSkills();\n\t\treturn computeCurationProposals(skills, {\n\t\t\tnow,\n\t\t\tstaleDays: options.staleDays ?? DEFAULT_CURATOR_OPTIONS.staleDays,\n\t\t\toverlapThreshold: options.overlapThreshold ?? DEFAULT_CURATOR_OPTIONS.overlapThreshold,\n\t\t});\n\t}\n\n\t/** Move a promoted skill into `.archive/` (restorable). Returns true if archived. */\n\tarchiveSkill(name: string): boolean {\n\t\ttry {\n\t\t\tconst from = join(this.skillsDir, name);\n\t\t\tif (!existsSync(join(from, \"SKILL.md\")) || !this.isPromoted(name)) return false;\n\t\t\tmkdirSync(this.archiveDir, { recursive: true });\n\t\t\trenameSync(from, join(this.archiveDir, name));\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/** Restore an archived skill back into the active skills dir. Returns true if restored. */\n\trestoreSkill(name: string): boolean {\n\t\ttry {\n\t\t\tconst from = join(this.archiveDir, name);\n\t\t\tconst to = join(this.skillsDir, name);\n\t\t\tif (!existsSync(join(from, \"SKILL.md\")) || existsSync(to)) return false;\n\t\t\trenameSync(from, to);\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tloadPromotedSkills(): PromotedSkillInfo[] {\n\t\tconst out: PromotedSkillInfo[] = [];\n\t\tlet entries: string[];\n\t\ttry {\n\t\t\tentries = readdirSync(this.skillsDir);\n\t\t} catch {\n\t\t\treturn out;\n\t\t}\n\t\tconst usage = this.loadUsage();\n\t\tfor (const name of entries) {\n\t\t\tif (name.startsWith(\".\")) continue; // skip .archive, .usage.json\n\t\t\tconst file = join(this.skillsDir, name, \"SKILL.md\");\n\t\t\tlet raw: string;\n\t\t\tlet createdMs = 0;\n\t\t\ttry {\n\t\t\t\traw = readFileSync(file, \"utf-8\");\n\t\t\t\tcreatedMs = statSync(file).birthtimeMs || statSync(file).mtimeMs;\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (!isPromotedFrontmatter(raw)) continue;\n\t\t\tconst u = usage[name] ?? { lastUsedMs: 0, useCount: 0 };\n\t\t\tout.push({\n\t\t\t\tname,\n\t\t\t\tcreatedMs,\n\t\t\t\tlastUsedMs: u.lastUsedMs,\n\t\t\t\tuseCount: u.useCount,\n\t\t\t\tkeywords: tokenize(raw.slice(0, KEYWORD_SOURCE_CAP)),\n\t\t\t});\n\t\t}\n\t\treturn out;\n\t}\n\n\tprivate isPromoted(name: string): boolean {\n\t\ttry {\n\t\t\treturn isPromotedFrontmatter(readFileSync(join(this.skillsDir, name, \"SKILL.md\"), \"utf-8\"));\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate loadUsage(): UsageMap {\n\t\ttry {\n\t\t\treturn JSON.parse(readFileSync(this.usageFile, \"utf-8\")) as UsageMap;\n\t\t} catch {\n\t\t\treturn {};\n\t\t}\n\t}\n}\n\n/** True if a SKILL.md's YAML frontmatter declares `promoted: true` (reflection-generated). */\nexport function isPromotedFrontmatter(content: string): boolean {\n\tconst fm = content.match(/^---\\n([\\s\\S]*?)\\n---/);\n\tif (!fm) return false;\n\treturn /^\\s*promoted\\s*:\\s*true\\s*$/im.test(fm[1]);\n}\n"]}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill curator (Hermes-parity #32). Reflection (R7) promotes recurring procedures into SKILL.md files;
|
|
3
|
+
* without curation they accumulate forever, bloating tool/context and raising per-turn cost. The curator
|
|
4
|
+
* tracks usage of PROMOTED skills (frontmatter `promoted: true`) and PROPOSES — never auto-applies —
|
|
5
|
+
* archiving stale ones and consolidating overlapping ones. Hand-authored user skills are never touched.
|
|
6
|
+
*
|
|
7
|
+
* Design (locked with agy): propose-only, session-start + idle triggers (not per-turn), restorable
|
|
8
|
+
* archive (non-destructive), and consolidation is a flagged suggestion (never an auto-merge).
|
|
9
|
+
*/
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { jaccard, tokenize } from "../tools/skill-audit.js";
|
|
13
|
+
export const DEFAULT_CURATOR_OPTIONS = {
|
|
14
|
+
staleDays: 30,
|
|
15
|
+
overlapThreshold: 0.5,
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Pure proposal logic: decide which promoted skills to PROPOSE archiving (stale + unused) and which pairs
|
|
19
|
+
* overlap enough to PROPOSE consolidating. Returns suggestions only; the caller applies them on approval.
|
|
20
|
+
*/
|
|
21
|
+
export function computeCurationProposals(skills, opts) {
|
|
22
|
+
const staleMs = opts.staleDays * 86_400_000;
|
|
23
|
+
const archive = [];
|
|
24
|
+
for (const s of skills) {
|
|
25
|
+
// "Stale" = never recently used AND not freshly promoted: measure age from the most recent of
|
|
26
|
+
// last-use / creation so a brand-new skill isn't archived before it has had a chance to be used.
|
|
27
|
+
const lastSeen = Math.max(s.lastUsedMs, s.createdMs);
|
|
28
|
+
const ageMs = opts.now - lastSeen;
|
|
29
|
+
if (ageMs > staleMs) {
|
|
30
|
+
const days = Math.floor(ageMs / 86_400_000);
|
|
31
|
+
archive.push({
|
|
32
|
+
name: s.name,
|
|
33
|
+
reason: s.useCount === 0 ? `never used, ${days}d old` : `unused for ${days}d (${s.useCount} total uses)`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const consolidate = [];
|
|
38
|
+
const archiving = new Set(archive.map((a) => a.name));
|
|
39
|
+
for (let i = 0; i < skills.length; i++) {
|
|
40
|
+
for (let j = i + 1; j < skills.length; j++) {
|
|
41
|
+
const a = skills[i];
|
|
42
|
+
const b = skills[j];
|
|
43
|
+
// Don't propose consolidating something already proposed for archival.
|
|
44
|
+
if (archiving.has(a.name) || archiving.has(b.name))
|
|
45
|
+
continue;
|
|
46
|
+
const overlap = jaccard(a.keywords, b.keywords);
|
|
47
|
+
if (overlap >= opts.overlapThreshold) {
|
|
48
|
+
consolidate.push({ names: [a.name, b.name], overlap });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return { archive, consolidate };
|
|
53
|
+
}
|
|
54
|
+
/** Cap on how much of a skill body feeds keyword extraction (keeps overlap detection cheap). */
|
|
55
|
+
const KEYWORD_SOURCE_CAP = 4000;
|
|
56
|
+
/**
|
|
57
|
+
* Filesystem layer over {@link computeCurationProposals}: reads promoted SKILL.md files + the usage
|
|
58
|
+
* sidecar, and archives/restores skills non-destructively. The current time is injected so callers (and
|
|
59
|
+
* tests) control "now".
|
|
60
|
+
*/
|
|
61
|
+
export class SkillCurator {
|
|
62
|
+
skillsDir;
|
|
63
|
+
archiveDir;
|
|
64
|
+
usageFile;
|
|
65
|
+
constructor(skillsDir) {
|
|
66
|
+
this.skillsDir = skillsDir;
|
|
67
|
+
this.archiveDir = join(skillsDir, ".archive");
|
|
68
|
+
this.usageFile = join(skillsDir, ".usage.json");
|
|
69
|
+
}
|
|
70
|
+
/** Record that a promoted skill was loaded/used (bumps count + last-used). Best-effort. */
|
|
71
|
+
recordUse(name, now) {
|
|
72
|
+
try {
|
|
73
|
+
const usage = this.loadUsage();
|
|
74
|
+
const prev = usage[name] ?? { lastUsedMs: 0, useCount: 0 };
|
|
75
|
+
usage[name] = { lastUsedMs: now, useCount: prev.useCount + 1 };
|
|
76
|
+
writeFileSync(this.usageFile, JSON.stringify(usage, null, 2), "utf-8");
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// usage tracking must never disrupt a turn
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Build the proposals from the current promoted-skill corpus. */
|
|
83
|
+
proposeCuration(now, options = {}) {
|
|
84
|
+
const skills = this.loadPromotedSkills();
|
|
85
|
+
return computeCurationProposals(skills, {
|
|
86
|
+
now,
|
|
87
|
+
staleDays: options.staleDays ?? DEFAULT_CURATOR_OPTIONS.staleDays,
|
|
88
|
+
overlapThreshold: options.overlapThreshold ?? DEFAULT_CURATOR_OPTIONS.overlapThreshold,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Move a promoted skill into `.archive/` (restorable). Returns true if archived. */
|
|
92
|
+
archiveSkill(name) {
|
|
93
|
+
try {
|
|
94
|
+
const from = join(this.skillsDir, name);
|
|
95
|
+
if (!existsSync(join(from, "SKILL.md")) || !this.isPromoted(name))
|
|
96
|
+
return false;
|
|
97
|
+
mkdirSync(this.archiveDir, { recursive: true });
|
|
98
|
+
renameSync(from, join(this.archiveDir, name));
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** Restore an archived skill back into the active skills dir. Returns true if restored. */
|
|
106
|
+
restoreSkill(name) {
|
|
107
|
+
try {
|
|
108
|
+
const from = join(this.archiveDir, name);
|
|
109
|
+
const to = join(this.skillsDir, name);
|
|
110
|
+
if (!existsSync(join(from, "SKILL.md")) || existsSync(to))
|
|
111
|
+
return false;
|
|
112
|
+
renameSync(from, to);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
loadPromotedSkills() {
|
|
120
|
+
const out = [];
|
|
121
|
+
let entries;
|
|
122
|
+
try {
|
|
123
|
+
entries = readdirSync(this.skillsDir);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
const usage = this.loadUsage();
|
|
129
|
+
for (const name of entries) {
|
|
130
|
+
if (name.startsWith("."))
|
|
131
|
+
continue; // skip .archive, .usage.json
|
|
132
|
+
const file = join(this.skillsDir, name, "SKILL.md");
|
|
133
|
+
let raw;
|
|
134
|
+
let createdMs = 0;
|
|
135
|
+
try {
|
|
136
|
+
raw = readFileSync(file, "utf-8");
|
|
137
|
+
createdMs = statSync(file).birthtimeMs || statSync(file).mtimeMs;
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!isPromotedFrontmatter(raw))
|
|
143
|
+
continue;
|
|
144
|
+
const u = usage[name] ?? { lastUsedMs: 0, useCount: 0 };
|
|
145
|
+
out.push({
|
|
146
|
+
name,
|
|
147
|
+
createdMs,
|
|
148
|
+
lastUsedMs: u.lastUsedMs,
|
|
149
|
+
useCount: u.useCount,
|
|
150
|
+
keywords: tokenize(raw.slice(0, KEYWORD_SOURCE_CAP)),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
isPromoted(name) {
|
|
156
|
+
try {
|
|
157
|
+
return isPromotedFrontmatter(readFileSync(join(this.skillsDir, name, "SKILL.md"), "utf-8"));
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
loadUsage() {
|
|
164
|
+
try {
|
|
165
|
+
return JSON.parse(readFileSync(this.usageFile, "utf-8"));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return {};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** True if a SKILL.md's YAML frontmatter declares `promoted: true` (reflection-generated). */
|
|
173
|
+
export function isPromotedFrontmatter(content) {
|
|
174
|
+
const fm = content.match(/^---\n([\s\S]*?)\n---/);
|
|
175
|
+
if (!fm)
|
|
176
|
+
return false;
|
|
177
|
+
return /^\s*promoted\s*:\s*true\s*$/im.test(fm[1]);
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=skill-curator.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"skill-curator.js","sourceRoot":"","sources":["../../../src/core/learning/skill-curator.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,UAAU,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,UAAU,EAAE,QAAQ,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAChH,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,yBAAyB,CAAC;AAuB5D,MAAM,CAAC,MAAM,uBAAuB,GAAgC;IACnE,SAAS,EAAE,EAAE;IACb,gBAAgB,EAAE,GAAG;CACrB,CAAC;AASF;;;GAGG;AACH,MAAM,UAAU,wBAAwB,CAAC,MAA2B,EAAE,IAAoB,EAAqB;IAC9G,MAAM,OAAO,GAAG,IAAI,CAAC,SAAS,GAAG,UAAU,CAAC;IAC5C,MAAM,OAAO,GAAiC,EAAE,CAAC;IACjD,KAAK,MAAM,CAAC,IAAI,MAAM,EAAE,CAAC;QACxB,8FAA8F;QAC9F,iGAAiG;QACjG,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,UAAU,EAAE,CAAC,CAAC,SAAS,CAAC,CAAC;QACrD,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC;QAClC,IAAI,KAAK,GAAG,OAAO,EAAE,CAAC;YACrB,MAAM,IAAI,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,UAAU,CAAC,CAAC;YAC5C,OAAO,CAAC,IAAI,CAAC;gBACZ,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,MAAM,EAAE,CAAC,CAAC,QAAQ,KAAK,CAAC,CAAC,CAAC,CAAC,eAAe,IAAI,OAAO,CAAC,CAAC,CAAC,cAAc,IAAI,MAAM,CAAC,CAAC,QAAQ,cAAc;aACxG,CAAC,CAAC;QACJ,CAAC;IACF,CAAC;IAED,MAAM,WAAW,GAAqC,EAAE,CAAC;IACzD,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC;IACtD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACxC,KAAK,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;YAC5C,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;YACpB,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC;YACpB,uEAAuE;YACvE,IAAI,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,SAAS,CAAC,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC;gBAAE,SAAS;YAC7D,MAAM,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,CAAC,QAAQ,CAAC,CAAC;YAChD,IAAI,OAAO,IAAI,IAAI,CAAC,gBAAgB,EAAE,CAAC;gBACtC,WAAW,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,CAAC,CAAC;YACxD,CAAC;QACF,CAAC;IACF,CAAC;IACD,OAAO,EAAE,OAAO,EAAE,WAAW,EAAE,CAAC;AAAA,CAChC;AAQD,gGAAgG;AAChG,MAAM,kBAAkB,GAAG,IAAI,CAAC;AAEhC;;;;GAIG;AACH,MAAM,OAAO,YAAY;IACP,SAAS,CAAS;IAClB,UAAU,CAAS;IACnB,SAAS,CAAS;IAEnC,YAAY,SAAiB,EAAE;QAC9B,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;QAC3B,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC;QAC9C,IAAI,CAAC,SAAS,GAAG,IAAI,CAAC,SAAS,EAAE,aAAa,CAAC,CAAC;IAAA,CAChD;IAED,2FAA2F;IAC3F,SAAS,CAAC,IAAY,EAAE,GAAW,EAAQ;QAC1C,IAAI,CAAC;YACJ,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;YAC/B,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;YAC3D,KAAK,CAAC,IAAI,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,QAAQ,EAAE,IAAI,CAAC,QAAQ,GAAG,CAAC,EAAE,CAAC;YAC/D,aAAa,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,OAAO,CAAC,CAAC;QACxE,CAAC;QAAC,MAAM,CAAC;YACR,2CAA2C;QAC5C,CAAC;IAAA,CACD;IAED,kEAAkE;IAClE,eAAe,CAAC,GAAW,EAAE,OAAO,GAAyC,EAAE,EAAqB;QACnG,MAAM,MAAM,GAAG,IAAI,CAAC,kBAAkB,EAAE,CAAC;QACzC,OAAO,wBAAwB,CAAC,MAAM,EAAE;YACvC,GAAG;YACH,SAAS,EAAE,OAAO,CAAC,SAAS,IAAI,uBAAuB,CAAC,SAAS;YACjE,gBAAgB,EAAE,OAAO,CAAC,gBAAgB,IAAI,uBAAuB,CAAC,gBAAgB;SACtF,CAAC,CAAC;IAAA,CACH;IAED,qFAAqF;IACrF,YAAY,CAAC,IAAY,EAAW;QACnC,IAAI,CAAC;YACJ,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;YACxC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC;gBAAE,OAAO,KAAK,CAAC;YAChF,SAAS,CAAC,IAAI,CAAC,UAAU,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YAChD,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC,CAAC;YAC9C,OAAO,IAAI,CAAC;QACb,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,KAAK,CAAC;QACd,CAAC;IAAA,CACD;IAED,2FAA2F;IAC3F,YAAY,CAAC,IAAY,EAAW;QACnC,IAAI,CAAC;YACJ,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,UAAU,EAAE,IAAI,CAAC,CAAC;YACzC,MAAM,EAAE,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;YACtC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC,IAAI,UAAU,CAAC,EAAE,CAAC;gBAAE,OAAO,KAAK,CAAC;YACxE,UAAU,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;YACrB,OAAO,IAAI,CAAC;QACb,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,KAAK,CAAC;QACd,CAAC;IAAA,CACD;IAED,kBAAkB,GAAwB;QACzC,MAAM,GAAG,GAAwB,EAAE,CAAC;QACpC,IAAI,OAAiB,CAAC;QACtB,IAAI,CAAC;YACJ,OAAO,GAAG,WAAW,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACvC,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,GAAG,CAAC;QACZ,CAAC;QACD,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;QAC/B,KAAK,MAAM,IAAI,IAAI,OAAO,EAAE,CAAC;YAC5B,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC;gBAAE,SAAS,CAAC,6BAA6B;YACjE,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,UAAU,CAAC,CAAC;YACpD,IAAI,GAAW,CAAC;YAChB,IAAI,SAAS,GAAG,CAAC,CAAC;YAClB,IAAI,CAAC;gBACJ,GAAG,GAAG,YAAY,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;gBAClC,SAAS,GAAG,QAAQ,CAAC,IAAI,CAAC,CAAC,WAAW,IAAI,QAAQ,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC;YAClE,CAAC;YAAC,MAAM,CAAC;gBACR,SAAS;YACV,CAAC;YACD,IAAI,CAAC,qBAAqB,CAAC,GAAG,CAAC;gBAAE,SAAS;YAC1C,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,EAAE,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,CAAC;YACxD,GAAG,CAAC,IAAI,CAAC;gBACR,IAAI;gBACJ,SAAS;gBACT,UAAU,EAAE,CAAC,CAAC,UAAU;gBACxB,QAAQ,EAAE,CAAC,CAAC,QAAQ;gBACpB,QAAQ,EAAE,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,EAAE,kBAAkB,CAAC,CAAC;aACpD,CAAC,CAAC;QACJ,CAAC;QACD,OAAO,GAAG,CAAC;IAAA,CACX;IAEO,UAAU,CAAC,IAAY,EAAW;QACzC,IAAI,CAAC;YACJ,OAAO,qBAAqB,CAAC,YAAY,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,EAAE,IAAI,EAAE,UAAU,CAAC,EAAE,OAAO,CAAC,CAAC,CAAC;QAC7F,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,KAAK,CAAC;QACd,CAAC;IAAA,CACD;IAEO,SAAS,GAAa;QAC7B,IAAI,CAAC;YACJ,OAAO,IAAI,CAAC,KAAK,CAAC,YAAY,CAAC,IAAI,CAAC,SAAS,EAAE,OAAO,CAAC,CAAa,CAAC;QACtE,CAAC;QAAC,MAAM,CAAC;YACR,OAAO,EAAE,CAAC;QACX,CAAC;IAAA,CACD;CACD;AAED,8FAA8F;AAC9F,MAAM,UAAU,qBAAqB,CAAC,OAAe,EAAW;IAC/D,MAAM,EAAE,GAAG,OAAO,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAC;IAClD,IAAI,CAAC,EAAE;QAAE,OAAO,KAAK,CAAC;IACtB,OAAO,+BAA+B,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;AAAA,CACnD","sourcesContent":["/**\n * Skill curator (Hermes-parity #32). Reflection (R7) promotes recurring procedures into SKILL.md files;\n * without curation they accumulate forever, bloating tool/context and raising per-turn cost. The curator\n * tracks usage of PROMOTED skills (frontmatter `promoted: true`) and PROPOSES — never auto-applies —\n * archiving stale ones and consolidating overlapping ones. Hand-authored user skills are never touched.\n *\n * Design (locked with agy): propose-only, session-start + idle triggers (not per-turn), restorable\n * archive (non-destructive), and consolidation is a flagged suggestion (never an auto-merge).\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { jaccard, tokenize } from \"../tools/skill-audit.ts\";\n\n/** Per-promoted-skill signal the proposal logic reasons over. Pure data — no I/O. */\nexport interface PromotedSkillInfo {\n\tname: string;\n\t/** When the skill file was created (ms epoch); guards a freshly-promoted skill from instant archival. */\n\tcreatedMs: number;\n\t/** Last time the skill was loaded/used (ms epoch); 0 if never used. */\n\tlastUsedMs: number;\n\tuseCount: number;\n\t/** Tokens from name+description+body, for overlap detection. */\n\tkeywords: string[];\n}\n\nexport interface CuratorOptions {\n\t/** A promoted skill unused and older than this many days is proposed for archival. Default 30. */\n\tstaleDays: number;\n\t/** Token-Jaccard ≥ this between two promoted skills flags them for consolidation. Default 0.5. */\n\toverlapThreshold: number;\n\t/** Current time (ms epoch); injected so the proposal logic stays pure/testable. */\n\tnow: number;\n}\n\nexport const DEFAULT_CURATOR_OPTIONS: Omit<CuratorOptions, \"now\"> = {\n\tstaleDays: 30,\n\toverlapThreshold: 0.5,\n};\n\nexport interface CurationProposals {\n\t/** Promoted skills proposed for (restorable) archival, with a human reason. */\n\tarchive: Array<{ name: string; reason: string }>;\n\t/** Pairs of promoted skills that overlap enough to consider merging (flag only, never auto-merge). */\n\tconsolidate: Array<{ names: [string, string]; overlap: number }>;\n}\n\n/**\n * Pure proposal logic: decide which promoted skills to PROPOSE archiving (stale + unused) and which pairs\n * overlap enough to PROPOSE consolidating. Returns suggestions only; the caller applies them on approval.\n */\nexport function computeCurationProposals(skills: PromotedSkillInfo[], opts: CuratorOptions): CurationProposals {\n\tconst staleMs = opts.staleDays * 86_400_000;\n\tconst archive: CurationProposals[\"archive\"] = [];\n\tfor (const s of skills) {\n\t\t// \"Stale\" = never recently used AND not freshly promoted: measure age from the most recent of\n\t\t// last-use / creation so a brand-new skill isn't archived before it has had a chance to be used.\n\t\tconst lastSeen = Math.max(s.lastUsedMs, s.createdMs);\n\t\tconst ageMs = opts.now - lastSeen;\n\t\tif (ageMs > staleMs) {\n\t\t\tconst days = Math.floor(ageMs / 86_400_000);\n\t\t\tarchive.push({\n\t\t\t\tname: s.name,\n\t\t\t\treason: s.useCount === 0 ? `never used, ${days}d old` : `unused for ${days}d (${s.useCount} total uses)`,\n\t\t\t});\n\t\t}\n\t}\n\n\tconst consolidate: CurationProposals[\"consolidate\"] = [];\n\tconst archiving = new Set(archive.map((a) => a.name));\n\tfor (let i = 0; i < skills.length; i++) {\n\t\tfor (let j = i + 1; j < skills.length; j++) {\n\t\t\tconst a = skills[i];\n\t\t\tconst b = skills[j];\n\t\t\t// Don't propose consolidating something already proposed for archival.\n\t\t\tif (archiving.has(a.name) || archiving.has(b.name)) continue;\n\t\t\tconst overlap = jaccard(a.keywords, b.keywords);\n\t\t\tif (overlap >= opts.overlapThreshold) {\n\t\t\t\tconsolidate.push({ names: [a.name, b.name], overlap });\n\t\t\t}\n\t\t}\n\t}\n\treturn { archive, consolidate };\n}\n\ninterface UsageRecord {\n\tlastUsedMs: number;\n\tuseCount: number;\n}\ntype UsageMap = Record<string, UsageRecord>;\n\n/** Cap on how much of a skill body feeds keyword extraction (keeps overlap detection cheap). */\nconst KEYWORD_SOURCE_CAP = 4000;\n\n/**\n * Filesystem layer over {@link computeCurationProposals}: reads promoted SKILL.md files + the usage\n * sidecar, and archives/restores skills non-destructively. The current time is injected so callers (and\n * tests) control \"now\".\n */\nexport class SkillCurator {\n\tprivate readonly skillsDir: string;\n\tprivate readonly archiveDir: string;\n\tprivate readonly usageFile: string;\n\n\tconstructor(skillsDir: string) {\n\t\tthis.skillsDir = skillsDir;\n\t\tthis.archiveDir = join(skillsDir, \".archive\");\n\t\tthis.usageFile = join(skillsDir, \".usage.json\");\n\t}\n\n\t/** Record that a promoted skill was loaded/used (bumps count + last-used). Best-effort. */\n\trecordUse(name: string, now: number): void {\n\t\ttry {\n\t\t\tconst usage = this.loadUsage();\n\t\t\tconst prev = usage[name] ?? { lastUsedMs: 0, useCount: 0 };\n\t\t\tusage[name] = { lastUsedMs: now, useCount: prev.useCount + 1 };\n\t\t\twriteFileSync(this.usageFile, JSON.stringify(usage, null, 2), \"utf-8\");\n\t\t} catch {\n\t\t\t// usage tracking must never disrupt a turn\n\t\t}\n\t}\n\n\t/** Build the proposals from the current promoted-skill corpus. */\n\tproposeCuration(now: number, options: Partial<Omit<CuratorOptions, \"now\">> = {}): CurationProposals {\n\t\tconst skills = this.loadPromotedSkills();\n\t\treturn computeCurationProposals(skills, {\n\t\t\tnow,\n\t\t\tstaleDays: options.staleDays ?? DEFAULT_CURATOR_OPTIONS.staleDays,\n\t\t\toverlapThreshold: options.overlapThreshold ?? DEFAULT_CURATOR_OPTIONS.overlapThreshold,\n\t\t});\n\t}\n\n\t/** Move a promoted skill into `.archive/` (restorable). Returns true if archived. */\n\tarchiveSkill(name: string): boolean {\n\t\ttry {\n\t\t\tconst from = join(this.skillsDir, name);\n\t\t\tif (!existsSync(join(from, \"SKILL.md\")) || !this.isPromoted(name)) return false;\n\t\t\tmkdirSync(this.archiveDir, { recursive: true });\n\t\t\trenameSync(from, join(this.archiveDir, name));\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/** Restore an archived skill back into the active skills dir. Returns true if restored. */\n\trestoreSkill(name: string): boolean {\n\t\ttry {\n\t\t\tconst from = join(this.archiveDir, name);\n\t\t\tconst to = join(this.skillsDir, name);\n\t\t\tif (!existsSync(join(from, \"SKILL.md\")) || existsSync(to)) return false;\n\t\t\trenameSync(from, to);\n\t\t\treturn true;\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tloadPromotedSkills(): PromotedSkillInfo[] {\n\t\tconst out: PromotedSkillInfo[] = [];\n\t\tlet entries: string[];\n\t\ttry {\n\t\t\tentries = readdirSync(this.skillsDir);\n\t\t} catch {\n\t\t\treturn out;\n\t\t}\n\t\tconst usage = this.loadUsage();\n\t\tfor (const name of entries) {\n\t\t\tif (name.startsWith(\".\")) continue; // skip .archive, .usage.json\n\t\t\tconst file = join(this.skillsDir, name, \"SKILL.md\");\n\t\t\tlet raw: string;\n\t\t\tlet createdMs = 0;\n\t\t\ttry {\n\t\t\t\traw = readFileSync(file, \"utf-8\");\n\t\t\t\tcreatedMs = statSync(file).birthtimeMs || statSync(file).mtimeMs;\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (!isPromotedFrontmatter(raw)) continue;\n\t\t\tconst u = usage[name] ?? { lastUsedMs: 0, useCount: 0 };\n\t\t\tout.push({\n\t\t\t\tname,\n\t\t\t\tcreatedMs,\n\t\t\t\tlastUsedMs: u.lastUsedMs,\n\t\t\t\tuseCount: u.useCount,\n\t\t\t\tkeywords: tokenize(raw.slice(0, KEYWORD_SOURCE_CAP)),\n\t\t\t});\n\t\t}\n\t\treturn out;\n\t}\n\n\tprivate isPromoted(name: string): boolean {\n\t\ttry {\n\t\t\treturn isPromotedFrontmatter(readFileSync(join(this.skillsDir, name, \"SKILL.md\"), \"utf-8\"));\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate loadUsage(): UsageMap {\n\t\ttry {\n\t\t\treturn JSON.parse(readFileSync(this.usageFile, \"utf-8\")) as UsageMap;\n\t\t} catch {\n\t\t\treturn {};\n\t\t}\n\t}\n}\n\n/** True if a SKILL.md's YAML frontmatter declares `promoted: true` (reflection-generated). */\nexport function isPromotedFrontmatter(content: string): boolean {\n\tconst fm = content.match(/^---\\n([\\s\\S]*?)\\n---/);\n\tif (!fm) return false;\n\treturn /^\\s*promoted\\s*:\\s*true\\s*$/im.test(fm[1]);\n}\n"]}
|