Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/agents/skills/activity-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import {
readActivityStore,
writeActivityStore,
recordSkillInvocation,
getSkillActivity,
daysSinceLastInvoked,
} from "./activity-store.js";

let tempDir: string;

afterEach(() => {
if (tempDir) {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {
// best effort
}
}
});

function tempStorePath(): string {
tempDir = mkdtempSync(join(tmpdir(), "mayros-activity-"));
return join(tempDir, "skill-activity.json");
}

describe("activity-store", () => {
it("returns empty record when file does not exist", () => {
const path = join(mkdtempSync(join(tmpdir(), "mayros-act-")), "missing.json");
expect(readActivityStore(path)).toEqual({});
});

it("writes and reads the store", () => {
const path = tempStorePath();
writeActivityStore(
{ "skill-a": { lastInvokedAt: 1000, invocationCount: 5, firstInvokedAt: 500 } },
path,
);
const store = readActivityStore(path);
expect(store["skill-a"]).toEqual({
lastInvokedAt: 1000,
invocationCount: 5,
firstInvokedAt: 500,
});
});

it("records a new invocation", () => {
const path = tempStorePath();
const result = recordSkillInvocation("skill-a", 5000, path);
expect(result.invocationCount).toBe(1);
expect(result.lastInvokedAt).toBe(5000);
expect(result.firstInvokedAt).toBe(5000);
});

it("increments count on subsequent invocations", () => {
const path = tempStorePath();
recordSkillInvocation("skill-a", 1000, path);
recordSkillInvocation("skill-a", 2000, path);
const result = recordSkillInvocation("skill-a", 3000, path);
expect(result.invocationCount).toBe(3);
expect(result.lastInvokedAt).toBe(3000);
expect(result.firstInvokedAt).toBe(1000);
});

it("returns null for never-invoked skills", () => {
const path = tempStorePath();
expect(getSkillActivity("never", path)).toBeNull();
});

it("calculates days since last invocation", () => {
const path = tempStorePath();
recordSkillInvocation("skill-a", 1000, path);
const now = 1000 + 5 * 24 * 60 * 60 * 1000; // 5 days later
expect(daysSinceLastInvoked("skill-a", now, path)).toBe(5);
});

it("returns Infinity for never-invoked skills", () => {
const path = tempStorePath();
expect(daysSinceLastInvoked("never", Date.now(), path)).toBe(Infinity);
});
});
86 changes: 86 additions & 0 deletions src/agents/skills/activity-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Skill activity tracking.
*
* A lightweight JSON sidecar at ~/.mayros/skills/skill-activity.json that
* records when each skill was last invoked and how many times. The curator
* uses this data to transition skills through lifecycle states (active →
* stale → archived).
*/

import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { resolveStateDir } from "../../config/paths.js";

export type SkillActivity = {
lastInvokedAt: number;
invocationCount: number;
firstInvokedAt: number;
};

export type SkillActivityStore = Record<string, SkillActivity>;

/** Resolve the activity store path (~/.mayros/skills/skill-activity.json). */
export function resolveActivityStorePath(): string {
return join(resolveStateDir(), "skills", "skill-activity.json");
}

/** Read the activity store, returning an empty record if it does not exist. */
export function readActivityStore(path?: string): SkillActivityStore {
const filePath = path ?? resolveActivityStorePath();
try {
if (!existsSync(filePath)) return {};
const content = readFileSync(filePath, "utf8");
return JSON.parse(content) as SkillActivityStore;
} catch {
return {};
}
}

/** Write the activity store, creating the directory if needed. */
export function writeActivityStore(store: SkillActivityStore, path?: string): void {
const filePath = path ?? resolveActivityStorePath();
mkdirSync(join(filePath, ".."), { recursive: true });
writeFileSync(filePath, JSON.stringify(store, null, 2) + "\n", "utf8");
}

/**
* Record a skill invocation: bump the count and update timestamps.
* Returns the updated entry.
*/
export function recordSkillInvocation(
skillKey: string,
now: number = Date.now(),
path?: string,
): SkillActivity {
const store = readActivityStore(path);
const existing = store[skillKey];
const updated: SkillActivity = {
lastInvokedAt: now,
invocationCount: (existing?.invocationCount ?? 0) + 1,
firstInvokedAt: existing?.firstInvokedAt ?? now,
};
store[skillKey] = updated;
writeActivityStore(store, path);
return updated;
}

/** Get activity for a specific skill, or null if never invoked. */
export function getSkillActivity(skillKey: string, path?: string): SkillActivity | null {
const store = readActivityStore(path);
return store[skillKey] ?? null;
}

/**
* Determine the days since last invocation for a skill.
* Returns Infinity if the skill was never invoked.
*/
export function daysSinceLastInvoked(
skillKey: string,
now: number = Date.now(),
path?: string,
): number {
const activity = getSkillActivity(skillKey, path);
if (!activity) return Infinity;
return Math.floor((now - activity.lastInvokedAt) / (1000 * 60 * 60 * 24));
}
214 changes: 214 additions & 0 deletions src/agents/skills/skill-curator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/**
* Skill curator — automatic lifecycle management.
*
* Runs as a background sweep (piggy-backed on the cron timer, mirroring
* session-reaper.ts) to transition skills through lifecycle states based
* on activity data:
*
* active → stale when not invoked for staleAfterDays (default 30)
* stale → archived when not invoked for archiveAfterDays (default 90)
*
* Pinned skills are never transitioned. Only skills with source
* "mayros-managed" (agent-created) are eligible for archival; user skills
* can go stale but are never archived automatically.
*/

