@caupulican/pi-adaptative 0.80.78 → 0.80.79
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 +14 -0
- package/dist/core/agent-session.d.ts +6 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +13 -0
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/learning/skill-curator.d.ts +7 -0
- package/dist/core/learning/skill-curator.d.ts.map +1 -1
- package/dist/core/learning/skill-curator.js +28 -0
- package/dist/core/learning/skill-curator.js.map +1 -1
- package/dist/core/settings-manager.d.ts +19 -1
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +17 -2
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/modes/interactive/components/footer.d.ts.map +1 -1
- package/dist/modes/interactive/components/footer.js +6 -0
- package/dist/modes/interactive/components/footer.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +12 -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
|
@@ -58,6 +58,13 @@ export declare class SkillCurator {
|
|
|
58
58
|
recordUse(name: string, now: number): void;
|
|
59
59
|
/** Build the proposals from the current promoted-skill corpus. */
|
|
60
60
|
proposeCuration(now: number, options?: Partial<Omit<CuratorOptions, "now">>): CurationProposals;
|
|
61
|
+
/**
|
|
62
|
+
* Auto-archive every stale promoted skill in ONE locked batch (#32 default-on). The lock serializes
|
|
63
|
+
* curation across sessions sharing this `agentDir` so a concurrent session can't archive a folder
|
|
64
|
+
* mid-rename (agy's race mitigation). Returns the names archived (for the host to announce). Never
|
|
65
|
+
* throws; on lock contention it simply does nothing this run.
|
|
66
|
+
*/
|
|
67
|
+
autoArchiveStale(now: number, options?: Partial<Omit<CuratorOptions, "now">>): Promise<string[]>;
|
|
61
68
|
/** Move a promoted skill into `.archive/` (restorable). Returns true if archived. */
|
|
62
69
|
archiveSkill(name: string): boolean;
|
|
63
70
|
/** Restore an archived skill back into the active skills dir. Returns true if restored. */
|
|
@@ -1 +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"]}
|
|
1
|
+
{"version":3,"file":"skill-curator.d.ts","sourceRoot":"","sources":["../../../src/core/learning/skill-curator.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAOH,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;;;;;OAKG;IACG,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,GAAE,OAAO,CAAC,IAAI,CAAC,cAAc,EAAE,KAAK,CAAC,CAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAezG;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 lockfile from \"proper-lockfile\";\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/**\n\t * Auto-archive every stale promoted skill in ONE locked batch (#32 default-on). The lock serializes\n\t * curation across sessions sharing this `agentDir` so a concurrent session can't archive a folder\n\t * mid-rename (agy's race mitigation). Returns the names archived (for the host to announce). Never\n\t * throws; on lock contention it simply does nothing this run.\n\t */\n\tasync autoArchiveStale(now: number, options: Partial<Omit<CuratorOptions, \"now\">> = {}): Promise<string[]> {\n\t\tif (!existsSync(this.skillsDir)) return [];\n\t\tlet release: (() => Promise<void>) | undefined;\n\t\ttry {\n\t\t\trelease = await lockfile.lock(this.skillsDir, { realpath: false, retries: 2 });\n\t\t\tconst archived: string[] = [];\n\t\t\tfor (const a of this.proposeCuration(now, options).archive) {\n\t\t\t\tif (this.archiveSkill(a.name)) archived.push(a.name);\n\t\t\t}\n\t\t\treturn archived;\n\t\t} catch {\n\t\t\treturn []; // another session holds the lock, or fs error — skip this run\n\t\t} finally {\n\t\t\tif (release) await release().catch(() => {});\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"]}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import lockfile from "proper-lockfile";
|
|
12
13
|
import { jaccard, tokenize } from "../tools/skill-audit.js";
|
|
13
14
|
export const DEFAULT_CURATOR_OPTIONS = {
|
|
14
15
|
staleDays: 30,
|
|
@@ -88,6 +89,33 @@ export class SkillCurator {
|
|
|
88
89
|
overlapThreshold: options.overlapThreshold ?? DEFAULT_CURATOR_OPTIONS.overlapThreshold,
|
|
89
90
|
});
|
|
90
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Auto-archive every stale promoted skill in ONE locked batch (#32 default-on). The lock serializes
|
|
94
|
+
* curation across sessions sharing this `agentDir` so a concurrent session can't archive a folder
|
|
95
|
+
* mid-rename (agy's race mitigation). Returns the names archived (for the host to announce). Never
|
|
96
|
+
* throws; on lock contention it simply does nothing this run.
|
|
97
|
+
*/
|
|
98
|
+
async autoArchiveStale(now, options = {}) {
|
|
99
|
+
if (!existsSync(this.skillsDir))
|
|
100
|
+
return [];
|
|
101
|
+
let release;
|
|
102
|
+
try {
|
|
103
|
+
release = await lockfile.lock(this.skillsDir, { realpath: false, retries: 2 });
|
|
104
|
+
const archived = [];
|
|
105
|
+
for (const a of this.proposeCuration(now, options).archive) {
|
|
106
|
+
if (this.archiveSkill(a.name))
|
|
107
|
+
archived.push(a.name);
|
|
108
|
+
}
|
|
109
|
+
return archived;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return []; // another session holds the lock, or fs error — skip this run
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
if (release)
|
|
116
|
+
await release().catch(() => { });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
91
119
|
/** Move a promoted skill into `.archive/` (restorable). Returns true if archived. */
|
|
92
120
|
archiveSkill(name) {
|
|
93
121
|
try {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"skill-curator.js","sourceRoot":"","sources":["../../../src/core/learning/skill-curator.ts"],"names":[],"mappings":"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","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"]}
|
|
1
|
+
{"version":3,"file":"skill-curator.js","sourceRoot":"","sources":["../../../src/core/learning/skill-curator.ts"],"names":[],"mappings":"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","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 lockfile from \"proper-lockfile\";\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/**\n\t * Auto-archive every stale promoted skill in ONE locked batch (#32 default-on). The lock serializes\n\t * curation across sessions sharing this `agentDir` so a concurrent session can't archive a folder\n\t * mid-rename (agy's race mitigation). Returns the names archived (for the host to announce). Never\n\t * throws; on lock contention it simply does nothing this run.\n\t */\n\tasync autoArchiveStale(now: number, options: Partial<Omit<CuratorOptions, \"now\">> = {}): Promise<string[]> {\n\t\tif (!existsSync(this.skillsDir)) return [];\n\t\tlet release: (() => Promise<void>) | undefined;\n\t\ttry {\n\t\t\trelease = await lockfile.lock(this.skillsDir, { realpath: false, retries: 2 });\n\t\t\tconst archived: string[] = [];\n\t\t\tfor (const a of this.proposeCuration(now, options).archive) {\n\t\t\t\tif (this.archiveSkill(a.name)) archived.push(a.name);\n\t\t\t}\n\t\t\treturn archived;\n\t\t} catch {\n\t\t\treturn []; // another session holds the lock, or fs error — skip this run\n\t\t} finally {\n\t\t\tif (release) await release().catch(() => {});\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"]}
|
|
@@ -128,6 +128,11 @@ export interface Settings {
|
|
|
128
128
|
maxTurnUsd?: number;
|
|
129
129
|
action?: "warn" | "downgrade";
|
|
130
130
|
};
|
|
131
|
+
/** Skill curator (#32): auto-archive stale reflection-promoted skills at session start. */
|
|
132
|
+
curator?: {
|
|
133
|
+
autoArchive?: boolean;
|
|
134
|
+
staleDays?: number;
|
|
135
|
+
};
|
|
131
136
|
contextGc?: ContextGcSettings;
|
|
132
137
|
branchSummary?: BranchSummarySettings;
|
|
133
138
|
retry?: RetrySettings;
|
|
@@ -344,7 +349,20 @@ export declare class SettingsManager {
|
|
|
344
349
|
getCompactionReserveTokens(): number;
|
|
345
350
|
getCompactionKeepRecentTokens(): number;
|
|
346
351
|
getCompactionTriggerPercent(): number;
|
|
347
|
-
/**
|
|
352
|
+
/**
|
|
353
|
+
* Skill curator (#32). Auto-archive of stale reflection-promoted skills is ON by default (restorable,
|
|
354
|
+
* announced, promoted-only). Set `autoArchive: false` to make it propose-only (`/curate`).
|
|
355
|
+
*/
|
|
356
|
+
getCuratorSettings(): {
|
|
357
|
+
autoArchive: boolean;
|
|
358
|
+
staleDays: number;
|
|
359
|
+
};
|
|
360
|
+
/**
|
|
361
|
+
* Proactive per-turn cost guard (#34). Default ON in WARN-only mode with a high anomaly-catching
|
|
362
|
+
* ceiling so an unusually expensive turn surfaces a visible footer notice without ever silently
|
|
363
|
+
* changing behavior. Set `maxTurnUsd: 0` to disable, or `action: "downgrade"` to also auto-reduce
|
|
364
|
+
* reasoning effort over the ceiling.
|
|
365
|
+
*/
|
|
348
366
|
getCostGuardSettings(): {
|
|
349
367
|
maxTurnUsd: number;
|
|
350
368
|
action: "warn" | "downgrade";
|