import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
import { join } from "node:path";
import { resolveStateDir } from "../../config/paths.js";
import {
daysSinceLastInvoked,
readActivityStore,
type SkillActivityStore,
} from "./activity-store.js";

export type CuratorConfig = {
enabled: boolean;
staleAfterDays: number;
archiveAfterDays: number;
};

export const DEFAULT_CURATOR_CONFIG: CuratorConfig = {
enabled: true,
staleAfterDays: 30,
archiveAfterDays: 90,
};

export type SkillSummary = {
skillKey: string;
name: string;
source: string;
state: string;
pinned: boolean;
filePath: string;
daysSinceInvoked: number;
};

export type CuratorSweepResult = {
scanned: number;
stale: number;
archived: number;
skipped: number;
};

const MIN_SWEEP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
let lastSweepAt = 0;

/**
* Discover skill files in the managed skills directory (~/.mayros/skills/).
* Returns a list of skill summaries with metadata parsed from frontmatter.
*/
export function discoverManagedSkills(skillsDir?: string): SkillSummary[] {
const dir = skillsDir ?? join(resolveStateDir(), "skills");
if (!existsSync(dir)) return [];

const { readdirSync } = require("node:fs") as typeof import("node:fs");
const skills: SkillSummary[] = [];

for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const skillFile = join(dir, entry.name, "SKILL.md");
if (!existsSync(skillFile)) continue;

try {
const content = readFileSync(skillFile, "utf8");
const meta = parseSkillFrontmatter(content);
skills.push({
skillKey: meta.skillKey ?? entry.name,
name: meta.name ?? entry.name,
source: meta.source ?? "mayros-managed",
state: meta.state ?? "active",
pinned: meta.pinned ?? false,
filePath: skillFile,
daysSinceInvoked: daysSinceLastInvoked(meta.skillKey ?? entry.name),
});
} catch {
// Skip unreadable skills.
}
}

return skills;
}

/**
* Run a lifecycle sweep: transition stale and archived skills based on
* activity data. Self-throttled to avoid running more than once per
* MIN_SWEEP_INTERVAL_MS.
*/
export function sweepSkillLifecycle(
config: CuratorConfig = DEFAULT_CURATOR_CONFIG,
opts?: { skillsDir?: string; now?: number; activityPath?: string },
): CuratorSweepResult {
const now = opts?.now ?? Date.now();
if (!config.enabled) return { scanned: 0, stale: 0, archived: 0, skipped: 0 };
if (now - lastSweepAt < MIN_SWEEP_INTERVAL_MS) {
return { scanned: 0, stale: 0, archived: 0, skipped: 0 };
}
lastSweepAt = now;

const skills = discoverManagedSkills(opts?.skillsDir);
let stale = 0;
let archived = 0;
let skipped = 0;

for (const skill of skills) {
// Pinned skills never transition.
if (skill.pinned) {
skipped++;
continue;
}

if (skill.state === "archived") {
skipped++;
continue;
}

const days = skill.daysSinceInvoked;

// active → stale
if (skill.state === "active" && days > config.staleAfterDays) {
updateSkillState(skill.filePath, "stale");
stale++;
continue;
}

// stale → archived (only for agent-created skills)
if (
skill.state === "stale" &&
days > config.archiveAfterDays &&
skill.source === "mayros-managed"
) {
archiveSkill(skill.filePath, skill.name);
archived++;
continue;
}
}

return { scanned: skills.length, stale, archived, skipped };
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

type ParsedFrontmatter = {
name?: string;
skillKey?: string;
source?: string;
state?: string;
pinned?: boolean;
};

function parseSkillFrontmatter(content: string): ParsedFrontmatter {
const match = content.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const yaml = match[1];
const result: ParsedFrontmatter = {};
for (const line of yaml.split("\n")) {
const m = line.match(/^(\w[\w-]*):\s*(.*)$/);
if (!m) continue;
const [, key, value] = m;
if (key === "name") result.name = value;
else if (key === "skillKey") result.skillKey = value;
else if (key === "source") result.source = value;
else if (key === "state") result.state = value;
else if (key === "pinned") result.pinned = value === "true";
}
return result;
}

function updateSkillState(filePath: string, newState: string): void {
try {
const content = readFileSync(filePath, "utf8");
// Update or add the state field in the frontmatter.
if (/^state:\s*/m.test(content)) {
const updated = content.replace(/^state:\s*.*$/m, `state: ${newState}`);
writeFileSync(filePath, updated, "utf8");
} else {
// Add state after the first line of frontmatter.
const updated = content.replace(/^---\n/, `---\nstate: ${newState}\n`);
writeFileSync(filePath, updated, "utf8");
}
} catch {
// Best-effort — if the file is unreadable, skip.
}
}

function archiveSkill(filePath: string, skillName: string): void {
try {
const archiveDir = join(resolveStateDir(), "skills.archived", skillName);
mkdirSync(archiveDir, { recursive: true });
const dest = join(archiveDir, "SKILL.md");
// Move the entire skill directory to the archive.
const skillDir = join(filePath, "..");
renameSync(skillDir, archiveDir);
} catch {
// Best-effort — if the move fails, just mark as archived in frontmatter.
updateSkillState(filePath, "archived");
}
}

/** Reset the sweep throttle (for testing). */
export function resetCuratorThrottle(): void {
lastSweepAt = 0;
}
Loading
Loading